Compare commits
1606 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| c8926cf887 | |||
| b8dd3ee258 | |||
| dc965687c2 | |||
| ebc66bbf64 | |||
| d04db0ecb5 | |||
| fc72b23b11 | |||
| a681b4d63b | |||
| c452ac24e0 | |||
| ae889bff7d | |||
| 99dc86dc27 | |||
| 8eefcb8290 | |||
| ad0f9a54d0 | |||
| 1531b77b5c | |||
| 9437fdba60 | |||
| a7551ef162 | |||
| c20204a6ea | |||
| 685de842ea | |||
| 7d13967708 | |||
| 109697a746 | |||
| dde28f4aff | |||
| 2f6ed3770f | |||
| e3d1cfb838 | |||
| 1821ac0d7b | |||
| 4eb8289e55 | |||
| 66e805542c | |||
| f91b127a2c | |||
| b2077678e2 | |||
| 5427198185 | |||
| 2bdba14cd6 | |||
| 95d8c4e4d6 | |||
| 6ab7745151 | |||
| 8b062a6c1d | |||
| cd48e7a402 | |||
| 22830b0cea | |||
| fd36034f6d | |||
| edf9fbd9c1 | |||
| e8e989bb63 | |||
| 69401d11c8 | |||
| 759caef45d | |||
| 9514861b32 | |||
| aa644aa9cf | |||
| 94820b1d9c | |||
| 4db8793d5c | |||
| 7c6e895ae4 | |||
| b1b67528bf | |||
| dd54a33159 | |||
| 92c4f91e5a | |||
| 838b19e996 | |||
| 3808277025 | |||
| f64863f2bc | |||
| 2c199c0e93 | |||
| 4924ef316f | |||
| 64cb17e91f | |||
| 1fd325823b | |||
| 1590ce5f18 | |||
| 3548c29f97 | |||
| 0fa831fa42 | |||
| a2f64a98c3 | |||
| 872ca17432 | |||
| 224c165d72 | |||
| bf7d2514f2 | |||
| 4e37bc5ab9 | |||
| 125da84f4e | |||
| 36ceb4c7fe | |||
| 88a3831975 | |||
| 63361964ca | |||
| 40b54b27f4 | |||
| a7eca4b9a7 | |||
| d152412e99 | |||
| 3ba6c6b6e4 | |||
| bffbf47c2f | |||
| f4e81da533 | |||
| 4b7f5459be | |||
| c68b0e9d7e | |||
| 32ec65116b | |||
| da8d26fcd9 | |||
| d33954e494 | |||
| 1b306d6493 | |||
| c881143e1b | |||
| 141700fcb3 | |||
| 7357b5bfec | |||
| 99cabd0007 | |||
| cf77e12cc3 | |||
| 3f2cbbb34a | |||
| 650ecf12c6 | |||
| d7cc009d07 | |||
| a872cf3611 | |||
| 1f9713312b | |||
| 159e555d7c | |||
| 981f4f9c9a | |||
| ddd5ce1392 | |||
| 7a75b31b56 | |||
| 24ac545f55 | |||
| d5da8ae701 | |||
| 53a04d064d | |||
| c0871a3b9e | |||
| d917dd8b2c | |||
| 6fc8084f2d | |||
| 41d6fe8ff6 | |||
| 73838312cd | |||
| eb2dd4c839 | |||
| 1a0c4f69f0 | |||
| 356e579558 | |||
| e980e3c5c9 | |||
| 8773542099 | |||
| 70378c9968 | |||
| c871087496 | |||
| 059d7780a0 | |||
| db36329011 | |||
| 69aa80e6c1 | |||
| 99a6e5107b | |||
| 39e2fdce27 | |||
| b2f98d780b | |||
| 07b5dc6a2c | |||
| 7207ca385e | |||
| 2a254e28d0 | |||
| d4377e49ac | |||
| 9dc0a818ff | |||
| 5e672fc9ed | |||
| 6d194d227e | |||
| ed217cbad2 | |||
| 3cca30dc70 | |||
| d3a15d5e7b | |||
| deeaa0af4b | |||
| 159459e1b9 | |||
| 89b6d8de06 | |||
| 3c725de2ac | |||
| dd71bdd38c | |||
| d066a98282 | |||
| 73bc4a1cd1 | |||
| 79d58e6390 | |||
| 69e9caf477 | |||
| 776e839ca4 | |||
| 97792898df | |||
| 9986163d20 | |||
| d1229585a1 | |||
| b8c5c3f3e9 | |||
| 6d9e237f9b | |||
| e65a2d300d | |||
| f45541de6d | |||
| 391f0cc335 | |||
| 4b5281bdd8 | |||
| 484be0a64e | |||
| 257e6899d3 | |||
| 4767cc7e52 | |||
| bcc3f46806 | |||
| 6c461ed55f | |||
| 28cf57c6dd | |||
| 0bb874f1db | |||
| 27f50baf5d | |||
| 499c3d6859 | |||
| b0e9f13e11 | |||
| b2ee79b3ea | |||
| 3ddd3b1684 | |||
| 4f8a359ab9 | |||
| 48114aee5e | |||
| 0cc87a2dbe | |||
| 23d3e19db9 | |||
| 29f5e2b940 | |||
| 3208a32ffe | |||
| 99da9b62bf | |||
| 36048d9a0a | |||
| 16091c9053 | |||
| eec00ce658 | |||
| e02010e409 | |||
| 498712e531 | |||
| 14e4432495 | |||
| 676c40176c | |||
| 0a50bca622 | |||
| ac9fc315b1 | |||
| 50d1a4a2bd | |||
| 444562235f | |||
| 760575e41d | |||
| 42699f84d2 | |||
| b660e47bc2 | |||
| 06b4ba8bcc | |||
| 1f67207f81 | |||
| baa8dbee46 | |||
| 3f9cdcac65 | |||
| 71874510a4 | |||
| 8c600d6b4b | |||
| e95b6f50dc | |||
| 93c16d80ec | |||
| b03da9ab37 | |||
| 09ca05cb4f | |||
| d6e02d241c | |||
| 8dd94e2fc4 | |||
| 9e3f714c61 | |||
| e2b0decd83 | |||
| e08db5e3ad | |||
| 1460b9ba77 | |||
| a41e0ffa5d | |||
| c51c75b6a6 | |||
| 041435bc93 | |||
| 15f27b73a5 | |||
| 25900a9911 | |||
| 4277e355e0 | |||
| 855f59b83f | |||
| 9c115c0b65 | |||
| fd726a125f | |||
| 6685669b29 | |||
| 3fa43f02d0 | |||
| 66c34942e6 | |||
| ef1fcd4026 | |||
| a36efa3b1d | |||
| 300a2ae6aa | |||
| 1d5b91b6e2 | |||
| a5c67a7fe1 | |||
| b16d0b1864 | |||
| f90a3b84a8 | |||
| 25a14ed9e7 | |||
| 84790c805c | |||
| a9499f0463 | |||
| c109ed79eb | |||
| 89e5455b29 | |||
| 647762f201 | |||
| b788cc65d1 | |||
| 50ec82213c | |||
| 51c1acd677 | |||
| fd38046113 | |||
| d294f2ecd1 | |||
| 3c7940c6c6 | |||
| 56c000154c | |||
| 8157836b42 | |||
| af0e76b29c | |||
| 39004aac0c | |||
| 8cbd746681 | |||
| dee04a47cb | |||
| 1304a27408 | |||
| 2327b1f622 | |||
| 94fed8ae38 | |||
| 042a26f148 | |||
| e606f0de01 | |||
| ed2253cb6b | |||
| 9a1508b7a6 | |||
| 39f3a31847 | |||
| f0b32961c1 | |||
| a08574b359 | |||
| 26ebb4108d | |||
| 1b95706f70 | |||
| b65ebbe397 | |||
| 0679af3029 | |||
| 8888b42adf | |||
| b8d68739cf | |||
| 4c2b838d7b | |||
| cdb3c29844 | |||
| 22c07bdb82 | |||
| 66513c5758 | |||
| f3c0d20268 | |||
| 622a30899f | |||
| 2c1e8c08ae | |||
| cc52e00d15 | |||
| e762658082 | |||
| 5111cee14b | |||
| 68a6d58339 | |||
| b91c8b27d7 | |||
| 3a91aa5903 | |||
| bfd6331be3 | |||
| 38ba474c1f | |||
| 76272b7e39 | |||
| 1f0c950b17 | |||
| f2998205e1 | |||
| 38e108c1ae | |||
| dec100f8ff | |||
| e89eb332d3 | |||
| cb5b279300 | |||
| 59d681dc00 | |||
| 82dcad569a | |||
| d8aaf3bf55 | |||
| 29b92d89b2 | |||
| 86bcdef13d | |||
| 20e6ae7421 | |||
| 4ed5117900 | |||
| 3388471685 | |||
| 58a957c98a | |||
| 2ad626cd59 | |||
| 6ce0257dc0 | |||
| 43dbd3b28b | |||
| 7671644e87 | |||
| f555a49746 | |||
| 9fe474978a | |||
| 9f8465d364 | |||
| ddfddc33f5 | |||
| 0ec7ed3a18 | |||
| 0bda3f6fd8 | |||
| 59765b14ca | |||
| 08b48371bc | |||
| d3d5b088cd | |||
| 083e931a78 | |||
| 0f0fb7cceb | |||
| f2bbb7f5d0 | |||
| 218c68dee0 | |||
| 202cf24722 | |||
| 1ddacd4454 | |||
| 2dbb091d61 | |||
| dbaf189628 | |||
| 4a0bac5b87 | |||
| 59b7e3dada | |||
| 24223ebe13 | |||
| de6e9ce2d6 | |||
| 470eb0778a | |||
| f075492554 | |||
| 8fb2fac47f | |||
| b1eac1454b | |||
| 84737e0c3b | |||
| c4359a2331 | |||
| 8fa538dbee | |||
| 26d82518fa | |||
| 04a7ba51e4 | |||
| ccf14c51bf | |||
| b97aa8936e | |||
| 98924e362e | |||
| 0384f72cbd | |||
| 0c8a486b6a | |||
| 7954765b73 | |||
| 7604327ca9 | |||
| 20542ac7e9 | |||
| 34137af815 | |||
| 0f2570e51b | |||
| ed917e16fc | |||
| 164510b7b7 | |||
| 6764023016 | |||
| 7c6c1cee6d | |||
| c251c5f413 | |||
| 342e86d7fb | |||
| 176b698f6e | |||
| ddf2ca5630 | |||
| d52061f6d8 | |||
| 3c0a75755b | |||
| 183469ebe5 | |||
| bbe8149e6c | |||
| f876caabe1 | |||
| 87c078f47d | |||
| 5a9292e10a | |||
| a5630022f5 | |||
| babc2aeb9d | |||
| 875b0f98a0 | |||
| 5d1edc71d7 | |||
| e4738e464f | |||
| 85c4963619 | |||
| 8d6707db95 | |||
| 0df3dd728d | |||
| 1fe8d8aa51 | |||
| 8f3c7beffa | |||
| aac0efbb14 | |||
| b0d4dd0899 | |||
| 16db67ea84 | |||
| 7a747268a1 | |||
| 2037ffc67a | |||
| 2136c1562a | |||
| eae169aff7 | |||
| 6c0bd2e409 | |||
| d69d7311d5 | |||
| c484dab210 | |||
| 3d62a6a227 | |||
| 1a5d4a6717 | |||
| 8f0491a90b | |||
| 7e961076b4 | |||
| 14062f3b60 | |||
| 0a8acdf33f | |||
| b5d194e74f | |||
| 3a50a8b015 | |||
| 921cf9d8b3 | |||
| ba0be65ed0 | |||
| caad6329c9 | |||
| cfd6ac861e | |||
| ca36e25948 | |||
| c84acf6ae7 | |||
| 610464e732 | |||
| 047dd22069 | |||
| 2c73121367 | |||
| 9affd6e03a | |||
| b414bbf59c | |||
| 5e22cb3106 | |||
| cc9a2a64df | |||
| c2b334b926 | |||
| ad6f0e1b62 | |||
| 1df8157d8d | |||
| 7d33e6afdb | |||
| 40d112c58c | |||
| cdfd8af078 | |||
| 0ce19527a2 | |||
| f3fc58e2c0 | |||
| b02b75fa90 | |||
| b626ac583a | |||
| adc10ab43b | |||
| 5555b1b89e | |||
| 7fd90f8cf0 | |||
| f80daba67b | |||
| 8bd4fd1d4b | |||
| f30e416e8d | |||
| f29a039562 | |||
| 882367cee6 | |||
| 74c32bc4d5 | |||
| dcd7196010 | |||
| 2e57b2ce07 | |||
| 9d664ba476 | |||
| 0fa89af1d9 | |||
| 2c5516ae4e | |||
| 0cba46b103 | |||
| 5014c4428b | |||
| 1d554429f1 | |||
| daea268465 | |||
| 1cceb2bd63 | |||
| e15d253d58 | |||
| 8ed0bd3d21 | |||
| 113f200eb7 | |||
| 1b5ffd2a3c | |||
| f168608cee | |||
| c28f93a1bb | |||
| 99ad2f797f | |||
| 1f28179ed1 | |||
| 1648f988ff | |||
| dc3a77f14f | |||
| 5994dccf23 | |||
| bfadc0d87b | |||
| 78782cc538 | |||
| 06277f21de | |||
| 8fbe37a163 | |||
| 20dd4d217a | |||
| 53657a9454 | |||
| 42066cebb2 | |||
| 6d60a729b7 | |||
| 89541a13f2 | |||
| f87bc5fd55 | |||
| 0a8078dee0 | |||
| 37da74708c | |||
| b470e7acea | |||
| 5cdd12783f | |||
| 8bf43f298c | |||
| a269949a23 | |||
| 8a5f200b44 | |||
| 1c41ca3e18 | |||
| b69963b75f | |||
| 1327d5da40 | |||
| 5d5b49f19b | |||
| d93c827b80 | |||
| 2349c39487 | |||
| ae96831fe4 | |||
| 706be782dc | |||
| a72e0b0fb9 | |||
| f5df6c97a9 | |||
| e00f2de4b1 | |||
| b4a7cafa3d | |||
| aebe4f899d | |||
| 2bf0ca1a8c | |||
| a0c414135c | |||
| 1dcd151c65 | |||
| 2e9d92d9c7 | |||
| 8776f75d12 | |||
| c2a53875a0 | |||
| ffb0b7372d | |||
| d03744c240 | |||
| d9a7929cb0 | |||
| 9bc087d824 | |||
| e65ab4d8c2 | |||
| 2d26a7c3bd | |||
| 1fd3e47656 | |||
| dd0ce967c8 | |||
| 1f6a4956d2 | |||
| 22f5d288d4 | |||
| 77cf67ea09 | |||
| d1dfc5502d | |||
| 0d83050956 | |||
| 134bd3f650 | |||
| 261f2eb9ce | |||
| 8f56bfba50 | |||
| 750fa05b15 | |||
| 302ddf6650 | |||
| 49dd148f9f | |||
| 79d4e79f3e | |||
| 874d9dc7d2 | |||
| 0cd95036da | |||
| 829c6b9978 | |||
| df1bfd4177 | |||
| 0fff2ec388 | |||
| 8a83c9fdb3 | |||
| 651fc7a745 | |||
| c22a306840 | |||
| 11e6161502 | |||
| 3af2ad203c | |||
| 7697dd006d | |||
| 73a6277e29 | |||
| 79fc407c33 | |||
| 84ec84c018 | |||
| beb9569663 | |||
| efcf01b49f | |||
| 01270a9e32 | |||
| c9280c2bad | |||
| 416fd807f4 | |||
| 3069e93697 | |||
| e47f4905e8 | |||
| bf59fb213e | |||
| 589bf533ea | |||
| 1fb29304a3 | |||
| 42604b9e23 | |||
| 4642063510 | |||
| 65944eb911 | |||
| 4cf2ceb2dd | |||
| 78151e5070 | |||
| f90fa160d7 | |||
| 3b7498c419 | |||
| a59997dafb | |||
| 4cdb6150dc | |||
| 7fa21c27ea | |||
| b981c090c6 | |||
| 605435b9ea | |||
| f4d00b4a22 | |||
| 504620fc89 | |||
| df3424c68f | |||
| 71b5af7615 | |||
| 7035f01441 | |||
| 525c7c4e2b | |||
| 4e330a6f03 | |||
| ff18ad8efc | |||
| 15c1317395 | |||
| 6b80d107b0 | |||
| 13124aca6b | |||
| a685c6ff9c | |||
| 02200bb1f7 | |||
| 0bbd488f0d | |||
| 2c08336e33 | |||
| a637a59a40 | |||
| 123f7c53f9 | |||
| 2c26cc01ba | |||
| 36bc1c2e95 | |||
| 5651ccb990 | |||
| cd7b06eaa2 | |||
| 949416059d | |||
| ca434cb08a | |||
| 2661aee915 | |||
| 74f672a2fd | |||
| 449b74ae3f | |||
| 3d1ad8da3a | |||
| 75e374bb69 | |||
| 2ba694655b | |||
| 879942d070 | |||
| bfd210d280 | |||
| e5c6b5e8d9 | |||
| 3cd1603b91 | |||
| 27523bc7ff | |||
| 67ef1a15ec | |||
| 288027bfce | |||
| bfdf73d4c0 | |||
| 033e3c3b35 | |||
| 40504f83e1 | |||
| ccca81bbab | |||
| 84f49af163 | |||
| e044c70072 | |||
| d3e09c25bd | |||
| 30f78a9290 | |||
| a5553966de | |||
| 9e2d7a6bc0 | |||
| 70c7eda415 | |||
| 0ad7dac6cb | |||
| 209875f0e6 | |||
| 919fa1b0b4 | |||
| 0b3bc53704 | |||
| dfc1365fa3 | |||
| f22ef1a163 | |||
| 7cb818b585 | |||
| 1a5cd5106f | |||
| 3b65144b68 | |||
| 4ae13b3a1a | |||
| 3de2be50cf | |||
| 80d197bc54 | |||
| a274796405 | |||
| 37fd1d8458 | |||
| 45ddce36aa | |||
| c3ddd01a6f | |||
| 6920c99931 | |||
| d2d71b3c85 | |||
| 6300df8e9e | |||
| b427731bc3 | |||
| 6058024434 | |||
| a4d9bd607c | |||
| 9349009ffa | |||
| 2293c839e8 | |||
| 34fb2df782 | |||
| 3b2158d85c | |||
| 7daef6677d | |||
| d895cf9110 | |||
| dffb4f087c | |||
| 37c1aab749 | |||
| 9083e744da | |||
| af1b6cb4ed | |||
| de8727ee05 | |||
| fd645420b8 | |||
| 1fdad9ce5b | |||
| 81551ecb41 | |||
| 7a5b4c1b08 | |||
| c144f07f78 | |||
| b44af1f79d | |||
| 22f3b94448 | |||
| 36adf5a904 | |||
| c2f43861fd | |||
| 356579f78a | |||
| e5ed265e8f | |||
| 97d5478792 | |||
| 96ca2f5602 | |||
| 478780e9b3 | |||
| 9aaed4f332 | |||
| 4327ff499c | |||
| b53017d226 | |||
| 4e694bd1b3 | |||
| 7a9f4a0876 | |||
| 431976296f | |||
| 1e84497c85 | |||
| 05458b1fb9 | |||
| 93a514fc37 | |||
| c4c810e23f | |||
| d1de4103e1 | |||
| d0b298e4cc | |||
| 91ed364747 | |||
| e6f78f4b1d | |||
| f452ed748e | |||
| 63ddb51e89 | |||
| 61c10db0dd | |||
| 6696d38638 | |||
| 84351f8bcc | |||
| eb1a22e69f | |||
| 34ecb71c44 | |||
| 33dfbc2c2e | |||
| 8eb2526404 | |||
| 8e99918813 | |||
| ea894e1ebf | |||
| edcfbd829a | |||
| 4e1db4aa6f | |||
| f303115ca0 | |||
| 5928a07ef0 | |||
| dea34c926b | |||
| 7564292f5b | |||
| 5aa155094f | |||
| 635e8053cd | |||
| 0d3da63aaf | |||
| 1670a7cd7a | |||
| 0505d01b71 | |||
| aae2dd7567 | |||
| 88c252eb45 | |||
| abd07f3113 | |||
| 9ba9319885 | |||
| 22ad340d43 | |||
| b0574ecf80 | |||
| e9db212121 | |||
| d5d0325420 | |||
| 1bc5cf31e0 | |||
| 2e2d491e2e | |||
| c7d1272e99 | |||
| 7a6bd169bf | |||
| bbaa8fa078 | |||
| 21995c62fb | |||
| 880f810aa1 | |||
| 49e4e9b69a | |||
| b50778b43f | |||
| da944f9ef1 | |||
| f6509bbaa8 | |||
| f9aab6296b | |||
| 899129dfd9 | |||
| 3eb51a871e | |||
| ca3e495467 | |||
| 85a91b340a | |||
| e04be4fbdd | |||
| f9a98e9de9 | |||
| f18d81296b | |||
| e86d8cc9fe | |||
| 4ab796fe12 | |||
| fc64dfadba | |||
| a5d729d26a | |||
| a661135394 | |||
| f1b1989424 | |||
| 145212fe46 | |||
| 607c2522f3 | |||
| 8646fd6881 | |||
| 63b783229c | |||
| b7e15da87a | |||
| 8a597ef1b0 | |||
| ca65ce11f2 | |||
| ad2f28c214 | |||
| e517327ce3 | |||
| ff5a88cb17 | |||
| 1fedf77b87 | |||
| 837aa53c9b | |||
| 9e38798f44 | |||
| 6d841167ea | |||
| 6e97298949 | |||
| 40fc4d21d3 | |||
| 9eeb708a51 | |||
| 35214de127 | |||
| 0adc97f440 | |||
| 0140126ed8 | |||
| f7b8bc3bfc | |||
| 7e16388f81 | |||
| 6dc09e723d | |||
| 1d50c32ba9 | |||
| 0bf4d28482 | |||
| bcf2b9d1ea | |||
| a3bf9b0081 | |||
| 1e35f10945 | |||
| 955b6f028a | |||
| cc751d0953 | |||
| a7e8e4f1dc | |||
| f55aed7b3d | |||
| 79265feb39 | |||
| 1ba5b4cf4b | |||
| 39add993e0 | |||
| 4fd8a5b9a5 | |||
| dad936cd6b | |||
| 704409cf6e | |||
| 6ee57cb7cd | |||
| 95da4c063e | |||
| 93de6d1556 | |||
| dbbb2b43a8 | |||
| 7758c56d10 | |||
| 6c56cfab85 | |||
| 6753c3717f | |||
| 085c666c19 | |||
| dca66003fd | |||
| dc55c538ab | |||
| bb69be4817 | |||
| af0223ab4c | |||
| 9ed5d5dc1a | |||
| dedf28c6de | |||
| 3607d4d4a4 | |||
| 6bdb7b6e1f | |||
| c864f408a1 | |||
| f1c22bfbc0 | |||
| 88bd049d95 | |||
| 8f4ce4441b | |||
| bee9ac8d25 | |||
| 61c9e362a8 | |||
| efde57078e | |||
| fc927c72d1 | |||
| 5c3a554010 | |||
| f21650505c | |||
| 0217c96faf | |||
| 4654902adc | |||
| 954b35b1d0 | |||
| 620f52f9ef | |||
| e495a08d1f | |||
| 74ce5ec9ab | |||
| fb107c083f | |||
| f9dcc0d341 | |||
| 9eef5d721b | |||
| 6d613028fc | |||
| 7db98f0979 | |||
| 4e38605008 | |||
| 7106a0840d | |||
| 1f7846f096 | |||
| 010565efb7 | |||
| 9baf1069b6 | |||
| 2896225826 | |||
| be6b3c5e2e | |||
| e487f50683 | |||
| 8042c726b0 | |||
| 59e29d858a | |||
| a59bcf054a | |||
| fe4faee7aa | |||
| 5db8bf0329 | |||
| d085bf2153 | |||
| a133e7a30c | |||
| ca59605afc | |||
| 597ac2c7b8 | |||
| f04f8b04c0 | |||
| 2215976571 | |||
| e6bb52702c | |||
| 0dc0102bb6 | |||
| 7d1e070ee6 | |||
| 6c060f24ec | |||
| b6a0f0d3fb | |||
| 1c6f28bae3 | |||
| 845ee7d4e9 | |||
| dadc5db0f9 | |||
| c4ddb4b51c | |||
| 76cc1f7b1c | |||
| c39430e987 | |||
| c00343abfe | |||
| 84070d2806 | |||
| 4a929956a7 | |||
| a7bf405af2 | |||
| 09f97c6eed | |||
| 1ee8fc589a | |||
| 3e2a9d2183 | |||
| cb0c00a695 | |||
| ee01ffa4ad | |||
| d19838a26f | |||
| c571043788 | |||
| f082bea571 | |||
| bcd35842cd | |||
| 5c9a877a9a | |||
| 9a2ba1fd07 | |||
| f3e90e4ad4 | |||
| a7605d9cc5 | |||
| 2c946c1071 | |||
| 1e17a679d3 | |||
| e6914ed079 | |||
| bf2d1f0c0a | |||
| 951554b6fc | |||
| 662578e941 | |||
| 524e6b0027 | |||
| ede4767a39 | |||
| d2d81f7119 | |||
| 1d95d59d8d | |||
| 70118e2e62 | |||
| 04a48af4c9 | |||
| 59d652a9c4 | |||
| 945776b885 | |||
| 98f9c4bc04 | |||
| 36eda9f258 | |||
| efd3acbc70 | |||
| 1ac0fc5b23 | |||
| fd23928922 | |||
| 2d50964971 | |||
| 8b21861867 | |||
| a0d67cbcd2 | |||
| 9f60411c5e | |||
| dd66774bda | |||
| 15be4e0068 | |||
| bc59ff66eb | |||
| b5d6bea0d1 | |||
| bbf3819e08 | |||
| 1b56933969 | |||
| f8628f7826 | |||
| 20b11359e0 | |||
| ecc26138a7 | |||
| 5ce48277ed | |||
| 447a4e830e | |||
| e8f1bcbe31 | |||
| 131fc379c3 | |||
| dd34e34970 | |||
| 23f1cb749e | |||
| 95507f640e | |||
| db32777f28 | |||
| a2135b5d55 | |||
| 87fcfbb7d9 | |||
| 92b4caa32f | |||
| 31f490a32b | |||
| 5b5b67d42a | |||
| c9874b3fda | |||
| c9b04772a0 | |||
| f9420c7a41 | |||
| fbe35a02a1 | |||
| 8122179c7a | |||
| f7a757c485 | |||
| 62850dd4f1 | |||
| 7d7ec4b676 | |||
| 6dee409a55 | |||
| 1242740258 | |||
| 6fb6093fe1 | |||
| 5fcc314fd0 | |||
| 856546b633 | |||
| b96683b3ad | |||
| aab403a782 | |||
| 8e3a2d251a | |||
| e386d160e2 | |||
| 4867acb30b | |||
| d071319df4 | |||
| c494779c82 | |||
| 52fc67803a | |||
| f504d9f2a1 | |||
| 34a6ac192d | |||
| 69bdc60087 | |||
| 9ac5ef8f59 | |||
| 95b625cec2 | |||
| f6c1a459d4 | |||
| e5acedbb01 | |||
| b01ceebbf3 | |||
| 43dc625e4a | |||
| 38f40e014a | |||
| bb2a80e2aa | |||
| 6e03cf5075 | |||
| fadc281fe8 | |||
| d79159670e | |||
| 489d8b9152 | |||
| 638a5d05bd | |||
| 6c880b3030 | |||
| 76b1816452 | |||
| 323e9ec8bf | |||
| 6ffc77a9d5 | |||
| e42ee0e03a | |||
| bb9259a82a | |||
| 6b02930a1a | |||
| aefdc507d8 | |||
| b307054453 | |||
| 960fe3e8d1 | |||
| 788e1ab9e9 | |||
| 73c72ef465 | |||
| 551e6f4f7e | |||
| a5f24cd5ec | |||
| 4d5e979a1a | |||
| 3fc716420c | |||
| 9bcd9d8bb7 | |||
| a4537879f9 | |||
| df62865eea | |||
| c757e743ac | |||
| a0e852775c | |||
| 353fb8d655 | |||
| d8edad98b2 | |||
| 9848d311c4 | |||
| 22c33d24c3 | |||
| 0a6774c284 | |||
| 25c00d7f1b | |||
| 7d7123498b | |||
| d0bb07df29 | |||
| 94f1396f2e | |||
| 3d7528030a | |||
| 9c881d3bd9 | |||
| 5c135b2d2e | |||
| 4c945932f9 | |||
| 90b7be286c | |||
| 00aa2e892f | |||
| 2811146656 | |||
| 34a2339b3b | |||
| 34abbe753b | |||
| 0fe00c3dd8 | |||
| 5a3eb7a8c8 | |||
| e63ca13d57 | |||
| b3d3098fe0 | |||
| 8f5a200526 | |||
| 411d2b42b0 | |||
| bce1322289 | |||
| 908819d24e | |||
| 6d21bb2e85 | |||
| 7df3fedc64 | |||
| b4e83b184e | |||
| 6e885df1dd | |||
| f153f831b3 | |||
| 66a90c87f1 | |||
| 6e17e4ce0d | |||
| 3c3e567573 | |||
| 2775851474 | |||
| 654a64e82d | |||
| 7dd7f369d8 | |||
| fb6110c71d | |||
| 93299a1abd | |||
| a58ddebd23 | |||
| 41cdb96e94 | |||
| 5a8e828b81 | |||
| c84a3072be | |||
| 0bd7ed4463 | |||
| ee232aa103 | |||
| 7151646600 | |||
| 1d7cf965ef | |||
| 0a9279dbd4 | |||
| bf3479dbc7 | |||
| a99dca246b | |||
| f76aaf6a9c | |||
| ce1541bb2d | |||
| d34e56aa89 | |||
| 6316d4bead | |||
| 56e5728245 | |||
| 6ff170e169 | |||
| 86d1cf0d65 | |||
| a0101bf1ae | |||
| 457afdc9ef | |||
| d5bf6440b0 | |||
| 803ed7d8d7 | |||
| 93c4dd3d3b | |||
| ab728de75f | |||
| 04b7214795 | |||
| 479fee6a5c | |||
| 40a126cf8b | |||
| 83c02aa00f | |||
| 0f44df2b9b | |||
| 16d1dcc125 | |||
| 927d0be1b8 | |||
| f6b9245b8b | |||
| 39e035b460 | |||
| cf9da39967 | |||
| 2e98850494 | |||
| 5d315b4834 | |||
| 6ef8238442 | |||
| f4a444354d | |||
| 0db5bbe36c | |||
| 69b6364f88 | |||
| 966aeefbdd | |||
| d944fdd0c0 | |||
| e345631e27 | |||
| 59d0108fe5 | |||
| 8d67b672f9 | |||
| 376650f937 | |||
| 485fbd63a3 | |||
| d3f059caab | |||
| bb9936af65 | |||
| 9568726bf3 | |||
| 4ae70ef1f1 | |||
| 21df4e0a77 | |||
| cc82504262 | |||
| c7b84b27b2 | |||
| 20528b576b | |||
| 817ad3f67f | |||
| b47ca53c5d | |||
| 7a7c1caecc | |||
| 87f068dccd | |||
| 31907ed1b2 | |||
| 36d7950859 | |||
| 0e4501cad3 | |||
| 71e4ff28c8 | |||
| 9f272df99c | |||
| 8ba8ceefb8 | |||
| 9590cd0f60 | |||
| 5e7c8ff137 | |||
| fae59849f8 | |||
| 837e1280bd | |||
| 8f9c825903 | |||
| 541073aae3 | |||
| b63ec6b15f | |||
| 117157e3ae | |||
| 0c10e78d5e | |||
| 6b7359707b | |||
| e0295cbd56 | |||
| 5271cfaea4 | |||
| 0370b64351 | |||
| 9ec31ba0f5 | |||
| a9de298057 | |||
| 9d303b1b94 | |||
| 4c434aeb7c | |||
| 64d9cac09c | |||
| c21d6a96fe | |||
| e392477dc7 | |||
| 12087460f6 |
62
.drone.yml
62
.drone.yml
@ -4,25 +4,79 @@
|
||||
################
|
||||
|
||||
kind: pipeline
|
||||
name: run_tests
|
||||
name: build & deploy
|
||||
|
||||
steps:
|
||||
# Run tests against Python/Flask engine backend (with pytest)
|
||||
- name: pytest with coverage
|
||||
image: python:3.10.4
|
||||
image: python:3.11.1
|
||||
commands:
|
||||
# Install dependencies
|
||||
- cp vrobbler.conf.test vrobbler.conf
|
||||
- pip install poetry
|
||||
- poetry install
|
||||
- poetry install --with test
|
||||
# Start with a fresh database (which is already running as a service from Drone)
|
||||
- poetry run pytest --cov-report term:skip-covered --cov=vrobbler tests
|
||||
- poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
environment:
|
||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||
volumes:
|
||||
# Mount pip cache from host
|
||||
- name: pip_cache
|
||||
path: /root/.cache/pip
|
||||
- name: deploy
|
||||
image: appleboy/drone-ssh
|
||||
settings:
|
||||
host:
|
||||
- vrobbler.service
|
||||
username: root
|
||||
ssh_key:
|
||||
from_secret: jail_key
|
||||
command_timeout: 2m
|
||||
script:
|
||||
- pip uninstall -y vrobbler
|
||||
- pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
|
||||
- vrobbler migrate
|
||||
- vrobbler collectstatic --noinput
|
||||
- immortalctl restart celery && immortalctl restart vrobbler
|
||||
when:
|
||||
ref:
|
||||
- refs/tags/*
|
||||
- name: build success notification
|
||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||
when:
|
||||
status: [success]
|
||||
settings:
|
||||
url: https://ntfy.unbl.ink
|
||||
topic: drone
|
||||
priority: low
|
||||
tags:
|
||||
- success
|
||||
- vrobbler
|
||||
actions:
|
||||
- action: view
|
||||
label: Changes
|
||||
url: "{{ .Commit.Link }}"
|
||||
- action: view
|
||||
label: Build
|
||||
url: "{{ .Build.Link }}"
|
||||
- name: build failure notification
|
||||
image: parrazam/drone-ntfy:0.3-linux-amd64
|
||||
when:
|
||||
status: [failure]
|
||||
settings:
|
||||
url: https://ntfy.unbl.ink
|
||||
topic: drone
|
||||
priority: high
|
||||
tags:
|
||||
- failure
|
||||
- vrobbler
|
||||
actions:
|
||||
- action: view
|
||||
label: Changes
|
||||
url: "{{ .Commit.Link }}"
|
||||
- action: view
|
||||
label: Build
|
||||
url: "{{ .Build.Link }}"
|
||||
volumes:
|
||||
- name: docker
|
||||
host:
|
||||
|
||||
68
.gitea/workflows/build.yml
Normal file
68
.gitea/workflows/build.yml
Normal file
@ -0,0 +1,68 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
~/.cache/pypoetry
|
||||
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py311-
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
cp vrobbler.conf.test vrobbler.conf
|
||||
poetry install --with test
|
||||
|
||||
- name: Pytest with coverage
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
155
.gitea/workflows/deploy.yml
Normal file
155
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,155 @@
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
~/.cache/pypoetry
|
||||
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py311-
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
cp vrobbler.conf.test vrobbler.conf
|
||||
poetry install --with test
|
||||
|
||||
- name: Pytest with coverage
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
build-and-deploy:
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Write commit hash to build file
|
||||
run: |
|
||||
mkdir -p build_meta
|
||||
echo "${{ gitea.sha }}" | cut -c1-8 > build_meta/commit.txt
|
||||
|
||||
- name: Build package with commit info
|
||||
run: |
|
||||
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
|
||||
poetry build
|
||||
git checkout vrobbler/_commit.py
|
||||
|
||||
- name: Clean old wheels from server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
script: |
|
||||
rm -f /var/lib/vrobbler/dist/*.whl
|
||||
|
||||
- name: Copy wheel to server and deploy
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
source: "dist/*.whl"
|
||||
target: "/var/lib/vrobbler"
|
||||
|
||||
- name: Install wheel and restart services
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
command_timeout: 2m
|
||||
script: |
|
||||
set -e
|
||||
mkdir -p /var/lib/vrobbler
|
||||
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
||||
pip uninstall -y vrobbler
|
||||
pip install /var/lib/vrobbler/dist/*.whl
|
||||
rm -f /var/lib/vrobbler/dist/*.whl
|
||||
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
||||
vrobbler migrate
|
||||
vrobbler collectstatic --noinput
|
||||
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||
|
||||
- name: Notify deploy success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler deploy success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler,deploy" \
|
||||
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "🚀 Deploy succeeded: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify deploy failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler deploy failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler,deploy" \
|
||||
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "💥 Deploy failed: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
31
.github/workflows/django.yml
vendored
Normal file
31
.github/workflows/django.yml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
name: Django CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "develop" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
python-version: [3.9, 3.11, 3.12]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install poetry
|
||||
poetry install
|
||||
- name: Run Tests
|
||||
run: |
|
||||
pytest
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,5 +1,7 @@
|
||||
db.sqlite3
|
||||
db.sqlite3*
|
||||
vrobbler.conf
|
||||
media/
|
||||
dist/
|
||||
.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
|
||||
6
Makefile
Normal file
6
Makefile
Normal file
@ -0,0 +1,6 @@
|
||||
deploy:
|
||||
ssh vrobbler.service "pip uninstall vrobbler && pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
|
||||
logs:
|
||||
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
|
||||
test:
|
||||
pytest vrobbler
|
||||
2809
PROJECT.org
Normal file
2809
PROJECT.org
Normal file
File diff suppressed because it is too large
Load Diff
2
Procfile
Normal file
2
Procfile
Normal file
@ -0,0 +1,2 @@
|
||||
web: python manage.py runserver 0.0.0.0:8014
|
||||
worker: celery -A vrobbler worker -l DEBUG
|
||||
22
README.md
22
README.md
@ -1,7 +1,7 @@
|
||||
Vrobbler
|
||||
========
|
||||
|
||||
[](https://ci.unbl.ink/secstate/vrobbler)
|
||||
[](https://ci.lab.unbl.ink/secstate/vrobbler)
|
||||
|
||||
Vrobbler is a pretty simple Django-powered web app for scrobbling video plays from you favorite Jellyfin installation.
|
||||
|
||||
@ -21,3 +21,23 @@ VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
||||
VROBBLER_DATABASE_URL="postgres://vrobbler:<pass>@db.service:5432/vrobbler"
|
||||
VROBBLER_REDIS_URL="redis://:<pass>@cache.service:6379/0"
|
||||
```
|
||||
|
||||
## Database Backup
|
||||
|
||||
A backup command is available via `./manage.py backup_database` (also runs on a cron schedule via Celery). It dumps the database with `pg_dump`, compresses with gzip, and optionally copies the backup to a remote host via SCP.
|
||||
|
||||
Configure these additional settings as needed:
|
||||
|
||||
```
|
||||
VROBBLER_DB_BACKUP_SSH_KEY="/path/to/ssh/private/key"
|
||||
VROBBLER_DB_BACKUP_SSH_DEST="user@backup.example.com:/remote/path/"
|
||||
VROBBLER_DB_BACKUP_NTFY_URL="https://ntfy.sh/your-topic"
|
||||
```
|
||||
|
||||
- `VROBBLER_DB_BACKUP_SSH_KEY` — Path to the SSH private key used for remote copy.
|
||||
- `VROBBLER_DB_BACKUP_SSH_DEST` — SCP destination (user@host:path). If set, the backup is copied to the remote host and old backups are pruned.
|
||||
- `VROBBLER_DB_BACKUP_LOCAL_DIR` — Local directory for backup storage. Defaults to `/var/backups/`. Backups are stored in a `vrobbler/` subdirectory.
|
||||
- `VROBBLER_DB_BACKUP_NTFY_URL` — ntfy.sh URL for success notifications. Defaults to `https://ntfy.unbl.ink/backups`.
|
||||
|
||||
Retention is hardcoded: keeps daily backups for 7 days, plus one per month for 12 months.
|
||||
```
|
||||
|
||||
5
data/birding-example.csv
Normal file
5
data/birding-example.csv
Normal file
@ -0,0 +1,5 @@
|
||||
Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||
Canada Goose,6,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Common Loon,1,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Double-crested Cormorant,1,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
Boat-tailed Grackle,2,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,Sitting together on roof line of a house on Water Street. 20 meters away. Both birds were mostly black with green accents on the breast with long tails which they were repeatedly fanning out to show the V shape.
|
||||
|
1
data/moods.json
Normal file
1
data/moods.json
Normal file
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.
@ -1,4 +1,4 @@
|
||||
export ENV_PATH=$(poetry env info --path)
|
||||
source "${ENV_PATH}/bin/activate"
|
||||
|
||||
export PYPI_PASSWORD="$(pass personal/apikey/pypi)"
|
||||
#export PYPI_PASSWORD="$(pass personal/apikey/pypi)"
|
||||
|
||||
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
|
||||
|
||||
@ -6,7 +6,7 @@ import sys
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vrobbler.settings')
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "vrobbler.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
@ -18,5 +18,5 @@ def main():
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
8181
poetry.lock
generated
8181
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,22 +1,23 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.7.5"
|
||||
version = "55.2"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
python = ">=3.11,<3.15"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
python-dotenv = "^0.20.0"
|
||||
python-json-logger = "^2.0.2"
|
||||
colorlog = "^6.6.0"
|
||||
httpx = "<=0.27.2"
|
||||
djangorestframework = "^3.13.1"
|
||||
Markdown = "^3.3.6"
|
||||
django-filter = "^21.1"
|
||||
Pillow = "^9.0.1"
|
||||
psycopg2 = {version = "^2.9.3", extras = ["production"]}
|
||||
Pillow = "^10.0.0"
|
||||
psycopg2 = "2.9.10"
|
||||
dj-database-url = "^0.5.0"
|
||||
django-mathfilters = "^1.0.0"
|
||||
django-allauth = "^0.50.0"
|
||||
@ -26,15 +27,49 @@ django-taggit = "^2.1.0"
|
||||
django-markdownify = "^0.9.1"
|
||||
gunicorn = "^20.1.0"
|
||||
django-simple-history = "^3.1.1"
|
||||
whitenoise = "^6.3.0"
|
||||
musicbrainzngs = "^0.7.1"
|
||||
cinemagoer = "^2022.12.27"
|
||||
pysportsdb = "^0.1.0"
|
||||
django-cachalot = "^2.5.2"
|
||||
pytz = "^2022.7.1"
|
||||
django-redis = "^5.2.0"
|
||||
pylast = "^5.1.0"
|
||||
django-encrypted-field = "^1.0.5"
|
||||
celery = "^5.2.7"
|
||||
honcho = "^1.1.0"
|
||||
howlongtobeatpy = "^1.0.5"
|
||||
beautifulsoup4 = "^4.11.2"
|
||||
django-storages = "^1.13.2"
|
||||
stream-sqlite = "^0.0.41"
|
||||
ipython = "^8.14.0"
|
||||
pendulum = "^3"
|
||||
trafilatura = "^1.6.3"
|
||||
django-imagekit = "^5.0.0"
|
||||
thefuzz = "^0.22.1"
|
||||
dataclass-wizard = "^0.35.0"
|
||||
webdavclient3 = "^3.14.6"
|
||||
boto3 = "^1.35.37"
|
||||
urllib3 = "<2"
|
||||
django-oauth-toolkit = "^3.0.1"
|
||||
meta-yt = "^0.1.9"
|
||||
berserk = "^0.13.2"
|
||||
poetry-bumpversion = "^0.3.3"
|
||||
orgparse = "^0.4.20250520"
|
||||
tmdbv3api = "^1.9.0"
|
||||
themoviedb = "^1.0.2"
|
||||
feedparser = "^6.0.12"
|
||||
titlecase = "^2.4.1"
|
||||
bgg-api = "^1.1.13"
|
||||
recipe-scrapers = "^15.11.0"
|
||||
gpxpy = "^1.6.2"
|
||||
fitparse = "^1.2.0"
|
||||
lxml = ">=5.5.0"
|
||||
vaderSentiment = "^3.3.2"
|
||||
sqids = "^0.5.2"
|
||||
python-amazon-paapi = "^6.3.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
Werkzeug = "2.0.3"
|
||||
black = "^22.3"
|
||||
coverage = "^7.0.5"
|
||||
@ -43,24 +78,22 @@ pytest = "^7.1"
|
||||
pytest-black = "^0.3.12"
|
||||
pytest-cov = "^3.0"
|
||||
pytest-django = "^4.5.2"
|
||||
pytest-xdist= "^1.0.0"
|
||||
pytest-flake8 = "^1.1"
|
||||
pytest-isort = "^3.0"
|
||||
pytest-runner = "^6.0"
|
||||
pytest-selenium = "^2.0.1"
|
||||
time-machine = "^2.9.0"
|
||||
types-pytz = "^2022.1"
|
||||
types-requests = "^2.27"
|
||||
bandit = "^1.7.4"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "6.0"
|
||||
addopts = "-ra -q"
|
||||
addopts = "-ra -q --reuse-db --no-migrations"
|
||||
testpaths = ["tests"]
|
||||
DJANGO_SETTINGS_MODULE='vrobbler.settings'
|
||||
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
|
||||
|
||||
[tool.black]
|
||||
line-length = 79
|
||||
skip-string-normalization = true
|
||||
line-length = 88
|
||||
target-version = ["py39", "py310"]
|
||||
include = ".py$"
|
||||
exclude = "migrations"
|
||||
@ -77,6 +110,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
|
||||
[tool.poetry.scripts]
|
||||
vrobbler = "vrobbler.cli:main"
|
||||
|
||||
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
27
scripts/README.org
Normal file
27
scripts/README.org
Normal file
@ -0,0 +1,27 @@
|
||||
#+title: Readme
|
||||
|
||||
Scripts are a collection of helpful utility scripts, or simple gut-check tests for various functional pieces.
|
||||
|
||||
* test_recipe_scraper.py
|
||||
Asserts various urls by making actual calls out to the internet, while our test suite mocks return values.
|
||||
|
||||
#+begin_src shell
|
||||
python ../manage.py shell < ../scripts/test_recipe_scraper.py
|
||||
#+end_src
|
||||
|
||||
#+RESULTS:
|
||||
| Eagerly | running | all | tasks |
|
||||
| Connected | to | sqlite@db.sqlite3 | |
|
||||
| Checking: | https://cookingwithmike.com/quinoa-meatloaf/ | | |
|
||||
| Checking: | https://www.kingarthurbaking.com/recipes/overnight-sourdough-waffles-recipe | | |
|
||||
| Checking: | https://dirt.fyi/article/2026/02/25-years-of-ipod-brain?src=longreads | | |
|
||||
|
||||
* test_koreader_import.py
|
||||
Run through an actual koreader sqlite file and make sure imports work as expected
|
||||
|
||||
#+begin_src shell
|
||||
rm db.sqlite3
|
||||
cp ../db.sqlite3 .
|
||||
python ../manage.py shell < ../scripts/test_koreader_import.py
|
||||
#+end_src
|
||||
|
||||
BIN
scripts/koreader-test.sqlite3
Normal file
BIN
scripts/koreader-test.sqlite3
Normal file
Binary file not shown.
217
scripts/release.py
Executable file
217
scripts/release.py
Executable file
@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cut a new release: collect DONE items from Backlog into a new Version section.
|
||||
|
||||
Usage:
|
||||
poetry run python scripts/release.py major
|
||||
poetry run python scripts/release.py minor
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_FILE = Path("PROJECT.org")
|
||||
PYPROJECT_FILE = Path("pyproject.toml")
|
||||
|
||||
BACKLOG_RE = re.compile(r"^\* Backlog\s+\[(\d+)/(\d+)\](.*)$")
|
||||
VERSION_RE = re.compile(r"^\* Version\s+(\d+\.\d+)\s+\[\d+/\d+\]")
|
||||
DONE_HEADER_RE = re.compile(r"^(\*\* DONE\s+)(.*)$")
|
||||
ITEM_HEADER_RE = re.compile(r"^\*\* ")
|
||||
|
||||
|
||||
def parse_done_line(line):
|
||||
"""Extract a clean title from a ** DONE line, stripping priority and tags."""
|
||||
rest = line[8:].strip() # remove "** DONE "
|
||||
# strip priority marker like [#A]
|
||||
rest = re.sub(r"^\[#[A-C]\]\s+", "", rest, count=1)
|
||||
# strip org-mode tags at end (space-colon-tags)
|
||||
rest = re.sub(r"\s+:\S.*:\s*$", "", rest)
|
||||
return rest
|
||||
|
||||
|
||||
def bump_version(current_major, current_minor, kind):
|
||||
if kind == "major":
|
||||
return current_major + 1, 0
|
||||
elif kind == "minor":
|
||||
return current_major, current_minor + 1
|
||||
else:
|
||||
raise ValueError(f"Unknown bump kind: {kind}")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("major", "minor"):
|
||||
print(f"Usage: {sys.argv[0]} <major|minor>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
kind = sys.argv[1]
|
||||
|
||||
lines = PROJECT_FILE.read_text().splitlines(keepends=True)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Identify top-level sections
|
||||
# ---------------------------------------------------------------
|
||||
section_starts = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("* ") and not line.startswith("** "):
|
||||
section_starts.append(i)
|
||||
section_starts.append(len(lines))
|
||||
|
||||
backlog_idx = None
|
||||
version_idx = None
|
||||
|
||||
for idx, start in enumerate(section_starts[:-1]):
|
||||
header = lines[start].strip()
|
||||
if header.startswith("* Backlog"):
|
||||
backlog_idx = idx
|
||||
if header.startswith("* Version"):
|
||||
version_idx = idx # last occurrence wins
|
||||
|
||||
if backlog_idx is None:
|
||||
print("ERROR: no Backlog section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if version_idx is None:
|
||||
print("ERROR: no Version section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
backlog_start = section_starts[backlog_idx]
|
||||
backlog_end = section_starts[backlog_idx + 1]
|
||||
|
||||
# Find the newest Version section (first after Backlog) that matches
|
||||
# our expected format (e.g. "37.0" not "0.11.4").
|
||||
version_start = None
|
||||
for idx in range(backlog_idx + 1, version_idx + 1):
|
||||
header = lines[section_starts[idx]].strip()
|
||||
if VERSION_RE.match(header):
|
||||
version_start = section_starts[idx]
|
||||
break
|
||||
|
||||
if version_start is None:
|
||||
print("ERROR: no parseable Version header found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
version_header = lines[version_start].strip()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Parse current version from the newest * Version header
|
||||
# ---------------------------------------------------------------
|
||||
vm = VERSION_RE.match(version_header)
|
||||
current_version = vm.group(1)
|
||||
major_str, minor_str = current_version.split(".")
|
||||
current_major = int(major_str)
|
||||
current_minor = int(minor_str)
|
||||
new_major, new_minor = bump_version(current_major, current_minor, kind)
|
||||
new_version = f"{new_major}.{new_minor}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Collect ** DONE items from the Backlog section
|
||||
# ---------------------------------------------------------------
|
||||
backlog_lines = lines[backlog_start:backlog_end]
|
||||
|
||||
# Split Backlog into items at each ** line (skip the section header)
|
||||
items = [] # list of (start_idx, end_idx, is_done)
|
||||
item_start = None
|
||||
for i in range(1, len(backlog_lines)):
|
||||
if ITEM_HEADER_RE.match(backlog_lines[i]):
|
||||
if item_start is not None:
|
||||
items.append((item_start, i, backlog_lines[item_start].startswith("** DONE")))
|
||||
item_start = i
|
||||
if item_start is not None:
|
||||
items.append((item_start, len(backlog_lines), backlog_lines[item_start].startswith("** DONE")))
|
||||
|
||||
done_items = [(s, e) for s, e, is_done in items if is_done]
|
||||
kept_items = [(s, e) for s, e, is_done in items if not is_done]
|
||||
|
||||
if not done_items:
|
||||
print("No DONE items found in Backlog — nothing to release.")
|
||||
sys.exit(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
|
||||
31
tests/boardgames_tests/test_bgg.py
Normal file
31
tests/boardgames_tests/test_bgg.py
Normal file
@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
from boardgames.bgg import (
|
||||
take_first,
|
||||
lookup_boardgame_id_from_bgg,
|
||||
lookup_boardgame_from_bgg,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_take_first():
|
||||
assert take_first([]) == ""
|
||||
|
||||
assert take_first(["a", "b"]) == "a"
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_lookup_boardgame_id_from_bgg():
|
||||
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
|
||||
assert bgg_id == "15"
|
||||
|
||||
bgg_id = lookup_boardgame_id_from_bgg("Comedy Encounter")
|
||||
assert bgg_id == None
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Deprecated library")
|
||||
def test_lookup_boardgame_from_bgg():
|
||||
bgg_result = lookup_boardgame_from_bgg(15)
|
||||
assert bgg_result.get("bggeek_id") == 15
|
||||
|
||||
bgg_result = lookup_boardgame_from_bgg("Cosmic Encounter")
|
||||
assert bgg_result.get("bggeek_id") == "15"
|
||||
0
tests/foods_tests/__init__.py
Normal file
0
tests/foods_tests/__init__.py
Normal file
186
tests/foods_tests/test_recipe_scraper.py
Normal file
186
tests/foods_tests/test_recipe_scraper.py
Normal file
@ -0,0 +1,186 @@
|
||||
import pytest
|
||||
from foods.sources.rscraper import (
|
||||
RecipeScraperService,
|
||||
)
|
||||
|
||||
|
||||
RECIPE_HTML_WITH_SCHEMA = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Test Recipe",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Test Author"
|
||||
},
|
||||
"recipeIngredient": ["1 cup flour", "2 eggs", "1/2 cup sugar"],
|
||||
"recipeInstructions": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"text": "Mix ingredients together"
|
||||
}
|
||||
],
|
||||
"totalTime": "PT30M",
|
||||
"recipeYield": "4 servings"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test Recipe</h1>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
RECIPE_HTML_WITHOUT_SCHEMA = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Not a Recipe Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to My Blog</h1>
|
||||
<p>This is just a regular blog post about cooking.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
RECIPE_HTML_WITH_MICRODATA = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Test Recipe</title>
|
||||
</head>
|
||||
<body itemscope itemtype="http://schema.org/Recipe">
|
||||
<h1 itemprop="name">Microdata Recipe</h1>
|
||||
<div itemprop="author" itemscope itemtype="http://schema.org/Person">
|
||||
<span itemprop="name">Test Author</span>
|
||||
</div>
|
||||
<div itemprop="recipeIngredient">1 cup flour</div>
|
||||
<div itemprop="recipeIngredient">2 eggs</div>
|
||||
<div itemprop="recipeInstructions">
|
||||
<div itemprop="text">Mix all ingredients</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class TestRecipeScraperService:
|
||||
@pytest.fixture
|
||||
def scraper(self):
|
||||
return RecipeScraperService()
|
||||
|
||||
def test_is_recipe_with_valid_schema(self, scraper):
|
||||
result = scraper.is_recipe(
|
||||
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_is_recipe_without_schema(self, scraper):
|
||||
result = scraper.is_recipe(
|
||||
RECIPE_HTML_WITHOUT_SCHEMA, "https://example.com/blog"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_is_recipe_with_microdata(self, scraper):
|
||||
result = scraper.is_recipe(
|
||||
RECIPE_HTML_WITH_MICRODATA, "https://example.com/recipe"
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_scrape_returns_title(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["title"] == "Test Recipe"
|
||||
|
||||
def test_scrape_returns_ingredients(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert len(result["ingredients"]) == 3
|
||||
assert "1 cup flour" in result["ingredients"]
|
||||
|
||||
def test_scrape_returns_instructions(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert len(result["instructions"]) > 0
|
||||
assert "Mix ingredients together" in result["instructions"]
|
||||
|
||||
def test_scrape_returns_yields(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["yields"] == "4 servings"
|
||||
|
||||
def test_scrape_returns_total_time(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["total_time"] == 30
|
||||
|
||||
def test_scrape_returns_url(self, scraper):
|
||||
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
|
||||
assert result["url"] == "https://example.com/recipe"
|
||||
|
||||
def test_scrape_raises_on_invalid_html(self, scraper):
|
||||
with pytest.raises(ValueError):
|
||||
scraper.scrape("", "https://example.com/recipe")
|
||||
|
||||
def test_scrape_handles_missing_optional_fields(self, scraper):
|
||||
minimal_html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org/",
|
||||
"@type": "Recipe",
|
||||
"name": "Minimal Recipe"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
"""
|
||||
result = scraper.scrape(minimal_html, "https://example.com/minimal")
|
||||
assert result["title"] == "Minimal Recipe"
|
||||
assert result["ingredients"] == []
|
||||
assert result["instructions"] == []
|
||||
|
||||
def test_parse_servings(self, scraper):
|
||||
assert scraper.parse_servings("4 servings") == 4
|
||||
assert scraper.parse_servings("6 people") == 6
|
||||
assert scraper.parse_servings("2") == 2
|
||||
assert scraper.parse_servings("serves 8") == 8
|
||||
assert scraper.parse_servings(None) is None
|
||||
assert scraper.parse_servings("") is None
|
||||
|
||||
def test_extract_tags_from_cuisine(self, scraper):
|
||||
recipe_data = {"cuisine": "Italian"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Italian" in tags
|
||||
|
||||
def test_extract_tags_from_cuisine_list(self, scraper):
|
||||
recipe_data = {"cuisine": ["Italian", "Mexican"]}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Italian" in tags
|
||||
assert "Mexican" in tags
|
||||
|
||||
def test_extract_tags_from_dietary(self, scraper):
|
||||
recipe_data = {"dietary": "Gluten-Free"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Gluten-Free" in tags
|
||||
|
||||
def test_extract_tags_from_course(self, scraper):
|
||||
recipe_data = {"course": "Dessert"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "Dessert" in tags
|
||||
|
||||
def test_extract_tags_from_keywords(self, scraper):
|
||||
recipe_data = {"keywords": "easy, quick, healthy"}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "easy" in tags
|
||||
assert "quick" in tags
|
||||
assert "healthy" in tags
|
||||
|
||||
def test_extract_tags_from_keywords_list(self, scraper):
|
||||
recipe_data = {"keywords": ["comfort food", "winter"]}
|
||||
tags = scraper.extract_tags(recipe_data)
|
||||
assert "comfort food" in tags
|
||||
assert "winter" in tags
|
||||
133
tests/foods_tests/test_usda.py
Normal file
133
tests/foods_tests/test_usda.py
Normal file
@ -0,0 +1,133 @@
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from foods.sources.usda import (
|
||||
USDAFoodAPI,
|
||||
NutritionCalculator,
|
||||
)
|
||||
|
||||
|
||||
class TestUSDAFoodAPI:
|
||||
@pytest.fixture
|
||||
def usda_api(self):
|
||||
with patch("vrobbler.apps.foods.sources.usda.settings") as mock_settings:
|
||||
mock_settings.USDA_API_KEY = "test_api_key"
|
||||
return USDAFoodAPI(api_key="test_api_key")
|
||||
|
||||
def test_extract_nutrients_with_nutrient_number(self, usda_api):
|
||||
food_data = {
|
||||
"description": "Test Food",
|
||||
"foodNutrients": [
|
||||
{
|
||||
"nutrientNumber": "203",
|
||||
"nutrientName": "Protein",
|
||||
"value": 10.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "204",
|
||||
"nutrientName": "Total lipid (fat)",
|
||||
"value": 5.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "205",
|
||||
"nutrientName": "Carbohydrate, by difference",
|
||||
"value": 20.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "208",
|
||||
"nutrientName": "Energy",
|
||||
"value": 150.0,
|
||||
},
|
||||
{
|
||||
"nutrientNumber": "269",
|
||||
"nutrientName": "Sugars, total",
|
||||
"value": 5.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 10.0
|
||||
assert result["fat"] == 5.0
|
||||
assert result["carbohydrates"] == 20.0
|
||||
assert result["calories"] == 150.0
|
||||
assert result["sugar"] == 5.0
|
||||
|
||||
def test_extract_nutrients_with_nested_nutrient(self, usda_api):
|
||||
food_data = {
|
||||
"description": "Test Food",
|
||||
"foodNutrients": [
|
||||
{
|
||||
"nutrient": {"id": 203, "name": "Protein"},
|
||||
"value": 10.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 10.0
|
||||
|
||||
def test_extract_nutrients_with_empty_nutrients(self, usda_api):
|
||||
food_data = {"description": "Test Food", "foodNutrients": []}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 0
|
||||
assert result["calories"] == 0
|
||||
|
||||
def test_extract_nutrients_with_no_nutrients_key(self, usda_api):
|
||||
food_data = {"description": "Test Food"}
|
||||
result = usda_api.extract_nutrients(food_data)
|
||||
assert result["protein"] == 0
|
||||
|
||||
|
||||
class TestNutritionCalculator:
|
||||
@pytest.fixture
|
||||
def calculator(self):
|
||||
with patch("vrobbler.apps.foods.sources.usda.USDAFoodAPI"):
|
||||
return NutritionCalculator()
|
||||
|
||||
def test_parse_ingredient_with_fraction(self, calculator):
|
||||
result = calculator.parse_ingredient("1/2 cup flour")
|
||||
assert result["quantity"] == 0.5
|
||||
assert result["unit"] == "cup"
|
||||
assert result["ingredient"] == "flour"
|
||||
|
||||
def test_parse_ingredient_with_mixed_number(self, calculator):
|
||||
result = calculator.parse_ingredient("1 1/2 cups sugar")
|
||||
assert result["quantity"] == 1.5
|
||||
assert result["unit"] == "cups"
|
||||
assert result["ingredient"] == "sugar"
|
||||
|
||||
def test_parse_ingredient_with_decimal(self, calculator):
|
||||
result = calculator.parse_ingredient("0.5 tsp salt")
|
||||
assert result["quantity"] == 0.5
|
||||
assert result["unit"] == "tsp"
|
||||
assert result["ingredient"] == "salt"
|
||||
|
||||
def test_parse_ingredient_with_whole_number(self, calculator):
|
||||
result = calculator.parse_ingredient("3 eggs")
|
||||
assert result["quantity"] == 3
|
||||
assert result["unit"] is None
|
||||
assert result["ingredient"] == "eggs"
|
||||
|
||||
def test_parse_ingredient_with_no_quantity(self, calculator):
|
||||
result = calculator.parse_ingredient("salt to taste")
|
||||
assert result["quantity"] == 1
|
||||
|
||||
def test_clean_ingredient_name_removes_modifiers(self, calculator):
|
||||
result = calculator._clean_ingredient_name("fresh chopped onions")
|
||||
assert "fresh" not in result.lower()
|
||||
assert "chopped" not in result.lower()
|
||||
|
||||
def test_clean_ingredient_name_removes_parentheses(self, calculator):
|
||||
result = calculator._clean_ingredient_name("flour (sifted)")
|
||||
assert "(" not in result
|
||||
assert ")" not in result
|
||||
|
||||
def test_convert_to_grams_cup(self, calculator):
|
||||
result = calculator._convert_to_grams(2, "cups", "flour")
|
||||
assert result == 480
|
||||
|
||||
def test_convert_to_grams_tablespoon(self, calculator):
|
||||
result = calculator._convert_to_grams(3, "tbsp", "olive oil")
|
||||
assert result == 45
|
||||
|
||||
def test_convert_to_grams_unknown_unit(self, calculator):
|
||||
result = calculator._convert_to_grams(1, "unknown", "something")
|
||||
assert result == 100
|
||||
19
tests/podcasts_tests/test_scrapers.py
Normal file
19
tests/podcasts_tests/test_scrapers.py
Normal file
@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
|
||||
|
||||
expected_desc_snippet = "NPR's Up First is the news you need to start your day. "
|
||||
|
||||
expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0CfR24RR6sme531yIkCrnK4zzmo97jeualO5drVPKG6oCk"
|
||||
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
|
||||
|
||||
|
||||
@pytest.mark.skip("Google Podcasts is gone")
|
||||
def test_get_not_allowed_from_mopidy():
|
||||
query = "Up First"
|
||||
result_dict = scrape_data_from_google_podcasts(query)
|
||||
|
||||
assert result_dict["title"] == query
|
||||
assert expected_desc_snippet in result_dict["description"]
|
||||
assert result_dict["image_url"] == expected_img_url
|
||||
assert result_dict["producer"] == "NPR"
|
||||
assert result_dict["google_url"] == expected_google_url
|
||||
@ -1,36 +1,74 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
from rest_framework.authtoken.models import Token
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from boardgames.models import BoardGame
|
||||
from music.models import Track, Artist
|
||||
from scrobbles.models import Scrobble
|
||||
from people.models import Person
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boardgame_scrobble():
|
||||
first = Person.objects.create(name="First Player")
|
||||
second = Person.objects.create(name="Second Player")
|
||||
return Scrobble.objects.create(
|
||||
board_game=BoardGame.objects.create(title="Test Board Game"),
|
||||
media_type="BoardGame",
|
||||
played_to_completion=True,
|
||||
log={
|
||||
"players": [
|
||||
{
|
||||
"person_id": first.id,
|
||||
"win": True,
|
||||
"score": 30,
|
||||
"color": "Blue",
|
||||
},
|
||||
{
|
||||
"person_id": second.id,
|
||||
"win": False,
|
||||
"score": 28,
|
||||
"color": "Red",
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_track():
|
||||
Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=Artist.objects.create(name="Carly Rae Jepsen"),
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
|
||||
|
||||
class MopidyRequest:
|
||||
name = "Same in the End"
|
||||
artist = "Sublime"
|
||||
album = "Sublime"
|
||||
track_number = 4
|
||||
run_time_ticks = 156604
|
||||
run_time = "156"
|
||||
run_time = 60
|
||||
playback_time_ticks = 15045
|
||||
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
|
||||
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
|
||||
mopidy_uri = "local:track:Sublime%20-%20Sublime/Disc%201%20-%2004%20-%20Same%20in%20the%20End.mp3"
|
||||
mopidy_uri = "local:track:Sublime%20-%20Sublime/Disc%201%20-%2004%20-%20Same%20in%20the%20End.mp3" # noqa
|
||||
status = "resumed"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.request_data = {
|
||||
"name": kwargs.get('name', self.name),
|
||||
"name": kwargs.get("name", self.name),
|
||||
"artist": kwargs.get("artist", self.artist),
|
||||
"album": kwargs.get("album", self.album),
|
||||
"track_number": int(kwargs.get("track_number", self.track_number)),
|
||||
"run_time_ticks": int(
|
||||
kwargs.get("run_time_ticks", self.run_time_ticks)
|
||||
),
|
||||
"run_time_ticks": int(kwargs.get("run_time_ticks", self.run_time_ticks)),
|
||||
"run_time": int(kwargs.get("run_time", self.run_time)),
|
||||
"playback_time_ticks": int(
|
||||
kwargs.get("playback_time_ticks", self.playback_time_ticks)
|
||||
@ -61,24 +99,89 @@ class MopidyRequest:
|
||||
|
||||
@pytest.fixture
|
||||
def valid_auth_token():
|
||||
user = User.objects.create(email='test@exmaple.com')
|
||||
user = User.objects.create(email="test@exmaple.com")
|
||||
return Token.objects.create(user=user).key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mopidy_track_request_data():
|
||||
return MopidyRequest().request_json
|
||||
def mopidy_track():
|
||||
return MopidyRequest()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mopidy_track_diff_album_request_data(**kwargs):
|
||||
mb_album_id = "0c56c457-afe1-4679-baab-759ba8dd2a58"
|
||||
return MopidyRequest(
|
||||
album="Gold", musicbrainz_album_id=mb_album_id
|
||||
).request_json
|
||||
return MopidyRequest(album="Gold", musicbrainz_album_id=mb_album_id).request_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mopidy_podcast_request_data():
|
||||
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
|
||||
return MopidyRequest(mopidy_uri=mopidy_uri).request_json
|
||||
return MopidyRequest(
|
||||
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||
).request_json
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mopidy_podcast_https_request_data():
|
||||
mopidy_uri = "podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-ae09-43d9-8853-31ccf43f68e6"
|
||||
return MopidyRequest(
|
||||
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
|
||||
).request_json
|
||||
|
||||
|
||||
class JellyfinTrackRequest:
|
||||
name = "Emotion"
|
||||
artist = "Carly Rae Jepsen"
|
||||
album = "Emotion"
|
||||
track_number = 1
|
||||
item_type = "Audio"
|
||||
timestamp = "2024-01-14 12:00:19"
|
||||
run_time_ticks = 156604
|
||||
run_time = "00:00:60"
|
||||
playback_time_ticks = 15045
|
||||
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
|
||||
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
|
||||
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
|
||||
status = "resumed"
|
||||
client_name = "Jellyfin"
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.request_data = {
|
||||
"Name": kwargs.get("name", self.name),
|
||||
"Artist": kwargs.get("artist", self.artist),
|
||||
"Album": kwargs.get("album", self.album),
|
||||
"TrackNumber": int(kwargs.get("track_number", self.track_number)),
|
||||
"RunTime": kwargs.get("run_time", self.run_time),
|
||||
"ItemType": kwargs.get("item_type", self.item_type),
|
||||
"UtcTimestamp": kwargs.get("timestamp", self.timestamp),
|
||||
"PlaybackPositionTicks": int(
|
||||
kwargs.get("playback_time_ticks", self.playback_time_ticks)
|
||||
),
|
||||
"Provider_musicbrainztrack": kwargs.get(
|
||||
"musicbrainz_track_id", self.musicbrainz_track_id
|
||||
),
|
||||
"Provider_musicbrainzalbum": kwargs.get(
|
||||
"musicbrainz_album_id", self.musicbrainz_album_id
|
||||
),
|
||||
"Provider_musicbrainzartist": kwargs.get(
|
||||
"musicbrainz_artist_id", self.musicbrainz_artist_id
|
||||
),
|
||||
"Status": kwargs.get("status", self.status),
|
||||
"ClientName": kwargs.get("client_name", self.client_name),
|
||||
}
|
||||
|
||||
def __eq__(self, other):
|
||||
for key in self.request_data.keys():
|
||||
if self.request_data[key] != getattr(self, key):
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def request_json(self):
|
||||
return json.dumps(self.request_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jellyfin_track():
|
||||
return JellyfinTrackRequest()
|
||||
|
||||
@ -1,107 +1,116 @@
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from music.aggregators import (
|
||||
scrobble_counts,
|
||||
top_artists,
|
||||
top_tracks,
|
||||
week_of_scrobbles,
|
||||
)
|
||||
from profiles.models import UserProfile
|
||||
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def build_scrobbles(client, request_data, num=7, spacing=2):
|
||||
url = reverse('scrobbles:mopidy-websocket')
|
||||
user = get_user_model().objects.create(username='Test User')
|
||||
UserProfile.objects.create(user=user, timezone='US/Eastern')
|
||||
def build_scrobbles(client, request_json, num=7, spacing=2, auth_token=None):
|
||||
from rest_framework.authtoken.models import Token
|
||||
import pytz
|
||||
|
||||
url = reverse("scrobbles:mopidy-webhook")
|
||||
user = get_user_model().objects.create(username="Test User")
|
||||
user.profile.timezone = "US/Eastern"
|
||||
user.profile.save()
|
||||
headers = {}
|
||||
if auth_token:
|
||||
headers = {"Authorization": f"Token {auth_token}"}
|
||||
user = Token.objects.get(key=auth_token).user
|
||||
|
||||
client.post(url, request_json, content_type="application/json", headers=headers)
|
||||
track = Scrobble.objects.last().track
|
||||
|
||||
est = pytz.timezone("US/Eastern")
|
||||
for i in range(num):
|
||||
client.post(url, request_data, content_type='application/json')
|
||||
s = Scrobble.objects.last()
|
||||
s.user = user
|
||||
s.timestamp = timezone.now() - timedelta(days=i * spacing)
|
||||
s.played_to_completion = True
|
||||
s.save()
|
||||
naive_time = timezone.now().replace(tzinfo=None) - timedelta(days=i * spacing)
|
||||
aware_time = est.localize(naive_time)
|
||||
Scrobble.objects.create(
|
||||
user=user,
|
||||
track=track,
|
||||
timestamp=aware_time,
|
||||
played_to_completion=True,
|
||||
source="Mopidy",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("music.models.get_album_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_track_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
|
||||
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||
@patch("music.models.lookup_album_from_tadb", return_value={})
|
||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||
def test_scrobble_counts_data(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data)
|
||||
user = get_user_model().objects.first()
|
||||
def test_scrobble_counts_data(
|
||||
mock_lookup_album_tadb,
|
||||
mock_lookup_artist_tadb,
|
||||
mock_get_recording,
|
||||
mock_get_track,
|
||||
mock_get_album,
|
||||
client,
|
||||
mopidy_track,
|
||||
valid_auth_token,
|
||||
):
|
||||
user = build_scrobbles(
|
||||
client, mopidy_track.request_json, auth_token=valid_auth_token
|
||||
)
|
||||
count_dict = scrobble_counts(user)
|
||||
assert count_dict == {
|
||||
'alltime': 7,
|
||||
'month': 2,
|
||||
'today': 1,
|
||||
'week': 3,
|
||||
'year': 7,
|
||||
"alltime": 7,
|
||||
"month": 2,
|
||||
"today": 1,
|
||||
"week": 3,
|
||||
"year": 7,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_week_of_scrobbles_data(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
@patch("music.models.get_album_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_track_metadata_with_artist", return_value={})
|
||||
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
|
||||
@patch("music.models.lookup_artist_from_tadb", return_value={})
|
||||
@patch("music.models.lookup_album_from_tadb", return_value={})
|
||||
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
|
||||
def test_live_charts(
|
||||
mock_lookup_album_tadb,
|
||||
mock_lookup_artist_tadb,
|
||||
mock_get_recording,
|
||||
mock_get_track,
|
||||
mock_get_album,
|
||||
client,
|
||||
mopidy_track,
|
||||
valid_auth_token,
|
||||
):
|
||||
user = build_scrobbles(
|
||||
client, mopidy_track.request_json, 7, 1, auth_token=valid_auth_token
|
||||
)
|
||||
|
||||
week = week_of_scrobbles(user)
|
||||
assert list(week.values()) == [1, 1, 1, 1, 1, 1, 1]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top_tracks_by_day(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = top_tracks(user)
|
||||
tops = live_charts(user)
|
||||
assert tops[0].title == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top_tracks_by_week(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = top_tracks(user, filter='week')
|
||||
tops = live_charts(user, chart_period="week")
|
||||
assert tops[0].title == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top_tracks_by_month(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = top_tracks(user, filter='month')
|
||||
tops = live_charts(user, chart_period="month")
|
||||
assert tops[0].title == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top_tracks_by_year(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = top_tracks(user, filter='year')
|
||||
tops = live_charts(user, chart_period="year")
|
||||
assert tops[0].title == "Same in the End"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top__artists_by_week(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = top_artists(user, filter='week')
|
||||
tops = live_charts(user, chart_period="week", media_type="Artist")
|
||||
assert tops[0].name == "Sublime"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top__artists_by_month(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = top_artists(user, filter='month')
|
||||
tops = live_charts(user, chart_period="month", media_type="Artist")
|
||||
assert tops[0].name == "Sublime"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_top__artists_by_year(client, mopidy_track_request_data):
|
||||
build_scrobbles(client, mopidy_track_request_data, 7, 1)
|
||||
user = get_user_model().objects.first()
|
||||
tops = top_artists(user, filter='year')
|
||||
tops = live_charts(user, chart_period="year", media_type="Artist")
|
||||
assert tops[0].name == "Sublime"
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import pytest
|
||||
import imdb
|
||||
from mock import patch
|
||||
|
||||
from vrobbler.apps.scrobbles.imdb import lookup_video_from_imdb
|
||||
|
||||
|
||||
def test_lookup_imdb_bad_id(caplog):
|
||||
data = lookup_video_from_imdb('3409324')
|
||||
assert data is None
|
||||
assert caplog.records[0].levelname == "WARNING"
|
||||
assert caplog.records[0].msg == "IMDB ID should begin with 'tt' 3409324"
|
||||
44
tests/scrobbles_tests/test_metadata.py
Normal file
44
tests/scrobbles_tests/test_metadata.py
Normal file
@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
|
||||
# from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to get local tests running working again")
|
||||
@pytest.mark.django_db
|
||||
def test_boardgame_log_data(boardgame_scrobble):
|
||||
assert boardgame_scrobble.logdata == BoardGameLogData(
|
||||
players=[
|
||||
BoardGameScoreLogData(
|
||||
person_id=1,
|
||||
bgg_username="",
|
||||
color="Blue",
|
||||
character=None,
|
||||
team=None,
|
||||
score=30,
|
||||
win=True,
|
||||
new=None,
|
||||
rank=None,
|
||||
seat_order=None,
|
||||
role=None,
|
||||
),
|
||||
BoardGameScoreLogData(
|
||||
person_id=2,
|
||||
bgg_username="",
|
||||
color="Red",
|
||||
character=None,
|
||||
team=None,
|
||||
score=28,
|
||||
win=False,
|
||||
new=None,
|
||||
rank=None,
|
||||
seat_order=None,
|
||||
role=None,
|
||||
),
|
||||
],
|
||||
difficulty=None,
|
||||
solo=None,
|
||||
two_handed=None,
|
||||
)
|
||||
assert len(boardgame_scrobble.logdata.players) == 1
|
||||
assert boardgame_scrobble.logdata.players[0].user.id == 1
|
||||
assert boardgame_scrobble.logdata.players[0].name == "Test"
|
||||
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,
|
||||
)
|
||||
10
tests/scrobbles_tests/test_utils.py
Normal file
10
tests/scrobbles_tests/test_utils.py
Normal file
@ -0,0 +1,10 @@
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
|
||||
|
||||
|
||||
def test_timestamp_user_tz_to_utc():
|
||||
timestamp = timestamp_user_tz_to_utc(1685561082, pytz.timezone("US/Eastern"))
|
||||
assert timestamp == datetime(2023, 5, 31, 23, 24, 42, tzinfo=pytz.utc)
|
||||
File diff suppressed because it is too large
Load Diff
127
tests/tasks_tests/test_todoist.py
Normal file
127
tests/tasks_tests/test_todoist.py
Normal file
@ -0,0 +1,127 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_profile(db):
|
||||
user = User.objects.create_user(username="testuser", password="testpass")
|
||||
return user.profile
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGenerateTodoistOauthUrl:
|
||||
def test_generates_url_with_state(self, user_profile):
|
||||
from tasks.todoist import generate_todoist_oauth_url
|
||||
|
||||
url = generate_todoist_oauth_url(user_profile.user_id)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_state is not None
|
||||
assert len(user_profile.todoist_state) == 32
|
||||
assert url.startswith("https://todoist.com/oauth/authorize")
|
||||
assert user_profile.todoist_state in url
|
||||
|
||||
def test_updates_existing_state(self, user_profile):
|
||||
from tasks.todoist import generate_todoist_oauth_url
|
||||
|
||||
old_state = "oldstate12345678901234567890123"
|
||||
user_profile.todoist_state = old_state
|
||||
user_profile.save()
|
||||
|
||||
url = generate_todoist_oauth_url(user_profile.user_id)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_state != old_state
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestGetTodoistAccessToken:
|
||||
def test_raises_when_profile_not_found(self):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
with pytest.raises(Exception, match="Could not find profile"):
|
||||
get_todoist_access_token(user_id=999, state="anystate", code="anycode")
|
||||
|
||||
def test_raises_when_state_mismatch(self, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
with pytest.raises(Exception, match="state mismatch"):
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id, state="wrongstate", code="anycode"
|
||||
)
|
||||
|
||||
@patch("tasks.todoist.requests.post")
|
||||
def test_exchanges_code_for_token(self, mock_post, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
mock_token_response = MagicMock()
|
||||
mock_token_response.status_code = 200
|
||||
mock_token_response.json.return_value = {"access_token": "test_access_token"}
|
||||
mock_post.return_value = mock_token_response
|
||||
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id,
|
||||
state="correctstate1234567890123",
|
||||
code="testcode",
|
||||
)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_auth_key == "test_access_token"
|
||||
assert user_profile.todoist_state is None
|
||||
|
||||
@patch("tasks.todoist.requests.post")
|
||||
def test_fetches_todoist_user_id(self, mock_post, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
mock_token_response = MagicMock()
|
||||
mock_token_response.status_code = 200
|
||||
mock_token_response.json.return_value = {"access_token": "test_access_token"}
|
||||
|
||||
mock_sync_response = MagicMock()
|
||||
mock_sync_response.status_code = 200
|
||||
mock_sync_response.json.return_value = {"user": {"id": "12345"}}
|
||||
|
||||
mock_post.side_effect = [mock_token_response, mock_sync_response]
|
||||
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id,
|
||||
state="correctstate1234567890123",
|
||||
code="testcode",
|
||||
)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_user_id == "12345"
|
||||
|
||||
@patch("tasks.todoist.requests.post")
|
||||
def test_handles_token_exchange_failure(self, mock_post, user_profile):
|
||||
from tasks.todoist import get_todoist_access_token
|
||||
|
||||
user_profile.todoist_state = "correctstate1234567890123"
|
||||
user_profile.save()
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
get_todoist_access_token(
|
||||
user_id=user_profile.user_id,
|
||||
state="correctstate1234567890123",
|
||||
code="badcode",
|
||||
)
|
||||
|
||||
user_profile.refresh_from_db()
|
||||
assert user_profile.todoist_auth_key is None
|
||||
assert user_profile.todoist_state == "correctstate1234567890123"
|
||||
161
tests/test_context_processors.py
Normal file
161
tests/test_context_processors.py
Normal file
@ -0,0 +1,161 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from vrobbler.context_processors import version_info
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class TestVersionInfo:
|
||||
def test_returns_version_and_commit(self, mock_request):
|
||||
"""Test that git commit is returned when _commit.py doesn't exist"""
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.return_value = b"abc1234"
|
||||
|
||||
# Mock the import to raise ImportError so git is used
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "vrobbler._commit":
|
||||
raise ImportError("No module named 'vrobbler._commit'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["app_version"] == "1.0.0"
|
||||
assert result["git_commit"] == "abc1234"
|
||||
|
||||
def test_uses_env_commit_if_set(self, mock_request):
|
||||
with (
|
||||
patch.dict(os.environ, {"VROBBLER_COMMIT": "env_commit_hash"}),
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "env_commit_hash"
|
||||
|
||||
def test_uses_commit_from_module_when_available(self, mock_request):
|
||||
"""Test that commit from _commit.py module is used when available"""
|
||||
with (patch("vrobbler.context_processors.get_version") as mock_get_version,):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
# Should use whatever value is in vrobbler/_commit.py
|
||||
# Could be "unknown" or an actual commit hash
|
||||
assert "git_commit" in result
|
||||
assert result["git_commit"] != ""
|
||||
|
||||
def test_uses_commit_from_file_when_module_unavailable(self, mock_request):
|
||||
"""Test that commit from /var/lib/vrobbler/commit.txt is used"""
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch("pathlib.Path.read_text", return_value="file_commit_hash"),
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "vrobbler._commit":
|
||||
raise ImportError("No module named 'vrobbler._commit'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "file_commit_hash"
|
||||
|
||||
def test_falls_back_to_git_when_file_unavailable(self, mock_request):
|
||||
"""Test fallback to git when _commit.py and file don't exist"""
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.return_value = b"git_commit_hash"
|
||||
|
||||
# Mock the import to raise ImportError
|
||||
import builtins
|
||||
|
||||
original_import = builtins.__import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "vrobbler._commit":
|
||||
raise ImportError("No module named 'vrobbler._commit'")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "git_commit_hash"
|
||||
|
||||
def test_returns_unknown_when_version_fails(self, mock_request):
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.side_effect = Exception("not found")
|
||||
mock_check_output.return_value = b"abc1234"
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["app_version"] == "unknown"
|
||||
|
||||
def test_returns_unknown_when_git_fails(self, mock_request):
|
||||
import subprocess
|
||||
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.side_effect = subprocess.SubprocessError()
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "unknown"
|
||||
|
||||
def test_returns_unknown_when_git_not_found(self, mock_request):
|
||||
import subprocess
|
||||
|
||||
with (
|
||||
patch("vrobbler.context_processors.get_version") as mock_get_version,
|
||||
patch(
|
||||
"vrobbler.context_processors.subprocess.check_output"
|
||||
) as mock_check_output,
|
||||
):
|
||||
mock_get_version.return_value = "1.0.0"
|
||||
mock_check_output.side_effect = FileNotFoundError()
|
||||
|
||||
result = version_info(mock_request)
|
||||
|
||||
assert result["git_commit"] == "unknown"
|
||||
0
tests/trails_tests/__init__.py
Normal file
0
tests/trails_tests/__init__.py
Normal file
281
tests/trails_tests/test_gpx_importer.py
Normal file
281
tests/trails_tests/test_gpx_importer.py
Normal file
@ -0,0 +1,281 @@
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files import File
|
||||
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.importers.trail_gpx import (
|
||||
compute_trail_stats,
|
||||
find_route_waypoint,
|
||||
import_trail_gpx,
|
||||
parse_trackpoints,
|
||||
)
|
||||
from scrobbles.models import Scrobble, TrailGPXImport
|
||||
from trails.models import Trail, TrailLogData
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SAMPLE_GPX = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "data", "sample_trail.gpx"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create(email="trailblazer@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_gpx_path():
|
||||
return SAMPLE_GPX
|
||||
|
||||
|
||||
class TestParseTrackpoints:
|
||||
def test_parses_gpx(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
assert len(points) == 837
|
||||
assert result["name"] == "Morning Run ⛅"
|
||||
assert result["description"] == "Run"
|
||||
lat, lon, ele, t = points[0]
|
||||
assert round(lat, 6) == 34.190598
|
||||
assert round(lon, 6) == -118.844015
|
||||
assert ele == 305.3
|
||||
assert t is not None
|
||||
|
||||
def test_first_and_last_times(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
first_time = points[0][3]
|
||||
last_time = points[-1][3]
|
||||
duration = (last_time - first_time).total_seconds()
|
||||
assert duration == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_gpx_extra_metadata(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
extra = result["extra"]
|
||||
assert extra["avg_heartrate"] == 159
|
||||
assert extra["max_heartrate"] == 183
|
||||
assert extra["avg_speed_kmh"] == pytest.approx(9.82, abs=0.1)
|
||||
assert extra["activity_type"] == "Run"
|
||||
assert extra["moving_time_seconds"] == 3008
|
||||
assert extra["total_elevation_gain_m"] == 246.4
|
||||
|
||||
|
||||
class TestImportTrailGPX:
|
||||
def test_creates_trail(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Trail.objects.filter(title="Morning Run ⛅").exists()
|
||||
|
||||
def test_creates_geolocation(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert GeoLocation.objects.filter(lat=34.190598, lon=-118.844015).exists()
|
||||
|
||||
def test_sets_trailhead(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
trail = Trail.objects.filter(title="Morning Run ⛅").first()
|
||||
assert trail.trailhead_location is not None
|
||||
assert round(trail.trailhead_location.lat, 6) == 34.190598
|
||||
|
||||
def test_creates_scrobble(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_timestamps(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timestamp.isoformat().startswith("2022-06-05T13:55:09")
|
||||
assert scrobble.stop_timestamp.isoformat().startswith("2022-06-05T14:57:59")
|
||||
assert scrobble.media_type == Scrobble.MediaType.TRAIL
|
||||
|
||||
def test_scrobble_has_trail_fk(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail is not None
|
||||
assert scrobble.trail.title == "Morning Run ⛅"
|
||||
|
||||
def test_scrobble_has_gpx_file(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.gpx_file
|
||||
assert scrobble.gpx_file.name.endswith(".gpx")
|
||||
|
||||
def test_lookup_existing_trail_by_trailhead(self, user, sample_gpx_path):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Existing Trail", trailhead_location=geo)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail.id == trail.id
|
||||
|
||||
def test_dedup(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_log_has_stats(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert log["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert log["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert log["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
assert log["description"] == "Run"
|
||||
|
||||
def test_scrobble_playback_position(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.playback_position_seconds == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_scrobble_has_timezone(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timezone is not None
|
||||
assert isinstance(scrobble.timezone, str)
|
||||
|
||||
def test_scrobble_log_extra_metadata(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["avg_heartrate"] == 159
|
||||
assert log["max_heartrate"] == 183
|
||||
assert log["activity_type"] == "Run"
|
||||
|
||||
def test_scrobble_log_no_calories_in_gpx(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.log.get("calories") is None
|
||||
|
||||
|
||||
class TestComputeTrailStats:
|
||||
def test_computes_distance_and_elevation(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
stats = compute_trail_stats(result["points"])
|
||||
assert stats["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert stats["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert stats["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert stats["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
|
||||
|
||||
class TestTrailGPXImportModel:
|
||||
def test_create_import_model(self, db, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="test_trail.gpx",
|
||||
)
|
||||
assert imp.uuid is not None
|
||||
assert imp.import_type == "Trail GPX"
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_process_via_model(self, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="Morning Run.gpx",
|
||||
)
|
||||
with open(sample_gpx_path, "rb") as f:
|
||||
imp.gpx_file.save("Morning Run.gpx", File(f), save=True)
|
||||
imp.process()
|
||||
imp.refresh_from_db()
|
||||
assert imp.process_count == 1
|
||||
assert imp.processed_finished is not None
|
||||
|
||||
|
||||
class TestFindRouteWaypoint:
|
||||
def test_returns_halfway_point(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
pt = find_route_waypoint(result["points"])
|
||||
assert pt is not None
|
||||
lat, lon = pt
|
||||
assert lat == pytest.approx(34.177853, abs=0.001)
|
||||
assert lon == pytest.approx(-118.829944, abs=0.001)
|
||||
|
||||
def test_returns_last_point_for_short_track(self):
|
||||
points = [(34.0, -118.0, None, None), (34.001, -118.001, None, None)]
|
||||
pt = find_route_waypoint(points)
|
||||
assert pt == (34.001, -118.001)
|
||||
|
||||
def test_returns_none_for_empty_points(self):
|
||||
assert find_route_waypoint([]) is None
|
||||
|
||||
|
||||
class TestFindByTrailhead:
|
||||
def test_exact_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Test Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found == trail
|
||||
|
||||
def test_within_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Nearby Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.191000, -118.844000, tolerance_m=100)
|
||||
assert found == trail
|
||||
|
||||
def test_beyond_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(title="Far Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.200000, -118.850000, tolerance_m=50)
|
||||
assert found is None
|
||||
|
||||
def test_no_trailhead_returns_none(self, db):
|
||||
Trail.objects.create(title="No Location")
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found is None
|
||||
|
||||
def test_same_trailhead_same_route_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Same Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.192167,
|
||||
route_lon=-118.843143,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
def test_same_trailhead_different_route_does_not_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(
|
||||
title="Different Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.200000,
|
||||
route_lon=-118.850000,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found is None
|
||||
|
||||
def test_legacy_trail_without_route_still_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Legacy Trail",
|
||||
trailhead_location=geo,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
|
||||
class TestFindOrCreate:
|
||||
def test_find_existing(self, db):
|
||||
Trail.objects.create(title="Existing Trail")
|
||||
trail = Trail.find_or_create("Existing Trail")
|
||||
assert trail.title == "Existing Trail"
|
||||
|
||||
def test_create_new(self, db):
|
||||
trail = Trail.find_or_create("New Trail")
|
||||
assert trail.title == "New Trail"
|
||||
assert Trail.objects.count() == 1
|
||||
0
tests/videos_tests/__init__.py
Normal file
0
tests/videos_tests/__init__.py
Normal file
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
|
||||
0
tests/videos_tests/test_imdb.py
Normal file
0
tests/videos_tests/test_imdb.py
Normal file
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
|
||||
9
tests/videos_tests/test_youtube.py
Normal file
9
tests/videos_tests/test_youtube.py
Normal file
@ -0,0 +1,9 @@
|
||||
import pytest
|
||||
from videos.sources.youtube import lookup_video_from_youtube
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Need to configure Youtube API stuffs in CI")
|
||||
@pytest.mark.django_db
|
||||
def test_lookup_youtube_id():
|
||||
metadata = lookup_video_from_youtube("RZxs9pAv99Y")
|
||||
assert metadata.title == "No Pun Included's Board Game of the Year 2024"
|
||||
351
todos.org
351
todos.org
@ -1,351 +0,0 @@
|
||||
#+title: TODOs
|
||||
|
||||
A fun way to keep track of things in the project to fix or improve.
|
||||
|
||||
* DONE [#A] Fix fetching artwork without release group :bug:
|
||||
CLOSED: [2023-01-29 Sun 14:27]
|
||||
|
||||
When we get artwork from Musicbrianz, and it's not found, we should check for
|
||||
release groups as well. This will stop issues with missing artwork because of
|
||||
obscure MB release matches.
|
||||
|
||||
* DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion perecnt :bug:
|
||||
CLOSED: [2023-01-30 Mon 18:31]
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
|
||||
:END:
|
||||
|
||||
If we play music from Jellyfin and the track reaches 90% completion, the
|
||||
scrobbling goes crazy and starts creating new scrobbles with every update.
|
||||
|
||||
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
|
||||
as complete for the following conditions:
|
||||
|
||||
- Play stopped and percent played beyond 90%
|
||||
- Play completely finished
|
||||
|
||||
But if we keep listening beyond 90, we should basically ignore updates (or just
|
||||
update the existing scrobble)
|
||||
|
||||
* TODO [#A] Add django-storage to store files on S3 :improvement:
|
||||
* TODO [#B] Adjust cancel/finish task to use javascript to submit :improvement:
|
||||
* TODO [#B] Implement a detail view for TV shows :improvement:
|
||||
* TODO [#B] Implement a detail view for Moviews :improvement:
|
||||
* TODO [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
|
||||
|
||||
This would allow a few nice flows. One, you'd be able to record the play of an
|
||||
entire album by just dropping the muscibrainz_id in. This could be helpful for
|
||||
offline listening. It would also mean bad metadata from mopidy would not break
|
||||
scrobbling.
|
||||
* TODO [#C] Implement keeping track of week/month/year chart-toppers :improvement:
|
||||
:LOGBOOK:
|
||||
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
|
||||
:END:
|
||||
|
||||
Maloja does this cool thing where artists and tracks get recorded as the top
|
||||
track of a given week, month or year. They get gold, silver or bronze stars for
|
||||
their place in the time period.
|
||||
|
||||
I could see this being implemented as a separate Chart table which gets
|
||||
populated at the end of a time period and has a start and end date that defines
|
||||
a period, along with a one, two, three instance.
|
||||
|
||||
Of course, it could also be a data model without a table, where it runs some fun
|
||||
calculations, stores it's values in Redis as a long-term lookup table and just
|
||||
has to re-populate when the server restarts.
|
||||
* TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :improvement:
|
||||
** Example payloads from mopidy-webhooks
|
||||
*** Podcast playback ended
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_ended",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 13,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
}
|
||||
},
|
||||
"time_position": 3290
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Podcast playback state changes
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "playback_state_changed",
|
||||
"data": {
|
||||
"old_state": "paused",
|
||||
"new_state": "playing"
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "playback_state_changed",
|
||||
"data": {
|
||||
"old_state": "stopped",
|
||||
"new_state": "playing"
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Podcast playback started
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_started",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 13,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Podcast playback paused
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"state": "paused",
|
||||
"current_track": {
|
||||
"__model__": "Track",
|
||||
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
|
||||
"name": "Wolf warriors",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"name": "The Economist"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"name": "The Prince",
|
||||
"date": "2022"
|
||||
},
|
||||
"genre": "Blues",
|
||||
"date": "2022",
|
||||
"length": 2437778,
|
||||
"bitrate": 127988
|
||||
},
|
||||
"time_position": 2350
|
||||
}
|
||||
}
|
||||
|
||||
#+end_src
|
||||
*** Track playback started
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_started",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 14,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Track playback in progress
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"state": "playing",
|
||||
"current_track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
},
|
||||
"time_position": 17031
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
*** Track event playback paused
|
||||
#+begin_src json
|
||||
{
|
||||
"type": "event",
|
||||
"event": "track_playback_paused",
|
||||
"data": {
|
||||
"tl_track": {
|
||||
"__model__": "TlTrack",
|
||||
"tlid": 14,
|
||||
"track": {
|
||||
"__model__": "Track",
|
||||
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
|
||||
"name": "Supermassive Black Hole",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
|
||||
"name": "Muse",
|
||||
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
|
||||
}
|
||||
],
|
||||
"album": {
|
||||
"__model__": "Album",
|
||||
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
|
||||
"name": "Twilight: Original Motion Picture Soundtrack",
|
||||
"artists": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
|
||||
"name": "Various Artists",
|
||||
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
|
||||
}
|
||||
],
|
||||
"num_tracks": 12,
|
||||
"num_discs": 1,
|
||||
"date": "2008-11-04",
|
||||
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
|
||||
},
|
||||
"composers": [
|
||||
{
|
||||
"__model__": "Artist",
|
||||
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
|
||||
"name": "Matt Bellamy"
|
||||
}
|
||||
],
|
||||
"genre": "Rock",
|
||||
"track_no": 1,
|
||||
"disc_no": 1,
|
||||
"date": "2008-11-04",
|
||||
"length": 211121,
|
||||
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
|
||||
"last_modified": 1672712949510
|
||||
}
|
||||
},
|
||||
"time_position": 67578
|
||||
}
|
||||
}
|
||||
#+end_src
|
||||
* TODO [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :improvement:
|
||||
* TODO Figure out how to add to web-scrobbler :imropvement:
|
||||
|
||||
An example:
|
||||
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
|
||||
@ -1,11 +1,31 @@
|
||||
# You can use this file to set environment variables for your local setup
|
||||
#
|
||||
VROBBLER_DEBUG=True
|
||||
VROBBLER_JSON_LOGGING=True
|
||||
VROBBLER_LOG_LEVEL="DEBUG"
|
||||
VROBBLER_JSON_LOGGING=True
|
||||
VROBBLER_MEDIA_ROOT = "/media/"
|
||||
VROBBLER_TMDB_API_KEY = "KEY"
|
||||
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
||||
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=False
|
||||
|
||||
VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
||||
VROBBLER_REDIS_URL="redis://:PASS@HOST:6379/0"
|
||||
VROBBLER_USE_S3=False
|
||||
# You may also need to set these in your environment
|
||||
AWS_S3_ACCESS_KEY_ID=""
|
||||
AWS_S3_SECRET_ACCESS_KEY=""
|
||||
AWS_S3_CUSTOM_DOMAIN="https://minio.dev/"
|
||||
|
||||
# API keys
|
||||
VROBBLER_TMDB_API_KEY = "<key>"
|
||||
VROBBLER_LASTFM_API_KEY = "<key>"
|
||||
VROBBLER_LASTFM_SECRET_KEY = "<key>"
|
||||
VROBBLER_THESPORTSDB_API_KEY="<key>"
|
||||
VROBBLER_THEAUDIODB_API_KEY="<key>"
|
||||
VROBBLER_IGDB_CLIENT_ID="<id>"
|
||||
VROBBLER_IGDB_CLIENT_SECRET="<key>"
|
||||
VROBBLER_COMICVINE_API_KEY="<key>"
|
||||
VROBBLER_TODOIST_CLIENT_ID="<id>"
|
||||
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
||||
VROBBLER_GOOGLE_API_KEY="<key>"
|
||||
VROBBLER_LICHESS_API_KEY = "<key>"
|
||||
|
||||
# Storages
|
||||
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
||||
# VROBBLER_REDIS_URL="redis://:PASS@HOST:6379/0"
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
# Local configuration for Emus
|
||||
|
||||
VROBBLER_DUMP_REQUEST_DATA=True
|
||||
VROBBLER_LOG_TO_CONSOLE=True
|
||||
VROBBLER_DEBUG=True
|
||||
VROBBLER_DUMP_REQUEST_DATA=False
|
||||
VROBBLER_LOG_TO_CONSOLE=False
|
||||
VROBBLER_DEBUG=False
|
||||
VROBBLER_LOG_LEVEL="DEBUG"
|
||||
VROBBLER_MEDIA_ROOT = "/tmp/media/"
|
||||
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
||||
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=False
|
||||
|
||||
VROBBLER_USE_S3="False"
|
||||
VROBBLER_DATABASE_URL="sqlite:///testdb.sqlite3"
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
# This will make sure the app is always imported when
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery import app as 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"
|
||||
35
vrobbler/apps/beers/admin.py
Normal file
35
vrobbler/apps/beers/admin.py
Normal file
@ -0,0 +1,35 @@
|
||||
from beers.models import Beer, BeerProducer, BeerStyle
|
||||
from django.contrib import admin
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
class BeerInline(admin.TabularInline):
|
||||
model = Beer
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(BeerStyle)
|
||||
class BeerStyle(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(BeerProducer)
|
||||
class BeerProducer(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(Beer)
|
||||
class BeerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"uuid",
|
||||
"title",
|
||||
)
|
||||
raw_id_fields = ("styles", "producer")
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
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]
|
||||
5
vrobbler/apps/beers/apps.py
Normal file
5
vrobbler/apps/beers/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BeersConfig(AppConfig):
|
||||
name = "beers"
|
||||
133
vrobbler/apps/beers/migrations/0001_initial.py
Normal file
133
vrobbler/apps/beers/migrations/0001_initial.py
Normal file
@ -0,0 +1,133 @@
|
||||
# Generated by Django 4.2.16 on 2024-10-22 21:26
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0065_alter_scrobble_log"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BeerProducer",
|
||||
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"
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"location",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Beer",
|
||||
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),
|
||||
),
|
||||
(
|
||||
"run_time_seconds",
|
||||
models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"run_time_ticks",
|
||||
models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
("ibu", models.SmallIntegerField(blank=True, null=True)),
|
||||
("abv", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"style",
|
||||
models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
("non_alcoholic", models.BooleanField(default=False)),
|
||||
(
|
||||
"beeradvocate_id",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"beeradvocate_score",
|
||||
models.SmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"untappd_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="Tags",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.2.16 on 2024-10-22 21:34
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("beers", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="beer",
|
||||
name="beeradvocate_image",
|
||||
field=models.ImageField(
|
||||
blank=True, null=True, upload_to="beers/beeradvcoate/"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beer",
|
||||
name="producer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="beers.beerproducer",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beerproducer",
|
||||
name="beeradvocate_id",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,75 @@
|
||||
# Generated by Django 4.2.16 on 2024-10-22 21:47
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BeerStyle",
|
||||
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"
|
||||
),
|
||||
),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="beer",
|
||||
name="beeradvocate_image",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="beer",
|
||||
name="style",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beer",
|
||||
name="untappd_image",
|
||||
field=models.ImageField(
|
||||
blank=True, null=True, upload_to="beers/untappd/"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beer",
|
||||
name="untappd_rating",
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beerproducer",
|
||||
name="untappd_id",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beer",
|
||||
name="styles",
|
||||
field=models.ManyToManyField(to="beers.beerstyle"),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,47 @@
|
||||
# Generated by Django 4.2.16 on 2024-10-22 21:52
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="beerproducer",
|
||||
name="name",
|
||||
field=models.CharField(default="Untitled", max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beerproducer",
|
||||
name="uuid",
|
||||
field=models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beerstyle",
|
||||
name="name",
|
||||
field=models.CharField(default="Untitled", max_length=255),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beerstyle",
|
||||
name="uuid",
|
||||
field=models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="beer",
|
||||
name="styles",
|
||||
field=models.ManyToManyField(
|
||||
related_name="styles", to="beers.beerstyle"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,21 @@
|
||||
# Generated by Django 4.2.16 on 2025-01-22 03:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"beers",
|
||||
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="beer",
|
||||
name="run_time_seconds",
|
||||
field=models.IntegerField(default=900),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/beers/migrations/__init__.py
Normal file
0
vrobbler/apps/beers/migrations/__init__.py
Normal file
143
vrobbler/apps/beers/models.py
Normal file
143
vrobbler/apps/beers/models.py
Normal file
@ -0,0 +1,143 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from beers.untappd import get_beer_from_untappd_id
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeerLogData(BaseLogData):
|
||||
rating: Optional[str] = None
|
||||
|
||||
|
||||
class BeerStyle(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class BeerProducer(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
location = models.CharField(max_length=255, **BNULL)
|
||||
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def find_or_create(cls, title: str) -> "BeerProducer":
|
||||
return cls.objects.filter(title=title).first()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Beer(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
ibu = models.SmallIntegerField(**BNULL)
|
||||
abv = models.FloatField(**BNULL)
|
||||
styles = models.ManyToManyField(BeerStyle, related_name="styles")
|
||||
non_alcoholic = models.BooleanField(default=False)
|
||||
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
||||
untappd_image = models.ImageField(upload_to="beers/untappd/", **BNULL)
|
||||
untappd_image_small = ImageSpecField(
|
||||
source="untappd_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
untappd_image_medium = ImageSpecField(
|
||||
source="untappd_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_rating = models.FloatField(**BNULL)
|
||||
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.producer}"
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.producer.name
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Drinking", tags="beer")
|
||||
|
||||
@property
|
||||
def beeradvocate_link(self) -> str:
|
||||
link = ""
|
||||
if self.producer and self.beeradvocate_id:
|
||||
if self.beeradvocate_id:
|
||||
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
|
||||
return link
|
||||
|
||||
@property
|
||||
def untappd_link(self) -> str:
|
||||
link = ""
|
||||
if self.untappd_id:
|
||||
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
|
||||
return link
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.untappd_image:
|
||||
url = self.untappd_image.url
|
||||
return url
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BeerLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, untappd_id: str) -> "Beer":
|
||||
beer = cls.objects.filter(untappd_id=untappd_id).first()
|
||||
|
||||
if not beer:
|
||||
beer_dict = get_beer_from_untappd_id(untappd_id)
|
||||
producer_dict = {}
|
||||
style_ids = []
|
||||
for key in list(beer_dict.keys()):
|
||||
if "producer__" in key:
|
||||
pkey = key.replace("producer__", "")
|
||||
producer_dict[pkey] = beer_dict.pop(key)
|
||||
if "styles" in key:
|
||||
for style in beer_dict.pop("styles"):
|
||||
style_inst, created = BeerStyle.objects.get_or_create(
|
||||
name=style
|
||||
)
|
||||
style_ids.append(style_inst.id)
|
||||
|
||||
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
|
||||
beer_dict["producer_id"] = producer.id
|
||||
beer = Beer.objects.create(**beer_dict)
|
||||
for style_id in style_ids:
|
||||
beer.styles.add(style_id)
|
||||
|
||||
return beer
|
||||
|
||||
def scrobbles(self, user_id):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
|
||||
"-timestamp"
|
||||
)
|
||||
138
vrobbler/apps/beers/untappd.py
Normal file
138
vrobbler/apps/beers/untappd.py
Normal file
@ -0,0 +1,138 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
UNTAPPD_URL = "https://untappd.com/beer/{id}"
|
||||
|
||||
|
||||
def get_first(key: str, result: dict) -> str:
|
||||
obj = ""
|
||||
if obj_list := result.get(key):
|
||||
obj = obj_list[0]
|
||||
return obj
|
||||
|
||||
|
||||
def get_title_from_soup(soup) -> str:
|
||||
title = ""
|
||||
try:
|
||||
title = soup.find("h1").get_text()
|
||||
except AttributeError:
|
||||
pass
|
||||
except ValueError:
|
||||
pass
|
||||
return title
|
||||
|
||||
|
||||
def get_description_from_soup(soup) -> str:
|
||||
desc = ""
|
||||
try:
|
||||
desc = (
|
||||
soup.find(class_="beer-descrption-read-less")
|
||||
.get_text()
|
||||
.replace("Show Less", "")
|
||||
.strip()
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
except ValueError:
|
||||
pass
|
||||
return desc
|
||||
|
||||
|
||||
def get_styles_from_soup(soup) -> list[str]:
|
||||
styles = []
|
||||
try:
|
||||
styles = soup.find("p", class_="style").get_text().split(" - ")
|
||||
except AttributeError:
|
||||
pass
|
||||
except ValueError:
|
||||
pass
|
||||
return styles
|
||||
|
||||
|
||||
def get_abv_from_soup(soup) -> Optional[float]:
|
||||
abv = None
|
||||
try:
|
||||
abv = soup.find(class_="abv").get_text()
|
||||
if abv:
|
||||
abv = float(abv.strip("\n").strip("% ABV").strip())
|
||||
except AttributeError:
|
||||
pass
|
||||
except ValueError:
|
||||
pass
|
||||
except TypeError:
|
||||
pass
|
||||
return abv
|
||||
|
||||
|
||||
def get_ibu_from_soup(soup) -> Optional[int]:
|
||||
ibu = None
|
||||
try:
|
||||
ibu = soup.find(class_="ibu").get_text()
|
||||
if ibu:
|
||||
ibu = int(ibu.strip("\n").strip(" IBU").strip())
|
||||
except AttributeError:
|
||||
pass
|
||||
except ValueError:
|
||||
ibu = None
|
||||
return ibu
|
||||
|
||||
|
||||
def get_rating_from_soup(soup) -> str:
|
||||
rating = ""
|
||||
try:
|
||||
rating = float(soup.find(class_="num").get_text().strip("(").strip(")"))
|
||||
except AttributeError:
|
||||
rating = None
|
||||
except ValueError:
|
||||
rating = None
|
||||
return rating
|
||||
|
||||
|
||||
def get_producer_id_from_soup(soup) -> str:
|
||||
id = ""
|
||||
try:
|
||||
id = soup.find(class_="brewery").find("a")["href"].strip("/")
|
||||
except ValueError:
|
||||
pass
|
||||
except IndexError:
|
||||
pass
|
||||
return id
|
||||
|
||||
|
||||
def get_producer_name_from_soup(soup) -> str:
|
||||
name = ""
|
||||
try:
|
||||
name = soup.find(class_="brewery").find("a").get_text()
|
||||
except AttributeError:
|
||||
pass
|
||||
except ValueError:
|
||||
pass
|
||||
return name
|
||||
|
||||
|
||||
def get_beer_from_untappd_id(untappd_id: str) -> dict:
|
||||
beer_url = UNTAPPD_URL.format(id=untappd_id)
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
response = requests.get(beer_url, headers=headers)
|
||||
beer_dict = {"untappd_id": untappd_id}
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warn("Bad response from untappd.com", extra={"response": response})
|
||||
return beer_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
beer_dict["title"] = get_title_from_soup(soup)
|
||||
beer_dict["description"] = get_description_from_soup(soup)
|
||||
beer_dict["styles"] = get_styles_from_soup(soup)
|
||||
beer_dict["abv"] = get_abv_from_soup(soup)
|
||||
beer_dict["ibu"] = get_ibu_from_soup(soup)
|
||||
beer_dict["untappd_rating"] = get_rating_from_soup(soup)
|
||||
beer_dict["producer__untappd_id"] = get_producer_id_from_soup(soup)
|
||||
beer_dict["producer__name"] = get_producer_name_from_soup(soup)
|
||||
|
||||
return beer_dict
|
||||
14
vrobbler/apps/beers/urls.py
Normal file
14
vrobbler/apps/beers/urls.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django.urls import path
|
||||
from beers import views
|
||||
|
||||
app_name = "beers"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("beers/", views.BeerListView.as_view(), name="beer_list"),
|
||||
path(
|
||||
"beers/<slug:slug>/",
|
||||
views.BeerDetailView.as_view(),
|
||||
name="beer_detail",
|
||||
),
|
||||
]
|
||||
11
vrobbler/apps/beers/views.py
Normal file
11
vrobbler/apps/beers/views.py
Normal file
@ -0,0 +1,11 @@
|
||||
from beers.models import Beer
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
|
||||
class BeerListView(ScrobbleableListView):
|
||||
model = Beer
|
||||
|
||||
|
||||
class BeerDetailView(ScrobbleableDetailView):
|
||||
model = Beer
|
||||
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
|
||||
58
vrobbler/apps/boardgames/admin.py
Normal file
58
vrobbler/apps/boardgames/admin.py
Normal file
@ -0,0 +1,58 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from boardgames.models import (
|
||||
BoardGame,
|
||||
BoardGameLocation,
|
||||
BoardGamePublisher,
|
||||
BoardGameDesigner,
|
||||
)
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(BoardGamePublisher)
|
||||
class BoardGamePublisherAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"uuid",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGameDesigner)
|
||||
class BoardGameDesignerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"uuid",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGameLocation)
|
||||
class BoardGameLocationAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"uuid",
|
||||
"geo_location",
|
||||
)
|
||||
raw_id_fields = ("geo_location",)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGame)
|
||||
class BoardGameAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"bggeek_id",
|
||||
"title",
|
||||
"published_year",
|
||||
)
|
||||
raw_id_fields = ("publisher", "publishers", "designers", "expansion_for_boardgame")
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
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]
|
||||
156
vrobbler/apps/boardgames/bgg.py
Normal file
156
vrobbler/apps/boardgames/bgg.py
Normal file
@ -0,0 +1,156 @@
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
|
||||
User = get_user_model()
|
||||
if TYPE_CHECKING:
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SEARCH_ID_URL = "https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
|
||||
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
|
||||
BGG_ACCESS_TOKEN = getattr(settings, "BGG_ACCESS_TOKEN", "")
|
||||
BASE_HEADERS = {
|
||||
"User-Agent": "Vrobbler 31.0",
|
||||
"Authorization": f"Bearer {BGG_ACCESS_TOKEN}",
|
||||
}
|
||||
|
||||
|
||||
def take_first(thing: Optional[list]) -> str:
|
||||
first = ""
|
||||
try:
|
||||
first = thing[0]
|
||||
except IndexError:
|
||||
pass
|
||||
|
||||
if first:
|
||||
try:
|
||||
first = first.get_text()
|
||||
except:
|
||||
pass
|
||||
|
||||
return first
|
||||
|
||||
|
||||
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
|
||||
soup = None
|
||||
game_id = None
|
||||
url = SEARCH_ID_URL.format(query=title)
|
||||
r = requests.get(url, headers=BASE_HEADERS)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
if soup:
|
||||
result = soup.findAll("boardgame")
|
||||
if not result:
|
||||
return game_id
|
||||
|
||||
game_id = result[0].get("objectid", None)
|
||||
|
||||
return game_id
|
||||
|
||||
|
||||
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
||||
soup = None
|
||||
game_dict = {}
|
||||
|
||||
title = ""
|
||||
bgg_id = None
|
||||
|
||||
try:
|
||||
bgg_id = int(lookup_id)
|
||||
logger.debug(f"Using BGG ID {bgg_id} to find board game")
|
||||
except ValueError:
|
||||
title = lookup_id
|
||||
logger.debug(f"Using title {title} to find board game")
|
||||
|
||||
if not bgg_id:
|
||||
bgg_id = lookup_boardgame_id_from_bgg(title)
|
||||
|
||||
url = GAME_ID_URL.format(id=bgg_id)
|
||||
r = requests.get(url, headers=BASE_HEADERS)
|
||||
if r.status_code == 200:
|
||||
soup = BeautifulSoup(r.text, "xml")
|
||||
|
||||
if soup:
|
||||
seconds_to_play = None
|
||||
minutes = take_first(soup.findAll("playingtime"))
|
||||
if minutes:
|
||||
seconds_to_play = int(minutes) * 60
|
||||
|
||||
game_dict = {
|
||||
"bggeek_id": bgg_id,
|
||||
"title": take_first(soup.findAll("name", primary="true")),
|
||||
"description": take_first(soup.findAll("description")),
|
||||
"year_published": take_first(soup.findAll("yearpublished")),
|
||||
"publisher_name": take_first(soup.findAll("boardgamepublisher")),
|
||||
"cover_url": take_first(soup.findAll("image")),
|
||||
"min_players": take_first(soup.findAll("minplayers")),
|
||||
"max_players": take_first(soup.findAll("maxplayers")),
|
||||
"recommended_age": take_first(soup.findAll("age")),
|
||||
"run_time_seconds": seconds_to_play,
|
||||
}
|
||||
|
||||
return game_dict
|
||||
|
||||
|
||||
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||
bgg_username = "secstate" # user.profile.bgg_username
|
||||
bgg_password = "yYFCKnfo8AK89lc68q0S"
|
||||
|
||||
if not bgg_username or bgg_password:
|
||||
return
|
||||
|
||||
login_payload = {
|
||||
"credentials": {"username": bgg_username, "password": bgg_password}
|
||||
}
|
||||
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
|
||||
|
||||
with requests.Session() as s:
|
||||
p = s.post(
|
||||
"https://boardgamegeek.com/login/api/v1",
|
||||
data=json.dumps(login_payload),
|
||||
headers=headers,
|
||||
)
|
||||
players = []
|
||||
if scrobble.log:
|
||||
for player in scrobble.log.get("players"):
|
||||
player_person = Person.objects.filter(
|
||||
id=player.get("person_id")
|
||||
).first()
|
||||
if player_person.get("bgg_username"):
|
||||
player["username"] = player_person.get("bgg_username")
|
||||
player["name"] = player_person.get("name")
|
||||
player["win"] = player.get("win")
|
||||
# player["role"] = player.get("role")
|
||||
player["new"] = player.get("new")
|
||||
player["score"] = player.get("score")
|
||||
players.append(player)
|
||||
|
||||
play_payload = {
|
||||
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
|
||||
"length": scrobble.playback_position_seconds / 60,
|
||||
"comments": "Uploaded from Vrobbler",
|
||||
"location": scrobble.log.location or None,
|
||||
"objectid": scrobble.media_obj.bggeek_id,
|
||||
"quantity": "1",
|
||||
"action": "save",
|
||||
"players": players,
|
||||
"objecttype": "thing",
|
||||
"ajax": 1,
|
||||
}
|
||||
r = s.post(
|
||||
"https://boardgamegeek.com/geekplay.php",
|
||||
data=json.dumps(play_payload),
|
||||
headers=headers,
|
||||
)
|
||||
168
vrobbler/apps/boardgames/migrations/0001_initial.py
Normal file
168
vrobbler/apps/boardgames/migrations/0001_initial.py
Normal file
@ -0,0 +1,168 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-17 22:11
|
||||
|
||||
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 = [
|
||||
("scrobbles", "0038_alter_objectwithgenres_tag"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BoardGamePublisher",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"logo",
|
||||
models.ImageField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to="games/platform-logos/",
|
||||
),
|
||||
),
|
||||
("igdb_id", models.IntegerField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BoardGame",
|
||||
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"
|
||||
),
|
||||
),
|
||||
(
|
||||
"run_time_seconds",
|
||||
models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"run_time_ticks",
|
||||
models.PositiveBigIntegerField(blank=True, null=True),
|
||||
),
|
||||
("title", models.CharField(max_length=255)),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"cover",
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to="boardgames/covers/"
|
||||
),
|
||||
),
|
||||
(
|
||||
"layout_image",
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to="boardgames/layouts/"
|
||||
),
|
||||
),
|
||||
("summary", models.TextField(blank=True, null=True)),
|
||||
("rating", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"max_players",
|
||||
models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"min_players",
|
||||
models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
("published_date", models.DateField(blank=True, null=True)),
|
||||
(
|
||||
"recommened_age",
|
||||
models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"seconds_to_play",
|
||||
models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
(
|
||||
"bggeek_id",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"genre",
|
||||
taggit.managers.TaggableManager(
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
(
|
||||
"publisher",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="boardgames.boardgamepublisher",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.7 on 2023-04-17 22:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user