Compare commits
326 Commits
f160f5a7b8
...
60.2
| Author | SHA1 | Date | |
|---|---|---|---|
| abf0eae493 | |||
| 740e5268dd | |||
| 681252b845 | |||
| f6a20b67a6 | |||
| c9298fe387 | |||
| f7d99f8b55 | |||
| 6861e28ac6 | |||
| fdc18d5a5f | |||
| 77d92f6c96 | |||
| d5d0eb6cd8 | |||
| d6f71e0761 | |||
| b00ebf49dd | |||
| 2385e9c7bd | |||
| d78529efe2 | |||
| f373e98e3d | |||
| 7559ce7824 | |||
| 2c481bd53a | |||
| 0deb3ee634 | |||
| 28a53d70eb | |||
| 98d4e8bacb | |||
| 8f97131b8d | |||
| 7c1f709f96 | |||
| 4b005e0e5b | |||
| d5dd63be0d | |||
| 5aa89b7e0a | |||
| cf444e8dd4 | |||
| a74a89c747 | |||
| 1695f7393e | |||
| 4468e68110 | |||
| da08eca4ab | |||
| 08752e30a4 | |||
| 619718c045 | |||
| cb23d5a5be | |||
| ec4c190e6c | |||
| 58126928c7 | |||
| c0d2881585 | |||
| 41a68291a4 | |||
| 0a411bedf4 | |||
| f2b67b38dc | |||
| 662ebe66b9 | |||
| 5e0dffdc7a | |||
| 2283a6c640 | |||
| 327ba94c63 | |||
| ee59cde882 | |||
| c7b4656679 | |||
| 04f9e00c9c | |||
| c2dabd1dac | |||
| 7a0cb8b9d0 | |||
| 1c2c570c4b | |||
| 0671ab432f | |||
| 893867419a | |||
| d9dfec81aa | |||
| 948fbc19bf | |||
| 7d708ad8a6 | |||
| e0505cb82c | |||
| ab6459e4b0 | |||
| c001248d1b | |||
| f1c777d4ef | |||
| 931488c288 | |||
| ab897fd848 | |||
| 4f189b4d66 | |||
| 1487504318 | |||
| 0655363a0d | |||
| dccc80c615 | |||
| 4f91d5b40b | |||
| cb01781615 | |||
| 1f5fada8b1 | |||
| 31888a85cb | |||
| 22d8b0787e | |||
| 8cc559752b | |||
| db3f9696fa | |||
| 407d570c82 | |||
| 033239260f | |||
| 9f854dc735 | |||
| f29272a853 | |||
| 4e56d9420a | |||
| 852a257159 | |||
| 68ff230f13 | |||
| 57a952a6d1 | |||
| 718fcf7392 | |||
| 52adcf83c7 | |||
| 0061623f7e | |||
| ec73e5151e | |||
| 2c90dd38b5 | |||
| c6b1e42d7a | |||
| fcf86d5b3f | |||
| 6fde9ec8d2 | |||
| 0f1882b21f | |||
| e819a2db0d | |||
| e03cf6c9b1 | |||
| 471e70ff7f | |||
| 255e335d7a | |||
| c8cf80b513 | |||
| b4180afbed | |||
| 37112babbb | |||
| fb775f2f58 | |||
| b26470c279 | |||
| d3b9ec815b | |||
| 19f2b5e801 | |||
| 9e3288a5ff | |||
| 06465919dd | |||
| 253e58eb48 | |||
| 5393996e47 | |||
| 1624f01e11 | |||
| 535dead7e8 | |||
| 3b97d49227 | |||
| ea7b0946bb | |||
| b8384166de | |||
| d2705758c6 | |||
| f4368c31f3 | |||
| 57f273b0cc | |||
| ac82292200 | |||
| 6a8432c08f | |||
| 5a2c41155c | |||
| 83a046111b | |||
| ab10758f40 | |||
| 88f16f0aaa | |||
| c1744fab37 | |||
| 042a3eb737 | |||
| 01d25e1b55 | |||
| c0be131e3d | |||
| 7d3f615ed7 | |||
| c2138b3ac6 | |||
| 947713d44a | |||
| 12b76837a3 | |||
| 102494ede7 | |||
| 96bda8d4ad | |||
| 46956d06d8 | |||
| 8a28d0675b | |||
| 5f6e75b14e | |||
| a96a42cdbf | |||
| c7f5d7d384 | |||
| d5830f5cd1 | |||
| c71b51fdb8 | |||
| 935d059a20 | |||
| 25776eb495 | |||
| 5ac4625af9 | |||
| a731427f6e | |||
| 410da163fe | |||
| a171192a6f | |||
| c16b61db40 | |||
| 29cb6a4991 | |||
| 25c28e8335 | |||
| 25626be3b6 | |||
| 0a880a2f2f | |||
| 248d3f2d3e | |||
| e243fec679 | |||
| de9b4ee9c1 | |||
| bf9a6a9679 | |||
| 709fed5cfe | |||
| b7df6299d0 | |||
| be16d513ef | |||
| 15d27f6d94 | |||
| c8292d1c06 | |||
| 68f821fce1 | |||
| ed2ed59f65 | |||
| 17a7bb52fa | |||
| bbac142b40 | |||
| 5f55ec557f | |||
| 7f3076608f | |||
| 568772a0e6 | |||
| 91c3376256 | |||
| 58639c6fc1 | |||
| 228441ddc5 | |||
| 6341075f07 | |||
| a135b9f5f2 | |||
| 9088412d1e | |||
| c7339fbe31 | |||
| 4ce3dc03c5 | |||
| 5a4ef678a8 | |||
| 5ca22efeaa | |||
| 912ea8bfac | |||
| b541e1084d | |||
| c9b9da4abc | |||
| 8236f43026 | |||
| ea1b43d1b8 | |||
| 4bf22c96e9 | |||
| dec7a79509 | |||
| 371e1d654c | |||
| bef7e683c5 | |||
| ec219ef3ea | |||
| dcc7229e90 | |||
| 73665ef19e | |||
| 2536e330af | |||
| 99c056adeb | |||
| 7a504e45de | |||
| 7618d0ba30 | |||
| ce4dc40033 | |||
| b0b22b79dc | |||
| 6471413681 | |||
| 50b10689fc | |||
| 85bddb6cba | |||
| c285b0d3b3 | |||
| 671fe8d86f | |||
| 89817110de | |||
| ee01e3d8df | |||
| a70343d6f3 | |||
| 3e72042c24 | |||
| 087c7775ae | |||
| 3f71065ad6 | |||
| 801672124f | |||
| 811e9c1ce9 | |||
| 415b32bdc7 | |||
| 22319c807a | |||
| f9ba6fec14 | |||
| 5f55163147 | |||
| a6ef34623e | |||
| 7cb48d20f6 | |||
| 445103a878 | |||
| 579da8c44e | |||
| daabd2f37f | |||
| 039c58cf89 | |||
| 410c033f12 | |||
| ce302e4d45 | |||
| 19589c9463 | |||
| 3d9506b14e | |||
| 23b87278b2 | |||
| 0b8e027c30 | |||
| 1bd9f0d942 | |||
| fa7890cb21 | |||
| 957c32e3a7 | |||
| 8d069df9d1 | |||
| 96d1d7ac6b | |||
| 009b2ba243 | |||
| 4f051ae250 | |||
| 7e9fbb1bf6 | |||
| ec1a54f623 | |||
| b502667ca6 | |||
| 263874288a | |||
| bca90c97ae | |||
| 12f49a6cee | |||
| 034c7cb413 | |||
| e737870733 | |||
| 95757650f6 | |||
| 1928acf8a6 | |||
| 22956c7c7f | |||
| 17aed1191d | |||
| 0b1fb8667c | |||
| 13dd5b67d0 | |||
| 20c7874466 | |||
| 62d8ffd794 | |||
| bea2b2d187 | |||
| 034cb99c77 | |||
| 37187f33dd | |||
| d7a23c3832 | |||
| 6d45571e75 | |||
| 88fd0ed7f8 | |||
| 2100cedc1a | |||
| 2b17a92c6c | |||
| 72fd1ab90e | |||
| 301440909b | |||
| 389641002d | |||
| 43d514cf5b | |||
| 25baeca2b0 | |||
| b4e15c73c1 | |||
| 90f3d38687 | |||
| 8afb227267 | |||
| 425abebc9a | |||
| afb61f6622 | |||
| 65815fc198 | |||
| 75479d91a3 | |||
| 99789e5477 | |||
| fd95f1e686 | |||
| e133c4149b | |||
| 7e75828012 | |||
| 664148e702 | |||
| 768819b664 | |||
| 760d453165 | |||
| 2fac5815b1 | |||
| f4b30ade70 | |||
| 1cbb84c29f | |||
| b622b151d4 | |||
| 4e1c3ffbf0 | |||
| 8a419c7bbc | |||
| 0639033aa9 | |||
| 6927729284 | |||
| 766f9db17c | |||
| 25cbd88071 | |||
| 972568bebc | |||
| 939c89d368 | |||
| 83e7061b92 | |||
| a171517f92 | |||
| a4030e89ec | |||
| dce31ed840 | |||
| 645e81299b | |||
| 72fa41977e | |||
| 56745b33f4 | |||
| dd2f44e72f | |||
| 41890d14d9 | |||
| c5f1ee2d64 | |||
| 26176ccd73 | |||
| 9b7fa0d4f8 | |||
| aeb460d677 | |||
| 4e059683b0 | |||
| d3146433f2 | |||
| 7b487f8494 | |||
| f7675b8a02 | |||
| d036dbe4fd | |||
| 86c495f22f | |||
| b9324d6443 | |||
| c11858810c | |||
| 9bafe45951 | |||
| 4d8e925f8c | |||
| e7fff25543 | |||
| 62c200ab05 | |||
| 7c33095d87 | |||
| cb50de13c0 | |||
| 08152de086 | |||
| 0d6b2c4afc | |||
| 9d3f7f434f | |||
| 2b88f89794 | |||
| 3014a30616 | |||
| c7850878fe | |||
| db5b673cd5 | |||
| 217e2443e2 | |||
| af8b1d4f8a | |||
| d69bc6c235 | |||
| cc0f7db453 | |||
| 11bde2a306 | |||
| cd4f6ff71d | |||
| bf7e5677e6 | |||
| 2b04a17d77 | |||
| 97f35f62b8 | |||
| dfd51c1343 | |||
| e47328e572 | |||
| e85d6ec779 |
@ -1,4 +1,4 @@
|
||||
name: build & deploy
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
@ -6,6 +6,10 @@ on:
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@ -22,7 +26,6 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@ -47,7 +50,6 @@ jobs:
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
# Notifications (success/failure) for the test job
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
@ -71,9 +73,8 @@ jobs:
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
build-and-deploy:
|
||||
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
|
||||
# if: startsWith(gitea.ref, 'refs/tags/')
|
||||
needs: [test]
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@ -97,12 +98,19 @@ jobs:
|
||||
|
||||
- name: Build package with commit info
|
||||
run: |
|
||||
# Write commit to _commit.py before build
|
||||
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
|
||||
poetry build
|
||||
# Restore original _commit.py
|
||||
git checkout vrobbler/_commit.py
|
||||
|
||||
- name: Clean old wheels from server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
script: |
|
||||
rm -f /var/lib/vrobbler/dist/*.whl
|
||||
|
||||
- name: Copy wheel to server and deploy
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
@ -120,13 +128,16 @@ jobs:
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
command_timeout: 2m
|
||||
script: |
|
||||
set -e
|
||||
mkdir -p /var/lib/vrobbler
|
||||
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
||||
pip uninstall -y vrobbler
|
||||
pip install /var/lib/vrobbler/dist/*.whl
|
||||
rm -f /var/lib/vrobbler/dist/*.whl
|
||||
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
||||
vrobbler migrate
|
||||
vrobbler collectstatic --noinput
|
||||
immortalctl restart celery && immortalctl restart vrobbler
|
||||
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||
|
||||
- name: Notify deploy success (ntfy)
|
||||
if: success()
|
||||
10
AGENTS.md
10
AGENTS.md
@ -14,3 +14,13 @@ ro class method should call the utility function.
|
||||
|
||||
Be sure to check pyproject.toml for project defaults. Specifically for black and
|
||||
isort expectations.
|
||||
|
||||
Imports in python files should always be top level if possible.
|
||||
|
||||
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
|
||||
|
||||
In local development, environment variables for various sensitive values live in a .envrc file
|
||||
|
||||
The .envrc file can be loaded into a shell environment to allow access to most third party services
|
||||
|
||||
Care should be taken when using .envrc that we do not spam services we use in production with requests
|
||||
|
||||
1986
PROJECT.org
1986
PROJECT.org
File diff suppressed because it is too large
Load Diff
2
Procfile
2
Procfile
@ -1,2 +1,2 @@
|
||||
web: python manage.py runserver 0.0.0.0:8014
|
||||
worker: celery -A vrobbler worker -l DEBUG
|
||||
worker: celery -A vrobbler worker -Q default,charts -l DEBUG
|
||||
|
||||
20
README.md
20
README.md
@ -21,3 +21,23 @@ VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
||||
VROBBLER_DATABASE_URL="postgres://vrobbler:<pass>@db.service:5432/vrobbler"
|
||||
VROBBLER_REDIS_URL="redis://:<pass>@cache.service:6379/0"
|
||||
```
|
||||
|
||||
## Database Backup
|
||||
|
||||
A backup command is available via `./manage.py backup_database` (also runs on a cron schedule via Celery). It dumps the database with `pg_dump`, compresses with gzip, and optionally copies the backup to a remote host via SCP.
|
||||
|
||||
Configure these additional settings as needed:
|
||||
|
||||
```
|
||||
VROBBLER_DB_BACKUP_SSH_KEY="/path/to/ssh/private/key"
|
||||
VROBBLER_DB_BACKUP_SSH_DEST="user@backup.example.com:/remote/path/"
|
||||
VROBBLER_DB_BACKUP_NTFY_URL="https://ntfy.sh/your-topic"
|
||||
```
|
||||
|
||||
- `VROBBLER_DB_BACKUP_SSH_KEY` — Path to the SSH private key used for remote copy.
|
||||
- `VROBBLER_DB_BACKUP_SSH_DEST` — SCP destination (user@host:path). If set, the backup is copied to the remote host and old backups are pruned.
|
||||
- `VROBBLER_DB_BACKUP_LOCAL_DIR` — Local directory for backup storage. Defaults to `/var/backups/`. Backups are stored in a `vrobbler/` subdirectory.
|
||||
- `VROBBLER_DB_BACKUP_NTFY_URL` — ntfy.sh URL for success notifications. Defaults to `https://ntfy.unbl.ink/backups`.
|
||||
|
||||
Retention is hardcoded: keeps daily backups for 7 days, plus one per month for 12 months.
|
||||
```
|
||||
|
||||
96
data/play-example.json
Normal file
96
data/play-example.json
Normal file
@ -0,0 +1,96 @@
|
||||
{
|
||||
"about": "This is a Play file that can be read by Board Game Stats. If you see this text, try to use a share, export or open-in function to open it with Board Game Stats.",
|
||||
"players": [
|
||||
{
|
||||
"uuid": "31f8b92e-11d8-4162-88b1-fd9c79eea249",
|
||||
"id": 2,
|
||||
"name": "Colin Powell",
|
||||
"isAnonymous": false,
|
||||
"modificationDate": "2025-10-18 08:32:40",
|
||||
"metaData": "{\"isNpc\":0,\"playerAvatar\":{\"image\":\"AnnikaHeller_Capybara.webp\",\"shape\":[1,1,1,1],\"color\":[0.6,0.20000000298023224,0.9803921580314636]}}"
|
||||
},
|
||||
{
|
||||
"uuid": "dd2d1881-84ab-474c-a6b2-3045d034dc40",
|
||||
"id": 3,
|
||||
"name": "Silas Sewell",
|
||||
"isAnonymous": false,
|
||||
"modificationDate": "2026-01-18 12:27:12",
|
||||
"metaData": "{\"isNpc\":0,\"playerAvatar\":{\"image\":\"RoryMuldoon_07.webp\",\"shape\":[1,1,1,1],\"color\":[0,0,0.1835034190722739]}}"
|
||||
}
|
||||
],
|
||||
"locations": [
|
||||
{
|
||||
"uuid": "14f7389c-767f-4725-9b35-906c407b293c",
|
||||
"id": 3,
|
||||
"name": "Timberwyck Farm",
|
||||
"modificationDate": "2025-07-01 18:03:38"
|
||||
}
|
||||
],
|
||||
"games": [
|
||||
{
|
||||
"uuid": "9e431cdd-b325-4061-a875-d415d46342c0",
|
||||
"id": 1046,
|
||||
"name": "Sweet Takes",
|
||||
"modificationDate": "2026-04-11 16:25:35",
|
||||
"cooperative": false,
|
||||
"highestWins": true,
|
||||
"noPoints": false,
|
||||
"usesTeams": false,
|
||||
"urlThumb": "https://cf.geekdo-images.com/l4HILZn5iLbceQeDph4G5A__small/img/gmGqlmwe9fniqYpniGwhg5RUgVQ=/fit-in/200x150/filters:strip_icc()/pic8784202.jpg",
|
||||
"urlImage": "https://cf.geekdo-images.com/l4HILZn5iLbceQeDph4G5A__original/img/b4IU8WIEWRpacbXp0FHG9HfFRpw=/0x0/filters:format(jpeg)/pic8784202.jpg",
|
||||
"bggName": "Sweet Takes",
|
||||
"bggYear": 2023,
|
||||
"bggId": 407581,
|
||||
"designers": "Hisashi Hayashi",
|
||||
"isBaseGame": 1,
|
||||
"isExpansion": 0,
|
||||
"rating": 67,
|
||||
"minPlayerCount": 2,
|
||||
"maxPlayerCount": 5,
|
||||
"minPlayTime": 15,
|
||||
"maxPlayTime": 15,
|
||||
"minAge": 8
|
||||
}
|
||||
],
|
||||
"plays": [
|
||||
{
|
||||
"uuid": "7b2fd583-e8f2-40fe-9565-90178390b87e",
|
||||
"modificationDate": "2026-04-16 20:18:03",
|
||||
"entryDate": "2026-04-16 20:13:33",
|
||||
"playDate": "2026-04-16 20:13:33",
|
||||
"usesTeams": false,
|
||||
"durationMin": 4,
|
||||
"ignored": false,
|
||||
"manualWinner": false,
|
||||
"rounds": 0,
|
||||
"locationRefId": 3,
|
||||
"gameRefId": 1046,
|
||||
"board": "",
|
||||
"scoringSetting": 1,
|
||||
"metaData": "{\"playerRefId\":2,\"playGameBggVersion\":\"{\\\"versionId\\\":0,\\\"versionName\\\":\\\"\\\",\\\"imageUrl\\\":\\\"https:\\\\\\/\\\\\\/cf.geekdo-images.com\\\\\\/l4HILZn5iLbceQeDph4G5A__small\\\\\\/img\\\\\\/gmGqlmwe9fniqYpniGwhg5RUgVQ=\\\\\\/fit-in\\\\\\/200x150\\\\\\/filters:strip_icc()\\\\\\/pic8784202.jpg\\\",\\\"thumbUrl\\\":\\\"https:\\\\\\/\\\\\\/cf.geekdo-images.com\\\\\\/l4HILZn5iLbceQeDph4G5A__small\\\\\\/img\\\\\\/gmGqlmwe9fniqYpniGwhg5RUgVQ=\\\\\\/fit-in\\\\\\/200x150\\\\\\/filters:strip_icc()\\\\\\/pic8784202.jpg\\\",\\\"yearPublished\\\":0}\",\"playUsedGameCopy\":2}",
|
||||
"playerScores": [
|
||||
{
|
||||
"score": "27",
|
||||
"winner": false,
|
||||
"newPlayer": false,
|
||||
"startPlayer": false,
|
||||
"playerRefId": 2,
|
||||
"role": "",
|
||||
"rank": 0,
|
||||
"seatOrder": 0
|
||||
},
|
||||
{
|
||||
"score": "36",
|
||||
"winner": true,
|
||||
"newPlayer": true,
|
||||
"startPlayer": false,
|
||||
"playerRefId": 3,
|
||||
"rank": 0,
|
||||
"seatOrder": 0
|
||||
}
|
||||
],
|
||||
"expansionPlays": []
|
||||
}
|
||||
],
|
||||
"userInfo": { "meRefId": 2 }
|
||||
}
|
||||
BIN
data/sample-trail.fit
Normal file
BIN
data/sample-trail.fit
Normal file
Binary file not shown.
3360
data/sample_trail.gpx
Normal file
3360
data/sample_trail.gpx
Normal file
File diff suppressed because one or more lines are too long
2
data/scale-example.csv
Normal file
2
data/scale-example.csv
Normal file
@ -0,0 +1,2 @@
|
||||
DATE,TIME,BICEPS,BMI,BMR,BODY_FAT,BONE,CALIPER,CALIPER_1,CALIPER_2,CALIPER_3,CALORIES,CHEST,COMMENT,HEART_RATE,HIPS,LBM,MUSCLE,NECK,TDEE,THIGH,VISCERAL_FAT,WAIST,WATER,WEIGHT,WHR,WHTR
|
||||
2026-05-20,11:56:58.076,,31.09,1706.74,29.084072,3.438837,,,,,,,,,,,33.07067,,2645.46,,,,54.445187,192.68378,,
|
||||
|
BIN
data/statistics.sqlite3
Normal file
BIN
data/statistics.sqlite3
Normal file
Binary file not shown.
9
justfile
9
justfile
@ -14,3 +14,12 @@ celery:
|
||||
|
||||
celery-beat:
|
||||
poetry run celery -A vrobbler beat -l info
|
||||
|
||||
push:
|
||||
git push && git push gitea
|
||||
git push --tags && git push --tags gitea
|
||||
|
||||
release kind="minor":
|
||||
poetry run python scripts/release.py {{kind}}
|
||||
just push
|
||||
|
||||
|
||||
1130
poetry.lock
generated
1130
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,16 +1,17 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.16.1"
|
||||
version = "60.2"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.11,<3.14"
|
||||
python = ">=3.11,<3.15"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
python-dotenv = "^0.20.0"
|
||||
python-dotenv = ">=0.20.0,<2"
|
||||
python-json-logger = "^2.0.2"
|
||||
cloudscraper = "^1.2.71"
|
||||
colorlog = "^6.6.0"
|
||||
httpx = "<=0.27.2"
|
||||
djangorestframework = "^3.13.1"
|
||||
@ -43,6 +44,7 @@ ipython = "^8.14.0"
|
||||
pendulum = "^3"
|
||||
trafilatura = "^1.6.3"
|
||||
django-imagekit = "^5.0.0"
|
||||
django-mcp-server = "^0.5.7"
|
||||
thefuzz = "^0.22.1"
|
||||
dataclass-wizard = "^0.35.0"
|
||||
webdavclient3 = "^3.14.6"
|
||||
@ -59,6 +61,13 @@ feedparser = "^6.0.12"
|
||||
titlecase = "^2.4.1"
|
||||
bgg-api = "^1.1.13"
|
||||
recipe-scrapers = "^15.11.0"
|
||||
gpxpy = "^1.6.2"
|
||||
fitparse = "^1.2.0"
|
||||
lxml = ">=5.5.0"
|
||||
vaderSentiment = "^3.3.2"
|
||||
sqids = "^0.5.2"
|
||||
python-amazon-paapi = "^6.3.0"
|
||||
yake = "^0.7.3"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
@ -104,6 +113,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
|
||||
[tool.poetry.scripts]
|
||||
vrobbler = "vrobbler.cli:main"
|
||||
|
||||
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
217
scripts/release.py
Executable file
217
scripts/release.py
Executable file
@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cut a new release: collect DONE items from Backlog into a new Version section.
|
||||
|
||||
Usage:
|
||||
poetry run python scripts/release.py major
|
||||
poetry run python scripts/release.py minor
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_FILE = Path("PROJECT.org")
|
||||
PYPROJECT_FILE = Path("pyproject.toml")
|
||||
|
||||
BACKLOG_RE = re.compile(r"^\* Backlog\s+\[(\d+)/(\d+)\](.*)$")
|
||||
VERSION_RE = re.compile(r"^\* Version\s+(\d+\.\d+)\s+\[\d+/\d+\]")
|
||||
DONE_HEADER_RE = re.compile(r"^(\*\* DONE\s+)(.*)$")
|
||||
ITEM_HEADER_RE = re.compile(r"^\*\* ")
|
||||
|
||||
|
||||
def parse_done_line(line):
|
||||
"""Extract a clean title from a ** DONE line, stripping priority and tags."""
|
||||
rest = line[8:].strip() # remove "** DONE "
|
||||
# strip priority marker like [#A]
|
||||
rest = re.sub(r"^\[#[A-C]\]\s+", "", rest, count=1)
|
||||
# strip org-mode tags at end (space-colon-tags)
|
||||
rest = re.sub(r"\s+:\S.*:\s*$", "", rest)
|
||||
return rest
|
||||
|
||||
|
||||
def bump_version(current_major, current_minor, kind):
|
||||
if kind == "major":
|
||||
return current_major + 1, 0
|
||||
elif kind == "minor":
|
||||
return current_major, current_minor + 1
|
||||
else:
|
||||
raise ValueError(f"Unknown bump kind: {kind}")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("major", "minor"):
|
||||
print(f"Usage: {sys.argv[0]} <major|minor>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
kind = sys.argv[1]
|
||||
|
||||
lines = PROJECT_FILE.read_text().splitlines(keepends=True)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Identify top-level sections
|
||||
# ---------------------------------------------------------------
|
||||
section_starts = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("* ") and not line.startswith("** "):
|
||||
section_starts.append(i)
|
||||
section_starts.append(len(lines))
|
||||
|
||||
backlog_idx = None
|
||||
version_idx = None
|
||||
|
||||
for idx, start in enumerate(section_starts[:-1]):
|
||||
header = lines[start].strip()
|
||||
if header.startswith("* Backlog"):
|
||||
backlog_idx = idx
|
||||
if header.startswith("* Version"):
|
||||
version_idx = idx # last occurrence wins
|
||||
|
||||
if backlog_idx is None:
|
||||
print("ERROR: no Backlog section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if version_idx is None:
|
||||
print("ERROR: no Version section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
backlog_start = section_starts[backlog_idx]
|
||||
backlog_end = section_starts[backlog_idx + 1]
|
||||
|
||||
# Find the newest Version section (first after Backlog) that matches
|
||||
# our expected format (e.g. "37.0" not "0.11.4").
|
||||
version_start = None
|
||||
for idx in range(backlog_idx + 1, version_idx + 1):
|
||||
header = lines[section_starts[idx]].strip()
|
||||
if VERSION_RE.match(header):
|
||||
version_start = section_starts[idx]
|
||||
break
|
||||
|
||||
if version_start is None:
|
||||
print("ERROR: no parseable Version header found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
version_header = lines[version_start].strip()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Parse current version from the newest * Version header
|
||||
# ---------------------------------------------------------------
|
||||
vm = VERSION_RE.match(version_header)
|
||||
current_version = vm.group(1)
|
||||
major_str, minor_str = current_version.split(".")
|
||||
current_major = int(major_str)
|
||||
current_minor = int(minor_str)
|
||||
new_major, new_minor = bump_version(current_major, current_minor, kind)
|
||||
new_version = f"{new_major}.{new_minor}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Collect ** DONE items from the Backlog section
|
||||
# ---------------------------------------------------------------
|
||||
backlog_lines = lines[backlog_start:backlog_end]
|
||||
|
||||
# Split Backlog into items at each ** line (skip the section header)
|
||||
items = [] # list of (start_idx, end_idx, is_done)
|
||||
item_start = None
|
||||
for i in range(1, len(backlog_lines)):
|
||||
if ITEM_HEADER_RE.match(backlog_lines[i]):
|
||||
if item_start is not None:
|
||||
items.append((item_start, i, backlog_lines[item_start].startswith("** DONE")))
|
||||
item_start = i
|
||||
if item_start is not None:
|
||||
items.append((item_start, len(backlog_lines), backlog_lines[item_start].startswith("** DONE")))
|
||||
|
||||
done_items = [(s, e) for s, e, is_done in items if is_done]
|
||||
kept_items = [(s, e) for s, e, is_done in items if not is_done]
|
||||
|
||||
if not done_items:
|
||||
print("No DONE items found in Backlog — nothing to release.")
|
||||
sys.exit(1)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Build the new Version section text
|
||||
# ---------------------------------------------------------------
|
||||
version_section_lines = [f"* Version {new_version} [{len(done_items)}/{len(done_items)}]\n"]
|
||||
for s, e in done_items:
|
||||
version_section_lines.extend(backlog_lines[s:e])
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 5. Build updated Backlog section
|
||||
# ---------------------------------------------------------------
|
||||
backlog_header_line = backlog_lines[0]
|
||||
bm = BACKLOG_RE.match(backlog_header_line.strip())
|
||||
if not bm:
|
||||
print(f"ERROR: could not parse backlog header: {backlog_header_line!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
done_count = int(bm.group(1))
|
||||
total_count = int(bm.group(2))
|
||||
tags = bm.group(3)
|
||||
|
||||
new_done = done_count - len(done_items)
|
||||
new_total = total_count - len(done_items)
|
||||
new_backlog_header = f"* Backlog [{new_done}/{new_total}]{tags}\n"
|
||||
|
||||
backlog_body = []
|
||||
for s, e in kept_items:
|
||||
backlog_body.extend(backlog_lines[s:e])
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 6. Assemble the new file
|
||||
# ---------------------------------------------------------------
|
||||
before_backlog = lines[:backlog_start]
|
||||
after_backlog = lines[backlog_end:version_start]
|
||||
|
||||
# Everything from the first Version section onwards
|
||||
from_version = lines[version_start:]
|
||||
|
||||
output = (
|
||||
before_backlog
|
||||
+ [new_backlog_header]
|
||||
+ backlog_body
|
||||
+ version_section_lines
|
||||
+ ["\n"]
|
||||
+ after_backlog
|
||||
+ from_version
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 7. Update pyproject.toml
|
||||
# ---------------------------------------------------------------
|
||||
pyproject = PYPROJECT_FILE.read_text()
|
||||
pyproject = re.sub(
|
||||
r'^version = "[\d.]+"',
|
||||
f'version = "{new_version}"',
|
||||
pyproject,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 8. Write files
|
||||
# ---------------------------------------------------------------
|
||||
PROJECT_FILE.write_text("".join(output))
|
||||
PYPROJECT_FILE.write_text(pyproject)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 9. Build commit body from done item titles
|
||||
# ---------------------------------------------------------------
|
||||
commit_lines = []
|
||||
for s, e in done_items:
|
||||
title = parse_done_line(backlog_lines[s])
|
||||
if title:
|
||||
commit_lines.append(f"- {title}")
|
||||
|
||||
commit_body = "\n".join(commit_lines)
|
||||
commit_message = f"[release] Bump to version {new_version}\n\n{commit_body}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 10. Git commit + tag
|
||||
# ---------------------------------------------------------------
|
||||
subprocess.run(["git", "add", str(PROJECT_FILE), str(PYPROJECT_FILE)], check=True)
|
||||
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
||||
subprocess.run(["git", "tag", new_version], check=True)
|
||||
|
||||
print(f"\nReleased v{new_version} — tag {new_version} created.")
|
||||
print(f"Moved {len(done_items)} DONE item(s) from Backlog to Version section.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,3 +1,6 @@
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from birds.importer import (
|
||||
import_birding_csv,
|
||||
@ -111,6 +114,13 @@ class TestImportBirdingCSV:
|
||||
goose = next(b for b in birds if b["quantity"] == 6)
|
||||
assert goose is not None
|
||||
|
||||
def test_import_sets_stop_timestamp(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
|
||||
assert scrobble.stop_timestamp is not None
|
||||
expected = scrobble.timestamp + timedelta(minutes=9)
|
||||
assert scrobble.stop_timestamp == expected
|
||||
|
||||
|
||||
class TestBirdingCSVImportModel:
|
||||
def test_create_import_model(self, db, user):
|
||||
@ -119,6 +129,23 @@ class TestBirdingCSVImportModel:
|
||||
assert imp.import_type == "Birding CSV"
|
||||
assert "Birding" in str(imp)
|
||||
|
||||
def test_record_error(self, db, user):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
assert imp.error_log is None
|
||||
imp.record_error("test error")
|
||||
imp.refresh_from_db()
|
||||
assert imp.error_log is not None
|
||||
assert "test error" in imp.error_log
|
||||
|
||||
def test_record_error_appends(self, db, user):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
imp.record_error("first error")
|
||||
imp.record_error("second error")
|
||||
imp.refresh_from_db()
|
||||
assert imp.error_log.count("\n") == 1
|
||||
assert "first error" in imp.error_log
|
||||
assert "second error" in imp.error_log
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_process_via_model(self, user, birding_csv_file):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
@ -128,3 +155,35 @@ class TestBirdingCSVImportModel:
|
||||
imp.refresh_from_db()
|
||||
assert imp.process_count == 1
|
||||
assert imp.processed_finished is not None
|
||||
|
||||
def test_record_error_on_bad_csv(self, user, db):
|
||||
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||
Canada Goose,6,Test Park,Stationary,"Bad Date",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(content)
|
||||
file_path = f.name
|
||||
|
||||
errors = []
|
||||
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
|
||||
assert len(scrobbles) == 0
|
||||
assert len(errors) == 1
|
||||
assert "Could not parse date/time" in errors[0]
|
||||
|
||||
def test_record_error_on_bad_location(self, user, db):
|
||||
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||
Canada Goose,6,,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(content)
|
||||
file_path = f.name
|
||||
|
||||
errors = []
|
||||
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
|
||||
assert len(scrobbles) == 0
|
||||
assert len(errors) == 1
|
||||
assert "Skipping rows with no location" in errors[0]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from birds.models import BirdingCSVImport
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from scrobbles.models import EBirdCSVImport
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -35,8 +35,8 @@ class TestBirdingCSVImportViews:
|
||||
user = User.objects.create(email="birder@example.com")
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
imp = EBirdCSVImport.objects.create(user=user)
|
||||
response = client.get(
|
||||
reverse("birds:csv_import_detail", kwargs={"slug": imp.uuid})
|
||||
reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": imp.uuid})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
63
tests/discgolf_tests/conftest.py
Normal file
63
tests/discgolf_tests/conftest.py
Normal file
@ -0,0 +1,63 @@
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create(email="golfer@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def udisc_singles_csv_content():
|
||||
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
|
||||
Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
|
||||
Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
|
||||
Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def udisc_singles_csv_file(udisc_singles_csv_content):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||
) as f:
|
||||
f.write(udisc_singles_csv_content)
|
||||
return f.name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def udisc_teams_csv_content():
|
||||
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
|
||||
Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
|
||||
Alice+Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
|
||||
Charlie+Diana,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def udisc_teams_csv_file(udisc_teams_csv_content):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||
) as f:
|
||||
f.write(udisc_teams_csv_content)
|
||||
return f.name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def udisc_csv_no_par_content():
|
||||
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
|
||||
Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def udisc_csv_no_par_file(udisc_csv_no_par_content):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||
) as f:
|
||||
f.write(udisc_csv_no_par_content)
|
||||
return f.name
|
||||
102
tests/discgolf_tests/test_models.py
Normal file
102
tests/discgolf_tests/test_models.py
Normal file
@ -0,0 +1,102 @@
|
||||
from discgolf.models import DiscGolfCourse, DiscGolfLogData
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
|
||||
|
||||
class TestDiscGolfCourseModel:
|
||||
def test_create_course(self, db):
|
||||
course = DiscGolfCourse.objects.create(
|
||||
title="Maple Hill",
|
||||
layout_name="Mountains",
|
||||
number_of_holes=18,
|
||||
par_total=54,
|
||||
par_per_hole={"hole_1": 3, "hole_2": 3},
|
||||
)
|
||||
assert course.uuid is not None
|
||||
assert str(course) == "Maple Hill (Mountains)"
|
||||
assert course.subtitle == "Mountains"
|
||||
|
||||
def test_subtitle_fallback(self, db):
|
||||
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||
assert course.subtitle == ""
|
||||
|
||||
def test_logdata_cls(self, db):
|
||||
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||
assert course.logdata_cls is DiscGolfLogData
|
||||
assert issubclass(course.logdata_cls, BaseLogData)
|
||||
|
||||
def test_strings(self, db):
|
||||
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||
assert course.strings.verb == "Playing"
|
||||
assert course.strings.tags == "golf"
|
||||
|
||||
def test_primary_image_url(self, db):
|
||||
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||
assert course.primary_image_url == ""
|
||||
|
||||
def test_get_absolute_url(self, db):
|
||||
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||
url = course.get_absolute_url()
|
||||
assert str(course.uuid) in url
|
||||
assert url.startswith("/disc-golf/")
|
||||
|
||||
def test_find_or_create_new(self, db):
|
||||
course = DiscGolfCourse.find_or_create(
|
||||
"New Course", layout_name="Default"
|
||||
)
|
||||
assert course.title == "New Course"
|
||||
assert course.layout_name == "Default"
|
||||
|
||||
def test_find_or_create_existing(self, db):
|
||||
created = DiscGolfCourse.objects.create(
|
||||
title="Existing", layout_name="Alpha"
|
||||
)
|
||||
found = DiscGolfCourse.find_or_create("Existing", layout_name="Beta")
|
||||
assert found.id == created.id
|
||||
assert found.layout_name == "Alpha"
|
||||
|
||||
def test_scrobbles_method(self, db, user):
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||
dt1 = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)
|
||||
dt2 = datetime(2026, 6, 14, 14, 0, 0, tzinfo=pytz.UTC)
|
||||
s1 = Scrobble.objects.create(
|
||||
user=user,
|
||||
disc_golf_course=course,
|
||||
media_type=Scrobble.MediaType.DISC_GOLF,
|
||||
timestamp=dt1,
|
||||
)
|
||||
s2 = Scrobble.objects.create(
|
||||
user=user,
|
||||
disc_golf_course=course,
|
||||
media_type=Scrobble.MediaType.DISC_GOLF,
|
||||
timestamp=dt2,
|
||||
)
|
||||
qs = course.scrobbles(user.id)
|
||||
assert list(qs) == [s1, s2]
|
||||
|
||||
|
||||
class TestDiscGolfLogData:
|
||||
def test_basic_logdata(self):
|
||||
data = DiscGolfLogData()
|
||||
assert data.scores is None
|
||||
assert data.weather is None
|
||||
assert data.fun_factor is None
|
||||
assert data.course_name is None
|
||||
|
||||
def test_logdata_with_scores(self):
|
||||
data = DiscGolfLogData(
|
||||
scores={"Alice": {"person_id": 1, "total": 9}},
|
||||
weather="Sunny",
|
||||
fun_factor="High",
|
||||
course_name="Maple Hill",
|
||||
par=9,
|
||||
round_type="Singles",
|
||||
)
|
||||
assert data.scores["Alice"]["total"] == 9
|
||||
assert data.weather == "Sunny"
|
||||
assert data.round_type == "Singles"
|
||||
150
tests/discgolf_tests/test_utils.py
Normal file
150
tests/discgolf_tests/test_utils.py
Normal file
@ -0,0 +1,150 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from discgolf.models import DiscGolfCourse
|
||||
from discgolf.utils import _parse_udisc_datetime, import_udisc_csv
|
||||
from people.models import Person
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
class TestParserHelpers:
|
||||
def test_parse_udisc_datetime(self):
|
||||
dt = _parse_udisc_datetime("Jun 15, 2026 10:00 AM")
|
||||
assert dt is not None
|
||||
assert dt.year == 2026
|
||||
assert dt.month == 6
|
||||
assert dt.day == 15
|
||||
assert dt.hour == 10
|
||||
assert dt.minute == 0
|
||||
|
||||
def test_parse_udisc_datetime_date_only(self):
|
||||
dt = _parse_udisc_datetime("Jun 15, 2026")
|
||||
assert dt is not None
|
||||
assert dt.year == 2026
|
||||
|
||||
|
||||
class TestImportUdiscCSV:
|
||||
def test_import_singles_creates_course(self, user, udisc_singles_csv_file):
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
course = DiscGolfCourse.objects.filter(title="Maple Hill").first()
|
||||
assert course is not None
|
||||
assert course.layout_name == "Mountains"
|
||||
assert course.number_of_holes == 3
|
||||
assert course.par_total == 9
|
||||
assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}
|
||||
|
||||
def test_import_singles_creates_scrobble(self, user, udisc_singles_csv_file):
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
assert Scrobble.objects.filter(source="uDisc").count() == 1
|
||||
|
||||
def test_import_singles_logdata(self, user, udisc_singles_csv_file):
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="uDisc").first()
|
||||
log = scrobble.log
|
||||
assert log["course_name"] == "Maple Hill"
|
||||
assert log["par"] == 9
|
||||
assert log["round_type"] == "Singles"
|
||||
assert "Alice" in log["scores"]
|
||||
assert "Bob" in log["scores"]
|
||||
assert log["scores"]["Alice"]["total"] == 9
|
||||
assert log["scores"]["Bob"]["total"] == 12
|
||||
|
||||
def test_import_singles_creates_people(self, user, udisc_singles_csv_file):
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
assert Person.objects.filter(name="Alice").exists()
|
||||
assert Person.objects.filter(name="Bob").exists()
|
||||
|
||||
def test_import_teams_creates_scrobble(self, user, udisc_teams_csv_file):
|
||||
import_udisc_csv(udisc_teams_csv_file, user.id)
|
||||
assert Scrobble.objects.filter(source="uDisc").count() == 1
|
||||
|
||||
def test_import_teams_logdata(self, user, udisc_teams_csv_file):
|
||||
import_udisc_csv(udisc_teams_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="uDisc").first()
|
||||
assert scrobble.log["round_type"] == "Teams"
|
||||
alice_bob = scrobble.log["scores"]["Alice+Bob"]
|
||||
assert "person_ids" in alice_bob
|
||||
assert len(alice_bob["person_ids"]) == 2
|
||||
|
||||
def test_import_creates_team_people(self, user, udisc_teams_csv_file):
|
||||
import_udisc_csv(udisc_teams_csv_file, user.id)
|
||||
assert Person.objects.filter(name="Alice").exists()
|
||||
assert Person.objects.filter(name="Bob").exists()
|
||||
assert Person.objects.filter(name="Charlie").exists()
|
||||
assert Person.objects.filter(name="Diana").exists()
|
||||
|
||||
def test_import_teams_par_per_hole(self, user, udisc_teams_csv_file):
|
||||
import_udisc_csv(udisc_teams_csv_file, user.id)
|
||||
course = DiscGolfCourse.objects.get(title="Maple Hill")
|
||||
assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}
|
||||
|
||||
def test_import_no_par_returns_empty(self, user, udisc_csv_no_par_file):
|
||||
result = import_udisc_csv(udisc_csv_no_par_file, user.id)
|
||||
assert result == []
|
||||
|
||||
def test_import_empty_csv(self, user, db):
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||
) as f:
|
||||
f.write("PlayerName,CourseName,LayoutName,StartDate,Hole1,Total\n")
|
||||
path = f.name
|
||||
|
||||
result = import_udisc_csv(path, user.id)
|
||||
assert result == []
|
||||
|
||||
def test_import_idempotent(self, user, udisc_singles_csv_file):
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
assert DiscGolfCourse.objects.filter(title="Maple Hill").count() == 1
|
||||
assert Scrobble.objects.filter(source="uDisc").count() == 2
|
||||
|
||||
def test_import_course_defaults_only_on_create(
|
||||
self, user, udisc_singles_csv_file
|
||||
):
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
course = DiscGolfCourse.objects.get(title="Maple Hill")
|
||||
assert course.layout_name == "Mountains"
|
||||
|
||||
course.layout_name = "Updated"
|
||||
course.save()
|
||||
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
course.refresh_from_db()
|
||||
assert course.layout_name == "Updated"
|
||||
|
||||
@patch("discgolf.utils.ScrobbleNtfyNotification")
|
||||
def test_import_sends_notification(self, mock_notification_class, user, udisc_singles_csv_file):
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
mock_notification_class.assert_called_once()
|
||||
mock_notification_class.return_value.send.assert_called_once()
|
||||
|
||||
def test_import_hole_scores_per_player(self, user, udisc_singles_csv_file):
|
||||
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="uDisc").first()
|
||||
alice = scrobble.log["scores"]["Alice"]
|
||||
assert alice["hole_1"] == 4
|
||||
assert alice["hole_2"] == 2
|
||||
assert alice["hole_3"] == 3
|
||||
bob = scrobble.log["scores"]["Bob"]
|
||||
assert bob["hole_1"] == 3
|
||||
assert bob["hole_2"] == 4
|
||||
assert bob["hole_3"] == 5
|
||||
|
||||
def test_import_record_error_on_bad_data(self, user, db):
|
||||
import tempfile
|
||||
|
||||
content = """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
|
||||
Par,,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||
) as f:
|
||||
f.write(content)
|
||||
path = f.name
|
||||
|
||||
errors = []
|
||||
result = import_udisc_csv(path, user.id, record_error=errors.append)
|
||||
assert len(result) == 1
|
||||
course = DiscGolfCourse.objects.first()
|
||||
assert course.title == ""
|
||||
58
tests/discgolf_tests/test_views.py
Normal file
58
tests/discgolf_tests/test_views.py
Normal file
@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
from discgolf.models import DiscGolfCourse
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TestDiscGolfCourseViews:
|
||||
def _make_scrobble(self, user, course):
|
||||
dt = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)
|
||||
return Scrobble.objects.create(
|
||||
user=user,
|
||||
disc_golf_course=course,
|
||||
media_type=Scrobble.MediaType.DISC_GOLF,
|
||||
timestamp=dt,
|
||||
)
|
||||
|
||||
def test_course_list_anonymous(self, db, user):
|
||||
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||
self._make_scrobble(user, course)
|
||||
client = Client()
|
||||
response = client.get(reverse("discgolf:course_list"))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_course_list_shows_course(self, db, user):
|
||||
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||
self._make_scrobble(user, course)
|
||||
client = Client()
|
||||
response = client.get(reverse("discgolf:course_list"))
|
||||
assert response.status_code == 200
|
||||
assert "Maple Hill" in response.content.decode()
|
||||
|
||||
def test_course_detail_anonymous(self, db, user):
|
||||
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||
self._make_scrobble(user, course)
|
||||
client = Client()
|
||||
response = client.get(
|
||||
reverse("discgolf:course_detail", kwargs={"slug": course.uuid})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_course_detail_shows_course(self, db, user):
|
||||
course = DiscGolfCourse.objects.create(
|
||||
title="Maple Hill", layout_name="Mountains"
|
||||
)
|
||||
self._make_scrobble(user, course)
|
||||
client = Client()
|
||||
response = client.get(
|
||||
reverse("discgolf:course_detail", kwargs={"slug": course.uuid})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "Maple Hill" in response.content.decode()
|
||||
@ -8,7 +8,8 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from music.models import Album, Artist, Track
|
||||
from podcasts.models import PodcastEpisode
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.models import Scrobble, ShareViewLog
|
||||
from scrobbles.sqids import encode_scrobble_share
|
||||
from tasks.models import Task
|
||||
|
||||
|
||||
@ -143,9 +144,10 @@ def test_mopidy_track_webhook_creates_track_and_scrobble(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -181,9 +183,10 @@ def test_jellyfin_track_webhook_creates_track_and_scrobble(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -225,9 +228,10 @@ def test_mopidy_track_webhook_stores_raw_data(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -265,10 +269,11 @@ def test_mopidy_track_webhook_stores_album_id(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
@ -303,9 +308,10 @@ def test_jellyfin_track_webhook_stores_raw_data(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -347,11 +353,11 @@ def test_jellyfin_track_webhook_stores_album_id(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.albums.add(album)
|
||||
track.artists.add(artist)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
@ -507,12 +513,13 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
visibility="public",
|
||||
log={
|
||||
"notes": ["First note", "Second note"],
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert "First note" in response.content.decode()
|
||||
@ -529,6 +536,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
visibility="public",
|
||||
log={
|
||||
"notes": [
|
||||
{"2024-01-01 10:00:00": "Note at first timestamp"},
|
||||
@ -537,7 +545,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
@ -557,6 +565,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
visibility="public",
|
||||
log={
|
||||
"notes": [
|
||||
{"2024-01-01 10:00:00": "Note with label"},
|
||||
@ -565,7 +574,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
@ -588,7 +597,7 @@ def test_scrobble_detail_view_post_updates_log(client):
|
||||
"description": "Original description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
@ -602,7 +611,8 @@ def test_scrobble_detail_view_post_updates_log(client):
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.log["description"] == "Updated description"
|
||||
assert scrobble.log["notes"] == ["Updated note"]
|
||||
assert isinstance(scrobble.log["notes"], dict)
|
||||
assert list(scrobble.log["notes"].values()) == ["Updated note"]
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@ -733,3 +743,293 @@ def test_gps_webhook_creates_location(client, valid_auth_token):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "scrobble_id" in response.data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_shared_visibility(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
url = reverse(
|
||||
"scrobbles:shared-detail",
|
||||
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||
)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_public_visibility(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser2", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="public",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
url = reverse(
|
||||
"scrobbles:shared-detail",
|
||||
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||
)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_private_visibility_returns_404(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser3", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="private",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
url = reverse(
|
||||
"scrobbles:shared-detail",
|
||||
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||
)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_invalid_sqid_returns_404(client):
|
||||
url = reverse("scrobbles:shared-detail", kwargs={"sqid": "InvalidSqid123"})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_expired_token_returns_404(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser4", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||
scrobble.regenerate_share_token()
|
||||
url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_increments_count_and_logs_view(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser5", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
assert scrobble.share_view_count == 0
|
||||
|
||||
url = reverse(
|
||||
"scrobbles:shared-detail",
|
||||
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||
)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.share_view_count == 1
|
||||
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 1
|
||||
|
||||
log_entry = ShareViewLog.objects.filter(scrobble=scrobble).first()
|
||||
assert log_entry.ip_address == "127.0.0.1"
|
||||
assert log_entry.user_agent == ""
|
||||
assert log_entry.referrer == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_explore_view_shows_only_public_scrobbles(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="exploreuser", password="testpass"
|
||||
)
|
||||
public_task = Task.objects.create(title="Public Task Title")
|
||||
shared_task = Task.objects.create(title="Shared Task Title")
|
||||
private_task = Task.objects.create(title="Private Task Title")
|
||||
ts = timezone.now()
|
||||
public_scrobble = Scrobble.objects.create(
|
||||
task=public_task, media_type="Task", user=user, visibility="public",
|
||||
timestamp=ts,
|
||||
)
|
||||
Scrobble.objects.create(
|
||||
task=shared_task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=ts,
|
||||
)
|
||||
Scrobble.objects.create(
|
||||
task=private_task, media_type="Task", user=user, visibility="private",
|
||||
timestamp=ts,
|
||||
)
|
||||
|
||||
url = reverse("scrobbles:explore")
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
content = response.content.decode()
|
||||
assert "Public Task Title" in content
|
||||
assert "Shared Task Title" not in content
|
||||
assert "Private Task Title" not in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_visibility_owner_can_change(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="visuser", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="private",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||
response = client.post(url, {"visibility": "shared"})
|
||||
assert response.status_code == 302
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.visibility == "shared"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_visibility_non_owner_gets_404(client):
|
||||
owner = get_user_model().objects.create_user(
|
||||
username="owner", password="testpass"
|
||||
)
|
||||
other = get_user_model().objects.create_user(
|
||||
username="other", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=owner, visibility="private",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||
response = client.post(url, {"visibility": "shared"})
|
||||
assert response.status_code == 404
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.visibility == "private"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_visibility_anonymous_redirects_to_login(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="anontest", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="private",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||
response = client.post(url, {"visibility": "shared"})
|
||||
assert response.status_code == 302
|
||||
assert "/login/" in response.url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_regenerate_share_token_invalidates_old_sqid(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="regentest", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:regenerate-share-token", kwargs={"pk": scrobble.id})
|
||||
response = client.post(url)
|
||||
assert response.status_code == 302
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.share_token_version == 1
|
||||
|
||||
old_url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
|
||||
old_response = client.get(old_url)
|
||||
assert old_response.status_code == 404
|
||||
|
||||
new_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||
new_url = reverse("scrobbles:shared-detail", kwargs={"sqid": new_sqid})
|
||||
new_response = client.get(new_url)
|
||||
assert new_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_analytics_owner_can_view(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="analyticsuser", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_analytics_non_owner_gets_404(client):
|
||||
owner = get_user_model().objects.create_user(
|
||||
username="analyticsowner", password="testpass"
|
||||
)
|
||||
other = get_user_model().objects.create_user(
|
||||
username="analyticsother", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=owner, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_analytics_shows_view_logs(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="analyticsviews", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||
share_url = reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
|
||||
client.get(share_url)
|
||||
client.get(share_url)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "127.0.0.1" in content
|
||||
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 2
|
||||
|
||||
@ -4,9 +4,15 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vrobbler import context_processors
|
||||
from vrobbler.context_processors import version_info
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_git_cache():
|
||||
context_processors._GIT_COMMIT = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
return MagicMock()
|
||||
|
||||
281
tests/trails_tests/test_gpx_importer.py
Normal file
281
tests/trails_tests/test_gpx_importer.py
Normal file
@ -0,0 +1,281 @@
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files import File
|
||||
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.importers.trail_gpx import (
|
||||
compute_trail_stats,
|
||||
find_route_waypoint,
|
||||
import_trail_gpx,
|
||||
parse_trackpoints,
|
||||
)
|
||||
from scrobbles.models import Scrobble, TrailGPXImport
|
||||
from trails.models import Trail, TrailLogData
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SAMPLE_GPX = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "data", "sample_trail.gpx"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create(email="trailblazer@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_gpx_path():
|
||||
return SAMPLE_GPX
|
||||
|
||||
|
||||
class TestParseTrackpoints:
|
||||
def test_parses_gpx(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
assert len(points) == 837
|
||||
assert result["name"] == "Morning Run ⛅"
|
||||
assert result["description"] == "Run"
|
||||
lat, lon, ele, t = points[0]
|
||||
assert round(lat, 6) == 34.190598
|
||||
assert round(lon, 6) == -118.844015
|
||||
assert ele == 305.3
|
||||
assert t is not None
|
||||
|
||||
def test_first_and_last_times(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
first_time = points[0][3]
|
||||
last_time = points[-1][3]
|
||||
duration = (last_time - first_time).total_seconds()
|
||||
assert duration == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_gpx_extra_metadata(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
extra = result["extra"]
|
||||
assert extra["avg_heartrate"] == 159
|
||||
assert extra["max_heartrate"] == 183
|
||||
assert extra["avg_speed_kmh"] == pytest.approx(9.82, abs=0.1)
|
||||
assert extra["activity_type"] == "Run"
|
||||
assert extra["moving_time_seconds"] == 3008
|
||||
assert extra["total_elevation_gain_m"] == 246.4
|
||||
|
||||
|
||||
class TestImportTrailGPX:
|
||||
def test_creates_trail(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Trail.objects.filter(title="Morning Run ⛅").exists()
|
||||
|
||||
def test_creates_geolocation(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert GeoLocation.objects.filter(lat=34.190598, lon=-118.844015).exists()
|
||||
|
||||
def test_sets_trailhead(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
trail = Trail.objects.filter(title="Morning Run ⛅").first()
|
||||
assert trail.trailhead_location is not None
|
||||
assert round(trail.trailhead_location.lat, 6) == 34.190598
|
||||
|
||||
def test_creates_scrobble(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_timestamps(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timestamp.isoformat().startswith("2022-06-05T13:55:09")
|
||||
assert scrobble.stop_timestamp.isoformat().startswith("2022-06-05T14:57:59")
|
||||
assert scrobble.media_type == Scrobble.MediaType.TRAIL
|
||||
|
||||
def test_scrobble_has_trail_fk(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail is not None
|
||||
assert scrobble.trail.title == "Morning Run ⛅"
|
||||
|
||||
def test_scrobble_has_gpx_file(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.gpx_file
|
||||
assert scrobble.gpx_file.name.endswith(".gpx")
|
||||
|
||||
def test_lookup_existing_trail_by_trailhead(self, user, sample_gpx_path):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Existing Trail", trailhead_location=geo)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail.id == trail.id
|
||||
|
||||
def test_dedup(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_log_has_stats(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert log["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert log["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert log["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
assert log["description"] == "Run"
|
||||
|
||||
def test_scrobble_playback_position(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.playback_position_seconds == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_scrobble_has_timezone(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timezone is not None
|
||||
assert isinstance(scrobble.timezone, str)
|
||||
|
||||
def test_scrobble_log_extra_metadata(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["avg_heartrate"] == 159
|
||||
assert log["max_heartrate"] == 183
|
||||
assert log["activity_type"] == "Run"
|
||||
|
||||
def test_scrobble_log_no_calories_in_gpx(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.log.get("calories") is None
|
||||
|
||||
|
||||
class TestComputeTrailStats:
|
||||
def test_computes_distance_and_elevation(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
stats = compute_trail_stats(result["points"])
|
||||
assert stats["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert stats["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert stats["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert stats["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
|
||||
|
||||
class TestTrailGPXImportModel:
|
||||
def test_create_import_model(self, db, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="test_trail.gpx",
|
||||
)
|
||||
assert imp.uuid is not None
|
||||
assert imp.import_type == "Trail GPX"
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_process_via_model(self, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="Morning Run.gpx",
|
||||
)
|
||||
with open(sample_gpx_path, "rb") as f:
|
||||
imp.gpx_file.save("Morning Run.gpx", File(f), save=True)
|
||||
imp.process()
|
||||
imp.refresh_from_db()
|
||||
assert imp.process_count == 1
|
||||
assert imp.processed_finished is not None
|
||||
|
||||
|
||||
class TestFindRouteWaypoint:
|
||||
def test_returns_halfway_point(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
pt = find_route_waypoint(result["points"])
|
||||
assert pt is not None
|
||||
lat, lon = pt
|
||||
assert lat == pytest.approx(34.177853, abs=0.001)
|
||||
assert lon == pytest.approx(-118.829944, abs=0.001)
|
||||
|
||||
def test_returns_last_point_for_short_track(self):
|
||||
points = [(34.0, -118.0, None, None), (34.001, -118.001, None, None)]
|
||||
pt = find_route_waypoint(points)
|
||||
assert pt == (34.001, -118.001)
|
||||
|
||||
def test_returns_none_for_empty_points(self):
|
||||
assert find_route_waypoint([]) is None
|
||||
|
||||
|
||||
class TestFindByTrailhead:
|
||||
def test_exact_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Test Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found == trail
|
||||
|
||||
def test_within_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Nearby Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.191000, -118.844000, tolerance_m=100)
|
||||
assert found == trail
|
||||
|
||||
def test_beyond_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(title="Far Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.200000, -118.850000, tolerance_m=50)
|
||||
assert found is None
|
||||
|
||||
def test_no_trailhead_returns_none(self, db):
|
||||
Trail.objects.create(title="No Location")
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found is None
|
||||
|
||||
def test_same_trailhead_same_route_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Same Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.192167,
|
||||
route_lon=-118.843143,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
def test_same_trailhead_different_route_does_not_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(
|
||||
title="Different Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.200000,
|
||||
route_lon=-118.850000,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found is None
|
||||
|
||||
def test_legacy_trail_without_route_still_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Legacy Trail",
|
||||
trailhead_location=geo,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
|
||||
class TestFindOrCreate:
|
||||
def test_find_existing(self, db):
|
||||
Trail.objects.create(title="Existing Trail")
|
||||
trail = Trail.find_or_create("Existing Trail")
|
||||
assert trail.title == "Existing Trail"
|
||||
|
||||
def test_create_new(self, db):
|
||||
trail = Trail.find_or_create("New Trail")
|
||||
assert trail.title == "New Trail"
|
||||
assert Trail.objects.count() == 1
|
||||
@ -25,6 +25,7 @@ VROBBLER_TODOIST_CLIENT_ID="<id>"
|
||||
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
||||
VROBBLER_GOOGLE_API_KEY="<key>"
|
||||
VROBBLER_LICHESS_API_KEY = "<key>"
|
||||
VROBBLER_FASTCORK_API_KEY="fc_<key>"
|
||||
|
||||
# Storages
|
||||
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
||||
|
||||
@ -2,4 +2,5 @@
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
__version__ = "42.0"
|
||||
__all__ = ("celery_app", "__version__")
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
from beers.models import Beer, BeerProducer, BeerStyle
|
||||
from django.contrib import admin
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
class BeerInline(admin.TabularInline):
|
||||
model = Beer
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(BeerStyle)
|
||||
class BeerStyle(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(BeerProducer)
|
||||
class BeerProducer(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(Beer)
|
||||
class BeerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"uuid",
|
||||
"title",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
@ -1,20 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from beers.models import Beer, BeerProducer, BeerStyle
|
||||
|
||||
|
||||
class BeerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Beer
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerProducer
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerStyle
|
||||
fields = "__all__"
|
||||
@ -1,21 +0,0 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from beers.api import serializers
|
||||
from beers import models
|
||||
|
||||
|
||||
class BeerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Beer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BeerProducerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerProducer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerProducerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BeerStyleViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerStyle.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerStyleSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -1,143 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from beers.untappd import get_beer_from_untappd_id
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeerLogData(BaseLogData):
|
||||
rating: Optional[str] = None
|
||||
|
||||
|
||||
class BeerStyle(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class BeerProducer(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
location = models.CharField(max_length=255, **BNULL)
|
||||
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def find_or_create(cls, title: str) -> "BeerProducer":
|
||||
return cls.objects.filter(title=title).first()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Beer(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
ibu = models.SmallIntegerField(**BNULL)
|
||||
abv = models.FloatField(**BNULL)
|
||||
styles = models.ManyToManyField(BeerStyle, related_name="styles")
|
||||
non_alcoholic = models.BooleanField(default=False)
|
||||
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
||||
untappd_image = models.ImageField(upload_to="beers/untappd/", **BNULL)
|
||||
untappd_image_small = ImageSpecField(
|
||||
source="untappd_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
untappd_image_medium = ImageSpecField(
|
||||
source="untappd_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_rating = models.FloatField(**BNULL)
|
||||
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.producer}"
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.producer.name
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Drinking", tags="beer")
|
||||
|
||||
@property
|
||||
def beeradvocate_link(self) -> str:
|
||||
link = ""
|
||||
if self.producer and self.beeradvocate_id:
|
||||
if self.beeradvocate_id:
|
||||
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
|
||||
return link
|
||||
|
||||
@property
|
||||
def untappd_link(self) -> str:
|
||||
link = ""
|
||||
if self.untappd_id:
|
||||
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
|
||||
return link
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.untappd_image:
|
||||
url = self.untappd_image.url
|
||||
return url
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BeerLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, untappd_id: str) -> "Beer":
|
||||
beer = cls.objects.filter(untappd_id=untappd_id).first()
|
||||
|
||||
if not beer:
|
||||
beer_dict = get_beer_from_untappd_id(untappd_id)
|
||||
producer_dict = {}
|
||||
style_ids = []
|
||||
for key in list(beer_dict.keys()):
|
||||
if "producer__" in key:
|
||||
pkey = key.replace("producer__", "")
|
||||
producer_dict[pkey] = beer_dict.pop(key)
|
||||
if "styles" in key:
|
||||
for style in beer_dict.pop("styles"):
|
||||
style_inst, created = BeerStyle.objects.get_or_create(
|
||||
name=style
|
||||
)
|
||||
style_ids.append(style_inst.id)
|
||||
|
||||
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
|
||||
beer_dict["producer_id"] = producer.id
|
||||
beer = Beer.objects.create(**beer_dict)
|
||||
for style_id in style_ids:
|
||||
beer.styles.add(style_id)
|
||||
|
||||
return beer
|
||||
|
||||
def scrobbles(self, user_id):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
|
||||
"-timestamp"
|
||||
)
|
||||
@ -1,14 +0,0 @@
|
||||
from django.urls import path
|
||||
from beers import views
|
||||
|
||||
app_name = "beers"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("beers/", views.BeerListView.as_view(), name="beer_list"),
|
||||
path(
|
||||
"beers/<slug:slug>/",
|
||||
views.BeerDetailView.as_view(),
|
||||
name="beer_detail",
|
||||
),
|
||||
]
|
||||
@ -1,11 +0,0 @@
|
||||
from beers.models import Beer
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
|
||||
class BeerListView(ScrobbleableListView):
|
||||
model = Beer
|
||||
|
||||
|
||||
class BeerDetailView(ScrobbleableDetailView):
|
||||
model = Beer
|
||||
@ -16,6 +16,7 @@ class BirdingLocationAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "title")
|
||||
ordering = ("-created",)
|
||||
raw_id_fields = ("geo_location",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
@ -25,5 +26,6 @@ class BirdingLocationAdmin(admin.ModelAdmin):
|
||||
@admin.register(BirdingCSVImport)
|
||||
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started")
|
||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
|
||||
raw_id_fields = ("user",)
|
||||
ordering = ("-created",)
|
||||
|
||||
@ -2,13 +2,15 @@ import csv
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import timedelta
|
||||
|
||||
from dateutil import parser
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
@ -35,12 +37,13 @@ def parse_coords(location_str):
|
||||
|
||||
def parse_timestamp(date_str, time_str):
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
|
||||
return dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
dt_str = f"{date_str} {time_str}".strip()
|
||||
dt = parser.parse(dt_str)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%B %d, %Y")
|
||||
return dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
dt = parser.parse(date_str)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
||||
return None
|
||||
@ -61,7 +64,7 @@ def parse_int(value):
|
||||
return None
|
||||
|
||||
|
||||
def import_birding_csv(file_path, user_id):
|
||||
def import_birding_csv(file_path, user_id, record_error=None):
|
||||
user = User.objects.get(id=user_id)
|
||||
new_scrobbles = []
|
||||
|
||||
@ -80,13 +83,21 @@ def import_birding_csv(file_path, user_id):
|
||||
|
||||
for (location_str, date_str, time_str), sighting_rows in groups.items():
|
||||
if not location_str:
|
||||
logger.warning("Skipping rows with no location")
|
||||
msg = "Skipping rows with no location"
|
||||
logger.warning(msg)
|
||||
if record_error:
|
||||
record_error(msg)
|
||||
continue
|
||||
|
||||
timestamp = parse_timestamp(date_str, time_str)
|
||||
if not timestamp:
|
||||
msg = f"Could not parse date/time: {date_str} {time_str}"
|
||||
if record_error:
|
||||
record_error(msg)
|
||||
continue
|
||||
|
||||
timestamp = user.profile.get_timestamp_with_tz(timestamp)
|
||||
|
||||
location_title = (
|
||||
LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip()
|
||||
)
|
||||
@ -122,9 +133,10 @@ def import_birding_csv(file_path, user_id):
|
||||
)
|
||||
birds_data.append(entry.asdict)
|
||||
|
||||
duration_minutes = parse_duration(first_row.get("Duration", ""))
|
||||
logdata = BirdSightingLogData(
|
||||
birds=birds_data,
|
||||
duration_minutes=parse_duration(first_row.get("Duration", "")),
|
||||
duration_minutes=duration_minutes,
|
||||
observation_type=first_row.get("Observation Type", "").strip() or None,
|
||||
distance=first_row.get("Distance", "").strip() or None,
|
||||
area=first_row.get("Area", "").strip() or None,
|
||||
@ -134,9 +146,34 @@ def import_birding_csv(file_path, user_id):
|
||||
|
||||
log_dict = logdata.asdict
|
||||
|
||||
weather_loc = location.geo_location
|
||||
if not weather_loc:
|
||||
last_loc = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
media_type=Scrobble.MediaType.GEO_LOCATION,
|
||||
geo_location__isnull=False,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
if last_loc:
|
||||
weather_loc = last_loc.geo_location
|
||||
if weather_loc:
|
||||
weather = weather_loc.current_weather
|
||||
if weather:
|
||||
log_dict["weather"] = weather["description"]
|
||||
log_dict["temperature"] = weather["temp"]
|
||||
|
||||
stop_timestamp = timestamp + timedelta(minutes=duration_minutes) if duration_minutes else None
|
||||
|
||||
tz = getattr(timestamp.tzinfo, "name", None)
|
||||
|
||||
scrobble = Scrobble(
|
||||
user=user,
|
||||
timestamp=timestamp,
|
||||
timezone=tz,
|
||||
stop_timestamp=stop_timestamp,
|
||||
source="Birding CSV Import",
|
||||
birding_location=location,
|
||||
log=log_dict,
|
||||
@ -156,4 +193,6 @@ def import_birding_csv(file_path, user_id):
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
logger.info(f"Created {len(created)} birding scrobbles")
|
||||
for scrobble in created:
|
||||
ScrobbleNtfyNotification(scrobble).send()
|
||||
return created
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-08 14:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("birds", "0002_birdingcsvimport"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="birdingcsvimport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -61,9 +61,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
||||
@cached_property
|
||||
def bird_list(self) -> str:
|
||||
if self.birds:
|
||||
return ", ".join(
|
||||
[BirdSightingEntry(**b).__str__() for b in self.birds]
|
||||
)
|
||||
return ", ".join([BirdSightingEntry(**b).__str__() for b in self.birds])
|
||||
return ""
|
||||
|
||||
def as_html(self) -> str:
|
||||
@ -80,9 +78,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
||||
)
|
||||
|
||||
if self.area:
|
||||
html_parts.append(
|
||||
f'<div class="birding-area">Area: {self.area}</div>'
|
||||
)
|
||||
html_parts.append(f'<div class="birding-area">Area: {self.area}</div>')
|
||||
|
||||
if self.party_size:
|
||||
html_parts.append(
|
||||
@ -105,9 +101,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
||||
)
|
||||
|
||||
if self.guide:
|
||||
html_parts.append(
|
||||
f'<div class="birding-guide">Guide: {self.guide}</div>'
|
||||
)
|
||||
html_parts.append(f'<div class="birding-guide">Guide: {self.guide}</div>')
|
||||
|
||||
if self.duration_minutes:
|
||||
html_parts.append(
|
||||
@ -183,9 +177,7 @@ class Bird(TimeStampedModel):
|
||||
|
||||
class BirdingLocation(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
geo_location = models.ForeignKey(
|
||||
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
|
||||
ebird_hotspot_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def get_absolute_url(self):
|
||||
@ -193,7 +185,7 @@ class BirdingLocation(ScrobblableMixin):
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return ""
|
||||
return self.geo_location
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
@ -224,6 +216,7 @@ class BirdingCSVImport(TimeStampedModel):
|
||||
processed_finished = models.DateTimeField(**BNULL)
|
||||
process_log = models.TextField(**BNULL)
|
||||
process_count = models.IntegerField(**BNULL)
|
||||
error_log = models.TextField(**BNULL)
|
||||
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
|
||||
|
||||
class Meta:
|
||||
@ -269,9 +262,7 @@ class BirdingCSVImport(TimeStampedModel):
|
||||
self.save(update_fields=["process_log", "process_count"])
|
||||
return
|
||||
for count, scrobble in enumerate(scrobbles):
|
||||
scrobble_str = (
|
||||
f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
|
||||
)
|
||||
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
|
||||
log_line = f"{scrobble_str}"
|
||||
if count > 0:
|
||||
log_line = "\n" + log_line
|
||||
@ -279,6 +270,14 @@ class BirdingCSVImport(TimeStampedModel):
|
||||
self.process_count = len(scrobbles)
|
||||
self.save(update_fields=["process_log", "process_count"])
|
||||
|
||||
def record_error(self, error_message):
|
||||
log_line = f"{timezone.now().isoformat()}: {error_message}"
|
||||
if self.error_log:
|
||||
self.error_log += "\n" + log_line
|
||||
else:
|
||||
self.error_log = log_line
|
||||
self.save(update_fields=["error_log"])
|
||||
|
||||
def scrobbles(self):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
@ -297,6 +296,13 @@ class BirdingCSVImport(TimeStampedModel):
|
||||
from birds.importer import import_birding_csv
|
||||
|
||||
self.mark_started()
|
||||
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
|
||||
self.record_log(scrobbles)
|
||||
self.mark_finished()
|
||||
try:
|
||||
scrobbles = import_birding_csv(
|
||||
self.upload_file_path, self.user_id, record_error=self.record_error
|
||||
)
|
||||
self.record_log(scrobbles)
|
||||
except Exception as e:
|
||||
self.record_error(f"Import failed: {e}")
|
||||
logger.exception(f"Import failed for {self}")
|
||||
finally:
|
||||
self.mark_finished()
|
||||
|
||||
27
vrobbler/apps/birds/static/birds/bird_sightings.js
Normal file
27
vrobbler/apps/birds/static/birds/bird_sightings.js
Normal file
@ -0,0 +1,27 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var widget = document.querySelector(".bird-sightings-widget");
|
||||
if (!widget) return;
|
||||
|
||||
var list = widget.querySelector(".bird-sightings-list");
|
||||
|
||||
widget.addEventListener("click", function (e) {
|
||||
if (e.target.classList.contains("add-sighting-row")) {
|
||||
var rows = list.querySelectorAll(".bird-sighting-row");
|
||||
var template = rows[rows.length - 1];
|
||||
if (!template) return;
|
||||
var clone = template.cloneNode(true);
|
||||
clone.querySelectorAll("select, input").forEach(function (el) {
|
||||
el.value = "";
|
||||
});
|
||||
clone.querySelector('input[name$="_quantity"]').value = "1";
|
||||
list.appendChild(clone);
|
||||
}
|
||||
|
||||
if (e.target.classList.contains("remove-sighting")) {
|
||||
var rows = list.querySelectorAll(".bird-sighting-row");
|
||||
if (rows.length > 1) {
|
||||
e.target.closest(".bird-sighting-row").remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,9 @@
|
||||
from birds.models import Bird, BirdingCSVImport, BirdingLocation
|
||||
from birds.models import Bird, BirdingLocation
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.views import generic
|
||||
from scrobbles.models import EBirdCSVImport as BirdingCSVImport
|
||||
from scrobbles.views import (
|
||||
ScrobbleableDetailView,
|
||||
ScrobbleableListView,
|
||||
@ -40,6 +41,9 @@ class BirdingCSVImportCreateView(
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.user = self.request.user
|
||||
self.object.original_filename = (
|
||||
form.cleaned_data["csv_file"].name
|
||||
)
|
||||
self.object.save()
|
||||
self.object.process()
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
||||
@ -55,5 +59,5 @@ class BirdingCSVImportDetailView(LoginRequiredMixin, generic.DetailView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["title"] = "Birding CSV Import"
|
||||
context_data["title"] = "eBird CSV Import"
|
||||
return context_data
|
||||
|
||||
@ -5,6 +5,7 @@ from boardgames.models import (
|
||||
BoardGameLocation,
|
||||
BoardGamePublisher,
|
||||
BoardGameDesigner,
|
||||
BoardGameVariant,
|
||||
)
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
@ -38,6 +39,20 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
|
||||
"uuid",
|
||||
"geo_location",
|
||||
)
|
||||
raw_id_fields = ("geo_location",)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGameVariant)
|
||||
class BoardGameVariantAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"board_game",
|
||||
"uuid",
|
||||
)
|
||||
raw_id_fields = ("board_game",)
|
||||
search_fields = ("name", "board_game__title")
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@ -49,6 +64,7 @@ class BoardGameAdmin(admin.ModelAdmin):
|
||||
"title",
|
||||
"published_year",
|
||||
)
|
||||
raw_id_fields = ("publisher", "publishers", "designers", "expansion_for_boardgame")
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
|
||||
@ -20,6 +20,12 @@ class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGameVariantSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGameVariant
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGame
|
||||
|
||||
@ -22,6 +22,12 @@ class BoardGameLocationViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameVariantViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGameVariant.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameVariantSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGame.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameSerializer
|
||||
|
||||
0
vrobbler/apps/boardgames/management/__init__.py
Normal file
0
vrobbler/apps/boardgames/management/__init__.py
Normal file
@ -0,0 +1,81 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Refresh board game metadata from BGG (categories→genres, families→tags)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Persist changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Update all games even if they already have a published_date",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Number of games to process per batch (default: 50)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Seconds to sleep between API calls (default: 1.0)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from boardgames.models import BoardGame
|
||||
|
||||
commit = options["commit"]
|
||||
force = options["force"]
|
||||
batch_size = options["batch_size"]
|
||||
sleep_secs = options["sleep"]
|
||||
|
||||
qs = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude(bggeek_id="")
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Found {total} board games with BGG IDs")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no API calls will be made. Use --commit to run lookups."
|
||||
)
|
||||
return
|
||||
|
||||
enriched = 0
|
||||
skipped = 0
|
||||
|
||||
for batch_num, offset in enumerate(range(0, total, batch_size)):
|
||||
batch = qs[offset : offset + batch_size]
|
||||
for game in batch:
|
||||
try:
|
||||
game.fix_metadata(force_update=force)
|
||||
enriched += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" [SKIPPED] {game.title} (BGG {game.bggeek_id}): {e}"
|
||||
)
|
||||
)
|
||||
skipped += 1
|
||||
time.sleep(sleep_secs)
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
||||
f"enriched: {enriched}, skipped: {skipped}"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults:\n"
|
||||
f" Games enriched: {enriched}\n"
|
||||
f" Games skipped: {skipped}"
|
||||
)
|
||||
@ -0,0 +1,63 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from boardgames.utils import board_names_to_variants
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Convert existing board scrobble log 'board' keys to 'variant_ids'"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Persist changes to the database",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
commit = options.get("commit", False)
|
||||
|
||||
board_scrobbles = Scrobble.objects.filter(
|
||||
board_game__isnull=False,
|
||||
log__board__isnull=False,
|
||||
).exclude(log__board="")
|
||||
|
||||
total = board_scrobbles.count()
|
||||
self.stdout.write(f"Found {total} scrobbles with a 'board' key in log data")
|
||||
|
||||
if total == 0:
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for scrobble in board_scrobbles.iterator(chunk_size=100):
|
||||
log = scrobble.log
|
||||
board_value = log.pop("board", None)
|
||||
if not board_value:
|
||||
continue
|
||||
|
||||
variant_ids = board_names_to_variants(
|
||||
scrobble.board_game, [board_value]
|
||||
)
|
||||
if variant_ids:
|
||||
log["variant_ids"] = variant_ids
|
||||
|
||||
if commit:
|
||||
Scrobble.objects.filter(pk=scrobble.pk).update(log=log)
|
||||
updated += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
if commit:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Updated {updated} scrobbles (changes committed)"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f"Would update {updated} scrobbles (pass --commit to persist)"
|
||||
)
|
||||
@ -0,0 +1,80 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from boardgames.models import BoardGame
|
||||
from boardgames.sources.bgg import lookup_boardgame_from_bgg
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fetch and link expansions for existing board games from BGG"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Persist changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bggeek-id",
|
||||
type=str,
|
||||
help="Only process a single game by BGG ID",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
commit = options.get("commit", False)
|
||||
bggeek_id = options.get("bggeek_id")
|
||||
|
||||
games = (
|
||||
BoardGame.objects.exclude(bggeek_id__isnull=True)
|
||||
.exclude(bggeek_id="")
|
||||
.exclude(skip_expansions=True)
|
||||
)
|
||||
if bggeek_id:
|
||||
games = games.filter(bggeek_id=bggeek_id)
|
||||
|
||||
total = games.count()
|
||||
self.stdout.write(f"Found {total} board games with BGG IDs")
|
||||
|
||||
if total == 0:
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for game in games.iterator(chunk_size=100):
|
||||
try:
|
||||
data = lookup_boardgame_from_bgg(lookup_id=str(game.bggeek_id))
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Failed to fetch BGG data for {game.title} ({game.bggeek_id}): {e}"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
expansions = data.get("expansions", [])
|
||||
if not expansions:
|
||||
continue
|
||||
|
||||
if commit:
|
||||
fetch_and_link_expansions(game, expansions)
|
||||
updated += 1
|
||||
self.stdout.write(
|
||||
f" Linked {len(expansions)} expansions to {game.title}"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" Would link {len(expansions)} expansions to {game.title}"
|
||||
)
|
||||
updated += 1
|
||||
|
||||
if commit:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Updated {updated} games (changes committed)")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f"Would update {updated} games (pass --commit to persist)"
|
||||
)
|
||||
62
vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py
Normal file
62
vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py
Normal file
@ -0,0 +1,62 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-02 22:23
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0015_alter_boardgame_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BoardGameVariant",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"board_game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="variants",
|
||||
to="boardgames.boardgame",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-04 15:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0016_boardgamevariant"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="skip_expansions",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -71,11 +71,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
difficulty: Optional[int] = None
|
||||
solo: Optional[bool] = None
|
||||
two_handed: Optional[bool] = None
|
||||
expansion_ids: Optional[int] = None
|
||||
expansion_ids: Optional[list[int]] = None
|
||||
moves: Optional[list] = None
|
||||
rated: Optional[str] = None
|
||||
speed: Optional[str] = None
|
||||
variant: Optional[str] = None
|
||||
variant_ids: Optional[list[int]] = None
|
||||
lichess_id: Optional[int] = None
|
||||
board: Optional[str] = None
|
||||
rounds: Optional[int] = None
|
||||
@ -90,6 +91,47 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
"variant",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from boardgames.widgets import VariantSelectWidget
|
||||
from scrobbles.forms import NotesDictField
|
||||
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"notes": NotesDictField(required=False),
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
),
|
||||
"variant_ids": forms.ModelMultipleChoiceField(
|
||||
queryset=BoardGameVariant.objects.all(),
|
||||
required=False,
|
||||
widget=VariantSelectWidget(attrs={"size": 5}),
|
||||
),
|
||||
"expansion_ids": forms.ModelMultipleChoiceField(
|
||||
queryset=BoardGame.objects.filter(
|
||||
expansion_for_boardgame__isnull=False
|
||||
),
|
||||
required=False,
|
||||
widget=forms.SelectMultiple(attrs={"size": 5}),
|
||||
),
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
@cached_property
|
||||
def variants(self) -> list["BoardGameVariant"]:
|
||||
if not self.variant_ids:
|
||||
return []
|
||||
return list(
|
||||
BoardGameVariant.objects.filter(id__in=self.variant_ids)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def location(self):
|
||||
if not self.location_id:
|
||||
@ -100,7 +142,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
def player_log(self) -> str:
|
||||
if self.players:
|
||||
return ", ".join(
|
||||
[BoardGameScoreLogData(**player).__str__() for player in self.players]
|
||||
[
|
||||
BoardGameScoreLogData(
|
||||
**{k: v for k, v in player.items() if k in BoardGameScoreLogData.__dataclass_fields__}
|
||||
).__str__()
|
||||
for player in self.players
|
||||
]
|
||||
)
|
||||
return ""
|
||||
|
||||
@ -110,13 +157,21 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
if self.board:
|
||||
html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
|
||||
|
||||
if self.variants:
|
||||
variant_names = ", ".join(v.name for v in self.variants)
|
||||
html_parts.append(
|
||||
f'<div class="boardgame-variants">Variants: {variant_names}</div>'
|
||||
)
|
||||
|
||||
if self.location:
|
||||
html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
|
||||
|
||||
if self.players:
|
||||
players_html = []
|
||||
for player_data in self.players:
|
||||
player = BoardGameScoreLogData(**player_data)
|
||||
player = BoardGameScoreLogData(
|
||||
**{k: v for k, v in player_data.items() if k in BoardGameScoreLogData.__dataclass_fields__}
|
||||
)
|
||||
player_info = player.name
|
||||
if player.score:
|
||||
player_info += f" ({player.score})"
|
||||
@ -133,23 +188,6 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
|
||||
return "".join(html_parts)
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
)
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
|
||||
class BoardGamePublisher(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
@ -262,9 +300,11 @@ class BoardGame(ScrobblableMixin):
|
||||
expansion_for_boardgame = models.ForeignKey(
|
||||
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
skip_expansions = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
return self.publisher
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})
|
||||
@ -295,18 +335,29 @@ class BoardGame(ScrobblableMixin):
|
||||
if not data:
|
||||
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
||||
|
||||
cover_url = data.pop("cover_url")
|
||||
year = data.pop("year_published")
|
||||
publisher_name = data.pop("publisher_name")
|
||||
expansions = data.pop("expansions", [])
|
||||
cover_url = data.pop("cover_url", "")
|
||||
year_published = data.pop("year_published", None)
|
||||
if year_published is None:
|
||||
year_published = data.pop("published_year", None)
|
||||
publisher_name = data.pop("publisher_name", "")
|
||||
|
||||
if year:
|
||||
data["published_year"] = int(year)
|
||||
if year_published:
|
||||
data["published_year"] = int(year_published)
|
||||
|
||||
if not data["min_players"]:
|
||||
data.pop("min_players")
|
||||
if not data["min_players"]:
|
||||
data.pop("max_players")
|
||||
|
||||
# Pop extra BGG metadata that isn't a model field
|
||||
categories = data.pop("categories", [])
|
||||
families = data.pop("families", [])
|
||||
data.pop("mechanics", None)
|
||||
data.pop("designers", None)
|
||||
data.pop("publishers", None)
|
||||
data.pop("publisher", None)
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
BoardGame.objects.filter(pk=self.id).update(**data)
|
||||
self.refresh_from_db()
|
||||
@ -318,10 +369,20 @@ class BoardGame(ScrobblableMixin):
|
||||
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
|
||||
self.save()
|
||||
|
||||
for cat in categories:
|
||||
self.genre.add(cat.strip())
|
||||
for fam in families:
|
||||
self.tags.add(fam.strip())
|
||||
|
||||
# Go get cover image if the URL is present
|
||||
if cover_url and not self.cover:
|
||||
self.save_image_from_url(cover_url)
|
||||
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
if not self.skip_expansions:
|
||||
fetch_and_link_expansions(self, expansions)
|
||||
|
||||
def save_image_from_url(self, url):
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
@ -330,7 +391,12 @@ class BoardGame(ScrobblableMixin):
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
|
||||
def find_or_create(
|
||||
cls,
|
||||
lookup_id: str,
|
||||
data: dict[str, Any] = {},
|
||||
defer_expansions: bool = False,
|
||||
) -> "BoardGame":
|
||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
if not game:
|
||||
@ -353,16 +419,19 @@ class BoardGame(ScrobblableMixin):
|
||||
else:
|
||||
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
||||
|
||||
expansions = bgg_data.pop("expansions", [])
|
||||
mechanics = bgg_data.pop("mechanics", [])
|
||||
designers = bgg_data.pop("designers", [])
|
||||
categories = bgg_data.pop("categories", [])
|
||||
families = bgg_data.pop("families", [])
|
||||
publishers = bgg_data.pop("publishers", [])
|
||||
publisher = bgg_data.pop("publisher", [])
|
||||
cover_url = bgg_data.pop("cover_url")
|
||||
cover_url = bgg_data.pop("cover_url") or ""
|
||||
|
||||
game = cls.objects.create(**bgg_data)
|
||||
|
||||
game.save_image_from_url(cover_url)
|
||||
if cover_url:
|
||||
game.save_image_from_url(cover_url)
|
||||
game.cooperative = data.get("cooperative", False)
|
||||
game.highest_wins = data.get("highestWins", True)
|
||||
game.no_points = data.get("noPoints", False)
|
||||
@ -371,6 +440,18 @@ class BoardGame(ScrobblableMixin):
|
||||
if publisher:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
|
||||
game.publisher = publisher
|
||||
|
||||
skip_expansions = (
|
||||
game.bggeek_id is not None
|
||||
and str(game.bggeek_id).isdigit()
|
||||
and int(game.bggeek_id) in settings.SKIP_AUTO_EXPANSION_DOWNLOAD
|
||||
) or any(
|
||||
c == "Collectible Card Games" for c in categories
|
||||
)
|
||||
|
||||
if skip_expansions:
|
||||
game.skip_expansions = True
|
||||
|
||||
game.save()
|
||||
|
||||
if designers:
|
||||
@ -385,4 +466,33 @@ class BoardGame(ScrobblableMixin):
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
|
||||
game.publishers.add(publisher)
|
||||
|
||||
for cat in categories:
|
||||
game.genre.add(cat.strip())
|
||||
for fam in families:
|
||||
game.tags.add(fam.strip())
|
||||
|
||||
if expansions and not game.skip_expansions:
|
||||
if defer_expansions:
|
||||
from boardgames.tasks import fetch_board_game_expansions
|
||||
|
||||
fetch_board_game_expansions.delay(game.id, expansions)
|
||||
else:
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
fetch_and_link_expansions(game, expansions)
|
||||
|
||||
return game
|
||||
|
||||
|
||||
class BoardGameVariant(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
board_game = models.ForeignKey(
|
||||
BoardGame,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="variants",
|
||||
)
|
||||
description = models.TextField(**BNULL)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.board_game.title})"
|
||||
|
||||
@ -32,9 +32,13 @@ def lookup_boardgame_from_bgg(
|
||||
)
|
||||
game_dict["mechanics"] = game.mechanics
|
||||
game_dict["categories"] = game.categories
|
||||
game_dict["families"] = game.families
|
||||
game_dict["designers"] = game.designers
|
||||
game_dict["publishers"] = game.publishers
|
||||
if game.publishers:
|
||||
game_dict["publisher"] = game.publishers[0]
|
||||
game_dict["expansions"] = [
|
||||
{"id": exp.id, "name": exp.name} for exp in game.expansions
|
||||
]
|
||||
|
||||
return game_dict
|
||||
|
||||
@ -103,6 +103,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
"source": "Lichess",
|
||||
"timezone": user.profile.timezone,
|
||||
"log": log_data,
|
||||
"visibility": "private",
|
||||
}
|
||||
if commit:
|
||||
Scrobble.objects.create(**scrobble_dict)
|
||||
@ -113,6 +114,8 @@ def import_chess_games_for_all_users():
|
||||
scrobbles_to_create = []
|
||||
for user in User.objects.filter(profile__lichess_username__isnull=False):
|
||||
scrobble_dict = import_chess_games_for_user_id(user.id)
|
||||
if not scrobble_dict:
|
||||
continue
|
||||
scrobbles_to_create.append(Scrobble(**scrobble_dict))
|
||||
|
||||
if scrobbles_to_create:
|
||||
|
||||
29
vrobbler/apps/boardgames/tasks.py
Normal file
29
vrobbler/apps/boardgames/tasks.py
Normal file
@ -0,0 +1,29 @@
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def fetch_board_game_expansions(board_game_id, expansions_data):
|
||||
from boardgames.models import BoardGame
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
game = BoardGame.objects.filter(id=board_game_id).first()
|
||||
if not game:
|
||||
logger.warning(
|
||||
"Board game not found for expansion linking",
|
||||
extra={"board_game_id": board_game_id},
|
||||
)
|
||||
return
|
||||
|
||||
fetch_and_link_expansions(game, expansions_data)
|
||||
logger.info(
|
||||
"Linked expansions for board game",
|
||||
extra={
|
||||
"board_game_id": board_game_id,
|
||||
"title": game.title,
|
||||
"count": len(expansions_data),
|
||||
},
|
||||
)
|
||||
@ -0,0 +1,97 @@
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
|
||||
{% for group_name, group_choices, group_index in widget.optgroups %}
|
||||
{% for option in group_choices %}
|
||||
{% include option.template_name with widget=option %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" data-bs-toggle="modal" data-bs-target="#addVariantModal">
|
||||
+ Add variant
|
||||
</button>
|
||||
|
||||
<div class="modal fade" id="addVariantModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add Variant</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="newVariantName" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="newVariantName" placeholder="e.g. Map A">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newVariantDescription" class="form-label">Description (optional)</label>
|
||||
<input type="text" class="form-control" id="newVariantDescription">
|
||||
</div>
|
||||
<p class="text-danger d-none" id="variantError"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="saveVariantBtn">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var select = document.getElementById('{{ widget.attrs.id }}');
|
||||
if (!select) return;
|
||||
|
||||
var saveBtn = document.getElementById('saveVariantBtn');
|
||||
if (!saveBtn) return;
|
||||
|
||||
var modalEl = document.getElementById('addVariantModal');
|
||||
var nameInput = document.getElementById('newVariantName');
|
||||
var descInput = document.getElementById('newVariantDescription');
|
||||
var errorEl = document.getElementById('variantError');
|
||||
|
||||
saveBtn.addEventListener('click', function() {
|
||||
var name = nameInput.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
var boardGameId = select.getAttribute('data-board-game-id');
|
||||
var ajaxUrl = select.getAttribute('data-ajax-url');
|
||||
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (!csrfToken) return;
|
||||
|
||||
errorEl.classList.add('d-none');
|
||||
|
||||
fetch(ajaxUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken.value,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
name: name,
|
||||
description: descInput.value.trim(),
|
||||
board_game_id: boardGameId,
|
||||
}),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
errorEl.textContent = data.error;
|
||||
errorEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
var opt = document.createElement('option');
|
||||
opt.value = data.id;
|
||||
opt.textContent = data.name;
|
||||
opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
var modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) modal.hide();
|
||||
nameInput.value = '';
|
||||
descInput.value = '';
|
||||
})
|
||||
.catch(function() {
|
||||
errorEl.textContent = 'Failed to create variant';
|
||||
errorEl.classList.remove('d-none');
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
0
vrobbler/apps/boardgames/tests/__init__.py
Normal file
0
vrobbler/apps/boardgames/tests/__init__.py
Normal file
226
vrobbler/apps/boardgames/tests/test_models.py
Normal file
226
vrobbler/apps/boardgames/tests/test_models.py
Normal file
@ -0,0 +1,226 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from boardgames.models import BoardGame, BoardGameVariant
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_creation():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name="Test Variant",
|
||||
board_game=game,
|
||||
description="A test variant",
|
||||
)
|
||||
assert variant.name == "Test Variant"
|
||||
assert variant.board_game == game
|
||||
assert variant.description == "A test variant"
|
||||
assert variant.uuid is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_str():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name="Test Variant",
|
||||
board_game=game,
|
||||
)
|
||||
assert str(variant) == "Test Variant (Test Game)"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_optional_description():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name="Test Variant",
|
||||
board_game=game,
|
||||
)
|
||||
assert variant.description is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_related_name():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant1 = BoardGameVariant.objects.create(
|
||||
name="Variant 1",
|
||||
board_game=game,
|
||||
)
|
||||
variant2 = BoardGameVariant.objects.create(
|
||||
name="Variant 2",
|
||||
board_game=game,
|
||||
)
|
||||
assert list(game.variants.all()) == [variant1, variant2]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_cascade_delete():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name="Test Variant",
|
||||
board_game=game,
|
||||
)
|
||||
game.delete()
|
||||
assert BoardGameVariant.objects.count() == 0
|
||||
|
||||
|
||||
def _mock_bgg_game(bggeek_id, title, expansions=None):
|
||||
"""Build a fake BGG game object shape used by lookup_boardgame_from_bgg."""
|
||||
class FakeGame:
|
||||
id = bggeek_id
|
||||
name = title
|
||||
description = f"Description of {title}"
|
||||
yearpublished = 2020
|
||||
image = "https://example.com/cover.jpg"
|
||||
minplayers = 1
|
||||
maxplayers = 4
|
||||
minage = 8
|
||||
rating_average = 7.5
|
||||
bgg_rank = 100
|
||||
playingtime = 60
|
||||
mechanics = []
|
||||
categories = []
|
||||
families = []
|
||||
designers = []
|
||||
publishers = []
|
||||
|
||||
@property
|
||||
def expansions(self):
|
||||
if expansions is None:
|
||||
return []
|
||||
return expansions
|
||||
|
||||
return FakeGame()
|
||||
|
||||
|
||||
def _mock_bgg_client(return_game):
|
||||
"""Return a callable that creates a fake BGGClient instance."""
|
||||
class FakeBGGClient:
|
||||
def __init__(self, access_token=None):
|
||||
pass
|
||||
|
||||
def game(self, game_id=None, name=None):
|
||||
return return_game
|
||||
|
||||
return FakeBGGClient()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_find_or_create_links_expansions(mock_bgg, mock_get):
|
||||
exp1 = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
exp2 = type("Thing", (), {"id": 201, "name": "Expansion 2"})()
|
||||
base_game = _mock_bgg_game("100", "Base Game", expansions=[exp1, exp2])
|
||||
exp1_game = _mock_bgg_game("200", "Expansion 1")
|
||||
exp2_game = _mock_bgg_game("201", "Expansion 2")
|
||||
mock_bgg.side_effect = [
|
||||
_mock_bgg_client(base_game),
|
||||
_mock_bgg_client(exp1_game),
|
||||
_mock_bgg_client(exp2_game),
|
||||
]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
game = BoardGame.find_or_create("100")
|
||||
assert game.title == "Base Game"
|
||||
|
||||
expansions = BoardGame.objects.filter(expansion_for_boardgame=game)
|
||||
assert expansions.count() == 2
|
||||
assert {e.title for e in expansions} == {"Expansion 1", "Expansion 2"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_find_or_create_skips_expansions_for_existing_game(mock_bgg):
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
mock_bgg.assert_not_called()
|
||||
|
||||
result = BoardGame.find_or_create("100")
|
||||
assert result == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_fetch_and_link_expansions(mock_bgg, mock_get):
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_game = _mock_bgg_game("200", "Expansion 1")
|
||||
mock_bgg.side_effect = [_mock_bgg_client(exp_game)]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
fetch_and_link_expansions(game, [{"id": 200, "name": "Expansion 1"}])
|
||||
|
||||
expansion = BoardGame.objects.get(bggeek_id="200")
|
||||
assert expansion.expansion_for_boardgame == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_fix_metadata_links_expansions(mock_bgg, mock_get):
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
|
||||
exp_game = _mock_bgg_game("200", "Expansion 1")
|
||||
# first call = fix_metadata -> lookup_boardgame_from_bgg(lookup_id='100')
|
||||
# second call = find_or_create inside fetch_and_link_expansions for expansion
|
||||
mock_bgg.side_effect = [
|
||||
_mock_bgg_client(base_with_exp),
|
||||
_mock_bgg_client(exp_game),
|
||||
]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
game.fix_metadata(force_update=True)
|
||||
|
||||
expansion = BoardGame.objects.get(bggeek_id="200")
|
||||
assert expansion.expansion_for_boardgame == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_management_command_fetch_expansions_dry_run(mock_bgg, capsys):
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
|
||||
mock_bgg.side_effect = [_mock_bgg_client(base_with_exp)]
|
||||
|
||||
call_command("fetch_expansions")
|
||||
captured = capsys.readouterr()
|
||||
assert "Would link 1 expansions" in captured.out
|
||||
|
||||
assert BoardGame.objects.filter(bggeek_id="200").count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_management_command_fetch_expansions_commit(mock_bgg, mock_get, capsys):
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
exp_game = _mock_bgg_game("200", "Expansion 1")
|
||||
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
|
||||
mock_bgg.side_effect = [
|
||||
_mock_bgg_client(base_with_exp),
|
||||
_mock_bgg_client(exp_game),
|
||||
]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
call_command("fetch_expansions", commit=True)
|
||||
captured = capsys.readouterr()
|
||||
assert "Updated 1 games" in captured.out
|
||||
|
||||
expansion = BoardGame.objects.get(bggeek_id="200")
|
||||
assert expansion.expansion_for_boardgame == game
|
||||
106
vrobbler/apps/boardgames/tests/test_utils.py
Normal file
106
vrobbler/apps/boardgames/tests/test_utils.py
Normal file
@ -0,0 +1,106 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from boardgames.models import BoardGame, BoardGameVariant
|
||||
from boardgames.utils import board_names_to_variants
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_creates_variant():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
ids = board_names_to_variants(game, ["Map A"])
|
||||
assert len(ids) == 1
|
||||
variant = BoardGameVariant.objects.get(id=ids[0])
|
||||
assert variant.name == "Map A"
|
||||
assert variant.board_game == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_reuses_existing():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
existing = BoardGameVariant.objects.create(
|
||||
name="Map A", board_game=game
|
||||
)
|
||||
ids = board_names_to_variants(game, ["Map A"])
|
||||
assert len(ids) == 1
|
||||
assert ids[0] == existing.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_multiple_names():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
ids = board_names_to_variants(game, ["Map A", "Map B"])
|
||||
assert len(ids) == 2
|
||||
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
|
||||
assert names == {"Map A", "Map B"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_splits_fullwidth_slash():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
ids = board_names_to_variants(game, ["Map A/Map B"])
|
||||
assert len(ids) == 2
|
||||
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
|
||||
assert names == {"Map A", "Map B"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_skips_empty_parts():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
ids = board_names_to_variants(game, ["/Map A/"])
|
||||
assert len(ids) == 1
|
||||
assert BoardGameVariant.objects.get(id=ids[0]).name == "Map A"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_different_games_independent():
|
||||
game1 = BoardGame.objects.create(title="Game 1")
|
||||
game2 = BoardGame.objects.create(title="Game 2")
|
||||
ids1 = board_names_to_variants(game1, ["Map A"])
|
||||
ids2 = board_names_to_variants(game2, ["Map A"])
|
||||
assert ids1 != ids2
|
||||
assert BoardGameVariant.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_management_command_dry_run(capsys):
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
user = User.objects.create(username="tester")
|
||||
scrobble = Scrobble.objects.create(
|
||||
user=user,
|
||||
board_game=game,
|
||||
log={"board": "Map A"},
|
||||
)
|
||||
|
||||
call_command("convert_board_to_variants")
|
||||
captured = capsys.readouterr()
|
||||
assert "Would update 1 scrobbles" in captured.out
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert "board" in scrobble.log
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_management_command_commit():
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
user = User.objects.create(username="tester")
|
||||
scrobble = Scrobble.objects.create(
|
||||
user=user,
|
||||
board_game=game,
|
||||
log={"board": "Map A"},
|
||||
)
|
||||
|
||||
call_command("convert_board_to_variants", commit=True)
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert "board" not in scrobble.log
|
||||
assert "variant_ids" in scrobble.log
|
||||
variant = BoardGameVariant.objects.get(board_game=game, name="Map A")
|
||||
assert variant.id in scrobble.log["variant_ids"]
|
||||
@ -20,4 +20,9 @@ urlpatterns = [
|
||||
views.BoardGamePublisherDetailView.as_view(),
|
||||
name="publisher_detail",
|
||||
),
|
||||
path(
|
||||
"variants/ajax-create/",
|
||||
views.ajax_create_variant,
|
||||
name="ajax-create-variant",
|
||||
),
|
||||
]
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from boardgames.models import BoardGame, BoardGameVariant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def board_names_to_variants(
|
||||
board_game: BoardGame, board_names: list[str]
|
||||
) -> list[int]:
|
||||
"""Given a board game and a list of board/scenario names, find or create
|
||||
BoardGameVariant records and return their IDs.
|
||||
|
||||
Splits each name on the full-width slash ``/`` so that a single field
|
||||
containing ``Map A/Map B`` produces two separate variants.
|
||||
"""
|
||||
variant_ids: list[int] = []
|
||||
for raw_name in board_names:
|
||||
for part in raw_name.split("/"):
|
||||
name = part.strip()
|
||||
if not name:
|
||||
continue
|
||||
variant, was_created = BoardGameVariant.objects.get_or_create(
|
||||
board_game=board_game,
|
||||
name=name,
|
||||
)
|
||||
logger.debug(
|
||||
"Resolved board variant",
|
||||
extra={
|
||||
"board_game_id": board_game.id,
|
||||
"variant_name": name,
|
||||
"variant_id": variant.id,
|
||||
"was_created": was_created,
|
||||
},
|
||||
)
|
||||
variant_ids.append(variant.id)
|
||||
return variant_ids
|
||||
|
||||
|
||||
def fetch_and_link_expansions(
|
||||
board_game: BoardGame, expansions_data: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Given a board game and a list of expansion dicts (with 'id' and 'name'),
|
||||
find or create each expansion BoardGame and link it via expansion_for_boardgame.
|
||||
"""
|
||||
if board_game.skip_expansions:
|
||||
logger.info(
|
||||
"Skipping expansion fetch for board game with skip_expansions=True",
|
||||
extra={"board_game_id": board_game.id, "title": board_game.title},
|
||||
)
|
||||
return
|
||||
|
||||
for exp_data in expansions_data:
|
||||
exp_id = exp_data.get("id")
|
||||
if not exp_id:
|
||||
continue
|
||||
expansion = BoardGame.find_or_create(str(exp_id))
|
||||
if expansion and expansion.id != board_game.id:
|
||||
expansion.expansion_for_boardgame = board_game
|
||||
expansion.save(update_fields=["expansion_for_boardgame"])
|
||||
logger.info(
|
||||
"Linked expansion to board game",
|
||||
extra={
|
||||
"board_game_id": board_game.id,
|
||||
"expansion_id": expansion.id,
|
||||
"expansion_name": expansion.title,
|
||||
},
|
||||
)
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import datetime
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views import generic
|
||||
from django.views.decorators.http import require_POST
|
||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.views import (
|
||||
@ -10,6 +13,38 @@ from scrobbles.views import (
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
def ajax_create_variant(request):
|
||||
name = request.POST.get("name", "").strip()
|
||||
board_game_id = request.POST.get("board_game_id")
|
||||
description = request.POST.get("description", "").strip()
|
||||
|
||||
if not name or not board_game_id:
|
||||
return JsonResponse({"error": "Name and board game are required"}, status=400)
|
||||
|
||||
try:
|
||||
board_game_id = int(board_game_id)
|
||||
except (ValueError, TypeError):
|
||||
return JsonResponse({"error": "Invalid board game"}, status=400)
|
||||
|
||||
from boardgames.models import BoardGameVariant
|
||||
|
||||
variant = BoardGameVariant.objects.filter(
|
||||
name__iexact=name,
|
||||
board_game_id=board_game_id,
|
||||
).first()
|
||||
if variant:
|
||||
return JsonResponse({"id": variant.id, "name": variant.name})
|
||||
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name=name,
|
||||
board_game_id=board_game_id,
|
||||
description=description or None,
|
||||
)
|
||||
|
||||
return JsonResponse({"id": variant.id, "name": variant.name})
|
||||
|
||||
|
||||
class BoardGameListView(ScrobbleableListView):
|
||||
model = BoardGame
|
||||
|
||||
|
||||
9
vrobbler/apps/boardgames/widgets.py
Normal file
9
vrobbler/apps/boardgames/widgets.py
Normal file
@ -0,0 +1,9 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class VariantSelectWidget(forms.SelectMultiple):
|
||||
template_name = "boardgames/widgets/variant_select.html"
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
return context
|
||||
@ -1,8 +1,19 @@
|
||||
from books.models import Author, Book, Paper
|
||||
from books.models import Author, Book, Journal, Paper
|
||||
from django.contrib import admin
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Journal)
|
||||
class JournalAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"website_url",
|
||||
)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(Author)
|
||||
class AuthorAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
@ -27,6 +38,7 @@ class BookAdmin(admin.ModelAdmin):
|
||||
"first_publish_year",
|
||||
"pages",
|
||||
)
|
||||
raw_id_fields = ("authors",)
|
||||
search_fields = ("name",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
@ -34,11 +46,11 @@ class BookAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
def issue_or_volume(self, obj):
|
||||
return obj.issue_number or obj.volume_number
|
||||
return obj.subtitle
|
||||
|
||||
|
||||
@admin.register(Paper)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
class PaperAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
@ -47,6 +59,7 @@ class BookAdmin(admin.ModelAdmin):
|
||||
"first_publish_year",
|
||||
"pages",
|
||||
)
|
||||
raw_id_fields = ("authors",)
|
||||
search_fields = ("name",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
|
||||
@ -1,128 +0,0 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from bs4 import BeautifulSoup
|
||||
import requests
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_AGENT = "Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
|
||||
AMAZON_SEARCH_URL = "https://www.amazon.com/s?k={amazon_id}"
|
||||
|
||||
|
||||
class AmazonAttribute(Enum):
|
||||
SERIES = 0
|
||||
PAGES = 1
|
||||
LANGUAGE = 2
|
||||
PUBLISHER = 3
|
||||
PUB_DATE = 4
|
||||
DIMENSIONS = 5
|
||||
ISBN_10 = 6
|
||||
ISBN_13 = 7
|
||||
|
||||
|
||||
def strip_and_clean(text):
|
||||
return text.strip("\n").rstrip().lstrip()
|
||||
|
||||
|
||||
def get_rating_from_soup(soup) -> Optional[int]:
|
||||
rating = None
|
||||
try:
|
||||
potential_rating = soup.find("div", class_="allmusic-rating")
|
||||
if potential_rating:
|
||||
rating = int(strip_and_clean(potential_rating.get_text()))
|
||||
except ValueError:
|
||||
pass
|
||||
return rating
|
||||
|
||||
|
||||
def get_review_from_soup(soup) -> str:
|
||||
review = ""
|
||||
try:
|
||||
potential_text = soup.find("div", class_="text")
|
||||
if potential_text:
|
||||
review = strip_and_clean(potential_text.get_text())
|
||||
except ValueError:
|
||||
pass
|
||||
return review
|
||||
|
||||
|
||||
def scrape_data_from_amazon(url) -> dict:
|
||||
data_dict = {}
|
||||
headers = {"User-Agent": USER_AGENT}
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
# TODO Fix this scraper
|
||||
data_dict["rating"] = get_rating_from_soup(soup)
|
||||
data_dict["review"] = get_review_from_soup(soup)
|
||||
return data_dict
|
||||
|
||||
|
||||
def get_amazon_product_dict(amazon_id: str) -> dict:
|
||||
data_dict = {}
|
||||
url = ""
|
||||
|
||||
search_url = AMAZON_SEARCH_URL.format(amazon_id=amazon_id)
|
||||
headers = {
|
||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
||||
"accept-language": "en-GB,en;q=0.9",
|
||||
}
|
||||
|
||||
response = requests.get(search_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.info(f"Bad http response from Amazon {response}")
|
||||
return data_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
results = soup.find("a", class_="a-link-normal")
|
||||
|
||||
if not results:
|
||||
logger.info(f"No search results for {amazon_id}")
|
||||
return data_dict
|
||||
|
||||
product_url = "https://www.amazon.com" + str(results.get("href", ""))
|
||||
|
||||
data_dict = {}
|
||||
response = requests.get(product_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.info(f"Bad http response from Amazon {response}")
|
||||
return data_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
try:
|
||||
data_dict["title"] = soup.findAll("span", class_="celwidget")[1].text.strip()
|
||||
data_dict["cover_url"] = soup.find("img", class_="frontImage").get("src")
|
||||
data_dict["summary"] = soup.findAll("div", class_="a-expander-content")[1].text
|
||||
meta = soup.findAll("div", class_="rpi-attribute-value")
|
||||
data_dict["isbn"] = meta[AmazonAttribute.ISBN_10.value].text.strip()
|
||||
pages = meta[AmazonAttribute.PAGES.value].text
|
||||
if "pages" in pages:
|
||||
data_dict["pages"] = (
|
||||
meta[AmazonAttribute.PAGES.value].text.split("pages")[0].strip()
|
||||
)
|
||||
except IndexError as e:
|
||||
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
|
||||
except AttributeError as e:
|
||||
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
|
||||
|
||||
return data_dict
|
||||
|
||||
|
||||
def lookup_book_from_amazon(amazon_id: str) -> dict:
|
||||
top = {}
|
||||
|
||||
return {
|
||||
"title": top.get("title"),
|
||||
"isbn": isbn,
|
||||
"openlibrary_id": ol_id,
|
||||
"goodreads_id": get_first("id_goodreads", top),
|
||||
"first_publish_year": top.get("first_publish_year"),
|
||||
"first_sentence": first_sentence,
|
||||
"pages": top.get("number_of_pages_median", None),
|
||||
"cover_url": COVER_URL.format(id=ol_id),
|
||||
"ol_author_id": ol_author_id,
|
||||
"subject_key_list": top.get("subject_key", []),
|
||||
}
|
||||
@ -17,6 +17,7 @@ class MediaSourceTag(str, Enum):
|
||||
LOCG = "source_locg"
|
||||
KOREADER = "source_koreader"
|
||||
SEMANTIC_SCHOLAR = "source_semantic_scholar"
|
||||
AMAZON = "source_amazon"
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
from books.constants import BOOKS_TITLES_TO_IGNORE
|
||||
@ -174,7 +173,7 @@ def build_book_map(rows) -> dict:
|
||||
return book_id_map
|
||||
|
||||
|
||||
def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
||||
def build_page_data(page_rows: list, book_map: dict) -> dict:
|
||||
"""Given rows of page data from KoReader, parse each row and build
|
||||
scrobbles for our user, loading the page data into the page_data
|
||||
field on the scrobble instance.
|
||||
@ -187,18 +186,20 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
||||
book_ids_not_found.append(koreader_book_id)
|
||||
continue
|
||||
|
||||
if "pages" not in book_map[koreader_book_id].keys():
|
||||
book_map[koreader_book_id]["pages"] = {}
|
||||
book_map[koreader_book_id].setdefault("pages", [])
|
||||
|
||||
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
||||
duration = page_row[KoReaderPageStatColumn.DURATION.value]
|
||||
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
||||
|
||||
book_map[koreader_book_id]["pages"][page_number] = {
|
||||
"duration": duration,
|
||||
"start_ts": start_ts,
|
||||
"end_ts": start_ts + duration,
|
||||
}
|
||||
book_map[koreader_book_id]["pages"].append(
|
||||
{
|
||||
"page_number": page_number,
|
||||
"duration": duration,
|
||||
"start_ts": start_ts,
|
||||
"end_ts": start_ts + duration,
|
||||
}
|
||||
)
|
||||
if book_ids_not_found:
|
||||
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
|
||||
return book_map
|
||||
@ -216,7 +217,6 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
pages_not_found.append(book_id)
|
||||
continue
|
||||
|
||||
should_create_scrobble = False
|
||||
scrobble_page_data = {}
|
||||
playback_position_seconds = 0
|
||||
prev_page_stats = {}
|
||||
@ -225,11 +225,12 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
pages_processed = 0
|
||||
total_pages_read = len(book_map[koreader_book_id]["pages"])
|
||||
ordered_pages = sorted(
|
||||
book_map[koreader_book_id]["pages"].items(),
|
||||
key=lambda x: x[1]["start_ts"],
|
||||
book_map[koreader_book_id]["pages"],
|
||||
key=lambda x: x["start_ts"],
|
||||
)
|
||||
|
||||
for cur_page_number, stats in ordered_pages:
|
||||
for stats in ordered_pages:
|
||||
page_number = stats["page_number"]
|
||||
pages_processed += 1
|
||||
|
||||
seconds_from_last_page = 0
|
||||
@ -243,46 +244,52 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
)
|
||||
|
||||
end_of_reading = pages_processed == total_pages_read
|
||||
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
|
||||
big_jump_to_this_page = (page_number - last_page_number) > 10
|
||||
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
||||
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
|
||||
should_create_scrobble = True
|
||||
should_create_scrobble = (
|
||||
is_session_gap and not big_jump_to_this_page
|
||||
) or end_of_reading
|
||||
|
||||
# Always accumulate the current page first
|
||||
scrobble_page_data[page_number] = stats
|
||||
|
||||
if should_create_scrobble:
|
||||
# For end-of-reading, the current page is already accumulated
|
||||
# and belongs in this scrobble. For a session gap, remove the
|
||||
# current page from this scrobble — it starts a new session.
|
||||
if is_session_gap and not end_of_reading:
|
||||
del scrobble_page_data[page_number]
|
||||
|
||||
scrobble_page_data = dict(
|
||||
sorted(
|
||||
scrobble_page_data.items(),
|
||||
key=lambda x: x[1]["start_ts"],
|
||||
)
|
||||
)
|
||||
try:
|
||||
first_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[0]
|
||||
)
|
||||
last_page = scrobble_page_data.get(
|
||||
list(scrobble_page_data.keys())[-1]
|
||||
)
|
||||
except IndexError:
|
||||
|
||||
if not scrobble_page_data:
|
||||
logger.error(
|
||||
"Could not process book, no page data found",
|
||||
extra={"scrobble_page_data": scrobble_page_data},
|
||||
)
|
||||
continue
|
||||
|
||||
first_page = next(iter(scrobble_page_data.values()))
|
||||
last_page = scrobble_page_data[
|
||||
list(scrobble_page_data.keys())[-1]
|
||||
]
|
||||
|
||||
timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(int(first_page.get("start_ts")))
|
||||
datetime.fromtimestamp(
|
||||
int(first_page.get("start_ts"))
|
||||
)
|
||||
)
|
||||
stop_timestamp = user.profile.get_timestamp_with_tz(
|
||||
datetime.fromtimestamp(int(last_page.get("end_ts")))
|
||||
datetime.fromtimestamp(
|
||||
int(last_page.get("end_ts"))
|
||||
)
|
||||
)
|
||||
|
||||
# Adjust for Daylight Saving Time
|
||||
# if timestamp.dst() == timedelta(
|
||||
# 0
|
||||
# ) or stop_timestamp.dst() == timedelta(0):
|
||||
# timestamp = timestamp - timedelta(hours=1)
|
||||
# stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||
|
||||
scrobble = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
book_id=book_id,
|
||||
@ -291,7 +298,7 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
|
||||
if not scrobble:
|
||||
logger.info(
|
||||
f"Queueing scrobble for {book_id}, page {cur_page_number}"
|
||||
f"Queueing scrobble for {book_id}, page {page_number}"
|
||||
)
|
||||
log_data = {
|
||||
"koreader_hash": book_dict.get("hash"),
|
||||
@ -318,16 +325,18 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
timezone=tz,
|
||||
)
|
||||
)
|
||||
# Then start over
|
||||
should_create_scrobble = False
|
||||
# Then start over for the next session
|
||||
playback_position_seconds = 0
|
||||
scrobble_page_data = {}
|
||||
|
||||
# We accumulate pages for the scrobble until we should create a new one
|
||||
scrobble_page_data[cur_page_number] = stats
|
||||
# For session gaps, re-add the current page as the
|
||||
# beginning of the next session's accumulation
|
||||
if is_session_gap and not end_of_reading:
|
||||
scrobble_page_data[page_number] = stats
|
||||
|
||||
last_page_number = cur_page_number
|
||||
last_page_number = page_number
|
||||
prev_page_stats = stats
|
||||
|
||||
if pages_not_found:
|
||||
logger.info(f"Pages not found for books: {set(pages_not_found)}")
|
||||
return scrobbles_to_create
|
||||
@ -335,13 +344,16 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
|
||||
|
||||
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
|
||||
"""Given a list of scrobbles, update pages read, long play seconds and check
|
||||
for media completion"""
|
||||
for media completion.
|
||||
|
||||
Uses the long_play_last_scrobble FK chain to accumulate time.
|
||||
Consider using the recompute_long_play_seconds management command instead.
|
||||
"""
|
||||
|
||||
for scrobble in scrobbles:
|
||||
# But if there's a next scrobble, set pages read to their starting page
|
||||
if scrobble.previous and not scrobble.previous.long_play_complete:
|
||||
if scrobble.long_play_last_scrobble and not scrobble.long_play_last_scrobble.long_play_complete:
|
||||
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
|
||||
scrobble.previous.long_play_seconds or 0
|
||||
scrobble.long_play_last_scrobble.long_play_seconds or 0
|
||||
)
|
||||
else:
|
||||
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
||||
@ -357,9 +369,6 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
|
||||
new_scrobbles = []
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
tz = ZoneInfo("UTC")
|
||||
if user:
|
||||
tz = user.profile.tzinfo
|
||||
|
||||
is_os_file = "https://" not in file_path
|
||||
if is_os_file:
|
||||
@ -375,7 +384,6 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
book_map = build_page_data(
|
||||
cur.execute("SELECT * from page_stat_data ORDER BY id_book, start_time"),
|
||||
book_map,
|
||||
tz,
|
||||
)
|
||||
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||
else:
|
||||
@ -392,7 +400,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
_sqlite_bytes(file_path), max_buffer_size=1_048_576
|
||||
):
|
||||
if table_name == "page_stat_data":
|
||||
book_map = build_page_data(rows, book_map, tz)
|
||||
book_map = build_page_data(rows, book_map)
|
||||
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||
|
||||
logger.info(f"Creating {len(new_scrobbles)} new scrobbles")
|
||||
|
||||
310
vrobbler/apps/books/management/commands/cleanup_book_metadata.py
Normal file
310
vrobbler/apps/books/management/commands/cleanup_book_metadata.py
Normal file
@ -0,0 +1,310 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from books.constants import READCOMICSONLINE_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MISSING_ALL = [
|
||||
"cover",
|
||||
"summary",
|
||||
"isbn",
|
||||
"pages",
|
||||
"language",
|
||||
"publisher",
|
||||
"publish_year",
|
||||
]
|
||||
|
||||
def _cover_missing_or_broken(book) -> bool:
|
||||
if not bool(book.cover):
|
||||
return True
|
||||
try:
|
||||
return not book.cover.storage.exists(book.cover.name)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
MISSING_GROUPS = {
|
||||
"cover": _cover_missing_or_broken,
|
||||
"summary": lambda b: not b.summary,
|
||||
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
|
||||
"pages": lambda b: b.pages is None,
|
||||
"language": lambda b: not b.language,
|
||||
"publisher": lambda b: not b.publisher,
|
||||
"publish_year": lambda b: b.first_publish_year is None,
|
||||
}
|
||||
|
||||
|
||||
def _book_matches(book, flags):
|
||||
if not flags:
|
||||
return False
|
||||
for flag in flags:
|
||||
fn = MISSING_GROUPS.get(flag)
|
||||
if fn and fn(book):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill missing metadata on books from Google Books, OpenLibrary, and ComicVine"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of books to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Seconds to sleep between API calls (default: 0.5)",
|
||||
)
|
||||
for flag in MISSING_ALL:
|
||||
parser.add_argument(
|
||||
f"--missing-{flag}",
|
||||
dest="missing_flags",
|
||||
action="append_const",
|
||||
const=flag,
|
||||
help=f"Process books missing {flag}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--comics-only",
|
||||
action="store_true",
|
||||
help="Only process books with a readcomicsonline.ru URL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
dest="all_missing",
|
||||
help="Process books missing any metadata field",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from books.models import Book
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
sleep_secs = options["sleep"]
|
||||
flags = options.get("missing_flags") or []
|
||||
comics_only = options["comics_only"]
|
||||
all_missing = options["all_missing"]
|
||||
|
||||
if all_missing:
|
||||
flags = MISSING_ALL
|
||||
|
||||
if not flags and not comics_only:
|
||||
self.stdout.write(
|
||||
"No filters specified. Use --all, --missing-*, or --comics-only."
|
||||
)
|
||||
return
|
||||
|
||||
qs = Book.objects.all()
|
||||
if comics_only:
|
||||
qs = qs.filter(readcomics_url__isnull=False)
|
||||
|
||||
if flags:
|
||||
qs = [b for b in qs.iterator() if _book_matches(b, flags)]
|
||||
else:
|
||||
qs = list(qs)
|
||||
|
||||
total = len(qs)
|
||||
self.stdout.write(f"Found {total} books to process")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no API calls will be made. Use --commit to run lookups."
|
||||
)
|
||||
return
|
||||
|
||||
enriched = 0
|
||||
skipped = 0
|
||||
stats = {
|
||||
"cover_fixed": 0,
|
||||
"summary_fixed": 0,
|
||||
"isbn_fixed": 0,
|
||||
"pages_fixed": 0,
|
||||
"language_fixed": 0,
|
||||
"publisher_fixed": 0,
|
||||
"publish_year_fixed": 0,
|
||||
}
|
||||
|
||||
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
|
||||
batch = qs[offset : offset + batch_size]
|
||||
for book in batch:
|
||||
result = self._enrich_book(book, sleep_secs)
|
||||
if result:
|
||||
enriched += 1
|
||||
for key in stats:
|
||||
if result.get(key):
|
||||
stats[key] += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
||||
f"enriched: {enriched}, skipped: {skipped}"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Books enriched: {enriched}\n"
|
||||
f" Books skipped: {skipped}\n"
|
||||
f" Covers fixed: {stats['cover_fixed']}\n"
|
||||
f" Summaries fixed:{stats['summary_fixed']}\n"
|
||||
f" ISBNs fixed: {stats['isbn_fixed']}\n"
|
||||
f" Pages fixed: {stats['pages_fixed']}\n"
|
||||
f" Languages fixed:{stats['language_fixed']}\n"
|
||||
f" Publishers fixed:{stats['publisher_fixed']}\n"
|
||||
f" Publish yrs fixed: {stats['publish_year_fixed']}"
|
||||
)
|
||||
|
||||
def _enrich_book(self, book, sleep_secs):
|
||||
from books.sources.comicvine import (
|
||||
lookup_comic_from_comicvine,
|
||||
lookup_issue_by_comicvine_id,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.openlibrary import lookup_book_from_openlibrary as lookup_book_from_ol
|
||||
|
||||
title = book.original_title or book.title
|
||||
author_name = book.author.name if book.author else None
|
||||
book_dict = {}
|
||||
|
||||
cv_data = None
|
||||
if book.comicvine_id:
|
||||
cv_data = lookup_issue_by_comicvine_id(str(book.comicvine_id))
|
||||
if not cv_data:
|
||||
cv_data = lookup_comic_from_comicvine(title)
|
||||
if cv_data:
|
||||
book_dict.update(cv_data)
|
||||
|
||||
ol_data = lookup_book_from_ol(title, author=author_name)
|
||||
time.sleep(sleep_secs)
|
||||
google_data = lookup_book_from_google(title)
|
||||
|
||||
if ol_data:
|
||||
for k, v in ol_data.items():
|
||||
book_dict.setdefault(k, v)
|
||||
|
||||
if google_data:
|
||||
for k, v in google_data.items():
|
||||
if v:
|
||||
book_dict.setdefault(k, v)
|
||||
|
||||
if not book_dict:
|
||||
return None
|
||||
|
||||
changed = self._apply(book, book_dict, title)
|
||||
return changed
|
||||
|
||||
def _apply(self, book, data, title):
|
||||
changed = {
|
||||
"cover_fixed": False,
|
||||
"summary_fixed": False,
|
||||
"isbn_fixed": False,
|
||||
"pages_fixed": False,
|
||||
"language_fixed": False,
|
||||
"publisher_fixed": False,
|
||||
"publish_year_fixed": False,
|
||||
}
|
||||
|
||||
update_fields = []
|
||||
|
||||
cover_url = data.pop("cover_url", "")
|
||||
|
||||
if data.get("summary") and not book.summary:
|
||||
book.summary = data["summary"]
|
||||
update_fields.append("summary")
|
||||
changed["summary_fixed"] = True
|
||||
|
||||
if data.get("isbn_13") and not book.isbn_13:
|
||||
book.isbn_13 = data["isbn_13"]
|
||||
update_fields.append("isbn_13")
|
||||
changed["isbn_fixed"] = True
|
||||
|
||||
if data.get("isbn_10") and not book.isbn_10:
|
||||
book.isbn_10 = data["isbn_10"]
|
||||
update_fields.append("isbn_10")
|
||||
changed["isbn_fixed"] = True
|
||||
|
||||
if data.get("pages") and book.pages is None:
|
||||
book.pages = data["pages"]
|
||||
update_fields.append("pages")
|
||||
changed["pages_fixed"] = True
|
||||
|
||||
if data.get("language") and not book.language:
|
||||
book.language = data["language"]
|
||||
update_fields.append("language")
|
||||
changed["language_fixed"] = True
|
||||
|
||||
if data.get("publisher") and not book.publisher:
|
||||
book.publisher = data["publisher"]
|
||||
update_fields.append("publisher")
|
||||
changed["publisher_fixed"] = True
|
||||
|
||||
if data.get("first_publish_year") and book.first_publish_year is None:
|
||||
book.first_publish_year = data["first_publish_year"]
|
||||
update_fields.append("first_publish_year")
|
||||
changed["publish_year_fixed"] = True
|
||||
|
||||
if data.get("openlibrary_id") and not book.openlibrary_id:
|
||||
book.openlibrary_id = data["openlibrary_id"]
|
||||
update_fields.append("openlibrary_id")
|
||||
|
||||
if data.get("comicvine_id") and not book.comicvine_id:
|
||||
book.comicvine_id = data["comicvine_id"]
|
||||
update_fields.append("comicvine_id")
|
||||
|
||||
if data.get("issue_number") and book.issue_number is None:
|
||||
book.issue_number = data["issue_number"]
|
||||
update_fields.append("issue_number")
|
||||
|
||||
if data.get("volume_number") and book.volume_number is None:
|
||||
book.volume_number = data["volume_number"]
|
||||
update_fields.append("volume_number")
|
||||
|
||||
if data.get("volume") and not book.volume:
|
||||
book.volume = data["volume"]
|
||||
update_fields.append("volume")
|
||||
|
||||
if data.get("volume_comicvine_id") and not book.volume_comicvine_id:
|
||||
book.volume_comicvine_id = data["volume_comicvine_id"]
|
||||
update_fields.append("volume_comicvine_id")
|
||||
|
||||
if update_fields:
|
||||
book.save(update_fields=update_fields)
|
||||
self.stdout.write(f" [ENRICHED] {book} — {', '.join(update_fields)}")
|
||||
|
||||
if cover_url and not book.cover:
|
||||
book.save_image_from_url(cover_url)
|
||||
if book.cover:
|
||||
changed["cover_fixed"] = True
|
||||
self.stdout.write(f" [COVER] {book} — cover saved from source")
|
||||
|
||||
genres = data.pop("genres", data.pop("generes", []))
|
||||
if genres:
|
||||
existing = set(book.genre.names())
|
||||
new_genres = [g for g in genres if g not in existing]
|
||||
if new_genres:
|
||||
book.genre.add(*new_genres)
|
||||
self.stdout.write(f" [GENRES] {book} — added {len(new_genres)} genres")
|
||||
|
||||
tags = data.pop("tags", [])
|
||||
if tags:
|
||||
existing_tags = set(book.tags.names())
|
||||
new_tags = [t for t in tags if t not in existing_tags]
|
||||
if new_tags:
|
||||
book.tags.add(*new_tags)
|
||||
self.stdout.write(f" [TAGS] {book} — added {', '.join(new_tags)}")
|
||||
|
||||
return changed if any(changed.values()) else None
|
||||
@ -0,0 +1,130 @@
|
||||
import logging
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from books.koreader import SESSION_GAP_SECONDS, fix_long_play_stats_for_scrobbles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
SESSION_GAP = timedelta(seconds=SESSION_GAP_SECONDS)
|
||||
|
||||
|
||||
def _page_data_keys(pages):
|
||||
return sorted(int(k) for k in (pages or {}))
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Merge orphaned 1-page KOReader scrobbles into the preceding scrobble"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
commit = options["commit"]
|
||||
|
||||
qs = Scrobble.objects.filter(
|
||||
media_type="Book", source="KOReader"
|
||||
).order_by("book_id", "timestamp")
|
||||
|
||||
if not qs.exists():
|
||||
self.stdout.write("No KOReader book scrobbles found.")
|
||||
return
|
||||
|
||||
merged = 0
|
||||
affected_books = set()
|
||||
|
||||
# Group by book_id manually since we're iterating in order
|
||||
book_scrobbles = {}
|
||||
for s in qs:
|
||||
book_scrobbles.setdefault(s.book_id, []).append(s)
|
||||
|
||||
if not commit:
|
||||
self.stdout.write("Dry run — no changes will be saved. Use --commit to apply.")
|
||||
|
||||
for book_id, scrobbles in book_scrobbles.items():
|
||||
batch_merged = 0
|
||||
i = 0
|
||||
while i < len(scrobbles) - 1:
|
||||
current = scrobbles[i]
|
||||
orphan = scrobbles[i + 1]
|
||||
|
||||
orphan_pages = orphan.logdata.page_data if orphan.logdata else {}
|
||||
orphan_keys = _page_data_keys(orphan_pages)
|
||||
if len(orphan_keys) != 1:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
current_pages = current.logdata.page_data if current.logdata else {}
|
||||
current_keys = _page_data_keys(current_pages)
|
||||
if not current_keys:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
orphan_page_num = orphan_keys[0]
|
||||
current_last_page = current_keys[-1]
|
||||
|
||||
if orphan_page_num != current_last_page + 1:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check that the orphan is close enough in time
|
||||
gap = orphan.timestamp - current.stop_timestamp
|
||||
if gap > SESSION_GAP:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Merge orphan into current
|
||||
current_pages[str(orphan_page_num)] = orphan_pages[str(orphan_page_num)]
|
||||
current.log["page_data"] = current_pages
|
||||
current.log["pages_read"] = len(current_pages)
|
||||
current.stop_timestamp = orphan.stop_timestamp
|
||||
current.playback_position_seconds += orphan.playback_position_seconds
|
||||
|
||||
affected_books.add(book_id)
|
||||
|
||||
if commit:
|
||||
with transaction.atomic():
|
||||
current.save(
|
||||
update_fields=[
|
||||
"log",
|
||||
"stop_timestamp",
|
||||
"playback_position_seconds",
|
||||
]
|
||||
)
|
||||
orphan.delete()
|
||||
|
||||
merged += 1
|
||||
batch_merged += 1
|
||||
scrobbles.pop(i + 1)
|
||||
|
||||
if batch_merged:
|
||||
self.stdout.write(
|
||||
f" Book {book_id}: merged {batch_merged} orphan scrobble(s)"
|
||||
)
|
||||
|
||||
self.stdout.write(f"\nTotal orphans merged: {merged}")
|
||||
|
||||
if commit and affected_books:
|
||||
self.stdout.write("Recalculating long_play_stats for affected books...")
|
||||
for book_id in affected_books:
|
||||
scrobbles_to_fix = (
|
||||
Scrobble.objects.filter(book_id=book_id, source="KOReader")
|
||||
.order_by("timestamp")
|
||||
)
|
||||
fix_long_play_stats_for_scrobbles(list(scrobbles_to_fix))
|
||||
|
||||
self.stdout.write(f"Fixed stats for {len(affected_books)} books.")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
f"\nWould merge {merged} orphan scrobble(s) across "
|
||||
f"{len(affected_books)} book(s)."
|
||||
)
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-15 16:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0036_alter_book_genre_alter_paper_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="volume",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="volume_comicvine_id",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/books/migrations/0038_paper_pdf_file.py
Normal file
18
vrobbler/apps/books/migrations/0038_paper_pdf_file.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-23 14:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0037_book_volume_book_volume_comicvine_id"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="paper",
|
||||
name="pdf_file",
|
||||
field=models.FileField(blank=True, null=True, upload_to="papers/pdf/"),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,92 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
def migrate_journal_data(apps, schema_editor):
|
||||
Paper = apps.get_model("books", "Paper")
|
||||
Journal = apps.get_model("books", "Journal")
|
||||
for paper in Paper.objects.all():
|
||||
old_journal = getattr(paper, "journal", None)
|
||||
if old_journal:
|
||||
journal, _ = Journal.objects.get_or_create(title=str(old_journal))
|
||||
paper._journal_tmp = journal
|
||||
paper.save(update_fields=["_journal_tmp"])
|
||||
|
||||
|
||||
def reverse_migrate_journal_data(apps, schema_editor):
|
||||
Paper = apps.get_model("books", "Paper")
|
||||
for paper in Paper.objects.all():
|
||||
if paper._journal_tmp:
|
||||
paper.journal = paper._journal_tmp.title
|
||||
paper.save(update_fields=["journal"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0038_paper_pdf_file"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Journal",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
("website_url", models.URLField(blank=True, max_length=500, null=True)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="paper",
|
||||
name="_journal_tmp",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="books.journal",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_journal_data, reverse_migrate_journal_data),
|
||||
migrations.RemoveField(
|
||||
model_name="paper",
|
||||
name="journal",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="paper",
|
||||
old_name="_journal_tmp",
|
||||
new_name="journal",
|
||||
),
|
||||
]
|
||||
@ -4,6 +4,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import requests
|
||||
from books.constants import MediaSourceTag, READCOMICSONLINE_URL
|
||||
@ -19,12 +20,18 @@ from books.openlibrary import (
|
||||
from books.sources.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
lookup_issue_by_comicvine_id,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.amazon import lookup_book_from_amazon
|
||||
from books.sources.openlibrary import (
|
||||
lookup_book_from_openlibrary as lookup_book_from_ol,
|
||||
)
|
||||
from books.sources.semantic import lookup_paper_from_semantic
|
||||
from books.sources.semantic import (
|
||||
lookup_paper_from_semantic,
|
||||
lookup_paper_from_semantic_by_doi,
|
||||
)
|
||||
from books.sources.scihub import SciHubService
|
||||
from books.utils import get_comic_issue_url
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -71,7 +78,7 @@ class BookLogData(BaseLogData, LongPlayLogData):
|
||||
_excluded_fields = {"koreader_hash", "page_data"}
|
||||
|
||||
def avg_seconds_per_page(self):
|
||||
if self.page_data:
|
||||
if self.page_data and isinstance(self.page_data, dict):
|
||||
total_duration = 0
|
||||
for page_num, stats in self.page_data.items():
|
||||
total_duration += stats.get("duration", 0)
|
||||
@ -79,6 +86,16 @@ class BookLogData(BaseLogData, LongPlayLogData):
|
||||
return int(total_duration / len(self.page_data))
|
||||
|
||||
|
||||
class Journal(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
website_url = models.URLField(max_length=500, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Author(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
@ -149,6 +166,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
first_sentence = models.TextField(**BNULL)
|
||||
# ComicVine
|
||||
comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
volume = models.CharField(max_length=255, **BNULL)
|
||||
volume_comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
next_readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
issue_number = models.IntegerField(**BNULL)
|
||||
@ -173,15 +192,32 @@ class Book(LongPlayScrobblableMixin):
|
||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.issue_number and "Issue" not in str(self.title):
|
||||
return f"{self.title} - Issue {self.issue_number}"
|
||||
if self.volume_number and "Volume" not in str(self.title):
|
||||
return f"{self.title} - Volume {self.volume_number}"
|
||||
return f"{self.title}"
|
||||
if not self.subtitle:
|
||||
return self.title
|
||||
|
||||
return f"{self.title} - {self.subtitle}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pages:
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return f" by {self.author}"
|
||||
subtitle_parts = []
|
||||
if self.author:
|
||||
subtitle_parts.append(self.author.name)
|
||||
if self.issue_number and "Issue" not in str(self.title):
|
||||
subtitle_parts.append(f"Issue {self.issue_number}")
|
||||
if self.volume_number and "Volume" not in str(self.title):
|
||||
subtitle_parts.append(f"Volume {self.volume_number}")
|
||||
if len(subtitle_parts) > 1:
|
||||
return " / ".join(subtitle_parts)
|
||||
if len(subtitle_parts) == 1:
|
||||
return subtitle_parts[0]
|
||||
return ""
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
@ -201,42 +237,34 @@ class Book(LongPlayScrobblableMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def resume_start_url(self):
|
||||
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid}) + "?resume=1"
|
||||
|
||||
@classmethod
|
||||
def get_from_comicvine(
|
||||
cls, title: str, overwrite: bool = False, force_new: bool = False
|
||||
) -> "Book":
|
||||
book, created = cls.objects.get_or_create(title=title)
|
||||
|
||||
if not created:
|
||||
if not created and not overwrite:
|
||||
return book
|
||||
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
if not book_dict:
|
||||
return book
|
||||
|
||||
if created or overwrite:
|
||||
author_list = []
|
||||
author_dicts = book_dict.pop("author_dicts")
|
||||
if author_dicts:
|
||||
for author_dict in author_dicts:
|
||||
if author_dict.get("authorId"):
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
semantic_id=author_dict.get("authorId")
|
||||
)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
author.name = author_dict.get("name")
|
||||
author.save()
|
||||
# TODO enrich author?
|
||||
...
|
||||
tags = book_dict.pop("tags", [])
|
||||
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
book.save()
|
||||
for k, v in book_dict.items():
|
||||
setattr(book, k, v)
|
||||
book.save()
|
||||
|
||||
if author_list:
|
||||
book.authors.add(*author_list)
|
||||
genres = book_dict.pop("genres", [])
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
if tags:
|
||||
book.tags.add(*tags)
|
||||
return book
|
||||
|
||||
@classmethod
|
||||
@ -247,6 +275,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
url: str = "",
|
||||
enrich: bool = True,
|
||||
commit: bool = True,
|
||||
amazon_id: str | None = None,
|
||||
):
|
||||
"""Given a title, get a Book instance.
|
||||
|
||||
@ -270,24 +299,50 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
book_dict = None
|
||||
source_tag = None
|
||||
tried_comicvine = False
|
||||
if READCOMICSONLINE_URL in url:
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
tried_comicvine = True
|
||||
if book_dict:
|
||||
source_tag = MediaSourceTag.COMICVINE
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
book_dict["next_readcomics_url"] = next_url_if_exists(
|
||||
book_dict["readcomics_url"]
|
||||
)
|
||||
book_dict["readcomics_url"] = get_comic_issue_url(url)
|
||||
book_dict["next_readcomics_url"] = next_url_if_exists(
|
||||
book_dict["readcomics_url"]
|
||||
)
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_ol(title, author=author)
|
||||
if book_dict:
|
||||
book_dict = {}
|
||||
ol_data = lookup_book_from_ol(title, author=author)
|
||||
google_data = lookup_book_from_google(title)
|
||||
|
||||
if ol_data:
|
||||
book_dict.update(ol_data)
|
||||
source_tag = MediaSourceTag.OPENLIBRARY
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_google(title)
|
||||
if book_dict:
|
||||
if google_data:
|
||||
for k, v in google_data.items():
|
||||
if v:
|
||||
book_dict.setdefault(k, v)
|
||||
source_tag = MediaSourceTag.GOOGLE_BOOKS
|
||||
if ol_data and ol_data.get("cover_url"):
|
||||
book_dict["cover_url"] = ol_data["cover_url"]
|
||||
|
||||
# Always try ComicVine as a fallback — it may recognize books that
|
||||
# OL/Google don't flag as comics
|
||||
if not tried_comicvine:
|
||||
cv_data = lookup_comic_from_comicvine(title)
|
||||
if cv_data:
|
||||
for k, v in cv_data.items():
|
||||
if v:
|
||||
book_dict.setdefault(k, v)
|
||||
source_tag = MediaSourceTag.COMICVINE
|
||||
|
||||
# Try Amazon PAAPI as a fallback when given an ASIN
|
||||
if amazon_id and not book_dict:
|
||||
amazon_data = lookup_book_from_amazon(amazon_id)
|
||||
if amazon_data:
|
||||
book_dict.update(amazon_data)
|
||||
source_tag = MediaSourceTag.AMAZON
|
||||
|
||||
if not book_dict:
|
||||
logger.warning(
|
||||
@ -299,6 +354,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
authors = book_dict.pop("authors", [])
|
||||
cover_url = book_dict.pop("cover_url", "")
|
||||
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||
tags = book_dict.pop("tags", [])
|
||||
|
||||
if authors:
|
||||
for author_str in authors:
|
||||
@ -318,6 +374,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
book.save_image_from_url(cover_url)
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
if tags:
|
||||
book.tags.add(*tags)
|
||||
book.authors.add(*author_list)
|
||||
if source_tag:
|
||||
book.tags.add(source_tag.value)
|
||||
@ -355,9 +413,14 @@ class Book(LongPlayScrobblableMixin):
|
||||
data = lookup_comic_from_locg(str(self.title))
|
||||
|
||||
if not data and COMICVINE_API_KEY:
|
||||
logger.warn(f"Checking ComicVine for {self.title}")
|
||||
cv_client = ComicVineClient(api_key=COMICVINE_API_KEY)
|
||||
data = lookup_comic_from_comicvine(str(self.title))
|
||||
if self.comicvine_id:
|
||||
logger.warn(
|
||||
f"Checking ComicVine by ID for {self.title}"
|
||||
)
|
||||
data = lookup_issue_by_comicvine_id(str(self.comicvine_id))
|
||||
if not data:
|
||||
logger.warn(f"Checking ComicVine for {self.title}")
|
||||
data = lookup_comic_from_comicvine(str(self.title))
|
||||
|
||||
if not data:
|
||||
logger.warn(f"Book not found in any sources: {self.title}")
|
||||
@ -395,10 +458,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
)
|
||||
data.pop("pages")
|
||||
|
||||
# Pop this, so we can look it up later
|
||||
# Pop these so they don't get passed to update()
|
||||
cover_url = data.pop("cover_url", "")
|
||||
|
||||
subject_key_list = data.pop("subject_key_list", [])
|
||||
tags = data.pop("tags", [])
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
Book.objects.filter(pk=self.id).update(**data)
|
||||
@ -406,6 +469,8 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
if subject_key_list:
|
||||
self.genre.add(*subject_key_list)
|
||||
if tags:
|
||||
self.tags.add(*tags)
|
||||
|
||||
if cover_url:
|
||||
r = requests.get(cover_url)
|
||||
@ -413,11 +478,6 @@ class Book(LongPlayScrobblableMixin):
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if self.pages:
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def fix_authors_metadata(self, openlibrary_author_id):
|
||||
@ -456,8 +516,11 @@ class Book(LongPlayScrobblableMixin):
|
||||
if scrobble.logdata.page_data:
|
||||
for page, data in scrobble.logdata.page_data.items():
|
||||
if convert_timestamps:
|
||||
data["start_ts"] = datetime.fromtimestamp(data["start_ts"])
|
||||
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
|
||||
tz = None
|
||||
if scrobble.timezone:
|
||||
tz = ZoneInfo(scrobble.timezone)
|
||||
data["start_ts"] = datetime.fromtimestamp(data["start_ts"], tz=tz)
|
||||
data["end_ts"] = datetime.fromtimestamp(data["end_ts"], tz=tz)
|
||||
pages[page] = data
|
||||
sorted_pages = OrderedDict(
|
||||
sorted(pages.items(), key=lambda x: x[1]["start_ts"])
|
||||
@ -491,6 +554,21 @@ class Book(LongPlayScrobblableMixin):
|
||||
return progress
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaperLogData(BaseLogData):
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from scrobbles.forms import NotesDictField
|
||||
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
fields["notes"] = NotesDictField(required=False)
|
||||
return fields
|
||||
|
||||
|
||||
class Paper(LongPlayScrobblableMixin):
|
||||
"""Keeps track of Academic Papers"""
|
||||
|
||||
@ -510,14 +588,29 @@ class Paper(LongPlayScrobblableMixin):
|
||||
language = models.CharField(max_length=4, **BNULL)
|
||||
first_publish_year = models.IntegerField(**BNULL)
|
||||
publish_date = models.DateField(**BNULL)
|
||||
journal = models.CharField(max_length=255, **BNULL)
|
||||
journal = models.ForeignKey(Journal, on_delete=models.DO_NOTHING, **BNULL)
|
||||
journal_volume = models.CharField(max_length=50, **BNULL)
|
||||
abstract = models.TextField(**BNULL)
|
||||
tldr = models.CharField(max_length=255, **BNULL)
|
||||
openaccess_pdf_url = models.CharField(max_length=255, **BNULL)
|
||||
pdf_file = models.FileField(upload_to="papers/pdf/", **BNULL)
|
||||
|
||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return PaperLogData
|
||||
|
||||
@property
|
||||
def scihub_url(self):
|
||||
if not self.doi_id:
|
||||
return None
|
||||
domain = getattr(settings, "SCIHUB_DOMAIN", "sci-hub.st")
|
||||
return f"https://{domain}/{self.doi_id}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("books:paper_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@classmethod
|
||||
def get_from_semantic(cls, title: str, overwrite: bool = False) -> "Paper":
|
||||
paper, created = cls.objects.get_or_create(title=title)
|
||||
@ -528,7 +621,7 @@ class Paper(LongPlayScrobblableMixin):
|
||||
|
||||
if created or overwrite:
|
||||
author_list = []
|
||||
author_dicts = paper_dict.pop("author_dicts")
|
||||
author_dicts = paper_dict.pop("author_dicts", None)
|
||||
if author_dicts:
|
||||
for author_dict in author_dicts:
|
||||
if author_dict.get("authorId"):
|
||||
@ -539,8 +632,11 @@ class Paper(LongPlayScrobblableMixin):
|
||||
if a_created:
|
||||
author.name = author_dict.get("name")
|
||||
author.save()
|
||||
# TODO enrich author?
|
||||
...
|
||||
|
||||
journal_name = paper_dict.pop("journal_name", None)
|
||||
if journal_name:
|
||||
journal, _ = Journal.objects.get_or_create(title=journal_name)
|
||||
paper.journal = journal
|
||||
|
||||
for k, v in paper_dict.items():
|
||||
setattr(paper, k, v)
|
||||
@ -552,3 +648,78 @@ class Paper(LongPlayScrobblableMixin):
|
||||
if genres:
|
||||
paper.genre.add(*genres)
|
||||
return paper
|
||||
|
||||
@classmethod
|
||||
def find_or_create_by_doi(cls, doi_url: str) -> "Paper":
|
||||
doi = doi_url.replace("https://doi.org/", "").split("?")[0].rstrip("/")
|
||||
paper = cls.objects.filter(doi_id=doi).first()
|
||||
if paper:
|
||||
return paper
|
||||
|
||||
paper = cls(doi_id=doi, title=f"Paper {doi}")
|
||||
paper.save()
|
||||
|
||||
from books.sources.crossref import lookup_paper_from_crossref
|
||||
|
||||
paper_dict = lookup_paper_from_semantic_by_doi(doi)
|
||||
if not paper_dict or not paper_dict.get("abstract"):
|
||||
paper_dict = lookup_paper_from_crossref(doi)
|
||||
|
||||
if paper_dict:
|
||||
author_list = []
|
||||
author_dicts = paper_dict.pop("author_dicts", None)
|
||||
if author_dicts:
|
||||
for author_dict in author_dicts:
|
||||
author_id = author_dict.get("authorId")
|
||||
if author_id:
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
semantic_id=author_id
|
||||
)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
author.name = author_dict.get("name")
|
||||
author.save()
|
||||
else:
|
||||
author_name = author_dict.get("name")
|
||||
if author_name:
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
name=author_name
|
||||
)
|
||||
author_list.append(author)
|
||||
|
||||
journal_name = paper_dict.pop("journal_name", None)
|
||||
if journal_name:
|
||||
journal, _ = Journal.objects.get_or_create(title=journal_name)
|
||||
paper.journal = journal
|
||||
|
||||
for k, v in paper_dict.items():
|
||||
if v is not None:
|
||||
setattr(paper, k, v)
|
||||
paper.save()
|
||||
|
||||
if author_list:
|
||||
paper.authors.add(*author_list)
|
||||
genres = paper_dict.pop("genres", [])
|
||||
if genres:
|
||||
paper.genre.add(*genres)
|
||||
|
||||
if not paper.pdf_file:
|
||||
service = SciHubService()
|
||||
if paper.openaccess_pdf_url:
|
||||
pdf_content = service.fetch_from_url(paper.openaccess_pdf_url)
|
||||
if pdf_content:
|
||||
filename = f"{doi.replace('/', '_')}.pdf"
|
||||
paper.pdf_file.save(filename, ContentFile(pdf_content))
|
||||
if not paper.pdf_file:
|
||||
try:
|
||||
pdf_content = service.fetch_pdf(doi)
|
||||
if pdf_content:
|
||||
filename = f"{doi.replace('/', '_')}.pdf"
|
||||
paper.pdf_file.save(filename, ContentFile(pdf_content))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[paper] sci-hub PDF download failed",
|
||||
extra={"doi": doi, "error": str(e)},
|
||||
)
|
||||
|
||||
return paper
|
||||
|
||||
123
vrobbler/apps/books/sources/amazon.py
Normal file
123
vrobbler/apps/books/sources/amazon.py
Normal file
@ -0,0 +1,123 @@
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
from amazon_paapi import AmazonApi
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_amazon_client = None
|
||||
|
||||
|
||||
def _get_client() -> AmazonApi | None:
|
||||
global _amazon_client
|
||||
if _amazon_client is not None:
|
||||
return _amazon_client
|
||||
|
||||
key = settings.AMAZON_PAAPI_ACCESS_KEY
|
||||
secret = settings.AMAZON_PAAPI_SECRET_KEY
|
||||
tag = settings.AMAZON_PAAPI_ASSOCIATE_TAG
|
||||
country = settings.AMAZON_PAAPI_COUNTRY
|
||||
|
||||
if not all([key, secret, tag]):
|
||||
logger.warning("Amazon PAAPI credentials not configured")
|
||||
return None
|
||||
|
||||
_amazon_client = AmazonApi(key, secret, tag, country)
|
||||
return _amazon_client
|
||||
|
||||
|
||||
def lookup_book_from_amazon(asin: str) -> dict:
|
||||
book_dict: dict = {}
|
||||
|
||||
client = _get_client()
|
||||
if not client:
|
||||
return book_dict
|
||||
|
||||
try:
|
||||
items = client.get_items(
|
||||
items=[asin],
|
||||
Condition="New",
|
||||
LanguagesOfPreference=["en_US"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Amazon PAAPI lookup failed for {asin}: {e}")
|
||||
return book_dict
|
||||
|
||||
if not items:
|
||||
logger.info(f"No Amazon item found for {asin}")
|
||||
return book_dict
|
||||
|
||||
item = items[0]
|
||||
raw = item.to_dict()
|
||||
item_info = raw.get("item_info", {}) or {}
|
||||
|
||||
book_dict["title"] = _get_nested(item_info, "title", "display_value")
|
||||
if not book_dict.get("title"):
|
||||
book_dict["title"] = _get_nested(item_info, "title", "value")
|
||||
|
||||
contributors = _get_nested(item_info, "by_line_info", "contributors") or []
|
||||
authors = [
|
||||
c["name"]
|
||||
for c in contributors
|
||||
if c.get("role", "").lower() in ("author", "artist", "writer")
|
||||
]
|
||||
if authors:
|
||||
book_dict["authors"] = authors
|
||||
|
||||
publisher = _get_nested(item_info, "by_line_info", "manufacturer")
|
||||
if publisher:
|
||||
book_dict["publisher"] = publisher
|
||||
|
||||
isb_ns = _get_nested(item_info, "external_ids", "isb_ns")
|
||||
if isb_ns and isinstance(isb_ns, list):
|
||||
for isb in isb_ns:
|
||||
if isinstance(isb, dict):
|
||||
if isb.get("type") == "ISBN_13":
|
||||
book_dict["isbn_13"] = isb.get("value")
|
||||
elif isb.get("type") == "ISBN_10":
|
||||
book_dict["isbn_10"] = isb.get("value")
|
||||
|
||||
pages_count = _get_nested(item_info, "content_info", "pages_count")
|
||||
if pages_count and isinstance(pages_count, dict):
|
||||
book_dict["pages"] = pages_count.get("value") or pages_count.get("display_value")
|
||||
|
||||
languages = _get_nested(item_info, "content_info", "languages") or []
|
||||
if languages and isinstance(languages, list):
|
||||
lang = languages[0]
|
||||
if isinstance(lang, dict):
|
||||
book_dict["language"] = lang.get("display_value") or lang.get("value")
|
||||
|
||||
pub_date = _get_nested(item_info, "content_info", "publication_date")
|
||||
if not pub_date:
|
||||
pub_date = _get_nested(item_info, "product_info", "release_date")
|
||||
if pub_date and isinstance(pub_date, dict):
|
||||
book_dict["publish_date"] = pub_date.get("display_value") or pub_date.get("value")
|
||||
|
||||
features = item_info.get("features") or []
|
||||
if features and isinstance(features, list):
|
||||
book_dict["summary"] = " ".join(features[:5])
|
||||
|
||||
images = raw.get("images", {}) or {}
|
||||
primary = images.get("primary", {}) or {}
|
||||
for size in ("large", "hi_res", "medium"):
|
||||
candidate = primary.get(size, {}) or {}
|
||||
url = candidate.get("url")
|
||||
if url:
|
||||
book_dict["cover_url"] = url
|
||||
break
|
||||
|
||||
book_dict["detail_page_url"] = raw.get("detail_page_url")
|
||||
|
||||
return book_dict
|
||||
|
||||
|
||||
def _get_nested(d: dict, *keys):
|
||||
for key in keys:
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
d = d.get(key)
|
||||
return d
|
||||
@ -17,8 +17,10 @@ class ComicVineClient(object):
|
||||
account on https://comicvine.gamespot.com/ in order to obtain an API key.
|
||||
"""
|
||||
|
||||
# All API requests made by this client will be made to this URL.
|
||||
API_URL = "https://www.comicvine.com/api/search/"
|
||||
# All API requests made by this client will be made to these URLs.
|
||||
API_URL = "https://comicvine.gamespot.com/api/search/"
|
||||
ISSUE_API_URL = "https://comicvine.gamespot.com/api/issue/4000-{issue_id}/"
|
||||
VOLUME_API_URL = "https://comicvine.gamespot.com/api/volume/4050-{volume_id}/"
|
||||
|
||||
# A valid User-Agent header must be set in order for our API requests to
|
||||
# be accepted, otherwise our request will be rejected with a
|
||||
@ -41,15 +43,12 @@ class ComicVineClient(object):
|
||||
"volume",
|
||||
}
|
||||
|
||||
def __init__(self, api_key, expire_after=300):
|
||||
def __init__(self, api_key):
|
||||
"""
|
||||
Store the API key in a class variable, and install the requests cache,
|
||||
configuring it using the ``expire_after`` parameter.
|
||||
Store the API key in a class variable.
|
||||
|
||||
:param api_key: Your personal ComicVine API key.
|
||||
:type api_key: str
|
||||
:param expire_after: The number of seconds to retain an entry in cache.
|
||||
:type expire_after: int or None
|
||||
"""
|
||||
|
||||
self.api_key = api_key
|
||||
@ -109,14 +108,17 @@ class ComicVineClient(object):
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
return {
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
"limit": min(10, limit), # hard limit of 10
|
||||
"offset": max(0, offset), # cannot provide negative offset
|
||||
"query": query,
|
||||
"resources": self._validate_resources(resources),
|
||||
}
|
||||
validated = self._validate_resources(resources)
|
||||
if validated:
|
||||
params["resources"] = validated
|
||||
return params
|
||||
|
||||
def _validate_resources(self, resources):
|
||||
"""
|
||||
@ -141,33 +143,35 @@ class ComicVineClient(object):
|
||||
def _query_api(self, params):
|
||||
"""
|
||||
Query the ComicVine API's ``search`` resource, providing the required
|
||||
headers and parameters with the request. Optionally allow the caller
|
||||
of the function to disable the request cache.
|
||||
headers and parameters with the request.
|
||||
|
||||
If an error occurs during the request, handle it accordingly. Upon
|
||||
success, return the JSON from the response.
|
||||
|
||||
:param params: Parameters to include with the request.
|
||||
:type params: dict
|
||||
:param use_cache: Toggle the use of requests_cache.
|
||||
:type use_cache: bool
|
||||
|
||||
:return: The JSON contained in the response.
|
||||
:rtype: dict
|
||||
"""
|
||||
|
||||
# Since we're performing the identical action regardless of whether
|
||||
# or not the request cache is to be used, store the procedure in a
|
||||
# local function to avoid repetition.
|
||||
def __httpget():
|
||||
response = requests.get(self.API_URL, headers=self.HEADERS, params=params)
|
||||
response = requests.get(self.API_URL, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
return response.json()
|
||||
json_data = response.json()
|
||||
|
||||
return __httpget()
|
||||
if json_data.get("status_code") != 1:
|
||||
error_msg = json_data.get("error", "Unknown ComicVine API error")
|
||||
logger.error(
|
||||
"ComicVine API returned status_code %s: %s",
|
||||
json_data.get("status_code"),
|
||||
error_msg,
|
||||
)
|
||||
return {}
|
||||
|
||||
return json_data
|
||||
|
||||
def _handle_http_error(self, response):
|
||||
"""
|
||||
@ -195,15 +199,81 @@ class ComicVineClient(object):
|
||||
|
||||
raise exception(message)
|
||||
|
||||
def get_issue(self, issue_id: str) -> dict:
|
||||
"""
|
||||
Fetch a single issue by its ComicVine ID directly from the issue detail
|
||||
endpoint, which returns richer data than the search endpoint.
|
||||
|
||||
:param issue_id: The ComicVine numeric ID for the issue (e.g. "538480")
|
||||
:type issue_id: str
|
||||
|
||||
:return: The full JSON response for the issue, or empty dict on failure.
|
||||
:rtype: dict
|
||||
"""
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
}
|
||||
url = self.ISSUE_API_URL.format(issue_id=issue_id)
|
||||
response = requests.get(url, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
json_data = response.json()
|
||||
|
||||
if json_data.get("status_code") != 1:
|
||||
error_msg = json_data.get("error", "Unknown ComicVine API error")
|
||||
logger.error(
|
||||
"ComicVine API returned status_code %s: %s",
|
||||
json_data.get("status_code"),
|
||||
error_msg,
|
||||
)
|
||||
return {}
|
||||
|
||||
return json_data.get("results", {})
|
||||
|
||||
def get_volume(self, volume_id: str) -> dict:
|
||||
"""
|
||||
Fetch a single volume by its ComicVine ID from the volume detail
|
||||
endpoint. Used to get publisher info and other volume-level metadata.
|
||||
|
||||
:param volume_id: The ComicVine numeric ID for the volume (e.g. "91273")
|
||||
:type volume_id: str
|
||||
|
||||
:return: The full JSON response for the volume, or empty dict on failure.
|
||||
:rtype: dict
|
||||
"""
|
||||
params = {
|
||||
"api_key": self.api_key,
|
||||
"format": "json",
|
||||
}
|
||||
url = self.VOLUME_API_URL.format(volume_id=volume_id)
|
||||
response = requests.get(url, headers=self.HEADERS, params=params)
|
||||
|
||||
if not response.ok:
|
||||
self._handle_http_error(response)
|
||||
|
||||
json_data = response.json()
|
||||
|
||||
if json_data.get("status_code") != 1:
|
||||
error_msg = json_data.get("error", "Unknown ComicVine API error")
|
||||
logger.error(
|
||||
"ComicVine API returned status_code %s: %s",
|
||||
json_data.get("status_code"),
|
||||
error_msg,
|
||||
)
|
||||
return {}
|
||||
|
||||
return json_data.get("results", {})
|
||||
|
||||
|
||||
def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
original_title = title
|
||||
|
||||
issue_number = None
|
||||
volume_nubmer = None
|
||||
resource_type = "issue"
|
||||
if "Issue " in title:
|
||||
resource_type = "issue"
|
||||
issue_number = title.split("Issue ")[1]
|
||||
volume_number = None
|
||||
if "Volume " in title:
|
||||
@ -215,48 +285,136 @@ def lookup_comic_from_comicvine(title: str) -> dict:
|
||||
logger.warning("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
client = ComicVineClient(api_key=getattr(settings, "COMICVINE_API_KEY", None))
|
||||
client = ComicVineClient(api_key=api_key)
|
||||
|
||||
raw_results = client.search(title).get("results")
|
||||
results = [r for r in raw_results if r.get("resource_type") == resource_type]
|
||||
raw_results = client.search(title)
|
||||
if not raw_results:
|
||||
return {}
|
||||
results = raw_results.get("results", [])
|
||||
results = [r for r in results if r.get("resource_type") == resource_type]
|
||||
if not results:
|
||||
logger.warning("No comic found on ComicVine")
|
||||
return {}
|
||||
|
||||
found_result = None
|
||||
for result in results:
|
||||
if result.get("issue_number") == str(issue_number):
|
||||
if issue_number is not None and result.get("issue_number") == str(issue_number):
|
||||
found_result = result
|
||||
break
|
||||
if result.get("volume_number") == str(volume_number):
|
||||
if volume_number is not None and result.get("volume_number") == str(volume_number):
|
||||
found_result = result
|
||||
break
|
||||
|
||||
if not found_result:
|
||||
found_result = results[0]
|
||||
|
||||
logger.info("ComicVine results", extra={"results": results})
|
||||
data_dict = _build_data_dict_from_issue(found_result, original_title)
|
||||
_enrich_with_volume_data(client, data_dict)
|
||||
return data_dict
|
||||
|
||||
if not found_result:
|
||||
logger.warning("No matches found on ComicVine")
|
||||
|
||||
def lookup_issue_by_comicvine_id(comicvine_id: str) -> dict:
|
||||
"""
|
||||
Look up an issue directly by its ComicVine ID using the issue detail
|
||||
endpoint. Returns richer data than the search-based lookup.
|
||||
|
||||
:param comicvine_id: The ComicVine numeric ID for the issue (e.g. "538480")
|
||||
:type comicvine_id: str
|
||||
|
||||
:return: A dict of extracted book metadata, or empty dict on failure.
|
||||
:rtype: dict
|
||||
"""
|
||||
if not comicvine_id:
|
||||
return {}
|
||||
|
||||
title = found_result.get("name")
|
||||
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
if not api_key:
|
||||
logger.warning("No ComicVine API key configured, not looking anything up")
|
||||
return {}
|
||||
|
||||
if found_result.get("volume"):
|
||||
title = found_result.get("volume").get("name")
|
||||
client = ComicVineClient(api_key=api_key)
|
||||
issue_data = client.get_issue(comicvine_id)
|
||||
if not issue_data:
|
||||
logger.warning("No issue found on ComicVine for ID %s", comicvine_id)
|
||||
return {}
|
||||
|
||||
data_dict = _build_data_dict_from_issue(issue_data, issue_data.get("name", ""))
|
||||
_enrich_with_volume_data(client, data_dict)
|
||||
return data_dict
|
||||
|
||||
|
||||
def _build_data_dict_from_issue(issue_data: dict, original_title: str = "") -> dict:
|
||||
"""
|
||||
Build a book metadata dict from a ComicVine issue resource (either from
|
||||
search results or issue detail endpoint). Both return the same shape of
|
||||
issue data.
|
||||
|
||||
:param issue_data: The issue resource dict from ComicVine.
|
||||
:param original_title: The original search term, if any.
|
||||
|
||||
:return: A dict of extracted book metadata.
|
||||
:rtype: dict
|
||||
"""
|
||||
title = issue_data.get("name")
|
||||
if issue_data.get("volume"):
|
||||
title = issue_data.get("volume").get("name")
|
||||
|
||||
cover_url = None
|
||||
if issue_data.get("image"):
|
||||
cover_url = issue_data["image"].get("original_url")
|
||||
|
||||
volume_name = None
|
||||
volume_cv_id = None
|
||||
publisher_name = None
|
||||
volume_data = issue_data.get("volume")
|
||||
if volume_data:
|
||||
volume_name = volume_data.get("name")
|
||||
volume_cv_id = volume_data.get("id")
|
||||
publisher_data = volume_data.get("publisher")
|
||||
if publisher_data:
|
||||
publisher_name = publisher_data.get("name")
|
||||
|
||||
data_dict = {
|
||||
"title": title,
|
||||
"original_title": original_title,
|
||||
"issue_number": found_result.get("issue_number"),
|
||||
"volume_number": found_result.get("volume_number"),
|
||||
"cover_url": found_result.get("image").get("original_url"),
|
||||
"comicvine_id": found_result.get("id"),
|
||||
"comicvine_data": found_result,
|
||||
"summary": found_result.get("description"),
|
||||
"publish_date": found_result.get("cover_date"),
|
||||
"first_publish_year": found_result.get("cover_date", "")[:4],
|
||||
"issue_number": issue_data.get("issue_number"),
|
||||
"volume_number": issue_data.get("volume_number"),
|
||||
"volume": volume_name,
|
||||
"volume_comicvine_id": volume_cv_id,
|
||||
"publisher": publisher_name,
|
||||
"cover_url": cover_url,
|
||||
"comicvine_id": issue_data.get("id"),
|
||||
"summary": issue_data.get("description"),
|
||||
"publish_date": issue_data.get("cover_date"),
|
||||
"first_publish_year": (issue_data.get("cover_date") or "")[:4],
|
||||
"tags": ["comicbook"],
|
||||
}
|
||||
|
||||
return data_dict
|
||||
|
||||
|
||||
def _enrich_with_volume_data(client: ComicVineClient, data_dict: dict) -> None:
|
||||
"""
|
||||
Follow-up a successful issue lookup by fetching the volume detail and
|
||||
filling in publisher and other volume-level metadata that the issue
|
||||
endpoint doesn't provide.
|
||||
|
||||
:param client: An initialised ComicVineClient instance.
|
||||
:param data_dict: The data dict from an issue lookup (mutated in place).
|
||||
"""
|
||||
volume_cv_id = data_dict.get("volume_comicvine_id")
|
||||
if not volume_cv_id:
|
||||
return
|
||||
|
||||
volume_data = client.get_volume(str(volume_cv_id))
|
||||
if not volume_data:
|
||||
return
|
||||
|
||||
publisher_data = volume_data.get("publisher")
|
||||
if publisher_data:
|
||||
publisher_name = publisher_data.get("name")
|
||||
if publisher_name and not data_dict.get("publisher"):
|
||||
data_dict["publisher"] = publisher_name
|
||||
|
||||
if not data_dict.get("volume"):
|
||||
data_dict["volume"] = volume_data.get("name")
|
||||
|
||||
149
vrobbler/apps/books/sources/crossref.py
Normal file
149
vrobbler/apps/books/sources/crossref.py
Normal file
@ -0,0 +1,149 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
import yake
|
||||
|
||||
CROSSREF_WORK_URL = "https://api.crossref.org/works/{}"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STOPWORDS = {
|
||||
"this", "that", "these", "those", "the", "a", "an", "in", "on", "at",
|
||||
"to", "for", "of", "and", "or", "is", "are", "was", "were", "be",
|
||||
"been", "being", "have", "has", "had", "do", "does", "did", "will",
|
||||
"would", "can", "could", "may", "might", "shall", "should", "not",
|
||||
"no", "nor", "with", "from", "by", "as", "at", "but", "if", "because",
|
||||
"while", "although", "however", "we", "our", "their", "its", "it",
|
||||
"they", "them", "also", "more", "most", "new", "such", "into",
|
||||
"across", "between", "through", "about", "after", "before", "during",
|
||||
"within", "without", "other", "many", "some", "each", "every", "both",
|
||||
"few", "own", "via",
|
||||
}
|
||||
|
||||
_DROP_PHRASES = {
|
||||
"paper", "study", "studies", "research", "introduction", "conclusion",
|
||||
"conclusions", "background", "methods", "results", "findings",
|
||||
"analysis", "approach", "approaches", "framework", "theory",
|
||||
"theories", "concept", "concepts", "model", "models", "process",
|
||||
"processes", "role", "roles", "factor", "factors", "effect",
|
||||
"effects", "impact", "implication", "implications", "actor", "actors",
|
||||
"article", "chapter", "section", "discussion", "review", "overview",
|
||||
"summary", "methodology", "special issue", "implications",
|
||||
"limitations", "findings", "purpose", "objective", "objectives",
|
||||
"design", "setting", "participants", "sample", "data",
|
||||
"contemporary", "little", "empirical", "theoretical",
|
||||
"organizations", "dissent",
|
||||
}
|
||||
|
||||
|
||||
def _strip_jats(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"</?jats:[^>]*>", "", text)
|
||||
text = re.sub(r"^\s*Abstract\s*", "", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _extract_genres_from_abstract(abstract: str, max_keywords: int = 8) -> list[str]:
|
||||
if not abstract or len(abstract) < 50:
|
||||
return []
|
||||
kw_extractor = yake.KeywordExtractor(lan="en", n=2, top=max_keywords)
|
||||
keywords = kw_extractor.extract_keywords(abstract)
|
||||
genres = []
|
||||
seen = set()
|
||||
for kw, score in keywords:
|
||||
kw_lower = kw.lower().strip()
|
||||
if kw_lower in seen or kw_lower in _DROP_PHRASES:
|
||||
continue
|
||||
words = [w for w in kw_lower.split() if w not in _STOPWORDS]
|
||||
cleaned = " ".join(words)
|
||||
if not cleaned or len(cleaned) < 3 or cleaned in seen:
|
||||
continue
|
||||
if cleaned in _DROP_PHRASES:
|
||||
continue
|
||||
seen.add(cleaned)
|
||||
genres.append(cleaned)
|
||||
return genres
|
||||
|
||||
|
||||
def lookup_paper_from_crossref(doi: str) -> dict:
|
||||
url = CROSSREF_WORK_URL.format(doi)
|
||||
headers = {"User-Agent": "Vrobbler/1.0 (mailto:hello@example.com)"}
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Bad response from Crossref",
|
||||
extra={"doi": doi, "status": response.status_code},
|
||||
)
|
||||
return {"doi_id": doi}
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except json.JSONDecodeError:
|
||||
return {"doi_id": doi}
|
||||
|
||||
msg = data.get("message", {})
|
||||
if not msg:
|
||||
return {"doi_id": doi}
|
||||
|
||||
paper_dict = {"doi_id": doi}
|
||||
|
||||
titles = msg.get("title", [])
|
||||
if titles:
|
||||
paper_dict["title"] = titles[0]
|
||||
|
||||
abstract = msg.get("abstract", "")
|
||||
if abstract:
|
||||
stripped = _strip_jats(abstract)
|
||||
paper_dict["abstract"] = stripped
|
||||
genres = _extract_genres_from_abstract(stripped)
|
||||
if genres:
|
||||
paper_dict["genres"] = genres
|
||||
|
||||
author_dicts = []
|
||||
for author in msg.get("author", []):
|
||||
given = author.get("given", "")
|
||||
family = author.get("family", "")
|
||||
name = f"{given} {family}".strip()
|
||||
if not name:
|
||||
continue
|
||||
entry = {"name": name}
|
||||
orcid = author.get("ORCID", "")
|
||||
if orcid:
|
||||
orcid_id = orcid.replace("https://orcid.org/", "")
|
||||
entry["authorId"] = orcid_id
|
||||
author_dicts.append(entry)
|
||||
if author_dicts:
|
||||
paper_dict["author_dicts"] = author_dicts
|
||||
|
||||
container = msg.get("container-title", [])
|
||||
if container:
|
||||
paper_dict["journal_name"] = container[0]
|
||||
|
||||
volume = msg.get("volume")
|
||||
if volume:
|
||||
paper_dict["journal_volume"] = volume
|
||||
|
||||
page = msg.get("page")
|
||||
if page:
|
||||
try:
|
||||
parts = page.split("-")
|
||||
if len(parts) == 2:
|
||||
paper_dict["pages"] = int(parts[1]) - int(parts[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
for date_field in ("published-print", "published-online", "created"):
|
||||
date_data = msg.get(date_field)
|
||||
if date_data and date_data.get("date-parts"):
|
||||
parts = date_data["date-parts"][0]
|
||||
if len(parts) >= 1:
|
||||
paper_dict["first_publish_year"] = int(parts[0])
|
||||
if len(parts) >= 3:
|
||||
paper_dict["publish_date"] = f"{parts[0]:04d}-{parts[1]:02d}-{parts[2]:02d}"
|
||||
break
|
||||
|
||||
return paper_dict
|
||||
@ -26,8 +26,6 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
if not google_result:
|
||||
return {}
|
||||
|
||||
publish_date = pendulum.parse(google_result.get("publishedDate"))
|
||||
|
||||
isbn_13 = ""
|
||||
isbn_10 = ""
|
||||
for ident in google_result.get("industryIdentifiers", []):
|
||||
@ -35,25 +33,25 @@ def lookup_book_from_google(title: str) -> dict:
|
||||
isbn_13 = ident.get("identifier")
|
||||
if ident.get("type") == "ISBN_10":
|
||||
isbn_10 = ident.get("identifier")
|
||||
# TODO this may lead to issues with the first get if Google changes our title
|
||||
# book_metadata.title = google_result.get("title")
|
||||
# if google_result.get("subtitle"):
|
||||
# book_metadata["title"] = ": ".join(
|
||||
# [google_result.get("title"), google_result.get("subtitle")]
|
||||
# )
|
||||
# book_dict["subtitle"] = google_result.get("subtitle")
|
||||
book_dict["authors"] = google_result.get("authors")
|
||||
book_dict["publisher"] = google_result.get("publisher")
|
||||
book_dict["first_publish_year"] = publish_date.year
|
||||
book_dict["pages"] = google_result.get("pageCount")
|
||||
book_dict["isbn_13"] = isbn_13
|
||||
book_dict["isbn_10"] = isbn_10
|
||||
book_dict["publish_date"] = google_result.get("publishedDate")
|
||||
if len(book_dict["publish_date"]) == 4:
|
||||
book_dict["publish_date"] = f"{book_dict['publish_date']}-1-1"
|
||||
book_dict["language"] = google_result.get("language")
|
||||
book_dict["summary"] = google_result.get("description")
|
||||
book_dict["genres"] = google_result.get("categories")
|
||||
|
||||
raw_date = google_result.get("publishedDate")
|
||||
if raw_date:
|
||||
try:
|
||||
publish_date = pendulum.parse(raw_date)
|
||||
book_dict["first_publish_year"] = publish_date.year
|
||||
except Exception:
|
||||
pass
|
||||
book_dict["publish_date"] = raw_date
|
||||
if len(raw_date) == 4:
|
||||
book_dict["publish_date"] = f"{raw_date}-1-1"
|
||||
book_dict["cover_url"] = (
|
||||
google_result.get("imageLinks", {})
|
||||
.get("thumbnail", "")
|
||||
|
||||
142
vrobbler/apps/books/sources/scihub.py
Normal file
142
vrobbler/apps/books/sources/scihub.py
Normal file
@ -0,0 +1,142 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCIHUB_DOMAINS = [
|
||||
"sci-hub.ru",
|
||||
"sci-hub.ee",
|
||||
"sci-hub.st",
|
||||
"sci-hub.do",
|
||||
]
|
||||
|
||||
|
||||
class SciHubService:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
)
|
||||
|
||||
def fetch_from_url(self, url: str) -> Optional[bytes]:
|
||||
try:
|
||||
resp = self.session.get(url, timeout=60)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"[pdf] URL download failed",
|
||||
extra={"status": resp.status_code, "url": url},
|
||||
)
|
||||
return None
|
||||
if not self._looks_like_pdf(resp):
|
||||
return None
|
||||
return resp.content
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
"[pdf] URL download request failed",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return None
|
||||
|
||||
def fetch_pdf(self, doi: str) -> Optional[bytes]:
|
||||
configured_domain = getattr(settings, "SCIHUB_DOMAIN", None)
|
||||
domains_to_try = (
|
||||
[configured_domain] + SCIHUB_DOMAINS
|
||||
if configured_domain and configured_domain not in SCIHUB_DOMAINS
|
||||
else SCIHUB_DOMAINS
|
||||
)
|
||||
|
||||
for domain in domains_to_try:
|
||||
url = f"https://{domain}/{doi}"
|
||||
logger.info(
|
||||
"[scihub] trying domain",
|
||||
extra={"domain": domain, "doi": doi},
|
||||
)
|
||||
try:
|
||||
response = self.session.get(url, timeout=30)
|
||||
if response.status_code != 200:
|
||||
continue
|
||||
|
||||
pdf_url = self._extract_pdf_url(response.text, url)
|
||||
if not pdf_url:
|
||||
continue
|
||||
|
||||
pdf_response = self.session.get(pdf_url, timeout=60)
|
||||
if pdf_response.status_code != 200:
|
||||
continue
|
||||
|
||||
if not self._looks_like_pdf(pdf_response):
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"[scihub] PDF downloaded successfully",
|
||||
extra={
|
||||
"domain": domain,
|
||||
"doi": doi,
|
||||
"size": len(pdf_response.content),
|
||||
},
|
||||
)
|
||||
return pdf_response.content
|
||||
except requests.RequestException as e:
|
||||
logger.debug(
|
||||
"[scihub] domain failed",
|
||||
extra={"domain": domain, "doi": doi, "error": str(e)},
|
||||
)
|
||||
continue
|
||||
|
||||
logger.warning(
|
||||
"[scihub] all domains failed",
|
||||
extra={"doi": doi, "tried": domains_to_try},
|
||||
)
|
||||
return None
|
||||
|
||||
def _looks_like_pdf(self, response: requests.Response) -> bool:
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "application/pdf" in content_type:
|
||||
return True
|
||||
if content_type.startswith("application/octet"):
|
||||
return True
|
||||
if response.url.endswith(".pdf"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_pdf_url(self, html: str, page_url: str) -> Optional[str]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
iframe = soup.find("iframe", {"id": "pdf"})
|
||||
if iframe and iframe.get("src"):
|
||||
src = iframe["src"]
|
||||
if src.startswith("http"):
|
||||
return src
|
||||
return urljoin(page_url, src)
|
||||
|
||||
embed = soup.find("embed", {"type": "application/pdf"})
|
||||
if embed and embed.get("src"):
|
||||
src = embed["src"]
|
||||
if src.startswith("http"):
|
||||
return src
|
||||
return urljoin(page_url, src)
|
||||
|
||||
download_div = soup.find("div", {"id": "download"})
|
||||
if download_div:
|
||||
link = download_div.find("a")
|
||||
if link and link.get("href"):
|
||||
href = link["href"]
|
||||
if href.startswith("http"):
|
||||
return href
|
||||
return urljoin(page_url, href)
|
||||
|
||||
for link in soup.find_all("a", href=True):
|
||||
href = link["href"]
|
||||
if ".pdf" in href:
|
||||
if href.startswith("http"):
|
||||
return href
|
||||
return urljoin(page_url, href)
|
||||
|
||||
return None
|
||||
@ -9,6 +9,7 @@ PAPER_SEARCH_URL = (
|
||||
"https://api.semanticscholar.org/graph/v1/paper/search/match?query={}"
|
||||
)
|
||||
PAPER_DETAIL_URL = "https://api.semanticscholar.org/graph/v1/paper/{}?fields=title,authors,url,year,abstract,externalIds,citationCount,referenceCount,journal,fieldsOfStudy,publicationDate,openAccessPdf"
|
||||
PAPER_DOI_URL = "https://api.semanticscholar.org/graph/v1/paper/DOI:{}?fields=title,authors,url,year,abstract,externalIds,citationCount,referenceCount,journal,fieldsOfStudy,publicationDate,openAccessPdf"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -39,6 +40,18 @@ def lookup_paper_from_semantic(title: str) -> dict:
|
||||
if not result:
|
||||
return paper_dict
|
||||
|
||||
paper_dict.update(_parse_semantic_result(result))
|
||||
paper_dict.setdefault("title", title)
|
||||
if paper_dict.get("publish_date"):
|
||||
paper_dict["publish_date"] = datetime.strptime(
|
||||
paper_dict["publish_date"], "%Y-%m-%d"
|
||||
)
|
||||
|
||||
return paper_dict
|
||||
|
||||
|
||||
def _parse_semantic_result(result: dict) -> dict:
|
||||
paper_dict = {}
|
||||
page_str = result.get("journal", {}).get("pages")
|
||||
if page_str:
|
||||
try:
|
||||
@ -55,12 +68,10 @@ def lookup_paper_from_semantic(title: str) -> dict:
|
||||
paper_dict["corpus_id"] = result.get("externalIds", {}).get("CorpusId")
|
||||
paper_dict["semantic_title"] = result.get("title")
|
||||
paper_dict["first_publish_year"] = result.get("year")
|
||||
paper_dict["publish_date"] = datetime.strptime(
|
||||
result.get("publicationDate", "1950-01-01"), "%Y-%m-%d"
|
||||
)
|
||||
paper_dict["publish_date"] = result.get("publicationDate")
|
||||
paper_dict["abstract"] = result.get("abstract")
|
||||
paper_dict["tldr"] = result.get("bib", {}).get("abstract")
|
||||
paper_dict["journal"] = result.get("journal", {}).get("name")
|
||||
paper_dict["journal_name"] = result.get("journal", {}).get("name")
|
||||
paper_dict["journal_volume"] = result.get("journal", {}).get("volume")
|
||||
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get("url")
|
||||
paper_dict["base_run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
|
||||
@ -68,5 +79,19 @@ def lookup_paper_from_semantic(title: str) -> dict:
|
||||
)
|
||||
paper_dict["author_dicts"] = result.get("authors")
|
||||
paper_dict["genres"] = result.get("fieldsOfStudy")
|
||||
|
||||
return paper_dict
|
||||
|
||||
|
||||
def lookup_paper_from_semantic_by_doi(doi: str) -> dict:
|
||||
response = get_api_result(PAPER_DOI_URL.format(doi))
|
||||
if not response:
|
||||
return {"doi_id": doi}
|
||||
|
||||
result = json.loads(response.content)
|
||||
if not result:
|
||||
return {"doi_id": doi}
|
||||
|
||||
paper_dict = _parse_semantic_result(result)
|
||||
if not paper_dict.get("title"):
|
||||
paper_dict["title"] = result.get("title", f"Paper {doi}")
|
||||
return paper_dict
|
||||
|
||||
@ -32,8 +32,6 @@ class KoReaderBookRows:
|
||||
DEFAULT_STR = "N/A"
|
||||
DEFAULT_INT = 0
|
||||
DEFAULT_TIME = 1703800469
|
||||
BOOK_ROWS = []
|
||||
PAGE_STATS_ROWS = []
|
||||
|
||||
def _gen_random_row(self, i):
|
||||
wiggle = random.randrange(15)
|
||||
@ -110,6 +108,8 @@ class KoReaderBookRows:
|
||||
end_session = True
|
||||
|
||||
def __init__(self, book_count=0, **kwargs):
|
||||
self.BOOK_ROWS = []
|
||||
self.PAGE_STATS_ROWS = []
|
||||
self._generate_random_book_rows(book_count)
|
||||
self._generate_custom_book_row(**kwargs)
|
||||
self._generate_random_page_stats_rows()
|
||||
|
||||
@ -44,7 +44,10 @@ def test_build_scrobbles_from_pages(get_mock, koreader_rows, demo_user, valid_re
|
||||
book_map = build_page_data(koreader_rows.PAGE_STATS_ROWS, book_map)
|
||||
|
||||
scrobbles = build_scrobbles_from_book_map(book_map, demo_user)
|
||||
# Corresponds to number of sessions per book ( 20 pages per session, 120 +/- 15 pages read )
|
||||
# The test data generator adds the session-gap 3600s AFTER the trigger page
|
||||
# (not before), so the first session includes 21 pages (1-21), and each
|
||||
# subsequent session has 20 until the last. The last page is now included
|
||||
# in the final scrobble instead of being orphaned.
|
||||
expected_scrobbles = 6 * len(book_map.keys())
|
||||
assert len(scrobbles) == expected_scrobbles
|
||||
assert len(scrobbles[0].logdata.page_data.keys()) == 21
|
||||
@ -52,7 +55,7 @@ def test_build_scrobbles_from_pages(get_mock, koreader_rows, demo_user, valid_re
|
||||
assert len(scrobbles[2].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[3].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[4].logdata.page_data.keys()) == 20
|
||||
assert len(scrobbles[5].logdata.page_data.keys()) == 18
|
||||
assert len(scrobbles[5].logdata.page_data.keys()) == 19
|
||||
|
||||
|
||||
def test_get_author_str_from_row():
|
||||
|
||||
@ -16,4 +16,15 @@ urlpatterns = [
|
||||
views.AuthorDetailView.as_view(),
|
||||
name="author_detail",
|
||||
),
|
||||
path("papers/", views.PaperListView.as_view(), name="paper_list"),
|
||||
path(
|
||||
"papers/<slug:slug>/",
|
||||
views.PaperDetailView.as_view(),
|
||||
name="paper_detail",
|
||||
),
|
||||
path(
|
||||
"papers/<slug:slug>/upload_pdf/",
|
||||
views.PaperUploadPdfView.as_view(),
|
||||
name="paper_upload_pdf",
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views import generic
|
||||
from books.models import Book, Author
|
||||
from books.models import Book, Author, Paper
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
@ -15,3 +18,24 @@ class BookDetailView(ScrobbleableDetailView):
|
||||
class AuthorDetailView(generic.DetailView):
|
||||
model = Author
|
||||
slug_field = "uuid"
|
||||
|
||||
|
||||
class PaperListView(ScrobbleableListView):
|
||||
model = Paper
|
||||
|
||||
|
||||
class PaperDetailView(ScrobbleableDetailView):
|
||||
model = Paper
|
||||
|
||||
|
||||
class PaperUploadPdfView(View):
|
||||
def post(self, request, slug):
|
||||
paper = Paper.objects.filter(uuid=slug).first()
|
||||
if not paper or not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(reverse("books:paper_detail", args=[slug]))
|
||||
|
||||
pdf_file = request.FILES.get("pdf_file")
|
||||
if pdf_file:
|
||||
paper.pdf_file.save(pdf_file.name, pdf_file)
|
||||
|
||||
return HttpResponseRedirect(reverse("books:paper_detail", args=[slug]))
|
||||
|
||||
@ -0,0 +1,146 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-12 16:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("charts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "album", "rank"],
|
||||
name="charts_char_user_id_1adcde_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "track", "rank"],
|
||||
name="charts_char_user_id_d18aab_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "video", "rank"],
|
||||
name="charts_char_user_id_de9f0a_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "board_game", "rank"],
|
||||
name="charts_char_user_id_d5d58f_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "book", "rank"],
|
||||
name="charts_char_user_id_e877cf_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "food", "rank"],
|
||||
name="charts_char_user_id_a0ad71_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "podcast", "rank"],
|
||||
name="charts_char_user_id_846b80_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "trail", "rank"],
|
||||
name="charts_char_user_id_54feba_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "album", "rank"],
|
||||
name="charts_char_user_id_a3dc49_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "track", "rank"],
|
||||
name="charts_char_user_id_4b01ab_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "video", "rank"],
|
||||
name="charts_char_user_id_2ac9d2_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "board_game", "rank"],
|
||||
name="charts_char_user_id_ba968a_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "book", "rank"],
|
||||
name="charts_char_user_id_e66751_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "food", "rank"],
|
||||
name="charts_char_user_id_d23f06_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "podcast", "rank"],
|
||||
name="charts_char_user_id_be8122_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "week", "trail", "rank"],
|
||||
name="charts_char_user_id_b94ea9_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "day", "artist", "rank"],
|
||||
name="charts_char_user_id_406e0e_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "day", "album", "rank"],
|
||||
name="charts_char_user_id_322b0d_idx",
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="chartrecord",
|
||||
index=models.Index(
|
||||
fields=["user", "year", "month", "day", "tv_series", "rank"],
|
||||
name="charts_char_user_id_aa44b7_idx",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,109 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-19 16:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("charts", "0002_chartrecord_charts_char_user_id_1adcde_idx_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("artist__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "artist"),
|
||||
name="unique_chart_artist_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("album__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "album"),
|
||||
name="unique_chart_album_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("track__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "track"),
|
||||
name="unique_chart_track_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("tv_series__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "tv_series"),
|
||||
name="unique_chart_tv_series_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("video__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "video"),
|
||||
name="unique_chart_video_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("podcast__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "podcast"),
|
||||
name="unique_chart_podcast_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("podcast_episode__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "podcast_episode"),
|
||||
name="unique_chart_podcast_episode_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("board_game__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "board_game"),
|
||||
name="unique_chart_board_game_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("trail__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "trail"),
|
||||
name="unique_chart_trail_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("geo_location__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "geo_location"),
|
||||
name="unique_chart_geo_location_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("food__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "food"),
|
||||
name="unique_chart_food_period",
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="chartrecord",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("book__isnull", False)),
|
||||
fields=("user", "year", "month", "week", "day", "book"),
|
||||
name="unique_chart_book_period",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -2,6 +2,7 @@ import calendar
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
|
||||
@ -60,10 +61,91 @@ class ChartRecord(TimeStampedModel):
|
||||
models.Index(fields=["user", "year", "geo_location", "rank"]),
|
||||
models.Index(fields=["user", "year", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "track", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "video", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "board_game", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "podcast", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "trail", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "track", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "video", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "board_game", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "book", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "food", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "podcast", "rank"]),
|
||||
models.Index(fields=["user", "year", "week", "trail", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "day", "artist", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
|
||||
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
|
||||
]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "artist"],
|
||||
condition=Q(artist__isnull=False),
|
||||
name="unique_chart_artist_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "album"],
|
||||
condition=Q(album__isnull=False),
|
||||
name="unique_chart_album_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "track"],
|
||||
condition=Q(track__isnull=False),
|
||||
name="unique_chart_track_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "tv_series"],
|
||||
condition=Q(tv_series__isnull=False),
|
||||
name="unique_chart_tv_series_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "video"],
|
||||
condition=Q(video__isnull=False),
|
||||
name="unique_chart_video_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "podcast"],
|
||||
condition=Q(podcast__isnull=False),
|
||||
name="unique_chart_podcast_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "podcast_episode"],
|
||||
condition=Q(podcast_episode__isnull=False),
|
||||
name="unique_chart_podcast_episode_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "board_game"],
|
||||
condition=Q(board_game__isnull=False),
|
||||
name="unique_chart_board_game_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "trail"],
|
||||
condition=Q(trail__isnull=False),
|
||||
name="unique_chart_trail_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "geo_location"],
|
||||
condition=Q(geo_location__isnull=False),
|
||||
name="unique_chart_geo_location_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "food"],
|
||||
condition=Q(food__isnull=False),
|
||||
name="unique_chart_food_period",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "year", "month", "week", "day", "book"],
|
||||
condition=Q(book__isnull=False),
|
||||
name="unique_chart_book_period",
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
@ -110,14 +192,13 @@ class ChartRecord(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def period_str(self) -> str:
|
||||
period = str(self.year)
|
||||
if self.month:
|
||||
period = f"{calendar.month_name[self.month]} {period}"
|
||||
if self.week:
|
||||
period = f"Week {self.week}, {period}"
|
||||
if self.day:
|
||||
period = f"{calendar.month_name[self.month]} {self.day}, {period}"
|
||||
return period
|
||||
return f"{calendar.month_name[self.month]} {self.day}, {self.year}"
|
||||
if self.week:
|
||||
return f"Week {self.week}, {self.year}"
|
||||
if self.month:
|
||||
return f"{calendar.month_name[self.month]} {self.year}"
|
||||
return str(self.year)
|
||||
|
||||
@property
|
||||
def period_type(self) -> str:
|
||||
|
||||
@ -83,6 +83,12 @@
|
||||
{% endfor %}
|
||||
<a href="#" class="btn btn-xs btn-outline-secondary" style="padding:1px 3px;font-size:9px;" onclick="var w=prompt('Week number (1-52):');if(w)this.href='/charts/?date={{ year }}-W'+w.padStart(2,'0');return false;">+</a>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
||||
<a href="{% url 'charts:bandcamp-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Bandcamp Tracks</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if chart_type == "maloja" %}
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
{% else %}
|
||||
@ -337,7 +343,7 @@
|
||||
{% for chart in charts.trail %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.trail.get_absolute_url}}">{{chart.trail.name}}</a></td>
|
||||
<td><a href="{{chart.trail.get_absolute_url}}">{{chart.trail.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -7,7 +7,10 @@ register = template.Library()
|
||||
def get_item(dictionary, key):
|
||||
if isinstance(dictionary, dict):
|
||||
return dictionary.get(key)
|
||||
return None
|
||||
try:
|
||||
return dictionary[int(key)]
|
||||
except (IndexError, KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@register.filter
|
||||
|
||||
@ -1,9 +1,20 @@
|
||||
from charts.views import ChartRecordView, SpotifyTracksView
|
||||
from charts.views import (
|
||||
BandcampTracksView,
|
||||
BirdsChartView,
|
||||
ChartDetailView,
|
||||
ChartRecordView,
|
||||
MalojaChartsView,
|
||||
SpotifyTracksView,
|
||||
)
|
||||
from django.urls import path
|
||||
|
||||
app_name = "charts"
|
||||
|
||||
urlpatterns = [
|
||||
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
||||
path("charts/maloja/", MalojaChartsView.as_view(), name="maloja-charts"),
|
||||
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
||||
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
|
||||
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
|
||||
path("charts/<slug:media_type>/", ChartDetailView.as_view(), name="chart-detail"),
|
||||
]
|
||||
|
||||
@ -6,6 +6,7 @@ from typing import Optional
|
||||
import pytz
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.utils import timezone
|
||||
|
||||
@ -104,8 +105,8 @@ def build_charts(
|
||||
|
||||
media_config = {
|
||||
"artist": {
|
||||
"filter": Q(track__isnull=False) & Q(track__artist__isnull=False),
|
||||
"values": "track__artist",
|
||||
"filter": Q(track__isnull=False) & Q(track__artists__isnull=False),
|
||||
"values": "track__artists",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"album": {
|
||||
@ -186,60 +187,64 @@ def build_charts(
|
||||
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
|
||||
|
||||
media_field = f"{media_type}_id"
|
||||
records_to_create = []
|
||||
records_to_update = []
|
||||
|
||||
existing = ChartRecord.objects.filter(
|
||||
period_filter, user=user, **{media_field + "__isnull": False}
|
||||
)
|
||||
existing_by_media_id = {getattr(r, media_field): r for r in existing}
|
||||
found_media_ids = set()
|
||||
with transaction.atomic():
|
||||
records_to_create = []
|
||||
records_to_update = []
|
||||
|
||||
for result in results:
|
||||
media_id = result[config["values"]]
|
||||
if media_id is None:
|
||||
continue
|
||||
|
||||
found_media_ids.add(media_id)
|
||||
|
||||
chart_record_data = {
|
||||
"user_id": user.id,
|
||||
"year": year,
|
||||
"month": month,
|
||||
"week": week,
|
||||
"day": day,
|
||||
"rank": ranks[result["scrobble_count"]],
|
||||
"count": result["scrobble_count"],
|
||||
}
|
||||
chart_record_data[media_field] = media_id
|
||||
|
||||
if media_id in existing_by_media_id:
|
||||
existing_record = existing_by_media_id[media_id]
|
||||
existing_record.rank = chart_record_data["rank"]
|
||||
existing_record.count = chart_record_data["count"]
|
||||
records_to_update.append(existing_record)
|
||||
else:
|
||||
records_to_create.append(ChartRecord(**chart_record_data))
|
||||
|
||||
ids_to_delete = [
|
||||
r.id for r in existing if getattr(r, media_field) not in found_media_ids
|
||||
]
|
||||
if ids_to_delete:
|
||||
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
|
||||
|
||||
if records_to_update:
|
||||
ChartRecord.objects.bulk_update(
|
||||
records_to_update, ["rank", "count"], batch_size=500
|
||||
existing = ChartRecord.objects.select_for_update().filter(
|
||||
period_filter, user=user, **{media_field + "__isnull": False}
|
||||
)
|
||||
existing_by_media_id = {getattr(r, media_field): r for r in existing}
|
||||
found_media_ids = set()
|
||||
|
||||
if records_to_create:
|
||||
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
|
||||
for result in results:
|
||||
media_id = result[config["values"]]
|
||||
if media_id is None:
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
|
||||
f"chart records for {media_type}, period "
|
||||
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
|
||||
)
|
||||
found_media_ids.add(media_id)
|
||||
|
||||
chart_record_data = {
|
||||
"user_id": user.id,
|
||||
"year": year,
|
||||
"month": month,
|
||||
"week": week,
|
||||
"day": day,
|
||||
"rank": ranks[result["scrobble_count"]],
|
||||
"count": result["scrobble_count"],
|
||||
}
|
||||
chart_record_data[media_field] = media_id
|
||||
|
||||
if media_id in existing_by_media_id:
|
||||
existing_record = existing_by_media_id[media_id]
|
||||
existing_record.rank = chart_record_data["rank"]
|
||||
existing_record.count = chart_record_data["count"]
|
||||
records_to_update.append(existing_record)
|
||||
else:
|
||||
records_to_create.append(ChartRecord(**chart_record_data))
|
||||
|
||||
ids_to_delete = [
|
||||
r.id
|
||||
for r in existing
|
||||
if getattr(r, media_field) not in found_media_ids
|
||||
]
|
||||
if ids_to_delete:
|
||||
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
|
||||
|
||||
if records_to_update:
|
||||
ChartRecord.objects.bulk_update(
|
||||
records_to_update, ["rank", "count"], batch_size=500
|
||||
)
|
||||
|
||||
if records_to_create:
|
||||
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
|
||||
|
||||
logger.info(
|
||||
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
|
||||
f"chart records for {media_type}, period "
|
||||
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
|
||||
)
|
||||
|
||||
|
||||
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:
|
||||
|
||||
@ -3,6 +3,7 @@ from datetime import timedelta
|
||||
|
||||
from charts.models import ChartRecord
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView
|
||||
from profiles.utils import now_user_timezone
|
||||
@ -113,176 +114,13 @@ class ChartRecordView(TemplateView):
|
||||
context["current_week"] = current_week
|
||||
context["current_day"] = current_day
|
||||
|
||||
if chart_type == "maloja":
|
||||
context["chart_keys"] = {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month",
|
||||
"year": "This Year",
|
||||
"all": "All Time",
|
||||
}
|
||||
|
||||
context["maloja_charts"] = {
|
||||
"artist": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "artist", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "artist")),
|
||||
},
|
||||
"track": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"track",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user, "track", year=current_year, week=current_week
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"track",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "track", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "track")),
|
||||
},
|
||||
"tv_series": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "tv_series", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "tv_series")),
|
||||
},
|
||||
}
|
||||
return context
|
||||
|
||||
if not date_param:
|
||||
context["period"] = "current"
|
||||
context["year"] = current_year
|
||||
context["month"] = current_month
|
||||
context["week"] = current_week
|
||||
context["day"] = current_day
|
||||
|
||||
context["charts"] = {
|
||||
"artist": list(
|
||||
self.get_charts_for_period(
|
||||
user, "artist", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"album": list(
|
||||
self.get_charts_for_period(
|
||||
user, "album", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"track": list(
|
||||
self.get_charts_for_period(
|
||||
user, "track", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"tv_series": list(
|
||||
self.get_charts_for_period(
|
||||
user, "tv_series", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"video": list(
|
||||
self.get_charts_for_period(
|
||||
user, "video", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"board_game": list(
|
||||
self.get_charts_for_period(
|
||||
user, "board_game", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"book": list(
|
||||
self.get_charts_for_period(
|
||||
user, "book", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"food": list(
|
||||
self.get_charts_for_period(
|
||||
user, "food", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"podcast": list(
|
||||
self.get_charts_for_period(
|
||||
user, "podcast", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"trail": list(
|
||||
self.get_charts_for_period(
|
||||
user, "trail", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
}
|
||||
else:
|
||||
# Resolve date parameters
|
||||
if date_param:
|
||||
parts = date_param.split("-")
|
||||
year = int(parts[0])
|
||||
|
||||
week = None
|
||||
month = None
|
||||
day = None
|
||||
|
||||
if len(parts) >= 2 and parts[1].startswith("W"):
|
||||
week = int(parts[1].lstrip("W"))
|
||||
elif len(parts) >= 2 and parts[1]:
|
||||
@ -290,19 +128,17 @@ class ChartRecordView(TemplateView):
|
||||
month = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if len(parts) >= 3:
|
||||
if parts[2].startswith("W"):
|
||||
week = int(parts[2].lstrip("W"))
|
||||
elif not parts[2].startswith("W"):
|
||||
day = int(parts[2])
|
||||
|
||||
context["period"] = "historical"
|
||||
context["year"] = year
|
||||
context["month"] = month
|
||||
context["month_name"] = calendar.month_name[month] if month else None
|
||||
context["week"] = week
|
||||
context["day"] = day
|
||||
|
||||
period_str = str(year)
|
||||
if month:
|
||||
period_str = f"{calendar.month_name[month]} {period_str}"
|
||||
@ -311,109 +147,96 @@ class ChartRecordView(TemplateView):
|
||||
if day:
|
||||
period_str = f"{calendar.month_name[month]} {day}, {year}"
|
||||
context["period_str"] = period_str
|
||||
else:
|
||||
year = current_year
|
||||
month = current_month
|
||||
week = current_week
|
||||
day = current_day
|
||||
context["period"] = "current"
|
||||
context["year"] = current_year
|
||||
context["month"] = current_month
|
||||
context["month_name"] = calendar.month_name[current_month]
|
||||
context["week"] = current_week
|
||||
context["day"] = current_day
|
||||
|
||||
context["charts"] = {
|
||||
"artist": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"album": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"track": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"track",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"tv_series": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"video": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"video",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"board_game": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"board_game",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"book": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"book",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"food": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"food",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"podcast": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"podcast",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"trail": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"trail",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
}
|
||||
# List-group tables default to week-level when no date param (matches active tab)
|
||||
if not date_param:
|
||||
list_year = current_year
|
||||
list_month = None
|
||||
list_week = current_week
|
||||
list_day = None
|
||||
else:
|
||||
list_year = year
|
||||
list_month = month
|
||||
list_week = week
|
||||
list_day = day
|
||||
|
||||
context["charts"] = {
|
||||
"artist": list(
|
||||
self.get_charts_for_period(
|
||||
user, "artist", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"album": list(
|
||||
self.get_charts_for_period(
|
||||
user, "album", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"track": list(
|
||||
self.get_charts_for_period(
|
||||
user, "track", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"tv_series": list(
|
||||
self.get_charts_for_period(
|
||||
user, "tv_series", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"video": list(
|
||||
self.get_charts_for_period(
|
||||
user, "video", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"board_game": list(
|
||||
self.get_charts_for_period(
|
||||
user, "board_game", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"book": list(
|
||||
self.get_charts_for_period(
|
||||
user, "book", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"food": list(
|
||||
self.get_charts_for_period(
|
||||
user, "food", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"podcast": list(
|
||||
self.get_charts_for_period(
|
||||
user, "podcast", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"trail": list(
|
||||
self.get_charts_for_period(
|
||||
user, "trail", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
bird_data = self.get_bird_chart_data(
|
||||
user,
|
||||
year=context.get("year"),
|
||||
month=context.get("month"),
|
||||
week=context.get("week"),
|
||||
day=context.get("day"),
|
||||
)
|
||||
context["birds_chart"] = bird_data["top_birds"]
|
||||
context["bird_stats"] = {
|
||||
"total_species": bird_data["total_species"],
|
||||
"total_outings": bird_data["total_outings"],
|
||||
"total_individuals": bird_data["total_individuals"],
|
||||
}
|
||||
|
||||
context["chart_years"] = self.get_available_years(user)
|
||||
context["period_type"] = self.get_period_type()
|
||||
@ -423,12 +246,14 @@ class ChartRecordView(TemplateView):
|
||||
return context
|
||||
|
||||
def get_available_years(self, user):
|
||||
return list(
|
||||
ChartRecord.objects.filter(user=user)
|
||||
.values_list("year", flat=True)
|
||||
.distinct()
|
||||
.order_by("-year")
|
||||
)
|
||||
if not hasattr(self, "_available_years"):
|
||||
self._available_years = list(
|
||||
ChartRecord.objects.filter(user=user)
|
||||
.values_list("year", flat=True)
|
||||
.distinct()
|
||||
.order_by("-year")
|
||||
)
|
||||
return self._available_years
|
||||
|
||||
def get_period_type(self):
|
||||
date_param = self.request.GET.get("date")
|
||||
@ -558,17 +383,227 @@ class ChartRecordView(TemplateView):
|
||||
return f"/charts/?date={year + 1}"
|
||||
return None
|
||||
|
||||
def get_bird_chart_data(
|
||||
self, user, year=None, month=None, week=None, day=None, limit=20
|
||||
):
|
||||
from birds.models import Bird
|
||||
|
||||
filters = {
|
||||
"user": user,
|
||||
"media_type": Scrobble.MediaType.BIRDING_LOCATION,
|
||||
}
|
||||
if year:
|
||||
filters["timestamp__year"] = year
|
||||
if month:
|
||||
filters["timestamp__month"] = month
|
||||
if week:
|
||||
filters["timestamp__week"] = week
|
||||
if day:
|
||||
filters["timestamp__day"] = day
|
||||
|
||||
scrobbles = Scrobble.objects.filter(**filters)
|
||||
|
||||
bird_counts = {}
|
||||
outings = 0
|
||||
for scrobble in scrobbles.iterator():
|
||||
outings += 1
|
||||
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
|
||||
for entry in birds_data:
|
||||
bird_id = entry.get("bird_id")
|
||||
quantity = entry.get("quantity", 1)
|
||||
if bird_id:
|
||||
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
|
||||
|
||||
sorted_birds = sorted(bird_counts.items(), key=lambda x: x[1], reverse=True)[
|
||||
:limit
|
||||
]
|
||||
|
||||
bird_ids = [bid for bid, _ in sorted_birds]
|
||||
birds = Bird.objects.filter(id__in=bird_ids)
|
||||
bird_map = {b.id: b for b in birds}
|
||||
|
||||
top_birds = [
|
||||
{"bird": bird_map[bid], "count": count}
|
||||
for bid, count in sorted_birds
|
||||
if bid in bird_map
|
||||
]
|
||||
|
||||
return {
|
||||
"top_birds": top_birds,
|
||||
"total_outings": outings,
|
||||
"total_species": len(bird_counts),
|
||||
"total_individuals": sum(bird_counts.values()),
|
||||
}
|
||||
|
||||
|
||||
class MalojaChartsView(ChartRecordView):
|
||||
"""Three maloja-themed image grid widgets (artists, albums, TV series)
|
||||
with Today/Week/Month/Year/All tabs. Each tab computes its own period
|
||||
from the current date — no query param needed."""
|
||||
|
||||
template_name = "charts/maloja_charts.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(ChartRecordView, self).get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
context["maloja_charts"] = {}
|
||||
context["chart_keys"] = {}
|
||||
return context
|
||||
|
||||
now = timezone.now()
|
||||
now = now_user_timezone(user.profile)
|
||||
today = now.date()
|
||||
|
||||
context["chart_keys"] = {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month",
|
||||
"year": "This Year",
|
||||
"all": "All Time",
|
||||
}
|
||||
|
||||
tab_params = {
|
||||
"today": {"year": today.year, "month": today.month, "day": today.day},
|
||||
"week": {"year": today.year, "week": today.isocalendar()[1]},
|
||||
"month": {"year": today.year, "month": today.month},
|
||||
"year": {"year": today.year},
|
||||
}
|
||||
|
||||
maloja_charts = {}
|
||||
for media_type in ("artist", "album", "tv_series"):
|
||||
tabs = {}
|
||||
for key in ("today", "week", "month", "year"):
|
||||
tabs[key] = list(
|
||||
self.get_charts_for_period(user, media_type, **tab_params[key])
|
||||
)
|
||||
tabs["all"] = list(
|
||||
self.get_charts_for_period(user, media_type)
|
||||
)
|
||||
maloja_charts[media_type] = tabs
|
||||
|
||||
context["maloja_charts"] = maloja_charts
|
||||
return context
|
||||
|
||||
|
||||
MEDIA_TYPE_LABELS = {
|
||||
"artist": ("🎤", "Top Artists"),
|
||||
"album": ("💿", "Top Albums"),
|
||||
"track": ("🎵", "Top Tracks"),
|
||||
"tv_series": ("📺", "Top TV Series"),
|
||||
"video": ("🎬", "Top Videos"),
|
||||
"podcast": ("🎙️", "Top Podcasts"),
|
||||
"podcast_episode": ("🎙️", "Top Podcast Episodes"),
|
||||
"board_game": ("🎲", "Top Board Games"),
|
||||
"book": ("📚", "Top Books"),
|
||||
"food": ("🍽️", "Top Foods"),
|
||||
"trail": ("🥾", "Top Trails"),
|
||||
"geo_location": ("📍", "Top Locations"),
|
||||
}
|
||||
|
||||
|
||||
class ChartDetailView(TemplateView):
|
||||
template_name = "charts/chart_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
media_type = kwargs.get("media_type")
|
||||
|
||||
if media_type not in MEDIA_TYPE_FILTERS:
|
||||
raise Http404
|
||||
|
||||
date_param = self.request.GET.get("date")
|
||||
now = timezone.now()
|
||||
if user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
today = now.date()
|
||||
|
||||
year = today.year
|
||||
month = None
|
||||
week = None
|
||||
day = None
|
||||
|
||||
if date_param:
|
||||
parts = date_param.split("-")
|
||||
year = int(parts[0])
|
||||
if len(parts) >= 2 and parts[1].startswith("W"):
|
||||
week = int(parts[1].lstrip("W"))
|
||||
month = None
|
||||
elif len(parts) >= 2:
|
||||
try:
|
||||
month = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) >= 3:
|
||||
if parts[2].startswith("W"):
|
||||
week = int(parts[2].lstrip("W"))
|
||||
else:
|
||||
day = int(parts[2])
|
||||
|
||||
params = {"user": user}
|
||||
if year:
|
||||
params["year"] = year
|
||||
if month:
|
||||
params["month"] = month
|
||||
if week:
|
||||
params["week"] = week
|
||||
if day:
|
||||
params["day"] = day
|
||||
|
||||
media_filter = MEDIA_TYPE_FILTERS.get(media_type, Q())
|
||||
qs = ChartRecord.objects.filter(media_filter, **params)
|
||||
|
||||
if day is not None:
|
||||
qs = qs.filter(day__isnull=False)
|
||||
elif week is not None:
|
||||
qs = qs.filter(week__isnull=False, day__isnull=True)
|
||||
elif month is not None:
|
||||
qs = qs.filter(month__isnull=False, week__isnull=True, day__isnull=True)
|
||||
else:
|
||||
qs = qs.filter(month__isnull=True, week__isnull=True, day__isnull=True)
|
||||
|
||||
context["page_charts"] = qs.order_by("rank")
|
||||
context["media_type"] = media_type
|
||||
emoji, label = MEDIA_TYPE_LABELS.get(media_type, ("", media_type))
|
||||
context["media_label"] = label
|
||||
context["media_emoji"] = emoji
|
||||
context["year"] = year
|
||||
context["month"] = month
|
||||
context["week"] = week
|
||||
context["day"] = day
|
||||
|
||||
context["chart_years"] = list(
|
||||
ChartRecord.objects.filter(Q(user=user) & media_filter)
|
||||
.values_list("year", flat=True)
|
||||
.distinct()
|
||||
.order_by("-year")
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class SpotifyTracksView(TemplateView):
|
||||
template_name = "charts/spotify_tracks.html"
|
||||
|
||||
def get_spotify_tracks(self, user, limit=50):
|
||||
non_spotify_mopidy_tracks = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
source="Mopidy",
|
||||
track__isnull=False,
|
||||
)
|
||||
.exclude(log__mopidy_source="spotify")
|
||||
.values("track")
|
||||
)
|
||||
track_ids = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
track__isnull=False,
|
||||
)
|
||||
.filter(Q(source="Last.fm") | Q(log__mopidy_source="spotify"))
|
||||
.exclude(track__in=non_spotify_mopidy_tracks)
|
||||
.values("track")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:limit]
|
||||
@ -594,3 +629,144 @@ class SpotifyTracksView(TemplateView):
|
||||
user = self.request.user
|
||||
context["spotify_tracks"] = self.get_spotify_tracks(user)
|
||||
return context
|
||||
|
||||
|
||||
class BandcampTracksView(TemplateView):
|
||||
template_name = "charts/bandcamp_tracks.html"
|
||||
|
||||
def get_bandcamp_tracks(self, user, limit=50):
|
||||
non_bandcamp_mopidy_tracks = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
source="Mopidy",
|
||||
track__isnull=False,
|
||||
)
|
||||
.exclude(log__mopidy_source="bandcamp")
|
||||
.values("track")
|
||||
)
|
||||
track_ids = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
track__isnull=False,
|
||||
log__mopidy_source="bandcamp",
|
||||
)
|
||||
.exclude(track__in=non_bandcamp_mopidy_tracks)
|
||||
.values("track")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:limit]
|
||||
)
|
||||
from music.models import Track
|
||||
|
||||
track_id_list = [item["track"] for item in track_ids]
|
||||
tracks = Track.objects.filter(id__in=track_id_list)
|
||||
track_map = {t.id: t for t in tracks}
|
||||
return [
|
||||
{
|
||||
"track": track_map[tid],
|
||||
"count": next(
|
||||
(item["count"] for item in track_ids if item["track"] == tid), 0
|
||||
),
|
||||
}
|
||||
for tid in track_id_list
|
||||
if tid in track_map
|
||||
]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
context["bandcamp_tracks"] = self.get_bandcamp_tracks(user)
|
||||
return context
|
||||
|
||||
|
||||
class BirdsChartView(TemplateView):
|
||||
template_name = "charts/birds_chart.html"
|
||||
|
||||
def get_bird_data(self, user, year=None, month=None, week=None, day=None, limit=50):
|
||||
from birds.models import Bird
|
||||
|
||||
filters = {
|
||||
"user": user,
|
||||
"media_type": Scrobble.MediaType.BIRDING_LOCATION,
|
||||
}
|
||||
if year:
|
||||
filters["timestamp__year"] = year
|
||||
if month:
|
||||
filters["timestamp__month"] = month
|
||||
if week:
|
||||
filters["timestamp__week"] = week
|
||||
if day:
|
||||
filters["timestamp__day"] = day
|
||||
|
||||
scrobbles = Scrobble.objects.filter(**filters)
|
||||
|
||||
bird_counts = {}
|
||||
outings = 0
|
||||
for scrobble in scrobbles.iterator():
|
||||
outings += 1
|
||||
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
|
||||
for entry in birds_data:
|
||||
bird_id = entry.get("bird_id")
|
||||
quantity = entry.get("quantity", 1)
|
||||
if bird_id:
|
||||
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
|
||||
|
||||
sorted_birds = sorted(bird_counts.items(), key=lambda x: x[1], reverse=True)[
|
||||
:limit
|
||||
]
|
||||
|
||||
bird_ids = [bid for bid, _ in sorted_birds]
|
||||
birds = Bird.objects.filter(id__in=bird_ids)
|
||||
bird_map = {b.id: b for b in birds}
|
||||
|
||||
top_birds = [
|
||||
{"bird": bird_map[bid], "count": count}
|
||||
for bid, count in sorted_birds
|
||||
if bid in bird_map
|
||||
]
|
||||
|
||||
return {
|
||||
"top_birds": top_birds,
|
||||
"total_outings": outings,
|
||||
"total_species": len(bird_counts),
|
||||
"total_individuals": sum(bird_counts.values()),
|
||||
}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
|
||||
date_param = self.request.GET.get("date")
|
||||
now = timezone.now()
|
||||
if user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
today = now.date()
|
||||
|
||||
year = None
|
||||
month = None
|
||||
week = None
|
||||
day = None
|
||||
|
||||
if date_param:
|
||||
parts = date_param.split("-")
|
||||
year = int(parts[0])
|
||||
if len(parts) >= 2 and parts[1].startswith("W"):
|
||||
week = int(parts[1].lstrip("W"))
|
||||
elif len(parts) >= 2:
|
||||
try:
|
||||
month = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) >= 3:
|
||||
if parts[2].startswith("W"):
|
||||
week = int(parts[2].lstrip("W"))
|
||||
else:
|
||||
day = int(parts[2])
|
||||
|
||||
bird_data = self.get_bird_data(user, year=year, month=month, week=week, day=day)
|
||||
context["birds_chart"] = bird_data["top_birds"]
|
||||
context["bird_stats"] = {
|
||||
"total_species": bird_data["total_species"],
|
||||
"total_outings": bird_data["total_outings"],
|
||||
"total_individuals": bird_data["total_individuals"],
|
||||
}
|
||||
return context
|
||||
|
||||
0
vrobbler/apps/discgolf/__init__.py
Normal file
0
vrobbler/apps/discgolf/__init__.py
Normal file
14
vrobbler/apps/discgolf/admin.py
Normal file
14
vrobbler/apps/discgolf/admin.py
Normal file
@ -0,0 +1,14 @@
|
||||
from discgolf.models import DiscGolfCourse
|
||||
from django.contrib import admin
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(DiscGolfCourse)
|
||||
class DiscGolfCourseAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("title", "layout_name", "number_of_holes", "par_total", "pdga_slug", "udisc_id")
|
||||
raw_id_fields = ("trail",)
|
||||
search_fields = ("title", "layout_name")
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
0
vrobbler/apps/discgolf/api/__init__.py
Normal file
0
vrobbler/apps/discgolf/api/__init__.py
Normal file
14
vrobbler/apps/discgolf/api/serializers.py
Normal file
14
vrobbler/apps/discgolf/api/serializers.py
Normal file
@ -0,0 +1,14 @@
|
||||
from discgolf import models
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class DiscGolfCourseSerializer(serializers.HyperlinkedModelSerializer):
|
||||
pdga_link = serializers.ReadOnlyField()
|
||||
udisc_link = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = models.DiscGolfCourse
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
|
||||
13
vrobbler/apps/discgolf/api/views.py
Normal file
13
vrobbler/apps/discgolf/api/views.py
Normal file
@ -0,0 +1,13 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from discgolf.api import serializers
|
||||
from discgolf import models
|
||||
|
||||
|
||||
class DiscGolfCourseViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.DiscGolfCourse.objects.all().order_by("-created")
|
||||
serializer_class = serializers.DiscGolfCourseSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
|
||||
6
vrobbler/apps/discgolf/apps.py
Normal file
6
vrobbler/apps/discgolf/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DiscgolfConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "discgolf"
|
||||
0
vrobbler/apps/discgolf/management/__init__.py
Normal file
0
vrobbler/apps/discgolf/management/__init__.py
Normal file
83
vrobbler/apps/discgolf/migrations/0001_initial.py
Normal file
83
vrobbler/apps/discgolf/migrations/0001_initial.py
Normal file
@ -0,0 +1,83 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-20 04:19
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("scrobbles", "0098_scrobble_long_play_last_scrobble"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DiscGolfCourse",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("title", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"layout_name",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
("number_of_holes", models.IntegerField(blank=True, null=True)),
|
||||
("par_total", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"genre",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-20 04:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("trails", "0009_trail_route_waypoint"),
|
||||
("discgolf", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="discgolfcourse",
|
||||
name="trail",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="trails.trail",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-20 04:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("discgolf", "0002_discgolfcourse_trail"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="discgolfcourse",
|
||||
name="par_per_hole",
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user