Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6f71e0761 | |||
| b00ebf49dd | |||
| 2385e9c7bd | |||
| d78529efe2 | |||
| f373e98e3d | |||
| 7559ce7824 | |||
| 2c481bd53a | |||
| 0deb3ee634 | |||
| 28a53d70eb | |||
| 98d4e8bacb | |||
| 8f97131b8d | |||
| 7c1f709f96 | |||
| 4b005e0e5b | |||
| d5dd63be0d | |||
| 5aa89b7e0a | |||
| cf444e8dd4 | |||
| a74a89c747 | |||
| 1695f7393e | |||
| 4468e68110 | |||
| da08eca4ab | |||
| 08752e30a4 | |||
| 619718c045 | |||
| cb23d5a5be | |||
| ec4c190e6c | |||
| 58126928c7 | |||
| c0d2881585 | |||
| 41a68291a4 | |||
| 0a411bedf4 | |||
| f2b67b38dc | |||
| 662ebe66b9 | |||
| 5e0dffdc7a | |||
| 2283a6c640 | |||
| 327ba94c63 | |||
| ee59cde882 | |||
| c7b4656679 | |||
| 04f9e00c9c | |||
| c2dabd1dac | |||
| 7a0cb8b9d0 | |||
| 1c2c570c4b |
@ -1,68 +0,0 @@
|
||||
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
|
||||
@ -1,8 +1,14 @@
|
||||
name: deploy
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ${{ gitea.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@ -68,6 +74,7 @@ jobs:
|
||||
|
||||
build-and-deploy:
|
||||
needs: [test]
|
||||
if: startsWith(gitea.ref, 'refs/tags/')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
189
PROJECT.org
189
PROJECT.org
@ -88,7 +88,8 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/22] :vrobbler:project:personal:
|
||||
* Backlog [0/24] :vrobbler:project:personal:
|
||||
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||
@ -603,6 +604,192 @@ a helper method to create board game scrobbles given a json blob. It's
|
||||
independent of the email flow it was originally creatdd for
|
||||
|
||||
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
||||
** TODO [#A] Update how board game scrobbles work :boardgames:
|
||||
|
||||
*** Description
|
||||
|
||||
When we scrobble a board game from a BGG URL, instead of going to the media
|
||||
detail page, we should go to the scrobble detail page, with the Edit Log form
|
||||
expanded by default.
|
||||
|
||||
The Edit log form should have from top to bottom:
|
||||
|
||||
- Board/Variant (one or many BoardGameVariant in a multi-select widget)
|
||||
- People (which should be similar to the Bird widget on BirdLocation and allow setting per user score, win true/false, rank, new true/false, seat_ordrer)
|
||||
- Expansion ids (which should a multi-select widget of expansions for this game)
|
||||
- Location (which should be a drop down of BoardGameLocations for this user)
|
||||
|
||||
* Version 59.4 [2/2]
|
||||
** DONE Fix bug in fetching expansions for board games :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 17995312-e76e-4a50-b591-0eab78cb59ab
|
||||
:END:
|
||||
|
||||
#+begin_src python
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/management/commands/fetch_expansions.py", line 60, in handle
|
||||
fetch_and_link_expansions(game, expansions)
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/utils.py", line 51, in fetch_and_link_expansions
|
||||
expansion = BoardGame.find_or_create(str(exp_id))
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/models.py", line 409, in find_or_create
|
||||
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/sources/bgg.py", line 15, in lookup_boardgame_from_bgg
|
||||
game = bgg.game(game_id=lookup_id)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/local/lib/python3.11/site-packages/boardgamegeek/api.py", line 1045, in game
|
||||
raise BGGApiError(msg)
|
||||
boardgamegeek.exceptions.BGGApiError: invalid data for game id: 242117
|
||||
#+end_src
|
||||
|
||||
** DONE Board games should have genres extracted from family data :boardgames:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: 7214b270-dccc-4b98-ac58-ff4f76c8cda9
|
||||
:END:
|
||||
|
||||
|
||||
* Version 59.3 [2/2]
|
||||
** DONE Exclude some board games from auto-expansion imports :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 51ffdf20-e732-4774-b781-c3501d26d46f
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Some board games, especially trading card games, have silly amounts of expansions.
|
||||
We should have a setting SKIP_AUTO_EXPANSION_DOWNLOAD that is a list of BGG ids of
|
||||
games where expansions should not be download automatically. This exclusion should also auto-include
|
||||
any games with "Collectible Card Games" in it's family.
|
||||
|
||||
** DONE Should be able to add new variants to board games via the log data form :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 5ed0dd25-3026-3da8-dc5c-f2a75751af9a
|
||||
:END:
|
||||
|
||||
* Version 59.2 [1/1]
|
||||
** DONE Fix test failure in discgolf app :discgolf:tests:
|
||||
:PROPERTIES:
|
||||
:ID: 813ae357-0568-5a4c-1a35-172e95d02740
|
||||
:END:
|
||||
|
||||
* Version 59.1 [1/1]
|
||||
** DONE Fix bug when expansions have no image :boardgames:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 9fee96c9-c6a0-32d9-b6f8-212c60fc3540
|
||||
:END:
|
||||
|
||||
* Version 59.0 [3/3]
|
||||
** DONE [#A] Add BoardGameVariant model :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 0ffb20d5-252f-b13d-473d-5529014602ff
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Variants represent unique boards being used per scrobble or scenarios when
|
||||
playing a game. Scrobbles of a board game may have one or more
|
||||
boardgame_variant_ids assocaited with their log data, and a variant is created
|
||||
for one specific board game.
|
||||
|
||||
|
||||
** DONE [#A] Lookup all Expansions for a game when creating it :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 8a84b06d-555c-4701-9058-ff364c89c198
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
We don't want to blow up the BGG API, but if possible with not too
|
||||
many calls, when we scrobble a board game, in order to allow
|
||||
populating the "Expansions" multi select, we should fetch any
|
||||
expansions for the board game when creating it for the first time.
|
||||
|
||||
We should also create a managemnt script to update existing board games.
|
||||
|
||||
** DONE [#A] Board game expansion lookup should be async on URL scrobbles :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 968d8dde-f906-cdf0-af4e-b87ce28ddbbb
|
||||
:END:
|
||||
|
||||
* Version 58.8 [1/1]
|
||||
** DONE [#B] Clean up trend templates :trends:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 83237e2c-857b-47c9-c86c-32a5e3f1359d
|
||||
:END:
|
||||
|
||||
* Version 58.7 [2/2]
|
||||
** DONE [#B] Split up chart page between tables and maloja :charts:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 103ab084-2016-cfa4-c677-3c5fdc54cce0
|
||||
:END:
|
||||
** DONE [#A] Fix CI so we don't double run deploys and builds :ci:
|
||||
:PROPERTIES:
|
||||
:ID: 1a93e7cb-b883-aae5-2bd5-fcdd6e16f8ab
|
||||
:END:
|
||||
|
||||
* Version 58.6 [1/1]
|
||||
** DONE [#B] Cleanup commands should check for broken images :metadata:cleanup:
|
||||
:PROPERTIES:
|
||||
:ID: bacce321-73c7-ae1f-bfa7-c3ee517b5441
|
||||
:END:
|
||||
|
||||
* Version 58.5 [1/1]
|
||||
** DONE [#A] The maloja style charts are messed up :templates:charts:
|
||||
:PROPERTIES:
|
||||
:ID: 987397a2-7e74-4eb1-87cc-4c8bbe1c7b23
|
||||
:END:
|
||||
|
||||
* Version 58.4 [2/2]
|
||||
** DONE [#B] Allow people all trends or individual trends :trends:profiles:
|
||||
:PROPERTIES:
|
||||
:ID: 1d081152-abd1-73c2-a625-903565a10c6c
|
||||
:END:
|
||||
** DONE [#A] Fix a bug in board game scorelog data :boardgames:logdata:
|
||||
:PROPERTIES:
|
||||
:ID: 014bab30-13bf-fae7-e678-4666a8d38ae4
|
||||
:END:
|
||||
|
||||
* Version 58.3 [1/1]
|
||||
** DONE [#A] Remove curl-cffi as it doesn't work on FreeBSD :webpages:deps:
|
||||
:PROPERTIES:
|
||||
:ID: 6bc1b0dd-e449-3d32-a176-46451e793e5d
|
||||
:END:
|
||||
|
||||
* Version 58.2 [2/2]
|
||||
** DONE [#B] Add more robust webpage scraping :webpages:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: 84d9bfa5-75c0-0718-764e-379f7456602a
|
||||
:END:
|
||||
** DONE [#B] Time of Day Categories trend :trends:
|
||||
:PROPERTIES:
|
||||
:ID: 6598074f-2290-46db-967b-29f45d30be29
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Added a "Time of Day Categories" trend that groups scrobbles for Books, Trails,
|
||||
Birding Locations, and Board Games into Early Bird (5-10:59am), Day Jay (11am-6:59pm),
|
||||
and Night Owl (7pm-4:59am) buckets. Shows both overall and per-media-type breakdowns.
|
||||
|
||||
|
||||
* Version 58.1 [1/1]
|
||||
** DONE [#B] Add auto genre tagging for papers :books:papers:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: e6b5c3a5-7fc6-b530-96c2-b5962a716db6
|
||||
:END:
|
||||
|
||||
* Version 58.0 [1/1]
|
||||
** DONE [#B] Add scrobbling of Papers via webpages with doi.org links in them :feature:papers:
|
||||
:PROPERTIES:
|
||||
:ID: d30bb8aa-eefd-002c-38d5-3f2fcef345f2
|
||||
:END:
|
||||
|
||||
* Version 57.1 [1/1]
|
||||
** DONE [#A] Write poetry lock file :bug:deps:
|
||||
:PROPERTIES:
|
||||
:ID: 0f5a6f4b-a486-ba7e-bbce-f7581274398c
|
||||
:END:
|
||||
|
||||
* Version 57.0 [5/5]
|
||||
** DONE [#A] Scrobble button on some media list pages dont work :bug:scrobbles:
|
||||
|
||||
784
poetry.lock
generated
784
poetry.lock
generated
@ -967,6 +967,23 @@ prompt-toolkit = ">=3.0.36"
|
||||
[package.extras]
|
||||
testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "cloudscraper"
|
||||
version = "1.2.71"
|
||||
description = "A Python module to bypass Cloudflare's anti-bot page."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "cloudscraper-1.2.71-py2.py3-none-any.whl", hash = "sha256:76f50ca529ed2279e220837befdec892626f9511708e200d48d5bb76ded679b0"},
|
||||
{file = "cloudscraper-1.2.71.tar.gz", hash = "sha256:429c6e8aa6916d5bad5c8a5eac50f3ea53c9ac22616f6cb21b18dcc71517d0d3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.4.7"
|
||||
requests = ">=2.9.2"
|
||||
requests-toolbelt = ">=0.9.1"
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@ -1512,6 +1529,25 @@ files = [
|
||||
{file = "django_mathfilters-1.0.0-py3-none-any.whl", hash = "sha256:64200a21bb249fbf27be601d4bbb788779e09c6e063170c097cd82c4d18ebb83"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-mcp-server"
|
||||
version = "0.5.7"
|
||||
description = "Django MCP Server is a Django extensions to easily enable AI Agents to interact with Django Apps through the Model Context Protocol it works equally well on WSGI and ASGI"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "django_mcp_server-0.5.7-py3-none-any.whl", hash = "sha256:04b58bf02623aaee59708c3661ffe17981acd4532587c38b6cfe2c9e7090c6d3"},
|
||||
{file = "django_mcp_server-0.5.7.tar.gz", hash = "sha256:5077f8fabf5fb621b5ce490afd0db60f21e57b3a451ed14a9f44aef545ea4eee"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
django = ">=4.0"
|
||||
djangorestframework = ">=3.15.0"
|
||||
inflection = ">=0.5.1,<0.6.0"
|
||||
mcp = ">=1.8.0"
|
||||
uritemplate = ">=4.1.1,<5.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "django-oauth-toolkit"
|
||||
version = "3.2.0"
|
||||
@ -2156,6 +2192,18 @@ http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"},
|
||||
{file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@ -2196,6 +2244,18 @@ perf = ["ipython"]
|
||||
test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
|
||||
type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""]
|
||||
|
||||
[[package]]
|
||||
name = "inflection"
|
||||
version = "0.5.1"
|
||||
description = "A port of Ruby on Rails inflector to Python"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"},
|
||||
{file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@ -2387,6 +2447,101 @@ files = [
|
||||
test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
|
||||
trio = ["trio"]
|
||||
|
||||
[[package]]
|
||||
name = "jellyfish"
|
||||
version = "1.2.1"
|
||||
description = ""
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b35d4b5b688f759ffd075190a9850b04671bad14c5b37124eb43e99306ec16ea"},
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b37b76ea338c4a473c34a9b9e1e033a78aafb9040a8c0eea579fc5805d8e4b46"},
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:137cfcc26396d0f2e1265ac61f800bb921921ea722a43dd897e58190f767c474"},
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab1bfea271ce4bda09d975080d5465cf5a8b127e7c0ea61ea3f972417a7a2193"},
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2348f698f9c1d72023afc8d39939045421a01da9b7e3078e3029227e35f28419"},
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4072e21ad4036af41bd57b447b1dda64fe60aa679cfa8854ba0a0338152439f1"},
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cf6cd68921f2bacc547ba1cf64ad0e76bc1727f3bab13bba2e5f5869aba038b1"},
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:01647c12261bc1f7b102e918e7665497176d87f6fc96271439c8855872bc2606"},
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-win32.whl", hash = "sha256:ddf05ea471da2808d77ecfa425d8884124b4754f4d483afa7703b6655530cf5c"},
|
||||
{file = "jellyfish-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:e4a210a960f3917da757b0581750b6e0a8db9acef68dafbc1b6e2ae39e847ba8"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9913789a98ccf49213fbb1dabc597847a0ec33d3b0e151689498f4b38ba9be0f"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e36d9000d4f7e1a35689a74ec7749d27a216dfa6c47cac2e5ad3de8a523bd69"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7853d2ed7d6929c029312ec849410f1ea7ae76ce72ad1140fb73f6e8a1e6aa4f"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68080af234256ef943f0add6fc79816b0c643d8df291c17a85c1b6e45bdfbb96"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c5acb213aa75a61bcfc176566e20f2503069667e760d83d403b59e115fef0dd"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4b28fcefc0c3534277ff0306e6c10672fb050f4784b5f3be7037e80801569fb5"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f69aeb08659a6c81d559bbe319075e3417434ae5b3a5e4a758d1c4055a03497a"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:63770120cc3386dcc13bcc4df508ab281a6b14c3b2c0e33586439a6c40ee122f"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-win32.whl", hash = "sha256:ecf62d4aad0baa8832ab60f96e7baedbe6558bd292597503d927e9c5bce745d8"},
|
||||
{file = "jellyfish-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:bd186c041d9be86c4fa5e2490943ce5d7f05b472f45d7f49426f259f3dd20bc4"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:32a85b752cb51463face13e2b1797cfa617cd7fb7073f15feaa4020a86a346ce"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:675ab43840488944899ca87f02d4813c1e32107e56afaba7489705a70214e8aa"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c888f624d03e55e501bc438906505c79fb307d8da37a6dda18dd1ac2e6d5ea9c"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2b56a1fd2c5126c4a3362ec4470291cdd3c7daa22f583da67e75e30dc425ce6"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a3ccff843822e7f3ad6f91662488a3630724c8587976bce114f3c7238e8ffa1"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10da696747e2de0336180fd5ba77ef769a7c80f9743123545f7fc0251efbbcec"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:c3c18f13175a9c90f3abd8805720b0eb3e10eca1d5d4e0cf57722b2a62d62016"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0368596e176bf548b3be2979ff33e274fb6d5e13b2cebe85137b8b698b002a85"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-win32.whl", hash = "sha256:451ddf4094e108e33d3b86d7817a7e20a2c5e6812d08c34ee22f6a595f38dcca"},
|
||||
{file = "jellyfish-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:15318c13070fe6d9caeb7e10f9cdf89ff47c9d20f05a9a2c0d3b5cb8062a7033"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4b3e3223aaad74e18aacc74775e01815e68af810258ceea6fa6a81b19f384312"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e967e67058b78189d2b20a9586c7720a05ec4a580d6a98c796cd5cd2b7b11303"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32581c50b34a09889b2d96796170e53da313a1e7fde32be63c82e50e7e791e3c"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07b022412ebece96759006cb015d46b8218d7f896d8b327c6bbee784ddf38ed9"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80a49eb817eaa6591f43a31e5c93d79904de62537f029907ef88c050d781a638"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e1b990fb15985571616f7f40a12d6fa062897b19fb5359b6dec3cd811d802c24"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:dd895cf63fac0a9f11b524fff810d9a6081dcf3c518b34172ac8684eb504dd43"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:6d2bac5982d7a08759ea487bfa00149e6aa8a3be7cd43c4ed1be1e3505425c69"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-win32.whl", hash = "sha256:509355ebedec69a8bf0cc113a6bf9c01820d12fe2eea44f47dfa809faf2d5463"},
|
||||
{file = "jellyfish-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:9c747ae5c0fb4bd519f6abbfe4bd704b2f1c63fd4dd3dbb8d8864478974e1571"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:212aaf177236192a735bbbf5938717aa8518d14a25b08b015e47e783e70be060"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b8986d9768daddd5e87abf513ae168ea0afe690a444d4c82d5b1b14b0d045820"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fa0ba0946f3c274f6a87aaa3c631dc70a363bd46cceea828ce777e8db653b6f"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6e76b23431a667cd485fb562428d1ad29bae9fdd0fcdfb5a51cc8087bae0e88c"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a058f4c6a591d5e5a47569f5648a26303ba19c76a960fef7e0beba2aa959e52e"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6a49ce2a580edd3b16b69421137deef464e2f8907f9ef906d49950b1a52908c1"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:c85aa2bc76a36d92a3197f406f86636664d5b323727dfec4fa2842a8a24a06ae"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:29cfa8bfb72aacf2d611a3313b358ed4d4140fa3d3efcffea750c8e7f8acb1aa"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-win32.whl", hash = "sha256:f121218dc33fb318c34ddd889dc7362606ce1316af2bb63b73cc1df81523ca34"},
|
||||
{file = "jellyfish-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:9a73b5c6425a70ebd440579a677eb4f03b327b2f59090db34e6c937aeea5aabd"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5335f622458aa105289a8e358bc32ecd1b9634b6ffec3e77ea3577e49c297171"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c51e565f85ce38cf9388c4f916d53888b0fa34788fcebe3aff3db24948e0960"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14bbb30d988dec1d12183cf5d4621c908f98add2009c72a185e8c3e8d00b804f"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9930e20f0e9f65ad1d57d98290c2be3abd75812d058815605f44a56056fb9a66"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0028857c5381c9d55e21cc6cb0d7f9545c3a9a7bb7dbca3960fe0a898c691ac2"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:56da7632e029912af25e25422fae3b6df318400297d552791f4b21da6d815ed6"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a3cab91020e3ff7565e55a611ec3e3257c093ac950d55778a48bfc8c57562b6e"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0b21c1596ce283fd7ee954eb0eeb007d59e480364324bcd91ad55146e91f3936"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-win32.whl", hash = "sha256:1098ce1f84ae3f147f0a18a6803ffb09b9c8cd5fedce42465643ca0b5c9d0224"},
|
||||
{file = "jellyfish-1.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:4b013876109d91fa6fc871ffa4e0dbfda11820c33dc4ad0e2967b3fc1187f804"},
|
||||
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c499ea3a134130797c50e367687a6a46a12653c59af381bee92c41a5ab0bd55d"},
|
||||
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:91cad49a4fb731b726afc5ae385a3217a7016ed88a04da40c131cff8136a5db5"},
|
||||
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bda2275f31a64adf3483e39f7a4e2107f7dfe3a3f85f0d2c0cb6ae5fbe4a443"},
|
||||
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98a133b40dc00cfda6609e1b0cb0ab0b77796fc2719aae886a12009514f73499"},
|
||||
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa30c7b59bd1c5e105693108a6d7a98f3e7a1a59e23e15bc5897b91fd5849f5"},
|
||||
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:db97d873f23b0c15b4ed911ece10e5cc0bb96cdc53666d5c3788bd0af81807f1"},
|
||||
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-musllinux_1_1_i686.whl", hash = "sha256:393f609fd6139ce782e747e22c399483ffc58341009e6a97e39ffe5f5b2c674c"},
|
||||
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fb3c6e537cb4605c22895a8d4a10cdb26611ba2bbfc7f0b4c1d06bb9d8aad648"},
|
||||
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:748dc45a0394fbe9120b8b3b9a39fab0967c7e2d6ecdd5304af018e774f80f96"},
|
||||
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:13f1ac9caba22af10bfe42f674822643c0266009f882e0fe652079706dc5d13a"},
|
||||
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ffeeb6c78c45fbb6d2a22b0173fb8a6af849001d6c26fab49c525136dbd9734"},
|
||||
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1354b558a0a16597b6032dd0af64bebd24994f7e7484cf14993320eb764b06cb"},
|
||||
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5977810972c6f0b2e61252c4758fd5aee21abf663ff309881195a99d37daa94"},
|
||||
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:536c80d8d4ec7f39cbb10b85d926ff96cef3cde4a83ca0991c07cd9835d5dc13"},
|
||||
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:21baa92d4a5112167721156f6d061c2ae105f2995b3a5e19cec6662928f0c439"},
|
||||
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68ea3ddd4dae1152a7f7155ef02a7bfad919611158d71b301f9aa167685819af"},
|
||||
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d7be8021658b46b22500a77f1707901bd98fc210f185c229b81c74efd3c1baf2"},
|
||||
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:bcdcd603a7737cd3f5a2ab10ce9b49844329deb81c2daafcd8131e54fc730205"},
|
||||
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c28a4ae3e201e1c1b7bacacd40e2e76c4068b90c9ae3a0d525e0ac98206f1cc"},
|
||||
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bebccd0652ac1c7e438ae1f451edefde63d14b3af6f6daa30c599919dcb92886"},
|
||||
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05be396aebe3dce7a8cb2f97727ecdf99e86457c48e97190775dce33f8b7e39d"},
|
||||
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:9d4448c874959ae012cda0f6d570ac0bd7f0fcf12007714eaebf86b86919b66f"},
|
||||
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-musllinux_1_1_i686.whl", hash = "sha256:4a21d7eda5e6996772055f798e3fe1de1b33b3edad7f6cf0567097a21585a812"},
|
||||
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:a0ef6f0ecc085c1f8fddb048f538c8bb89989e5d470eab45d4e9bd48ee73a40d"},
|
||||
{file = "jellyfish-1.2.1.tar.gz", hash = "sha256:72d2fda61b23babe862018729be73c8b0dc12e3e6601f36f6e65d905e249f4db"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jmespath"
|
||||
version = "1.1.0"
|
||||
@ -2399,6 +2554,43 @@ files = [
|
||||
{file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
description = "An implementation of JSON Schema validation for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"},
|
||||
{file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
jsonschema-specifications = ">=2023.03.6"
|
||||
referencing = ">=0.28.4"
|
||||
rpds-py = ">=0.25.0"
|
||||
|
||||
[package.extras]
|
||||
format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"]
|
||||
format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"},
|
||||
{file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
referencing = ">=0.31.0"
|
||||
|
||||
[[package]]
|
||||
name = "jstyleson"
|
||||
version = "0.0.2"
|
||||
@ -2745,6 +2937,48 @@ files = [
|
||||
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.28.0"
|
||||
description = "Model Context Protocol SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4"},
|
||||
{file = "mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=4.5"
|
||||
httpx = ">=0.27.1,<1.0.0"
|
||||
httpx-sse = ">=0.4"
|
||||
jsonschema = ">=4.20.0"
|
||||
pydantic = [
|
||||
{version = ">=2.11.0,<3.0.0", markers = "python_version < \"3.14\""},
|
||||
{version = ">=2.12.0,<3.0.0", markers = "python_version >= \"3.14\""},
|
||||
]
|
||||
pydantic-settings = ">=2.5.2"
|
||||
pyjwt = {version = ">=2.10.1", extras = ["crypto"]}
|
||||
python-multipart = ">=0.0.9"
|
||||
pywin32 = [
|
||||
{version = ">=310", markers = "sys_platform == \"win32\" and python_version < \"3.14\""},
|
||||
{version = ">=311", markers = "sys_platform == \"win32\" and python_version >= \"3.14\""},
|
||||
]
|
||||
sse-starlette = ">=1.6.1"
|
||||
starlette = [
|
||||
{version = ">=0.27", markers = "python_version < \"3.14\""},
|
||||
{version = ">=0.48.0", markers = "python_version >= \"3.14\""},
|
||||
]
|
||||
typing-extensions = ">=4.9.0"
|
||||
typing-inspection = ">=0.4.1"
|
||||
uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""}
|
||||
|
||||
[package.extras]
|
||||
cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"]
|
||||
rich = ["rich (>=13.9.4)"]
|
||||
ws = ["websockets (>=15.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "mdurl"
|
||||
version = "0.1.2"
|
||||
@ -3125,6 +3359,192 @@ files = [
|
||||
{file = "ndjson-0.3.1.tar.gz", hash = "sha256:bf9746cb6bb1cb53d172cda7f154c07c786d665ff28341e4e689b796b229e5d6"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "networkx"
|
||||
version = "3.6"
|
||||
description = "Python package for creating and manipulating graphs and networks"
|
||||
optional = false
|
||||
python-versions = ">=3.11"
|
||||
groups = ["main"]
|
||||
markers = "python_version == \"3.14\""
|
||||
files = [
|
||||
{file = "networkx-3.6-py3-none-any.whl", hash = "sha256:cdb395b105806062473d3be36458d8f1459a4e4b98e236a66c3a48996e07684f"},
|
||||
{file = "networkx-3.6.tar.gz", hash = "sha256:285276002ad1f7f7da0f7b42f004bcba70d381e936559166363707fdad3d72ad"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
benchmarking = ["asv", "virtualenv"]
|
||||
default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"]
|
||||
developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"]
|
||||
doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"]
|
||||
example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "iplotx (>=0.9.0)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"]
|
||||
extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"]
|
||||
release = ["build (>=0.10)", "changelist (==0.5)", "twine (>=4.0)", "wheel (>=0.40)"]
|
||||
test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"]
|
||||
test-extras = ["pytest-mpl", "pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
name = "networkx"
|
||||
version = "3.6.1"
|
||||
description = "Python package for creating and manipulating graphs and networks"
|
||||
optional = false
|
||||
python-versions = "!=3.14.1,>=3.11"
|
||||
groups = ["main"]
|
||||
markers = "python_version < \"3.14\""
|
||||
files = [
|
||||
{file = "networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762"},
|
||||
{file = "networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
benchmarking = ["asv", "virtualenv"]
|
||||
default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"]
|
||||
developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"]
|
||||
doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"]
|
||||
example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "iplotx (>=0.9.0)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"]
|
||||
extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"]
|
||||
release = ["build (>=0.10)", "changelist (==0.5)", "twine (>=4.0)", "wheel (>=0.40)"]
|
||||
test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"]
|
||||
test-extras = ["pytest-mpl", "pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.6"
|
||||
description = "Fundamental package for array computing in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.11"
|
||||
groups = ["main"]
|
||||
markers = "python_version < \"3.14\""
|
||||
files = [
|
||||
{file = "numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147"},
|
||||
{file = "numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751"},
|
||||
{file = "numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359"},
|
||||
{file = "numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75"},
|
||||
{file = "numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261"},
|
||||
{file = "numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627"},
|
||||
{file = "numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66"},
|
||||
{file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662"},
|
||||
{file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7"},
|
||||
{file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f"},
|
||||
{file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c"},
|
||||
{file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0"},
|
||||
{file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02"},
|
||||
{file = "numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73"},
|
||||
{file = "numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.5.0"
|
||||
description = "Fundamental package for array computing in Python"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
markers = "python_version == \"3.14\""
|
||||
files = [
|
||||
{file = "numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2"},
|
||||
{file = "numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748"},
|
||||
{file = "numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73"},
|
||||
{file = "numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa"},
|
||||
{file = "numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865"},
|
||||
{file = "numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
@ -3980,6 +4400,30 @@ files = [
|
||||
[package.dependencies]
|
||||
typing-extensions = ">=4.14.1"
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.14.2"
|
||||
description = "Settings management using Pydantic"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"},
|
||||
{file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pydantic = ">=2.7.0"
|
||||
python-dotenv = ">=0.21.0"
|
||||
typing-inspection = ">=0.4.0"
|
||||
|
||||
[package.extras]
|
||||
aws-secrets-manager = ["boto3 (>=1.35.0)", "types-boto3[secretsmanager]"]
|
||||
azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"]
|
||||
gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
|
||||
toml = ["tomli (>=2.0.1)"]
|
||||
yaml = ["pyyaml (>=6.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "pyflakes"
|
||||
version = "3.4.0"
|
||||
@ -4330,14 +4774,14 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pyt
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "0.20.0"
|
||||
version = "1.2.2"
|
||||
description = "Read key-value pairs from a .env file and set them as environment variables"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python-dotenv-0.20.0.tar.gz", hash = "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f"},
|
||||
{file = "python_dotenv-0.20.0-py3-none-any.whl", hash = "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"},
|
||||
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
|
||||
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@ -4355,6 +4799,18 @@ files = [
|
||||
{file = "python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.32"
|
||||
description = "A streaming multipart parser for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"},
|
||||
{file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python3-openid"
|
||||
version = "3.2.0"
|
||||
@ -4386,6 +4842,38 @@ files = [
|
||||
{file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "312"
|
||||
description = "Python for Windows Extensions"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "sys_platform == \"win32\""
|
||||
files = [
|
||||
{file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"},
|
||||
{file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"},
|
||||
{file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"},
|
||||
{file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"},
|
||||
{file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"},
|
||||
{file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"},
|
||||
{file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"},
|
||||
{file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"},
|
||||
{file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"},
|
||||
{file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"},
|
||||
{file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"},
|
||||
{file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"},
|
||||
{file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"},
|
||||
{file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"},
|
||||
{file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"},
|
||||
{file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"},
|
||||
{file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"},
|
||||
{file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"},
|
||||
{file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"},
|
||||
{file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"},
|
||||
{file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
@ -4646,6 +5134,23 @@ async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2
|
||||
hiredis = ["hiredis (>=1.0.0)"]
|
||||
ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
description = "JSON Referencing + Python"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"},
|
||||
{file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
attrs = ">=22.2.0"
|
||||
rpds-py = ">=0.7.0"
|
||||
typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2026.2.28"
|
||||
@ -4874,6 +5379,146 @@ pygments = ">=2.13.0,<3.0.0"
|
||||
[package.extras]
|
||||
jupyter = ["ipywidgets (>=7.5.1,<9)"]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "2026.5.1"
|
||||
description = "Python bindings to Rust's persistent data structures (rpds)"
|
||||
optional = false
|
||||
python-versions = ">=3.11"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a"},
|
||||
{file = "rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870"},
|
||||
{file = "rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc"},
|
||||
{file = "rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a"},
|
||||
{file = "rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df"},
|
||||
{file = "rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c"},
|
||||
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049"},
|
||||
{file = "rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.16.0"
|
||||
@ -4909,6 +5554,21 @@ files = [
|
||||
cryptography = ">=2.0"
|
||||
jeepney = ">=0.6"
|
||||
|
||||
[[package]]
|
||||
name = "segtok"
|
||||
version = "1.5.11"
|
||||
description = "sentence segmentation and word tokenization tools"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "segtok-1.5.11-py3-none-any.whl", hash = "sha256:910616b76198c3141b2772df530270d3b706e42ae69a5b30ef115c7bd5d1501a"},
|
||||
{file = "segtok-1.5.11.tar.gz", hash = "sha256:8ab2dd44245bcbfec25b575dc4618473bbdf2af8c2649698cd5a370f42f3db23"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
regex = "*"
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "82.0.1"
|
||||
@ -5017,6 +5677,29 @@ files = [
|
||||
dev = ["build"]
|
||||
doc = ["sphinx"]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "3.4.5"
|
||||
description = "SSE plugin for Starlette"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296"},
|
||||
{file = "sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=4.7.0"
|
||||
starlette = ">=0.49.1"
|
||||
|
||||
[package.extras]
|
||||
daphne = ["daphne (>=4.2.0)"]
|
||||
examples = ["fastapi (>=0.115.12)", "pydantic (>=2)", "uvicorn (>=0.34.0)"]
|
||||
examples-db = ["aiosqlite (>=0.21.0)", "sqlalchemy[asyncio] (>=2.0.41)"]
|
||||
granian = ["granian (>=2.3.1)"]
|
||||
uvicorn = ["uvicorn (>=0.34.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "stack-data"
|
||||
version = "0.6.3"
|
||||
@ -5037,6 +5720,25 @@ pure-eval = "*"
|
||||
[package.extras]
|
||||
tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.3.1"
|
||||
description = "The little ASGI library that shines."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"},
|
||||
{file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.6.2,<5"
|
||||
typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
|
||||
|
||||
[package.extras]
|
||||
full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "stevedore"
|
||||
version = "5.7.0"
|
||||
@ -5061,6 +5763,21 @@ files = [
|
||||
{file = "stream_sqlite-0.0.41-py3-none-any.whl", hash = "sha256:3aa1bbf4b50eb67df7e5f56b9bbe828b31750c05c9bd883be29d15b8bdc016f5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tabulate"
|
||||
version = "0.10.0"
|
||||
description = "Pretty-print tabular data"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3"},
|
||||
{file = "tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
widechars = ["wcwidth"]
|
||||
|
||||
[[package]]
|
||||
name = "thefuzz"
|
||||
version = "0.22.1"
|
||||
@ -5439,6 +6156,18 @@ tzdata = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
[package.extras]
|
||||
devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
|
||||
|
||||
[[package]]
|
||||
name = "uritemplate"
|
||||
version = "4.2.0"
|
||||
description = "Implementation of RFC 6570 URI Templates"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686"},
|
||||
{file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url-normalize"
|
||||
version = "2.2.1"
|
||||
@ -5474,6 +6203,26 @@ brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and p
|
||||
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.49.0"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "sys_platform != \"emscripten\""
|
||||
files = [
|
||||
{file = "uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f"},
|
||||
{file = "uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=7.0"
|
||||
h11 = ">=0.8"
|
||||
|
||||
[package.extras]
|
||||
standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.8.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "vadersentiment"
|
||||
version = "3.3.2"
|
||||
@ -5776,6 +6525,31 @@ files = [
|
||||
[package.extras]
|
||||
test = ["pytest", "pytest-cov"]
|
||||
|
||||
[[package]]
|
||||
name = "yake"
|
||||
version = "0.7.3"
|
||||
description = "Keyword extraction Python package"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "yake-0.7.3-py3-none-any.whl", hash = "sha256:38f7f135ff8ed4bcdc05e16b533a9dc93299f1e694b0c308c3c086bab316c5fe"},
|
||||
{file = "yake-0.7.3.tar.gz", hash = "sha256:8778fb2832e58d26d838d6d7ac967b4947521f1fe8cdf23dd872636161fc53ed"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
click = ">=6.0"
|
||||
jellyfish = "*"
|
||||
networkx = "*"
|
||||
numpy = ">=1.24.0"
|
||||
segtok = "*"
|
||||
tabulate = "*"
|
||||
|
||||
[package.extras]
|
||||
benchmark = ["matplotlib (>=3.5.0)", "memory-profiler (>=0.60.0)", "pytest-benchmark (>=4.0.0)"]
|
||||
dev = ["black (>=22.0.0)", "flake8 (>=6.0.0)", "pylint (>=3.3.0)", "pytest (>=7.0.0)", "pytest-cov (>=4.0.0)", "ruff (>=0.1.0)"]
|
||||
lemmatization = ["nltk (>=3.8.0)", "spacy (>=3.8.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.23.0"
|
||||
@ -6055,4 +6829,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.15"
|
||||
content-hash = "aafab54d3c3d674b917782bf449b7d6324ca2259fb58bff13a08caabe110c342"
|
||||
content-hash = "beac677c269bb8618ca802e5f92f7558391d8d26f1b2150f3c8a6d3417848cb1"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "57.0"
|
||||
version = "59.4"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
@ -11,6 +11,7 @@ django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
python-dotenv = ">=0.20.0,<2"
|
||||
python-json-logger = "^2.0.2"
|
||||
cloudscraper = "^1.2.71"
|
||||
colorlog = "^6.6.0"
|
||||
httpx = "<=0.27.2"
|
||||
djangorestframework = "^3.13.1"
|
||||
@ -66,6 +67,7 @@ lxml = ">=5.5.0"
|
||||
vaderSentiment = "^3.3.2"
|
||||
sqids = "^0.5.2"
|
||||
python-amazon-paapi = "^6.3.0"
|
||||
yake = "^0.7.3"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
@ -5,6 +5,7 @@ from boardgames.models import (
|
||||
BoardGameLocation,
|
||||
BoardGamePublisher,
|
||||
BoardGameDesigner,
|
||||
BoardGameVariant,
|
||||
)
|
||||
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
@ -42,6 +43,19 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGameVariant)
|
||||
class BoardGameVariantAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"name",
|
||||
"board_game",
|
||||
"uuid",
|
||||
)
|
||||
raw_id_fields = ("board_game",)
|
||||
search_fields = ("name", "board_game__title")
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(BoardGame)
|
||||
class BoardGameAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
|
||||
@ -20,6 +20,12 @@ class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGameVariantSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGameVariant
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.BoardGame
|
||||
|
||||
@ -22,6 +22,12 @@ class BoardGameLocationViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameVariantViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGameVariant.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameVariantSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BoardGameViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BoardGame.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BoardGameSerializer
|
||||
|
||||
0
vrobbler/apps/boardgames/management/__init__.py
Normal file
0
vrobbler/apps/boardgames/management/__init__.py
Normal file
@ -0,0 +1,81 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Refresh board game metadata from BGG (categories→genres, families→tags)"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Persist changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Update all games even if they already have a published_date",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Number of games to process per batch (default: 50)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Seconds to sleep between API calls (default: 1.0)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from boardgames.models import BoardGame
|
||||
|
||||
commit = options["commit"]
|
||||
force = options["force"]
|
||||
batch_size = options["batch_size"]
|
||||
sleep_secs = options["sleep"]
|
||||
|
||||
qs = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude(bggeek_id="")
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Found {total} board games with BGG IDs")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no API calls will be made. Use --commit to run lookups."
|
||||
)
|
||||
return
|
||||
|
||||
enriched = 0
|
||||
skipped = 0
|
||||
|
||||
for batch_num, offset in enumerate(range(0, total, batch_size)):
|
||||
batch = qs[offset : offset + batch_size]
|
||||
for game in batch:
|
||||
try:
|
||||
game.fix_metadata(force_update=force)
|
||||
enriched += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" [SKIPPED] {game.title} (BGG {game.bggeek_id}): {e}"
|
||||
)
|
||||
)
|
||||
skipped += 1
|
||||
time.sleep(sleep_secs)
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
||||
f"enriched: {enriched}, skipped: {skipped}"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults:\n"
|
||||
f" Games enriched: {enriched}\n"
|
||||
f" Games skipped: {skipped}"
|
||||
)
|
||||
@ -0,0 +1,63 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from boardgames.utils import board_names_to_variants
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Convert existing board scrobble log 'board' keys to 'variant_ids'"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Persist changes to the database",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
commit = options.get("commit", False)
|
||||
|
||||
board_scrobbles = Scrobble.objects.filter(
|
||||
board_game__isnull=False,
|
||||
log__board__isnull=False,
|
||||
).exclude(log__board="")
|
||||
|
||||
total = board_scrobbles.count()
|
||||
self.stdout.write(f"Found {total} scrobbles with a 'board' key in log data")
|
||||
|
||||
if total == 0:
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for scrobble in board_scrobbles.iterator(chunk_size=100):
|
||||
log = scrobble.log
|
||||
board_value = log.pop("board", None)
|
||||
if not board_value:
|
||||
continue
|
||||
|
||||
variant_ids = board_names_to_variants(
|
||||
scrobble.board_game, [board_value]
|
||||
)
|
||||
if variant_ids:
|
||||
log["variant_ids"] = variant_ids
|
||||
|
||||
if commit:
|
||||
Scrobble.objects.filter(pk=scrobble.pk).update(log=log)
|
||||
updated += 1
|
||||
else:
|
||||
updated += 1
|
||||
|
||||
if commit:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Updated {updated} scrobbles (changes committed)"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f"Would update {updated} scrobbles (pass --commit to persist)"
|
||||
)
|
||||
@ -0,0 +1,80 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from boardgames.models import BoardGame
|
||||
from boardgames.sources.bgg import lookup_boardgame_from_bgg
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fetch and link expansions for existing board games from BGG"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Persist changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bggeek-id",
|
||||
type=str,
|
||||
help="Only process a single game by BGG ID",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
commit = options.get("commit", False)
|
||||
bggeek_id = options.get("bggeek_id")
|
||||
|
||||
games = (
|
||||
BoardGame.objects.exclude(bggeek_id__isnull=True)
|
||||
.exclude(bggeek_id="")
|
||||
.exclude(skip_expansions=True)
|
||||
)
|
||||
if bggeek_id:
|
||||
games = games.filter(bggeek_id=bggeek_id)
|
||||
|
||||
total = games.count()
|
||||
self.stdout.write(f"Found {total} board games with BGG IDs")
|
||||
|
||||
if total == 0:
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for game in games.iterator(chunk_size=100):
|
||||
try:
|
||||
data = lookup_boardgame_from_bgg(lookup_id=str(game.bggeek_id))
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Failed to fetch BGG data for {game.title} ({game.bggeek_id}): {e}"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
expansions = data.get("expansions", [])
|
||||
if not expansions:
|
||||
continue
|
||||
|
||||
if commit:
|
||||
fetch_and_link_expansions(game, expansions)
|
||||
updated += 1
|
||||
self.stdout.write(
|
||||
f" Linked {len(expansions)} expansions to {game.title}"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" Would link {len(expansions)} expansions to {game.title}"
|
||||
)
|
||||
updated += 1
|
||||
|
||||
if commit:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Updated {updated} games (changes committed)")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f"Would update {updated} games (pass --commit to persist)"
|
||||
)
|
||||
62
vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py
Normal file
62
vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py
Normal file
@ -0,0 +1,62 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-02 22:23
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0015_alter_boardgame_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BoardGameVariant",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"board_game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="variants",
|
||||
to="boardgames.boardgame",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-04 15:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0016_boardgamevariant"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="boardgame",
|
||||
name="skip_expansions",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -71,11 +71,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
difficulty: Optional[int] = None
|
||||
solo: Optional[bool] = None
|
||||
two_handed: Optional[bool] = None
|
||||
expansion_ids: Optional[int] = None
|
||||
expansion_ids: Optional[list[int]] = None
|
||||
moves: Optional[list] = None
|
||||
rated: Optional[str] = None
|
||||
speed: Optional[str] = None
|
||||
variant: Optional[str] = None
|
||||
variant_ids: Optional[list[int]] = None
|
||||
lichess_id: Optional[int] = None
|
||||
board: Optional[str] = None
|
||||
rounds: Optional[int] = None
|
||||
@ -92,6 +93,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from boardgames.widgets import VariantSelectWidget
|
||||
from scrobbles.forms import NotesDictField
|
||||
|
||||
fields = {}
|
||||
@ -106,10 +108,30 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
),
|
||||
"variant_ids": forms.ModelMultipleChoiceField(
|
||||
queryset=BoardGameVariant.objects.all(),
|
||||
required=False,
|
||||
widget=VariantSelectWidget(attrs={"size": 5}),
|
||||
),
|
||||
"expansion_ids": forms.ModelMultipleChoiceField(
|
||||
queryset=BoardGame.objects.filter(
|
||||
expansion_for_boardgame__isnull=False
|
||||
),
|
||||
required=False,
|
||||
widget=forms.SelectMultiple(attrs={"size": 5}),
|
||||
),
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
@cached_property
|
||||
def variants(self) -> list["BoardGameVariant"]:
|
||||
if not self.variant_ids:
|
||||
return []
|
||||
return list(
|
||||
BoardGameVariant.objects.filter(id__in=self.variant_ids)
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def location(self):
|
||||
if not self.location_id:
|
||||
@ -120,7 +142,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
def player_log(self) -> str:
|
||||
if self.players:
|
||||
return ", ".join(
|
||||
[BoardGameScoreLogData(**player).__str__() for player in self.players]
|
||||
[
|
||||
BoardGameScoreLogData(
|
||||
**{k: v for k, v in player.items() if k in BoardGameScoreLogData.__dataclass_fields__}
|
||||
).__str__()
|
||||
for player in self.players
|
||||
]
|
||||
)
|
||||
return ""
|
||||
|
||||
@ -130,13 +157,21 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
if self.board:
|
||||
html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
|
||||
|
||||
if self.variants:
|
||||
variant_names = ", ".join(v.name for v in self.variants)
|
||||
html_parts.append(
|
||||
f'<div class="boardgame-variants">Variants: {variant_names}</div>'
|
||||
)
|
||||
|
||||
if self.location:
|
||||
html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
|
||||
|
||||
if self.players:
|
||||
players_html = []
|
||||
for player_data in self.players:
|
||||
player = BoardGameScoreLogData(**player_data)
|
||||
player = BoardGameScoreLogData(
|
||||
**{k: v for k, v in player_data.items() if k in BoardGameScoreLogData.__dataclass_fields__}
|
||||
)
|
||||
player_info = player.name
|
||||
if player.score:
|
||||
player_info += f" ({player.score})"
|
||||
@ -265,6 +300,7 @@ class BoardGame(ScrobblableMixin):
|
||||
expansion_for_boardgame = models.ForeignKey(
|
||||
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
skip_expansions = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
@ -299,18 +335,29 @@ class BoardGame(ScrobblableMixin):
|
||||
if not data:
|
||||
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
||||
|
||||
cover_url = data.pop("cover_url")
|
||||
year = data.pop("year_published")
|
||||
publisher_name = data.pop("publisher_name")
|
||||
expansions = data.pop("expansions", [])
|
||||
cover_url = data.pop("cover_url", "")
|
||||
year_published = data.pop("year_published", None)
|
||||
if year_published is None:
|
||||
year_published = data.pop("published_year", None)
|
||||
publisher_name = data.pop("publisher_name", "")
|
||||
|
||||
if year:
|
||||
data["published_year"] = int(year)
|
||||
if year_published:
|
||||
data["published_year"] = int(year_published)
|
||||
|
||||
if not data["min_players"]:
|
||||
data.pop("min_players")
|
||||
if not data["min_players"]:
|
||||
data.pop("max_players")
|
||||
|
||||
# Pop extra BGG metadata that isn't a model field
|
||||
categories = data.pop("categories", [])
|
||||
families = data.pop("families", [])
|
||||
data.pop("mechanics", None)
|
||||
data.pop("designers", None)
|
||||
data.pop("publishers", None)
|
||||
data.pop("publisher", None)
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
BoardGame.objects.filter(pk=self.id).update(**data)
|
||||
self.refresh_from_db()
|
||||
@ -322,10 +369,20 @@ class BoardGame(ScrobblableMixin):
|
||||
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
|
||||
self.save()
|
||||
|
||||
for cat in categories:
|
||||
self.genre.add(cat.strip())
|
||||
for fam in families:
|
||||
self.tags.add(fam.strip())
|
||||
|
||||
# Go get cover image if the URL is present
|
||||
if cover_url and not self.cover:
|
||||
self.save_image_from_url(cover_url)
|
||||
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
if not self.skip_expansions:
|
||||
fetch_and_link_expansions(self, expansions)
|
||||
|
||||
def save_image_from_url(self, url):
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
@ -334,7 +391,12 @@ class BoardGame(ScrobblableMixin):
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
|
||||
def find_or_create(
|
||||
cls,
|
||||
lookup_id: str,
|
||||
data: dict[str, Any] = {},
|
||||
defer_expansions: bool = False,
|
||||
) -> "BoardGame":
|
||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
if not game:
|
||||
@ -357,16 +419,19 @@ class BoardGame(ScrobblableMixin):
|
||||
else:
|
||||
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
||||
|
||||
expansions = bgg_data.pop("expansions", [])
|
||||
mechanics = bgg_data.pop("mechanics", [])
|
||||
designers = bgg_data.pop("designers", [])
|
||||
categories = bgg_data.pop("categories", [])
|
||||
families = bgg_data.pop("families", [])
|
||||
publishers = bgg_data.pop("publishers", [])
|
||||
publisher = bgg_data.pop("publisher", [])
|
||||
cover_url = bgg_data.pop("cover_url")
|
||||
cover_url = bgg_data.pop("cover_url") or ""
|
||||
|
||||
game = cls.objects.create(**bgg_data)
|
||||
|
||||
game.save_image_from_url(cover_url)
|
||||
if cover_url:
|
||||
game.save_image_from_url(cover_url)
|
||||
game.cooperative = data.get("cooperative", False)
|
||||
game.highest_wins = data.get("highestWins", True)
|
||||
game.no_points = data.get("noPoints", False)
|
||||
@ -375,6 +440,18 @@ class BoardGame(ScrobblableMixin):
|
||||
if publisher:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
|
||||
game.publisher = publisher
|
||||
|
||||
skip_expansions = (
|
||||
game.bggeek_id is not None
|
||||
and str(game.bggeek_id).isdigit()
|
||||
and int(game.bggeek_id) in settings.SKIP_AUTO_EXPANSION_DOWNLOAD
|
||||
) or any(
|
||||
c == "Collectible Card Games" for c in categories
|
||||
)
|
||||
|
||||
if skip_expansions:
|
||||
game.skip_expansions = True
|
||||
|
||||
game.save()
|
||||
|
||||
if designers:
|
||||
@ -389,4 +466,33 @@ class BoardGame(ScrobblableMixin):
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
|
||||
game.publishers.add(publisher)
|
||||
|
||||
for cat in categories:
|
||||
game.genre.add(cat.strip())
|
||||
for fam in families:
|
||||
game.tags.add(fam.strip())
|
||||
|
||||
if expansions and not game.skip_expansions:
|
||||
if defer_expansions:
|
||||
from boardgames.tasks import fetch_board_game_expansions
|
||||
|
||||
fetch_board_game_expansions.delay(game.id, expansions)
|
||||
else:
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
fetch_and_link_expansions(game, expansions)
|
||||
|
||||
return game
|
||||
|
||||
|
||||
class BoardGameVariant(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
board_game = models.ForeignKey(
|
||||
BoardGame,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="variants",
|
||||
)
|
||||
description = models.TextField(**BNULL)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} ({self.board_game.title})"
|
||||
|
||||
@ -32,9 +32,13 @@ def lookup_boardgame_from_bgg(
|
||||
)
|
||||
game_dict["mechanics"] = game.mechanics
|
||||
game_dict["categories"] = game.categories
|
||||
game_dict["families"] = game.families
|
||||
game_dict["designers"] = game.designers
|
||||
game_dict["publishers"] = game.publishers
|
||||
if game.publishers:
|
||||
game_dict["publisher"] = game.publishers[0]
|
||||
game_dict["expansions"] = [
|
||||
{"id": exp.id, "name": exp.name} for exp in game.expansions
|
||||
]
|
||||
|
||||
return game_dict
|
||||
|
||||
29
vrobbler/apps/boardgames/tasks.py
Normal file
29
vrobbler/apps/boardgames/tasks.py
Normal file
@ -0,0 +1,29 @@
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def fetch_board_game_expansions(board_game_id, expansions_data):
|
||||
from boardgames.models import BoardGame
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
game = BoardGame.objects.filter(id=board_game_id).first()
|
||||
if not game:
|
||||
logger.warning(
|
||||
"Board game not found for expansion linking",
|
||||
extra={"board_game_id": board_game_id},
|
||||
)
|
||||
return
|
||||
|
||||
fetch_and_link_expansions(game, expansions_data)
|
||||
logger.info(
|
||||
"Linked expansions for board game",
|
||||
extra={
|
||||
"board_game_id": board_game_id,
|
||||
"title": game.title,
|
||||
"count": len(expansions_data),
|
||||
},
|
||||
)
|
||||
@ -0,0 +1,97 @@
|
||||
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
|
||||
{% for group_name, group_choices, group_index in widget.optgroups %}
|
||||
{% for option in group_choices %}
|
||||
{% include option.template_name with widget=option %}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" data-bs-toggle="modal" data-bs-target="#addVariantModal">
|
||||
+ Add variant
|
||||
</button>
|
||||
|
||||
<div class="modal fade" id="addVariantModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add Variant</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="newVariantName" class="form-label">Name</label>
|
||||
<input type="text" class="form-control" id="newVariantName" placeholder="e.g. Map A">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="newVariantDescription" class="form-label">Description (optional)</label>
|
||||
<input type="text" class="form-control" id="newVariantDescription">
|
||||
</div>
|
||||
<p class="text-danger d-none" id="variantError"></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="saveVariantBtn">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var select = document.getElementById('{{ widget.attrs.id }}');
|
||||
if (!select) return;
|
||||
|
||||
var saveBtn = document.getElementById('saveVariantBtn');
|
||||
if (!saveBtn) return;
|
||||
|
||||
var modalEl = document.getElementById('addVariantModal');
|
||||
var nameInput = document.getElementById('newVariantName');
|
||||
var descInput = document.getElementById('newVariantDescription');
|
||||
var errorEl = document.getElementById('variantError');
|
||||
|
||||
saveBtn.addEventListener('click', function() {
|
||||
var name = nameInput.value.trim();
|
||||
if (!name) return;
|
||||
|
||||
var boardGameId = select.getAttribute('data-board-game-id');
|
||||
var ajaxUrl = select.getAttribute('data-ajax-url');
|
||||
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
if (!csrfToken) return;
|
||||
|
||||
errorEl.classList.add('d-none');
|
||||
|
||||
fetch(ajaxUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': csrfToken.value,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
name: name,
|
||||
description: descInput.value.trim(),
|
||||
board_game_id: boardGameId,
|
||||
}),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
errorEl.textContent = data.error;
|
||||
errorEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
var opt = document.createElement('option');
|
||||
opt.value = data.id;
|
||||
opt.textContent = data.name;
|
||||
opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
var modal = bootstrap.Modal.getInstance(modalEl);
|
||||
if (modal) modal.hide();
|
||||
nameInput.value = '';
|
||||
descInput.value = '';
|
||||
})
|
||||
.catch(function() {
|
||||
errorEl.textContent = 'Failed to create variant';
|
||||
errorEl.classList.remove('d-none');
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
0
vrobbler/apps/boardgames/tests/__init__.py
Normal file
0
vrobbler/apps/boardgames/tests/__init__.py
Normal file
226
vrobbler/apps/boardgames/tests/test_models.py
Normal file
226
vrobbler/apps/boardgames/tests/test_models.py
Normal file
@ -0,0 +1,226 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from boardgames.models import BoardGame, BoardGameVariant
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_creation():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name="Test Variant",
|
||||
board_game=game,
|
||||
description="A test variant",
|
||||
)
|
||||
assert variant.name == "Test Variant"
|
||||
assert variant.board_game == game
|
||||
assert variant.description == "A test variant"
|
||||
assert variant.uuid is not None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_str():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name="Test Variant",
|
||||
board_game=game,
|
||||
)
|
||||
assert str(variant) == "Test Variant (Test Game)"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_optional_description():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name="Test Variant",
|
||||
board_game=game,
|
||||
)
|
||||
assert variant.description is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_related_name():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant1 = BoardGameVariant.objects.create(
|
||||
name="Variant 1",
|
||||
board_game=game,
|
||||
)
|
||||
variant2 = BoardGameVariant.objects.create(
|
||||
name="Variant 2",
|
||||
board_game=game,
|
||||
)
|
||||
assert list(game.variants.all()) == [variant1, variant2]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_game_variant_cascade_delete():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name="Test Variant",
|
||||
board_game=game,
|
||||
)
|
||||
game.delete()
|
||||
assert BoardGameVariant.objects.count() == 0
|
||||
|
||||
|
||||
def _mock_bgg_game(bggeek_id, title, expansions=None):
|
||||
"""Build a fake BGG game object shape used by lookup_boardgame_from_bgg."""
|
||||
class FakeGame:
|
||||
id = bggeek_id
|
||||
name = title
|
||||
description = f"Description of {title}"
|
||||
yearpublished = 2020
|
||||
image = "https://example.com/cover.jpg"
|
||||
minplayers = 1
|
||||
maxplayers = 4
|
||||
minage = 8
|
||||
rating_average = 7.5
|
||||
bgg_rank = 100
|
||||
playingtime = 60
|
||||
mechanics = []
|
||||
categories = []
|
||||
families = []
|
||||
designers = []
|
||||
publishers = []
|
||||
|
||||
@property
|
||||
def expansions(self):
|
||||
if expansions is None:
|
||||
return []
|
||||
return expansions
|
||||
|
||||
return FakeGame()
|
||||
|
||||
|
||||
def _mock_bgg_client(return_game):
|
||||
"""Return a callable that creates a fake BGGClient instance."""
|
||||
class FakeBGGClient:
|
||||
def __init__(self, access_token=None):
|
||||
pass
|
||||
|
||||
def game(self, game_id=None, name=None):
|
||||
return return_game
|
||||
|
||||
return FakeBGGClient()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_find_or_create_links_expansions(mock_bgg, mock_get):
|
||||
exp1 = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
exp2 = type("Thing", (), {"id": 201, "name": "Expansion 2"})()
|
||||
base_game = _mock_bgg_game("100", "Base Game", expansions=[exp1, exp2])
|
||||
exp1_game = _mock_bgg_game("200", "Expansion 1")
|
||||
exp2_game = _mock_bgg_game("201", "Expansion 2")
|
||||
mock_bgg.side_effect = [
|
||||
_mock_bgg_client(base_game),
|
||||
_mock_bgg_client(exp1_game),
|
||||
_mock_bgg_client(exp2_game),
|
||||
]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
game = BoardGame.find_or_create("100")
|
||||
assert game.title == "Base Game"
|
||||
|
||||
expansions = BoardGame.objects.filter(expansion_for_boardgame=game)
|
||||
assert expansions.count() == 2
|
||||
assert {e.title for e in expansions} == {"Expansion 1", "Expansion 2"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_find_or_create_skips_expansions_for_existing_game(mock_bgg):
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
mock_bgg.assert_not_called()
|
||||
|
||||
result = BoardGame.find_or_create("100")
|
||||
assert result == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_fetch_and_link_expansions(mock_bgg, mock_get):
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_game = _mock_bgg_game("200", "Expansion 1")
|
||||
mock_bgg.side_effect = [_mock_bgg_client(exp_game)]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
fetch_and_link_expansions(game, [{"id": 200, "name": "Expansion 1"}])
|
||||
|
||||
expansion = BoardGame.objects.get(bggeek_id="200")
|
||||
assert expansion.expansion_for_boardgame == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_fix_metadata_links_expansions(mock_bgg, mock_get):
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
|
||||
exp_game = _mock_bgg_game("200", "Expansion 1")
|
||||
# first call = fix_metadata -> lookup_boardgame_from_bgg(lookup_id='100')
|
||||
# second call = find_or_create inside fetch_and_link_expansions for expansion
|
||||
mock_bgg.side_effect = [
|
||||
_mock_bgg_client(base_with_exp),
|
||||
_mock_bgg_client(exp_game),
|
||||
]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
game.fix_metadata(force_update=True)
|
||||
|
||||
expansion = BoardGame.objects.get(bggeek_id="200")
|
||||
assert expansion.expansion_for_boardgame == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_management_command_fetch_expansions_dry_run(mock_bgg, capsys):
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
|
||||
mock_bgg.side_effect = [_mock_bgg_client(base_with_exp)]
|
||||
|
||||
call_command("fetch_expansions")
|
||||
captured = capsys.readouterr()
|
||||
assert "Would link 1 expansions" in captured.out
|
||||
|
||||
assert BoardGame.objects.filter(bggeek_id="200").count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_management_command_fetch_expansions_commit(mock_bgg, mock_get, capsys):
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
exp_game = _mock_bgg_game("200", "Expansion 1")
|
||||
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
|
||||
mock_bgg.side_effect = [
|
||||
_mock_bgg_client(base_with_exp),
|
||||
_mock_bgg_client(exp_game),
|
||||
]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
call_command("fetch_expansions", commit=True)
|
||||
captured = capsys.readouterr()
|
||||
assert "Updated 1 games" in captured.out
|
||||
|
||||
expansion = BoardGame.objects.get(bggeek_id="200")
|
||||
assert expansion.expansion_for_boardgame == game
|
||||
106
vrobbler/apps/boardgames/tests/test_utils.py
Normal file
106
vrobbler/apps/boardgames/tests/test_utils.py
Normal file
@ -0,0 +1,106 @@
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from boardgames.models import BoardGame, BoardGameVariant
|
||||
from boardgames.utils import board_names_to_variants
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_creates_variant():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
ids = board_names_to_variants(game, ["Map A"])
|
||||
assert len(ids) == 1
|
||||
variant = BoardGameVariant.objects.get(id=ids[0])
|
||||
assert variant.name == "Map A"
|
||||
assert variant.board_game == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_reuses_existing():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
existing = BoardGameVariant.objects.create(
|
||||
name="Map A", board_game=game
|
||||
)
|
||||
ids = board_names_to_variants(game, ["Map A"])
|
||||
assert len(ids) == 1
|
||||
assert ids[0] == existing.id
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_multiple_names():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
ids = board_names_to_variants(game, ["Map A", "Map B"])
|
||||
assert len(ids) == 2
|
||||
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
|
||||
assert names == {"Map A", "Map B"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_splits_fullwidth_slash():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
ids = board_names_to_variants(game, ["Map A/Map B"])
|
||||
assert len(ids) == 2
|
||||
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
|
||||
assert names == {"Map A", "Map B"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_skips_empty_parts():
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
ids = board_names_to_variants(game, ["/Map A/"])
|
||||
assert len(ids) == 1
|
||||
assert BoardGameVariant.objects.get(id=ids[0]).name == "Map A"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_names_to_variants_different_games_independent():
|
||||
game1 = BoardGame.objects.create(title="Game 1")
|
||||
game2 = BoardGame.objects.create(title="Game 2")
|
||||
ids1 = board_names_to_variants(game1, ["Map A"])
|
||||
ids2 = board_names_to_variants(game2, ["Map A"])
|
||||
assert ids1 != ids2
|
||||
assert BoardGameVariant.objects.count() == 2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_management_command_dry_run(capsys):
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
user = User.objects.create(username="tester")
|
||||
scrobble = Scrobble.objects.create(
|
||||
user=user,
|
||||
board_game=game,
|
||||
log={"board": "Map A"},
|
||||
)
|
||||
|
||||
call_command("convert_board_to_variants")
|
||||
captured = capsys.readouterr()
|
||||
assert "Would update 1 scrobbles" in captured.out
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert "board" in scrobble.log
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_management_command_commit():
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Test Game")
|
||||
user = User.objects.create(username="tester")
|
||||
scrobble = Scrobble.objects.create(
|
||||
user=user,
|
||||
board_game=game,
|
||||
log={"board": "Map A"},
|
||||
)
|
||||
|
||||
call_command("convert_board_to_variants", commit=True)
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert "board" not in scrobble.log
|
||||
assert "variant_ids" in scrobble.log
|
||||
variant = BoardGameVariant.objects.get(board_game=game, name="Map A")
|
||||
assert variant.id in scrobble.log["variant_ids"]
|
||||
@ -20,4 +20,9 @@ urlpatterns = [
|
||||
views.BoardGamePublisherDetailView.as_view(),
|
||||
name="publisher_detail",
|
||||
),
|
||||
path(
|
||||
"variants/ajax-create/",
|
||||
views.ajax_create_variant,
|
||||
name="ajax-create-variant",
|
||||
),
|
||||
]
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from boardgames.models import BoardGame, BoardGameVariant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def board_names_to_variants(
|
||||
board_game: BoardGame, board_names: list[str]
|
||||
) -> list[int]:
|
||||
"""Given a board game and a list of board/scenario names, find or create
|
||||
BoardGameVariant records and return their IDs.
|
||||
|
||||
Splits each name on the full-width slash ``/`` so that a single field
|
||||
containing ``Map A/Map B`` produces two separate variants.
|
||||
"""
|
||||
variant_ids: list[int] = []
|
||||
for raw_name in board_names:
|
||||
for part in raw_name.split("/"):
|
||||
name = part.strip()
|
||||
if not name:
|
||||
continue
|
||||
variant, was_created = BoardGameVariant.objects.get_or_create(
|
||||
board_game=board_game,
|
||||
name=name,
|
||||
)
|
||||
logger.debug(
|
||||
"Resolved board variant",
|
||||
extra={
|
||||
"board_game_id": board_game.id,
|
||||
"variant_name": name,
|
||||
"variant_id": variant.id,
|
||||
"was_created": was_created,
|
||||
},
|
||||
)
|
||||
variant_ids.append(variant.id)
|
||||
return variant_ids
|
||||
|
||||
|
||||
def fetch_and_link_expansions(
|
||||
board_game: BoardGame, expansions_data: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Given a board game and a list of expansion dicts (with 'id' and 'name'),
|
||||
find or create each expansion BoardGame and link it via expansion_for_boardgame.
|
||||
"""
|
||||
if board_game.skip_expansions:
|
||||
logger.info(
|
||||
"Skipping expansion fetch for board game with skip_expansions=True",
|
||||
extra={"board_game_id": board_game.id, "title": board_game.title},
|
||||
)
|
||||
return
|
||||
|
||||
for exp_data in expansions_data:
|
||||
exp_id = exp_data.get("id")
|
||||
if not exp_id:
|
||||
continue
|
||||
expansion = BoardGame.find_or_create(str(exp_id))
|
||||
if expansion and expansion.id != board_game.id:
|
||||
expansion.expansion_for_boardgame = board_game
|
||||
expansion.save(update_fields=["expansion_for_boardgame"])
|
||||
logger.info(
|
||||
"Linked expansion to board game",
|
||||
extra={
|
||||
"board_game_id": board_game.id,
|
||||
"expansion_id": expansion.id,
|
||||
"expansion_name": expansion.title,
|
||||
},
|
||||
)
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import datetime
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views import generic
|
||||
from django.views.decorators.http import require_POST
|
||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.views import (
|
||||
@ -10,6 +13,38 @@ from scrobbles.views import (
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
def ajax_create_variant(request):
|
||||
name = request.POST.get("name", "").strip()
|
||||
board_game_id = request.POST.get("board_game_id")
|
||||
description = request.POST.get("description", "").strip()
|
||||
|
||||
if not name or not board_game_id:
|
||||
return JsonResponse({"error": "Name and board game are required"}, status=400)
|
||||
|
||||
try:
|
||||
board_game_id = int(board_game_id)
|
||||
except (ValueError, TypeError):
|
||||
return JsonResponse({"error": "Invalid board game"}, status=400)
|
||||
|
||||
from boardgames.models import BoardGameVariant
|
||||
|
||||
variant = BoardGameVariant.objects.filter(
|
||||
name__iexact=name,
|
||||
board_game_id=board_game_id,
|
||||
).first()
|
||||
if variant:
|
||||
return JsonResponse({"id": variant.id, "name": variant.name})
|
||||
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name=name,
|
||||
board_game_id=board_game_id,
|
||||
description=description or None,
|
||||
)
|
||||
|
||||
return JsonResponse({"id": variant.id, "name": variant.name})
|
||||
|
||||
|
||||
class BoardGameListView(ScrobbleableListView):
|
||||
model = BoardGame
|
||||
|
||||
|
||||
9
vrobbler/apps/boardgames/widgets.py
Normal file
9
vrobbler/apps/boardgames/widgets.py
Normal file
@ -0,0 +1,9 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class VariantSelectWidget(forms.SelectMultiple):
|
||||
template_name = "boardgames/widgets/variant_select.html"
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
return context
|
||||
@ -1,8 +1,19 @@
|
||||
from books.models import Author, Book, Paper
|
||||
from books.models import Author, Book, Journal, Paper
|
||||
from django.contrib import admin
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@admin.register(Journal)
|
||||
class JournalAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"title",
|
||||
"website_url",
|
||||
)
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(Author)
|
||||
class AuthorAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
|
||||
@ -18,8 +18,17 @@ MISSING_ALL = [
|
||||
"publish_year",
|
||||
]
|
||||
|
||||
def _cover_missing_or_broken(book) -> bool:
|
||||
if not bool(book.cover):
|
||||
return True
|
||||
try:
|
||||
return not book.cover.storage.exists(book.cover.name)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
MISSING_GROUPS = {
|
||||
"cover": lambda b: not bool(b.cover),
|
||||
"cover": _cover_missing_or_broken,
|
||||
"summary": lambda b: not b.summary,
|
||||
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
|
||||
"pages": lambda b: b.pages is None,
|
||||
|
||||
18
vrobbler/apps/books/migrations/0038_paper_pdf_file.py
Normal file
18
vrobbler/apps/books/migrations/0038_paper_pdf_file.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-23 14:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0037_book_volume_book_volume_comicvine_id"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="paper",
|
||||
name="pdf_file",
|
||||
field=models.FileField(blank=True, null=True, upload_to="papers/pdf/"),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,92 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
def migrate_journal_data(apps, schema_editor):
|
||||
Paper = apps.get_model("books", "Paper")
|
||||
Journal = apps.get_model("books", "Journal")
|
||||
for paper in Paper.objects.all():
|
||||
old_journal = getattr(paper, "journal", None)
|
||||
if old_journal:
|
||||
journal, _ = Journal.objects.get_or_create(title=str(old_journal))
|
||||
paper._journal_tmp = journal
|
||||
paper.save(update_fields=["_journal_tmp"])
|
||||
|
||||
|
||||
def reverse_migrate_journal_data(apps, schema_editor):
|
||||
Paper = apps.get_model("books", "Paper")
|
||||
for paper in Paper.objects.all():
|
||||
if paper._journal_tmp:
|
||||
paper.journal = paper._journal_tmp.title
|
||||
paper.save(update_fields=["journal"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("books", "0038_paper_pdf_file"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Journal",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
("website_url", models.URLField(blank=True, max_length=500, null=True)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="paper",
|
||||
name="_journal_tmp",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="books.journal",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(migrate_journal_data, reverse_migrate_journal_data),
|
||||
migrations.RemoveField(
|
||||
model_name="paper",
|
||||
name="journal",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="paper",
|
||||
old_name="_journal_tmp",
|
||||
new_name="journal",
|
||||
),
|
||||
]
|
||||
@ -27,7 +27,11 @@ from books.sources.amazon import lookup_book_from_amazon
|
||||
from books.sources.openlibrary import (
|
||||
lookup_book_from_openlibrary as lookup_book_from_ol,
|
||||
)
|
||||
from books.sources.semantic import lookup_paper_from_semantic
|
||||
from books.sources.semantic import (
|
||||
lookup_paper_from_semantic,
|
||||
lookup_paper_from_semantic_by_doi,
|
||||
)
|
||||
from books.sources.scihub import SciHubService
|
||||
from books.utils import get_comic_issue_url
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -82,6 +86,16 @@ class BookLogData(BaseLogData, LongPlayLogData):
|
||||
return int(total_duration / len(self.page_data))
|
||||
|
||||
|
||||
class Journal(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
website_url = models.URLField(max_length=500, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class Author(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
@ -540,6 +554,21 @@ class Book(LongPlayScrobblableMixin):
|
||||
return progress
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaperLogData(BaseLogData):
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from scrobbles.forms import NotesDictField
|
||||
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
fields["notes"] = NotesDictField(required=False)
|
||||
return fields
|
||||
|
||||
|
||||
class Paper(LongPlayScrobblableMixin):
|
||||
"""Keeps track of Academic Papers"""
|
||||
|
||||
@ -559,14 +588,29 @@ class Paper(LongPlayScrobblableMixin):
|
||||
language = models.CharField(max_length=4, **BNULL)
|
||||
first_publish_year = models.IntegerField(**BNULL)
|
||||
publish_date = models.DateField(**BNULL)
|
||||
journal = models.CharField(max_length=255, **BNULL)
|
||||
journal = models.ForeignKey(Journal, on_delete=models.DO_NOTHING, **BNULL)
|
||||
journal_volume = models.CharField(max_length=50, **BNULL)
|
||||
abstract = models.TextField(**BNULL)
|
||||
tldr = models.CharField(max_length=255, **BNULL)
|
||||
openaccess_pdf_url = models.CharField(max_length=255, **BNULL)
|
||||
pdf_file = models.FileField(upload_to="papers/pdf/", **BNULL)
|
||||
|
||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return PaperLogData
|
||||
|
||||
@property
|
||||
def scihub_url(self):
|
||||
if not self.doi_id:
|
||||
return None
|
||||
domain = getattr(settings, "SCIHUB_DOMAIN", "sci-hub.st")
|
||||
return f"https://{domain}/{self.doi_id}"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("books:paper_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@classmethod
|
||||
def get_from_semantic(cls, title: str, overwrite: bool = False) -> "Paper":
|
||||
paper, created = cls.objects.get_or_create(title=title)
|
||||
@ -577,7 +621,7 @@ class Paper(LongPlayScrobblableMixin):
|
||||
|
||||
if created or overwrite:
|
||||
author_list = []
|
||||
author_dicts = paper_dict.pop("author_dicts")
|
||||
author_dicts = paper_dict.pop("author_dicts", None)
|
||||
if author_dicts:
|
||||
for author_dict in author_dicts:
|
||||
if author_dict.get("authorId"):
|
||||
@ -588,8 +632,11 @@ class Paper(LongPlayScrobblableMixin):
|
||||
if a_created:
|
||||
author.name = author_dict.get("name")
|
||||
author.save()
|
||||
# TODO enrich author?
|
||||
...
|
||||
|
||||
journal_name = paper_dict.pop("journal_name", None)
|
||||
if journal_name:
|
||||
journal, _ = Journal.objects.get_or_create(title=journal_name)
|
||||
paper.journal = journal
|
||||
|
||||
for k, v in paper_dict.items():
|
||||
setattr(paper, k, v)
|
||||
@ -601,3 +648,78 @@ class Paper(LongPlayScrobblableMixin):
|
||||
if genres:
|
||||
paper.genre.add(*genres)
|
||||
return paper
|
||||
|
||||
@classmethod
|
||||
def find_or_create_by_doi(cls, doi_url: str) -> "Paper":
|
||||
doi = doi_url.replace("https://doi.org/", "").split("?")[0].rstrip("/")
|
||||
paper = cls.objects.filter(doi_id=doi).first()
|
||||
if paper:
|
||||
return paper
|
||||
|
||||
paper = cls(doi_id=doi, title=f"Paper {doi}")
|
||||
paper.save()
|
||||
|
||||
from books.sources.crossref import lookup_paper_from_crossref
|
||||
|
||||
paper_dict = lookup_paper_from_semantic_by_doi(doi)
|
||||
if not paper_dict or not paper_dict.get("abstract"):
|
||||
paper_dict = lookup_paper_from_crossref(doi)
|
||||
|
||||
if paper_dict:
|
||||
author_list = []
|
||||
author_dicts = paper_dict.pop("author_dicts", None)
|
||||
if author_dicts:
|
||||
for author_dict in author_dicts:
|
||||
author_id = author_dict.get("authorId")
|
||||
if author_id:
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
semantic_id=author_id
|
||||
)
|
||||
author_list.append(author)
|
||||
if a_created:
|
||||
author.name = author_dict.get("name")
|
||||
author.save()
|
||||
else:
|
||||
author_name = author_dict.get("name")
|
||||
if author_name:
|
||||
author, a_created = Author.objects.get_or_create(
|
||||
name=author_name
|
||||
)
|
||||
author_list.append(author)
|
||||
|
||||
journal_name = paper_dict.pop("journal_name", None)
|
||||
if journal_name:
|
||||
journal, _ = Journal.objects.get_or_create(title=journal_name)
|
||||
paper.journal = journal
|
||||
|
||||
for k, v in paper_dict.items():
|
||||
if v is not None:
|
||||
setattr(paper, k, v)
|
||||
paper.save()
|
||||
|
||||
if author_list:
|
||||
paper.authors.add(*author_list)
|
||||
genres = paper_dict.pop("genres", [])
|
||||
if genres:
|
||||
paper.genre.add(*genres)
|
||||
|
||||
if not paper.pdf_file:
|
||||
service = SciHubService()
|
||||
if paper.openaccess_pdf_url:
|
||||
pdf_content = service.fetch_from_url(paper.openaccess_pdf_url)
|
||||
if pdf_content:
|
||||
filename = f"{doi.replace('/', '_')}.pdf"
|
||||
paper.pdf_file.save(filename, ContentFile(pdf_content))
|
||||
if not paper.pdf_file:
|
||||
try:
|
||||
pdf_content = service.fetch_pdf(doi)
|
||||
if pdf_content:
|
||||
filename = f"{doi.replace('/', '_')}.pdf"
|
||||
paper.pdf_file.save(filename, ContentFile(pdf_content))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[paper] sci-hub PDF download failed",
|
||||
extra={"doi": doi, "error": str(e)},
|
||||
)
|
||||
|
||||
return paper
|
||||
|
||||
149
vrobbler/apps/books/sources/crossref.py
Normal file
149
vrobbler/apps/books/sources/crossref.py
Normal file
@ -0,0 +1,149 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
import yake
|
||||
|
||||
CROSSREF_WORK_URL = "https://api.crossref.org/works/{}"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STOPWORDS = {
|
||||
"this", "that", "these", "those", "the", "a", "an", "in", "on", "at",
|
||||
"to", "for", "of", "and", "or", "is", "are", "was", "were", "be",
|
||||
"been", "being", "have", "has", "had", "do", "does", "did", "will",
|
||||
"would", "can", "could", "may", "might", "shall", "should", "not",
|
||||
"no", "nor", "with", "from", "by", "as", "at", "but", "if", "because",
|
||||
"while", "although", "however", "we", "our", "their", "its", "it",
|
||||
"they", "them", "also", "more", "most", "new", "such", "into",
|
||||
"across", "between", "through", "about", "after", "before", "during",
|
||||
"within", "without", "other", "many", "some", "each", "every", "both",
|
||||
"few", "own", "via",
|
||||
}
|
||||
|
||||
_DROP_PHRASES = {
|
||||
"paper", "study", "studies", "research", "introduction", "conclusion",
|
||||
"conclusions", "background", "methods", "results", "findings",
|
||||
"analysis", "approach", "approaches", "framework", "theory",
|
||||
"theories", "concept", "concepts", "model", "models", "process",
|
||||
"processes", "role", "roles", "factor", "factors", "effect",
|
||||
"effects", "impact", "implication", "implications", "actor", "actors",
|
||||
"article", "chapter", "section", "discussion", "review", "overview",
|
||||
"summary", "methodology", "special issue", "implications",
|
||||
"limitations", "findings", "purpose", "objective", "objectives",
|
||||
"design", "setting", "participants", "sample", "data",
|
||||
"contemporary", "little", "empirical", "theoretical",
|
||||
"organizations", "dissent",
|
||||
}
|
||||
|
||||
|
||||
def _strip_jats(text: str) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"</?jats:[^>]*>", "", text)
|
||||
text = re.sub(r"^\s*Abstract\s*", "", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _extract_genres_from_abstract(abstract: str, max_keywords: int = 8) -> list[str]:
|
||||
if not abstract or len(abstract) < 50:
|
||||
return []
|
||||
kw_extractor = yake.KeywordExtractor(lan="en", n=2, top=max_keywords)
|
||||
keywords = kw_extractor.extract_keywords(abstract)
|
||||
genres = []
|
||||
seen = set()
|
||||
for kw, score in keywords:
|
||||
kw_lower = kw.lower().strip()
|
||||
if kw_lower in seen or kw_lower in _DROP_PHRASES:
|
||||
continue
|
||||
words = [w for w in kw_lower.split() if w not in _STOPWORDS]
|
||||
cleaned = " ".join(words)
|
||||
if not cleaned or len(cleaned) < 3 or cleaned in seen:
|
||||
continue
|
||||
if cleaned in _DROP_PHRASES:
|
||||
continue
|
||||
seen.add(cleaned)
|
||||
genres.append(cleaned)
|
||||
return genres
|
||||
|
||||
|
||||
def lookup_paper_from_crossref(doi: str) -> dict:
|
||||
url = CROSSREF_WORK_URL.format(doi)
|
||||
headers = {"User-Agent": "Vrobbler/1.0 (mailto:hello@example.com)"}
|
||||
response = requests.get(url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Bad response from Crossref",
|
||||
extra={"doi": doi, "status": response.status_code},
|
||||
)
|
||||
return {"doi_id": doi}
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except json.JSONDecodeError:
|
||||
return {"doi_id": doi}
|
||||
|
||||
msg = data.get("message", {})
|
||||
if not msg:
|
||||
return {"doi_id": doi}
|
||||
|
||||
paper_dict = {"doi_id": doi}
|
||||
|
||||
titles = msg.get("title", [])
|
||||
if titles:
|
||||
paper_dict["title"] = titles[0]
|
||||
|
||||
abstract = msg.get("abstract", "")
|
||||
if abstract:
|
||||
stripped = _strip_jats(abstract)
|
||||
paper_dict["abstract"] = stripped
|
||||
genres = _extract_genres_from_abstract(stripped)
|
||||
if genres:
|
||||
paper_dict["genres"] = genres
|
||||
|
||||
author_dicts = []
|
||||
for author in msg.get("author", []):
|
||||
given = author.get("given", "")
|
||||
family = author.get("family", "")
|
||||
name = f"{given} {family}".strip()
|
||||
if not name:
|
||||
continue
|
||||
entry = {"name": name}
|
||||
orcid = author.get("ORCID", "")
|
||||
if orcid:
|
||||
orcid_id = orcid.replace("https://orcid.org/", "")
|
||||
entry["authorId"] = orcid_id
|
||||
author_dicts.append(entry)
|
||||
if author_dicts:
|
||||
paper_dict["author_dicts"] = author_dicts
|
||||
|
||||
container = msg.get("container-title", [])
|
||||
if container:
|
||||
paper_dict["journal_name"] = container[0]
|
||||
|
||||
volume = msg.get("volume")
|
||||
if volume:
|
||||
paper_dict["journal_volume"] = volume
|
||||
|
||||
page = msg.get("page")
|
||||
if page:
|
||||
try:
|
||||
parts = page.split("-")
|
||||
if len(parts) == 2:
|
||||
paper_dict["pages"] = int(parts[1]) - int(parts[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
for date_field in ("published-print", "published-online", "created"):
|
||||
date_data = msg.get(date_field)
|
||||
if date_data and date_data.get("date-parts"):
|
||||
parts = date_data["date-parts"][0]
|
||||
if len(parts) >= 1:
|
||||
paper_dict["first_publish_year"] = int(parts[0])
|
||||
if len(parts) >= 3:
|
||||
paper_dict["publish_date"] = f"{parts[0]:04d}-{parts[1]:02d}-{parts[2]:02d}"
|
||||
break
|
||||
|
||||
return paper_dict
|
||||
142
vrobbler/apps/books/sources/scihub.py
Normal file
142
vrobbler/apps/books/sources/scihub.py
Normal file
@ -0,0 +1,142 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCIHUB_DOMAINS = [
|
||||
"sci-hub.ru",
|
||||
"sci-hub.ee",
|
||||
"sci-hub.st",
|
||||
"sci-hub.do",
|
||||
]
|
||||
|
||||
|
||||
class SciHubService:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
}
|
||||
)
|
||||
|
||||
def fetch_from_url(self, url: str) -> Optional[bytes]:
|
||||
try:
|
||||
resp = self.session.get(url, timeout=60)
|
||||
if resp.status_code != 200:
|
||||
logger.warning(
|
||||
"[pdf] URL download failed",
|
||||
extra={"status": resp.status_code, "url": url},
|
||||
)
|
||||
return None
|
||||
if not self._looks_like_pdf(resp):
|
||||
return None
|
||||
return resp.content
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
"[pdf] URL download request failed",
|
||||
extra={"url": url, "error": str(e)},
|
||||
)
|
||||
return None
|
||||
|
||||
def fetch_pdf(self, doi: str) -> Optional[bytes]:
|
||||
configured_domain = getattr(settings, "SCIHUB_DOMAIN", None)
|
||||
domains_to_try = (
|
||||
[configured_domain] + SCIHUB_DOMAINS
|
||||
if configured_domain and configured_domain not in SCIHUB_DOMAINS
|
||||
else SCIHUB_DOMAINS
|
||||
)
|
||||
|
||||
for domain in domains_to_try:
|
||||
url = f"https://{domain}/{doi}"
|
||||
logger.info(
|
||||
"[scihub] trying domain",
|
||||
extra={"domain": domain, "doi": doi},
|
||||
)
|
||||
try:
|
||||
response = self.session.get(url, timeout=30)
|
||||
if response.status_code != 200:
|
||||
continue
|
||||
|
||||
pdf_url = self._extract_pdf_url(response.text, url)
|
||||
if not pdf_url:
|
||||
continue
|
||||
|
||||
pdf_response = self.session.get(pdf_url, timeout=60)
|
||||
if pdf_response.status_code != 200:
|
||||
continue
|
||||
|
||||
if not self._looks_like_pdf(pdf_response):
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"[scihub] PDF downloaded successfully",
|
||||
extra={
|
||||
"domain": domain,
|
||||
"doi": doi,
|
||||
"size": len(pdf_response.content),
|
||||
},
|
||||
)
|
||||
return pdf_response.content
|
||||
except requests.RequestException as e:
|
||||
logger.debug(
|
||||
"[scihub] domain failed",
|
||||
extra={"domain": domain, "doi": doi, "error": str(e)},
|
||||
)
|
||||
continue
|
||||
|
||||
logger.warning(
|
||||
"[scihub] all domains failed",
|
||||
extra={"doi": doi, "tried": domains_to_try},
|
||||
)
|
||||
return None
|
||||
|
||||
def _looks_like_pdf(self, response: requests.Response) -> bool:
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if "application/pdf" in content_type:
|
||||
return True
|
||||
if content_type.startswith("application/octet"):
|
||||
return True
|
||||
if response.url.endswith(".pdf"):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _extract_pdf_url(self, html: str, page_url: str) -> Optional[str]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
iframe = soup.find("iframe", {"id": "pdf"})
|
||||
if iframe and iframe.get("src"):
|
||||
src = iframe["src"]
|
||||
if src.startswith("http"):
|
||||
return src
|
||||
return urljoin(page_url, src)
|
||||
|
||||
embed = soup.find("embed", {"type": "application/pdf"})
|
||||
if embed and embed.get("src"):
|
||||
src = embed["src"]
|
||||
if src.startswith("http"):
|
||||
return src
|
||||
return urljoin(page_url, src)
|
||||
|
||||
download_div = soup.find("div", {"id": "download"})
|
||||
if download_div:
|
||||
link = download_div.find("a")
|
||||
if link and link.get("href"):
|
||||
href = link["href"]
|
||||
if href.startswith("http"):
|
||||
return href
|
||||
return urljoin(page_url, href)
|
||||
|
||||
for link in soup.find_all("a", href=True):
|
||||
href = link["href"]
|
||||
if ".pdf" in href:
|
||||
if href.startswith("http"):
|
||||
return href
|
||||
return urljoin(page_url, href)
|
||||
|
||||
return None
|
||||
@ -9,6 +9,7 @@ PAPER_SEARCH_URL = (
|
||||
"https://api.semanticscholar.org/graph/v1/paper/search/match?query={}"
|
||||
)
|
||||
PAPER_DETAIL_URL = "https://api.semanticscholar.org/graph/v1/paper/{}?fields=title,authors,url,year,abstract,externalIds,citationCount,referenceCount,journal,fieldsOfStudy,publicationDate,openAccessPdf"
|
||||
PAPER_DOI_URL = "https://api.semanticscholar.org/graph/v1/paper/DOI:{}?fields=title,authors,url,year,abstract,externalIds,citationCount,referenceCount,journal,fieldsOfStudy,publicationDate,openAccessPdf"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -39,6 +40,18 @@ def lookup_paper_from_semantic(title: str) -> dict:
|
||||
if not result:
|
||||
return paper_dict
|
||||
|
||||
paper_dict.update(_parse_semantic_result(result))
|
||||
paper_dict.setdefault("title", title)
|
||||
if paper_dict.get("publish_date"):
|
||||
paper_dict["publish_date"] = datetime.strptime(
|
||||
paper_dict["publish_date"], "%Y-%m-%d"
|
||||
)
|
||||
|
||||
return paper_dict
|
||||
|
||||
|
||||
def _parse_semantic_result(result: dict) -> dict:
|
||||
paper_dict = {}
|
||||
page_str = result.get("journal", {}).get("pages")
|
||||
if page_str:
|
||||
try:
|
||||
@ -55,12 +68,10 @@ def lookup_paper_from_semantic(title: str) -> dict:
|
||||
paper_dict["corpus_id"] = result.get("externalIds", {}).get("CorpusId")
|
||||
paper_dict["semantic_title"] = result.get("title")
|
||||
paper_dict["first_publish_year"] = result.get("year")
|
||||
paper_dict["publish_date"] = datetime.strptime(
|
||||
result.get("publicationDate", "1950-01-01"), "%Y-%m-%d"
|
||||
)
|
||||
paper_dict["publish_date"] = result.get("publicationDate")
|
||||
paper_dict["abstract"] = result.get("abstract")
|
||||
paper_dict["tldr"] = result.get("bib", {}).get("abstract")
|
||||
paper_dict["journal"] = result.get("journal", {}).get("name")
|
||||
paper_dict["journal_name"] = result.get("journal", {}).get("name")
|
||||
paper_dict["journal_volume"] = result.get("journal", {}).get("volume")
|
||||
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get("url")
|
||||
paper_dict["base_run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
|
||||
@ -68,5 +79,19 @@ def lookup_paper_from_semantic(title: str) -> dict:
|
||||
)
|
||||
paper_dict["author_dicts"] = result.get("authors")
|
||||
paper_dict["genres"] = result.get("fieldsOfStudy")
|
||||
|
||||
return paper_dict
|
||||
|
||||
|
||||
def lookup_paper_from_semantic_by_doi(doi: str) -> dict:
|
||||
response = get_api_result(PAPER_DOI_URL.format(doi))
|
||||
if not response:
|
||||
return {"doi_id": doi}
|
||||
|
||||
result = json.loads(response.content)
|
||||
if not result:
|
||||
return {"doi_id": doi}
|
||||
|
||||
paper_dict = _parse_semantic_result(result)
|
||||
if not paper_dict.get("title"):
|
||||
paper_dict["title"] = result.get("title", f"Paper {doi}")
|
||||
return paper_dict
|
||||
|
||||
@ -16,4 +16,15 @@ urlpatterns = [
|
||||
views.AuthorDetailView.as_view(),
|
||||
name="author_detail",
|
||||
),
|
||||
path("papers/", views.PaperListView.as_view(), name="paper_list"),
|
||||
path(
|
||||
"papers/<slug:slug>/",
|
||||
views.PaperDetailView.as_view(),
|
||||
name="paper_detail",
|
||||
),
|
||||
path(
|
||||
"papers/<slug:slug>/upload_pdf/",
|
||||
views.PaperUploadPdfView.as_view(),
|
||||
name="paper_upload_pdf",
|
||||
),
|
||||
]
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views import generic
|
||||
from books.models import Book, Author
|
||||
from books.models import Book, Author, Paper
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
@ -15,3 +18,24 @@ class BookDetailView(ScrobbleableDetailView):
|
||||
class AuthorDetailView(generic.DetailView):
|
||||
model = Author
|
||||
slug_field = "uuid"
|
||||
|
||||
|
||||
class PaperListView(ScrobbleableListView):
|
||||
model = Paper
|
||||
|
||||
|
||||
class PaperDetailView(ScrobbleableDetailView):
|
||||
model = Paper
|
||||
|
||||
|
||||
class PaperUploadPdfView(View):
|
||||
def post(self, request, slug):
|
||||
paper = Paper.objects.filter(uuid=slug).first()
|
||||
if not paper or not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(reverse("books:paper_detail", args=[slug]))
|
||||
|
||||
pdf_file = request.FILES.get("pdf_file")
|
||||
if pdf_file:
|
||||
paper.pdf_file.save(pdf_file.name, pdf_file)
|
||||
|
||||
return HttpResponseRedirect(reverse("books:paper_detail", args=[slug]))
|
||||
|
||||
@ -3,6 +3,7 @@ from charts.views import (
|
||||
BirdsChartView,
|
||||
ChartDetailView,
|
||||
ChartRecordView,
|
||||
MalojaChartsView,
|
||||
SpotifyTracksView,
|
||||
)
|
||||
from django.urls import path
|
||||
@ -11,6 +12,7 @@ app_name = "charts"
|
||||
|
||||
urlpatterns = [
|
||||
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
||||
path("charts/maloja/", MalojaChartsView.as_view(), name="maloja-charts"),
|
||||
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
||||
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
|
||||
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
|
||||
|
||||
@ -114,175 +114,13 @@ class ChartRecordView(TemplateView):
|
||||
context["current_week"] = current_week
|
||||
context["current_day"] = current_day
|
||||
|
||||
context["chart_keys"] = {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month",
|
||||
"year": "This Year",
|
||||
"all": "All Time",
|
||||
}
|
||||
|
||||
context["maloja_charts"] = {
|
||||
"artist": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "artist", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "artist")),
|
||||
},
|
||||
"album": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user, "album", year=current_year, week=current_week
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "album", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "album")),
|
||||
},
|
||||
"tv_series": {
|
||||
"today": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
day=current_day,
|
||||
)
|
||||
),
|
||||
"week": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
week=current_week,
|
||||
)
|
||||
),
|
||||
"month": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=current_year,
|
||||
month=current_month,
|
||||
)
|
||||
),
|
||||
"year": list(
|
||||
self.get_charts_for_period(user, "tv_series", year=current_year)
|
||||
),
|
||||
"all": list(self.get_charts_for_period(user, "tv_series")),
|
||||
},
|
||||
}
|
||||
|
||||
if not date_param:
|
||||
context["period"] = "current"
|
||||
context["year"] = current_year
|
||||
context["month"] = current_month
|
||||
context["month_name"] = calendar.month_name[current_month]
|
||||
context["week"] = current_week
|
||||
context["day"] = current_day
|
||||
|
||||
context["charts"] = {
|
||||
"artist": list(
|
||||
self.get_charts_for_period(
|
||||
user, "artist", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"album": list(
|
||||
self.get_charts_for_period(
|
||||
user, "album", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"track": list(
|
||||
self.get_charts_for_period(
|
||||
user, "track", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"tv_series": list(
|
||||
self.get_charts_for_period(
|
||||
user, "tv_series", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"video": list(
|
||||
self.get_charts_for_period(
|
||||
user, "video", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"board_game": list(
|
||||
self.get_charts_for_period(
|
||||
user, "board_game", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"book": list(
|
||||
self.get_charts_for_period(
|
||||
user, "book", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"food": list(
|
||||
self.get_charts_for_period(
|
||||
user, "food", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"podcast": list(
|
||||
self.get_charts_for_period(
|
||||
user, "podcast", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
"trail": list(
|
||||
self.get_charts_for_period(
|
||||
user, "trail", year=current_year, limit=20
|
||||
)
|
||||
),
|
||||
}
|
||||
else:
|
||||
# Resolve date parameters
|
||||
if date_param:
|
||||
parts = date_param.split("-")
|
||||
year = int(parts[0])
|
||||
|
||||
week = None
|
||||
month = None
|
||||
day = None
|
||||
|
||||
if len(parts) >= 2 and parts[1].startswith("W"):
|
||||
week = int(parts[1].lstrip("W"))
|
||||
elif len(parts) >= 2 and parts[1]:
|
||||
@ -290,20 +128,17 @@ class ChartRecordView(TemplateView):
|
||||
month = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if len(parts) >= 3:
|
||||
if parts[2].startswith("W"):
|
||||
week = int(parts[2].lstrip("W"))
|
||||
elif not parts[2].startswith("W"):
|
||||
day = int(parts[2])
|
||||
|
||||
context["period"] = "historical"
|
||||
context["year"] = year
|
||||
context["month"] = month
|
||||
context["month_name"] = calendar.month_name[month] if month else None
|
||||
context["week"] = week
|
||||
context["day"] = day
|
||||
|
||||
period_str = str(year)
|
||||
if month:
|
||||
period_str = f"{calendar.month_name[month]} {period_str}"
|
||||
@ -312,109 +147,82 @@ class ChartRecordView(TemplateView):
|
||||
if day:
|
||||
period_str = f"{calendar.month_name[month]} {day}, {year}"
|
||||
context["period_str"] = period_str
|
||||
else:
|
||||
year = current_year
|
||||
month = current_month
|
||||
week = current_week
|
||||
day = current_day
|
||||
context["period"] = "current"
|
||||
context["year"] = current_year
|
||||
context["month"] = current_month
|
||||
context["month_name"] = calendar.month_name[current_month]
|
||||
context["week"] = current_week
|
||||
context["day"] = current_day
|
||||
|
||||
context["charts"] = {
|
||||
"artist": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"artist",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"album": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"album",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"track": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"track",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"tv_series": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"tv_series",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"video": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"video",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"board_game": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"board_game",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"book": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"book",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"food": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"food",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"podcast": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"podcast",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
"trail": list(
|
||||
self.get_charts_for_period(
|
||||
user,
|
||||
"trail",
|
||||
year=year,
|
||||
month=month,
|
||||
week=week,
|
||||
day=day,
|
||||
)
|
||||
),
|
||||
}
|
||||
# List-group tables default to week-level when no date param (matches active tab)
|
||||
if not date_param:
|
||||
list_year = current_year
|
||||
list_month = None
|
||||
list_week = current_week
|
||||
list_day = None
|
||||
else:
|
||||
list_year = year
|
||||
list_month = month
|
||||
list_week = week
|
||||
list_day = day
|
||||
|
||||
context["charts"] = {
|
||||
"artist": list(
|
||||
self.get_charts_for_period(
|
||||
user, "artist", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"album": list(
|
||||
self.get_charts_for_period(
|
||||
user, "album", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"track": list(
|
||||
self.get_charts_for_period(
|
||||
user, "track", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"tv_series": list(
|
||||
self.get_charts_for_period(
|
||||
user, "tv_series", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"video": list(
|
||||
self.get_charts_for_period(
|
||||
user, "video", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"board_game": list(
|
||||
self.get_charts_for_period(
|
||||
user, "board_game", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"book": list(
|
||||
self.get_charts_for_period(
|
||||
user, "book", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"food": list(
|
||||
self.get_charts_for_period(
|
||||
user, "food", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"podcast": list(
|
||||
self.get_charts_for_period(
|
||||
user, "podcast", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
"trail": list(
|
||||
self.get_charts_for_period(
|
||||
user, "trail", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
bird_data = self.get_bird_chart_data(
|
||||
user,
|
||||
@ -628,6 +436,57 @@ class ChartRecordView(TemplateView):
|
||||
}
|
||||
|
||||
|
||||
class MalojaChartsView(ChartRecordView):
|
||||
"""Three maloja-themed image grid widgets (artists, albums, TV series)
|
||||
with Today/Week/Month/Year/All tabs. Each tab computes its own period
|
||||
from the current date — no query param needed."""
|
||||
|
||||
template_name = "charts/maloja_charts.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(ChartRecordView, self).get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
context["maloja_charts"] = {}
|
||||
context["chart_keys"] = {}
|
||||
return context
|
||||
|
||||
now = timezone.now()
|
||||
now = now_user_timezone(user.profile)
|
||||
today = now.date()
|
||||
|
||||
context["chart_keys"] = {
|
||||
"today": "Today",
|
||||
"week": "This Week",
|
||||
"month": "This Month",
|
||||
"year": "This Year",
|
||||
"all": "All Time",
|
||||
}
|
||||
|
||||
tab_params = {
|
||||
"today": {"year": today.year, "month": today.month, "day": today.day},
|
||||
"week": {"year": today.year, "week": today.isocalendar()[1]},
|
||||
"month": {"year": today.year, "month": today.month},
|
||||
"year": {"year": today.year},
|
||||
}
|
||||
|
||||
maloja_charts = {}
|
||||
for media_type in ("artist", "album", "tv_series"):
|
||||
tabs = {}
|
||||
for key in ("today", "week", "month", "year"):
|
||||
tabs[key] = list(
|
||||
self.get_charts_for_period(user, media_type, **tab_params[key])
|
||||
)
|
||||
tabs["all"] = list(
|
||||
self.get_charts_for_period(user, media_type)
|
||||
)
|
||||
maloja_charts[media_type] = tabs
|
||||
|
||||
context["maloja_charts"] = maloja_charts
|
||||
return context
|
||||
|
||||
|
||||
MEDIA_TYPE_LABELS = {
|
||||
"artist": ("🎤", "Top Artists"),
|
||||
"album": ("💿", "Top Albums"),
|
||||
|
||||
@ -12,7 +12,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_udisc_datetime(raw: str) -> datetime:
|
||||
return parse_datetime(raw)
|
||||
dt = parse_datetime(raw)
|
||||
if timezone.is_naive(dt):
|
||||
return timezone.make_aware(dt)
|
||||
return dt
|
||||
|
||||
|
||||
def _resolve_player(name: str, user_id: int) -> Person:
|
||||
|
||||
@ -0,0 +1,202 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Enrich artist and album metadata (covers, thumbnails) from MusicBrainz and TheAudioDB"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite existing cover image and metadata",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be done without making changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--artists",
|
||||
action="store_true",
|
||||
help="Only process artists",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--albums",
|
||||
action="store_true",
|
||||
help="Only process albums",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--needs-metadata",
|
||||
action="store_true",
|
||||
help="Only process items missing metadata or with broken images",
|
||||
)
|
||||
|
||||
def _has_broken_image(self, obj, field_name: str) -> bool:
|
||||
field = getattr(obj, field_name, None)
|
||||
if not field or not field.name:
|
||||
return False
|
||||
try:
|
||||
return not field.storage.exists(field.name)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from music.models import Album, Artist
|
||||
|
||||
force = options["force"]
|
||||
dry_run = options["dry_run"]
|
||||
only_artists = options["artists"]
|
||||
only_albums = options["albums"]
|
||||
needs_metadata = options["needs_metadata"]
|
||||
|
||||
if not only_artists and not only_albums:
|
||||
only_artists = only_albums = True
|
||||
|
||||
updated_total = 0
|
||||
errors_total = 0
|
||||
|
||||
if only_artists:
|
||||
updated_total += self._process_artists(force, dry_run, needs_metadata)
|
||||
errors_total = 0 # reset per section
|
||||
|
||||
if only_albums:
|
||||
updated_total += self._process_albums(force, dry_run, needs_metadata)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\nDone! {updated_total} items processed")
|
||||
)
|
||||
|
||||
def _get_artists(self, needs_metadata: bool):
|
||||
from music.models import Artist
|
||||
|
||||
qs = Artist.objects.all()
|
||||
if needs_metadata:
|
||||
qs = qs.filter(
|
||||
models.Q(theaudiodb_id__isnull=True)
|
||||
| models.Q(theaudiodb_id="")
|
||||
| models.Q(thumbnail__isnull=True)
|
||||
| models.Q(thumbnail="")
|
||||
)
|
||||
|
||||
broken = []
|
||||
if needs_metadata:
|
||||
broken_qs = Artist.objects.exclude(
|
||||
models.Q(thumbnail__isnull=True) | models.Q(thumbnail=""),
|
||||
)
|
||||
for artist in broken_qs.iterator():
|
||||
if self._has_broken_image(artist, "thumbnail"):
|
||||
broken.append(artist)
|
||||
|
||||
return list(qs) + broken
|
||||
|
||||
def _get_albums(self, needs_metadata: bool):
|
||||
from music.models import Album
|
||||
|
||||
qs = Album.objects.all()
|
||||
if needs_metadata:
|
||||
qs = qs.filter(
|
||||
models.Q(cover_image__isnull=True)
|
||||
| models.Q(cover_image="")
|
||||
| models.Q(cover_image="default-image-replace-me")
|
||||
)
|
||||
|
||||
broken = []
|
||||
if needs_metadata:
|
||||
broken_qs = Album.objects.exclude(
|
||||
models.Q(cover_image__isnull=True) | models.Q(cover_image=""),
|
||||
)
|
||||
for album in broken_qs.iterator():
|
||||
if self._has_broken_image(album, "cover_image"):
|
||||
broken.append(album)
|
||||
|
||||
return list(qs) + broken
|
||||
|
||||
def _process_artists(self, force, dry_run, needs_metadata):
|
||||
from music.models import Artist
|
||||
|
||||
artists = self._get_artists(needs_metadata) if needs_metadata else list(Artist.objects.all())
|
||||
total = len(artists)
|
||||
self.stdout.write(f"Processing {total} artists")
|
||||
|
||||
if dry_run:
|
||||
for artist in artists:
|
||||
has_tadb = bool(artist.theaudiodb_id)
|
||||
has_thumb = bool(artist.thumbnail)
|
||||
thumb_broken = self._has_broken_image(artist, "thumbnail")
|
||||
status = f"theaudiodb_id={'✓' if has_tadb else '✗'}"
|
||||
if thumb_broken:
|
||||
status += ", thumbnail=BROKEN"
|
||||
elif has_thumb:
|
||||
status += ", thumbnail=✓"
|
||||
else:
|
||||
status += ", thumbnail=✗"
|
||||
self.stdout.write(f" [DRY RUN] Would fix {artist.name} ({status})")
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
errors = 0
|
||||
for artist in artists:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
artist.fix_metadata(
|
||||
force_update=force or self._has_broken_image(artist, "thumbnail")
|
||||
)
|
||||
updated += 1
|
||||
self.stdout.write(f" [ARTIST {updated}/{total}] {artist.name}")
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f" Error updating artist {artist.name}: {e}")
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\nArtists done! {updated} updated, {errors} errors")
|
||||
)
|
||||
return updated
|
||||
|
||||
def _process_albums(self, force, dry_run, needs_metadata):
|
||||
from music.models import Album
|
||||
|
||||
albums = self._get_albums(needs_metadata) if needs_metadata else list(Album.objects.all())
|
||||
total = len(albums)
|
||||
self.stdout.write(f"Processing {total} albums")
|
||||
|
||||
if dry_run:
|
||||
for album in albums:
|
||||
has_cover = bool(album.cover_image)
|
||||
cover_broken = self._has_broken_image(album, "cover_image")
|
||||
if cover_broken:
|
||||
status = "cover=BROKEN"
|
||||
elif has_cover:
|
||||
status = "cover=✓"
|
||||
else:
|
||||
status = "cover=✗"
|
||||
self.stdout.write(f" [DRY RUN] Would fix {album.name} ({status})")
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
errors = 0
|
||||
for album in albums:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if self._has_broken_image(album, "cover_image") or force:
|
||||
album.fetch_artwork(force=True)
|
||||
else:
|
||||
album.fix_metadata()
|
||||
updated += 1
|
||||
self.stdout.write(f" [ALBUM {updated}/{total}] {album.name}")
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f" Error updating album {album.name}: {e}")
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\nAlbums done! {updated} updated, {errors} errors")
|
||||
)
|
||||
return updated
|
||||
@ -42,6 +42,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"home_scrobble_limit",
|
||||
"live_now_playing",
|
||||
"weigh_in_units",
|
||||
"trends_disabled",
|
||||
]
|
||||
widgets = {
|
||||
"lastfm_password": forms.PasswordInput(render_value=True),
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-25 20:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0039_userprofile_live_now_playing"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="disabled_trends",
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=list,
|
||||
help_text="List of trend slugs the user has disabled",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="trends_disabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -106,6 +106,14 @@ class UserProfile(TimeStampedModel):
|
||||
default=WeighUnit.METRIC,
|
||||
)
|
||||
|
||||
trends_disabled = models.BooleanField(default=False)
|
||||
|
||||
disabled_trends = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="List of trend slugs the user has disabled",
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"User profile for {self.user}"
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ LONG_PLAY_MEDIA = {
|
||||
"books": "Book",
|
||||
"bricksets": "BrickSet",
|
||||
"tasks": "Task",
|
||||
"papers": "Paper",
|
||||
}
|
||||
|
||||
# Media types that should just be finished if they go over time
|
||||
@ -61,6 +62,7 @@ SCROBBLE_CONTENT_URLS = {
|
||||
"-b": ["https://www.amazon.com/"],
|
||||
"-t": ["https://app.todoist.com/app/task/{id}"],
|
||||
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
|
||||
"-pp": ["https://doi.org/"],
|
||||
"-l": ["https://brickset.com/sets/"],
|
||||
"-c": ["https://readcomicsonline.ru"],
|
||||
"-h": ["https://www.twitch.tv/"],
|
||||
@ -83,6 +85,7 @@ MANUAL_SCROBBLE_FNS = {
|
||||
"-f": "manual_scrobble_food",
|
||||
"-h": "manual_scrobble_twitch_channel",
|
||||
"-dg": "manual_scrobble_discgolf",
|
||||
"-pp": "manual_scrobble_paper",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1377,6 +1377,8 @@ class Scrobble(TimeStampedModel):
|
||||
media_obj = self.channel
|
||||
if self.birding_location:
|
||||
media_obj = self.birding_location
|
||||
if self.paper:
|
||||
media_obj = self.paper
|
||||
if self.disc_golf_course:
|
||||
media_obj = self.disc_golf_course
|
||||
return media_obj
|
||||
|
||||
@ -8,8 +8,9 @@ import pytz
|
||||
import requests
|
||||
from beers.models import Beer
|
||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
|
||||
from boardgames.utils import board_names_to_variants
|
||||
from books.constants import READCOMICSONLINE_URL
|
||||
from books.models import Book, BookLogData, BookPageLogData
|
||||
from books.models import Book, BookLogData, BookPageLogData, Paper
|
||||
from books.utils import parse_readcomicsonline_uri
|
||||
from bricksets.models import BrickSet
|
||||
from dateutil.parser import parse
|
||||
@ -370,7 +371,7 @@ def manual_scrobble_board_game(
|
||||
source: str = "BGG",
|
||||
action: Optional[str] = None,
|
||||
) -> Scrobble | None:
|
||||
boardgame = BoardGame.find_or_create(bggeek_id)
|
||||
boardgame = BoardGame.find_or_create(bggeek_id, defer_expansions=True)
|
||||
|
||||
if not boardgame:
|
||||
logger.error(f"No board game found for ID {bggeek_id}")
|
||||
@ -495,7 +496,9 @@ def email_scrobble_board_game(
|
||||
if play_dict.get("rounds", False):
|
||||
log_data["rounds"] = play_dict.get("rounds")
|
||||
if play_dict.get("board", False):
|
||||
log_data["board"] = play_dict.get("board")
|
||||
log_data["variant_ids"] = board_names_to_variants(
|
||||
base_game, [play_dict.get("board")]
|
||||
)
|
||||
|
||||
log_data["players"] = []
|
||||
for score_dict in play_dict.get("playerScores", []):
|
||||
@ -641,6 +644,8 @@ def manual_scrobble_from_url(
|
||||
item_id = "tt" + str(item_id)
|
||||
elif content_key == "-h" and "twitch.tv" in url:
|
||||
item_id = url
|
||||
elif content_key == "-pp" and "doi.org" in url:
|
||||
item_id = url
|
||||
|
||||
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
|
||||
return eval(scrobble_fn)(item_id, user_id, source=source, action=action)
|
||||
@ -995,6 +1000,38 @@ def manual_scrobble_task(
|
||||
return scrobble
|
||||
|
||||
|
||||
def manual_scrobble_paper(
|
||||
doi_url: str,
|
||||
user_id: int,
|
||||
source: str = "Bookmarklet",
|
||||
action: Optional[str] = None,
|
||||
):
|
||||
paper = Paper.find_or_create_by_doi(doi_url)
|
||||
|
||||
scrobble_dict = {
|
||||
"user_id": user_id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_seconds": 0,
|
||||
"source": source,
|
||||
}
|
||||
logger.info(
|
||||
"[vrobbler-scrobble] paper scrobble request received",
|
||||
extra={
|
||||
"paper_id": paper.id,
|
||||
"user_id": user_id,
|
||||
"scrobble_dict": scrobble_dict,
|
||||
"media_type": Scrobble.MediaType.PAPER,
|
||||
},
|
||||
)
|
||||
|
||||
scrobble = Scrobble.create_or_update(paper, user_id, scrobble_dict)
|
||||
|
||||
if action == "stop":
|
||||
scrobble.stop(force_finish=True)
|
||||
|
||||
return scrobble
|
||||
|
||||
|
||||
def manual_scrobble_webpage(
|
||||
url: str,
|
||||
user_id: int,
|
||||
|
||||
@ -33,7 +33,7 @@ from django.http import (
|
||||
JsonResponse,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.dateformat import DateFormat
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@ -610,7 +610,12 @@ class ManualScrobbleView(FormView):
|
||||
item_str = form.cleaned_data.get("item_id")
|
||||
logger.debug(f"Looking for scrobblable media with input {item_str}")
|
||||
|
||||
key, item_id = item_str[:2], item_str[3:]
|
||||
if len(item_str) > 2 and item_str[:3] in MANUAL_SCROBBLE_FNS:
|
||||
key = item_str[:3]
|
||||
item_id = item_str[4:]
|
||||
else:
|
||||
key = item_str[:2]
|
||||
item_id = item_str[3:]
|
||||
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
|
||||
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
|
||||
|
||||
@ -1230,6 +1235,28 @@ class ScrobbleDetailView(DetailView):
|
||||
def get_form_class(self):
|
||||
return self.object.media_obj.logdata_cls().form()
|
||||
|
||||
def _update_board_game_widgets(self, form):
|
||||
from boardgames.models import BoardGame
|
||||
|
||||
if not isinstance(self.object.media_obj, BoardGame):
|
||||
return
|
||||
|
||||
if "expansion_ids" in form.fields:
|
||||
expansions = BoardGame.objects.filter(
|
||||
expansion_for_boardgame=self.object.media_obj
|
||||
)
|
||||
form.fields["expansion_ids"].queryset = expansions
|
||||
|
||||
if "variant_ids" in form.fields:
|
||||
form.fields["variant_ids"].widget.attrs["data-board-game-id"] = (
|
||||
self.object.media_obj.id
|
||||
)
|
||||
form.fields["variant_ids"].widget.attrs["data-ajax-url"] = (
|
||||
self.request.build_absolute_uri(
|
||||
reverse("boardgames:ajax-create-variant")
|
||||
)
|
||||
)
|
||||
|
||||
def get_form(self):
|
||||
FormClass = self.get_form_class()
|
||||
|
||||
@ -1240,12 +1267,15 @@ class ScrobbleDetailView(DetailView):
|
||||
else:
|
||||
log["notes"] = self.object.logdata.notes_as_str(separator="\n")
|
||||
|
||||
return FormClass(initial=log)
|
||||
form = FormClass(initial=log)
|
||||
self._update_board_game_widgets(form)
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
FormClass = self.get_form_class()
|
||||
form = FormClass(request.POST)
|
||||
self._update_board_game_widgets(form)
|
||||
|
||||
if form.is_valid():
|
||||
data = form.cleaned_data.copy()
|
||||
@ -1258,6 +1288,12 @@ class ScrobbleDetailView(DetailView):
|
||||
if data.get("with_people_ids") is not None:
|
||||
data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
|
||||
|
||||
if data.get("expansion_ids") is not None:
|
||||
data["expansion_ids"] = [e.id for e in data["expansion_ids"]]
|
||||
|
||||
if data.get("variant_ids") is not None:
|
||||
data["variant_ids"] = [v.id for v in data["variant_ids"]]
|
||||
|
||||
if data.get("mood_reason_ids") is not None:
|
||||
data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]]
|
||||
|
||||
|
||||
@ -38,15 +38,36 @@ class Command(BaseCommand):
|
||||
overall_start = timezone.now()
|
||||
ok_count = 0
|
||||
fail_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for user in users:
|
||||
total_trends = len(TREND_REGISTRY)
|
||||
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
|
||||
try:
|
||||
profile = user.profile
|
||||
if profile.trends_disabled:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f" {user} ({user.id}): trends disabled globally, skipping"
|
||||
)
|
||||
)
|
||||
skipped_count += len(TREND_REGISTRY)
|
||||
continue
|
||||
disabled_trends = set(profile.disabled_trends or [])
|
||||
except Exception:
|
||||
disabled_trends = set()
|
||||
|
||||
active_slugs = [
|
||||
s for s in TREND_REGISTRY if s not in disabled_trends
|
||||
]
|
||||
total_trends = len(active_slugs)
|
||||
self.stdout.write(
|
||||
f" {user} ({user.id}): {total_trends} trends ("
|
||||
f"{len(disabled_trends & set(TREND_REGISTRY.keys()))} disabled)..."
|
||||
)
|
||||
user_start = timezone.now()
|
||||
user_ok = 0
|
||||
user_fail = 0
|
||||
|
||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||
for idx, slug in enumerate(active_slugs, start=1):
|
||||
periods = get_supported_periods(slug)
|
||||
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
|
||||
for period in periods:
|
||||
@ -76,7 +97,7 @@ class Command(BaseCommand):
|
||||
overall_elapsed = (timezone.now() - overall_start).total_seconds()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Done! {ok_count} OK, {fail_count} failed "
|
||||
f"Done! {ok_count} OK, {fail_count} failed, {skipped_count} skipped "
|
||||
f"({overall_elapsed:.1f}s across {total_users} user(s))"
|
||||
)
|
||||
)
|
||||
|
||||
@ -26,7 +26,17 @@ def compute_user_trends(user_id):
|
||||
logger.warning("User %s not found, skipping trends", user_id)
|
||||
return
|
||||
|
||||
total = len(TREND_REGISTRY)
|
||||
try:
|
||||
profile = user.profile
|
||||
if profile.trends_disabled:
|
||||
logger.info("User %s (%d) has trends disabled, skipping", user, user_id)
|
||||
return
|
||||
disabled = set(profile.disabled_trends or [])
|
||||
except Exception:
|
||||
disabled = set()
|
||||
|
||||
active_slugs = [s for s in TREND_REGISTRY if s not in disabled]
|
||||
total = len(active_slugs)
|
||||
logger.info(
|
||||
"Computing %d trends for user %s (%d)",
|
||||
total,
|
||||
@ -34,7 +44,7 @@ def compute_user_trends(user_id):
|
||||
user_id,
|
||||
)
|
||||
|
||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||
for idx, slug in enumerate(active_slugs, start=1):
|
||||
compute_single_trend.delay(user_id, slug)
|
||||
|
||||
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
|
||||
@ -52,6 +62,21 @@ def compute_single_trend(user_id, slug):
|
||||
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
|
||||
return
|
||||
|
||||
try:
|
||||
profile = user.profile
|
||||
if profile.trends_disabled:
|
||||
logger.info(
|
||||
"User %d has trends disabled, skipping '%s'", user_id, slug
|
||||
)
|
||||
return
|
||||
if slug in (profile.disabled_trends or []):
|
||||
logger.info(
|
||||
"User %d has trend '%s' disabled, skipping", user_id, slug
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
periods = get_supported_periods(slug)
|
||||
|
||||
for period in periods:
|
||||
|
||||
@ -1,45 +1,78 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.distribution %}
|
||||
{{ data.distribution|json_script:"activity-distribution-data" }}
|
||||
<p class="text-muted mb-3">
|
||||
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Media Type</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th class="text-end">Completed</th>
|
||||
<th class="text-end">%</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with max=data.distribution.0.count %}
|
||||
{% for entry in data.distribution %}
|
||||
<tr>
|
||||
<td>{{ entry.media_type }}</td>
|
||||
<td class="text-end">{{ entry.count }}</td>
|
||||
<td class="text-end">{{ entry.completed }}</td>
|
||||
<td class="text-end">{{ entry.pct }}%</td>
|
||||
<td style="width: 30%;">
|
||||
{% if max > 0 %}
|
||||
<div class="progress" style="height: 12px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ entry.pct }}%;"
|
||||
aria-valuenow="{{ entry.count }}"
|
||||
aria-valuemin="0" aria-valuemax="{{ max }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<canvas id="activityDistChart" width="700" style="max-width:100%;"></canvas>
|
||||
<script>
|
||||
(function() {
|
||||
var el = document.getElementById('activity-distribution-data');
|
||||
if (!el) return;
|
||||
var data = JSON.parse(el.textContent);
|
||||
if (!data.length) return;
|
||||
var canvas = document.getElementById('activityDistChart');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var W = canvas.width;
|
||||
var rowH = 34;
|
||||
var labelW = 140;
|
||||
var barLeft = labelW + 8;
|
||||
var barRight = W - 80;
|
||||
var barMaxW = barRight - barLeft;
|
||||
var padTop = 8;
|
||||
|
||||
var maxCount = data[0].count;
|
||||
|
||||
// Set canvas height based on data
|
||||
canvas.height = data.length * rowH + padTop;
|
||||
|
||||
function roundRect(x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
data.forEach(function(entry, i) {
|
||||
var y = padTop + i * rowH;
|
||||
var pct = entry.count / maxCount;
|
||||
var barW = pct * barMaxW;
|
||||
|
||||
// Label
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(entry.media_type, labelW - 6, y + rowH / 2);
|
||||
|
||||
// Bar background
|
||||
ctx.fillStyle = '#f3f4f6';
|
||||
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
|
||||
ctx.fill();
|
||||
|
||||
// Bar fill — green (top) to red (bottom)
|
||||
var hue = 120 * (1 - i / (data.length - 1 || 1));
|
||||
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
|
||||
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
|
||||
ctx.fill();
|
||||
|
||||
// Count + percentage label on the right
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = 'bold 13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(entry.count + ' (' + entry.pct + '%)', barRight + 6, y + rowH / 2);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<p class="text-muted">No activity data found.</p>
|
||||
{% endif %}
|
||||
|
||||
@ -1,43 +1,74 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.moods %}
|
||||
{{ data.moods|json_script:"mood-distribution-data" }}
|
||||
<p class="text-muted mb-3">
|
||||
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
|
||||
· Positive: <strong>{{ data.positive_count }}</strong>
|
||||
· Negative: <strong>{{ data.negative_count }}</strong>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mood</th>
|
||||
<th class="text-end">Count</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with max=data.moods.0.count %}
|
||||
{% for entry in data.moods %}
|
||||
<tr>
|
||||
<td>{{ entry.mood }}</td>
|
||||
<td class="text-end">{{ entry.count }}</td>
|
||||
<td style="width: 40%;">
|
||||
{% if max > 0 %}
|
||||
<div class="progress" style="height: 12px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {% widthratio entry.count max 100 %}%;"
|
||||
aria-valuenow="{{ entry.count }}"
|
||||
aria-valuemin="0" aria-valuemax="{{ max }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<canvas id="moodDistChart" width="700" style="max-width:100%;"></canvas>
|
||||
<script>
|
||||
(function() {
|
||||
var el = document.getElementById('mood-distribution-data');
|
||||
if (!el) return;
|
||||
var data = JSON.parse(el.textContent);
|
||||
if (!data.length) return;
|
||||
var canvas = document.getElementById('moodDistChart');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var rowH = 34;
|
||||
var labelW = 140;
|
||||
var barLeft = labelW + 8;
|
||||
var barRight = canvas.width - 80;
|
||||
var barMaxW = barRight - barLeft;
|
||||
var padTop = 8;
|
||||
|
||||
canvas.height = data.length * rowH + padTop;
|
||||
|
||||
var maxCount = data[0].count;
|
||||
|
||||
function roundRect(x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
data.forEach(function(entry, i) {
|
||||
var y = padTop + i * rowH;
|
||||
var pct = entry.count / maxCount;
|
||||
var barW = pct * barMaxW;
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = '13px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(entry.mood, labelW - 6, y + rowH / 2);
|
||||
|
||||
ctx.fillStyle = '#f3f4f6';
|
||||
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
|
||||
ctx.fill();
|
||||
|
||||
var hue = 120 * (1 - i / (data.length - 1 || 1));
|
||||
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
|
||||
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#374151';
|
||||
ctx.font = 'bold 13px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('' + entry.count, barRight + 6, y + rowH / 2);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<p class="text-muted">No mood distribution data found.</p>
|
||||
{% endif %}
|
||||
|
||||
@ -1,37 +1,105 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.trajectory %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Avg Quality</th>
|
||||
<th class="text-end">Check-ins</th>
|
||||
<th>Mood Bar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in data.trajectory %}
|
||||
<tr>
|
||||
<td>{{ entry.date }}</td>
|
||||
<td class="text-end">{{ entry.avg_quality }}</td>
|
||||
<td class="text-end">{{ entry.count }}</td>
|
||||
<td style="width: 40%;">
|
||||
<div class="progress" style="height: 16px;">
|
||||
<div class="progress-bar {% if entry.avg_quality >= 5 %}bg-success{% elif entry.avg_quality >= 4 %}bg-info{% elif entry.avg_quality >= 3 %}bg-warning{% else %}bg-danger{% endif %}"
|
||||
role="progressbar"
|
||||
style="width: {% widthratio entry.avg_quality 7 100 %}%;"
|
||||
aria-valuenow="{{ entry.avg_quality }}"
|
||||
aria-valuemin="1" aria-valuemax="7">
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ data.trajectory|json_script:"mood-trajectory-data" }}
|
||||
<div class="d-flex flex-column align-items-center">
|
||||
<canvas id="moodTrajectoryChart" width="700" height="300" style="max-width:100%;"></canvas>
|
||||
<div class="d-flex gap-4 mt-2 small text-muted">
|
||||
<span>← Earlier</span>
|
||||
<span>Later →</span>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var el = document.getElementById('mood-trajectory-data');
|
||||
if (!el) return;
|
||||
var data = JSON.parse(el.textContent);
|
||||
if (!data.length) return;
|
||||
var canvas = document.getElementById('moodTrajectoryChart');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var W = canvas.width, H = canvas.height;
|
||||
var pad = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||
var plotW = W - pad.left - pad.right;
|
||||
var plotH = H - pad.top - pad.bottom;
|
||||
|
||||
var yMin = 1, yMax = 7;
|
||||
var yRange = yMax - yMin;
|
||||
var maxCount = data.reduce(function(m, d) { return Math.max(m, d.count); }, 0);
|
||||
|
||||
function xPos(i) {
|
||||
return pad.left + (i / (data.length - 1 || 1)) * plotW;
|
||||
}
|
||||
function yPos(val) {
|
||||
return pad.top + (1 - (val - yMin) / yRange) * plotH;
|
||||
}
|
||||
|
||||
// Background grid
|
||||
ctx.strokeStyle = '#e5e7eb';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (var q = 1; q <= 7; q++) {
|
||||
var y = yPos(q);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, y);
|
||||
ctx.lineTo(W - pad.right, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(q.toFixed(1), pad.left - 5, y + 4);
|
||||
}
|
||||
|
||||
// Reference line at neutral (4)
|
||||
var neutralY = yPos(4);
|
||||
ctx.strokeStyle = '#d1d5db';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, neutralY);
|
||||
ctx.lineTo(W - pad.right, neutralY);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = '#9ca3af';
|
||||
ctx.font = '11px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('neutral', W - pad.right + 4, neutralY + 4);
|
||||
|
||||
// Line chart
|
||||
ctx.beginPath();
|
||||
data.forEach(function(d, i) {
|
||||
var x = xPos(i);
|
||||
var y = yPos(d.avg_quality);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.strokeStyle = '#6366f1';
|
||||
ctx.lineWidth = 2.5;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.stroke();
|
||||
|
||||
// Gradient fill below the line
|
||||
var gradient = ctx.createLinearGradient(0, pad.top, 0, H - pad.bottom);
|
||||
gradient.addColorStop(0, 'rgba(99, 102, 241, 0.25)');
|
||||
gradient.addColorStop(1, 'rgba(99, 102, 241, 0.02)');
|
||||
ctx.lineTo(xPos(data.length - 1), yPos(yMin));
|
||||
ctx.lineTo(xPos(0), yPos(yMin));
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
|
||||
// Dots
|
||||
data.forEach(function(d, i) {
|
||||
var x = xPos(i);
|
||||
var y = yPos(d.avg_quality);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = '#6366f1';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<p class="text-muted">No mood check-in data found.</p>
|
||||
{% endif %}
|
||||
|
||||
@ -1,50 +1,90 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.hours %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hour</th>
|
||||
<th class="text-end">Scrobbles</th>
|
||||
<th>Distribution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with total=data.hours|dictsortreversed:"count"|first %}
|
||||
{% with max_count=total.count %}
|
||||
{% for entry in data.hours %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if entry.hour == 0 %}
|
||||
12 AM
|
||||
{% elif entry.hour < 12 %}
|
||||
{{ entry.hour }} AM
|
||||
{% elif entry.hour == 12 %}
|
||||
12 PM
|
||||
{% else %}
|
||||
{{ entry.hour|add:"-12" }} PM
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">{{ entry.count }}</td>
|
||||
<td style="width: 40%;">
|
||||
{% if max_count > 0 %}
|
||||
<div class="progress" style="height: 12px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ entry.count|floatformat:0 }}%;"
|
||||
aria-valuenow="{{ entry.count }}"
|
||||
aria-valuemin="0" aria-valuemax="{{ max_count }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ data.hours|json_script:"peak-hours-data" }}
|
||||
<div class="d-flex flex-wrap align-items-start justify-content-center gap-4">
|
||||
<div>
|
||||
<canvas id="peakHoursChart" width="300" height="300" style="max-width:100%;"></canvas>
|
||||
</div>
|
||||
<div class="table-responsive" style="max-height:360px; overflow-y:auto;">
|
||||
<table class="table table-sm table-borderless mb-0" id="peakHoursLegend">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:14px; padding-right:0;"></th>
|
||||
<th>Hour</th>
|
||||
<th class="text-end">Scrobbles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in data.hours %}
|
||||
<tr>
|
||||
<td class="p-1 text-center">
|
||||
<span class="legend-swatch" data-idx="{{ forloop.counter0 }}" style="display:inline-block; width:12px; height:12px; border-radius:2px;"></span>
|
||||
</td>
|
||||
<td>
|
||||
{% if entry.hour == 0 %}
|
||||
12 AM
|
||||
{% elif entry.hour < 12 %}
|
||||
{{ entry.hour }} AM
|
||||
{% elif entry.hour == 12 %}
|
||||
12 PM
|
||||
{% else %}
|
||||
{{ entry.hour|add:"-12" }} PM
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">{{ entry.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var dataEl = document.getElementById('peak-hours-data');
|
||||
if (!dataEl) return;
|
||||
var data = JSON.parse(dataEl.textContent);
|
||||
var total = data.reduce(function(s, h) { return s + h.count; }, 0) || 1;
|
||||
var canvas = document.getElementById('peakHoursChart');
|
||||
if (!canvas) return;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var cx = canvas.width / 2;
|
||||
var cy = canvas.height / 2;
|
||||
var radius = Math.min(cx, cy) - 10;
|
||||
var startAngle = -Math.PI / 2;
|
||||
|
||||
var minCount = data.reduce(function(m, h) { return Math.min(m, h.count); }, Infinity);
|
||||
var maxCount = data.reduce(function(m, h) { return Math.max(m, h.count); }, 0);
|
||||
var range = maxCount - minCount || 1;
|
||||
|
||||
function countToHue(count) {
|
||||
var t = (count - minCount) / range;
|
||||
return 120 * t;
|
||||
}
|
||||
|
||||
data.forEach(function(entry, i) {
|
||||
var sliceAngle = (entry.count / total) * 2 * Math.PI;
|
||||
var hue = countToHue(entry.count);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx, cy);
|
||||
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = 'hsl(' + hue + ', 70%, 50%)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
startAngle += sliceAngle;
|
||||
});
|
||||
|
||||
document.querySelectorAll('.legend-swatch').forEach(function(el) {
|
||||
var idx = parseInt(el.getAttribute('data-idx'), 10);
|
||||
var entry = data[idx];
|
||||
var hue = countToHue(entry.count);
|
||||
el.style.backgroundColor = 'hsl(' + hue + ', 70%, 50%)';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% else %}
|
||||
<p class="text-muted">No activity data found.</p>
|
||||
{% endif %}
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data.total and data.total > 0 %}
|
||||
<div class="mb-4">
|
||||
{% for slug, info in data.categories.items %}
|
||||
{% if forloop.first %}
|
||||
<div class="card text-center border-0 bg-light mb-3">
|
||||
<div class="card-body py-4">
|
||||
{% if slug == "early_bird" %}
|
||||
<div class="display-1">🌅</div>
|
||||
{% elif slug == "day_jay" %}
|
||||
<div class="display-1">☀️</div>
|
||||
{% else %}
|
||||
<div class="display-1">🌙</div>
|
||||
{% endif %}
|
||||
<h3 class="mt-2">{{ info.label }}</h3>
|
||||
<div class="display-4 fw-bold">{{ info.pct }}%</div>
|
||||
<div class="text-muted">{{ info.count }} scrobbles</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card text-center border-0 bg-light mb-2">
|
||||
<div class="card-body py-2 d-flex align-items-center justify-content-between px-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
{% if slug == "early_bird" %}
|
||||
<span style="font-size:1.5rem;">🌅</span>
|
||||
{% elif slug == "day_jay" %}
|
||||
<span style="font-size:1.5rem;">☀️</span>
|
||||
{% else %}
|
||||
<span style="font-size:1.5rem;">🌙</span>
|
||||
{% endif %}
|
||||
<h5 class="mb-0">{{ info.label }}</h5>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="fs-4 fw-bold">{{ info.pct }}%</span>
|
||||
<br><small class="text-muted">{{ info.count }} scrobbles</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="text-center text-muted small mt-2">Total: {{ data.total }} scrobbles across Books, Trails, Birding Locations, and Board Games</div>
|
||||
</div>
|
||||
|
||||
<h5>By Media Type</h5>
|
||||
{% for mt, mt_data in data.by_media_type.items %}
|
||||
<h6 class="mt-3">{{ mt }}</h6>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th class="text-end">Scrobbles</th>
|
||||
<th class="text-end">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for slug, info in mt_data.categories.items %}
|
||||
<tr>
|
||||
<td>{{ info.label }}</td>
|
||||
<td class="text-end">{{ info.count }}</td>
|
||||
<td class="text-end">{{ info.pct }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="table-secondary">
|
||||
<td><strong>Total</strong></td>
|
||||
<td class="text-end"><strong>{{ mt_data.total }}</strong></td>
|
||||
<td class="text-end"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
<p class="text-muted">No data found for Books, Trails, Birding Locations, or Board Games in this period.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@ -1,38 +1,27 @@
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% if data %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Media Type</th>
|
||||
<th class="text-end">Recent ({{ current_period_label }})</th>
|
||||
<th class="text-end">Previous ({{ current_period_label }})</th>
|
||||
<th class="text-end">Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for mt, info in data.items %}
|
||||
<tr>
|
||||
<td>{{ mt }}</td>
|
||||
<td class="text-end">{{ info.recent }}</td>
|
||||
<td class="text-end">{{ info.previous }}</td>
|
||||
<td class="text-end">
|
||||
{% if info.change_pct > 0 %}
|
||||
<span class="text-success">+{{ info.change_pct }}%</span>
|
||||
{% elif info.change_pct < 0 %}
|
||||
<span class="text-danger">{{ info.change_pct }}%</span>
|
||||
{% else %}
|
||||
<span class="text-muted">0%</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="row g-3">
|
||||
{% if data %}
|
||||
{% for mt, info in data.items %}
|
||||
<div class="col-6 col-md-4 col-lg-3 col-xl-2">
|
||||
<div class="card text-center h-100">
|
||||
<div class="card-body d-flex flex-column align-items-center justify-content-center" style="aspect-ratio: 1;">
|
||||
{% if info.change_pct > 0 %}
|
||||
<div class="display-3 lh-1 text-success">↑</div>
|
||||
<div class="fs-6 text-success mt-1">+{{ info.change_pct }}%</div>
|
||||
{% elif info.change_pct < 0 %}
|
||||
<div class="display-3 lh-1 text-danger">↓</div>
|
||||
<div class="fs-6 text-danger mt-1">{{ info.change_pct }}%</div>
|
||||
{% else %}
|
||||
<div class="display-3 lh-1 text-muted">→</div>
|
||||
<div class="fs-6 text-muted mt-1">0%</div>
|
||||
{% endif %}
|
||||
<div class="fw-bold mt-2">{{ mt }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No trending data found.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<p class="text-muted">No trending data found.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,10 @@
|
||||
{% block title %}{{ trend.title }}{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
{% if trend_not_found %}
|
||||
<div class="alert alert-warning">Trend not found.</div>
|
||||
|
||||
{% else %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">← All Trends</a>
|
||||
@ -35,13 +39,21 @@
|
||||
{% if computed_at %}
|
||||
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2">
|
||||
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
{% if trend_disabled %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Enable this trend</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Disable this trend</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if trend_not_found %}
|
||||
<div class="alert alert-warning">Trend not found.</div>
|
||||
|
||||
{% elif data is None %}
|
||||
{% if data is None %}
|
||||
<div class="alert alert-info">
|
||||
No data computed yet for this period. Trends are updated once daily, check back later.
|
||||
</div>
|
||||
@ -55,6 +67,9 @@
|
||||
{% elif trend.slug == "reading-pace-vs-activity" %}
|
||||
{% include "trends/_reading_pace.html" %}
|
||||
|
||||
{% elif trend.slug == "time-of-day-categories" %}
|
||||
{% include "trends/_time_of_day_categories.html" %}
|
||||
|
||||
{% elif trend.slug == "trending-up" %}
|
||||
{% include "trends/_trending_up.html" %}
|
||||
|
||||
@ -82,5 +97,6 @@
|
||||
{% elif trend.slug == "mood-weather" %}
|
||||
{% include "trends/_mood_weather.html" %}
|
||||
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@ -25,11 +25,23 @@
|
||||
</a>
|
||||
</h5>
|
||||
<p class="card-text text-muted">{{ trend.description }}</p>
|
||||
{% if trend.computed_at %}
|
||||
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Pending</span>
|
||||
{% endif %}
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
{% if trend.computed_at %}
|
||||
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Pending</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="m-0">
|
||||
{% csrf_token %}
|
||||
{% if trend.disabled %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Enable</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Disable</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -15,6 +15,7 @@ from trends.trends.mood import (
|
||||
compute_mood_weather,
|
||||
)
|
||||
from trends.trends.reading import compute_reading_pace_vs_activity
|
||||
from trends.trends.time_of_day import compute_time_of_day_categories
|
||||
from trends.trends.trending import compute_trending_up
|
||||
|
||||
TREND_REGISTRY = {}
|
||||
@ -44,5 +45,8 @@ compute_peak_hours = register("peak-hours")(compute_peak_hours)
|
||||
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
|
||||
compute_reading_pace_vs_activity
|
||||
)
|
||||
compute_time_of_day_categories = register("time-of-day-categories")(
|
||||
compute_time_of_day_categories
|
||||
)
|
||||
compute_trending_up = register("trending-up")(compute_trending_up)
|
||||
compute_weekly_rhythm = register("weekly-rhythm")(compute_weekly_rhythm)
|
||||
|
||||
95
vrobbler/apps/trends/trends/time_of_day.py
Normal file
95
vrobbler/apps/trends/trends/time_of_day.py
Normal file
@ -0,0 +1,95 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models.functions import Extract
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
TARGET_MEDIA_TYPES = ["Book", "Trail", "BirdingLocation", "BoardGame"]
|
||||
|
||||
CATEGORIES = OrderedDict(
|
||||
[
|
||||
("early_bird", {"label": "Early Bird", "hours": {5, 6, 7, 8, 9, 10}}),
|
||||
("day_jay", {"label": "Day Jay", "hours": {11, 12, 13, 14, 15, 16, 17, 18}}),
|
||||
("night_owl", {"label": "Night Owl", "hours": {19, 20, 21, 22, 23, 0, 1, 2, 3, 4}}),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _categorize_hour(hour):
|
||||
for slug, cat in CATEGORIES.items():
|
||||
if hour in cat["hours"]:
|
||||
return slug
|
||||
return None
|
||||
|
||||
|
||||
def compute_time_of_day_categories(user, period="last_30"):
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
filters = Q(user=user, media_type__in=TARGET_MEDIA_TYPES, timestamp__isnull=False)
|
||||
if start:
|
||||
filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
filters &= Q(timestamp__lte=end)
|
||||
|
||||
qs = (
|
||||
Scrobble.objects.filter(filters)
|
||||
.annotate(hour=Extract("timestamp", "hour"))
|
||||
.values("media_type", "hour")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("media_type", "hour")
|
||||
)
|
||||
|
||||
raw = {}
|
||||
for row in qs:
|
||||
mt = row["media_type"]
|
||||
raw.setdefault(mt, {})[row["hour"]] = row["count"]
|
||||
|
||||
by_media_type = {}
|
||||
grand_totals = {"early_bird": 0, "day_jay": 0, "night_owl": 0}
|
||||
grand_total = 0
|
||||
|
||||
for mt in TARGET_MEDIA_TYPES:
|
||||
mt_data = raw.get(mt, {})
|
||||
cat_counts = {"early_bird": 0, "day_jay": 0, "night_owl": 0}
|
||||
mt_total = 0
|
||||
for hour, count in mt_data.items():
|
||||
slug = _categorize_hour(hour)
|
||||
if slug:
|
||||
cat_counts[slug] += count
|
||||
mt_total += count
|
||||
mt_categories = {}
|
||||
for slug in CATEGORIES:
|
||||
c = cat_counts[slug]
|
||||
mt_categories[slug] = {
|
||||
"count": c,
|
||||
"pct": round((c / mt_total * 100), 1) if mt_total else 0,
|
||||
"label": CATEGORIES[slug]["label"],
|
||||
}
|
||||
grand_totals[slug] += c
|
||||
by_media_type[mt] = {
|
||||
"total": mt_total,
|
||||
"categories": dict(
|
||||
sorted(mt_categories.items(), key=lambda x: x[1]["count"], reverse=True)
|
||||
),
|
||||
}
|
||||
grand_total += mt_total
|
||||
|
||||
categories = {}
|
||||
for slug in CATEGORIES:
|
||||
c = grand_totals[slug]
|
||||
categories[slug] = {
|
||||
"count": c,
|
||||
"pct": round((c / grand_total * 100), 1) if grand_total else 0,
|
||||
"label": CATEGORIES[slug]["label"],
|
||||
}
|
||||
categories = dict(
|
||||
sorted(categories.items(), key=lambda x: x[1]["count"], reverse=True)
|
||||
)
|
||||
|
||||
return {
|
||||
"categories": categories,
|
||||
"total": grand_total,
|
||||
"by_media_type": by_media_type,
|
||||
}
|
||||
@ -1,9 +1,18 @@
|
||||
from django.urls import path
|
||||
from trends.views import TrendDetailView, TrendListView
|
||||
from trends.views import (
|
||||
ToggleTrendDisabledView,
|
||||
TrendDetailView,
|
||||
TrendListView,
|
||||
)
|
||||
|
||||
app_name = "trends"
|
||||
|
||||
urlpatterns = [
|
||||
path("trends/", TrendListView.as_view(), name="trends-home"),
|
||||
path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"),
|
||||
path(
|
||||
"trends/<slug:trend_slug>/toggle/",
|
||||
ToggleTrendDisabledView.as_view(),
|
||||
name="trend-toggle-disabled",
|
||||
),
|
||||
]
|
||||
|
||||
@ -3,6 +3,7 @@ from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from trends.models import PERIOD_CHOICES, TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -25,6 +26,7 @@ TIME_BOUND_TRENDS = {
|
||||
"mood-weather",
|
||||
"peak-hours",
|
||||
"reading-pace-vs-activity",
|
||||
"time-of-day-categories",
|
||||
"trending-up",
|
||||
"weekly-rhythm",
|
||||
}
|
||||
@ -34,6 +36,13 @@ TREND_PERIOD_OVERRIDES = {
|
||||
}
|
||||
|
||||
|
||||
def get_disabled_trends(user):
|
||||
profile = user.profile
|
||||
if profile.trends_disabled:
|
||||
return set(TREND_REGISTRY.keys())
|
||||
return set(profile.disabled_trends or [])
|
||||
|
||||
|
||||
def get_supported_periods(trend_slug):
|
||||
if trend_slug in TREND_PERIOD_OVERRIDES:
|
||||
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import TemplateView
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.views.generic import TemplateView, View
|
||||
from trends.models import TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import get_period_nav, get_supported_periods
|
||||
from trends.utils import get_disabled_trends, get_period_nav, get_supported_periods
|
||||
|
||||
TREND_METADATA = {
|
||||
"activity-distribution": {
|
||||
@ -55,6 +58,11 @@ TREND_METADATA = {
|
||||
"description": "Compare how long you read per session with and without concurrent music.",
|
||||
"icon": "📊",
|
||||
},
|
||||
"time-of-day-categories": {
|
||||
"title": "Time of Day Categories",
|
||||
"description": "Are you an early bird, day jay, or night owl? Categorized by Books, Trails, Birding Locations, and Board Games.",
|
||||
"icon": "🦉",
|
||||
},
|
||||
"trending-up": {
|
||||
"title": "Trending Media Types",
|
||||
"description": "Which media types have you been consuming more or less of recently?",
|
||||
@ -73,6 +81,8 @@ class TrendListView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
disabled = get_disabled_trends(self.request.user)
|
||||
|
||||
results = TrendResult.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("trend_slug", "-computed_at")
|
||||
@ -84,8 +94,11 @@ class TrendListView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
trends = []
|
||||
for slug in TREND_REGISTRY:
|
||||
if slug in disabled:
|
||||
continue
|
||||
meta = TREND_METADATA.get(slug, {})
|
||||
result = latest_by_slug.get(slug)
|
||||
is_disabled = slug in (self.request.user.profile.disabled_trends or [])
|
||||
trends.append(
|
||||
{
|
||||
"slug": slug,
|
||||
@ -94,6 +107,7 @@ class TrendListView(LoginRequiredMixin, TemplateView):
|
||||
"icon": meta.get("icon", ""),
|
||||
"computed_at": result.computed_at if result else None,
|
||||
"has_data": result is not None,
|
||||
"disabled": is_disabled,
|
||||
}
|
||||
)
|
||||
ctx["trends"] = trends
|
||||
@ -143,4 +157,25 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
||||
ctx["computed_at"] = None
|
||||
ctx["data"] = None
|
||||
|
||||
disabled = get_disabled_trends(self.request.user)
|
||||
ctx["trend_disabled"] = slug in disabled
|
||||
ctx["globally_disabled"] = self.request.user.profile.trends_disabled
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
class ToggleTrendDisabledView(LoginRequiredMixin, View):
|
||||
def post(self, request, trend_slug):
|
||||
profile = request.user.profile
|
||||
disabled = set(profile.disabled_trends or [])
|
||||
if trend_slug in disabled:
|
||||
disabled.discard(trend_slug)
|
||||
messages.success(request, f"Trend re-enabled.")
|
||||
else:
|
||||
disabled.add(trend_slug)
|
||||
messages.success(request, f"Trend disabled.")
|
||||
profile.disabled_trends = list(disabled)
|
||||
profile.save(update_fields=["disabled_trends"])
|
||||
return HttpResponseRedirect(
|
||||
request.META.get("HTTP_REFERER", reverse("trends:trends-home"))
|
||||
)
|
||||
|
||||
@ -17,8 +17,8 @@ MISSING_ALL = [
|
||||
]
|
||||
|
||||
MISSING_GROUPS = {
|
||||
"cover": lambda g: not bool(g.cover),
|
||||
"screenshot": lambda g: not bool(g.screenshot),
|
||||
"cover": lambda g: _image_missing_or_broken(g, "cover"),
|
||||
"screenshot": lambda g: _image_missing_or_broken(g, "screenshot"),
|
||||
"summary": lambda g: not g.summary,
|
||||
"rating": lambda g: g.rating is None,
|
||||
"release_date": lambda g: g.release_date is None,
|
||||
@ -28,6 +28,16 @@ MISSING_GROUPS = {
|
||||
}
|
||||
|
||||
|
||||
def _image_missing_or_broken(game, field_name) -> bool:
|
||||
field = getattr(game, field_name)
|
||||
if not bool(field):
|
||||
return True
|
||||
try:
|
||||
return not field.storage.exists(field.name)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _game_matches(game, flags):
|
||||
if not flags:
|
||||
return False
|
||||
@ -103,6 +113,7 @@ class Command(BaseCommand):
|
||||
|
||||
if all_missing:
|
||||
flags = MISSING_ALL
|
||||
fix_broken_images = True
|
||||
|
||||
if not flags and not game_id and not force and not fix_broken_images:
|
||||
self.stdout.write(
|
||||
|
||||
@ -30,6 +30,19 @@ class Command(BaseCommand):
|
||||
action="store_true",
|
||||
help="Only process channels with a twitch_id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--needs-metadata",
|
||||
action="store_true",
|
||||
help="Only process channels missing youtube_id, twitch_id, cover image, or with broken cover image",
|
||||
)
|
||||
|
||||
def _has_broken_image(self, channel) -> bool:
|
||||
if not channel.cover_image or not channel.cover_image.name:
|
||||
return False
|
||||
try:
|
||||
return not channel.cover_image.storage.exists(channel.cover_image.name)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from videos.models import Channel
|
||||
@ -38,6 +51,7 @@ class Command(BaseCommand):
|
||||
dry_run = options["dry_run"]
|
||||
youtube_only = options["youtube_only"]
|
||||
twitch_only = options["twitch_only"]
|
||||
needs_metadata = options["needs_metadata"]
|
||||
|
||||
qs = Channel.objects.all()
|
||||
|
||||
@ -45,29 +59,61 @@ class Command(BaseCommand):
|
||||
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
|
||||
elif twitch_only:
|
||||
qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
|
||||
elif needs_metadata:
|
||||
no_id = models.Q(youtube_id__isnull=True) & models.Q(twitch_id__isnull=True)
|
||||
no_id |= models.Q(youtube_id="") & models.Q(twitch_id="")
|
||||
no_id |= models.Q(youtube_id__isnull=True) & models.Q(twitch_id="")
|
||||
no_id |= models.Q(youtube_id="") & models.Q(twitch_id__isnull=True)
|
||||
qs = qs.filter(
|
||||
no_id
|
||||
| models.Q(cover_image__isnull=True)
|
||||
| models.Q(cover_image="")
|
||||
)
|
||||
else:
|
||||
qs = qs.filter(
|
||||
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
|
||||
).exclude(youtube_id="", twitch_id="")
|
||||
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Processing {total} channels")
|
||||
self.stdout.write(f"Processing {total} channels from DB filter")
|
||||
|
||||
broken_channels = []
|
||||
if needs_metadata:
|
||||
broken_qs = Channel.objects.filter(
|
||||
cover_image__isnull=False,
|
||||
).exclude(
|
||||
cover_image="",
|
||||
)
|
||||
if youtube_only:
|
||||
broken_qs = broken_qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
|
||||
elif twitch_only:
|
||||
broken_qs = broken_qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
|
||||
for channel in broken_qs.iterator():
|
||||
if self._has_broken_image(channel):
|
||||
broken_channels.append(channel)
|
||||
|
||||
all_channels = list(qs) + broken_channels
|
||||
total = len(all_channels)
|
||||
self.stdout.write(f"Total channels to process: {total}")
|
||||
|
||||
if dry_run:
|
||||
for channel in qs.iterator():
|
||||
for channel in all_channels:
|
||||
source = "youtube" if channel.youtube_id else "twitch"
|
||||
identifier = channel.youtube_id or channel.twitch_id
|
||||
status = f"({source}: {identifier})"
|
||||
if self._has_broken_image(channel):
|
||||
status += " [image BROKEN]"
|
||||
self.stdout.write(
|
||||
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})"
|
||||
f" [DRY RUN] Would fix {channel.name} {status}"
|
||||
)
|
||||
return
|
||||
|
||||
updated = 0
|
||||
errors = 0
|
||||
for channel in qs.iterator():
|
||||
for channel in all_channels:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
channel.fix_metadata(force=force)
|
||||
channel.fix_metadata(force=force or self._has_broken_image(channel))
|
||||
updated += 1
|
||||
source = "youtube" if channel.youtube_id else "twitch"
|
||||
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models, transaction
|
||||
@ -28,9 +29,18 @@ class Command(BaseCommand):
|
||||
parser.add_argument(
|
||||
"--needs-metadata",
|
||||
action="store_true",
|
||||
help="Only process series missing imdb_id or cover image",
|
||||
help="Only process series missing imdb_id or with broken cover image",
|
||||
)
|
||||
|
||||
def _has_broken_image(self, series) -> bool:
|
||||
"""Check if a series has a cover_image set but the file is missing."""
|
||||
if not series.cover_image:
|
||||
return False
|
||||
try:
|
||||
return not os.path.exists(series.cover_image.path)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from videos.models import Series
|
||||
|
||||
@ -51,25 +61,50 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Processing {total} series")
|
||||
self.stdout.write(f"Processing {total} series from DB filter")
|
||||
|
||||
# Also find series with broken cover images
|
||||
broken_image_series = []
|
||||
if needs_metadata:
|
||||
broken_qs = Series.objects.filter(
|
||||
cover_image__isnull=False,
|
||||
).exclude(
|
||||
models.Q(imdb_id__isnull=True)
|
||||
| models.Q(imdb_id="")
|
||||
| models.Q(cover_image__isnull=True)
|
||||
| models.Q(cover_image=""),
|
||||
)
|
||||
if imdb_id:
|
||||
broken_qs = broken_qs.filter(imdb_id=imdb_id)
|
||||
for series in broken_qs.iterator():
|
||||
if self._has_broken_image(series):
|
||||
broken_image_series.append(series)
|
||||
|
||||
all_series = list(qs) + broken_image_series
|
||||
total = len(all_series)
|
||||
self.stdout.write(f"Total series to process: {total}")
|
||||
|
||||
if dry_run:
|
||||
for series in qs.iterator():
|
||||
for series in all_series:
|
||||
has_imdb = bool(series.imdb_id)
|
||||
has_image = bool(series.cover_image)
|
||||
self.stdout.write(
|
||||
f" [DRY RUN] Would fix {series.name}"
|
||||
f" (imdb_id={'✓' if has_imdb else '✗'}"
|
||||
f", image={'✓' if has_image else '✗'})"
|
||||
)
|
||||
image_broken = self._has_broken_image(series)
|
||||
status = f"imdb_id={'✓' if has_imdb else '✗'}"
|
||||
if image_broken:
|
||||
status += ", image=BROKEN"
|
||||
elif has_image:
|
||||
status += ", image=✓"
|
||||
else:
|
||||
status += ", image=✗"
|
||||
self.stdout.write(f" [DRY RUN] Would fix {series.name} ({status})")
|
||||
return
|
||||
|
||||
updated = 0
|
||||
errors = 0
|
||||
for series in qs.iterator():
|
||||
for series in all_series:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
series.fix_metadata(force_update=force)
|
||||
series.fix_metadata(force_update=force or self._has_broken_image(series))
|
||||
updated += 1
|
||||
self.stdout.write(f" [{updated}/{total}] {series.name}")
|
||||
except Exception as e:
|
||||
|
||||
@ -45,6 +45,37 @@ class Domain(TimeStampedModel):
|
||||
)
|
||||
|
||||
|
||||
def _fetch_url_raw(url: str) -> Optional[str]:
|
||||
"""Fetch raw HTML for a URL.
|
||||
|
||||
Tries two strategies in order:
|
||||
1. trafilatura (standard, works for most sites)
|
||||
2. cloudscraper (handles Cloudflare JS challenges)
|
||||
|
||||
cloudscraper is lazy-imported so deployments that cannot compile
|
||||
its dependencies (e.g. FreeBSD) still work.
|
||||
"""
|
||||
raw = trafilatura.fetch_url(url)
|
||||
if raw:
|
||||
return raw
|
||||
|
||||
logger.debug("trafilatura returned nothing for %s, trying cloudscraper", url)
|
||||
try:
|
||||
import cloudscraper
|
||||
except ImportError:
|
||||
logger.debug("cloudscraper not available")
|
||||
else:
|
||||
try:
|
||||
scraper = cloudscraper.create_scraper()
|
||||
resp = scraper.get(url, timeout=30)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as exc:
|
||||
logger.debug("cloudscraper failed for %s: %s", url, exc)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class WebPage(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "WEBSITE_COMPLETION_PERCENT", 100)
|
||||
|
||||
@ -80,7 +111,7 @@ class WebPage(ScrobblableMixin):
|
||||
|
||||
def _update_extract_from_web(self, raw_text: str = "", force=True):
|
||||
if not raw_text:
|
||||
raw_text = requests.get(self.url, headers=headers).text
|
||||
raw_text = _fetch_url_raw(self.url)
|
||||
if not self.extract or force:
|
||||
self.extract = trafilatura.extract(raw_text)
|
||||
self.save(update_fields=["extract"])
|
||||
@ -259,7 +290,7 @@ class WebPage(ScrobblableMixin):
|
||||
return False
|
||||
|
||||
def fetch_data_from_web(self, save=True, force=True):
|
||||
raw_text = trafilatura.fetch_url(self.url)
|
||||
raw_text = _fetch_url_raw(self.url)
|
||||
if not self.extract or force:
|
||||
self.extract = trafilatura.extract(
|
||||
raw_text,
|
||||
|
||||
@ -103,7 +103,13 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
CSRF_TRUSTED_ORIGINS = [os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000")]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
os.getenv(
|
||||
"VROBBLER_TRUSTED_ORIGINS",
|
||||
"http://localhost:8000",
|
||||
),
|
||||
"https://dev-vrobbler.lab.unbl.ink",
|
||||
]
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
|
||||
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
|
||||
@ -397,6 +403,14 @@ else:
|
||||
MEDIA_URL = os.getenv("VROBBLER_MEDIA_URL", "/media/")
|
||||
|
||||
|
||||
SCIHUB_DOMAIN = os.getenv("VROBBLER_SCIHUB_DOMAIN", "sci-hub.st")
|
||||
|
||||
SKIP_AUTO_EXPANSION_DOWNLOAD = [
|
||||
int(x.strip())
|
||||
for x in os.getenv("VROBBLER_SKIP_AUTO_EXPANSION_DOWNLOAD", "").split(",")
|
||||
if x.strip().isdigit()
|
||||
]
|
||||
|
||||
JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY
|
||||
LOG_TYPE = "json" if JSON_LOGGING else "log"
|
||||
|
||||
|
||||
@ -46,6 +46,20 @@
|
||||
<p>
|
||||
<a href="{{object.start_url}}">Play again</a>
|
||||
</p>
|
||||
{% if object.genre.all %}
|
||||
<p>Genres:
|
||||
{% for tag in object.genre.all %}
|
||||
<a href="{% url 'boardgames:boardgame_list' %}?genre={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if object.tags.all %}
|
||||
<p>Tags:
|
||||
{% for tag in object.tags.all %}
|
||||
<a href="{% url 'boardgames:boardgame_list' %}?tag={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if charts %}
|
||||
<div class="row">
|
||||
|
||||
62
vrobbler/templates/books/paper_detail.html
Normal file
62
vrobbler/templates/books/paper_detail.html
Normal file
@ -0,0 +1,62 @@
|
||||
{% extends "base_list.html" %}
|
||||
|
||||
{% block title %}{{object.title}}{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1>{{object.title}}</h1>
|
||||
{% if object.authors.all %}
|
||||
<p>{{object.authors.all|join:", "}}</p>
|
||||
{% endif %}
|
||||
{% if object.journal %}
|
||||
<p><em>{{object.journal.title}}{% if object.journal_volume %}, vol. {{object.journal_volume}}{% endif %}</em></p>
|
||||
{% endif %}
|
||||
{% if object.doi_id %}
|
||||
<p><a href="https://doi.org/{{object.doi_id}}">doi: {{object.doi_id}}</a></p>
|
||||
{% endif %}
|
||||
{% if object.abstract %}
|
||||
<p>{{object.abstract|linebreaks|truncatewords:200}}</p>
|
||||
{% endif %}
|
||||
{% if object.pdf_file %}
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="togglePdf()">Show/Hide PDF</button>
|
||||
<div id="pdf-embed" style="display:none; margin-top:0.5rem;">
|
||||
<iframe src="{{object.pdf_file.url}}" style="width:100%;height:600px;border:1px solid #ccc;"></iframe>
|
||||
</div>
|
||||
<script>
|
||||
function togglePdf() {
|
||||
var el = document.getElementById('pdf-embed');
|
||||
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if object.openaccess_pdf_url %}
|
||||
<p><a href="{{object.openaccess_pdf_url}}">Open Access PDF</a></p>
|
||||
{% endif %}
|
||||
{% if object.pdf_file %}
|
||||
<a href="{{object.pdf_file.url}}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
|
||||
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
|
||||
<path d="M4.603 14.087a.81.81 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.197-.307.526-.568.897-.707.07-.024.15-.023.222 0l.044.014a.27.27 0 0 1 .152.295.7.7 0 0 1-.128.416c-.159.206-.344.388-.544.555-.357.299-.592.527-.406.623.08.04.272.045.578-.057a.93.93 0 0 0 .363-.226.8.8 0 0 0 .194-.277.28.28 0 0 1 .414-.112.3.3 0 0 1 .065.422 1.3 1.3 0 0 1-.67.522c-.38.147-.746.103-1.04.02zM7.12 11.5c.16-.186.34-.34.486-.514.294-.35.628-.617.947-.786.204-.108.546-.206.715-.153.087.027.135.068.16.126a.6.6 0 0 1-.003.27 1 1 0 0 1-.158.354c-.163.242-.349.47-.7.769-.332.283-.598.483-.793.607a1.1 1.1 0 0 1-.582.214c-.136 0-.234-.038-.298-.11-.05-.056-.076-.134-.07-.236a.99.99 0 0 1 .098-.45c.08-.17.21-.35.378-.57zm5.09 2.013c-.135.06-.277.104-.428.116-.205.015-.39-.048-.553-.177-.104-.082-.226-.196-.317-.325a1 1 0 0 1-.17-.572c0-.15.035-.27.095-.36.04-.063.089-.098.153-.112.138-.028.316.04.477.174.074.061.145.136.228.232.174.2.302.37.397.515.108.164.153.285.121.345a.25.25 0 0 1-.003.053c0 .079-.05.147-.198.262z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if object.scihub_url %}
|
||||
<p><a href="{{object.scihub_url}}">View on Sci-Hub</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<form method="post" enctype="multipart/form-data" action="{% url 'books:paper_upload_pdf' slug=object.uuid %}">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="pdf_file">Upload PDF</label>
|
||||
<input type="file" class="form-control-file" id="pdf_file" name="pdf_file" accept=".pdf,application/pdf">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Upload</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
vrobbler/templates/books/paper_list.html
Normal file
13
vrobbler/templates/books/paper_list.html
Normal file
@ -0,0 +1,13 @@
|
||||
{% extends "base_list.html" %}
|
||||
|
||||
{% block title %}Papers{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<div class="table-responsive">
|
||||
{% include "_longplay_scrobblable_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -120,9 +120,67 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'charts:maloja-charts' %}" class="btn btn-sm btn-outline-secondary">🎨 Maloja Widgets</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
{% if charts.artist %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>🎤 Top Artists</h3>
|
||||
<ul class="list-group">
|
||||
{% for chart in charts.artist|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||
<a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a>
|
||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'charts:chart-detail' 'artist' %}">View all »</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.album %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>💿 Top Albums</h3>
|
||||
<ul class="list-group">
|
||||
{% for chart in charts.album|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||
<a href="{{chart.album.get_absolute_url}}">{{chart.album.name}}</a>
|
||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'charts:chart-detail' 'album' %}">View all »</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if charts.tv_series %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>📺 Top TV Series</h3>
|
||||
<ul class="list-group">
|
||||
{% for chart in charts.tv_series|slice:":20" %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||
<a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a>
|
||||
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="list-group-item">
|
||||
<a href="{% url 'charts:chart-detail' 'tv_series' %}">View all »</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
{% if charts.track %}
|
||||
<div class="col-md-6 col-lg-4 chart-section">
|
||||
<h3>🎵 Top Tracks</h3>
|
||||
|
||||
45
vrobbler/templates/charts/maloja_charts.html
Normal file
45
vrobbler/templates/charts/maloja_charts.html
Normal file
@ -0,0 +1,45 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Maloja Widgets{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.container { margin-bottom: 100px; }
|
||||
h2 { padding-top: 20px; }
|
||||
.nav-tabs { cursor: pointer; }
|
||||
.image-wrapper { contain: content; }
|
||||
.image-wrapper :hover { background: rgba(0,0,0,0.3); }
|
||||
.caption {
|
||||
position: fixed; top: 5px; left: 5px;
|
||||
padding: 3px; font-size: 90%;
|
||||
color: white; background: rgba(0,0,0,0.4);
|
||||
}
|
||||
.caption-medium {
|
||||
position: fixed; top: 5px; left: 5px;
|
||||
padding: 3px; font-size: 75%;
|
||||
color: white; background: rgba(0,0,0,0.4);
|
||||
}
|
||||
.caption-small {
|
||||
position: fixed; top: 5px; left: 5px;
|
||||
padding: 3px; font-size: 60%;
|
||||
color: white; background: rgba(0,0,0,0.4);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
|
||||
{% block grid_view_button %}{% endblock %}
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">← Full Charts</a>
|
||||
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
||||
<a href="{% url 'charts:bandcamp-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Bandcamp Tracks</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
|
||||
{% endblock %}
|
||||
@ -130,6 +130,11 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif field.name == "trends_disabled" %}
|
||||
<p class="checkbox-row">
|
||||
{{ field }}
|
||||
{{ field.label_tag }}
|
||||
</p>
|
||||
{% elif field.name == "home_scrobble_limit" %}
|
||||
<p>
|
||||
{{ field.label_tag }}
|
||||
|
||||
@ -49,11 +49,12 @@
|
||||
</div>
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "67891011121314" %}
|
||||
{% with artists|get_item:forloop.counter|add:5 as artist %}
|
||||
{% for i in "123456789" %}
|
||||
{% with forloop.counter|add:4 as idx %}
|
||||
{% with artists|get_item:idx as artist %}
|
||||
{% if artist %}
|
||||
<div class="image-wrapper" style="width:33%">
|
||||
<div class="caption-small">#{{forloop.counter|add:6}} {{artist.artist.name}}</div>
|
||||
<div class="caption-small">#{{forloop.counter|add:5}} {{artist.artist.name}}</div>
|
||||
{% if artist.artist.thumbnail %}
|
||||
<a href="{{artist.artist.get_absolute_url}}"><img src="{{artist.artist.thumbnail_medium.url}}" width="100px"></a>
|
||||
{% else %}
|
||||
@ -62,6 +63,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@ -95,7 +97,7 @@
|
||||
<div style="display:block">
|
||||
<div style="float:left;">
|
||||
<div class="image-wrapper" style="display:flex; flex-wrap: wrap; margin:0">
|
||||
<div class="caption">#1 {{albums.0.album.title}}</div>
|
||||
<div class="caption">#1 {{albums.0.album.name}}</div>
|
||||
{% if albums.0.album.cover_image %}
|
||||
<a href="{{albums.0.album.get_absolute_url}}"><img src="{{albums.0.album.cover_image_medium.url}}" width="300px"></a>
|
||||
{% else %}
|
||||
@ -109,7 +111,7 @@
|
||||
{% with albums|get_item:forloop.counter as album %}
|
||||
{% if album %}
|
||||
<div class="image-wrapper" style="width:50%">
|
||||
<div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.title}}</div>
|
||||
<div class="caption-medium">#{{forloop.counter|add:1}} {{album.album.name}}</div>
|
||||
{% if album.album.cover_image %}
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="150px"></a>
|
||||
{% else %}
|
||||
@ -123,11 +125,12 @@
|
||||
</div>
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "67891011121314" %}
|
||||
{% with albums|get_item:forloop.counter|add:5 as album %}
|
||||
{% for i in "123456789" %}
|
||||
{% with forloop.counter|add:4 as idx %}
|
||||
{% with albums|get_item:idx as album %}
|
||||
{% if album %}
|
||||
<div class="image-wrapper" style="width:33%">
|
||||
<div class="caption-small">#{{forloop.counter|add:6}} {{album.album.title}}</div>
|
||||
<div class="caption-small">#{{forloop.counter|add:5}} {{album.album.name}}</div>
|
||||
{% if album.album.cover_image %}
|
||||
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="100px"></a>
|
||||
{% else %}
|
||||
@ -136,6 +139,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
@ -197,11 +201,12 @@
|
||||
</div>
|
||||
<div style="float:left; width:300px;">
|
||||
<div style="display:flex; flex-wrap: wrap;">
|
||||
{% for i in "67891011121314" %}
|
||||
{% with shows|get_item:forloop.counter|add:5 as show %}
|
||||
{% for i in "123456789" %}
|
||||
{% with forloop.counter|add:4 as idx %}
|
||||
{% with shows|get_item:idx as show %}
|
||||
{% if show %}
|
||||
<div class="image-wrapper" style="width:33%">
|
||||
<div class="caption-small">#{{forloop.counter|add:6}} {{show.tv_series.name}}</div>
|
||||
<div class="caption-small">#{{forloop.counter|add:5}} {{show.tv_series.name}}</div>
|
||||
{% if show.tv_series.cover_image %}
|
||||
<a href="{{show.tv_series.get_absolute_url}}"><img src="{{show.tv_series.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
|
||||
{% else %}
|
||||
@ -210,6 +215,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -76,9 +76,11 @@
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'scrobbles:scrobble-list' %}" class="btn btn-sm btn-outline-secondary">Live</a>
|
||||
</div>
|
||||
{% if not user.profile.trends_disabled %}
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="btn-group me-2">
|
||||
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
|
||||
<form action="{% url 'scrobbles:lastfm-import' %}" method="get">
|
||||
|
||||
@ -16,6 +16,7 @@ from vrobbler.apps.boardgames.api.views import (
|
||||
BoardGameDesignerViewSet,
|
||||
BoardGamePublisherViewSet,
|
||||
BoardGameLocationViewSet,
|
||||
BoardGameVariantViewSet,
|
||||
)
|
||||
|
||||
from vrobbler.apps.books import urls as book_urls
|
||||
@ -146,6 +147,7 @@ router.register(r"boardgames", BoardGameViewSet)
|
||||
router.register(r"boardgame-designers", BoardGameDesignerViewSet)
|
||||
router.register(r"boardgame-publishers", BoardGamePublisherViewSet)
|
||||
router.register(r"boardgame-locations", BoardGameLocationViewSet)
|
||||
router.register(r"boardgame-variants", BoardGameVariantViewSet)
|
||||
router.register(r"podcast-producers", ProducerViewSet)
|
||||
router.register(r"podcast-episodes", PodcastEpisodeViewSet)
|
||||
router.register(r"podcasts", PodcastViewSet)
|
||||
|
||||
Reference in New Issue
Block a user