Compare commits
603 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f189b4d66 | |||
| 1487504318 | |||
| 0655363a0d | |||
| dccc80c615 | |||
| 4f91d5b40b | |||
| cb01781615 | |||
| 1f5fada8b1 | |||
| 31888a85cb | |||
| 22d8b0787e | |||
| 8cc559752b | |||
| db3f9696fa | |||
| 407d570c82 | |||
| 033239260f | |||
| 9f854dc735 | |||
| f29272a853 | |||
| 4e56d9420a | |||
| 852a257159 | |||
| 68ff230f13 | |||
| 57a952a6d1 | |||
| 718fcf7392 | |||
| 52adcf83c7 | |||
| 0061623f7e | |||
| ec73e5151e | |||
| 2c90dd38b5 | |||
| c6b1e42d7a | |||
| fcf86d5b3f | |||
| 6fde9ec8d2 | |||
| 0f1882b21f | |||
| e819a2db0d | |||
| e03cf6c9b1 | |||
| 471e70ff7f | |||
| 255e335d7a | |||
| c8cf80b513 | |||
| b4180afbed | |||
| 37112babbb | |||
| fb775f2f58 | |||
| b26470c279 | |||
| d3b9ec815b | |||
| 19f2b5e801 | |||
| 9e3288a5ff | |||
| 06465919dd | |||
| 253e58eb48 | |||
| 5393996e47 | |||
| 1624f01e11 | |||
| 535dead7e8 | |||
| 3b97d49227 | |||
| ea7b0946bb | |||
| b8384166de | |||
| d2705758c6 | |||
| f4368c31f3 | |||
| 57f273b0cc | |||
| ac82292200 | |||
| 6a8432c08f | |||
| 5a2c41155c | |||
| 83a046111b | |||
| ab10758f40 | |||
| 88f16f0aaa | |||
| c1744fab37 | |||
| 042a3eb737 | |||
| 01d25e1b55 | |||
| c0be131e3d | |||
| 7d3f615ed7 | |||
| c2138b3ac6 | |||
| 947713d44a | |||
| 12b76837a3 | |||
| 102494ede7 | |||
| 96bda8d4ad | |||
| 46956d06d8 | |||
| 8a28d0675b | |||
| 5f6e75b14e | |||
| a96a42cdbf | |||
| c7f5d7d384 | |||
| d5830f5cd1 | |||
| c71b51fdb8 | |||
| 935d059a20 | |||
| 25776eb495 | |||
| 5ac4625af9 | |||
| a731427f6e | |||
| 410da163fe | |||
| a171192a6f | |||
| c16b61db40 | |||
| 29cb6a4991 | |||
| 25c28e8335 | |||
| 25626be3b6 | |||
| 0a880a2f2f | |||
| 248d3f2d3e | |||
| e243fec679 | |||
| de9b4ee9c1 | |||
| bf9a6a9679 | |||
| 709fed5cfe | |||
| b7df6299d0 | |||
| be16d513ef | |||
| 15d27f6d94 | |||
| c8292d1c06 | |||
| 68f821fce1 | |||
| ed2ed59f65 | |||
| 17a7bb52fa | |||
| bbac142b40 | |||
| 5f55ec557f | |||
| 7f3076608f | |||
| 568772a0e6 | |||
| 91c3376256 | |||
| 58639c6fc1 | |||
| 228441ddc5 | |||
| 6341075f07 | |||
| a135b9f5f2 | |||
| 9088412d1e | |||
| c7339fbe31 | |||
| 4ce3dc03c5 | |||
| 5a4ef678a8 | |||
| 5ca22efeaa | |||
| 912ea8bfac | |||
| b541e1084d | |||
| c9b9da4abc | |||
| 8236f43026 | |||
| ea1b43d1b8 | |||
| 4bf22c96e9 | |||
| dec7a79509 | |||
| 371e1d654c | |||
| bef7e683c5 | |||
| ec219ef3ea | |||
| dcc7229e90 | |||
| 73665ef19e | |||
| 2536e330af | |||
| 99c056adeb | |||
| 7a504e45de | |||
| 7618d0ba30 | |||
| ce4dc40033 | |||
| b0b22b79dc | |||
| 6471413681 | |||
| 50b10689fc | |||
| 85bddb6cba | |||
| c285b0d3b3 | |||
| 671fe8d86f | |||
| 89817110de | |||
| ee01e3d8df | |||
| a70343d6f3 | |||
| 3e72042c24 | |||
| 087c7775ae | |||
| 3f71065ad6 | |||
| 801672124f | |||
| 811e9c1ce9 | |||
| 415b32bdc7 | |||
| 22319c807a | |||
| f9ba6fec14 | |||
| 5f55163147 | |||
| a6ef34623e | |||
| 7cb48d20f6 | |||
| 445103a878 | |||
| 579da8c44e | |||
| daabd2f37f | |||
| 039c58cf89 | |||
| 410c033f12 | |||
| ce302e4d45 | |||
| 19589c9463 | |||
| 3d9506b14e | |||
| 23b87278b2 | |||
| 0b8e027c30 | |||
| 1bd9f0d942 | |||
| fa7890cb21 | |||
| 957c32e3a7 | |||
| 8d069df9d1 | |||
| 96d1d7ac6b | |||
| 009b2ba243 | |||
| 4f051ae250 | |||
| 7e9fbb1bf6 | |||
| ec1a54f623 | |||
| b502667ca6 | |||
| 263874288a | |||
| bca90c97ae | |||
| 12f49a6cee | |||
| 034c7cb413 | |||
| e737870733 | |||
| 95757650f6 | |||
| 1928acf8a6 | |||
| 22956c7c7f | |||
| 17aed1191d | |||
| 0b1fb8667c | |||
| 13dd5b67d0 | |||
| 20c7874466 | |||
| 62d8ffd794 | |||
| bea2b2d187 | |||
| 034cb99c77 | |||
| 37187f33dd | |||
| d7a23c3832 | |||
| 6d45571e75 | |||
| 88fd0ed7f8 | |||
| 2100cedc1a | |||
| 2b17a92c6c | |||
| 72fd1ab90e | |||
| 301440909b | |||
| 389641002d | |||
| 43d514cf5b | |||
| 25baeca2b0 | |||
| b4e15c73c1 | |||
| 90f3d38687 | |||
| 8afb227267 | |||
| 425abebc9a | |||
| afb61f6622 | |||
| 65815fc198 | |||
| 75479d91a3 | |||
| 99789e5477 | |||
| fd95f1e686 | |||
| e133c4149b | |||
| 7e75828012 | |||
| 664148e702 | |||
| 768819b664 | |||
| 760d453165 | |||
| 2fac5815b1 | |||
| f4b30ade70 | |||
| 1cbb84c29f | |||
| b622b151d4 | |||
| 4e1c3ffbf0 | |||
| 8a419c7bbc | |||
| 0639033aa9 | |||
| 6927729284 | |||
| 766f9db17c | |||
| 25cbd88071 | |||
| 972568bebc | |||
| 939c89d368 | |||
| 83e7061b92 | |||
| a171517f92 | |||
| a4030e89ec | |||
| dce31ed840 | |||
| 645e81299b | |||
| 72fa41977e | |||
| 56745b33f4 | |||
| dd2f44e72f | |||
| 41890d14d9 | |||
| c5f1ee2d64 | |||
| 26176ccd73 | |||
| 9b7fa0d4f8 | |||
| aeb460d677 | |||
| 4e059683b0 | |||
| d3146433f2 | |||
| 7b487f8494 | |||
| f7675b8a02 | |||
| d036dbe4fd | |||
| 86c495f22f | |||
| b9324d6443 | |||
| c11858810c | |||
| 9bafe45951 | |||
| 4d8e925f8c | |||
| e7fff25543 | |||
| 62c200ab05 | |||
| 7c33095d87 | |||
| cb50de13c0 | |||
| 08152de086 | |||
| 0d6b2c4afc | |||
| 9d3f7f434f | |||
| 2b88f89794 | |||
| 3014a30616 | |||
| c7850878fe | |||
| db5b673cd5 | |||
| 217e2443e2 | |||
| af8b1d4f8a | |||
| d69bc6c235 | |||
| cc0f7db453 | |||
| 11bde2a306 | |||
| cd4f6ff71d | |||
| bf7e5677e6 | |||
| 2b04a17d77 | |||
| 97f35f62b8 | |||
| dfd51c1343 | |||
| e47328e572 | |||
| e85d6ec779 | |||
| 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 | |||
| d4f82f2d6f | |||
| 106d25c20f | |||
| d77caa2783 | |||
| b5bfad73ef | |||
| 274b2704ed | |||
| 80fcb6c002 | |||
| c6f3c90006 | |||
| 387dee7d37 | |||
| 188e899357 | |||
| 30b005fa46 | |||
| 72f739ee5a | |||
| 56ee14512d | |||
| 8c947d35dd | |||
| 61bab1f734 | |||
| 42ce6df9bd | |||
| cbd46df4bc | |||
| e7203cdb9b | |||
| 7246adfeb6 | |||
| a5606951c5 | |||
| 0b4537b7ed | |||
| 6306390f82 | |||
| 350d3ceb14 | |||
| a1ff82bfec | |||
| 92c0c668b3 | |||
| 3b77feda45 | |||
| 45c402f8c1 | |||
| 90a1398438 | |||
| c7a81802ac | |||
| a9a8678ac0 | |||
| cbf0583871 | |||
| 5cac1fe109 | |||
| 6782ed312d | |||
| fda505ea4e | |||
| 8db111f66f | |||
| ee1cae496a | |||
| 9403c68184 | |||
| 96030f4a99 | |||
| a8c3925af4 | |||
| a2f507a976 | |||
| 7a7edc6e47 | |||
| af6c39fb85 | |||
| 36cfdd6f6c | |||
| b11d87af75 | |||
| 1cf50209a4 | |||
| 8a5486fb2c | |||
| 135d6e65fa | |||
| 965f2dd41b | |||
| 1a1de02843 | |||
| a1868e7b2c | |||
| 52494651bf | |||
| 1093aa2376 | |||
| d1f04c15a9 | |||
| fd3487c225 | |||
| df91526b0c | |||
| 70f103db6f | |||
| b0b32821e3 | |||
| 278cab32ea | |||
| 06e075553a | |||
| 833368c8d7 | |||
| f70bab30d0 | |||
| f230af89eb | |||
| bbc27209ab | |||
| b7638c648a |
20
.drone.yml
20
.drone.yml
@ -34,7 +34,7 @@ steps:
|
|||||||
command_timeout: 2m
|
command_timeout: 2m
|
||||||
script:
|
script:
|
||||||
- pip uninstall -y vrobbler
|
- 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 migrate
|
||||||
- vrobbler collectstatic --noinput
|
- vrobbler collectstatic --noinput
|
||||||
- immortalctl restart celery && immortalctl restart vrobbler
|
- immortalctl restart celery && immortalctl restart vrobbler
|
||||||
@ -50,8 +50,15 @@ steps:
|
|||||||
topic: drone
|
topic: drone
|
||||||
priority: low
|
priority: low
|
||||||
tags:
|
tags:
|
||||||
- failure
|
- success
|
||||||
- vrobbler
|
- vrobbler
|
||||||
|
actions:
|
||||||
|
- action: view
|
||||||
|
label: Changes
|
||||||
|
url: "{{ .Commit.Link }}"
|
||||||
|
- action: view
|
||||||
|
label: Build
|
||||||
|
url: "{{ .Build.Link }}"
|
||||||
- name: build failure notification
|
- name: build failure notification
|
||||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||||
when:
|
when:
|
||||||
@ -61,8 +68,15 @@ steps:
|
|||||||
topic: drone
|
topic: drone
|
||||||
priority: high
|
priority: high
|
||||||
tags:
|
tags:
|
||||||
- success
|
- failure
|
||||||
- vrobbler
|
- vrobbler
|
||||||
|
actions:
|
||||||
|
- action: view
|
||||||
|
label: Changes
|
||||||
|
url: "{{ .Commit.Link }}"
|
||||||
|
- action: view
|
||||||
|
label: Build
|
||||||
|
url: "{{ .Build.Link }}"
|
||||||
volumes:
|
volumes:
|
||||||
- name: docker
|
- name: docker
|
||||||
host:
|
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/
|
media/
|
||||||
dist/
|
dist/
|
||||||
.coverage
|
.coverage
|
||||||
|
tmp/*
|
||||||
|
vrobbler/static/*
|
||||||
|
|||||||
26
AGENTS.md
Normal file
26
AGENTS.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
Imports in python files should always be top level if possible.
|
||||||
|
|
||||||
|
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
|
||||||
|
|
||||||
|
In local development, environment variables for various sensitive values live in a .envrc file
|
||||||
|
|
||||||
|
The .envrc file can be loaded into a shell environment to allow access to most third party services
|
||||||
|
|
||||||
|
Care should be taken when using .envrc that we do not spam services we use in production with requests
|
||||||
2
Makefile
2
Makefile
@ -1,5 +1,5 @@
|
|||||||
deploy:
|
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:
|
logs:
|
||||||
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
|
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
|
||||||
test:
|
test:
|
||||||
|
|||||||
2249
PROJECT.org
2249
PROJECT.org
File diff suppressed because it is too large
Load Diff
2
Procfile
2
Procfile
@ -1,2 +1,2 @@
|
|||||||
web: python manage.py runserver 0.0.0.0:8014
|
web: python manage.py runserver 0.0.0.0:8014
|
||||||
worker: celery -A vrobbler worker -l DEBUG
|
worker: celery -A vrobbler worker -Q default,charts -l DEBUG
|
||||||
|
|||||||
20
README.md
20
README.md
@ -21,3 +21,23 @@ VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
|||||||
VROBBLER_DATABASE_URL="postgres://vrobbler:<pass>@db.service:5432/vrobbler"
|
VROBBLER_DATABASE_URL="postgres://vrobbler:<pass>@db.service:5432/vrobbler"
|
||||||
VROBBLER_REDIS_URL="redis://:<pass>@cache.service:6379/0"
|
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
96
data/play-example.json
Normal file
96
data/play-example.json
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
{
|
||||||
|
"about": "This is a Play file that can be read by Board Game Stats. If you see this text, try to use a share, export or open-in function to open it with Board Game Stats.",
|
||||||
|
"players": [
|
||||||
|
{
|
||||||
|
"uuid": "31f8b92e-11d8-4162-88b1-fd9c79eea249",
|
||||||
|
"id": 2,
|
||||||
|
"name": "Colin Powell",
|
||||||
|
"isAnonymous": false,
|
||||||
|
"modificationDate": "2025-10-18 08:32:40",
|
||||||
|
"metaData": "{\"isNpc\":0,\"playerAvatar\":{\"image\":\"AnnikaHeller_Capybara.webp\",\"shape\":[1,1,1,1],\"color\":[0.6,0.20000000298023224,0.9803921580314636]}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"uuid": "dd2d1881-84ab-474c-a6b2-3045d034dc40",
|
||||||
|
"id": 3,
|
||||||
|
"name": "Silas Sewell",
|
||||||
|
"isAnonymous": false,
|
||||||
|
"modificationDate": "2026-01-18 12:27:12",
|
||||||
|
"metaData": "{\"isNpc\":0,\"playerAvatar\":{\"image\":\"RoryMuldoon_07.webp\",\"shape\":[1,1,1,1],\"color\":[0,0,0.1835034190722739]}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locations": [
|
||||||
|
{
|
||||||
|
"uuid": "14f7389c-767f-4725-9b35-906c407b293c",
|
||||||
|
"id": 3,
|
||||||
|
"name": "Timberwyck Farm",
|
||||||
|
"modificationDate": "2025-07-01 18:03:38"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"games": [
|
||||||
|
{
|
||||||
|
"uuid": "9e431cdd-b325-4061-a875-d415d46342c0",
|
||||||
|
"id": 1046,
|
||||||
|
"name": "Sweet Takes",
|
||||||
|
"modificationDate": "2026-04-11 16:25:35",
|
||||||
|
"cooperative": false,
|
||||||
|
"highestWins": true,
|
||||||
|
"noPoints": false,
|
||||||
|
"usesTeams": false,
|
||||||
|
"urlThumb": "https://cf.geekdo-images.com/l4HILZn5iLbceQeDph4G5A__small/img/gmGqlmwe9fniqYpniGwhg5RUgVQ=/fit-in/200x150/filters:strip_icc()/pic8784202.jpg",
|
||||||
|
"urlImage": "https://cf.geekdo-images.com/l4HILZn5iLbceQeDph4G5A__original/img/b4IU8WIEWRpacbXp0FHG9HfFRpw=/0x0/filters:format(jpeg)/pic8784202.jpg",
|
||||||
|
"bggName": "Sweet Takes",
|
||||||
|
"bggYear": 2023,
|
||||||
|
"bggId": 407581,
|
||||||
|
"designers": "Hisashi Hayashi",
|
||||||
|
"isBaseGame": 1,
|
||||||
|
"isExpansion": 0,
|
||||||
|
"rating": 67,
|
||||||
|
"minPlayerCount": 2,
|
||||||
|
"maxPlayerCount": 5,
|
||||||
|
"minPlayTime": 15,
|
||||||
|
"maxPlayTime": 15,
|
||||||
|
"minAge": 8
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plays": [
|
||||||
|
{
|
||||||
|
"uuid": "7b2fd583-e8f2-40fe-9565-90178390b87e",
|
||||||
|
"modificationDate": "2026-04-16 20:18:03",
|
||||||
|
"entryDate": "2026-04-16 20:13:33",
|
||||||
|
"playDate": "2026-04-16 20:13:33",
|
||||||
|
"usesTeams": false,
|
||||||
|
"durationMin": 4,
|
||||||
|
"ignored": false,
|
||||||
|
"manualWinner": false,
|
||||||
|
"rounds": 0,
|
||||||
|
"locationRefId": 3,
|
||||||
|
"gameRefId": 1046,
|
||||||
|
"board": "",
|
||||||
|
"scoringSetting": 1,
|
||||||
|
"metaData": "{\"playerRefId\":2,\"playGameBggVersion\":\"{\\\"versionId\\\":0,\\\"versionName\\\":\\\"\\\",\\\"imageUrl\\\":\\\"https:\\\\\\/\\\\\\/cf.geekdo-images.com\\\\\\/l4HILZn5iLbceQeDph4G5A__small\\\\\\/img\\\\\\/gmGqlmwe9fniqYpniGwhg5RUgVQ=\\\\\\/fit-in\\\\\\/200x150\\\\\\/filters:strip_icc()\\\\\\/pic8784202.jpg\\\",\\\"thumbUrl\\\":\\\"https:\\\\\\/\\\\\\/cf.geekdo-images.com\\\\\\/l4HILZn5iLbceQeDph4G5A__small\\\\\\/img\\\\\\/gmGqlmwe9fniqYpniGwhg5RUgVQ=\\\\\\/fit-in\\\\\\/200x150\\\\\\/filters:strip_icc()\\\\\\/pic8784202.jpg\\\",\\\"yearPublished\\\":0}\",\"playUsedGameCopy\":2}",
|
||||||
|
"playerScores": [
|
||||||
|
{
|
||||||
|
"score": "27",
|
||||||
|
"winner": false,
|
||||||
|
"newPlayer": false,
|
||||||
|
"startPlayer": false,
|
||||||
|
"playerRefId": 2,
|
||||||
|
"role": "",
|
||||||
|
"rank": 0,
|
||||||
|
"seatOrder": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"score": "36",
|
||||||
|
"winner": true,
|
||||||
|
"newPlayer": true,
|
||||||
|
"startPlayer": false,
|
||||||
|
"playerRefId": 3,
|
||||||
|
"rank": 0,
|
||||||
|
"seatOrder": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expansionPlays": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"userInfo": { "meRefId": 2 }
|
||||||
|
}
|
||||||
BIN
data/sample-trail.fit
Normal file
BIN
data/sample-trail.fit
Normal file
Binary file not shown.
3360
data/sample_trail.gpx
Normal file
3360
data/sample_trail.gpx
Normal file
File diff suppressed because one or more lines are too long
2
data/scale-example.csv
Normal file
2
data/scale-example.csv
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
DATE,TIME,BICEPS,BMI,BMR,BODY_FAT,BONE,CALIPER,CALIPER_1,CALIPER_2,CALIPER_3,CALORIES,CHEST,COMMENT,HEART_RATE,HIPS,LBM,MUSCLE,NECK,TDEE,THIGH,VISCERAL_FAT,WAIST,WATER,WEIGHT,WHR,WHTR
|
||||||
|
2026-05-20,11:56:58.076,,31.09,1706.74,29.084072,3.438837,,,,,,,,,,,33.07067,,2645.46,,,,54.445187,192.68378,,
|
||||||
|
BIN
data/statistics.sqlite3
Normal file
BIN
data/statistics.sqlite3
Normal file
Binary file not shown.
25
justfile
Normal file
25
justfile
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
push:
|
||||||
|
git push && git push gitea
|
||||||
|
git push --tags && git push --tags gitea
|
||||||
|
|
||||||
|
release kind="minor":
|
||||||
|
poetry run python scripts/release.py {{kind}}
|
||||||
|
just push
|
||||||
|
|
||||||
5766
poetry.lock
generated
5766
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,11 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "0.16.1"
|
version = "56.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = ">=3.9,<3.12"
|
python = ">=3.11,<3.15"
|
||||||
Django = "^4.0.3"
|
Django = "^4.0.3"
|
||||||
django-extensions = "^3.1.5"
|
django-extensions = "^3.1.5"
|
||||||
python-dateutil = "^2.8.2"
|
python-dateutil = "^2.8.2"
|
||||||
@ -16,8 +16,8 @@ httpx = "<=0.27.2"
|
|||||||
djangorestframework = "^3.13.1"
|
djangorestframework = "^3.13.1"
|
||||||
Markdown = "^3.3.6"
|
Markdown = "^3.3.6"
|
||||||
django-filter = "^21.1"
|
django-filter = "^21.1"
|
||||||
Pillow = "^9.0.1"
|
Pillow = "^10.0.0"
|
||||||
psycopg2 = "^2.9.3"
|
psycopg2 = "2.9.10"
|
||||||
dj-database-url = "^0.5.0"
|
dj-database-url = "^0.5.0"
|
||||||
django-mathfilters = "^1.0.0"
|
django-mathfilters = "^1.0.0"
|
||||||
django-allauth = "^0.50.0"
|
django-allauth = "^0.50.0"
|
||||||
@ -28,7 +28,6 @@ django-markdownify = "^0.9.1"
|
|||||||
gunicorn = "^20.1.0"
|
gunicorn = "^20.1.0"
|
||||||
django-simple-history = "^3.1.1"
|
django-simple-history = "^3.1.1"
|
||||||
musicbrainzngs = "^0.7.1"
|
musicbrainzngs = "^0.7.1"
|
||||||
cinemagoer = "^2022.12.27"
|
|
||||||
pysportsdb = "^0.1.0"
|
pysportsdb = "^0.1.0"
|
||||||
pytz = "^2022.7.1"
|
pytz = "^2022.7.1"
|
||||||
django-redis = "^5.2.0"
|
django-redis = "^5.2.0"
|
||||||
@ -41,11 +40,11 @@ beautifulsoup4 = "^4.11.2"
|
|||||||
django-storages = "^1.13.2"
|
django-storages = "^1.13.2"
|
||||||
stream-sqlite = "^0.0.41"
|
stream-sqlite = "^0.0.41"
|
||||||
ipython = "^8.14.0"
|
ipython = "^8.14.0"
|
||||||
pendulum = "^2.1.2"
|
pendulum = "^3"
|
||||||
trafilatura = "^1.6.3"
|
trafilatura = "^1.6.3"
|
||||||
django-imagekit = "^5.0.0"
|
django-imagekit = "^5.0.0"
|
||||||
thefuzz = "^0.22.1"
|
thefuzz = "^0.22.1"
|
||||||
dataclass-wizard = "0.22.0"
|
dataclass-wizard = "^0.35.0"
|
||||||
webdavclient3 = "^3.14.6"
|
webdavclient3 = "^3.14.6"
|
||||||
boto3 = "^1.35.37"
|
boto3 = "^1.35.37"
|
||||||
urllib3 = "<2"
|
urllib3 = "<2"
|
||||||
@ -56,6 +55,16 @@ poetry-bumpversion = "^0.3.3"
|
|||||||
orgparse = "^0.4.20250520"
|
orgparse = "^0.4.20250520"
|
||||||
tmdbv3api = "^1.9.0"
|
tmdbv3api = "^1.9.0"
|
||||||
themoviedb = "^1.0.2"
|
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"
|
||||||
|
vaderSentiment = "^3.3.2"
|
||||||
|
sqids = "^0.5.2"
|
||||||
|
python-amazon-paapi = "^6.3.0"
|
||||||
|
|
||||||
[tool.poetry.group.test]
|
[tool.poetry.group.test]
|
||||||
optional = true
|
optional = true
|
||||||
@ -79,13 +88,12 @@ types-requests = "^2.27"
|
|||||||
bandit = "^1.7.4"
|
bandit = "^1.7.4"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
minversion = "6.0"
|
addopts = "-ra -q --reuse-db --no-migrations"
|
||||||
addopts = "-ra -q --reuse-db"
|
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
|
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 79
|
line-length = 88
|
||||||
target-version = ["py39", "py310"]
|
target-version = ["py39", "py310"]
|
||||||
include = ".py$"
|
include = ".py$"
|
||||||
exclude = "migrations"
|
exclude = "migrations"
|
||||||
@ -102,6 +110,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
|
|||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
vrobbler = "vrobbler.cli:main"
|
vrobbler = "vrobbler.cli:main"
|
||||||
|
|
||||||
|
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
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(1)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 4. Build the new Version section text
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
version_section_lines = [f"* Version {new_version} [{len(done_items)}/{len(done_items)}]\n"]
|
||||||
|
for s, e in done_items:
|
||||||
|
version_section_lines.extend(backlog_lines[s:e])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 5. Build updated Backlog section
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
backlog_header_line = backlog_lines[0]
|
||||||
|
bm = BACKLOG_RE.match(backlog_header_line.strip())
|
||||||
|
if not bm:
|
||||||
|
print(f"ERROR: could not parse backlog header: {backlog_header_line!r}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
done_count = int(bm.group(1))
|
||||||
|
total_count = int(bm.group(2))
|
||||||
|
tags = bm.group(3)
|
||||||
|
|
||||||
|
new_done = done_count - len(done_items)
|
||||||
|
new_total = total_count - len(done_items)
|
||||||
|
new_backlog_header = f"* Backlog [{new_done}/{new_total}]{tags}\n"
|
||||||
|
|
||||||
|
backlog_body = []
|
||||||
|
for s, e in kept_items:
|
||||||
|
backlog_body.extend(backlog_lines[s:e])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 6. Assemble the new file
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
before_backlog = lines[:backlog_start]
|
||||||
|
after_backlog = lines[backlog_end:version_start]
|
||||||
|
|
||||||
|
# Everything from the first Version section onwards
|
||||||
|
from_version = lines[version_start:]
|
||||||
|
|
||||||
|
output = (
|
||||||
|
before_backlog
|
||||||
|
+ [new_backlog_header]
|
||||||
|
+ backlog_body
|
||||||
|
+ version_section_lines
|
||||||
|
+ ["\n"]
|
||||||
|
+ after_backlog
|
||||||
|
+ from_version
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 7. Update pyproject.toml
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
pyproject = PYPROJECT_FILE.read_text()
|
||||||
|
pyproject = re.sub(
|
||||||
|
r'^version = "[\d.]+"',
|
||||||
|
f'version = "{new_version}"',
|
||||||
|
pyproject,
|
||||||
|
count=1,
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 8. Write files
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
PROJECT_FILE.write_text("".join(output))
|
||||||
|
PYPROJECT_FILE.write_text(pyproject)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 9. Build commit body from done item titles
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
commit_lines = []
|
||||||
|
for s, e in done_items:
|
||||||
|
title = parse_done_line(backlog_lines[s])
|
||||||
|
if title:
|
||||||
|
commit_lines.append(f"- {title}")
|
||||||
|
|
||||||
|
commit_body = "\n".join(commit_lines)
|
||||||
|
commit_message = f"[release] Bump to version {new_version}\n\n{commit_body}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# 10. Git commit + tag
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
subprocess.run(["git", "add", str(PROJECT_FILE), str(PYPROJECT_FILE)], check=True)
|
||||||
|
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
||||||
|
subprocess.run(["git", "tag", new_version], check=True)
|
||||||
|
|
||||||
|
print(f"\nReleased v{new_version} — tag {new_version} created.")
|
||||||
|
print(f"Moved {len(done_items)} DONE item(s) from Backlog to Version section.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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
|
||||||
189
tests/birds_tests/test_importer.py
Normal file
189
tests/birds_tests/test_importer.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import tempfile
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_record_error(self, db, user):
|
||||||
|
imp = BirdingCSVImport.objects.create(user=user)
|
||||||
|
assert imp.error_log is None
|
||||||
|
imp.record_error("test error")
|
||||||
|
imp.refresh_from_db()
|
||||||
|
assert imp.error_log is not None
|
||||||
|
assert "test error" in imp.error_log
|
||||||
|
|
||||||
|
def test_record_error_appends(self, db, user):
|
||||||
|
imp = BirdingCSVImport.objects.create(user=user)
|
||||||
|
imp.record_error("first error")
|
||||||
|
imp.record_error("second error")
|
||||||
|
imp.refresh_from_db()
|
||||||
|
assert imp.error_log.count("\n") == 1
|
||||||
|
assert "first error" in imp.error_log
|
||||||
|
assert "second error" in imp.error_log
|
||||||
|
|
||||||
|
@pytest.mark.django_db(transaction=True)
|
||||||
|
def test_process_via_model(self, user, birding_csv_file):
|
||||||
|
imp = BirdingCSVImport.objects.create(user=user)
|
||||||
|
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
|
||||||
|
|
||||||
|
def test_record_error_on_bad_csv(self, user, db):
|
||||||
|
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||||
|
Canada Goose,6,Test Park,Stationary,"Bad Date",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write(content)
|
||||||
|
file_path = f.name
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
|
||||||
|
assert len(scrobbles) == 0
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "Could not parse date/time" in errors[0]
|
||||||
|
|
||||||
|
def test_record_error_on_bad_location(self, user, db):
|
||||||
|
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||||
|
Canada Goose,6,,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write(content)
|
||||||
|
file_path = f.name
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
|
||||||
|
assert len(scrobbles) == 0
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "Skipping rows with no location" in errors[0]
|
||||||
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 (
|
from boardgames.bgg import (
|
||||||
take_first,
|
take_first,
|
||||||
lookup_boardgame_id_from_bgg,
|
lookup_boardgame_id_from_bgg,
|
||||||
@ -5,12 +6,14 @@ from boardgames.bgg import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Deprecated library")
|
||||||
def test_take_first():
|
def test_take_first():
|
||||||
assert take_first([]) == ""
|
assert take_first([]) == ""
|
||||||
|
|
||||||
assert take_first(["a", "b"]) == "a"
|
assert take_first(["a", "b"]) == "a"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Deprecated library")
|
||||||
def test_lookup_boardgame_id_from_bgg():
|
def test_lookup_boardgame_id_from_bgg():
|
||||||
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
|
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
|
||||||
assert bgg_id == "15"
|
assert bgg_id == "15"
|
||||||
@ -19,6 +22,7 @@ def test_lookup_boardgame_id_from_bgg():
|
|||||||
assert bgg_id == None
|
assert bgg_id == None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Deprecated library")
|
||||||
def test_lookup_boardgame_from_bgg():
|
def test_lookup_boardgame_from_bgg():
|
||||||
bgg_result = lookup_boardgame_from_bgg(15)
|
bgg_result = lookup_boardgame_from_bgg(15)
|
||||||
assert bgg_result.get("bggeek_id") == 15
|
assert bgg_result.get("bggeek_id") == 15
|
||||||
|
|||||||
0
tests/discgolf_tests/__init__.py
Normal file
0
tests/discgolf_tests/__init__.py
Normal file
63
tests/discgolf_tests/conftest.py
Normal file
63
tests/discgolf_tests/conftest.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user(db):
|
||||||
|
return User.objects.create(email="golfer@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def udisc_singles_csv_content():
|
||||||
|
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
|
||||||
|
Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
|
||||||
|
Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
|
||||||
|
Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def udisc_singles_csv_file(udisc_singles_csv_content):
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||||
|
) as f:
|
||||||
|
f.write(udisc_singles_csv_content)
|
||||||
|
return f.name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def udisc_teams_csv_content():
|
||||||
|
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
|
||||||
|
Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
|
||||||
|
Alice+Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
|
||||||
|
Charlie+Diana,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def udisc_teams_csv_file(udisc_teams_csv_content):
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||||
|
) as f:
|
||||||
|
f.write(udisc_teams_csv_content)
|
||||||
|
return f.name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def udisc_csv_no_par_content():
|
||||||
|
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
|
||||||
|
Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def udisc_csv_no_par_file(udisc_csv_no_par_content):
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||||
|
) as f:
|
||||||
|
f.write(udisc_csv_no_par_content)
|
||||||
|
return f.name
|
||||||
102
tests/discgolf_tests/test_models.py
Normal file
102
tests/discgolf_tests/test_models.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
from discgolf.models import DiscGolfCourse, DiscGolfLogData
|
||||||
|
from scrobbles.dataclasses import BaseLogData
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscGolfCourseModel:
|
||||||
|
def test_create_course(self, db):
|
||||||
|
course = DiscGolfCourse.objects.create(
|
||||||
|
title="Maple Hill",
|
||||||
|
layout_name="Mountains",
|
||||||
|
number_of_holes=18,
|
||||||
|
par_total=54,
|
||||||
|
par_per_hole={"hole_1": 3, "hole_2": 3},
|
||||||
|
)
|
||||||
|
assert course.uuid is not None
|
||||||
|
assert str(course) == "Maple Hill (Mountains)"
|
||||||
|
assert course.subtitle == "Mountains"
|
||||||
|
|
||||||
|
def test_subtitle_fallback(self, db):
|
||||||
|
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||||
|
assert course.subtitle == ""
|
||||||
|
|
||||||
|
def test_logdata_cls(self, db):
|
||||||
|
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||||
|
assert course.logdata_cls is DiscGolfLogData
|
||||||
|
assert issubclass(course.logdata_cls, BaseLogData)
|
||||||
|
|
||||||
|
def test_strings(self, db):
|
||||||
|
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||||
|
assert course.strings.verb == "Playing"
|
||||||
|
assert course.strings.tags == "golf"
|
||||||
|
|
||||||
|
def test_primary_image_url(self, db):
|
||||||
|
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||||
|
assert course.primary_image_url == ""
|
||||||
|
|
||||||
|
def test_get_absolute_url(self, db):
|
||||||
|
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||||
|
url = course.get_absolute_url()
|
||||||
|
assert str(course.uuid) in url
|
||||||
|
assert url.startswith("/disc-golf/")
|
||||||
|
|
||||||
|
def test_find_or_create_new(self, db):
|
||||||
|
course = DiscGolfCourse.find_or_create(
|
||||||
|
"New Course", layout_name="Default"
|
||||||
|
)
|
||||||
|
assert course.title == "New Course"
|
||||||
|
assert course.layout_name == "Default"
|
||||||
|
|
||||||
|
def test_find_or_create_existing(self, db):
|
||||||
|
created = DiscGolfCourse.objects.create(
|
||||||
|
title="Existing", layout_name="Alpha"
|
||||||
|
)
|
||||||
|
found = DiscGolfCourse.find_or_create("Existing", layout_name="Beta")
|
||||||
|
assert found.id == created.id
|
||||||
|
assert found.layout_name == "Alpha"
|
||||||
|
|
||||||
|
def test_scrobbles_method(self, db, user):
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||||
|
dt1 = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)
|
||||||
|
dt2 = datetime(2026, 6, 14, 14, 0, 0, tzinfo=pytz.UTC)
|
||||||
|
s1 = Scrobble.objects.create(
|
||||||
|
user=user,
|
||||||
|
disc_golf_course=course,
|
||||||
|
media_type=Scrobble.MediaType.DISC_GOLF,
|
||||||
|
timestamp=dt1,
|
||||||
|
)
|
||||||
|
s2 = Scrobble.objects.create(
|
||||||
|
user=user,
|
||||||
|
disc_golf_course=course,
|
||||||
|
media_type=Scrobble.MediaType.DISC_GOLF,
|
||||||
|
timestamp=dt2,
|
||||||
|
)
|
||||||
|
qs = course.scrobbles(user.id)
|
||||||
|
assert list(qs) == [s1, s2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscGolfLogData:
|
||||||
|
def test_basic_logdata(self):
|
||||||
|
data = DiscGolfLogData()
|
||||||
|
assert data.scores is None
|
||||||
|
assert data.weather is None
|
||||||
|
assert data.fun_factor is None
|
||||||
|
assert data.course_name is None
|
||||||
|
|
||||||
|
def test_logdata_with_scores(self):
|
||||||
|
data = DiscGolfLogData(
|
||||||
|
scores={"Alice": {"person_id": 1, "total": 9}},
|
||||||
|
weather="Sunny",
|
||||||
|
fun_factor="High",
|
||||||
|
course_name="Maple Hill",
|
||||||
|
par=9,
|
||||||
|
round_type="Singles",
|
||||||
|
)
|
||||||
|
assert data.scores["Alice"]["total"] == 9
|
||||||
|
assert data.weather == "Sunny"
|
||||||
|
assert data.round_type == "Singles"
|
||||||
150
tests/discgolf_tests/test_utils.py
Normal file
150
tests/discgolf_tests/test_utils.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
from discgolf.utils import _parse_udisc_datetime, import_udisc_csv
|
||||||
|
from people.models import Person
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
|
||||||
|
class TestParserHelpers:
|
||||||
|
def test_parse_udisc_datetime(self):
|
||||||
|
dt = _parse_udisc_datetime("Jun 15, 2026 10:00 AM")
|
||||||
|
assert dt is not None
|
||||||
|
assert dt.year == 2026
|
||||||
|
assert dt.month == 6
|
||||||
|
assert dt.day == 15
|
||||||
|
assert dt.hour == 10
|
||||||
|
assert dt.minute == 0
|
||||||
|
|
||||||
|
def test_parse_udisc_datetime_date_only(self):
|
||||||
|
dt = _parse_udisc_datetime("Jun 15, 2026")
|
||||||
|
assert dt is not None
|
||||||
|
assert dt.year == 2026
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportUdiscCSV:
|
||||||
|
def test_import_singles_creates_course(self, user, udisc_singles_csv_file):
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
course = DiscGolfCourse.objects.filter(title="Maple Hill").first()
|
||||||
|
assert course is not None
|
||||||
|
assert course.layout_name == "Mountains"
|
||||||
|
assert course.number_of_holes == 3
|
||||||
|
assert course.par_total == 9
|
||||||
|
assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}
|
||||||
|
|
||||||
|
def test_import_singles_creates_scrobble(self, user, udisc_singles_csv_file):
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
assert Scrobble.objects.filter(source="uDisc").count() == 1
|
||||||
|
|
||||||
|
def test_import_singles_logdata(self, user, udisc_singles_csv_file):
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
scrobble = Scrobble.objects.filter(source="uDisc").first()
|
||||||
|
log = scrobble.log
|
||||||
|
assert log["course_name"] == "Maple Hill"
|
||||||
|
assert log["par"] == 9
|
||||||
|
assert log["round_type"] == "Singles"
|
||||||
|
assert "Alice" in log["scores"]
|
||||||
|
assert "Bob" in log["scores"]
|
||||||
|
assert log["scores"]["Alice"]["total"] == 9
|
||||||
|
assert log["scores"]["Bob"]["total"] == 12
|
||||||
|
|
||||||
|
def test_import_singles_creates_people(self, user, udisc_singles_csv_file):
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
assert Person.objects.filter(name="Alice").exists()
|
||||||
|
assert Person.objects.filter(name="Bob").exists()
|
||||||
|
|
||||||
|
def test_import_teams_creates_scrobble(self, user, udisc_teams_csv_file):
|
||||||
|
import_udisc_csv(udisc_teams_csv_file, user.id)
|
||||||
|
assert Scrobble.objects.filter(source="uDisc").count() == 1
|
||||||
|
|
||||||
|
def test_import_teams_logdata(self, user, udisc_teams_csv_file):
|
||||||
|
import_udisc_csv(udisc_teams_csv_file, user.id)
|
||||||
|
scrobble = Scrobble.objects.filter(source="uDisc").first()
|
||||||
|
assert scrobble.log["round_type"] == "Teams"
|
||||||
|
alice_bob = scrobble.log["scores"]["Alice+Bob"]
|
||||||
|
assert "person_ids" in alice_bob
|
||||||
|
assert len(alice_bob["person_ids"]) == 2
|
||||||
|
|
||||||
|
def test_import_creates_team_people(self, user, udisc_teams_csv_file):
|
||||||
|
import_udisc_csv(udisc_teams_csv_file, user.id)
|
||||||
|
assert Person.objects.filter(name="Alice").exists()
|
||||||
|
assert Person.objects.filter(name="Bob").exists()
|
||||||
|
assert Person.objects.filter(name="Charlie").exists()
|
||||||
|
assert Person.objects.filter(name="Diana").exists()
|
||||||
|
|
||||||
|
def test_import_teams_par_per_hole(self, user, udisc_teams_csv_file):
|
||||||
|
import_udisc_csv(udisc_teams_csv_file, user.id)
|
||||||
|
course = DiscGolfCourse.objects.get(title="Maple Hill")
|
||||||
|
assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}
|
||||||
|
|
||||||
|
def test_import_no_par_returns_empty(self, user, udisc_csv_no_par_file):
|
||||||
|
result = import_udisc_csv(udisc_csv_no_par_file, user.id)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_import_empty_csv(self, user, db):
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||||
|
) as f:
|
||||||
|
f.write("PlayerName,CourseName,LayoutName,StartDate,Hole1,Total\n")
|
||||||
|
path = f.name
|
||||||
|
|
||||||
|
result = import_udisc_csv(path, user.id)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_import_idempotent(self, user, udisc_singles_csv_file):
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
assert DiscGolfCourse.objects.filter(title="Maple Hill").count() == 1
|
||||||
|
assert Scrobble.objects.filter(source="uDisc").count() == 2
|
||||||
|
|
||||||
|
def test_import_course_defaults_only_on_create(
|
||||||
|
self, user, udisc_singles_csv_file
|
||||||
|
):
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
course = DiscGolfCourse.objects.get(title="Maple Hill")
|
||||||
|
assert course.layout_name == "Mountains"
|
||||||
|
|
||||||
|
course.layout_name = "Updated"
|
||||||
|
course.save()
|
||||||
|
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
course.refresh_from_db()
|
||||||
|
assert course.layout_name == "Updated"
|
||||||
|
|
||||||
|
@patch("discgolf.utils.ScrobbleNtfyNotification")
|
||||||
|
def test_import_sends_notification(self, mock_notification_class, user, udisc_singles_csv_file):
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
mock_notification_class.assert_called_once()
|
||||||
|
mock_notification_class.return_value.send.assert_called_once()
|
||||||
|
|
||||||
|
def test_import_hole_scores_per_player(self, user, udisc_singles_csv_file):
|
||||||
|
import_udisc_csv(udisc_singles_csv_file, user.id)
|
||||||
|
scrobble = Scrobble.objects.filter(source="uDisc").first()
|
||||||
|
alice = scrobble.log["scores"]["Alice"]
|
||||||
|
assert alice["hole_1"] == 4
|
||||||
|
assert alice["hole_2"] == 2
|
||||||
|
assert alice["hole_3"] == 3
|
||||||
|
bob = scrobble.log["scores"]["Bob"]
|
||||||
|
assert bob["hole_1"] == 3
|
||||||
|
assert bob["hole_2"] == 4
|
||||||
|
assert bob["hole_3"] == 5
|
||||||
|
|
||||||
|
def test_import_record_error_on_bad_data(self, user, db):
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
content = """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
|
||||||
|
Par,,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
|
||||||
|
"""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
|
||||||
|
) as f:
|
||||||
|
f.write(content)
|
||||||
|
path = f.name
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
result = import_udisc_csv(path, user.id, record_error=errors.append)
|
||||||
|
assert len(result) == 1
|
||||||
|
course = DiscGolfCourse.objects.first()
|
||||||
|
assert course.title == ""
|
||||||
58
tests/discgolf_tests/test_views.py
Normal file
58
tests/discgolf_tests/test_views.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from discgolf.models import DiscGolfCourse
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscGolfCourseViews:
|
||||||
|
def _make_scrobble(self, user, course):
|
||||||
|
dt = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)
|
||||||
|
return Scrobble.objects.create(
|
||||||
|
user=user,
|
||||||
|
disc_golf_course=course,
|
||||||
|
media_type=Scrobble.MediaType.DISC_GOLF,
|
||||||
|
timestamp=dt,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_course_list_anonymous(self, db, user):
|
||||||
|
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||||
|
self._make_scrobble(user, course)
|
||||||
|
client = Client()
|
||||||
|
response = client.get(reverse("discgolf:course_list"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_course_list_shows_course(self, db, user):
|
||||||
|
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||||
|
self._make_scrobble(user, course)
|
||||||
|
client = Client()
|
||||||
|
response = client.get(reverse("discgolf:course_list"))
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Maple Hill" in response.content.decode()
|
||||||
|
|
||||||
|
def test_course_detail_anonymous(self, db, user):
|
||||||
|
course = DiscGolfCourse.objects.create(title="Maple Hill")
|
||||||
|
self._make_scrobble(user, course)
|
||||||
|
client = Client()
|
||||||
|
response = client.get(
|
||||||
|
reverse("discgolf:course_detail", kwargs={"slug": course.uuid})
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_course_detail_shows_course(self, db, user):
|
||||||
|
course = DiscGolfCourse.objects.create(
|
||||||
|
title="Maple Hill", layout_name="Mountains"
|
||||||
|
)
|
||||||
|
self._make_scrobble(user, course)
|
||||||
|
client = Client()
|
||||||
|
response = client.get(
|
||||||
|
reverse("discgolf:course_detail", kwargs={"slug": course.uuid})
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Maple Hill" in response.content.decode()
|
||||||
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
|
import pytest
|
||||||
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
|
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
|
||||||
|
|
||||||
expected_desc_snippet = (
|
expected_desc_snippet = "NPR's Up First is the news you need to start your day. "
|
||||||
"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_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0CfR24RR6sme531yIkCrnK4zzmo97jeualO5drVPKG6oCk"
|
||||||
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
|
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
|
||||||
|
|||||||
@ -22,8 +22,18 @@ def boardgame_scrobble():
|
|||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
log={
|
log={
|
||||||
"players": [
|
"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(
|
Track.objects.create(
|
||||||
title="Emotion",
|
title="Emotion",
|
||||||
artist=Artist.objects.create(name="Carly Rae Jepsen"),
|
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),
|
"artist": kwargs.get("artist", self.artist),
|
||||||
"album": kwargs.get("album", self.album),
|
"album": kwargs.get("album", self.album),
|
||||||
"track_number": int(kwargs.get("track_number", self.track_number)),
|
"track_number": int(kwargs.get("track_number", self.track_number)),
|
||||||
"run_time_ticks": int(
|
"run_time_ticks": int(kwargs.get("run_time_ticks", self.run_time_ticks)),
|
||||||
kwargs.get("run_time_ticks", self.run_time_ticks)
|
|
||||||
),
|
|
||||||
"run_time": int(kwargs.get("run_time", self.run_time)),
|
"run_time": int(kwargs.get("run_time", self.run_time)),
|
||||||
"playback_time_ticks": int(
|
"playback_time_ticks": int(
|
||||||
kwargs.get("playback_time_ticks", self.playback_time_ticks)
|
kwargs.get("playback_time_ticks", self.playback_time_ticks)
|
||||||
@ -103,9 +111,7 @@ def mopidy_track():
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mopidy_track_diff_album_request_data(**kwargs):
|
def mopidy_track_diff_album_request_data(**kwargs):
|
||||||
mb_album_id = "0c56c457-afe1-4679-baab-759ba8dd2a58"
|
mb_album_id = "0c56c457-afe1-4679-baab-759ba8dd2a58"
|
||||||
return MopidyRequest(
|
return MopidyRequest(album="Gold", musicbrainz_album_id=mb_album_id).request_json
|
||||||
album="Gold", musicbrainz_album_id=mb_album_id
|
|
||||||
).request_json
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@ -116,6 +122,14 @@ def mopidy_podcast_request_data():
|
|||||||
).request_json
|
).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"
|
||||||
|
return MopidyRequest(
|
||||||
|
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||||
|
).request_json
|
||||||
|
|
||||||
|
|
||||||
class JellyfinTrackRequest:
|
class JellyfinTrackRequest:
|
||||||
name = "Emotion"
|
name = "Emotion"
|
||||||
artist = "Carly Rae Jepsen"
|
artist = "Carly Rae Jepsen"
|
||||||
@ -130,6 +144,7 @@ class JellyfinTrackRequest:
|
|||||||
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||||
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
|
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
|
||||||
status = "resumed"
|
status = "resumed"
|
||||||
|
client_name = "Jellyfin"
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
self.request_data = {
|
self.request_data = {
|
||||||
@ -153,6 +168,7 @@ class JellyfinTrackRequest:
|
|||||||
"musicbrainz_artist_id", self.musicbrainz_artist_id
|
"musicbrainz_artist_id", self.musicbrainz_artist_id
|
||||||
),
|
),
|
||||||
"Status": kwargs.get("status", self.status),
|
"Status": kwargs.get("status", self.status),
|
||||||
|
"ClientName": kwargs.get("client_name", self.client_name),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import time_machine
|
import time_machine
|
||||||
@ -6,30 +7,59 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
|
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
|
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")
|
url = reverse("scrobbles:mopidy-webhook")
|
||||||
user = get_user_model().objects.create(username="Test User")
|
user = get_user_model().objects.create(username="Test User")
|
||||||
user.profile.timezone = "US/Eastern"
|
user.profile.timezone = "US/Eastern"
|
||||||
user.profile.save()
|
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):
|
for i in range(num):
|
||||||
client.post(url, request_json, content_type="application/json")
|
naive_time = timezone.now().replace(tzinfo=None) - timedelta(days=i * spacing)
|
||||||
s = Scrobble.objects.last()
|
aware_time = est.localize(naive_time)
|
||||||
s.user = user
|
Scrobble.objects.create(
|
||||||
s.timestamp = timezone.now() - timedelta(days=i * spacing)
|
user=user,
|
||||||
s.played_to_completion = True
|
track=track,
|
||||||
s.save()
|
timestamp=aware_time,
|
||||||
|
played_to_completion=True,
|
||||||
|
source="Mopidy",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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))
|
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||||
def test_scrobble_counts_data(client, mopidy_track):
|
def test_scrobble_counts_data(
|
||||||
build_scrobbles(client, mopidy_track.request_json)
|
mock_lookup_album_tadb,
|
||||||
user = get_user_model().objects.first()
|
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)
|
count_dict = scrobble_counts(user)
|
||||||
assert count_dict == {
|
assert count_dict == {
|
||||||
"alltime": 7,
|
"alltime": 7,
|
||||||
@ -41,10 +71,25 @@ def test_scrobble_counts_data(client, mopidy_track):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@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))
|
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||||
def test_live_charts(client, mopidy_track):
|
def test_live_charts(
|
||||||
build_scrobbles(client, mopidy_track.request_json, 7, 1)
|
mock_lookup_album_tadb,
|
||||||
user = get_user_model().objects.first()
|
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)
|
week = week_of_scrobbles(user)
|
||||||
assert list(week.values()) == [1, 1, 1, 1, 1, 1, 1]
|
assert list(week.values()) == [1, 1, 1, 1, 1, 1, 1]
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import pytest
|
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")
|
@pytest.mark.skip("Need to get local tests running working again")
|
||||||
@ -19,7 +19,7 @@ def test_boardgame_log_data(boardgame_scrobble):
|
|||||||
new=None,
|
new=None,
|
||||||
rank=None,
|
rank=None,
|
||||||
seat_order=None,
|
seat_order=None,
|
||||||
role=None
|
role=None,
|
||||||
),
|
),
|
||||||
BoardGameScoreLogData(
|
BoardGameScoreLogData(
|
||||||
person_id=2,
|
person_id=2,
|
||||||
@ -32,7 +32,7 @@ def test_boardgame_log_data(boardgame_scrobble):
|
|||||||
new=None,
|
new=None,
|
||||||
rank=None,
|
rank=None,
|
||||||
seat_order=None,
|
seat_order=None,
|
||||||
role=None
|
role=None,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
difficulty=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():
|
def test_timestamp_user_tz_to_utc():
|
||||||
timestamp = timestamp_user_tz_to_utc(
|
timestamp = timestamp_user_tz_to_utc(1685561082, pytz.timezone("US/Eastern"))
|
||||||
1685561082, pytz.timezone("US/Eastern")
|
|
||||||
)
|
|
||||||
assert timestamp == datetime(2023, 5, 31, 23, 24, 42, tzinfo=pytz.utc)
|
assert timestamp == datetime(2023, 5, 31, 23, 24, 42, tzinfo=pytz.utc)
|
||||||
|
|||||||
@ -1,13 +1,16 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import time_machine
|
import time_machine
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from django.urls import reverse
|
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 podcasts.models import PodcastEpisode
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble, ShareViewLog
|
||||||
|
from scrobbles.sqids import encode_scrobble_share
|
||||||
|
from tasks.models import Task
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@ -18,11 +21,21 @@ def test_get_not_allowed_from_mopidy(client, valid_auth_token):
|
|||||||
assert response.status_code == 405
|
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
|
@pytest.mark.django_db
|
||||||
def test_bad_mopidy_request_data(client, valid_auth_token):
|
def test_bad_mopidy_request_data(client, valid_auth_token):
|
||||||
url = reverse("scrobbles:mopidy-webhook")
|
url = reverse("scrobbles:mopidy-webhook")
|
||||||
headers = {"Authorization": f"Token {valid_auth_token}"}
|
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.status_code == 400
|
||||||
assert (
|
assert (
|
||||||
response.data["detail"]
|
response.data["detail"]
|
||||||
@ -30,6 +43,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.skip("Need to refactor")
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
@patch("music.utils.lookup_artist_from_mb", return_value={})
|
||||||
@ -146,9 +498,121 @@ def test_scrobble_jellyfin_track(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.data == {"scrobble_id": 1}
|
assert response.data == {"scrobble_id": 1}
|
||||||
|
|
||||||
scrobble = Scrobble.objects.get(id=1)
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
assert scrobble.media_obj.__class__ == Track
|
assert scrobble.media_obj.__class__ == Track
|
||||||
assert scrobble.media_obj.title == "Emotion"
|
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,
|
||||||
|
visibility="public",
|
||||||
|
log={
|
||||||
|
"notes": ["First note", "Second note"],
|
||||||
|
"description": "Test description",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
|
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,
|
||||||
|
visibility="public",
|
||||||
|
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={"pk": scrobble.id})
|
||||||
|
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,
|
||||||
|
visibility="public",
|
||||||
|
log={
|
||||||
|
"notes": [
|
||||||
|
{"2024-01-01 10:00:00": "Note with label"},
|
||||||
|
],
|
||||||
|
"labels": ["work", "urgent"],
|
||||||
|
"description": "Test description",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||||
|
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={"pk": scrobble.id})
|
||||||
|
|
||||||
|
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")
|
@pytest.mark.skip("Need to refactor")
|
||||||
@ -250,3 +714,322 @@ def test_scrobble_jellyfin_track_create_new(
|
|||||||
scrobble = Scrobble.objects.get(id=1)
|
scrobble = Scrobble.objects.get(id=1)
|
||||||
assert scrobble.media_obj.__class__ == Track
|
assert scrobble.media_obj.__class__ == Track
|
||||||
assert scrobble.media_obj.title == "Emotion"
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_share_view_shared_visibility(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="shareuser", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="shared",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
url = reverse(
|
||||||
|
"scrobbles:shared-detail",
|
||||||
|
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||||
|
)
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_share_view_public_visibility(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="shareuser2", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="public",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
url = reverse(
|
||||||
|
"scrobbles:shared-detail",
|
||||||
|
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||||
|
)
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_share_view_private_visibility_returns_404(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="shareuser3", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="private",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
url = reverse(
|
||||||
|
"scrobbles:shared-detail",
|
||||||
|
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||||
|
)
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_share_view_invalid_sqid_returns_404(client):
|
||||||
|
url = reverse("scrobbles:shared-detail", kwargs={"sqid": "InvalidSqid123"})
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_share_view_expired_token_returns_404(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="shareuser4", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="shared",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||||
|
scrobble.regenerate_share_token()
|
||||||
|
url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_share_view_increments_count_and_logs_view(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="shareuser5", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="shared",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
assert scrobble.share_view_count == 0
|
||||||
|
|
||||||
|
url = reverse(
|
||||||
|
"scrobbles:shared-detail",
|
||||||
|
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||||
|
)
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
scrobble.refresh_from_db()
|
||||||
|
assert scrobble.share_view_count == 1
|
||||||
|
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 1
|
||||||
|
|
||||||
|
log_entry = ShareViewLog.objects.filter(scrobble=scrobble).first()
|
||||||
|
assert log_entry.ip_address == "127.0.0.1"
|
||||||
|
assert log_entry.user_agent == ""
|
||||||
|
assert log_entry.referrer == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_explore_view_shows_only_public_scrobbles(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="exploreuser", password="testpass"
|
||||||
|
)
|
||||||
|
public_task = Task.objects.create(title="Public Task Title")
|
||||||
|
shared_task = Task.objects.create(title="Shared Task Title")
|
||||||
|
private_task = Task.objects.create(title="Private Task Title")
|
||||||
|
ts = timezone.now()
|
||||||
|
public_scrobble = Scrobble.objects.create(
|
||||||
|
task=public_task, media_type="Task", user=user, visibility="public",
|
||||||
|
timestamp=ts,
|
||||||
|
)
|
||||||
|
Scrobble.objects.create(
|
||||||
|
task=shared_task, media_type="Task", user=user, visibility="shared",
|
||||||
|
timestamp=ts,
|
||||||
|
)
|
||||||
|
Scrobble.objects.create(
|
||||||
|
task=private_task, media_type="Task", user=user, visibility="private",
|
||||||
|
timestamp=ts,
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse("scrobbles:explore")
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
content = response.content.decode()
|
||||||
|
assert "Public Task Title" in content
|
||||||
|
assert "Shared Task Title" not in content
|
||||||
|
assert "Private Task Title" not in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_change_visibility_owner_can_change(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="visuser", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="private",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
client.force_login(user)
|
||||||
|
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||||
|
response = client.post(url, {"visibility": "shared"})
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
scrobble.refresh_from_db()
|
||||||
|
assert scrobble.visibility == "shared"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_change_visibility_non_owner_gets_404(client):
|
||||||
|
owner = get_user_model().objects.create_user(
|
||||||
|
username="owner", password="testpass"
|
||||||
|
)
|
||||||
|
other = get_user_model().objects.create_user(
|
||||||
|
username="other", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=owner, visibility="private",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
client.force_login(other)
|
||||||
|
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||||
|
response = client.post(url, {"visibility": "shared"})
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
scrobble.refresh_from_db()
|
||||||
|
assert scrobble.visibility == "private"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_change_visibility_anonymous_redirects_to_login(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="anontest", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="private",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||||
|
response = client.post(url, {"visibility": "shared"})
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert "/login/" in response.url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_regenerate_share_token_invalidates_old_sqid(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="regentest", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="shared",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||||
|
|
||||||
|
client.force_login(user)
|
||||||
|
url = reverse("scrobbles:regenerate-share-token", kwargs={"pk": scrobble.id})
|
||||||
|
response = client.post(url)
|
||||||
|
assert response.status_code == 302
|
||||||
|
|
||||||
|
scrobble.refresh_from_db()
|
||||||
|
assert scrobble.share_token_version == 1
|
||||||
|
|
||||||
|
old_url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
|
||||||
|
old_response = client.get(old_url)
|
||||||
|
assert old_response.status_code == 404
|
||||||
|
|
||||||
|
new_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||||
|
new_url = reverse("scrobbles:shared-detail", kwargs={"sqid": new_sqid})
|
||||||
|
new_response = client.get(new_url)
|
||||||
|
assert new_response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_share_analytics_owner_can_view(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="analyticsuser", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="shared",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
client.force_login(user)
|
||||||
|
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_share_analytics_non_owner_gets_404(client):
|
||||||
|
owner = get_user_model().objects.create_user(
|
||||||
|
username="analyticsowner", password="testpass"
|
||||||
|
)
|
||||||
|
other = get_user_model().objects.create_user(
|
||||||
|
username="analyticsother", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=owner, visibility="shared",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
client.force_login(other)
|
||||||
|
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_share_analytics_shows_view_logs(client):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="analyticsviews", password="testpass"
|
||||||
|
)
|
||||||
|
task = Task.objects.create(title="Test Task")
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
task=task, media_type="Task", user=user, visibility="shared",
|
||||||
|
timestamp=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||||
|
share_url = reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
|
||||||
|
client.get(share_url)
|
||||||
|
client.get(share_url)
|
||||||
|
|
||||||
|
client.force_login(user)
|
||||||
|
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
content = response.content.decode()
|
||||||
|
assert "127.0.0.1" in content
|
||||||
|
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 2
|
||||||
|
|||||||
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"
|
||||||
167
tests/test_context_processors.py
Normal file
167
tests/test_context_processors.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from vrobbler import context_processors
|
||||||
|
from vrobbler.context_processors import version_info
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_git_cache():
|
||||||
|
context_processors._GIT_COMMIT = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_request():
|
||||||
|
return MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
# Django starts so that shared_task will use this app.
|
||||||
from .celery import app as celery_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"
|
||||||
@ -27,6 +27,7 @@ class BeerAdmin(admin.ModelAdmin):
|
|||||||
"uuid",
|
"uuid",
|
||||||
"title",
|
"title",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("styles", "producer")
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
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_id = models.CharField(max_length=255, **BNULL)
|
||||||
untappd_rating = models.FloatField(**BNULL)
|
untappd_rating = models.FloatField(**BNULL)
|
||||||
producer = models.ForeignKey(
|
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
BeerProducer, on_delete=models.DO_NOTHING, **BNULL
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
def get_absolute_url(self) -> str:
|
||||||
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
|
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
|
||||||
@ -130,9 +128,7 @@ class Beer(ScrobblableMixin):
|
|||||||
)
|
)
|
||||||
style_ids.append(style_inst.id)
|
style_ids.append(style_inst.id)
|
||||||
|
|
||||||
producer, _created = BeerProducer.objects.get_or_create(
|
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
|
||||||
**producer_dict
|
|
||||||
)
|
|
||||||
beer_dict["producer_id"] = producer.id
|
beer_dict["producer_id"] = producer.id
|
||||||
beer = Beer.objects.create(**beer_dict)
|
beer = Beer.objects.create(**beer_dict)
|
||||||
for style_id in style_ids:
|
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:
|
def get_rating_from_soup(soup) -> str:
|
||||||
rating = ""
|
rating = ""
|
||||||
try:
|
try:
|
||||||
rating = float(
|
rating = float(soup.find(class_="num").get_text().strip("(").strip(")"))
|
||||||
soup.find(class_="num").get_text().strip("(").strip(")")
|
|
||||||
)
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
rating = None
|
rating = None
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -124,9 +122,7 @@ def get_beer_from_untappd_id(untappd_id: str) -> dict:
|
|||||||
beer_dict = {"untappd_id": untappd_id}
|
beer_dict = {"untappd_id": untappd_id}
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
logger.warn(
|
logger.warn("Bad response from untappd.com", extra={"response": response})
|
||||||
"Bad response from untappd.com", extra={"response": response}
|
|
||||||
)
|
|
||||||
return beer_dict
|
return beer_dict
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
soup = BeautifulSoup(response.text, "html.parser")
|
||||||
|
|||||||
0
vrobbler/apps/birds/__init__.py
Normal file
0
vrobbler/apps/birds/__init__.py
Normal file
31
vrobbler/apps/birds/admin.py
Normal file
31
vrobbler/apps/birds/admin.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
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", "error_log")
|
||||||
|
raw_id_fields = ("user",)
|
||||||
|
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
|
||||||
198
vrobbler/apps/birds/importer.py
Normal file
198
vrobbler/apps/birds/importer.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from dateutil import parser
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
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_str = f"{date_str} {time_str}".strip()
|
||||||
|
dt = parser.parse(dt_str)
|
||||||
|
return dt
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
try:
|
||||||
|
dt = parser.parse(date_str)
|
||||||
|
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, record_error=None):
|
||||||
|
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:
|
||||||
|
msg = "Skipping rows with no location"
|
||||||
|
logger.warning(msg)
|
||||||
|
if record_error:
|
||||||
|
record_error(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = parse_timestamp(date_str, time_str)
|
||||||
|
if not timestamp:
|
||||||
|
msg = f"Could not parse date/time: {date_str} {time_str}"
|
||||||
|
if record_error:
|
||||||
|
record_error(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
|
timestamp = user.profile.get_timestamp_with_tz(timestamp)
|
||||||
|
|
||||||
|
location_title = (
|
||||||
|
LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip()
|
||||||
|
)
|
||||||
|
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,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-06-08 14:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("birds", "0002_birdingcsvimport"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="birdingcsvimport",
|
||||||
|
name="error_log",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/birds/migrations/__init__.py
Normal file
0
vrobbler/apps/birds/migrations/__init__.py
Normal file
308
vrobbler/apps/birds/models.py
Normal file
308
vrobbler/apps/birds/models.py
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
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 self.geo_location
|
||||||
|
|
||||||
|
@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)
|
||||||
|
error_log = models.TextField(**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 record_error(self, error_message):
|
||||||
|
log_line = f"{timezone.now().isoformat()}: {error_message}"
|
||||||
|
if self.error_log:
|
||||||
|
self.error_log += "\n" + log_line
|
||||||
|
else:
|
||||||
|
self.error_log = log_line
|
||||||
|
self.save(update_fields=["error_log"])
|
||||||
|
|
||||||
|
def scrobbles(self):
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
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()
|
||||||
|
try:
|
||||||
|
scrobbles = import_birding_csv(
|
||||||
|
self.upload_file_path, self.user_id, record_error=self.record_error
|
||||||
|
)
|
||||||
|
self.record_log(scrobbles)
|
||||||
|
except Exception as e:
|
||||||
|
self.record_error(f"Import failed: {e}")
|
||||||
|
logger.exception(f"Import failed for {self}")
|
||||||
|
finally:
|
||||||
|
self.mark_finished()
|
||||||
27
vrobbler/apps/birds/static/birds/bird_sightings.js
Normal file
27
vrobbler/apps/birds/static/birds/bird_sightings.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
var widget = document.querySelector(".bird-sightings-widget");
|
||||||
|
if (!widget) return;
|
||||||
|
|
||||||
|
var list = widget.querySelector(".bird-sightings-list");
|
||||||
|
|
||||||
|
widget.addEventListener("click", function (e) {
|
||||||
|
if (e.target.classList.contains("add-sighting-row")) {
|
||||||
|
var rows = list.querySelectorAll(".bird-sighting-row");
|
||||||
|
var template = rows[rows.length - 1];
|
||||||
|
if (!template) return;
|
||||||
|
var clone = template.cloneNode(true);
|
||||||
|
clone.querySelectorAll("select, input").forEach(function (el) {
|
||||||
|
el.value = "";
|
||||||
|
});
|
||||||
|
clone.querySelector('input[name$="_quantity"]').value = "1";
|
||||||
|
list.appendChild(clone);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.classList.contains("remove-sighting")) {
|
||||||
|
var rows = list.querySelectorAll(".bird-sighting-row");
|
||||||
|
if (rows.length > 1) {
|
||||||
|
e.target.closest(".bird-sighting-row").remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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
|
||||||
@ -38,6 +38,7 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
|
|||||||
"uuid",
|
"uuid",
|
||||||
"geo_location",
|
"geo_location",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("geo_location",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
@ -49,6 +50,7 @@ class BoardGameAdmin(admin.ModelAdmin):
|
|||||||
"title",
|
"title",
|
||||||
"published_year",
|
"published_year",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("publisher", "publishers", "designers", "expansion_for_boardgame")
|
||||||
search_fields = ("title",)
|
search_fields = ("title",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
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
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -13,10 +14,13 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SEARCH_ID_URL = (
|
SEARCH_ID_URL = "https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||||
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
|
||||||
)
|
|
||||||
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
|
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:
|
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]:
|
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||||
soup = None
|
soup = None
|
||||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
|
||||||
game_id = None
|
game_id = None
|
||||||
url = SEARCH_ID_URL.format(query=title)
|
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:
|
if r.status_code == 200:
|
||||||
soup = BeautifulSoup(r.text, "xml")
|
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:
|
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||||
soup = None
|
soup = None
|
||||||
game_dict = {}
|
game_dict = {}
|
||||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
|
||||||
|
|
||||||
title = ""
|
title = ""
|
||||||
bgg_id = None
|
bgg_id = None
|
||||||
@ -73,7 +75,7 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
|||||||
bgg_id = lookup_boardgame_id_from_bgg(title)
|
bgg_id = lookup_boardgame_id_from_bgg(title)
|
||||||
|
|
||||||
url = GAME_ID_URL.format(id=bgg_id)
|
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:
|
if r.status_code == 200:
|
||||||
soup = BeautifulSoup(r.text, "xml")
|
soup = BeautifulSoup(r.text, "xml")
|
||||||
|
|
||||||
@ -109,7 +111,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
|||||||
login_payload = {
|
login_payload = {
|
||||||
"credentials": {"username": bgg_username, "password": bgg_password}
|
"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
|
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before
|
||||||
|
|
||||||
@ -119,8 +122,6 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
|||||||
data=json.dumps(login_payload),
|
data=json.dumps(login_payload),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
print(p)
|
|
||||||
|
|
||||||
players = []
|
players = []
|
||||||
if scrobble.log:
|
if scrobble.log:
|
||||||
for player in scrobble.log.get("players"):
|
for player in scrobble.log.get("players"):
|
||||||
@ -153,4 +154,3 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
|||||||
data=json.dumps(play_payload),
|
data=json.dumps(play_payload),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
print(r)
|
|
||||||
|
|||||||
@ -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,12 +1,13 @@
|
|||||||
from functools import cached_property
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from functools import cached_property
|
||||||
|
from typing import Any, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import requests
|
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.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -79,21 +80,79 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
|||||||
board: Optional[str] = None
|
board: Optional[str] = None
|
||||||
rounds: Optional[int] = None
|
rounds: Optional[int] = None
|
||||||
details: Optional[str] = None
|
details: Optional[str] = None
|
||||||
# Legacy
|
raw_data: Optional[dict] = None
|
||||||
learning: Optional[bool] = None
|
|
||||||
scenario: Optional[str] = None
|
_excluded_fields = {
|
||||||
|
"lichess_id",
|
||||||
|
"speed",
|
||||||
|
"rated",
|
||||||
|
"moves",
|
||||||
|
"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:
|
||||||
|
return
|
||||||
|
return BoardGameLocation.objects.filter(id=self.location_id).first()
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def player_log(self) -> str:
|
def player_log(self) -> str:
|
||||||
if self.players:
|
if self.players:
|
||||||
return ", ".join(
|
return ", ".join(
|
||||||
[
|
[BoardGameScoreLogData(**player).__str__() for player in self.players]
|
||||||
BoardGameScoreLogData(**player).__str__()
|
|
||||||
for player in self.players
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return "".join(html_parts)
|
||||||
|
|
||||||
|
|
||||||
class BoardGamePublisher(TimeStampedModel):
|
class BoardGamePublisher(TimeStampedModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
@ -105,9 +164,7 @@ class BoardGamePublisher(TimeStampedModel):
|
|||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse("boardgames:publisher_detail", kwargs={"slug": self.uuid})
|
||||||
"boardgames:publisher_detail", kwargs={"slug": self.uuid}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGameDesigner(TimeStampedModel):
|
class BoardGameDesigner(TimeStampedModel):
|
||||||
@ -120,9 +177,7 @@ class BoardGameDesigner(TimeStampedModel):
|
|||||||
return str(self.name)
|
return str(self.name)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse("boardgames:designer_detail", kwargs={"slug": self.uuid})
|
||||||
"boardgames:designer_detail", kwargs={"slug": self.uuid}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGameLocation(TimeStampedModel):
|
class BoardGameLocation(TimeStampedModel):
|
||||||
@ -130,23 +185,17 @@ class BoardGameLocation(TimeStampedModel):
|
|||||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
bgstats_id = models.UUIDField(**BNULL)
|
bgstats_id = models.UUIDField(**BNULL)
|
||||||
description = models.TextField(**BNULL)
|
description = models.TextField(**BNULL)
|
||||||
geo_location = models.ForeignKey(
|
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
|
||||||
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return str(self.name)
|
return str(self.name)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse("boardgames:location_detail", kwargs={"slug": self.uuid})
|
||||||
"boardgames:location_detail", kwargs={"slug": self.uuid}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BoardGame(ScrobblableMixin):
|
class BoardGame(ScrobblableMixin):
|
||||||
COMPLETION_PERCENT = getattr(
|
COMPLETION_PERCENT = getattr(settings, "BOARD_GAME_COMPLETION_PERCENT", 100)
|
||||||
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
|
|
||||||
)
|
|
||||||
|
|
||||||
FIELDS_FROM_BGGEEK = [
|
FIELDS_FROM_BGGEEK = [
|
||||||
"igdb_id",
|
"igdb_id",
|
||||||
@ -162,6 +211,10 @@ class BoardGame(ScrobblableMixin):
|
|||||||
publisher = models.ForeignKey(
|
publisher = models.ForeignKey(
|
||||||
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
|
||||||
)
|
)
|
||||||
|
publishers = models.ManyToManyField(
|
||||||
|
BoardGamePublisher,
|
||||||
|
related_name="board_games",
|
||||||
|
)
|
||||||
designers = models.ManyToManyField(
|
designers = models.ManyToManyField(
|
||||||
BoardGameDesigner,
|
BoardGameDesigner,
|
||||||
related_name="board_games",
|
related_name="board_games",
|
||||||
@ -195,6 +248,7 @@ class BoardGame(ScrobblableMixin):
|
|||||||
options={"quality": 75},
|
options={"quality": 75},
|
||||||
)
|
)
|
||||||
rating = models.FloatField(**BNULL)
|
rating = models.FloatField(**BNULL)
|
||||||
|
bgg_rank = models.IntegerField(**BNULL)
|
||||||
max_players = models.PositiveSmallIntegerField(**BNULL)
|
max_players = models.PositiveSmallIntegerField(**BNULL)
|
||||||
min_players = models.PositiveSmallIntegerField(**BNULL)
|
min_players = models.PositiveSmallIntegerField(**BNULL)
|
||||||
published_date = models.DateField(**BNULL)
|
published_date = models.DateField(**BNULL)
|
||||||
@ -212,13 +266,12 @@ class BoardGame(ScrobblableMixin):
|
|||||||
"self", **BNULL, on_delete=models.DO_NOTHING
|
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
@property
|
||||||
return self.title
|
def subtitle(self) -> str:
|
||||||
|
return self.publisher
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})
|
||||||
"boardgames:boardgame_detail", kwargs={"slug": self.uuid}
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logdata_cls(self):
|
def logdata_cls(self):
|
||||||
@ -243,7 +296,6 @@ class BoardGame(ScrobblableMixin):
|
|||||||
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
|
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
|
||||||
|
|
||||||
if not self.published_date or force_update:
|
if not self.published_date or force_update:
|
||||||
|
|
||||||
if not data:
|
if not data:
|
||||||
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
||||||
|
|
||||||
@ -272,29 +324,69 @@ class BoardGame(ScrobblableMixin):
|
|||||||
|
|
||||||
# Go get cover image if the URL is present
|
# Go get cover image if the URL is present
|
||||||
if cover_url and not self.cover:
|
if cover_url and not self.cover:
|
||||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
self.save_image_from_url(cover_url)
|
||||||
r = requests.get(cover_url, headers=headers)
|
|
||||||
logger.debug(r.status_code)
|
def save_image_from_url(self, url):
|
||||||
if r.status_code == 200:
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
r = requests.get(url, headers=headers)
|
||||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
if r.status_code == 200:
|
||||||
logger.debug("Loaded cover image from BGGeek")
|
fname = f"{self.title}_cover_{self.uuid}.jpg"
|
||||||
|
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(
|
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
|
||||||
cls, lookup_id: str, data: Optional[dict] = {}
|
|
||||||
) -> Optional["BoardGame"]:
|
|
||||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
"""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:
|
if game:
|
||||||
data = lookup_boardgame_from_bgg(lookup_id)
|
logger.info(
|
||||||
|
"Board game exists in database.",
|
||||||
if data and not boardgame:
|
extra={"lookup_id": lookup_id, "data": data},
|
||||||
boardgame, created = cls.objects.get_or_create(
|
|
||||||
title=data["title"], bggeek_id=lookup_id
|
|
||||||
)
|
)
|
||||||
if created:
|
return game
|
||||||
boardgame.fix_metadata(data=data)
|
|
||||||
|
|
||||||
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:
|
def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
|
|
||||||
client = berserk.Client(
|
client = berserk.Client(session=berserk.TokenSession(settings.LICHESS_API_KEY))
|
||||||
session=berserk.TokenSession(settings.LICHESS_API_KEY)
|
|
||||||
)
|
|
||||||
games = client.games.export_by_player(user.profile.lichess_username)
|
games = client.games.export_by_player(user.profile.lichess_username)
|
||||||
for game_dict in games:
|
for game_dict in games:
|
||||||
chess, created = BoardGame.objects.get_or_create(title="Chess")
|
chess, created = BoardGame.objects.get_or_create(title="Chess")
|
||||||
if created:
|
if created:
|
||||||
chess.run_time_seconds = 1800
|
chess.base_run_time_seconds = 1800
|
||||||
chess.bggeek_id = 171
|
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(
|
scrobble = Scrobble.objects.filter(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
timestamp=game_dict.get("createdAt"),
|
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", "")
|
white_player.get("aiLevel", "")
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
other_player["name_str"] = white_player.get("user", {}).get(
|
other_player["name_str"] = white_player.get("user", {}).get("name", "")
|
||||||
"name", ""
|
|
||||||
)
|
|
||||||
other_player["lichess_username"] = other_player["name_str"]
|
other_player["lichess_username"] = other_player["name_str"]
|
||||||
|
|
||||||
other_player["color"] = "white"
|
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", "")
|
black_player.get("aiLevel", "")
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
other_player["name_str"] = black_player.get("user", {}).get(
|
other_player["name_str"] = black_player.get("user", {}).get("name", "")
|
||||||
"name", ""
|
|
||||||
)
|
|
||||||
other_player["lichess_username"] = other_player["name_str"]
|
other_player["lichess_username"] = other_player["name_str"]
|
||||||
other_player["color"] = "black"
|
other_player["color"] = "black"
|
||||||
if winner == "white":
|
if winner == "white":
|
||||||
@ -109,6 +103,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
|
|||||||
"source": "Lichess",
|
"source": "Lichess",
|
||||||
"timezone": user.profile.timezone,
|
"timezone": user.profile.timezone,
|
||||||
"log": log_data,
|
"log": log_data,
|
||||||
|
"visibility": "private",
|
||||||
}
|
}
|
||||||
if commit:
|
if commit:
|
||||||
Scrobble.objects.create(**scrobble_dict)
|
Scrobble.objects.create(**scrobble_dict)
|
||||||
@ -119,6 +114,8 @@ def import_chess_games_for_all_users():
|
|||||||
scrobbles_to_create = []
|
scrobbles_to_create = []
|
||||||
for user in User.objects.filter(profile__lichess_username__isnull=False):
|
for user in User.objects.filter(profile__lichess_username__isnull=False):
|
||||||
scrobble_dict = import_chess_games_for_user_id(user.id)
|
scrobble_dict = import_chess_games_for_user_id(user.id)
|
||||||
|
if not scrobble_dict:
|
||||||
|
continue
|
||||||
scrobbles_to_create.append(Scrobble(**scrobble_dict))
|
scrobbles_to_create.append(Scrobble(**scrobble_dict))
|
||||||
|
|
||||||
if scrobbles_to_create:
|
if scrobbles_to_create:
|
||||||
|
|||||||
@ -1,13 +1,70 @@
|
|||||||
|
import datetime
|
||||||
|
from django.utils import timezone
|
||||||
from django.views import generic
|
from django.views import generic
|
||||||
from boardgames.models import BoardGame, BoardGamePublisher
|
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
|
||||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
from scrobbles.models import Scrobble
|
||||||
|
from scrobbles.views import (
|
||||||
|
ChartContextMixin,
|
||||||
|
ScrobbleableListView,
|
||||||
|
ScrobbleableDetailView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BoardGameListView(ScrobbleableListView):
|
class BoardGameListView(ScrobbleableListView):
|
||||||
model = BoardGame
|
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
|
model = BoardGame
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -21,20 +21,25 @@ class BookAdmin(admin.ModelAdmin):
|
|||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
"subtitle",
|
"author",
|
||||||
|
"issue_or_volume",
|
||||||
"isbn_13",
|
"isbn_13",
|
||||||
"first_publish_year",
|
"first_publish_year",
|
||||||
"pages",
|
"pages",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("authors",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
inlines = [
|
inlines = [
|
||||||
ScrobbleInline,
|
ScrobbleInline,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def issue_or_volume(self, obj):
|
||||||
|
return obj.subtitle
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Paper)
|
@admin.register(Paper)
|
||||||
class BookAdmin(admin.ModelAdmin):
|
class PaperAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
@ -43,6 +48,7 @@ class BookAdmin(admin.ModelAdmin):
|
|||||||
"first_publish_year",
|
"first_publish_year",
|
||||||
"pages",
|
"pages",
|
||||||
)
|
)
|
||||||
|
raw_id_fields = ("authors",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
inlines = [
|
inlines = [
|
||||||
|
|||||||
@ -1,142 +0,0 @@
|
|||||||
from enum import Enum
|
|
||||||
from typing import Optional
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
import requests
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
USER_AGENT = (
|
|
||||||
"Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
|
|
||||||
)
|
|
||||||
AMAZON_SEARCH_URL = "https://www.amazon.com/s?k={amazon_id}"
|
|
||||||
|
|
||||||
|
|
||||||
class AmazonAttribute(Enum):
|
|
||||||
SERIES = 0
|
|
||||||
PAGES = 1
|
|
||||||
LANGUAGE = 2
|
|
||||||
PUBLISHER = 3
|
|
||||||
PUB_DATE = 4
|
|
||||||
DIMENSIONS = 5
|
|
||||||
ISBN_10 = 6
|
|
||||||
ISBN_13 = 7
|
|
||||||
|
|
||||||
|
|
||||||
def strip_and_clean(text):
|
|
||||||
return text.strip("\n").rstrip().lstrip()
|
|
||||||
|
|
||||||
|
|
||||||
def get_rating_from_soup(soup) -> Optional[int]:
|
|
||||||
rating = None
|
|
||||||
try:
|
|
||||||
potential_rating = soup.find("div", class_="allmusic-rating")
|
|
||||||
if potential_rating:
|
|
||||||
rating = int(strip_and_clean(potential_rating.get_text()))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return rating
|
|
||||||
|
|
||||||
|
|
||||||
def get_review_from_soup(soup) -> str:
|
|
||||||
review = ""
|
|
||||||
try:
|
|
||||||
potential_text = soup.find("div", class_="text")
|
|
||||||
if potential_text:
|
|
||||||
review = strip_and_clean(potential_text.get_text())
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return review
|
|
||||||
|
|
||||||
|
|
||||||
def scrape_data_from_amazon(url) -> dict:
|
|
||||||
data_dict = {}
|
|
||||||
headers = {"User-Agent": USER_AGENT}
|
|
||||||
r = requests.get(url, headers=headers)
|
|
||||||
if r.status_code == 200:
|
|
||||||
soup = BeautifulSoup(r.text, "html.parser")
|
|
||||||
# TODO Fix this scraper
|
|
||||||
data_dict["rating"] = get_rating_from_soup(soup)
|
|
||||||
data_dict["review"] = get_review_from_soup(soup)
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
|
|
||||||
def get_amazon_product_dict(amazon_id: str) -> dict:
|
|
||||||
data_dict = {}
|
|
||||||
url = ""
|
|
||||||
|
|
||||||
search_url = AMAZON_SEARCH_URL.format(amazon_id=amazon_id)
|
|
||||||
headers = {
|
|
||||||
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
|
|
||||||
"accept-language": "en-GB,en;q=0.9",
|
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.get(search_url, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
logger.info(f"Bad http response from Amazon {response}")
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
|
||||||
results = soup.find("a", class_="a-link-normal")
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
logger.info(f"No search results for {amazon_id}")
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
product_url = "https://www.amazon.com" + str(results.get("href", ""))
|
|
||||||
|
|
||||||
data_dict = {}
|
|
||||||
response = requests.get(product_url, headers=headers)
|
|
||||||
|
|
||||||
if response.status_code != 200:
|
|
||||||
logger.info(f"Bad http response from Amazon {response}")
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "html.parser")
|
|
||||||
try:
|
|
||||||
data_dict["title"] = soup.findAll("span", class_="celwidget")[
|
|
||||||
1
|
|
||||||
].text.strip()
|
|
||||||
data_dict["cover_url"] = soup.find("img", class_="frontImage").get(
|
|
||||||
"src"
|
|
||||||
)
|
|
||||||
data_dict["summary"] = soup.findAll(
|
|
||||||
"div", class_="a-expander-content"
|
|
||||||
)[1].text
|
|
||||||
meta = soup.findAll("div", class_="rpi-attribute-value")
|
|
||||||
data_dict["isbn"] = meta[AmazonAttribute.ISBN_10.value].text.strip()
|
|
||||||
pages = meta[AmazonAttribute.PAGES.value].text
|
|
||||||
if "pages" in pages:
|
|
||||||
data_dict["pages"] = (
|
|
||||||
meta[AmazonAttribute.PAGES.value]
|
|
||||||
.text.split("pages")[0]
|
|
||||||
.strip()
|
|
||||||
)
|
|
||||||
except IndexError as e:
|
|
||||||
logger.error(
|
|
||||||
f"Amazon lookup is failing for this product {amazon_id}: {e}"
|
|
||||||
)
|
|
||||||
except AttributeError as e:
|
|
||||||
logger.error(
|
|
||||||
f"Amazon lookup is failing for this product {amazon_id}: {e}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return data_dict
|
|
||||||
|
|
||||||
|
|
||||||
def lookup_book_from_amazon(amazon_id: str) -> dict:
|
|
||||||
top = {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": top.get("title"),
|
|
||||||
"isbn": isbn,
|
|
||||||
"openlibrary_id": ol_id,
|
|
||||||
"goodreads_id": get_first("id_goodreads", top),
|
|
||||||
"first_publish_year": top.get("first_publish_year"),
|
|
||||||
"first_sentence": first_sentence,
|
|
||||||
"pages": top.get("number_of_pages_median", None),
|
|
||||||
"cover_url": COVER_URL.format(id=ol_id),
|
|
||||||
"ol_author_id": ol_author_id,
|
|
||||||
"subject_key_list": top.get("subject_key", []),
|
|
||||||
}
|
|
||||||
@ -1,19 +1,16 @@
|
|||||||
from rest_framework import permissions, viewsets
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
from books.api.serializers import (
|
from books.api import serializers
|
||||||
AuthorSerializer,
|
from books import models
|
||||||
BookSerializer,
|
|
||||||
)
|
|
||||||
from books.models import Author, Book
|
|
||||||
|
|
||||||
|
|
||||||
class AuthorViewSet(viewsets.ModelViewSet):
|
class AuthorViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Author.objects.all().order_by("-created")
|
queryset = models.Author.objects.all().order_by("-created")
|
||||||
serializer_class = AuthorSerializer
|
serializer_class = serializers.AuthorSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
class BookViewSet(viewsets.ModelViewSet):
|
class BookViewSet(viewsets.ModelViewSet):
|
||||||
queryset = Book.objects.all().order_by("-created")
|
queryset = models.Book.objects.all().order_by("-created")
|
||||||
serializer_class = BookSerializer
|
serializer_class = serializers.BookSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|||||||
@ -1,233 +0,0 @@
|
|||||||
"""
|
|
||||||
ComicVine API Information & Documentation:
|
|
||||||
https://comicvine.gamespot.com/api/
|
|
||||||
https://comicvine.gamespot.com/api/documentation
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ComicVineClient(object):
|
|
||||||
"""
|
|
||||||
Interacts with the ``search`` resource of the ComicVine API. Requires an
|
|
||||||
account on https://comicvine.gamespot.com/ in order to obtain an API key.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# All API requests made by this client will be made to this URL.
|
|
||||||
API_URL = "https://www.comicvine.com/api/search/"
|
|
||||||
|
|
||||||
# A valid User-Agent header must be set in order for our API requests to
|
|
||||||
# be accepted, otherwise our request will be rejected with a
|
|
||||||
# **403 - Forbidden** error.
|
|
||||||
HEADERS = {
|
|
||||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:7.0) "
|
|
||||||
"Gecko/20130825 Firefox/36.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
# A set of valid resource types to return in results.
|
|
||||||
RESOURCE_TYPES = {
|
|
||||||
"character",
|
|
||||||
"issue",
|
|
||||||
"location",
|
|
||||||
"object",
|
|
||||||
"person",
|
|
||||||
"publisher",
|
|
||||||
"story_arc",
|
|
||||||
"team",
|
|
||||||
"volume",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, api_key, expire_after=300):
|
|
||||||
"""
|
|
||||||
Store the API key in a class variable, and install the requests cache,
|
|
||||||
configuring it using the ``expire_after`` parameter.
|
|
||||||
|
|
||||||
:param api_key: Your personal ComicVine API key.
|
|
||||||
:type api_key: str
|
|
||||||
:param expire_after: The number of seconds to retain an entry in cache.
|
|
||||||
:type expire_after: int or None
|
|
||||||
"""
|
|
||||||
|
|
||||||
self.api_key = api_key
|
|
||||||
|
|
||||||
def search(self, query, offset=0, limit=10, resources=None):
|
|
||||||
"""
|
|
||||||
Perform a search against the API, using the provided query term. If
|
|
||||||
required, a list of resource types to filter search results to can
|
|
||||||
be included.
|
|
||||||
|
|
||||||
Take the JSON contained in the response and provide it to the custom
|
|
||||||
``Response`` object's constructor. Return the ``Response`` object.
|
|
||||||
|
|
||||||
:param query: The search query with which to make the request.
|
|
||||||
:type query: str
|
|
||||||
:param offset: The index of the first record returned.
|
|
||||||
:type offset: int or None
|
|
||||||
:param limit: How many records to return **(max 10)**
|
|
||||||
:type limit: int or None
|
|
||||||
:param resources: A list of resources to include in the search results.
|
|
||||||
:type resources: list or None
|
|
||||||
:type use_cache: bool
|
|
||||||
|
|
||||||
:return: The response object containing the results of the search
|
|
||||||
query.
|
|
||||||
:rtype: comicvine_search.response.Response
|
|
||||||
"""
|
|
||||||
|
|
||||||
params = self._request_params(query, offset, limit, resources)
|
|
||||||
json_data = self._query_api(params)
|
|
||||||
|
|
||||||
return json_data
|
|
||||||
|
|
||||||
def _request_params(self, query, offset, limit, resources):
|
|
||||||
"""
|
|
||||||
Construct a dict containing the required key-value pairs of parameters
|
|
||||||
required in order to make the API request.
|
|
||||||
|
|
||||||
The documentation for the ``search`` resource can be found at
|
|
||||||
https://comicvine.gamespot.com/api/documentation#toc-0-30.
|
|
||||||
|
|
||||||
Regarding 'limit', as per the documentation:
|
|
||||||
|
|
||||||
The number of results to display per page. This value defaults to
|
|
||||||
10 and can not exceed this number.
|
|
||||||
|
|
||||||
:param query: The search query with which to make the request.
|
|
||||||
:type query: str
|
|
||||||
:param offset: The index of the first record returned.
|
|
||||||
:type offset: int
|
|
||||||
:param limit: How many records to return **(max 10)**
|
|
||||||
:type limit: int
|
|
||||||
:param resources: A list of resources to include in the search results.
|
|
||||||
:type resources: list or None
|
|
||||||
|
|
||||||
:return: A dictionary of request parameters.
|
|
||||||
:rtype: dict
|
|
||||||
"""
|
|
||||||
|
|
||||||
return {
|
|
||||||
"api_key": self.api_key,
|
|
||||||
"format": "json",
|
|
||||||
"limit": min(10, limit), # hard limit of 10
|
|
||||||
"offset": max(0, offset), # cannot provide negative offset
|
|
||||||
"query": query,
|
|
||||||
"resources": self._validate_resources(resources),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _validate_resources(self, resources):
|
|
||||||
"""
|
|
||||||
Provided a list of resources, first convert it to a set and perform an
|
|
||||||
intersection with the set of valid resource types, ``RESOURCE_TYPES``.
|
|
||||||
Return a comma-separted string of the remaining valid resources, or
|
|
||||||
None if the set is empty.
|
|
||||||
|
|
||||||
:param resources: A list of resources to include in the search results.
|
|
||||||
:type resources: list or None
|
|
||||||
|
|
||||||
:return: A comma-separated string of valid resources.
|
|
||||||
:rtype: str or None
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not resources:
|
|
||||||
return None
|
|
||||||
|
|
||||||
valid_resources = self.RESOURCE_TYPES & set(resources)
|
|
||||||
return ",".join(valid_resources) if valid_resources else None
|
|
||||||
|
|
||||||
def _query_api(self, params):
|
|
||||||
"""
|
|
||||||
Query the ComicVine API's ``search`` resource, providing the required
|
|
||||||
headers and parameters with the request. Optionally allow the caller
|
|
||||||
of the function to disable the request cache.
|
|
||||||
|
|
||||||
If an error occurs during the request, handle it accordingly. Upon
|
|
||||||
success, return the JSON from the response.
|
|
||||||
|
|
||||||
:param params: Parameters to include with the request.
|
|
||||||
:type params: dict
|
|
||||||
:param use_cache: Toggle the use of requests_cache.
|
|
||||||
:type use_cache: bool
|
|
||||||
|
|
||||||
:return: The JSON contained in the response.
|
|
||||||
:rtype: dict
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Since we're performing the identical action regardless of whether
|
|
||||||
# or not the request cache is to be used, store the procedure in a
|
|
||||||
# local function to avoid repetition.
|
|
||||||
def __httpget():
|
|
||||||
response = requests.get(
|
|
||||||
self.API_URL, headers=self.HEADERS, params=params
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response.ok:
|
|
||||||
self._handle_http_error(response)
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
return __httpget()
|
|
||||||
|
|
||||||
def _handle_http_error(self, response):
|
|
||||||
"""
|
|
||||||
Provided a ``requests.Response`` object, if the status code is
|
|
||||||
anything other than **200**, we will treat it as an error.
|
|
||||||
|
|
||||||
Using the response's status code, determine which type of exception to
|
|
||||||
raise. Construct an exception message from the response's status code
|
|
||||||
and reason properties before raising the exception.
|
|
||||||
|
|
||||||
:param response: The requests.Response object returned by the HTTP
|
|
||||||
request.
|
|
||||||
:type response: requests.Response
|
|
||||||
|
|
||||||
:raises ComicVineUnauthorizedException: if no API key provided.
|
|
||||||
:raises ComicVineForbiddenException: if no User-Agent header provided.
|
|
||||||
:raises ComicVineApiException: if an unidentified error occurs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
exception = {
|
|
||||||
401: Exception,
|
|
||||||
403: Exception,
|
|
||||||
}.get(response.status_code, Exception)
|
|
||||||
message = f"{response.status_code} {response.reason}"
|
|
||||||
|
|
||||||
raise exception(message)
|
|
||||||
|
|
||||||
|
|
||||||
def lookup_comic_from_comicvine(title: str) -> dict:
|
|
||||||
api_key = getattr(settings, "COMICVINE_API_KEY", "")
|
|
||||||
if not api_key:
|
|
||||||
logger.warn("No ComicVine API key configured, not looking anything up")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
client = ComicVineClient(
|
|
||||||
api_key=getattr(settings, "COMICVINE_API_KEY", None)
|
|
||||||
)
|
|
||||||
result = [
|
|
||||||
r
|
|
||||||
for r in client.search(title).get("results")
|
|
||||||
if r.get("resource_type") == "volume"
|
|
||||||
][0]
|
|
||||||
|
|
||||||
if "volume" not in result.keys():
|
|
||||||
logger.warn("No result found on ComicVine", extra={"title": title})
|
|
||||||
return {}
|
|
||||||
|
|
||||||
title = " ".join([result.get("volume").get("name"), result.get("name)")])
|
|
||||||
data_dict = {
|
|
||||||
"title": title,
|
|
||||||
"cover_url": result.get("image").get("original_url"),
|
|
||||||
"comicvine_data": {
|
|
||||||
"id": result.get("id"),
|
|
||||||
"site_detail_url": result.get("site_detail_url"),
|
|
||||||
"description": result.get("description"),
|
|
||||||
"image": result.get("image").get("original_url"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return data_dict
|
|
||||||
@ -1,7 +1,24 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
BOOKS_TITLES_TO_IGNORE = [
|
BOOKS_TITLES_TO_IGNORE = [
|
||||||
"KOReader Quickstart Guide",
|
"KOReader Quickstart Guide",
|
||||||
"zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL",
|
"zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL",
|
||||||
"zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif",
|
"zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
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"
|
||||||
|
AMAZON = "source_amazon"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def choices(cls):
|
||||||
|
return [(tag.value, tag.name.replace("_", " ").title()) for tag in cls]
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from books.constants import BOOKS_TITLES_TO_IGNORE
|
from books.constants import BOOKS_TITLES_TO_IGNORE
|
||||||
@ -114,7 +113,7 @@ def create_book_from_row(row: list):
|
|||||||
"raw_row_data": clean_row,
|
"raw_row_data": clean_row,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
run_time_seconds=run_time,
|
base_run_time_seconds=run_time,
|
||||||
)
|
)
|
||||||
# TODO Move these to async processes after importing
|
# TODO Move these to async processes after importing
|
||||||
# book.fix_metadata()
|
# book.fix_metadata()
|
||||||
@ -139,7 +138,6 @@ def build_book_map(rows) -> dict:
|
|||||||
book_id_map = {}
|
book_id_map = {}
|
||||||
|
|
||||||
for book_row in rows:
|
for book_row in rows:
|
||||||
|
|
||||||
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
|
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
|
||||||
logger.info(
|
logger.info(
|
||||||
"[build_book_map] Ignoring book title that is likely garbage",
|
"[build_book_map] Ignoring book title that is likely garbage",
|
||||||
@ -147,9 +145,7 @@ def build_book_map(rows) -> dict:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
book = Book.objects.filter(
|
book = Book.objects.filter(
|
||||||
koreader_data_by_hash__icontains=book_row[
|
koreader_data_by_hash__icontains=book_row[KoReaderBookColumn.MD5.value]
|
||||||
KoReaderBookColumn.MD5.value
|
|
||||||
]
|
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not book:
|
if not book:
|
||||||
@ -177,7 +173,7 @@ def build_book_map(rows) -> dict:
|
|||||||
return book_id_map
|
return book_id_map
|
||||||
|
|
||||||
|
|
||||||
def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
def build_page_data(page_rows: list, book_map: dict) -> dict:
|
||||||
"""Given rows of page data from KoReader, parse each row and build
|
"""Given rows of page data from KoReader, parse each row and build
|
||||||
scrobbles for our user, loading the page data into the page_data
|
scrobbles for our user, loading the page data into the page_data
|
||||||
field on the scrobble instance.
|
field on the scrobble instance.
|
||||||
@ -190,28 +186,26 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
|||||||
book_ids_not_found.append(koreader_book_id)
|
book_ids_not_found.append(koreader_book_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if "pages" not in book_map[koreader_book_id].keys():
|
book_map[koreader_book_id].setdefault("pages", [])
|
||||||
book_map[koreader_book_id]["pages"] = {}
|
|
||||||
|
|
||||||
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
|
||||||
duration = page_row[KoReaderPageStatColumn.DURATION.value]
|
duration = page_row[KoReaderPageStatColumn.DURATION.value]
|
||||||
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
|
||||||
|
|
||||||
book_map[koreader_book_id]["pages"][page_number] = {
|
book_map[koreader_book_id]["pages"].append(
|
||||||
"duration": duration,
|
{
|
||||||
"start_ts": start_ts,
|
"page_number": page_number,
|
||||||
"end_ts": start_ts + duration,
|
"duration": duration,
|
||||||
}
|
"start_ts": start_ts,
|
||||||
if book_ids_not_found:
|
"end_ts": start_ts + duration,
|
||||||
logger.info(
|
}
|
||||||
f"Found pages for books not in file: {set(book_ids_not_found)}"
|
|
||||||
)
|
)
|
||||||
|
if book_ids_not_found:
|
||||||
|
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
|
||||||
return book_map
|
return book_map
|
||||||
|
|
||||||
|
|
||||||
def build_scrobbles_from_book_map(
|
def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobble"]:
|
||||||
book_map: dict, user: "User"
|
|
||||||
) -> list["Scrobble"]:
|
|
||||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
|
||||||
scrobbles_to_create = []
|
scrobbles_to_create = []
|
||||||
@ -223,7 +217,6 @@ def build_scrobbles_from_book_map(
|
|||||||
pages_not_found.append(book_id)
|
pages_not_found.append(book_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
should_create_scrobble = False
|
|
||||||
scrobble_page_data = {}
|
scrobble_page_data = {}
|
||||||
playback_position_seconds = 0
|
playback_position_seconds = 0
|
||||||
prev_page_stats = {}
|
prev_page_stats = {}
|
||||||
@ -232,68 +225,71 @@ def build_scrobbles_from_book_map(
|
|||||||
pages_processed = 0
|
pages_processed = 0
|
||||||
total_pages_read = len(book_map[koreader_book_id]["pages"])
|
total_pages_read = len(book_map[koreader_book_id]["pages"])
|
||||||
ordered_pages = sorted(
|
ordered_pages = sorted(
|
||||||
book_map[koreader_book_id]["pages"].items(),
|
book_map[koreader_book_id]["pages"],
|
||||||
key=lambda x: x[1]["start_ts"],
|
key=lambda x: x["start_ts"],
|
||||||
)
|
)
|
||||||
|
|
||||||
for cur_page_number, stats in ordered_pages:
|
for stats in ordered_pages:
|
||||||
|
page_number = stats["page_number"]
|
||||||
pages_processed += 1
|
pages_processed += 1
|
||||||
|
|
||||||
seconds_from_last_page = 0
|
seconds_from_last_page = 0
|
||||||
if prev_page_stats:
|
if prev_page_stats:
|
||||||
seconds_from_last_page = stats.get(
|
seconds_from_last_page = stats.get("end_ts") - prev_page_stats.get(
|
||||||
"end_ts"
|
"start_ts"
|
||||||
) - prev_page_stats.get("start_ts")
|
)
|
||||||
|
|
||||||
playback_position_seconds = playback_position_seconds + stats.get(
|
playback_position_seconds = playback_position_seconds + stats.get(
|
||||||
"duration"
|
"duration"
|
||||||
)
|
)
|
||||||
|
|
||||||
end_of_reading = pages_processed == total_pages_read
|
end_of_reading = pages_processed == total_pages_read
|
||||||
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
|
big_jump_to_this_page = (page_number - last_page_number) > 10
|
||||||
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
|
||||||
if (
|
should_create_scrobble = (
|
||||||
is_session_gap and not big_jump_to_this_page
|
is_session_gap and not big_jump_to_this_page
|
||||||
) or end_of_reading:
|
) or end_of_reading
|
||||||
should_create_scrobble = True
|
|
||||||
|
# Always accumulate the current page first
|
||||||
|
scrobble_page_data[page_number] = stats
|
||||||
|
|
||||||
if should_create_scrobble:
|
if should_create_scrobble:
|
||||||
|
# For end-of-reading, the current page is already accumulated
|
||||||
|
# and belongs in this scrobble. For a session gap, remove the
|
||||||
|
# current page from this scrobble — it starts a new session.
|
||||||
|
if is_session_gap and not end_of_reading:
|
||||||
|
del scrobble_page_data[page_number]
|
||||||
|
|
||||||
scrobble_page_data = dict(
|
scrobble_page_data = dict(
|
||||||
sorted(
|
sorted(
|
||||||
scrobble_page_data.items(),
|
scrobble_page_data.items(),
|
||||||
key=lambda x: x[1]["start_ts"],
|
key=lambda x: x[1]["start_ts"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
first_page = scrobble_page_data.get(
|
if not scrobble_page_data:
|
||||||
list(scrobble_page_data.keys())[0]
|
|
||||||
)
|
|
||||||
last_page = scrobble_page_data.get(
|
|
||||||
list(scrobble_page_data.keys())[-1]
|
|
||||||
)
|
|
||||||
except IndexError:
|
|
||||||
logger.error(
|
logger.error(
|
||||||
"Could not process book, no page data found",
|
"Could not process book, no page data found",
|
||||||
extra={"scrobble_page_data": scrobble_page_data},
|
extra={"scrobble_page_data": scrobble_page_data},
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
first_page = next(iter(scrobble_page_data.values()))
|
||||||
|
last_page = scrobble_page_data[
|
||||||
|
list(scrobble_page_data.keys())[-1]
|
||||||
|
]
|
||||||
|
|
||||||
timestamp = user.profile.get_timestamp_with_tz(
|
timestamp = user.profile.get_timestamp_with_tz(
|
||||||
datetime.fromtimestamp(int(first_page.get("start_ts")))
|
datetime.fromtimestamp(
|
||||||
|
int(first_page.get("start_ts"))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
stop_timestamp = user.profile.get_timestamp_with_tz(
|
stop_timestamp = user.profile.get_timestamp_with_tz(
|
||||||
datetime.fromtimestamp(int(last_page.get("end_ts")))
|
datetime.fromtimestamp(
|
||||||
|
int(last_page.get("end_ts"))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Adjust for Daylight Saving Time
|
|
||||||
if timestamp.dst() == timedelta(
|
|
||||||
0
|
|
||||||
) or stop_timestamp.dst() == timedelta(0):
|
|
||||||
timestamp = timestamp - timedelta(hours=1)
|
|
||||||
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
|
||||||
else:
|
|
||||||
print("In DST! ", timestamp)
|
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(
|
scrobble = Scrobble.objects.filter(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
book_id=book_id,
|
book_id=book_id,
|
||||||
@ -302,13 +298,17 @@ def build_scrobbles_from_book_map(
|
|||||||
|
|
||||||
if not scrobble:
|
if not scrobble:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Queueing scrobble for {book_id}, page {cur_page_number}"
|
f"Queueing scrobble for {book_id}, page {page_number}"
|
||||||
)
|
)
|
||||||
log_data = {
|
log_data = {
|
||||||
"koreader_hash": book_dict.get("hash"),
|
"koreader_hash": book_dict.get("hash"),
|
||||||
"page_data": scrobble_page_data,
|
"page_data": scrobble_page_data,
|
||||||
"pages_read": len(scrobble_page_data.keys()),
|
"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(
|
scrobbles_to_create.append(
|
||||||
Scrobble(
|
Scrobble(
|
||||||
book_id=book_id,
|
book_id=book_id,
|
||||||
@ -322,19 +322,21 @@ def build_scrobbles_from_book_map(
|
|||||||
in_progress=False,
|
in_progress=False,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
long_play_complete=False,
|
long_play_complete=False,
|
||||||
timezone=timestamp.tzinfo.name,
|
timezone=tz,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Then start over
|
# Then start over for the next session
|
||||||
should_create_scrobble = False
|
|
||||||
playback_position_seconds = 0
|
playback_position_seconds = 0
|
||||||
scrobble_page_data = {}
|
scrobble_page_data = {}
|
||||||
|
|
||||||
# We accumulate pages for the scrobble until we should create a new one
|
# For session gaps, re-add the current page as the
|
||||||
scrobble_page_data[cur_page_number] = stats
|
# beginning of the next session's accumulation
|
||||||
|
if is_session_gap and not end_of_reading:
|
||||||
|
scrobble_page_data[page_number] = stats
|
||||||
|
|
||||||
last_page_number = cur_page_number
|
last_page_number = page_number
|
||||||
prev_page_stats = stats
|
prev_page_stats = stats
|
||||||
|
|
||||||
if pages_not_found:
|
if pages_not_found:
|
||||||
logger.info(f"Pages not found for books: {set(pages_not_found)}")
|
logger.info(f"Pages not found for books: {set(pages_not_found)}")
|
||||||
return scrobbles_to_create
|
return scrobbles_to_create
|
||||||
@ -342,13 +344,16 @@ def build_scrobbles_from_book_map(
|
|||||||
|
|
||||||
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
|
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
|
||||||
"""Given a list of scrobbles, update pages read, long play seconds and check
|
"""Given a list of scrobbles, update pages read, long play seconds and check
|
||||||
for media completion"""
|
for media completion.
|
||||||
|
|
||||||
|
Uses the long_play_last_scrobble FK chain to accumulate time.
|
||||||
|
Consider using the recompute_long_play_seconds management command instead.
|
||||||
|
"""
|
||||||
|
|
||||||
for scrobble in scrobbles:
|
for scrobble in scrobbles:
|
||||||
# But if there's a next scrobble, set pages read to their starting page
|
if scrobble.long_play_last_scrobble and not scrobble.long_play_last_scrobble.long_play_complete:
|
||||||
if scrobble.previous and not scrobble.previous.long_play_complete:
|
|
||||||
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
|
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
|
||||||
scrobble.previous.long_play_seconds or 0
|
scrobble.long_play_last_scrobble.long_play_seconds or 0
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
scrobble.long_play_seconds = scrobble.playback_position_seconds
|
||||||
@ -364,9 +369,6 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
|||||||
|
|
||||||
new_scrobbles = []
|
new_scrobbles = []
|
||||||
user = User.objects.filter(id=user_id).first()
|
user = User.objects.filter(id=user_id).first()
|
||||||
tz = ZoneInfo("UTC")
|
|
||||||
if user:
|
|
||||||
tz = user.profile.tzinfo
|
|
||||||
|
|
||||||
is_os_file = "https://" not in file_path
|
is_os_file = "https://" not in file_path
|
||||||
if is_os_file:
|
if is_os_file:
|
||||||
@ -380,11 +382,8 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
|||||||
return new_scrobbles
|
return new_scrobbles
|
||||||
|
|
||||||
book_map = build_page_data(
|
book_map = build_page_data(
|
||||||
cur.execute(
|
cur.execute("SELECT * from page_stat_data ORDER BY id_book, start_time"),
|
||||||
"SELECT * from page_stat_data ORDER BY id_book, start_time"
|
|
||||||
),
|
|
||||||
book_map,
|
book_map,
|
||||||
tz,
|
|
||||||
)
|
)
|
||||||
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||||
else:
|
else:
|
||||||
@ -401,7 +400,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
|||||||
_sqlite_bytes(file_path), max_buffer_size=1_048_576
|
_sqlite_bytes(file_path), max_buffer_size=1_048_576
|
||||||
):
|
):
|
||||||
if table_name == "page_stat_data":
|
if table_name == "page_stat_data":
|
||||||
book_map = build_page_data(rows, book_map, tz)
|
book_map = build_page_data(rows, book_map)
|
||||||
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
|
||||||
|
|
||||||
logger.info(f"Creating {len(new_scrobbles)} new scrobbles")
|
logger.info(f"Creating {len(new_scrobbles)} new scrobbles")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user