Compare commits
418 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| f160f5a7b8 | |||
| b967c526f1 | |||
| 77f143299d | |||
| 7b7c66de8f | |||
| 3b8d7421b1 | |||
| e707c94b70 | |||
| a5510d7294 | |||
| 666224875b | |||
| 1866b43cbe | |||
| df6a16f1e7 | |||
| 752b4afaa9 | |||
| 5175a9a39a | |||
| 9d519138aa | |||
| cc31c7e22e | |||
| eb604f5eb2 | |||
| 2b2b20d1b7 | |||
| 41a7255ed8 | |||
| fc4db68725 | |||
| b1d6f4726b | |||
| cbdb5c49d0 | |||
| df2108807d | |||
| de733d5893 | |||
| acf0c342bf | |||
| f486b1614b | |||
| 9642aebfc0 | |||
| 9780e39825 | |||
| 5739b23f99 | |||
| 181700b05e | |||
| 05cbcc1967 | |||
| ef4e510814 | |||
| e912eda6e4 | |||
| caf56289b4 | |||
| 49a2429a4c | |||
| 88178e5ad2 | |||
| 69dd47eac7 | |||
| b11bb782e3 | |||
| 523ed3a499 | |||
| decaba82f2 | |||
| 4931e2d87b | |||
| 25a60f4d60 | |||
| 6aef34e43f | |||
| 710aff5de4 | |||
| df673eaccc | |||
| 3b266083b0 | |||
| 29e179adad | |||
| b6af201ba3 | |||
| 1bf558938d | |||
| ce7128c7ac | |||
| 9ce6dd8876 | |||
| 78651af802 | |||
| 0896517345 | |||
| 0d6fb5928b | |||
| 2dbd752609 | |||
| 01aa0cba76 | |||
| c5b7e57005 | |||
| 0a74c692d2 | |||
| 192d0c489b | |||
| 9a8d5a7608 | |||
| 6f6063e204 | |||
| 59998ac849 | |||
| 3058bfdd22 | |||
| 173bbfa91f | |||
| 6b278ad99f | |||
| 2ca3dd1ed9 | |||
| 1e21bd9481 | |||
| ea66ee376d | |||
| 64854f78a6 | |||
| 3d2f3cbe71 | |||
| 931246e043 | |||
| 77fd289c38 | |||
| a5e8c97063 | |||
| bf01f45eca | |||
| 3beb0bc879 | |||
| 70bf06df38 | |||
| 062386a5b6 | |||
| 49f1410814 | |||
| 2ab6fc6cba | |||
| 3db0330cfe | |||
| b949a84763 | |||
| 45d524ca61 | |||
| 5b3e91fdc1 | |||
| a79cc4c217 | |||
| 06acc9ec2b | |||
| 4121915aa3 | |||
| cce2db0ea1 | |||
| 6766ea7dbb | |||
| 125222845b | |||
| 41bb52c551 | |||
| 24c7c33ac2 | |||
| 1ea29fee1b | |||
| 21355bae41 | |||
| 7bb53809ad | |||
| d576467db8 | |||
| ab4b5470b7 | |||
| 18e7af5052 | |||
| d4aefa6678 | |||
| 0917025cac | |||
| b61339b25c | |||
| e36a0726c8 | |||
| 44c6e014f5 | |||
| 881450eb8d | |||
| d9a7a1cfd9 | |||
| 573321420d | |||
| a874e9c712 | |||
| 8ca73693d1 | |||
| 9f3b7b0361 | |||
| 1a2c39b4d3 | |||
| 44eb193b33 | |||
| 466d8d26c6 | |||
| 61c583e497 | |||
| 83d89001a7 | |||
| 02d13b5a99 | |||
| bf17a0eeab | |||
| d29708bd59 | |||
| d5d27256f8 | |||
| 565adfe58e | |||
| 16cd990c34 | |||
| 190f486c49 | |||
| a36abacfbd | |||
| 7666958974 | |||
| 3a02bcad9d | |||
| 6a2cb4a881 | |||
| 10af7190ab | |||
| 62c81c65ef | |||
| 29a677142c | |||
| 29cd2f8015 | |||
| 5e835595c3 | |||
| b75b259bd9 | |||
| c1ef057ad2 | |||
| 8bfc5646f9 | |||
| 851c747a60 | |||
| 49b8b86249 | |||
| e8f120f85e | |||
| 34a48c8c7b | |||
| 4a18c01cdb | |||
| f4de4dbcb7 | |||
| 64ec3c1cca | |||
| 7a74f7f882 | |||
| 373db5563a | |||
| 349b10904a | |||
| 8e8d25aa1d | |||
| 28db747b59 | |||
| a0b867e20a | |||
| 6d25e5f663 | |||
| 2e7d6364a2 | |||
| a8dc336950 | |||
| 653aabfbb1 | |||
| 48e13af2e8 | |||
| f1f615f0ed | |||
| 052d75ea26 | |||
| 223de52a12 | |||
| a5ff6abf56 | |||
| 74252b8759 | |||
| 466f33dd3c | |||
| c9abb22bfb | |||
| 18d21e6651 | |||
| 1f67d4c0a6 | |||
| ff3fe00afa | |||
| 2e289f3852 | |||
| c0659f26a5 | |||
| 8ac6ed542f | |||
| 14e32dae12 | |||
| 31d3a85e8c | |||
| 7cf817025b | |||
| f5675e5319 | |||
| 40617b77e2 | |||
| 64967aa357 | |||
| 88166d01eb | |||
| f506fa465f | |||
| 5934dcdf8e | |||
| 1e11679419 | |||
| 0e5d8e6b2f | |||
| 118e208a36 | |||
| 93666cec54 | |||
| 6d5ebb68e3 | |||
| c89b434d18 | |||
| e83f922e32 | |||
| 508e2db60e | |||
| e9f9004d6d | |||
| e3364d15ce | |||
| 744e9f1d38 | |||
| a343e2f3fa | |||
| 4036e883fd | |||
| ff6de28b24 | |||
| ad03f22b20 | |||
| 4f8cf4f244 | |||
| d6efdb6979 | |||
| d38e960897 | |||
| 88a8b8320c | |||
| 5d9834b63d | |||
| 2dc7acc536 | |||
| 3a79c17006 | |||
| c43771c757 | |||
| a5eff556be | |||
| 5b8559efd0 | |||
| e3fb529419 | |||
| 8357ce8901 | |||
| 8309a4e3c3 | |||
| 7d8e1ac817 | |||
| 446144bc51 | |||
| d36502ec09 | |||
| 770a51b9c0 | |||
| 210ff0a4aa | |||
| db52ebeea7 | |||
| 0e7286ac4e | |||
| 4ee687b9c7 | |||
| 4b8785ead7 | |||
| bec7ef337e | |||
| e7bc38d0f8 | |||
| 1be8e4b083 | |||
| 88a94aadca | |||
| 2d8f433314 | |||
| d1844c01a0 | |||
| 9848e5874d | |||
| a027e877f7 | |||
| 82a7fd8673 | |||
| c897507de4 | |||
| 009ed2ace3 | |||
| d945acfb92 | |||
| 8fd0ed6e17 | |||
| a5d72ce4c3 | |||
| 809014736c | |||
| fcccd6d9a4 | |||
| a5295d7973 | |||
| 186ae18e1f | |||
| 6f7f739ca6 | |||
| c2ba8a48ac | |||
| 1530de3188 | |||
| 2d235c0577 | |||
| b0eb58953b | |||
| 7309181fed | |||
| 971fee5b4b | |||
| 920a9180c8 | |||
| d568a377f0 | |||
| 3851624dd7 | |||
| 8c865fe008 | |||
| 572dbf7a88 | |||
| 7addd50577 | |||
| cd5dc25642 | |||
| 9c2355978e | |||
| 4b9b785e50 | |||
| 050b2b9d77 | |||
| d12cca304f | |||
| 8603bbd5cb | |||
| 749e74a54c | |||
| 7b3692ef7b | |||
| c49f6a1740 | |||
| 1d813e4643 | |||
| 5e0a429d81 | |||
| d928d266b9 | |||
| b4dbbb4211 | |||
| dcb5260cfc | |||
| a8747dfe77 | |||
| a474b5df48 | |||
| 082979bea6 | |||
| 1275186d86 | |||
| cd60ac6387 | |||
| bdfbd3e5c0 | |||
| dff63f325f | |||
| 2b634e3b7e | |||
| 723d739405 | |||
| e62a07af37 | |||
| f86c3b2935 | |||
| 050add8543 | |||
| 8faf0296a6 | |||
| f209f3b107 | |||
| b233b60ae0 | |||
| e1d4a7c5a4 | |||
| 59e8339e94 | |||
| 9277db97e5 | |||
| e755dc6641 | |||
| 782f5c15d6 | |||
| 2f4fae7d02 | |||
| 4b7c5aa58d |
20
.drone.yml
20
.drone.yml
@ -34,7 +34,7 @@ steps:
|
||||
command_timeout: 2m
|
||||
script:
|
||||
- pip uninstall -y vrobbler
|
||||
- pip install git+https://code.unbl.ink/secstate/vrobbler.git@main
|
||||
- pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
|
||||
- vrobbler migrate
|
||||
- vrobbler collectstatic --noinput
|
||||
- immortalctl restart celery && immortalctl restart vrobbler
|
||||
@ -50,8 +50,15 @@ steps:
|
||||
topic: drone
|
||||
priority: low
|
||||
tags:
|
||||
- failure
|
||||
- success
|
||||
- vrobbler
|
||||
actions:
|
||||
- action: view
|
||||
label: Changes
|
||||
url: "{{ .Commit.Link }}"
|
||||
- action: view
|
||||
label: Build
|
||||
url: "{{ .Build.Link }}"
|
||||
- name: build failure notification
|
||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||
when:
|
||||
@ -61,8 +68,15 @@ steps:
|
||||
topic: drone
|
||||
priority: high
|
||||
tags:
|
||||
- success
|
||||
- failure
|
||||
- vrobbler
|
||||
actions:
|
||||
- action: view
|
||||
label: Changes
|
||||
url: "{{ .Commit.Link }}"
|
||||
- action: view
|
||||
label: Build
|
||||
url: "{{ .Build.Link }}"
|
||||
volumes:
|
||||
- name: docker
|
||||
host:
|
||||
|
||||
68
.gitea/workflows/build.yml
Normal file
68
.gitea/workflows/build.yml
Normal file
@ -0,0 +1,68 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
~/.cache/pypoetry
|
||||
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py311-
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
cp vrobbler.conf.test vrobbler.conf
|
||||
poetry install --with test
|
||||
|
||||
- name: Pytest with coverage
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
155
.gitea/workflows/deploy.yml
Normal file
155
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,155 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
~/.cache/pypoetry
|
||||
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py311-
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
cp vrobbler.conf.test vrobbler.conf
|
||||
poetry install --with test
|
||||
|
||||
- name: Pytest with coverage
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
build-and-deploy:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Write commit hash to build file
|
||||
run: |
|
||||
mkdir -p build_meta
|
||||
echo "${{ gitea.sha }}" | cut -c1-8 > build_meta/commit.txt
|
||||
|
||||
- name: Build package with commit info
|
||||
run: |
|
||||
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
|
||||
poetry build
|
||||
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:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
source: "dist/*.whl"
|
||||
target: "/var/lib/vrobbler"
|
||||
|
||||
- name: Install wheel and restart services
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
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 vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||
|
||||
- name: Notify deploy success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler deploy success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler,deploy" \
|
||||
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "🚀 Deploy succeeded: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify deploy failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler deploy failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler,deploy" \
|
||||
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "💥 Deploy failed: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -3,3 +3,5 @@ vrobbler.conf
|
||||
media/
|
||||
dist/
|
||||
.coverage
|
||||
tmp/*
|
||||
vrobbler/static/*
|
||||
|
||||
16
AGENTS.md
Normal file
16
AGENTS.md
Normal file
@ -0,0 +1,16 @@
|
||||
This is a Django-based web application that has an API, but primarily functions
|
||||
with traditional Django views with HTML templates to display data that mostly
|
||||
constitutes "scrobbled" items. The app started as a way to track a user's
|
||||
watched videos via a Jellyfin server, but has since grown to keep track of a
|
||||
number of media types: music tracks, tasks, videos, web pages, food, life
|
||||
events, sports events, podcasts, video games, board games, beers, brick (lego)
|
||||
sets, puzzles, books and geolocations.
|
||||
|
||||
The project is written in Python and prefers to use "fat" models where logical
|
||||
methods are contained in either instance methods on instatiated data models, or
|
||||
classmethods on the Django model class itself. When logic grows too complex,
|
||||
helper functions should be pulled out into utils.py files and the model instance
|
||||
ro class method should call the utility function.
|
||||
|
||||
Be sure to check pyproject.toml for project defaults. Specifically for black and
|
||||
isort expectations.
|
||||
2
Makefile
2
Makefile
@ -1,5 +1,5 @@
|
||||
deploy:
|
||||
ssh vrobbler.service "rm -rf /usr/local/lib/python3.11/site-packages/vrobbler-0.15.4.dist-info/ && pip install git+https://code.unbl.ink/secstate/vrobbler.git@develop && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
|
||||
ssh vrobbler.service "pip uninstall vrobbler && pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
|
||||
logs:
|
||||
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
|
||||
test:
|
||||
|
||||
1020
PROJECT.org
1020
PROJECT.org
File diff suppressed because it is too large
Load Diff
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.
|
||||
```
|
||||
|
||||
5
data/birding-example.csv
Normal file
5
data/birding-example.csv
Normal file
@ -0,0 +1,5 @@
|
||||
Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||
Canada Goose,6,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Common Loon,1,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Double-crested Cormorant,1,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Boat-tailed Grackle,2,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,Sitting together on roof line of a house on Water Street. 20 meters away. Both birds were mostly black with green accents on the breast with long tails which they were repeatedly fanning out to show the V shape.
|
||||
|
File diff suppressed because one or more lines are too long
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,,
|
||||
|
23
justfile
Normal file
23
justfile
Normal file
@ -0,0 +1,23 @@
|
||||
dj-port := "0.0.0.0:" + env_var_or_default("DJANGO_PORT", "8000")
|
||||
|
||||
default:
|
||||
@just --list
|
||||
|
||||
django:
|
||||
poetry run python manage.py runserver {{dj-port}}
|
||||
|
||||
shell:
|
||||
poetry run python manage.py shell
|
||||
|
||||
celery:
|
||||
poetry run celery -A vrobbler worker -l info --concurrency=2 --pool=threads
|
||||
|
||||
celery-beat:
|
||||
poetry run celery -A vrobbler beat -l info
|
||||
|
||||
release kind="minor":
|
||||
poetry run python scripts/release.py {{kind}}
|
||||
|
||||
push:
|
||||
git push && git push gitea
|
||||
git push --tags && git push --tags gitea
|
||||
5682
poetry.lock
generated
5682
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,11 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.16.1"
|
||||
version = "45.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.9,<3.12"
|
||||
python = ">=3.11,<3.15"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
@ -16,8 +16,8 @@ httpx = "<=0.27.2"
|
||||
djangorestframework = "^3.13.1"
|
||||
Markdown = "^3.3.6"
|
||||
django-filter = "^21.1"
|
||||
Pillow = "^9.0.1"
|
||||
psycopg2 = "^2.9.3"
|
||||
Pillow = "^10.0.0"
|
||||
psycopg2 = "2.9.10"
|
||||
dj-database-url = "^0.5.0"
|
||||
django-mathfilters = "^1.0.0"
|
||||
django-allauth = "^0.50.0"
|
||||
@ -28,7 +28,6 @@ django-markdownify = "^0.9.1"
|
||||
gunicorn = "^20.1.0"
|
||||
django-simple-history = "^3.1.1"
|
||||
musicbrainzngs = "^0.7.1"
|
||||
cinemagoer = "^2022.12.27"
|
||||
pysportsdb = "^0.1.0"
|
||||
pytz = "^2022.7.1"
|
||||
django-redis = "^5.2.0"
|
||||
@ -41,11 +40,11 @@ beautifulsoup4 = "^4.11.2"
|
||||
django-storages = "^1.13.2"
|
||||
stream-sqlite = "^0.0.41"
|
||||
ipython = "^8.14.0"
|
||||
pendulum = "^2.1.2"
|
||||
pendulum = "^3"
|
||||
trafilatura = "^1.6.3"
|
||||
django-imagekit = "^5.0.0"
|
||||
thefuzz = "^0.22.1"
|
||||
dataclass-wizard = "0.22.0"
|
||||
dataclass-wizard = "^0.35.0"
|
||||
webdavclient3 = "^3.14.6"
|
||||
boto3 = "^1.35.37"
|
||||
urllib3 = "<2"
|
||||
@ -58,6 +57,11 @@ tmdbv3api = "^1.9.0"
|
||||
themoviedb = "^1.0.2"
|
||||
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"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
@ -81,13 +85,12 @@ types-requests = "^2.27"
|
||||
bandit = "^1.7.4"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "6.0"
|
||||
addopts = "-ra -q --reuse-db"
|
||||
addopts = "-ra -q --reuse-db --no-migrations"
|
||||
testpaths = ["tests"]
|
||||
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
|
||||
|
||||
[tool.black]
|
||||
line-length = 79
|
||||
line-length = 88
|
||||
target-version = ["py39", "py310"]
|
||||
include = ".py$"
|
||||
exclude = "migrations"
|
||||
@ -104,6 +107,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"
|
||||
|
||||
27
scripts/README.org
Normal file
27
scripts/README.org
Normal file
@ -0,0 +1,27 @@
|
||||
#+title: Readme
|
||||
|
||||
Scripts are a collection of helpful utility scripts, or simple gut-check tests for various functional pieces.
|
||||
|
||||
* test_recipe_scraper.py
|
||||
Asserts various urls by making actual calls out to the internet, while our test suite mocks return values.
|
||||
|
||||
#+begin_src shell
|
||||
python ../manage.py shell < ../scripts/test_recipe_scraper.py
|
||||
#+end_src
|
||||
|
||||
#+RESULTS:
|
||||
| Eagerly | running | all | tasks |
|
||||
| Connected | to | sqlite@db.sqlite3 | |
|
||||
| Checking: | https://cookingwithmike.com/quinoa-meatloaf/ | | |
|
||||
| Checking: | https://www.kingarthurbaking.com/recipes/overnight-sourdough-waffles-recipe | | |
|
||||
| Checking: | https://dirt.fyi/article/2026/02/25-years-of-ipod-brain?src=longreads | | |
|
||||
|
||||
* test_koreader_import.py
|
||||
Run through an actual koreader sqlite file and make sure imports work as expected
|
||||
|
||||
#+begin_src shell
|
||||
rm db.sqlite3
|
||||
cp ../db.sqlite3 .
|
||||
python ../manage.py shell < ../scripts/test_koreader_import.py
|
||||
#+end_src
|
||||
|
||||
BIN
scripts/koreader-test.sqlite3
Normal file
BIN
scripts/koreader-test.sqlite3
Normal file
Binary file not shown.
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(0)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 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()
|
||||
6
scripts/test_koreader_import.py
Normal file
6
scripts/test_koreader_import.py
Normal file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from books.koreader import process_koreader_sqlite_file
|
||||
|
||||
|
||||
process_koreader_sqlite_file("./koreader-test.sqlite3", 1)
|
||||
21
scripts/test_recipe_scraper.py
Normal file
21
scripts/test_recipe_scraper.py
Normal file
@ -0,0 +1,21 @@
|
||||
import requests
|
||||
from foods.sources.rscraper import (
|
||||
RecipeScraperService,
|
||||
)
|
||||
|
||||
|
||||
test_urls = {
|
||||
"https://cookingwithmike.com/quinoa-meatloaf/": True,
|
||||
"https://www.kingarthurbaking.com/recipes/overnight-sourdough-waffles-recipe": True,
|
||||
"https://dirt.fyi/article/2026/02/25-years-of-ipod-brain?src=longreads": False,
|
||||
"https://tastesbetterfromscratch.com/belgian-waffles/": True,
|
||||
}
|
||||
|
||||
for k, v in test_urls.items():
|
||||
|
||||
html = requests.get(k).text
|
||||
print("Checking: ", k)
|
||||
if v:
|
||||
assert RecipeScraperService().is_recipe(html, k)
|
||||
else:
|
||||
assert not RecipeScraperService().is_recipe(html, k)
|
||||
0
tests/birds_tests/__init__.py
Normal file
0
tests/birds_tests/__init__.py
Normal file
70
tests/birds_tests/conftest.py
Normal file
70
tests/birds_tests/conftest.py
Normal file
@ -0,0 +1,70 @@
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
from birds.models import (
|
||||
Bird,
|
||||
BirdSightingEntry,
|
||||
BirdSightingLogData,
|
||||
BirdingLocation,
|
||||
)
|
||||
from django.contrib.auth import get_user_model
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create(email="birder@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bird(db):
|
||||
return Bird.objects.create(
|
||||
common_name="Northern Cardinal",
|
||||
scientific_name="Cardinalis cardinalis",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def birding_location(db):
|
||||
return BirdingLocation.objects.create(title="Test Park")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def birding_csv_content():
|
||||
return """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,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Northern Cardinal,2,Test Park,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,At the feeder
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def birding_csv_file(birding_csv_content):
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(birding_csv_content)
|
||||
return f.name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scrobble_with_sightings(user, birding_location, bird):
|
||||
return Scrobble.objects.create(
|
||||
user=user,
|
||||
birding_location=birding_location,
|
||||
media_type=Scrobble.MediaType.BIRDING_LOCATION,
|
||||
timestamp="2026-05-10 16:15:00+00:00",
|
||||
played_to_completion=True,
|
||||
log={
|
||||
"birds": [
|
||||
BirdSightingEntry(
|
||||
bird_id=bird.id, quantity=2, sighting_notes="At the feeder"
|
||||
).asdict
|
||||
],
|
||||
"duration_minutes": 9,
|
||||
"observation_type": "Stationary",
|
||||
"party_size": 4,
|
||||
"complete_checklist": True,
|
||||
},
|
||||
)
|
||||
96
tests/birds_tests/test_dataclasses.py
Normal file
96
tests/birds_tests/test_dataclasses.py
Normal file
@ -0,0 +1,96 @@
|
||||
from birds.models import BirdSightingEntry, BirdSightingLogData
|
||||
|
||||
|
||||
class TestBirdSightingEntry:
|
||||
def test_create_entry(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=3)
|
||||
assert entry.bird_id == bird.id
|
||||
assert entry.quantity == 3
|
||||
assert entry.sighting_notes is None
|
||||
|
||||
def test_entry_default_quantity(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id)
|
||||
assert entry.quantity == 1
|
||||
|
||||
def test_entry_str(self, db, bird):
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird.id, quantity=2, sighting_notes="in the tree"
|
||||
)
|
||||
expected = f"{bird.common_name} x2 (in the tree)"
|
||||
assert str(entry) == expected
|
||||
|
||||
def test_entry_str_no_notes(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=1)
|
||||
expected = f"{bird.common_name} x1"
|
||||
assert str(entry) == expected
|
||||
|
||||
def test_entry_bird_property(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id)
|
||||
assert entry.bird == bird
|
||||
|
||||
def test_entry_bird_property_none(self, db):
|
||||
entry = BirdSightingEntry(bird_id=None)
|
||||
assert entry.bird is None
|
||||
|
||||
def test_entry_asdict(self, db, bird):
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird.id, quantity=4, sighting_notes="flying south"
|
||||
)
|
||||
d = entry.asdict
|
||||
assert d["bird_id"] == bird.id
|
||||
assert d["quantity"] == 4
|
||||
assert d["sighting_notes"] == "flying south"
|
||||
|
||||
|
||||
class TestBirdSightingLogData:
|
||||
def test_empty_logdata(self):
|
||||
logdata = BirdSightingLogData()
|
||||
assert logdata.birds is None
|
||||
assert logdata.duration_minutes is None
|
||||
assert logdata.observation_type is None
|
||||
assert logdata.party_size is None
|
||||
assert logdata.complete_checklist is None
|
||||
|
||||
def test_with_birds(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict
|
||||
logdata = BirdSightingLogData(
|
||||
birds=[entry],
|
||||
duration_minutes=15,
|
||||
observation_type="Traveling",
|
||||
party_size=3,
|
||||
complete_checklist=True,
|
||||
)
|
||||
assert len(logdata.birds) == 1
|
||||
assert logdata.duration_minutes == 15
|
||||
assert logdata.observation_type == "Traveling"
|
||||
assert logdata.party_size == 3
|
||||
assert logdata.complete_checklist is True
|
||||
|
||||
def test_bird_list_property(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict
|
||||
logdata = BirdSightingLogData(birds=[entry])
|
||||
assert bird.common_name in logdata.bird_list
|
||||
|
||||
def test_bird_list_empty(self):
|
||||
logdata = BirdSightingLogData()
|
||||
assert logdata.bird_list == ""
|
||||
|
||||
def test_as_html_with_all_fields(self, db, bird):
|
||||
entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict
|
||||
logdata = BirdSightingLogData(
|
||||
birds=[entry],
|
||||
observation_type="Stationary",
|
||||
distance="2 km",
|
||||
area="Woodland",
|
||||
party_size=4,
|
||||
complete_checklist=True,
|
||||
weather="Sunny",
|
||||
)
|
||||
html = logdata.as_html()
|
||||
assert "Stationary" in html
|
||||
assert "2 km" in html
|
||||
assert "Woodland" in html
|
||||
assert "Party size: 4" in html
|
||||
assert "Complete checklist: True" in html
|
||||
assert "Sunny" in html
|
||||
assert bird.common_name in html
|
||||
139
tests/birds_tests/test_importer.py
Normal file
139
tests/birds_tests/test_importer.py
Normal file
@ -0,0 +1,139 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from birds.importer import (
|
||||
import_birding_csv,
|
||||
parse_bool,
|
||||
parse_coords,
|
||||
parse_duration,
|
||||
parse_int,
|
||||
parse_timestamp,
|
||||
)
|
||||
from birds.models import Bird, BirdingLocation, BirdingCSVImport
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
class TestParserHelpers:
|
||||
def test_parse_duration(self):
|
||||
assert parse_duration("9 minute(s)") == 9
|
||||
assert parse_duration("120 minute(s)") == 120
|
||||
assert parse_duration("") is None
|
||||
assert parse_duration(None) is None
|
||||
assert parse_duration("not a duration") is None
|
||||
|
||||
def test_parse_coords(self):
|
||||
loc = "Some Place, US (44.384, -68.805)"
|
||||
lat, lon = parse_coords(loc)
|
||||
assert lat == 44.384
|
||||
assert lon == -68.805
|
||||
|
||||
def test_parse_coords_no_match(self):
|
||||
loc = "Some Place, US"
|
||||
lat, lon = parse_coords(loc)
|
||||
assert lat is None
|
||||
assert lon is None
|
||||
|
||||
def test_parse_timestamp(self):
|
||||
dt = parse_timestamp("May 10, 2026", "4:15 PM")
|
||||
assert dt is not None
|
||||
assert dt.year == 2026
|
||||
assert dt.month == 5
|
||||
assert dt.day == 10
|
||||
assert dt.hour == 16
|
||||
assert dt.minute == 15
|
||||
|
||||
def test_parse_timestamp_no_time(self):
|
||||
dt = parse_timestamp("May 10, 2026", "")
|
||||
assert dt is not None
|
||||
assert dt.year == 2026
|
||||
|
||||
def test_parse_timestamp_invalid(self):
|
||||
assert parse_timestamp("not a date", "") is None
|
||||
|
||||
def test_parse_bool(self):
|
||||
assert parse_bool("true") is True
|
||||
assert parse_bool("True") is True
|
||||
assert parse_bool("yes") is True
|
||||
assert parse_bool("1") is True
|
||||
assert parse_bool("false") is False
|
||||
assert parse_bool("") is None
|
||||
assert parse_bool(None) is None
|
||||
|
||||
def test_parse_int(self):
|
||||
assert parse_int("42") == 42
|
||||
assert parse_int("") is None
|
||||
assert parse_int(None) is None
|
||||
assert parse_int("not a number") is None
|
||||
|
||||
|
||||
class TestImportBirdingCSV:
|
||||
def test_import_creates_birds(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
assert Bird.objects.filter(common_name="Canada Goose").exists()
|
||||
assert Bird.objects.filter(common_name="Northern Cardinal").exists()
|
||||
|
||||
def test_import_creates_location(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
assert BirdingLocation.objects.filter(title="Test Park").exists()
|
||||
|
||||
def test_import_creates_scrobble(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
assert Scrobble.objects.filter(
|
||||
source="Birding CSV Import"
|
||||
).count() == 1
|
||||
|
||||
def test_import_logdata_fields(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
|
||||
log = scrobble.log
|
||||
assert log["duration_minutes"] == 9
|
||||
assert log["observation_type"] == "Stationary"
|
||||
assert log["party_size"] == 4
|
||||
assert log["complete_checklist"] is True
|
||||
assert len(log["birds"]) == 2
|
||||
|
||||
def test_import_sighting_details(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
|
||||
birds = scrobble.log["birds"]
|
||||
cardinal = next(b for b in birds if b["quantity"] == 2)
|
||||
assert cardinal["sighting_notes"] == "At the feeder"
|
||||
|
||||
def test_import_idempotent(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
assert Scrobble.objects.filter(
|
||||
source="Birding CSV Import"
|
||||
).count() == 1
|
||||
|
||||
def test_import_bird_quantities(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
|
||||
birds = scrobble.log["birds"]
|
||||
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):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
assert imp.uuid is not None
|
||||
assert imp.import_type == "Birding CSV"
|
||||
assert "Birding" in str(imp)
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_process_via_model(self, user, birding_csv_file):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
with open(birding_csv_file, "rb") as f:
|
||||
imp.csv_file.save("test.csv", f, save=True)
|
||||
imp.process()
|
||||
imp.refresh_from_db()
|
||||
assert imp.process_count == 1
|
||||
assert imp.processed_finished is not None
|
||||
30
tests/birds_tests/test_models.py
Normal file
30
tests/birds_tests/test_models.py
Normal file
@ -0,0 +1,30 @@
|
||||
import pytest
|
||||
from birds.models import Bird
|
||||
|
||||
|
||||
class TestBirdModel:
|
||||
def test_create_bird(self, db):
|
||||
bird = Bird.objects.create(common_name="Blue Jay")
|
||||
assert bird.common_name == "Blue Jay"
|
||||
assert bird.uuid is not None
|
||||
assert str(bird) == "Blue Jay"
|
||||
|
||||
def test_find_or_create_new(self, db):
|
||||
bird = Bird.find_or_create("American Robin")
|
||||
assert bird.common_name == "American Robin"
|
||||
|
||||
def test_find_or_create_existing(self, db, bird):
|
||||
result = Bird.find_or_create("Northern Cardinal")
|
||||
assert result.id == bird.id
|
||||
assert result.common_name == "Northern Cardinal"
|
||||
|
||||
def test_find_or_create_case_insensitive(self, db, bird):
|
||||
result = Bird.find_or_create("northern cardinal")
|
||||
assert result.id == bird.id
|
||||
|
||||
def test_bird_str(self, db):
|
||||
bird = Bird.objects.create(common_name="Mourning Dove")
|
||||
assert str(bird) == "Mourning Dove"
|
||||
|
||||
def test_bird_scientific_name(self, db, bird):
|
||||
assert bird.scientific_name == "Cardinalis cardinalis"
|
||||
42
tests/birds_tests/test_views.py
Normal file
42
tests/birds_tests/test_views.py
Normal file
@ -0,0 +1,42 @@
|
||||
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()
|
||||
|
||||
|
||||
class TestBirdingLocationViews:
|
||||
def test_birding_location_list_anonymous(self, db):
|
||||
client = Client()
|
||||
response = client.get(reverse("birds:birding_location_list"))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_bird_list_anonymous(self, db):
|
||||
client = Client()
|
||||
response = client.get(reverse("birds:bird_list"))
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestBirdingCSVImportViews:
|
||||
def test_upload_view_requires_login(self, db):
|
||||
client = Client()
|
||||
response = client.get(reverse("birds:csv-upload"))
|
||||
assert response.status_code == 302
|
||||
|
||||
def test_import_detail_view_requires_login(self, db):
|
||||
client = Client()
|
||||
response = client.get(
|
||||
reverse("birds:csv_import_detail", kwargs={"slug": "00000000-0000-0000-0000-000000000001"})
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
||||
def test_import_detail_authenticated(self, db):
|
||||
user = User.objects.create(email="birder@example.com")
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
imp = EBirdCSVImport.objects.create(user=user)
|
||||
response = client.get(
|
||||
reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": imp.uuid})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@ -1,3 +1,4 @@
|
||||
import pytest
|
||||
from boardgames.bgg import (
|
||||
take_first,
|
||||
lookup_boardgame_id_from_bgg,
|
||||
@ -5,12 +6,14 @@ from boardgames.bgg import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_take_first():
|
||||
assert take_first([]) == ""
|
||||
|
||||
assert take_first(["a", "b"]) == "a"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_lookup_boardgame_id_from_bgg():
|
||||
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
|
||||
assert bgg_id == "15"
|
||||
@ -19,6 +22,7 @@ def test_lookup_boardgame_id_from_bgg():
|
||||
assert bgg_id == None
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_lookup_boardgame_from_bgg():
|
||||
bgg_result = lookup_boardgame_from_bgg(15)
|
||||
assert bgg_result.get("bggeek_id") == 15
|
||||
|
||||
0
tests/foods_tests/__init__.py
Normal file
0
tests/foods_tests/__init__.py
Normal file
186
tests/foods_tests/test_recipe_scraper.py
Normal file
186
tests/foods_tests/test_recipe_scraper.py
Normal file
@ -0,0 +1,186 @@
|
||||
import pytest
|
||||
from foods.sources.rscraper import (
|
||||
RecipeScraperService,
|
||||
)
|
||||
|
||||
|
||||
RECIPE_HTML_WITH_SCHEMA = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Test Recipe",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Test Author"
|
||||
},
|
||||
"recipeIngredient": ["1 cup flour", "2 eggs", "1/2 cup sugar"],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Mix ingredients together"
|
||||
}
|
||||
],
|
||||
"totalTime": "PT30M",
|
||||
"recipeYield": "4 servings"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Recipe</h1>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
RECIPE_HTML_WITHOUT_SCHEMA = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Not a Recipe Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to My Blog</h1>
|
||||
<p>This is just a regular blog post about cooking.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
RECIPE_HTML_WITH_MICRODATA = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Recipe</title>
|
||||
</head>
|
||||
<body itemscope itemtype="http://schema.org/Recipe">
|
||||
<h1 itemprop="name">Microdata Recipe</h1>
|
||||
<div itemprop="author" itemscope itemtype="http://schema.org/Person">
|
||||
<span itemprop="name">Test Author</span>
|
||||
</div>
|
||||
<div itemprop="recipeIngredient">1 cup flour</div>
|
||||
<div itemprop="recipeIngredient">2 eggs</div>
|
||||
<div itemprop="recipeInstructions">
|
||||
<div itemprop="text">Mix all ingredients</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class TestRecipeScraperService:
|
||||
@pytest.fixture
|
||||
def scraper(self):
|
||||
return RecipeScraperService()
|
||||
|
||||
def test_is_recipe_with_valid_schema(self, scraper):
|
||||
result = scraper.is_recipe(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_is_recipe_without_schema(self, scraper):
|
||||
result = scraper.is_recipe(
|
||||
RECIPE_HTML_WITHOUT_SCHEMA, "https://example.com/blog"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_is_recipe_with_microdata(self, scraper):
|
||||
result = scraper.is_recipe(
|
||||
RECIPE_HTML_WITH_MICRODATA, "https://example.com/recipe"
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_scrape_returns_title(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["title"] == "Test Recipe"
|
||||
|
||||
def test_scrape_returns_ingredients(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert len(result["ingredients"]) == 3
|
||||
assert "1 cup flour" in result["ingredients"]
|
||||
|
||||
def test_scrape_returns_instructions(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert len(result["instructions"]) > 0
|
||||
assert "Mix ingredients together" in result["instructions"]
|
||||
|
||||
def test_scrape_returns_yields(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["yields"] == "4 servings"
|
||||
|
||||
def test_scrape_returns_total_time(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["total_time"] == 30
|
||||
|
||||
def test_scrape_returns_url(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["url"] == "https://example.com/recipe"
|
||||
|
||||
def test_scrape_raises_on_invalid_html(self, scraper):
|
||||
with pytest.raises(ValueError):
|
||||
scraper.scrape("", "https://example.com/recipe")
|
||||
|
||||
def test_scrape_handles_missing_optional_fields(self, scraper):
|
||||
minimal_html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Minimal Recipe"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
"""
|
||||
result = scraper.scrape(minimal_html, "https://example.com/minimal")
|
||||
assert result["title"] == "Minimal Recipe"
|
||||
assert result["ingredients"] == []
|
||||
assert result["instructions"] == []
|
||||
|
||||
def test_parse_servings(self, scraper):
|
||||
assert scraper.parse_servings("4 servings") == 4
|
||||
assert scraper.parse_servings("6 people") == 6
|
||||
assert scraper.parse_servings("2") == 2
|
||||
assert scraper.parse_servings("serves 8") == 8
|
||||
assert scraper.parse_servings(None) is None
|
||||
assert scraper.parse_servings("") is None
|
||||
|
||||
def test_extract_tags_from_cuisine(self, scraper):
|
||||
recipe_data = {"cuisine": "Italian"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Italian" in tags
|
||||
|
||||
def test_extract_tags_from_cuisine_list(self, scraper):
|
||||
recipe_data = {"cuisine": ["Italian", "Mexican"]}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Italian" in tags
|
||||
assert "Mexican" in tags
|
||||
|
||||
def test_extract_tags_from_dietary(self, scraper):
|
||||
recipe_data = {"dietary": "Gluten-Free"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Gluten-Free" in tags
|
||||
|
||||
def test_extract_tags_from_course(self, scraper):
|
||||
recipe_data = {"course": "Dessert"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Dessert" in tags
|
||||
|
||||
def test_extract_tags_from_keywords(self, scraper):
|
||||
recipe_data = {"keywords": "easy, quick, healthy"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "easy" in tags
|
||||
assert "quick" in tags
|
||||
assert "healthy" in tags
|
||||
|
||||
def test_extract_tags_from_keywords_list(self, scraper):
|
||||
recipe_data = {"keywords": ["comfort food", "winter"]}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "comfort food" in tags
|
||||
assert "winter" in tags
|
||||
133
tests/foods_tests/test_usda.py
Normal file
133
tests/foods_tests/test_usda.py
Normal file
@ -0,0 +1,133 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from foods.sources.usda import (
|
||||
USDAFoodAPI,
|
||||
NutritionCalculator,
|
||||
)
|
||||
|
||||
|
||||
class TestUSDAFoodAPI:
|
||||
@pytest.fixture
|
||||
def usda_api(self):
|
||||
with patch("vrobbler.apps.foods.sources.usda.settings") as mock_settings:
|
||||
mock_settings.USDA_API_KEY = "test_api_key"
|
||||
return USDAFoodAPI(api_key="test_api_key")
|
||||
|
||||
def test_extract_nutrients_with_nutrient_number(self, usda_api):
|
||||
food_data = {
|
||||
"description": "Test Food",
|
||||
"foodNutrients": [
|
||||
{
|
||||
"nutrientNumber": "203",
|
||||
"nutrientName": "Protein",
|
||||
"value": 10.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "204",
|
||||
"nutrientName": "Total lipid (fat)",
|
||||
"value": 5.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "205",
|
||||
"nutrientName": "Carbohydrate, by difference",
|
||||
"value": 20.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "208",
|
||||
"nutrientName": "Energy",
|
||||
"value": 150.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "269",
|
||||
"nutrientName": "Sugars, total",
|
||||
"value": 5.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 10.0
|
||||
assert result["fat"] == 5.0
|
||||
assert result["carbohydrates"] == 20.0
|
||||
assert result["calories"] == 150.0
|
||||
assert result["sugar"] == 5.0
|
||||
|
||||
def test_extract_nutrients_with_nested_nutrient(self, usda_api):
|
||||
food_data = {
|
||||
"description": "Test Food",
|
||||
"foodNutrients": [
|
||||
{
|
||||
"nutrient": {"id": 203, "name": "Protein"},
|
||||
"value": 10.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 10.0
|
||||
|
||||
def test_extract_nutrients_with_empty_nutrients(self, usda_api):
|
||||
food_data = {"description": "Test Food", "foodNutrients": []}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 0
|
||||
assert result["calories"] == 0
|
||||
|
||||
def test_extract_nutrients_with_no_nutrients_key(self, usda_api):
|
||||
food_data = {"description": "Test Food"}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 0
|
||||
|
||||
|
||||
class TestNutritionCalculator:
|
||||
@pytest.fixture
|
||||
def calculator(self):
|
||||
with patch("vrobbler.apps.foods.sources.usda.USDAFoodAPI"):
|
||||
return NutritionCalculator()
|
||||
|
||||
def test_parse_ingredient_with_fraction(self, calculator):
|
||||
result = calculator.parse_ingredient("1/2 cup flour")
|
||||
assert result["quantity"] == 0.5
|
||||
assert result["unit"] == "cup"
|
||||
assert result["ingredient"] == "flour"
|
||||
|
||||
def test_parse_ingredient_with_mixed_number(self, calculator):
|
||||
result = calculator.parse_ingredient("1 1/2 cups sugar")
|
||||
assert result["quantity"] == 1.5
|
||||
assert result["unit"] == "cups"
|
||||
assert result["ingredient"] == "sugar"
|
||||
|
||||
def test_parse_ingredient_with_decimal(self, calculator):
|
||||
result = calculator.parse_ingredient("0.5 tsp salt")
|
||||
assert result["quantity"] == 0.5
|
||||
assert result["unit"] == "tsp"
|
||||
assert result["ingredient"] == "salt"
|
||||
|
||||
def test_parse_ingredient_with_whole_number(self, calculator):
|
||||
result = calculator.parse_ingredient("3 eggs")
|
||||
assert result["quantity"] == 3
|
||||
assert result["unit"] is None
|
||||
assert result["ingredient"] == "eggs"
|
||||
|
||||
def test_parse_ingredient_with_no_quantity(self, calculator):
|
||||
result = calculator.parse_ingredient("salt to taste")
|
||||
assert result["quantity"] == 1
|
||||
|
||||
def test_clean_ingredient_name_removes_modifiers(self, calculator):
|
||||
result = calculator._clean_ingredient_name("fresh chopped onions")
|
||||
assert "fresh" not in result.lower()
|
||||
assert "chopped" not in result.lower()
|
||||
|
||||
def test_clean_ingredient_name_removes_parentheses(self, calculator):
|
||||
result = calculator._clean_ingredient_name("flour (sifted)")
|
||||
assert "(" not in result
|
||||
assert ")" not in result
|
||||
|
||||
def test_convert_to_grams_cup(self, calculator):
|
||||
result = calculator._convert_to_grams(2, "cups", "flour")
|
||||
assert result == 480
|
||||
|
||||
def test_convert_to_grams_tablespoon(self, calculator):
|
||||
result = calculator._convert_to_grams(3, "tbsp", "olive oil")
|
||||
assert result == 45
|
||||
|
||||
def test_convert_to_grams_unknown_unit(self, calculator):
|
||||
result = calculator._convert_to_grams(1, "unknown", "something")
|
||||
assert result == 100
|
||||
@ -1,9 +1,7 @@
|
||||
import pytest
|
||||
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
|
||||
|
||||
expected_desc_snippet = (
|
||||
"NPR's Up First is the news you need to start your day. "
|
||||
)
|
||||
expected_desc_snippet = "NPR's Up First is the news you need to start your day. "
|
||||
|
||||
expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0CfR24RR6sme531yIkCrnK4zzmo97jeualO5drVPKG6oCk"
|
||||
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
|
||||
|
||||
@ -22,8 +22,18 @@ def boardgame_scrobble():
|
||||
played_to_completion=True,
|
||||
log={
|
||||
"players": [
|
||||
{"person_id": first.id, "win": True, "score": 30, "color": "Blue"},
|
||||
{"person_id": second.id, "win": False, "score": 28, "color": "Red"}
|
||||
{
|
||||
"person_id": first.id,
|
||||
"win": True,
|
||||
"score": 30,
|
||||
"color": "Blue",
|
||||
},
|
||||
{
|
||||
"person_id": second.id,
|
||||
"win": False,
|
||||
"score": 28,
|
||||
"color": "Red",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
@ -34,7 +44,7 @@ def test_track():
|
||||
Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=Artist.objects.create(name="Carly Rae Jepsen"),
|
||||
run_time_seconds=60,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
|
||||
|
||||
@ -58,9 +68,7 @@ class MopidyRequest:
|
||||
"artist": kwargs.get("artist", self.artist),
|
||||
"album": kwargs.get("album", self.album),
|
||||
"track_number": int(kwargs.get("track_number", self.track_number)),
|
||||
"run_time_ticks": int(
|
||||
kwargs.get("run_time_ticks", self.run_time_ticks)
|
||||
),
|
||||
"run_time_ticks": int(kwargs.get("run_time_ticks", self.run_time_ticks)),
|
||||
"run_time": int(kwargs.get("run_time", self.run_time)),
|
||||
"playback_time_ticks": int(
|
||||
kwargs.get("playback_time_ticks", self.playback_time_ticks)
|
||||
@ -103,9 +111,7 @@ def mopidy_track():
|
||||
@pytest.fixture
|
||||
def mopidy_track_diff_album_request_data(**kwargs):
|
||||
mb_album_id = "0c56c457-afe1-4679-baab-759ba8dd2a58"
|
||||
return MopidyRequest(
|
||||
album="Gold", musicbrainz_album_id=mb_album_id
|
||||
).request_json
|
||||
return MopidyRequest(album="Gold", musicbrainz_album_id=mb_album_id).request_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -115,6 +121,7 @@ def mopidy_podcast_request_data():
|
||||
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||
).request_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mopidy_podcast_https_request_data():
|
||||
mopidy_uri = "podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-ae09-43d9-8853-31ccf43f68e6"
|
||||
@ -122,6 +129,7 @@ def mopidy_podcast_https_request_data():
|
||||
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||
).request_json
|
||||
|
||||
|
||||
class JellyfinTrackRequest:
|
||||
name = "Emotion"
|
||||
artist = "Carly Rae Jepsen"
|
||||
@ -136,6 +144,7 @@ class JellyfinTrackRequest:
|
||||
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
|
||||
status = "resumed"
|
||||
client_name = "Jellyfin"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.request_data = {
|
||||
@ -159,6 +168,7 @@ class JellyfinTrackRequest:
|
||||
"musicbrainz_artist_id", self.musicbrainz_artist_id
|
||||
),
|
||||
"Status": kwargs.get("status", self.status),
|
||||
"ClientName": kwargs.get("client_name", self.client_name),
|
||||
}
|
||||
|
||||
def __eq__(self, other):
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
@ -6,30 +7,59 @@ from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
|
||||
from music.models import Album, Artist
|
||||
from profiles.models import UserProfile
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def build_scrobbles(client, request_json, num=7, spacing=2):
|
||||
def build_scrobbles(client, request_json, num=7, spacing=2, auth_token=None):
|
||||
from rest_framework.authtoken.models import Token
|
||||
import pytz
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
user = get_user_model().objects.create(username="Test User")
|
||||
user.profile.timezone = "US/Eastern"
|
||||
user.profile.save()
|
||||
headers = {}
|
||||
if auth_token:
|
||||
headers = {"Authorization": f"Token {auth_token}"}
|
||||
user = Token.objects.get(key=auth_token).user
|
||||
|
||||
client.post(url, request_json, content_type="application/json", headers=headers)
|
||||
track = Scrobble.objects.last().track
|
||||
|
||||
est = pytz.timezone("US/Eastern")
|
||||
for i in range(num):
|
||||
client.post(url, request_json, content_type="application/json")
|
||||
s = Scrobble.objects.last()
|
||||
s.user = user
|
||||
s.timestamp = timezone.now() - timedelta(days=i * spacing)
|
||||
s.played_to_completion = True
|
||||
s.save()
|
||||
naive_time = timezone.now().replace(tzinfo=None) - timedelta(days=i * spacing)
|
||||
aware_time = est.localize(naive_time)
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track,
|
||||
timestamp=aware_time,
|
||||
played_to_completion=True,
|
||||
source="Mopidy",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.get_album_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_track_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
|
||||
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||
@patch("music.models.lookup_album_from_tadb", return_value={})
|
||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||
def test_scrobble_counts_data(client, mopidy_track):
|
||||
build_scrobbles(client, mopidy_track.request_json)
|
||||
user = get_user_model().objects.first()
|
||||
def test_scrobble_counts_data(
|
||||
mock_lookup_album_tadb,
|
||||
mock_lookup_artist_tadb,
|
||||
mock_get_recording,
|
||||
mock_get_track,
|
||||
mock_get_album,
|
||||
client,
|
||||
mopidy_track,
|
||||
valid_auth_token,
|
||||
):
|
||||
user = build_scrobbles(
|
||||
client, mopidy_track.request_json, auth_token=valid_auth_token
|
||||
)
|
||||
count_dict = scrobble_counts(user)
|
||||
assert count_dict == {
|
||||
"alltime": 7,
|
||||
@ -41,10 +71,25 @@ def test_scrobble_counts_data(client, mopidy_track):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.get_album_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_track_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
|
||||
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||
@patch("music.models.lookup_album_from_tadb", return_value={})
|
||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||
def test_live_charts(client, mopidy_track):
|
||||
build_scrobbles(client, mopidy_track.request_json, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
def test_live_charts(
|
||||
mock_lookup_album_tadb,
|
||||
mock_lookup_artist_tadb,
|
||||
mock_get_recording,
|
||||
mock_get_track,
|
||||
mock_get_album,
|
||||
client,
|
||||
mopidy_track,
|
||||
valid_auth_token,
|
||||
):
|
||||
user = build_scrobbles(
|
||||
client, mopidy_track.request_json, 7, 1, auth_token=valid_auth_token
|
||||
)
|
||||
|
||||
week = week_of_scrobbles(user)
|
||||
assert list(week.values()) == [1, 1, 1, 1, 1, 1, 1]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
#from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
|
||||
# from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to get local tests running working again")
|
||||
@ -19,7 +19,7 @@ def test_boardgame_log_data(boardgame_scrobble):
|
||||
new=None,
|
||||
rank=None,
|
||||
seat_order=None,
|
||||
role=None
|
||||
role=None,
|
||||
),
|
||||
BoardGameScoreLogData(
|
||||
person_id=2,
|
||||
@ -32,7 +32,7 @@ def test_boardgame_log_data(boardgame_scrobble):
|
||||
new=None,
|
||||
rank=None,
|
||||
seat_order=None,
|
||||
role=None
|
||||
role=None,
|
||||
),
|
||||
],
|
||||
difficulty=None,
|
||||
|
||||
59
tests/scrobbles_tests/test_scrobblers.py
Normal file
59
tests/scrobbles_tests/test_scrobblers.py
Normal file
@ -0,0 +1,59 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from scrobbles.scrobblers import jellyfin_scrobble_media, mopidy_scrobble_media
|
||||
|
||||
|
||||
def test_jellyfin_scrobble_video_with_no_imdb_id():
|
||||
with patch("scrobbles.scrobblers.Video") as mock_video_class:
|
||||
mock_video_class.find_or_create.return_value = None
|
||||
|
||||
post_data = {
|
||||
"ItemType": "Video",
|
||||
"Name": "Test Video",
|
||||
"Provider_imdb": "",
|
||||
"PlaybackPosition": "00:05:00",
|
||||
"NotificationType": "PlaybackProgress",
|
||||
"UtcTimestamp": "2024-01-15T10:30:00Z",
|
||||
}
|
||||
|
||||
result = jellyfin_scrobble_media(post_data, 1)
|
||||
|
||||
mock_video_class.find_or_create.assert_called_once_with(None)
|
||||
|
||||
|
||||
def test_jellyfin_scrobble_media_ignores_progress_with_zero_position():
|
||||
|
||||
post_data = {
|
||||
"ItemType": "Audio",
|
||||
"PlaybackPosition": "00:00:00",
|
||||
"NotificationType": "PlaybackProgress",
|
||||
}
|
||||
|
||||
result = jellyfin_scrobble_media(post_data, 1)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_mopidy_scrobble_handles_missing_mopidy_uri():
|
||||
|
||||
with patch("scrobbles.scrobblers.Track") as mock_track_class:
|
||||
with patch("scrobbles.scrobblers.parse_mopidy_uri", return_value=None):
|
||||
mock_track = MagicMock()
|
||||
mock_track.scrobble_for_user = MagicMock(return_value=MagicMock())
|
||||
mock_track_class.find_or_create.return_value = mock_track
|
||||
|
||||
post_data = {
|
||||
"name": "Test Song",
|
||||
"artist": "Test Artist",
|
||||
"album": "Test Album",
|
||||
"run_time": 180000,
|
||||
}
|
||||
|
||||
result = mopidy_scrobble_media(post_data, 1)
|
||||
|
||||
mock_track_class.find_or_create.assert_called_once_with(
|
||||
title="Test Song",
|
||||
artist_name="Test Artist",
|
||||
album_name="Test Album",
|
||||
run_time_seconds=180000,
|
||||
)
|
||||
@ -6,7 +6,5 @@ from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||
|
||||
|
||||
def test_timestamp_user_tz_to_utc():
|
||||
timestamp = timestamp_user_tz_to_utc(
|
||||
1685561082, pytz.timezone("US/Eastern")
|
||||
)
|
||||
timestamp = timestamp_user_tz_to_utc(1685561082, pytz.timezone("US/Eastern"))
|
||||
assert timestamp == datetime(2023, 5, 31, 23, 24, 42, tzinfo=pytz.utc)
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from django.utils import timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from music.models import Track
|
||||
from django.utils import timezone
|
||||
from music.models import Album, Artist, Track
|
||||
from podcasts.models import PodcastEpisode
|
||||
from scrobbles.models import Scrobble
|
||||
from tasks.models import Task
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -18,11 +20,21 @@ def test_get_not_allowed_from_mopidy(client, valid_auth_token):
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_not_allowed_from_jellyfin(client, valid_auth_token):
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.get(url, headers=headers)
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(url, headers)
|
||||
response = client.post(
|
||||
url, "not valid json", content_type="application/json", headers=headers
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert (
|
||||
response.data["detail"]
|
||||
@ -30,6 +42,345 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_bad_jellyfin_request_data(client, valid_auth_token):
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url, "not valid json", content_type="application/json", headers=headers
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert (
|
||||
response.data["detail"]
|
||||
== "JSON parse error - Expecting value: line 1 column 1 (char 0)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_create_scrobble_from_mopidy_track_webhook(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
mopidy_track,
|
||||
):
|
||||
mock_artist = MagicMock(spec=Artist)
|
||||
mock_artist.id = 1
|
||||
mock_artist_fc.return_value = mock_artist
|
||||
|
||||
mock_track = MagicMock(spec=Track)
|
||||
mock_track.id = 1
|
||||
mock_track.scrobble_for_user.return_value = Scrobble(
|
||||
id=1, track_id=1, user_id=1, in_progress=True
|
||||
)
|
||||
mock_track_fc.return_value = mock_track
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track.request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
mock_track.scrobble_for_user.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_create_scrobble_from_jellyfin_track_webhook(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
jellyfin_track,
|
||||
):
|
||||
mock_artist = MagicMock(spec=Artist)
|
||||
mock_artist.id = 1
|
||||
mock_artist_fc.return_value = mock_artist
|
||||
|
||||
mock_track = MagicMock(spec=Track)
|
||||
mock_track.id = 1
|
||||
mock_track.scrobble_for_user.return_value = Scrobble(
|
||||
id=1, track_id=1, user_id=1, in_progress=True
|
||||
)
|
||||
mock_track_fc.return_value = mock_track
|
||||
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
response = client.post(
|
||||
url,
|
||||
jellyfin_track.request_json,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
mock_track.scrobble_for_user.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_mopidy_track_webhook_creates_track_and_scrobble(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
mopidy_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Sublime")
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track.request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.track == track
|
||||
assert scrobble.source == "Mopidy"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_jellyfin_track_webhook_creates_track_and_scrobble(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
jellyfin_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Carly Rae Jepsen")
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
response = client.post(
|
||||
url,
|
||||
jellyfin_track.request_json,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.track == track
|
||||
assert scrobble.source == "Jellyfin"
|
||||
assert "raw_data" in scrobble.log
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_mopidy_track_webhook_stores_raw_data(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
mopidy_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Sublime")
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track.request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.track == track
|
||||
assert scrobble.source == "Mopidy"
|
||||
assert "raw_data" in scrobble.log
|
||||
assert scrobble.log["raw_data"]["name"] == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_mopidy_track_webhook_stores_album_id(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
mopidy_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Sublime")
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
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
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.post(
|
||||
url,
|
||||
mopidy_track.request_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert "album_id" in scrobble.log
|
||||
assert scrobble.log["album_id"] == album.id
|
||||
assert "album" not in scrobble.log
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_jellyfin_track_webhook_stores_raw_data(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
jellyfin_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Carly Rae Jepsen")
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
response = client.post(
|
||||
url,
|
||||
jellyfin_track.request_json,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.track == track
|
||||
assert scrobble.source == "Jellyfin"
|
||||
assert "raw_data" in scrobble.log
|
||||
assert scrobble.log["raw_data"]["Name"] == "Emotion"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.Artist.find_or_create")
|
||||
@patch("music.models.Track.find_or_create")
|
||||
def test_jellyfin_track_webhook_stores_album_id(
|
||||
mock_track_fc,
|
||||
mock_artist_fc,
|
||||
client,
|
||||
valid_auth_token,
|
||||
jellyfin_track,
|
||||
):
|
||||
artist = Artist.objects.create(name="Carly Rae Jepsen")
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
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
|
||||
|
||||
url = reverse("scrobbles:jellyfin-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
|
||||
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
|
||||
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
response = client.post(
|
||||
url,
|
||||
jellyfin_track.request_json,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert "album_id" in scrobble.log
|
||||
assert scrobble.log["album_id"] == album.id
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@pytest.mark.django_db
|
||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||
@ -146,9 +497,118 @@ def test_scrobble_jellyfin_track(
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"scrobble_id": 1}
|
||||
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Track
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Track
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_detail_view_with_notes_as_flat_list(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task", description="Test description")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
log={
|
||||
"notes": ["First note", "Second note"],
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert "First note" in response.content.decode()
|
||||
assert "Second note" in response.content.decode()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task", description="Test description")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
log={
|
||||
"notes": [
|
||||
{"2024-01-01 10:00:00": "Note at first timestamp"},
|
||||
{"2024-01-02 11:30:00": "Note at second timestamp"},
|
||||
],
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "2024-01-01 10:00:00" in content
|
||||
assert "Note at first timestamp" in content
|
||||
assert "2024-01-02 11:30:00" in content
|
||||
assert "Note at second timestamp" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_detail_view_with_notes_and_labels(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task", description="Test description")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
log={
|
||||
"notes": [
|
||||
{"2024-01-01 10:00:00": "Note with label"},
|
||||
],
|
||||
"labels": ["work", "urgent"],
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "work" in content
|
||||
assert "urgent" in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_scrobble_detail_view_post_updates_log(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task", description="Test description")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
log={
|
||||
"notes": ["Original note"],
|
||||
"description": "Original description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
url,
|
||||
{
|
||||
"description": "Updated description",
|
||||
"notes": "Updated note",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 302
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.log["description"] == "Updated description"
|
||||
assert isinstance(scrobble.log["notes"], dict)
|
||||
assert list(scrobble.log["notes"].values()) == ["Updated note"]
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@ -250,3 +710,32 @@ def test_scrobble_jellyfin_track_create_new(
|
||||
scrobble = Scrobble.objects.get(id=1)
|
||||
assert scrobble.media_obj.__class__ == Track
|
||||
assert scrobble.media_obj.title == "Emotion"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_not_allowed_from_gps(client, valid_auth_token):
|
||||
url = reverse("scrobbles:gps-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
response = client.get(url, headers=headers)
|
||||
assert response.status_code == 405
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_gps_webhook_creates_location(client, valid_auth_token):
|
||||
url = reverse("scrobbles:gps-webhook")
|
||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
||||
gps_data = {
|
||||
"lat": "40.7128",
|
||||
"lon": "-74.0060",
|
||||
"alt": "10.5",
|
||||
"time": "2024-01-14T12:00:00Z",
|
||||
"prov": "gps",
|
||||
}
|
||||
response = client.post(
|
||||
url,
|
||||
gps_data,
|
||||
content_type="application/json",
|
||||
headers=headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "scrobble_id" in response.data
|
||||
|
||||
127
tests/tasks_tests/test_todoist.py
Normal file
127
tests/tasks_tests/test_todoist.py
Normal file
@ -0,0 +1,127 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_profile(db):
|
||||
user = User.objects.create_user(username="testuser", password="testpass")
|
||||
return user.profile
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGenerateTodoistOauthUrl:
|
||||
def test_generates_url_with_state(self, user_profile):
|
||||
from tasks.todoist import generate_todoist_oauth_url
|
||||
|
||||
url = generate_todoist_oauth_url(user_profile.user_id)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_state is not None
|
||||
assert len(user_profile.todoist_state) == 32
|
||||
assert url.startswith("https://todoist.com/oauth/authorize")
|
||||
assert user_profile.todoist_state in url
|
||||
|
||||
def test_updates_existing_state(self, user_profile):
|
||||
from tasks.todoist import generate_todoist_oauth_url
|
||||
|
||||
old_state = "oldstate12345678901234567890123"
|
||||
user_profile.todoist_state = old_state
|
||||
user_profile.save()
|
||||
|
||||
url = generate_todoist_oauth_url(user_profile.user_id)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_state != old_state
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGetTodoistAccessToken:
|
||||
def test_raises_when_profile_not_found(self):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
with pytest.raises(Exception, match="Could not find profile"):
|
||||
get_todoist_access_token(user_id=999, state="anystate", code="anycode")
|
||||
|
||||
def test_raises_when_state_mismatch(self, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
with pytest.raises(Exception, match="state mismatch"):
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id, state="wrongstate", code="anycode"
|
||||
)
|
||||
|
||||
@patch("tasks.todoist.requests.post")
|
||||
def test_exchanges_code_for_token(self, mock_post, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
mock_token_response = MagicMock()
|
||||
mock_token_response.status_code = 200
|
||||
mock_token_response.json.return_value = {"access_token": "test_access_token"}
|
||||
mock_post.return_value = mock_token_response
|
||||
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id,
|
||||
state="correctstate1234567890123",
|
||||
code="testcode",
|
||||
)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_auth_key == "test_access_token"
|
||||
assert user_profile.todoist_state is None
|
||||
|
||||
@patch("tasks.todoist.requests.post")
|
||||
def test_fetches_todoist_user_id(self, mock_post, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
mock_token_response = MagicMock()
|
||||
mock_token_response.status_code = 200
|
||||
mock_token_response.json.return_value = {"access_token": "test_access_token"}
|
||||
|
||||
mock_sync_response = MagicMock()
|
||||
mock_sync_response.status_code = 200
|
||||
mock_sync_response.json.return_value = {"user": {"id": "12345"}}
|
||||
|
||||
mock_post.side_effect = [mock_token_response, mock_sync_response]
|
||||
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id,
|
||||
state="correctstate1234567890123",
|
||||
code="testcode",
|
||||
)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_user_id == "12345"
|
||||
|
||||
@patch("tasks.todoist.requests.post")
|
||||
def test_handles_token_exchange_failure(self, mock_post, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id,
|
||||
state="correctstate1234567890123",
|
||||
code="badcode",
|
||||
)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_auth_key is None
|
||||
assert user_profile.todoist_state == "correctstate1234567890123"
|
||||
161
tests/test_context_processors.py
Normal file
161
tests/test_context_processors.py
Normal file
@ -0,0 +1,161 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vrobbler.context_processors import version_info
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class TestVersionInfo:
|
||||
def test_returns_version_and_commit(self, mock_request):
|
||||
"""Test that git commit is returned when _commit.py doesn't exist"""
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.return_value = b"abc1234"
|
||||
|
||||
# Mock the import to raise ImportError so git is used
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "vrobbler._commit":
|
||||
raise ImportError("No module named 'vrobbler._commit'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["app_version"] == "1.0.0"
|
||||
assert result["git_commit"] == "abc1234"
|
||||
|
||||
def test_uses_env_commit_if_set(self, mock_request):
|
||||
with (
|
||||
patch.dict(os.environ, {"VROBBLER_COMMIT": "env_commit_hash"}),
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "env_commit_hash"
|
||||
|
||||
def test_uses_commit_from_module_when_available(self, mock_request):
|
||||
"""Test that commit from _commit.py module is used when available"""
|
||||
with (patch("vrobbler.context_processors.get_version") as mock_get_version,):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
# Should use whatever value is in vrobbler/_commit.py
|
||||
# Could be "unknown" or an actual commit hash
|
||||
assert "git_commit" in result
|
||||
assert result["git_commit"] != ""
|
||||
|
||||
def test_uses_commit_from_file_when_module_unavailable(self, mock_request):
|
||||
"""Test that commit from /var/lib/vrobbler/commit.txt is used"""
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch("pathlib.Path.read_text", return_value="file_commit_hash"),
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "vrobbler._commit":
|
||||
raise ImportError("No module named 'vrobbler._commit'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "file_commit_hash"
|
||||
|
||||
def test_falls_back_to_git_when_file_unavailable(self, mock_request):
|
||||
"""Test fallback to git when _commit.py and file don't exist"""
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.return_value = b"git_commit_hash"
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "vrobbler._commit":
|
||||
raise ImportError("No module named 'vrobbler._commit'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "git_commit_hash"
|
||||
|
||||
def test_returns_unknown_when_version_fails(self, mock_request):
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.side_effect = Exception("not found")
|
||||
mock_check_output.return_value = b"abc1234"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["app_version"] == "unknown"
|
||||
|
||||
def test_returns_unknown_when_git_fails(self, mock_request):
|
||||
import subprocess
|
||||
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.side_effect = subprocess.SubprocessError()
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "unknown"
|
||||
|
||||
def test_returns_unknown_when_git_not_found(self, mock_request):
|
||||
import subprocess
|
||||
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.side_effect = FileNotFoundError()
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "unknown"
|
||||
0
tests/trails_tests/__init__.py
Normal file
0
tests/trails_tests/__init__.py
Normal file
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
|
||||
98
tests/videos_tests/test_api.py
Normal file
98
tests/videos_tests/test_api.py
Normal file
@ -0,0 +1,98 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from videos.models import Channel, Series, Video
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
user = User.objects.create(email="api@test.com")
|
||||
token = Token.objects.create(user=user)
|
||||
return {"HTTP_AUTHORIZATION": f"Token {token.key}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def channel():
|
||||
return Channel.objects.create(name="Test Channel")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def series():
|
||||
return Series.objects.create(
|
||||
name="Test Series",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video(channel):
|
||||
return Video.objects.create(
|
||||
title="Test Video",
|
||||
imdb_id="tt1234567",
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestVideoAPI:
|
||||
def test_list_videos(self, client, auth_headers, video):
|
||||
response = client.get("/api/v1/videos/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["title"] == "Test Video"
|
||||
|
||||
def test_get_video(self, client, auth_headers, video):
|
||||
response = client.get(f"/api/v1/videos/{video.id}/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.data["title"] == "Test Video"
|
||||
|
||||
def test_filter_videos_by_channel(self, client, auth_headers, channel, video):
|
||||
response = client.get(f"/api/v1/videos/?channel={channel.id}", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["channel"] == channel.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestChannelAPI:
|
||||
def test_list_channels(self, client, auth_headers, channel):
|
||||
response = client.get("/api/v1/channels/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Test Channel"
|
||||
|
||||
def test_get_channel(self, client, auth_headers, channel):
|
||||
response = client.get(f"/api/v1/channels/{channel.id}/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.data["name"] == "Test Channel"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSeriesAPI:
|
||||
def test_list_series(self, client, auth_headers, series):
|
||||
response = client.get("/api/v1/series/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.data["results"]) == 1
|
||||
assert response.data["results"][0]["name"] == "Test Series"
|
||||
|
||||
def test_get_series(self, client, auth_headers, series):
|
||||
response = client.get(f"/api/v1/series/{series.id}/", **auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.data["name"] == "Test Series"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestVideoAPIUnauthorized:
|
||||
def test_list_videos_unauthenticated(self, client, video):
|
||||
response = client.get("/api/v1/videos/")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_list_channels_unauthenticated(self, client, channel):
|
||||
response = client.get("/api/v1/channels/")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_list_series_unauthenticated(self, client, series):
|
||||
response = client.get("/api/v1/series/")
|
||||
assert response.status_code == 401
|
||||
@ -1,6 +0,0 @@
|
||||
from videos.sources.imdb import lookup_video_from_imdb
|
||||
|
||||
|
||||
def test_lookup_imdb():
|
||||
metadata = lookup_video_from_imdb("8946378")
|
||||
assert metadata.title == "Knives Out"
|
||||
|
||||
17
tests/videos_tests/test_video_find_or_create.py
Normal file
17
tests/videos_tests/test_video_find_or_create.py
Normal file
@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
from videos.models import Video
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestVideoFindOrCreate:
|
||||
def test_find_or_create_with_none_returns_none(self):
|
||||
result = Video.find_or_create(None)
|
||||
assert result is None
|
||||
|
||||
def test_find_or_create_with_empty_string_returns_none(self):
|
||||
result = Video.find_or_create("")
|
||||
assert result is None
|
||||
|
||||
def test_find_or_create_with_invalid_id_returns_none(self):
|
||||
result = Video.find_or_create("invalid-id")
|
||||
assert result is None
|
||||
@ -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
vrobbler/_commit.py
Normal file
1
vrobbler/_commit.py
Normal file
@ -0,0 +1 @@
|
||||
commit = "unknown"
|
||||
0
vrobbler/apps/beers/api/__init__.py
Normal file
0
vrobbler/apps/beers/api/__init__.py
Normal file
20
vrobbler/apps/beers/api/serializers.py
Normal file
20
vrobbler/apps/beers/api/serializers.py
Normal file
@ -0,0 +1,20 @@
|
||||
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__"
|
||||
21
vrobbler/apps/beers/api/views.py
Normal file
21
vrobbler/apps/beers/api/views.py
Normal file
@ -0,0 +1,21 @@
|
||||
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]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('beers', '0005_alter_beer_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='beer',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='beer',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='beer',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
26
vrobbler/apps/beers/migrations/0007_beer_tags.py
Normal file
26
vrobbler/apps/beers/migrations/0007_beer_tags.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("beers", "0006_remove_beer_run_time_seconds_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="beer",
|
||||
name="tags",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
26
vrobbler/apps/beers/migrations/0008_alter_beer_genre.py
Normal file
26
vrobbler/apps/beers/migrations/0008_alter_beer_genre.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
("beers", "0007_beer_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="beer",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -67,9 +67,7 @@ class Beer(ScrobblableMixin):
|
||||
)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_rating = models.FloatField(**BNULL)
|
||||
producer = models.ForeignKey(
|
||||
BeerProducer, on_delete=models.DO_NOTHING, **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})
|
||||
@ -130,9 +128,7 @@ class Beer(ScrobblableMixin):
|
||||
)
|
||||
style_ids.append(style_inst.id)
|
||||
|
||||
producer, _created = BeerProducer.objects.get_or_create(
|
||||
**producer_dict
|
||||
)
|
||||
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:
|
||||
|
||||
@ -85,9 +85,7 @@ def get_ibu_from_soup(soup) -> Optional[int]:
|
||||
def get_rating_from_soup(soup) -> str:
|
||||
rating = ""
|
||||
try:
|
||||
rating = float(
|
||||
soup.find(class_="num").get_text().strip("(").strip(")")
|
||||
)
|
||||
rating = float(soup.find(class_="num").get_text().strip("(").strip(")"))
|
||||
except AttributeError:
|
||||
rating = None
|
||||
except ValueError:
|
||||
@ -124,9 +122,7 @@ def get_beer_from_untappd_id(untappd_id: str) -> dict:
|
||||
beer_dict = {"untappd_id": untappd_id}
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warn(
|
||||
"Bad response from untappd.com", extra={"response": response}
|
||||
)
|
||||
logger.warn("Bad response from untappd.com", extra={"response": response})
|
||||
return beer_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
0
vrobbler/apps/birds/__init__.py
Normal file
0
vrobbler/apps/birds/__init__.py
Normal file
30
vrobbler/apps/birds/admin.py
Normal file
30
vrobbler/apps/birds/admin.py
Normal file
@ -0,0 +1,30 @@
|
||||
from birds.models import Bird, BirdingCSVImport, BirdingLocation
|
||||
from django.contrib import admin
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Bird)
|
||||
class BirdAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "common_name", "scientific_name", "ebird_code")
|
||||
ordering = ("-created",)
|
||||
search_fields = ("common_name", "scientific_name")
|
||||
|
||||
|
||||
@admin.register(BirdingLocation)
|
||||
class BirdingLocationAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "title")
|
||||
ordering = ("-created",)
|
||||
raw_id_fields = ("geo_location",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
|
||||
@admin.register(BirdingCSVImport)
|
||||
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started")
|
||||
ordering = ("-created",)
|
||||
0
vrobbler/apps/birds/api/__init__.py
Normal file
0
vrobbler/apps/birds/api/__init__.py
Normal file
14
vrobbler/apps/birds/api/serializers.py
Normal file
14
vrobbler/apps/birds/api/serializers.py
Normal file
@ -0,0 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
from birds.models import Bird, BirdingLocation
|
||||
|
||||
|
||||
class BirdSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Bird
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BirdingLocationSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BirdingLocation
|
||||
fields = "__all__"
|
||||
15
vrobbler/apps/birds/api/views.py
Normal file
15
vrobbler/apps/birds/api/views.py
Normal file
@ -0,0 +1,15 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from birds.api import serializers
|
||||
from birds import models
|
||||
|
||||
|
||||
class BirdViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Bird.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BirdSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BirdingLocationViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BirdingLocation.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BirdingLocationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
5
vrobbler/apps/birds/apps.py
Normal file
5
vrobbler/apps/birds/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BirdsConfig(AppConfig):
|
||||
name = "birds"
|
||||
85
vrobbler/apps/birds/forms.py
Normal file
85
vrobbler/apps/birds/forms.py
Normal file
@ -0,0 +1,85 @@
|
||||
import json
|
||||
|
||||
from birds.models import Bird, BirdSightingEntry
|
||||
from django import forms
|
||||
|
||||
|
||||
class BirdSightingsWidget(forms.Widget):
|
||||
template_name = "birds/bird_sightings_widget.html"
|
||||
|
||||
class Media:
|
||||
js = ("birds/bird_sightings.js",)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
bird_ids = data.getlist(f"{name}_bird_id")
|
||||
quantities = data.getlist(f"{name}_quantity")
|
||||
notes = data.getlist(f"{name}_sighting_notes")
|
||||
return {
|
||||
"bird_id": bird_ids,
|
||||
"quantity": quantities,
|
||||
"sighting_notes": notes,
|
||||
}
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
sightings = []
|
||||
if value:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
value = []
|
||||
for item in (value or []):
|
||||
if isinstance(item, dict):
|
||||
sightings.append(item)
|
||||
elif isinstance(item, BirdSightingEntry):
|
||||
sightings.append(item.asdict)
|
||||
context["widget"]["sightings"] = sightings
|
||||
context["widget"]["birds"] = Bird.objects.all().order_by("common_name")
|
||||
return context
|
||||
|
||||
|
||||
class BirdSightingsField(forms.Field):
|
||||
widget = BirdSightingsWidget
|
||||
|
||||
def clean(self, value):
|
||||
if not value:
|
||||
return None
|
||||
result = []
|
||||
bird_ids = value.get("bird_id", []) if isinstance(value, dict) else []
|
||||
quantities = value.get("quantity", []) if isinstance(value, dict) else []
|
||||
notes_list = (
|
||||
value.get("sighting_notes", []) if isinstance(value, dict) else []
|
||||
)
|
||||
|
||||
if isinstance(bird_ids, list):
|
||||
for i, bird_id in enumerate(bird_ids):
|
||||
if not bird_id:
|
||||
continue
|
||||
try:
|
||||
bird_id = int(bird_id)
|
||||
quantity = int(quantities[i]) if i < len(quantities) else 1
|
||||
except (ValueError, TypeError, IndexError):
|
||||
continue
|
||||
note = notes_list[i] if i < len(notes_list) else ""
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird_id,
|
||||
quantity=quantity,
|
||||
sighting_notes=note or None,
|
||||
)
|
||||
result.append(entry.asdict)
|
||||
elif bird_ids:
|
||||
try:
|
||||
bird_id = int(bird_ids)
|
||||
quantity = int(quantities) if quantities else 1
|
||||
except (ValueError, TypeError):
|
||||
raise forms.ValidationError("Invalid bird sighting data")
|
||||
note = notes_list if notes_list else ""
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird_id,
|
||||
quantity=quantity,
|
||||
sighting_notes=note or None,
|
||||
)
|
||||
result.append(entry.asdict)
|
||||
|
||||
return result if result else None
|
||||
189
vrobbler/apps/birds/importer.py
Normal file
189
vrobbler/apps/birds/importer.py
Normal file
@ -0,0 +1,189 @@
|
||||
import csv
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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()
|
||||
|
||||
LOCATION_COORDS_RE = re.compile(r"\(([\d\.\-]+),\s*([\d\.\-]+)\)")
|
||||
DURATION_RE = re.compile(r"(\d+)\s*minute")
|
||||
|
||||
|
||||
def parse_duration(duration_str):
|
||||
if not duration_str:
|
||||
return None
|
||||
match = DURATION_RE.search(duration_str)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def parse_coords(location_str):
|
||||
match = LOCATION_COORDS_RE.search(location_str)
|
||||
if match:
|
||||
return float(match.group(1)), float(match.group(2))
|
||||
return None, None
|
||||
|
||||
|
||||
def parse_timestamp(date_str, time_str):
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%B %d, %Y")
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_bool(value):
|
||||
if not value:
|
||||
return None
|
||||
return value.strip().lower() in ("true", "yes", "1")
|
||||
|
||||
|
||||
def parse_int(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(value.strip())
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def import_birding_csv(file_path, user_id):
|
||||
user = User.objects.get(id=user_id)
|
||||
new_scrobbles = []
|
||||
|
||||
with open(file_path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
rows = list(reader)
|
||||
|
||||
groups = defaultdict(list)
|
||||
for row in rows:
|
||||
key = (
|
||||
row.get("Location", "").strip(),
|
||||
row.get("Observation Date", "").strip(),
|
||||
row.get("Start Time", "").strip(),
|
||||
)
|
||||
groups[key].append(row)
|
||||
|
||||
for (location_str, date_str, time_str), sighting_rows in groups.items():
|
||||
if not location_str:
|
||||
logger.warning("Skipping rows with no location")
|
||||
continue
|
||||
|
||||
timestamp = parse_timestamp(date_str, time_str)
|
||||
if not timestamp:
|
||||
continue
|
||||
|
||||
timestamp = user.profile.get_timestamp_with_tz(timestamp)
|
||||
|
||||
location_title = (
|
||||
LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip()
|
||||
)
|
||||
if not location_title:
|
||||
location_title = location_str
|
||||
|
||||
location = BirdingLocation.find_or_create(location_title)
|
||||
lat, lon = parse_coords(location_str)
|
||||
if lat and lon and not location.geo_location:
|
||||
from locations.models import GeoLocation
|
||||
|
||||
geo, _ = GeoLocation.objects.get_or_create(
|
||||
lat=round(lat, 6),
|
||||
lon=round(lon, 6),
|
||||
defaults={"altitude": None},
|
||||
)
|
||||
location.geo_location = geo
|
||||
location.save(update_fields=["geo_location"])
|
||||
|
||||
first_row = sighting_rows[0]
|
||||
|
||||
birds_data = []
|
||||
for row in sighting_rows:
|
||||
species = row.get("Species", "").strip()
|
||||
if not species:
|
||||
continue
|
||||
count = parse_int(row.get("Count")) or 1
|
||||
details = row.get("Details", "").strip()
|
||||
|
||||
bird = Bird.find_or_create(species)
|
||||
entry = BirdSightingEntry(
|
||||
bird_id=bird.id, quantity=count, sighting_notes=details or None
|
||||
)
|
||||
birds_data.append(entry.asdict)
|
||||
|
||||
duration_minutes = parse_duration(first_row.get("Duration", ""))
|
||||
logdata = BirdSightingLogData(
|
||||
birds=birds_data,
|
||||
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,
|
||||
party_size=parse_int(first_row.get("Party Size")),
|
||||
complete_checklist=parse_bool(first_row.get("Complete Checklist")),
|
||||
)
|
||||
|
||||
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,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.BIRDING_LOCATION,
|
||||
)
|
||||
existing = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
birding_location=location,
|
||||
user=user,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble for {location}")
|
||||
continue
|
||||
new_scrobbles.append(scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
logger.info(f"Created {len(created)} birding scrobbles")
|
||||
for scrobble in created:
|
||||
ScrobbleNtfyNotification(scrobble).send()
|
||||
return created
|
||||
144
vrobbler/apps/birds/migrations/0001_initial.py
Normal file
144
vrobbler/apps/birds/migrations/0001_initial.py
Normal file
@ -0,0 +1,144 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-15 15:05
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
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"),
|
||||
("locations", "0010_clean_start"),
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Bird",
|
||||
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
|
||||
),
|
||||
),
|
||||
("common_name", models.CharField(max_length=255)),
|
||||
(
|
||||
"scientific_name",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"ebird_code",
|
||||
models.CharField(
|
||||
blank=True, db_index=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"photo",
|
||||
models.ImageField(blank=True, null=True, upload_to="birds/photos/"),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BirdingLocation",
|
||||
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)),
|
||||
(
|
||||
"ebird_hotspot_id",
|
||||
models.CharField(blank=True, max_length=255, 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",
|
||||
),
|
||||
),
|
||||
(
|
||||
"geo_location",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="locations.geolocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"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,
|
||||
},
|
||||
),
|
||||
]
|
||||
67
vrobbler/apps/birds/migrations/0002_birdingcsvimport.py
Normal file
67
vrobbler/apps/birds/migrations/0002_birdingcsvimport.py
Normal file
@ -0,0 +1,67 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-15 15:41
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("birds", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BirdingCSVImport",
|
||||
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(default=uuid.uuid4, editable=False)),
|
||||
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||
("process_log", models.TextField(blank=True, null=True)),
|
||||
("process_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"csv_file",
|
||||
models.FileField(
|
||||
blank=True, null=True, upload_to="birding-csv-uploads/"
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Birding CSV Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/birds/migrations/__init__.py
Normal file
0
vrobbler/apps/birds/migrations/__init__.py
Normal file
302
vrobbler/apps/birds/models.py
Normal file
302
vrobbler/apps/birds/models.py
Normal file
@ -0,0 +1,302 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files import File
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BirdSightingEntry(BaseLogData):
|
||||
bird_id: Optional[int] = None
|
||||
quantity: int = 1
|
||||
sighting_notes: Optional[str] = None
|
||||
|
||||
@property
|
||||
def bird(self) -> Optional["Bird"]:
|
||||
if not self.bird_id:
|
||||
return None
|
||||
return Bird.objects.filter(id=self.bird_id).first()
|
||||
|
||||
def __str__(self) -> str:
|
||||
name = self.bird.common_name if self.bird else "Unknown"
|
||||
out = f"{name} x{self.quantity}"
|
||||
if self.sighting_notes:
|
||||
out += f" ({self.sighting_notes})"
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
||||
birds: Optional[list[BirdSightingEntry]] = None
|
||||
duration_minutes: Optional[int] = None
|
||||
observation_type: Optional[str] = None
|
||||
distance: Optional[str] = None
|
||||
area: Optional[str] = None
|
||||
party_size: Optional[int] = None
|
||||
complete_checklist: Optional[bool] = None
|
||||
weather: Optional[str] = None
|
||||
temperature: Optional[int] = None
|
||||
guide: Optional[str] = None
|
||||
|
||||
_excluded_fields = {}
|
||||
|
||||
@cached_property
|
||||
def bird_list(self) -> str:
|
||||
if self.birds:
|
||||
return ", ".join(
|
||||
[BirdSightingEntry(**b).__str__() for b in self.birds]
|
||||
)
|
||||
return ""
|
||||
|
||||
def as_html(self) -> str:
|
||||
html_parts = []
|
||||
|
||||
if self.observation_type:
|
||||
html_parts.append(
|
||||
f'<div class="birding-obs-type">Type: {self.observation_type}</div>'
|
||||
)
|
||||
|
||||
if self.distance:
|
||||
html_parts.append(
|
||||
f'<div class="birding-distance">Distance: {self.distance}</div>'
|
||||
)
|
||||
|
||||
if self.area:
|
||||
html_parts.append(
|
||||
f'<div class="birding-area">Area: {self.area}</div>'
|
||||
)
|
||||
|
||||
if self.party_size:
|
||||
html_parts.append(
|
||||
f'<div class="birding-party">Party size: {self.party_size}</div>'
|
||||
)
|
||||
|
||||
if self.complete_checklist is not None:
|
||||
html_parts.append(
|
||||
f'<div class="birding-checklist">Complete checklist: {self.complete_checklist}</div>'
|
||||
)
|
||||
|
||||
if self.weather:
|
||||
html_parts.append(
|
||||
f'<div class="birding-weather">Weather: {self.weather}</div>'
|
||||
)
|
||||
|
||||
if self.temperature:
|
||||
html_parts.append(
|
||||
f'<div class="birding-temp">Temp: {self.temperature}°</div>'
|
||||
)
|
||||
|
||||
if self.guide:
|
||||
html_parts.append(
|
||||
f'<div class="birding-guide">Guide: {self.guide}</div>'
|
||||
)
|
||||
|
||||
if self.duration_minutes:
|
||||
html_parts.append(
|
||||
f'<div class="birding-duration">Duration: {self.duration_minutes} min</div>'
|
||||
)
|
||||
|
||||
if self.birds:
|
||||
birds_html = []
|
||||
for bird_data in self.birds:
|
||||
sighting = BirdSightingEntry(**bird_data)
|
||||
bird_info = sighting.bird.common_name if sighting.bird else "Unknown"
|
||||
extra = f" x{sighting.quantity}"
|
||||
if sighting.sighting_notes:
|
||||
extra += f" \u2014 {sighting.sighting_notes}"
|
||||
birds_html.append(
|
||||
f'<div class="bird-sighting">{bird_info}{extra}</div>'
|
||||
)
|
||||
html_parts.append(
|
||||
f'<div class="bird-sightings">{"".join(birds_html)}</div>'
|
||||
)
|
||||
|
||||
return "".join(html_parts)
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from birds.forms import BirdSightingsField
|
||||
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"birds": BirdSightingsField(required=False),
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
|
||||
class Bird(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
common_name = models.CharField(max_length=255)
|
||||
scientific_name = models.CharField(max_length=255, **BNULL)
|
||||
description = models.TextField(**BNULL)
|
||||
ebird_code = models.CharField(max_length=255, **BNULL, db_index=True)
|
||||
photo = models.ImageField(upload_to="birds/photos/", **BNULL)
|
||||
photo_small = ImageSpecField(
|
||||
source="photo",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
photo_medium = ImageSpecField(
|
||||
source="photo",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.common_name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("birds:bird_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, common_name: str) -> "Bird":
|
||||
bird = cls.objects.filter(common_name__iexact=common_name).first()
|
||||
if not bird:
|
||||
bird = cls.objects.create(common_name=common_name)
|
||||
return bird
|
||||
|
||||
|
||||
class BirdingLocation(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
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):
|
||||
return reverse("birds:birding_location_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return ""
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Birding at", tags="bird")
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BirdSightingLogData
|
||||
|
||||
def primary_image_url(self) -> str:
|
||||
return ""
|
||||
|
||||
def fix_metadata(self) -> None:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, title: str) -> "BirdingLocation":
|
||||
location = cls.objects.filter(title__iexact=title).first()
|
||||
if not location:
|
||||
location = cls.objects.create(title=title)
|
||||
return location
|
||||
|
||||
|
||||
class BirdingCSVImport(TimeStampedModel):
|
||||
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
|
||||
uuid = models.UUIDField(editable=False, default=uuid4)
|
||||
processing_started = models.DateTimeField(**BNULL)
|
||||
processed_finished = models.DateTimeField(**BNULL)
|
||||
process_log = models.TextField(**BNULL)
|
||||
process_count = models.IntegerField(**BNULL)
|
||||
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Birding CSV Import"
|
||||
|
||||
def __str__(self):
|
||||
return f"Birding import on {self.human_start}"
|
||||
|
||||
@property
|
||||
def human_start(self):
|
||||
start = "Unknown"
|
||||
if self.processing_started:
|
||||
start = self.processing_started.strftime("%B %d, %Y at %H:%M")
|
||||
return start
|
||||
|
||||
@property
|
||||
def import_type(self):
|
||||
return "Birding CSV"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("birds:csv_import_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def upload_file_path(self):
|
||||
if getattr(settings, "USE_S3_STORAGE"):
|
||||
path = self.csv_file.url
|
||||
else:
|
||||
path = self.csv_file.path
|
||||
return path
|
||||
|
||||
def mark_started(self):
|
||||
self.processing_started = timezone.now()
|
||||
self.save(update_fields=["processing_started"])
|
||||
|
||||
def mark_finished(self):
|
||||
self.processed_finished = timezone.now()
|
||||
self.save(update_fields=["processed_finished"])
|
||||
|
||||
def record_log(self, scrobbles):
|
||||
self.process_log = ""
|
||||
if not scrobbles:
|
||||
self.process_count = 0
|
||||
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}"
|
||||
)
|
||||
log_line = f"{scrobble_str}"
|
||||
if count > 0:
|
||||
log_line = "\n" + log_line
|
||||
self.process_log += log_line
|
||||
self.process_count = len(scrobbles)
|
||||
self.save(update_fields=["process_log", "process_count"])
|
||||
|
||||
def scrobbles(self):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
scrobble_ids = []
|
||||
if self.process_log:
|
||||
for line in self.process_log.split("\n"):
|
||||
sid = line.split("\t")[0]
|
||||
if sid:
|
||||
scrobble_ids.append(sid)
|
||||
return Scrobble.objects.filter(id__in=scrobble_ids)
|
||||
|
||||
def process(self, force=False):
|
||||
if self.processed_finished and not force:
|
||||
logger.info(f"{self} already processed on {self.processed_finished}")
|
||||
return
|
||||
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()
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
<div class="bird-sightings-widget">
|
||||
<div class="bird-sightings-list">
|
||||
{% for sighting in widget.sightings %}
|
||||
<div class="bird-sighting-row row mb-2">
|
||||
<div class="col-md-6">
|
||||
<select name="{{widget.name}}_bird_id" class="form-control">
|
||||
<option value="">Select bird...</option>
|
||||
{% for bird in widget.birds %}
|
||||
<option value="{{bird.id}}" {% if sighting.bird_id == bird.id %}selected{% endif %}>{{bird.common_name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="{{sighting.quantity|default:1}}" min="1" placeholder="Qty">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="{{sighting.sighting_notes|default:''}}" placeholder="Notes">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="bird-sighting-row row mb-2">
|
||||
<div class="col-md-6">
|
||||
<select name="{{widget.name}}_bird_id" class="form-control">
|
||||
<option value="">Select bird...</option>
|
||||
{% for bird in widget.birds %}
|
||||
<option value="{{bird.id}}">{{bird.common_name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="1" min="1" placeholder="Qty">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="" placeholder="Notes">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary add-sighting-row mt-2">Add bird</button>
|
||||
</div>
|
||||
37
vrobbler/apps/birds/urls.py
Normal file
37
vrobbler/apps/birds/urls.py
Normal file
@ -0,0 +1,37 @@
|
||||
from birds import views
|
||||
from django.urls import path
|
||||
|
||||
app_name = "birds"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"birding-locations/",
|
||||
views.BirdingLocationListView.as_view(),
|
||||
name="birding_location_list",
|
||||
),
|
||||
path(
|
||||
"birding-locations/<slug:slug>/",
|
||||
views.BirdingLocationDetailView.as_view(),
|
||||
name="birding_location_detail",
|
||||
),
|
||||
path(
|
||||
"birds/",
|
||||
views.BirdListView.as_view(),
|
||||
name="bird_list",
|
||||
),
|
||||
path(
|
||||
"birds/<slug:slug>/",
|
||||
views.BirdDetailView.as_view(),
|
||||
name="bird_detail",
|
||||
),
|
||||
path(
|
||||
"upload/birding-csv/",
|
||||
views.BirdingCSVImportCreateView.as_view(),
|
||||
name="csv-upload",
|
||||
),
|
||||
path(
|
||||
"imports/birding-csv/<slug:slug>/",
|
||||
views.BirdingCSVImportDetailView.as_view(),
|
||||
name="csv_import_detail",
|
||||
),
|
||||
]
|
||||
63
vrobbler/apps/birds/views.py
Normal file
63
vrobbler/apps/birds/views.py
Normal file
@ -0,0 +1,63 @@
|
||||
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,
|
||||
JsonableResponseMixin,
|
||||
)
|
||||
|
||||
|
||||
class BirdingLocationListView(ScrobbleableListView):
|
||||
model = BirdingLocation
|
||||
|
||||
|
||||
class BirdingLocationDetailView(ScrobbleableDetailView):
|
||||
model = BirdingLocation
|
||||
|
||||
|
||||
class BirdListView(generic.ListView):
|
||||
model = Bird
|
||||
paginate_by = 200
|
||||
ordering = "common_name"
|
||||
|
||||
|
||||
class BirdDetailView(generic.DetailView):
|
||||
model = Bird
|
||||
slug_field = "uuid"
|
||||
|
||||
|
||||
class BirdingCSVImportCreateView(
|
||||
LoginRequiredMixin, JsonableResponseMixin, generic.CreateView
|
||||
):
|
||||
model = BirdingCSVImport
|
||||
fields = ["csv_file"]
|
||||
template_name = "scrobbles/upload_form.html"
|
||||
success_url = reverse_lazy("vrobbler-home")
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
class BirdingCSVImportDetailView(LoginRequiredMixin, generic.DetailView):
|
||||
model = BirdingCSVImport
|
||||
slug_field = "uuid"
|
||||
template_name = "scrobbles/import_detail.html"
|
||||
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(user=self.request.user)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["title"] = "eBird CSV Import"
|
||||
return context_data
|
||||
26
vrobbler/apps/boardgames/api/serializers.py
Normal file
26
vrobbler/apps/boardgames/api/serializers.py
Normal file
@ -0,0 +1,26 @@
|
||||
from boardgames import models
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class BoardGameDesignerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGameDesigner
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGamePublisherSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGamePublisher
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGameLocation
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGame
|
||||
fields = "__all__"
|
||||
28
vrobbler/apps/boardgames/api/views.py
Normal file
28
vrobbler/apps/boardgames/api/views.py
Normal file
@ -0,0 +1,28 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from boardgames.api import serializers
|
||||
from boardgames import models
|
||||
|
||||
|
||||
class BoardGameDesignerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGameDesigner.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameDesignerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGamePublisherViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGamePublisher.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGamePublisherSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameLocationViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGameLocation.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameLocationSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGame.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
|
||||
User = get_user_model()
|
||||
if TYPE_CHECKING:
|
||||
@ -13,10 +14,13 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEARCH_ID_URL = (
|
||||
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||
)
|
||||
SEARCH_ID_URL = "https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
|
||||
BGG_ACCESS_TOKEN = getattr(settings, "BGG_ACCESS_TOKEN", "")
|
||||
BASE_HEADERS = {
|
||||
"User-Agent": "Vrobbler 31.0",
|
||||
"Authorization": f"Bearer {BGG_ACCESS_TOKEN}",
|
||||
}
|
||||
|
||||
|
||||
def take_first(thing: Optional[list]) -> str:
|
||||
@ -37,10 +41,9 @@ def take_first(thing: Optional[list]) -> str:
|
||||
|
||||
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||
soup = None
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
game_id = None
|
||||
url = SEARCH_ID_URL.format(query=title)
|
||||
r = requests.get(url, headers=headers)
|
||||
r = requests.get(url, headers=BASE_HEADERS)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
@ -57,7 +60,6 @@ def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
soup = None
|
||||
game_dict = {}
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
|
||||
title = ""
|
||||
bgg_id = None
|
||||
@ -73,7 +75,7 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
bgg_id = lookup_boardgame_id_from_bgg(title)
|
||||
|
||||
url = GAME_ID_URL.format(id=bgg_id)
|
||||
r = requests.get(url, headers=headers)
|
||||
r = requests.get(url, headers=BASE_HEADERS)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
@ -109,7 +111,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
login_payload = {
|
||||
"credentials": {"username": bgg_username, "password": bgg_password}
|
||||
}
|
||||
headers = {"content-type": "application/json"}
|
||||
headers = BASE_HEADERS
|
||||
headers["content-type"] = "application/json"
|
||||
|
||||
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before
|
||||
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boardgames', '0010_boardgame_published_year'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='boardgame',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='boardgame',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-11-03 04:34
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boardgames', '0011_remove_boardgame_run_time_seconds_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='bgg_rank',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-11-03 04:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('boardgames', '0012_boardgame_bgg_rank'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='boardgame',
|
||||
name='publishers',
|
||||
field=models.ManyToManyField(related_name='board_games', to='boardgames.boardgamepublisher'),
|
||||
),
|
||||
]
|
||||
26
vrobbler/apps/boardgames/migrations/0014_boardgame_tags.py
Normal file
26
vrobbler/apps/boardgames/migrations/0014_boardgame_tags.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("boardgames", "0013_boardgame_publishers"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="tags",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
("boardgames", "0014_boardgame_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="boardgame",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,13 +1,13 @@
|
||||
from functools import cached_property
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from functools import cached_property
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django import forms
|
||||
import requests
|
||||
from boardgames.bgg import lookup_boardgame_from_bgg
|
||||
from boardgames.sources.bgg import lookup_boardgame_from_bgg
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
@ -80,6 +80,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
board: Optional[str] = None
|
||||
rounds: Optional[int] = None
|
||||
details: Optional[str] = None
|
||||
raw_data: Optional[dict] = None
|
||||
|
||||
_excluded_fields = {
|
||||
"lichess_id",
|
||||
@ -89,6 +90,26 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
"variant",
|
||||
}
|
||||
|
||||
@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)
|
||||
custom_fields = {
|
||||
"notes": NotesDictField(required=False),
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
),
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
@cached_property
|
||||
def location(self):
|
||||
if not self.location_id:
|
||||
@ -99,29 +120,38 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
def player_log(self) -> str:
|
||||
if self.players:
|
||||
return ", ".join(
|
||||
[
|
||||
BoardGameScoreLogData(**player).__str__()
|
||||
for player in self.players
|
||||
]
|
||||
[BoardGameScoreLogData(**player).__str__() for player in self.players]
|
||||
)
|
||||
return ""
|
||||
|
||||
@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(),
|
||||
def as_html(self) -> str:
|
||||
html_parts = []
|
||||
|
||||
if self.board:
|
||||
html_parts.append(f'<div class="boardgame-board">{self.board}</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_info = player.name
|
||||
if player.score:
|
||||
player_info += f" ({player.score})"
|
||||
if player.win:
|
||||
player_info += " 🏆"
|
||||
if player.new:
|
||||
player_info += " (new)"
|
||||
players_html.append(
|
||||
f'<div class="boardgame-player">{player_info}</div>'
|
||||
)
|
||||
html_parts.append(
|
||||
f'<div class="boardgame-players">{"".join(players_html)}</div>'
|
||||
)
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
return "".join(html_parts)
|
||||
|
||||
|
||||
class BoardGamePublisher(TimeStampedModel):
|
||||
@ -134,9 +164,7 @@ class BoardGamePublisher(TimeStampedModel):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:publisher_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
return reverse("boardgames:publisher_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
|
||||
class BoardGameDesigner(TimeStampedModel):
|
||||
@ -149,9 +177,7 @@ class BoardGameDesigner(TimeStampedModel):
|
||||
return str(self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:designer_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
return reverse("boardgames:designer_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
|
||||
class BoardGameLocation(TimeStampedModel):
|
||||
@ -159,23 +185,17 @@ class BoardGameLocation(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
bgstats_id = models.UUIDField(**BNULL)
|
||||
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)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:location_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
return reverse("boardgames:location_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
|
||||
class BoardGame(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(
|
||||
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
||||
)
|
||||
COMPLETION_PERCENT = getattr(settings, "BOARD_GAME_COMPLETION_PERCENT", 100)
|
||||
|
||||
FIELDS_FROM_BGGEEK = [
|
||||
"igdb_id",
|
||||
@ -191,6 +211,10 @@ class BoardGame(ScrobblableMixin):
|
||||
publisher = models.ForeignKey(
|
||||
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
publishers = models.ManyToManyField(
|
||||
BoardGamePublisher,
|
||||
related_name="board_games",
|
||||
)
|
||||
designers = models.ManyToManyField(
|
||||
BoardGameDesigner,
|
||||
related_name="board_games",
|
||||
@ -224,6 +248,7 @@ class BoardGame(ScrobblableMixin):
|
||||
options={"quality": 75},
|
||||
)
|
||||
rating = models.FloatField(**BNULL)
|
||||
bgg_rank = models.IntegerField(**BNULL)
|
||||
max_players = models.PositiveSmallIntegerField(**BNULL)
|
||||
min_players = models.PositiveSmallIntegerField(**BNULL)
|
||||
published_date = models.DateField(**BNULL)
|
||||
@ -245,9 +270,7 @@ class BoardGame(ScrobblableMixin):
|
||||
return self.title
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"boardgames:boardgame_detail", kwargs={"slug": self.uuid}
|
||||
)
|
||||
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
@ -272,7 +295,6 @@ class BoardGame(ScrobblableMixin):
|
||||
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
|
||||
|
||||
if not self.published_date or force_update:
|
||||
|
||||
if not data:
|
||||
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
||||
|
||||
@ -301,29 +323,69 @@ class BoardGame(ScrobblableMixin):
|
||||
|
||||
# Go get cover image if the URL is present
|
||||
if cover_url and not self.cover:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
logger.debug(r.status_code)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
logger.debug("Loaded cover image from BGGeek")
|
||||
self.save_image_from_url(cover_url)
|
||||
|
||||
def save_image_from_url(self, url):
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, lookup_id: str, data: Optional[dict] = {}
|
||||
) -> Optional["BoardGame"]:
|
||||
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
|
||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||
boardgame = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
if not game:
|
||||
game = cls.objects.filter(title=lookup_id).first()
|
||||
|
||||
if not data or not boardgame:
|
||||
data = lookup_boardgame_from_bgg(lookup_id)
|
||||
|
||||
if data and not boardgame:
|
||||
boardgame, created = cls.objects.get_or_create(
|
||||
title=data["title"], bggeek_id=lookup_id
|
||||
if game:
|
||||
logger.info(
|
||||
"Board game exists in database.",
|
||||
extra={"lookup_id": lookup_id, "data": data},
|
||||
)
|
||||
if created:
|
||||
boardgame.fix_metadata(data=data)
|
||||
return game
|
||||
|
||||
return boardgame
|
||||
if data.get("bggId"):
|
||||
bgg_data = lookup_boardgame_from_bgg(lookup_id=data.get("bggId"))
|
||||
elif data.get("name"):
|
||||
bgg_data = lookup_boardgame_from_bgg(title=data.get("name"))
|
||||
else:
|
||||
if int(lookup_id):
|
||||
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
|
||||
else:
|
||||
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
||||
|
||||
mechanics = bgg_data.pop("mechanics", [])
|
||||
designers = bgg_data.pop("designers", [])
|
||||
categories = bgg_data.pop("categories", [])
|
||||
publishers = bgg_data.pop("publishers", [])
|
||||
publisher = bgg_data.pop("publisher", [])
|
||||
cover_url = bgg_data.pop("cover_url")
|
||||
|
||||
game = cls.objects.create(**bgg_data)
|
||||
|
||||
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)
|
||||
game.uses_teams = data.get("useTeams", False)
|
||||
game.bgstats_id = data.get("uuid", None)
|
||||
if publisher:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
|
||||
game.publisher = publisher
|
||||
game.save()
|
||||
|
||||
if designers:
|
||||
for designer_name in designers:
|
||||
designer, created = BoardGameDesigner.objects.get_or_create(
|
||||
name=designer_name
|
||||
)
|
||||
game.designers.add(designer.id)
|
||||
|
||||
if publishers:
|
||||
for name in publishers:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
|
||||
game.publishers.add(publisher)
|
||||
|
||||
return game
|
||||
|
||||
40
vrobbler/apps/boardgames/sources/bgg.py
Normal file
40
vrobbler/apps/boardgames/sources/bgg.py
Normal file
@ -0,0 +1,40 @@
|
||||
from typing import Any, Union
|
||||
from boardgamegeek import BGGClient
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def lookup_boardgame_from_bgg(
|
||||
lookup_id: str | None = None, title: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
game_dict: dict[str, Any] = {}
|
||||
|
||||
bgg = BGGClient(access_token=settings.BGG_ACCESS_TOKEN)
|
||||
|
||||
if lookup_id:
|
||||
game = bgg.game(game_id=lookup_id)
|
||||
else:
|
||||
game = bgg.game(title)
|
||||
|
||||
if game:
|
||||
game_dict["title"] = game.name
|
||||
game_dict["description"] = game.description
|
||||
game_dict["published_year"] = game.yearpublished
|
||||
game_dict["cover_url"] = game.image
|
||||
game_dict["min_players"] = game.minplayers
|
||||
game_dict["max_players"] = game.maxplayers
|
||||
game_dict["recommended_age"] = game.minage
|
||||
game_dict["rating"] = game.rating_average
|
||||
game_dict["bggeek_id"] = game.id
|
||||
game_dict["bgg_rank"] = game.bgg_rank
|
||||
game_dict["base_run_time_seconds"] = (
|
||||
int(game.playingtime) * 60 if game.playingtime else None
|
||||
)
|
||||
game_dict["mechanics"] = game.mechanics
|
||||
game_dict["categories"] = game.categories
|
||||
game_dict["designers"] = game.designers
|
||||
game_dict["publishers"] = game.publishers
|
||||
if game.publishers:
|
||||
game_dict["publisher"] = game.publishers[0]
|
||||
|
||||
return game_dict
|
||||
@ -11,16 +11,14 @@ User = get_user_model()
|
||||
def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
user = User.objects.get(id=user_id)
|
||||
|
||||
client = berserk.Client(
|
||||
session=berserk.TokenSession(settings.LICHESS_API_KEY)
|
||||
)
|
||||
client = berserk.Client(session=berserk.TokenSession(settings.LICHESS_API_KEY))
|
||||
games = client.games.export_by_player(user.profile.lichess_username)
|
||||
for game_dict in games:
|
||||
chess, created = BoardGame.objects.get_or_create(title="Chess")
|
||||
if created:
|
||||
chess.run_time_seconds = 1800
|
||||
chess.base_run_time_seconds = 1800
|
||||
chess.bggeek_id = 171
|
||||
chess.save(update_fields=["run_time_seconds", "bggeek_id"])
|
||||
chess.save(update_fields=["base_run_time_seconds", "bggeek_id"])
|
||||
scrobble = Scrobble.objects.filter(
|
||||
user_id=user.id,
|
||||
timestamp=game_dict.get("createdAt"),
|
||||
@ -62,9 +60,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
white_player.get("aiLevel", "")
|
||||
)
|
||||
else:
|
||||
other_player["name_str"] = white_player.get("user", {}).get(
|
||||
"name", ""
|
||||
)
|
||||
other_player["name_str"] = white_player.get("user", {}).get("name", "")
|
||||
other_player["lichess_username"] = other_player["name_str"]
|
||||
|
||||
other_player["color"] = "white"
|
||||
@ -82,9 +78,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||
black_player.get("aiLevel", "")
|
||||
)
|
||||
else:
|
||||
other_player["name_str"] = black_player.get("user", {}).get(
|
||||
"name", ""
|
||||
)
|
||||
other_player["name_str"] = black_player.get("user", {}).get("name", "")
|
||||
other_player["lichess_username"] = other_player["name_str"]
|
||||
other_player["color"] = "black"
|
||||
if winner == "white":
|
||||
@ -119,6 +113,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:
|
||||
|
||||
@ -1,13 +1,70 @@
|
||||
import datetime
|
||||
from django.utils import timezone
|
||||
from django.views import generic
|
||||
from boardgames.models import BoardGame, BoardGamePublisher
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.views import (
|
||||
ChartContextMixin,
|
||||
ScrobbleableListView,
|
||||
ScrobbleableDetailView,
|
||||
)
|
||||
|
||||
|
||||
class BoardGameListView(ScrobbleableListView):
|
||||
model = BoardGame
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
now = timezone.now()
|
||||
start_day_of_week = now - datetime.timedelta(days=now.weekday())
|
||||
start_day_of_month = now.replace(day=1)
|
||||
|
||||
class BoardGameDetailView(ScrobbleableDetailView):
|
||||
scrobbles_this_week = Scrobble.objects.filter(
|
||||
user=user,
|
||||
board_game__isnull=False,
|
||||
timestamp__gte=start_day_of_week,
|
||||
).select_related("board_game")
|
||||
|
||||
scrobbles_this_month = Scrobble.objects.filter(
|
||||
user=user,
|
||||
board_game__isnull=False,
|
||||
timestamp__gte=start_day_of_month,
|
||||
).select_related("board_game")
|
||||
|
||||
designers_this_week = {}
|
||||
for scrobble in scrobbles_this_week:
|
||||
for designer in scrobble.board_game.designers.all():
|
||||
designers_this_week[designer.id] = {
|
||||
"designer": designer,
|
||||
"count": designers_this_week.get(designer.id, {}).get("count", 0)
|
||||
+ 1,
|
||||
}
|
||||
|
||||
designers_this_month = {}
|
||||
for scrobble in scrobbles_this_month:
|
||||
for designer in scrobble.board_game.designers.all():
|
||||
designers_this_month[designer.id] = {
|
||||
"designer": designer,
|
||||
"count": designers_this_month.get(designer.id, {}).get("count", 0)
|
||||
+ 1,
|
||||
}
|
||||
|
||||
context_data["designers_this_week"] = sorted(
|
||||
designers_this_week.values(),
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)
|
||||
context_data["designers_this_month"] = sorted(
|
||||
designers_this_month.values(),
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return context_data
|
||||
|
||||
|
||||
class BoardGameDetailView(ScrobbleableDetailView, ChartContextMixin):
|
||||
model = BoardGame
|
||||
|
||||
|
||||
|
||||
@ -21,7 +21,8 @@ class BookAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"subtitle",
|
||||
"author",
|
||||
"issue_or_volume",
|
||||
"isbn_13",
|
||||
"first_publish_year",
|
||||
"pages",
|
||||
@ -32,6 +33,9 @@ class BookAdmin(admin.ModelAdmin):
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
def issue_or_volume(self, obj):
|
||||
return obj.issue_number or obj.volume_number
|
||||
|
||||
|
||||
@admin.register(Paper)
|
||||
class BookAdmin(admin.ModelAdmin):
|
||||
|
||||
@ -6,9 +6,7 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
|
||||
)
|
||||
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}"
|
||||
|
||||
|
||||
@ -95,32 +93,20 @@ def get_amazon_product_dict(amazon_id: str) -> 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
|
||||
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()
|
||||
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}"
|
||||
)
|
||||
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}"
|
||||
)
|
||||
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
|
||||
|
||||
return data_dict
|
||||
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
from books.api.serializers import (
|
||||
AuthorSerializer,
|
||||
BookSerializer,
|
||||
)
|
||||
from books.models import Author, Book
|
||||
from books.api import serializers
|
||||
from books import models
|
||||
|
||||
|
||||
class AuthorViewSet(viewsets.ModelViewSet):
|
||||
queryset = Author.objects.all().order_by("-created")
|
||||
serializer_class = AuthorSerializer
|
||||
queryset = models.Author.objects.all().order_by("-created")
|
||||
serializer_class = serializers.AuthorSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BookViewSet(viewsets.ModelViewSet):
|
||||
queryset = Book.objects.all().order_by("-created")
|
||||
serializer_class = BookSerializer
|
||||
queryset = models.Book.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BookSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
from enum import Enum
|
||||
|
||||
BOOKS_TITLES_TO_IGNORE = [
|
||||
"KOReader Quickstart Guide",
|
||||
@ -7,3 +8,16 @@ BOOKS_TITLES_TO_IGNORE = [
|
||||
]
|
||||
|
||||
READCOMICSONLINE_URL = "https://readcomicsonline.ru"
|
||||
|
||||
|
||||
class MediaSourceTag(str, Enum):
|
||||
OPENLIBRARY = "source_openlibrary"
|
||||
GOOGLE_BOOKS = "source_google_books"
|
||||
COMICVINE = "source_comicvine"
|
||||
LOCG = "source_locg"
|
||||
KOREADER = "source_koreader"
|
||||
SEMANTIC_SCHOLAR = "source_semantic_scholar"
|
||||
|
||||
@classmethod
|
||||
def choices(cls):
|
||||
return [(tag.value, tag.name.replace("_", " ").title()) for tag in cls]
|
||||
|
||||
@ -114,7 +114,7 @@ def create_book_from_row(row: list):
|
||||
"raw_row_data": clean_row,
|
||||
}
|
||||
},
|
||||
run_time_seconds=run_time,
|
||||
base_run_time_seconds=run_time,
|
||||
)
|
||||
# TODO Move these to async processes after importing
|
||||
# book.fix_metadata()
|
||||
@ -139,7 +139,6 @@ def build_book_map(rows) -> dict:
|
||||
book_id_map = {}
|
||||
|
||||
for book_row in rows:
|
||||
|
||||
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
|
||||
logger.info(
|
||||
"[build_book_map] Ignoring book title that is likely garbage",
|
||||
@ -147,9 +146,7 @@ def build_book_map(rows) -> dict:
|
||||
)
|
||||
continue
|
||||
book = Book.objects.filter(
|
||||
koreader_data_by_hash__icontains=book_row[
|
||||
KoReaderBookColumn.MD5.value
|
||||
]
|
||||
koreader_data_by_hash__icontains=book_row[KoReaderBookColumn.MD5.value]
|
||||
).first()
|
||||
|
||||
if not book:
|
||||
@ -203,15 +200,11 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
||||
"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)}"
|
||||
)
|
||||
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
|
||||
return book_map
|
||||
|
||||
|
||||
def build_scrobbles_from_book_map(
|
||||
book_map: dict, user: "User"
|
||||
) -> list["Scrobble"]:
|
||||
def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobble"]:
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
|
||||
scrobbles_to_create = []
|
||||
@ -241,9 +234,9 @@ def build_scrobbles_from_book_map(
|
||||
|
||||
seconds_from_last_page = 0
|
||||
if prev_page_stats:
|
||||
seconds_from_last_page = stats.get(
|
||||
"end_ts"
|
||||
) - prev_page_stats.get("start_ts")
|
||||
seconds_from_last_page = stats.get("end_ts") - prev_page_stats.get(
|
||||
"start_ts"
|
||||
)
|
||||
|
||||
playback_position_seconds = playback_position_seconds + stats.get(
|
||||
"duration"
|
||||
@ -252,9 +245,7 @@ def build_scrobbles_from_book_map(
|
||||
end_of_reading = pages_processed == total_pages_read
|
||||
big_jump_to_this_page = (cur_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:
|
||||
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
|
||||
should_create_scrobble = True
|
||||
|
||||
if should_create_scrobble:
|
||||
@ -286,11 +277,11 @@ def build_scrobbles_from_book_map(
|
||||
)
|
||||
|
||||
# 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)
|
||||
# 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,
|
||||
@ -307,6 +298,10 @@ def build_scrobbles_from_book_map(
|
||||
"page_data": scrobble_page_data,
|
||||
"pages_read": len(scrobble_page_data.keys()),
|
||||
}
|
||||
if hasattr(timestamp.tzinfo, "tzname"):
|
||||
tz = timestamp.tzinfo.tzname
|
||||
if hasattr(timestamp.tzinfo, "name"):
|
||||
tz = timestamp.tzinfo.name
|
||||
scrobbles_to_create.append(
|
||||
Scrobble(
|
||||
book_id=book_id,
|
||||
@ -320,7 +315,7 @@ def build_scrobbles_from_book_map(
|
||||
in_progress=False,
|
||||
played_to_completion=True,
|
||||
long_play_complete=False,
|
||||
timezone=timestamp.tzinfo.name,
|
||||
timezone=tz,
|
||||
)
|
||||
)
|
||||
# Then start over
|
||||
@ -378,9 +373,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
||||
return new_scrobbles
|
||||
|
||||
book_map = build_page_data(
|
||||
cur.execute(
|
||||
"SELECT * from page_stat_data ORDER BY id_book, start_time"
|
||||
),
|
||||
cur.execute("SELECT * from page_stat_data ORDER BY id_book, start_time"),
|
||||
book_map,
|
||||
tz,
|
||||
)
|
||||
|
||||
@ -13,9 +13,7 @@ HEADERS = {
|
||||
}
|
||||
LOCG_WRTIER_URL = ""
|
||||
LOCG_WRITER_DETAIL_URL = "https://leagueofcomicgeeks.com/people/{slug}"
|
||||
LOCG_SEARCH_URL = (
|
||||
"https://leagueofcomicgeeks.com/search/ajax_issues?query={query}"
|
||||
)
|
||||
LOCG_SEARCH_URL = "https://leagueofcomicgeeks.com/search/ajax_issues?query={query}"
|
||||
LOCG_DETAIL_URL = "https://leagueofcomicgeeks.com/comic/{locg_slug}"
|
||||
|
||||
|
||||
@ -72,26 +70,19 @@ def lookup_comic_by_locg_slug(slug: str) -> dict:
|
||||
attrs = soup.findAll("div", class_="details-addtl-block")
|
||||
try:
|
||||
data_dict["pages"] = (
|
||||
attrs[1]
|
||||
.find("div", class_="value")
|
||||
.text.split("pages")[0]
|
||||
.strip()
|
||||
attrs[1].find("div", class_="value").text.split("pages")[0].strip()
|
||||
)
|
||||
except IndexError:
|
||||
logger.warn(f"No ISBN field")
|
||||
try:
|
||||
data_dict["isbn"] = (
|
||||
attrs[3].find("div", class_="value").text.strip()
|
||||
)
|
||||
data_dict["isbn"] = attrs[3].find("div", class_="value").text.strip()
|
||||
except IndexError:
|
||||
logger.warn(f"No ISBN field")
|
||||
|
||||
writer_slug = None
|
||||
try:
|
||||
writer_slug = (
|
||||
soup.findAll("div", class_="name")[5]
|
||||
.a.get("href")
|
||||
.split("people/")[1]
|
||||
soup.findAll("div", class_="name")[5].a.get("href").split("people/")[1]
|
||||
)
|
||||
except IndexError:
|
||||
logger.warn(f"No wrtier found")
|
||||
|
||||
@ -14,14 +14,13 @@ logger = logging.getLogger(__name__)
|
||||
# Grace period between page reads for it to be a new scrobble
|
||||
SESSION_GAP_SECONDS = 1800 # a half hour
|
||||
|
||||
|
||||
def update_scrobble_from_page_data(scrobble, commit=True):
|
||||
page_list = list(scrobble.book_page_data.items())
|
||||
first_page_start_ts = datetime.fromtimestamp(page_list[0][1]["start_ts"])
|
||||
last_page_end_ts = datetime.fromtimestamp(page_list[-1][1]["end_ts"])
|
||||
|
||||
if (
|
||||
datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15)
|
||||
):
|
||||
if datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15):
|
||||
first_page_start_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||
last_page_end_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
|
||||
else:
|
||||
|
||||
@ -52,8 +52,7 @@ class Command(BaseCommand):
|
||||
seconds_from_last_page = 0
|
||||
if prev_page:
|
||||
seconds_from_last_page = (
|
||||
page.end_time.timestamp()
|
||||
- prev_page.start_time.timestamp()
|
||||
page.end_time.timestamp() - prev_page.start_time.timestamp()
|
||||
)
|
||||
playback_position_seconds = (
|
||||
playback_position_seconds + page.duration_seconds
|
||||
@ -62,9 +61,7 @@ class Command(BaseCommand):
|
||||
end_of_reading = pages_processed == total_pages
|
||||
big_jump_to_this_page = False
|
||||
if prev_page:
|
||||
big_jump_to_this_page = (
|
||||
page.number - prev_page.number
|
||||
) > 10
|
||||
big_jump_to_this_page = (page.number - prev_page.number) > 10
|
||||
if (
|
||||
seconds_from_last_page > SESSION_GAP_SECONDS
|
||||
and not big_jump_to_this_page
|
||||
@ -113,9 +110,7 @@ class Command(BaseCommand):
|
||||
user_id=user.id,
|
||||
).first()
|
||||
if scrobble:
|
||||
logger.info(
|
||||
f"Found existing scrobble {scrobble}, updating"
|
||||
)
|
||||
logger.info(f"Found existing scrobble {scrobble}, updating")
|
||||
scrobble.book_page_data = scrobble_page_data
|
||||
scrobble.playback_position_seconds = (
|
||||
scrobble.calc_reading_duration()
|
||||
|
||||
@ -1,11 +1,5 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
import pendulum
|
||||
|
||||
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
|
||||
IMDB_VIDEO_URL = "https://www.imdb.com/title/tt"
|
||||
|
||||
|
||||
class BookType:
|
||||
...
|
||||
|
||||
18
vrobbler/apps/books/migrations/0030_book_readcomics_url.py
Normal file
18
vrobbler/apps/books/migrations/0030_book_readcomics_url.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-22 16:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0029_book_comicvine_id_book_issue_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='readcomics_url',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-22 17:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0030_book_readcomics_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='next_readcomics_url',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,39 @@
|
||||
# Generated by Django 4.2.19 on 2025-10-30 01:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('books', '0031_book_next_readcomics_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='book',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='book',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paper',
|
||||
name='run_time_seconds',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='paper',
|
||||
name='run_time_ticks',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='book',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='paper',
|
||||
name='base_run_time_seconds',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-08 05:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0032_remove_book_run_time_seconds_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="issue_number",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="volume_number",
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
26
vrobbler/apps/books/migrations/0034_book_tags.py
Normal file
26
vrobbler/apps/books/migrations/0034_book_tags.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:23
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("books", "0033_alter_book_issue_number_alter_book_volume_number"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="tags",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
26
vrobbler/apps/books/migrations/0035_paper_tags.py
Normal file
26
vrobbler/apps/books/migrations/0035_paper_tags.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("books", "0034_book_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="paper",
|
||||
name="tags",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,37 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
("books", "0035_paper_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="paper",
|
||||
name="genre",
|
||||
field=taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -1,15 +1,31 @@
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from books.constants import MediaSourceTag, READCOMICSONLINE_URL
|
||||
from books.locg import (
|
||||
lookup_comic_by_locg_slug,
|
||||
lookup_comic_from_locg,
|
||||
lookup_comic_writer_by_locg_slug,
|
||||
)
|
||||
from books.openlibrary import (
|
||||
lookup_author_from_openlibrary,
|
||||
lookup_book_from_openlibrary,
|
||||
)
|
||||
from books.sources.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
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.utils import get_comic_issue_url
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.base import ContentFile
|
||||
@ -18,27 +34,15 @@ 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, LongPlayLogData
|
||||
from scrobbles.mixins import (
|
||||
LongPlayScrobblableMixin,
|
||||
ObjectWithGenres,
|
||||
ScrobblableConstants,
|
||||
)
|
||||
from scrobbles.utils import get_scrobbles_for_media
|
||||
from scrobbles.utils import get_scrobbles_for_media, next_url_if_exists
|
||||
from taggit.managers import TaggableManager
|
||||
from thefuzz import fuzz
|
||||
from vrobbler.apps.books.sources.comicvine import (
|
||||
ComicVineClient,
|
||||
lookup_comic_from_comicvine,
|
||||
)
|
||||
|
||||
from vrobbler.apps.books.locg import (
|
||||
lookup_comic_by_locg_slug,
|
||||
lookup_comic_from_locg,
|
||||
lookup_comic_writer_by_locg_slug,
|
||||
)
|
||||
from books.sources.google import lookup_book_from_google
|
||||
from books.sources.semantic import lookup_paper_from_semantic
|
||||
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
|
||||
|
||||
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
|
||||
|
||||
@ -62,6 +66,7 @@ class BookLogData(BaseLogData, LongPlayLogData):
|
||||
pages_read: Optional[int] = None
|
||||
page_start: Optional[int] = None
|
||||
page_end: Optional[int] = None
|
||||
resume_url: Optional[str] = None
|
||||
|
||||
_excluded_fields = {"koreader_hash", "page_data"}
|
||||
|
||||
@ -103,11 +108,9 @@ class Author(TimeStampedModel):
|
||||
def __str__(self):
|
||||
return f"{self.name}"
|
||||
|
||||
def enrich_from_semantic(self, overwrite=False):
|
||||
...
|
||||
def enrich_from_semantic(self, overwrite=False): ...
|
||||
|
||||
def enrich_from_google_books(self, overwrite=False):
|
||||
...
|
||||
def enrich_from_google_books(self, overwrite=False): ...
|
||||
|
||||
def enrich_from_openlibrary(self, overwrite=False):
|
||||
data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
|
||||
@ -130,9 +133,7 @@ class Author(TimeStampedModel):
|
||||
|
||||
class Book(LongPlayScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "BOOK_COMPLETION_PERCENT", 95)
|
||||
AVG_PAGE_READING_SECONDS = getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
AVG_PAGE_READING_SECONDS = getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
original_title = models.CharField(max_length=255, **BNULL)
|
||||
@ -148,8 +149,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
first_sentence = models.TextField(**BNULL)
|
||||
# ComicVine
|
||||
comicvine_id = models.CharField(max_length=255, **BNULL)
|
||||
issue_number = models.IntegerField(max_length=5, **BNULL)
|
||||
volume_number = models.IntegerField(max_length=5, **BNULL)
|
||||
readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
next_readcomics_url = models.CharField(max_length=255, **BNULL)
|
||||
issue_number = models.IntegerField(**BNULL)
|
||||
volume_number = models.IntegerField(**BNULL)
|
||||
# OpenLibrary
|
||||
openlibrary_id = models.CharField(max_length=255, **BNULL)
|
||||
cover = models.ImageField(upload_to="books/covers/", **BNULL)
|
||||
@ -167,11 +170,22 @@ class Book(LongPlayScrobblableMixin):
|
||||
)
|
||||
summary = models.TextField(**BNULL)
|
||||
|
||||
genre = TaggableManager(through=ObjectWithGenres)
|
||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||
|
||||
def __str__(self):
|
||||
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}"
|
||||
|
||||
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}"
|
||||
@ -194,12 +208,17 @@ 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={"uuid": self.uuid}) + "?resume=1"
|
||||
|
||||
@classmethod
|
||||
def get_from_comicvine(cls, title: str, overwrite: bool = False, force_new: bool =False) -> "Book":
|
||||
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 and not overwrite and not force_new:
|
||||
book, created = cls.objects.get_or_create(original_title=title)
|
||||
logger.info("Found comic by original title, use force_new=True to override")
|
||||
|
||||
if not created:
|
||||
return book
|
||||
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
@ -233,7 +252,12 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, title: str, enrich: bool = False, commit: bool = True
|
||||
cls,
|
||||
title: str,
|
||||
author: str | None = None,
|
||||
url: str = "",
|
||||
enrich: bool = True,
|
||||
commit: bool = True,
|
||||
):
|
||||
"""Given a title, get a Book instance.
|
||||
|
||||
@ -244,11 +268,9 @@ class Book(LongPlayScrobblableMixin):
|
||||
like to batch create, use commit=False and you'll get an unsaved but enriched
|
||||
instance back which you can then save at your convenience."""
|
||||
# TODO use either a Google Books id identifier or author name like for tracks
|
||||
book, created = cls.objects.get_or_create(title=title)
|
||||
book, created = cls.objects.get_or_create(original_title=title)
|
||||
if not created:
|
||||
logger.info(
|
||||
"Found exact match for book by title", extra={"title": title}
|
||||
)
|
||||
logger.info("Found exact match for book by title", extra={"title": title})
|
||||
|
||||
if not enrich:
|
||||
logger.info(
|
||||
@ -257,24 +279,42 @@ class Book(LongPlayScrobblableMixin):
|
||||
)
|
||||
return book
|
||||
|
||||
book_dict = lookup_book_from_google(title)
|
||||
if not book_dict or book_dict.get("isbn_10"):
|
||||
book_dict = None
|
||||
source_tag = None
|
||||
if READCOMICSONLINE_URL in url:
|
||||
book_dict = lookup_comic_from_comicvine(title)
|
||||
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"]
|
||||
)
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_ol(title, author=author)
|
||||
if book_dict:
|
||||
source_tag = MediaSourceTag.OPENLIBRARY
|
||||
|
||||
if not book_dict:
|
||||
book_dict = lookup_book_from_google(title)
|
||||
if book_dict:
|
||||
source_tag = MediaSourceTag.GOOGLE_BOOKS
|
||||
|
||||
if not book_dict:
|
||||
logger.warning(
|
||||
"No book found in any source, using data as is",
|
||||
extra={"title": title},
|
||||
)
|
||||
|
||||
author_list = []
|
||||
authors = book_dict.pop("authors")
|
||||
cover_url = book_dict.pop("cover_url")
|
||||
try:
|
||||
genres = book_dict.pop("generes")
|
||||
except:
|
||||
genres = []
|
||||
authors = book_dict.pop("authors", [])
|
||||
cover_url = book_dict.pop("cover_url", "")
|
||||
genres = book_dict.pop("genres", book_dict.pop("generes", []))
|
||||
|
||||
if authors:
|
||||
for author_str in authors:
|
||||
if author_str:
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
name=author_str
|
||||
)
|
||||
author, a_created = Author.objects.get_or_create(name=author_str)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
# TODO enrich author
|
||||
@ -287,13 +327,16 @@ class Book(LongPlayScrobblableMixin):
|
||||
book.save()
|
||||
|
||||
book.save_image_from_url(cover_url)
|
||||
book.genre.add(*genres)
|
||||
if genres:
|
||||
book.genre.add(*genres)
|
||||
book.authors.add(*author_list)
|
||||
if source_tag:
|
||||
book.tags.add(source_tag.value)
|
||||
|
||||
return book
|
||||
|
||||
def save_image_from_url(self, url: str, force_update: bool = False):
|
||||
if not self.cover or (force_update and url):
|
||||
if url and (not self.cover or force_update):
|
||||
r = requests.get(url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
@ -308,13 +351,9 @@ class Book(LongPlayScrobblableMixin):
|
||||
if not data:
|
||||
logger.warn(f"Checking openlibrary for {self.title}")
|
||||
if self.openlibrary_id and force_update:
|
||||
data = lookup_book_from_openlibrary(
|
||||
str(self.openlibrary_id)
|
||||
)
|
||||
data = lookup_book_from_ol(str(self.openlibrary_id))
|
||||
else:
|
||||
data = lookup_book_from_openlibrary(
|
||||
str(self.title), author_name
|
||||
)
|
||||
data = lookup_book_from_ol(str(self.title), author_name)
|
||||
|
||||
if not data:
|
||||
if self.locg_slug:
|
||||
@ -361,10 +400,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
if "pages" in data.keys() and data.get("pages") == None:
|
||||
data.pop("pages")
|
||||
|
||||
if (
|
||||
not isinstance(data.get("pages"), int)
|
||||
and "pages" in data.keys()
|
||||
):
|
||||
if not isinstance(data.get("pages"), int) and "pages" in data.keys():
|
||||
logger.info(
|
||||
f"Pages for {self} from OL expected to be int, but got {data.get('pages')}"
|
||||
)
|
||||
@ -373,7 +409,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
# Pop this, so we can look it up later
|
||||
cover_url = data.pop("cover_url", "")
|
||||
|
||||
subject_key_list = data.pop("subject_key_list", "")
|
||||
subject_key_list = data.pop("subject_key_list", [])
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
Book.objects.filter(pk=self.id).update(**data)
|
||||
@ -388,17 +424,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if self.pages:
|
||||
self.run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def fix_authors_metadata(self, openlibrary_author_id):
|
||||
author = Author.objects.filter(
|
||||
openlibrary_id=openlibrary_author_id
|
||||
).first()
|
||||
author = Author.objects.filter(openlibrary_id=openlibrary_author_id).first()
|
||||
if not author:
|
||||
data = lookup_author_from_openlibrary(openlibrary_author_id)
|
||||
author_image_url = data.pop("author_headshot_url", None)
|
||||
@ -409,9 +438,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
r = requests.get(author_image_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{author.name}_{author.uuid}.jpg"
|
||||
author.headshot.save(
|
||||
fname, ContentFile(r.content), save=True
|
||||
)
|
||||
author.headshot.save(fname, ContentFile(r.content), save=True)
|
||||
self.authors.add(author)
|
||||
|
||||
def get_author_from_locg(self, locg_slug):
|
||||
@ -427,9 +454,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
author.headshot.save(fname, ContentFile(r.content), save=True)
|
||||
self.authors.add(author)
|
||||
|
||||
def page_data_for_user(
|
||||
self, user_id: int, convert_timestamps: bool = True
|
||||
) -> dict:
|
||||
def page_data_for_user(self, user_id: int, convert_timestamps: bool = True) -> dict:
|
||||
scrobbles = self.scrobble_set.filter(user=user_id)
|
||||
|
||||
pages = {}
|
||||
@ -437,9 +462,7 @@ 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["start_ts"] = datetime.fromtimestamp(data["start_ts"])
|
||||
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
|
||||
pages[page] = data
|
||||
sorted_pages = OrderedDict(
|
||||
@ -478,9 +501,7 @@ class Paper(LongPlayScrobblableMixin):
|
||||
"""Keeps track of Academic Papers"""
|
||||
|
||||
COMPLETION_PERCENT = getattr(settings, "PAPER_COMPLETION_PERCENT", 60)
|
||||
AVG_PAGE_READING_SECONDS = getattr(
|
||||
settings, "AVERAGE_PAGE_READING_SECONDS", 60
|
||||
)
|
||||
AVG_PAGE_READING_SECONDS = getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
semantic_title = models.CharField(max_length=255, **BNULL)
|
||||
@ -501,7 +522,7 @@ class Paper(LongPlayScrobblableMixin):
|
||||
tldr = models.CharField(max_length=255, **BNULL)
|
||||
openaccess_pdf_url = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
genre = TaggableManager(through=ObjectWithGenres)
|
||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||
|
||||
@classmethod
|
||||
def get_from_semantic(cls, title: str, overwrite: bool = False) -> "Paper":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user