Compare commits

...

289 Commits
0.15.2 ... 17.1

Author SHA1 Message Date
36ceb4c7fe [release] 17.1 2025-06-27 10:52:50 -04:00
88a3831975 [tasks] Make tasks use user profile string 2025-06-27 10:48:05 -04:00
63361964ca [tasks] Add optional user context labels to get title 2025-06-27 10:37:09 -04:00
40b54b27f4 [tasks] Actually add the new utils file 2025-06-26 14:50:11 -04:00
a7eca4b9a7 [tasks] Actually get title consistently 2025-06-26 14:09:17 -04:00
d152412e99 [books] Add optional details key to log data for books 2025-06-25 10:21:26 -04:00
3ba6c6b6e4 [project] Add new bug in tasks
Though honestly, it will probably be a config setting in the Emacs hook,
but we'll see. Actually, maybe the emacs hook code should end up in this
repo somewhere 🤔
2025-06-24 14:25:20 -04:00
bffbf47c2f [release] 17.0 2025-06-24 10:56:14 -04:00
f4e81da533 [project] Completing task for label bug 2025-06-24 10:55:01 -04:00
4b7f5459be [project] Add new task for TV series bug 2025-06-24 10:53:57 -04:00
c68b0e9d7e [tasks] Fix bug in emacs labels 2025-06-24 10:52:47 -04:00
32ec65116b [tasks] Move to single context for task titles 2025-06-22 21:26:20 -04:00
da8d26fcd9 [trails] Add alltrails and gaiagps ids to model 2025-06-22 20:26:37 -04:00
d33954e494 [project] Tidy up todos 2025-06-17 10:53:26 -04:00
1b306d6493 [project] Move todos.org file to PROJECT.org 2025-06-17 10:50:30 -04:00
c881143e1b [todos] Update backlog with tasks from Todoist 2025-06-17 10:18:48 -04:00
141700fcb3 [music] Fixes RYM slug including commas 2025-06-16 09:38:19 -04:00
7357b5bfec [todos] Update todo formatting 2025-06-16 09:35:30 -04:00
99cabd0007 [todos] Finish another task! 2025-06-13 12:35:24 -04:00
cf77e12cc3 [tasks] Add description of task to the template 2025-06-13 12:34:55 -04:00
3f2cbbb34a [videos] Fix TMDB lookup for movies 2025-06-13 12:10:43 -04:00
650ecf12c6 Merge branch 'main' into develop 2025-06-13 11:55:04 -04:00
d7cc009d07 [ci] Make deploys on happen on tags 2025-06-13 11:52:14 -04:00
a872cf3611 [templates] Use TMDB rating ID if available 2025-06-13 11:47:42 -04:00
1f9713312b [videos] Fix lookup for mevie posters 2025-06-13 11:47:04 -04:00
159e555d7c [templates] Remove global counts from home page 2025-06-13 11:33:21 -04:00
981f4f9c9a [videos] Quick fix 2025-06-13 11:23:37 -04:00
ddd5ce1392 Merge branch 'develop' 2025-06-13 11:21:07 -04:00
7a75b31b56 [todos] Update task for metadata fixing 2025-06-13 11:20:34 -04:00
24ac545f55 [videos] Switch to TMDB for scraping videos 2025-06-13 11:19:15 -04:00
d5da8ae701 [todos] Update priority task 2025-06-13 09:43:08 -04:00
53a04d064d [videos] At least stop IMDB from breaking 2025-06-13 09:15:48 -04:00
c0871a3b9e [scrobbles] Fix failing tests 2025-06-12 09:57:32 -04:00
d917dd8b2c [tasks] Fix source checking for emacs/orgmode tasks when updating 2025-06-12 09:54:16 -04:00
6fc8084f2d [scrobbles] Fix log of media type 2025-06-11 12:01:02 -04:00
41d6fe8ff6 [todos] Add new task 2025-06-10 11:27:37 -04:00
73838312cd [tasks] Clean up emacs webhook flow 2025-06-10 11:06:47 -04:00
eb2dd4c839 [todos] Clean up todos and add new milestone 17 2025-06-10 10:05:40 -04:00
1a0c4f69f0 Merge branch 'develop' 2025-06-09 11:26:25 -04:00
356e579558 [brickset] Little fix to scrobble of bricksets 2025-06-09 10:40:12 -04:00
e980e3c5c9 [brickset] Fix manual scrobbling and admin 2025-06-09 10:36:13 -04:00
8773542099 [tasks] Fix source being orgmode not emacs 2025-06-08 03:05:43 -04:00
70378c9968 [tasks] Fix updating and stopping tasks from Emacs 2025-06-08 02:40:07 -04:00
c871087496 [tasks] First try at adding an emacs webhook 2025-06-08 01:09:03 -04:00
059d7780a0 [templates] Add counts and durations to new tables 2025-06-07 20:02:21 -04:00
db36329011 Merge branch 'develop' 2025-06-07 18:11:08 -04:00
69aa80e6c1 [templates] Allow going back and forward in time 2025-06-07 15:17:56 -04:00
99a6e5107b [scrobbles] Add orgparse and start of emacs webhook 2025-06-06 17:25:04 -04:00
39e2fdce27 [music] Fix allmusic lookup 2025-06-06 17:24:41 -04:00
b2f98d780b [middleware] Add auto timezone setting 2025-06-06 09:49:53 -04:00
07b5dc6a2c [templates] Clean up dashboard 2025-06-06 09:49:08 -04:00
7207ca385e [scrobbles] Add two new methods to Scrobble model 2025-06-06 09:48:15 -04:00
2a254e28d0 [templates] Start redesigning the dashboard 2025-05-18 23:20:47 -04:00
d4377e49ac [music] Fix getting album metadata 2025-05-18 23:19:17 -04:00
9dc0a818ff [templates] Fix chart loading 2025-05-18 23:19:05 -04:00
5e672fc9ed [webpages] Add title to webpage reading 2025-05-18 09:37:53 -04:00
6d194d227e Merge branch 'develop' 2025-05-11 01:43:55 -04:00
ed217cbad2 [beers] Blacken and clean up imports 2025-05-11 01:42:30 -04:00
3cca30dc70 [puzzles] Add puzzle lookup via IPDB 2025-05-11 01:41:43 -04:00
d3a15d5e7b Merge branch 'develop' 2025-05-11 00:03:48 -04:00
deeaa0af4b [puzzles] Fix misnamed ipdb field 2025-05-10 23:58:44 -04:00
159459e1b9 [puzzles] Add puzzle model and hooks 2025-05-10 23:48:40 -04:00
89b6d8de06 [importers] Fixes TSV comma missing 2025-05-10 23:48:40 -04:00
3c725de2ac [webpages] Fixes 500 errors when webpage lookup fails 2025-05-10 23:48:40 -04:00
dd71bdd38c [puzzles] Add puzzle model and hooks 2025-05-10 23:36:03 -04:00
d066a98282 [importers] Fixes TSV comma missing 2025-04-15 16:32:14 -04:00
73bc4a1cd1 [webpages] Fixes 500 errors when webpage lookup fails 2025-04-15 16:18:26 -04:00
79d58e6390 Merge branch 'develop' 2025-04-07 17:45:16 -04:00
69e9caf477 [release] Bump version to 0.16.1 2025-04-07 17:43:36 -04:00
776e839ca4 [ci] Fixes wrong public key 2025-04-07 17:42:31 -04:00
97792898df [release] Bump version to 0.16.0 2025-04-07 17:34:00 -04:00
9986163d20 [notifications] Fixes missing title 2025-04-07 17:34:00 -04:00
d1229585a1 [release] Bump version to 0.16.0 2025-04-07 17:31:50 -04:00
b8c5c3f3e9 Merge branch 'main' into develop 2025-04-07 16:57:54 -04:00
6d9e237f9b [notifications] Fixes missing title 2025-04-07 16:53:30 -04:00
e65a2d300d [ci] Fixes reference to wrong ssh key 2025-04-07 14:38:46 -04:00
f45541de6d [deps] Fixes premature bump to py312
Turns out our deployment uses python 3.11, so we need to roll back
the pillow and pendulum upgrades so they work with python 3.11 ... how
irritating to have all this version bump nonesense.
2025-04-07 14:27:28 -04:00
391f0cc335 [tests] Trying to parallel tests 2025-04-07 13:55:42 -04:00
4b5281bdd8 [cleaning] Adding migrations that are past due 2025-04-07 13:47:21 -04:00
484be0a64e [tests] Skip openlibrary lookup tests 2025-04-07 13:45:13 -04:00
257e6899d3 [tests] Clean up podcast tests 2025-04-07 13:40:32 -04:00
4767cc7e52 [podcasts] Fixes enrichment of podcasts with podcastindex 2025-04-07 13:32:03 -04:00
bcc3f46806 [tests] We dont differentiate on albums anymore 2025-04-07 12:39:57 -04:00
6c461ed55f [tests] Simplify view tests to not mess with time 2025-04-07 12:30:54 -04:00
28cf57c6dd [tests] Try fixing CI breaking with small seconds 2025-04-07 09:56:10 -04:00
0bb874f1db [scrobblers] Fixes test with wiggly second 2025-04-07 00:12:29 -04:00
27f50baf5d [music] Fixes missing jellyfin ts converter 2025-04-06 23:39:32 -04:00
499c3d6859 [music] Fix bug in scobblers for tracks 2025-04-06 22:40:33 -04:00
b0e9f13e11 [music] Attempts to fix bad lookups from LastFM and Jellyfin
Broader issue was creating tracks without albums that were duplicates of
existing tracks because sometimes Jellyfin and LastFM do not have albums
sent with them.
2025-04-06 22:28:32 -04:00
b2ee79b3ea [project] Update TODOs file 2025-04-04 11:00:28 -04:00
3ddd3b1684 [deps] Upgrades pillow to work with Python 3.11 2025-04-04 10:23:41 -04:00
4f8a359ab9 [deps] Upgrades pendulum to work with python 3.13 2025-04-02 22:14:24 -04:00
48114aee5e [scrobblers] Fix missing r in regex string 2025-04-02 22:14:11 -04:00
0cc87a2dbe [profiles] Add user profile context override 2025-04-02 22:13:43 -04:00
23d3e19db9 [scrobbles] Fixes typo in transaction import 2025-03-29 13:56:20 -04:00
29f5e2b940 [scrobbles] Add action parsing to scrobble url parsing
This also elaborates on the web scrobbler endpoint, though it does not
actually work at this time. But I'm tired of carrying this around in
stashes, so we'll push it and fix it later.
2025-03-29 13:52:46 -04:00
3208a32ffe [scrobbles] Fixes dedup utility, adding a transaction
Deleting tracks needs to be in a transaction so we don't try to delete a
track that may still have scrobbles associated with it.
2025-03-29 13:49:54 -04:00
99da9b62bf [music] Fixes bug where lastfm created new tracks
The issue here was that if we couldn't find a track from a musicbrainz
ID (which was almost never), we would fall back to just creating a new
track with a blank album. So we'd spam the track table with identical
tracks just no albums.

Now we do a quick check for the track where the title and artist match
and use that if it's found. This will result in some tracks being
associated with the wrong album, but I think that's better than the
current behavior.
2025-03-29 13:47:45 -04:00
36048d9a0a [music] Cleans up last.fm import logging
We weren't tracking import info very well in the original flow, this
should provide better insight into what happened after a run.
2025-03-29 13:44:48 -04:00
16091c9053 [tests] Skips the podcast scrobble tests, no Google Podcast 2025-03-27 16:50:42 -04:00
eec00ce658 [urls] Removes accidentaly committed URL updates
The modern_ui app is not ready yet, still sitting on a dev machine.

These url paths should not have been committed, so we'll just comment
them out for now.
2025-03-27 16:38:34 -04:00
e02010e409 [videos] Fix skip for youtube tests 2025-03-27 16:35:23 -04:00
498712e531 [drone] Update drone to install with test, rather than dev 2025-03-27 16:32:33 -04:00
14e4432495 [poetry] Update pyproject to only install test deps when needed
We'll also need to fast follow this with a new line in the drone
file otherwise CI will fail because pytest wont be installed by default
any longer.
2025-03-27 16:31:44 -04:00
676c40176c [notifications] Add a class for notifications 2025-03-25 09:50:14 -04:00
0a50bca622 [notifications] Add stop notification utility 2025-03-24 13:23:33 -04:00
ac9fc315b1 [books] Fix date lookup and literal string title search 2025-03-19 01:02:39 -04:00
50d1a4a2bd [videos] Fix youtube videos longer than an hour 2025-03-19 00:17:21 -04:00
444562235f [webpages] Handle title truncation better 2025-03-07 14:36:35 -05:00
760575e41d [webpages] Bail if we can't get text 2025-03-07 14:33:02 -05:00
42699f84d2 [tasks] Finish task based on timestamp, not now 2025-03-07 09:02:29 -05:00
b660e47bc2 [videos] Just a little type hinting 2025-02-25 21:09:02 -05:00
06b4ba8bcc [foods] Fix image bug in foods 2025-02-25 21:08:34 -05:00
1f67207f81 [books] Fix two bugs in looking up books 2025-02-25 21:07:45 -05:00
baa8dbee46 [boardgames] Modularize the Lichess imports 2025-02-25 11:09:29 -05:00
3f9cdcac65 [books] Clean up old page model and bug in page calc 2025-02-25 10:34:08 -05:00
71874510a4 [profiles] Actually make the form work 2025-02-25 01:17:12 -05:00
8c600d6b4b [books] Fix no result for detail lookup 2025-02-23 23:08:47 -05:00
e95b6f50dc [tests] Fix views and comment out youtube 2025-02-23 22:49:44 -05:00
93c16d80ec [profiles] Add settings form 2025-02-23 22:49:18 -05:00
b03da9ab37 [middleware] Add health check middleware 2025-02-21 10:40:44 -05:00
09ca05cb4f [make] Move deploys back to homelab 2025-02-20 23:27:47 -05:00
d6e02d241c [settings] Fix test settings 2025-02-20 23:27:31 -05:00
8dd94e2fc4 [books] Fix book admin 2025-02-20 23:27:22 -05:00
9e3f714c61 [books] Add papers as a data model 2025-02-20 23:07:56 -05:00
e2b0decd83 [utils] Add commit option to the dedupper 2025-02-17 17:36:43 -05:00
e08db5e3ad [github] Change when actions run 2025-02-10 09:33:38 -05:00
1460b9ba77 Fix py version for github actions 2025-02-10 09:32:07 -05:00
a41e0ffa5d Create django.yml for Github Actions 2025-02-10 09:30:34 -05:00
c51c75b6a6 [books] Handle authors from Google 2025-02-09 22:44:11 -05:00
041435bc93 [books] Use google and title to get book 2025-02-09 21:58:03 -05:00
15f27b73a5 [videos] Fix error where we add tt to the IMDB id 2025-02-03 00:32:22 -05:00
25900a9911 [boardgames] Send notifications on chess imports 2025-01-29 00:23:46 -05:00
4277e355e0 [boardgames] Few more little tweaks to imports 2025-01-29 00:19:40 -05:00
855f59b83f [boardgames] Fix small bug in black username 2025-01-29 00:08:55 -05:00
9c115c0b65 [boardgames] Add lichess importing 2025-01-29 00:04:49 -05:00
fd726a125f [books] Remove meta_yt references 2025-01-28 22:20:04 -05:00
6685669b29 [videos] Fix missing defaults for no tags 2025-01-27 14:58:47 -05:00
3fa43f02d0 [tasks] Add exercise and lifeevent to constants 2025-01-27 14:52:15 -05:00
66c34942e6 [videos] Youtube using the actual API 2025-01-27 14:51:56 -05:00
ef1fcd4026 [videos] Fix video type reference 2025-01-27 01:37:21 -05:00
a36efa3b1d [videos] Add check if youtube fails 2025-01-27 01:30:41 -05:00
300a2ae6aa [scrobblers] Add ability to stop via bookmarklet 2025-01-27 01:14:20 -05:00
1d5b91b6e2 [videos] Fix splitting youtube IDs 2025-01-27 00:41:03 -05:00
a5c67a7fe1 [videos] Clean up video tools 2025-01-26 23:41:41 -05:00
b16d0b1864 [vrobbler] Update poetry deps 2025-01-26 23:39:18 -05:00
f90a3b84a8 [books] Add google as a source and clean up data model 2025-01-26 23:39:02 -05:00
25a14ed9e7 [videos] Clean imdb getter up and fix series 2025-01-21 23:20:30 -05:00
84790c805c [migrations] Add default value to run time seconds 2025-01-21 23:20:09 -05:00
a9499f0463 [video] Fix youtube redirects 2025-01-21 22:49:15 -05:00
c109ed79eb [video] Update how we get video metadata for YT add 2025-01-20 09:13:18 -05:00
89e5455b29 [deps] Add meta-yt and updates 2024-11-25 13:55:30 -05:00
647762f201 [beers] Fix missing url path 2024-11-24 17:26:42 -05:00
b788cc65d1 [notifications] Fix finish url being on media 2024-11-24 09:59:18 -05:00
50ec82213c [foods] Fix migrations 2024-11-24 09:45:56 -05:00
51c1acd677 [notifications] Add click to finish 2024-11-24 09:39:44 -05:00
fd38046113 [foods] Add Food scrobbling 2024-11-20 14:16:44 -05:00
d294f2ecd1 [koreader] Fix list access bug 2024-11-19 20:46:25 -05:00
3c7940c6c6 [lastfm] Fix importing tracks 2024-11-18 15:12:20 -05:00
56c000154c [scrobbles] Simplify notifications and add to books 2024-11-18 14:40:05 -05:00
8157836b42 [lastfm] Fix issue with no media obj 2024-11-18 14:19:41 -05:00
af0e76b29c [utils] Add crud deduplicator 2024-11-17 22:02:19 -05:00
39004aac0c [make] Restart celery too 2024-11-17 21:01:43 -05:00
8cbd746681 [tasks] Fix import in utils 2024-11-17 21:00:27 -05:00
dee04a47cb [config] No need to load pypi pass everytime 2024-11-17 20:49:14 -05:00
1304a27408 [books] Add webdav koreader importer 2024-11-17 20:49:14 -05:00
2327b1f622 [notifications] Clean up emojis and fix priority 2024-11-08 10:51:44 -05:00
94fed8ae38 [tasks] Add research as a tag 2024-11-08 08:59:17 -05:00
042a26f148 [make] Add migrations to deploy 2024-11-06 14:50:15 -05:00
e606f0de01 [trails] Add trail and default activity type 2024-11-06 14:48:56 -05:00
ed2253cb6b [videos] Clean up admin stuff 2024-11-06 11:07:27 -05:00
9a1508b7a6 [videos] Add channel admin access 2024-11-06 11:03:16 -05:00
39f3a31847 [videos] Update youtubes stuff 2024-11-06 10:50:39 -05:00
f0b32961c1 [scrobbles] Can't just use subtitle for notifications 2024-11-06 09:00:21 -05:00
a08574b359 [videos] Add subtitle to notifications 2024-11-06 00:18:16 -05:00
26ebb4108d [videos] Try to fix auth on webhooks 2024-11-05 23:54:21 -05:00
1b95706f70 [videos] Add youtube scrobbling 2024-11-05 23:32:03 -05:00
b65ebbe397 [books] Fix koreader import with nul strings 2024-11-05 21:13:37 -05:00
0679af3029 [scrobbles] Fix if log does not exist 2024-11-05 18:47:34 -05:00
8888b42adf [scrobbles] Actually use notify str 2024-11-05 16:50:35 -05:00
b8d68739cf [scrobbles] Kidding, we use description 2024-11-05 16:46:14 -05:00
4c2b838d7b [scrobbles] Add details if we have them 2024-11-05 16:43:40 -05:00
cdb3c29844 [music] Fix timestamp setting in lastfm importer 2024-11-05 16:41:12 -05:00
22c07bdb82 [music] Fix lastfm importing 2024-11-05 16:33:28 -05:00
66513c5758 [scrobbles] Memos 2024-11-05 16:28:58 -05:00
f3c0d20268 [scrobbles] Fix ntfy formatting 2024-11-05 16:27:16 -05:00
622a30899f [music] Fix how we create tracks from LastFM 2024-11-05 14:47:30 -05:00
2c1e8c08ae [scrobbles] Add ntfy config to user profiles 2024-11-05 14:38:01 -05:00
cc52e00d15 [videos] Fix utils 2024-11-04 20:40:49 -05:00
e762658082 [deps] Update howlongtobeatpy 2024-11-04 15:30:50 -05:00
5111cee14b [videogames] Fix minor metadata look up bug 2024-11-04 15:30:00 -05:00
68a6d58339 [tsv] Try fixing our lookup when titla and MB id are none 2024-11-04 10:18:52 -05:00
b91c8b27d7 [tsv] Fix lookup to use new dict method 2024-11-04 09:55:38 -05:00
3a91aa5903 [tsv] Fix missing comma error 2024-11-04 09:45:13 -05:00
bfd6331be3 [vidoes] default run time seconds to 1800 2024-11-03 11:31:13 -05:00
38ba474c1f [beers] Fix abv lookup 2024-11-02 18:59:11 -04:00
76272b7e39 [beers] Fix rating error 2024-11-01 22:42:42 -04:00
1f0c950b17 [beers] Fix missing abv error 2024-11-01 22:39:46 -04:00
f2998205e1 [tasks] Remove data class until we fix it 2024-11-01 16:03:20 -04:00
38e108c1ae [tasks] Trying to fix logdata 2024-11-01 15:58:16 -04:00
dec100f8ff [make] Add basic makefile 2024-11-01 15:55:21 -04:00
e89eb332d3 [task] Try to fix logdata for tasks 2024-11-01 15:55:07 -04:00
cb5b279300 [templates] Fix missing logdata model 2024-11-01 15:42:47 -04:00
59d681dc00 [task] Ooops, add task id 2024-11-01 13:10:47 -04:00
82dcad569a [task] Log when we don't find a task for notes 2024-11-01 12:48:59 -04:00
d8aaf3bf55 [task] Little bug 2024-11-01 12:43:18 -04:00
29b92d89b2 [task] Try to fix scrobbling 2024-11-01 12:35:11 -04:00
86bcdef13d [task] Fix small bug 2024-11-01 12:12:30 -04:00
20e6ae7421 [tasks] Update checking for inprogress changed 2024-11-01 12:08:06 -04:00
4ed5117900 [tasks] Stop ignoring notes 2024-11-01 11:16:24 -04:00
3388471685 [tasks] Don't stop tasks when modified while in progress 2024-10-31 15:28:06 -04:00
58a957c98a [tasks] Add ability to save comments as notes on tasks 2024-10-25 15:10:35 -04:00
2ad626cd59 [beers] Add subtitles 2024-10-23 12:59:18 -04:00
6ce0257dc0 [beers] Cleaning up 2024-10-23 12:57:45 -04:00
43dbd3b28b [beers] Fix description tag typo 2024-10-23 12:55:37 -04:00
7671644e87 [beers] Allow scrobbling from URLS 2024-10-23 12:48:55 -04:00
f555a49746 [beers] Fix untappd and beeradvocate urls 2024-10-23 10:58:07 -04:00
9fe474978a [beers] Add manual scrobbling from URLs 2024-10-23 10:51:35 -04:00
9f8465d364 [beers] Fix a few more display stuffs 2024-10-22 17:58:39 -04:00
ddfddc33f5 [beers] Actually give producers names 2024-10-22 17:52:18 -04:00
0ec7ed3a18 [beers] Finish out the model 2024-10-22 17:47:38 -04:00
0bda3f6fd8 [beers] Fix error in cover reference, add adminf or beer producers 2024-10-22 17:44:46 -04:00
59765b14ca [beers] Connect beers with producers 2024-10-22 17:34:20 -04:00
08b48371bc [beers] Add beer scrobbling 2024-10-22 17:26:55 -04:00
d3d5b088cd [webdav] Add basic client 2024-10-20 16:22:56 -04:00
083e931a78 [webdav] Add crednetials to user profile 2024-10-20 16:06:24 -04:00
0f0fb7cceb [scrobbles] Add lookup of last serial scrobble 2024-10-20 14:35:42 -04:00
f2bbb7f5d0 [scrobblers] Use udpated_at key for Todoist scrobbles 2024-10-20 14:29:17 -04:00
218c68dee0 [books] Fix importing scrobbles from existing books 2024-10-18 14:50:46 -04:00
202cf24722 [books] Clean up koreader imports and data storage 2024-10-18 12:49:59 -04:00
1ddacd4454 [scrobbles] Improve webhook logging 2024-10-17 22:51:10 -04:00
2dbb091d61 [books] Fix importing to not use OL by default for now 2024-10-17 16:40:07 -04:00
dbaf189628 [scrobbles] Fix scrobbling duplicate tasks 2024-10-15 21:29:50 -04:00
4a0bac5b87 [scrobbles] Fix double scrobbling tasks 2024-10-15 19:34:27 -04:00
59b7e3dada [scrobbles] Add description to current task 2024-10-15 15:29:31 -04:00
24223ebe13 [scrobbles] Add working to status page 2024-10-15 15:27:48 -04:00
de6e9ce2d6 [tasks] We only care about in-progress for scrobbling 2024-10-15 15:23:15 -04:00
470eb0778a [tasks] Get last task, not first 2024-10-15 15:14:37 -04:00
f075492554 [tasks] Add Bug as suffix type 2024-10-15 15:12:58 -04:00
8fb2fac47f [tasks] Remove in-progress tag to stop 2024-10-15 15:11:03 -04:00
b1eac1454b [tasks] Don't create new scrobbles for in progress tasks 2024-10-15 15:07:43 -04:00
84737e0c3b [tasks] Add habit to suffix type 2024-10-15 15:02:01 -04:00
c4359a2331 [tasks] Fix catch for no scrobble available 2024-10-15 14:58:34 -04:00
8fa538dbee [tasks] Fix use of source_id 2024-10-15 14:56:26 -04:00
26d82518fa [tasks] Temporarily don't use updated_at 2024-10-15 14:52:58 -04:00
04a7ba51e4 [tasks] Add user id to Profile 2024-10-15 14:49:43 -04:00
ccf14c51bf [tasks] Fix webhook creating duplicates 2024-10-15 14:41:40 -04:00
b97aa8936e [scrobbles] Fix if past seconds is zero 2024-10-15 14:40:05 -04:00
98924e362e [tasks] Fix loading todoist data from webhook data 2024-10-15 14:37:15 -04:00
0384f72cbd [tasks] Fix webhook for Todoist 2024-10-15 14:25:15 -04:00
0c8a486b6a [tasks] Implement todoist webhooks #8479861260 2024-10-15 14:21:30 -04:00
7954765b73 [tasks] Add oauth flow for Todoist 2024-10-14 22:15:29 -04:00
7604327ca9 [oauth] Adding oauth pattern 2024-10-14 18:13:27 -04:00
20542ac7e9 [scrobbles] Fix tasks overwriting tasks 2024-10-11 18:34:13 -04:00
34137af815 [videos] Fix series lookup from IDMB too 2024-10-09 21:08:04 -04:00
0f2570e51b [videos] Fix imdb dict lookups 2024-10-09 20:31:43 -04:00
ed917e16fc [scrobbles] Fix when scrobble has not dict 2024-10-07 16:50:54 -04:00
164510b7b7 [tasks] Add media url link 2024-10-05 17:59:32 -04:00
6764023016 [tasks] Fix capitalization call 2024-10-05 16:07:19 -04:00
7c6c1cee6d [tasks] Fix scrobbling tasks 2024-10-05 15:49:22 -04:00
c251c5f413 [tasks] Allow scrobbling tasks from URLs 2024-10-05 15:08:38 -04:00
342e86d7fb [videos] Don't lookup video when we know what it is 2024-10-05 14:33:25 -04:00
176b698f6e [templates] Clean up tables 2024-10-02 17:51:02 -04:00
ddf2ca5630 [scrobbling] Actually fix typo in mopidy uri 2024-10-02 17:51:00 -04:00
d52061f6d8 [scrobbling] Actually fix typo in mopidy uri 2024-10-02 15:40:16 -04:00
3c0a75755b [tasks] Fix source url generation 2024-09-30 17:10:28 -04:00
183469ebe5 [tasks] Add tasks app 2024-09-30 15:05:07 -04:00
bbe8149e6c [scrobbles] Fix scrobbling from IMDB urls 2024-09-26 22:12:58 -04:00
f876caabe1 [scrobblers] Add mopidy source to scrobbler 2024-09-26 22:07:01 -04:00
87c078f47d [scrobbles] Allow scrobbling any content via URLs 2024-09-26 21:58:40 -04:00
5a9292e10a [videos] Fix looking up on IMDB 2024-09-26 21:58:39 -04:00
a5630022f5 [status] Add pocast playing to listening status 2024-09-16 12:42:39 -04:00
babc2aeb9d [boardgames] Fix missing default None for serial scrobble 2024-09-14 09:34:07 -04:00
875b0f98a0 [scrobblers] Fix manual vidoe lookup and simplify Sports 2024-09-12 20:16:05 -04:00
5d1edc71d7 [scrobbles] Add gpx file for trails 2024-09-11 13:31:48 -04:00
e4738e464f [trails] Finish hooking things up for trails 2024-09-09 17:20:10 -04:00
85c4963619 [templates] Fix trail template loading 2024-09-09 17:07:13 -04:00
8d6707db95 [templates] Clean up redundent templates and fix main menu 2024-09-09 16:58:33 -04:00
0df3dd728d [release] Bump to version 0.15.4 2024-09-09 13:05:17 -04:00
1fe8d8aa51 [boardgames] Add better list template and fix URLs (also for moods) 2024-09-09 12:56:42 -04:00
8f3c7beffa [trails] Make trail admin raw ID field 2024-09-09 12:12:39 -04:00
aac0efbb14 [misc] Cleaning up imports and urls for webpages 2024-09-09 12:12:08 -04:00
b0d4dd0899 [trails] Add new trails app 2024-09-09 12:11:39 -04:00
16db67ea84 [bgg] Add rudimentary bgg push scrobble script 2024-09-09 11:56:42 -04:00
7a747268a1 [lifevents] Remove redundant completion percentage 2024-09-09 11:56:20 -04:00
2037ffc67a [deps] Add webdav client library and fix boto3 2024-09-09 11:55:58 -04:00
2136c1562a [books] Fix calculation of current page 2024-09-08 14:13:40 -04:00
eae169aff7 [version] Bump to version 0.15.3 2024-09-08 00:53:19 -04:00
6c0bd2e409 [scrobbles] Clean up references to book_page_data 2024-09-08 00:52:50 -04:00
219 changed files with 14846 additions and 3453 deletions

View File

@ -14,9 +14,9 @@ steps:
# Install dependencies
- cp vrobbler.conf.test vrobbler.conf
- pip install poetry
- poetry install --with dev
- poetry install --with test
# Start with a fresh database (which is already running as a service from Drone)
- poetry run pytest --cov-report term:skip-covered --cov=vrobbler tests
- poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
environment:
VROBBLER_DATABASE_URL: sqlite:///test.db
volumes:
@ -39,8 +39,8 @@ steps:
- vrobbler collectstatic --noinput
- immortalctl restart celery && immortalctl restart vrobbler
when:
branch:
- main
ref:
- refs/tags/*
- name: build success notification
image: parrazam/drone-ntfy:0.3-linux-amd64
when:

31
.github/workflows/django.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Django CI
on:
push:
branches: [ "develop" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.9, 3.11, 3.12]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
python -m pip install poetry
poetry install
- name: Run Tests
run: |
pytest

6
Makefile Normal file
View File

@ -0,0 +1,6 @@
deploy:
ssh vrobbler.service "rm -rf /usr/local/lib/python3.11/site-packages/vrobbler-0.15.4.dist-info/ && pip install git+https://code.unbl.ink/secstate/vrobbler.git@develop && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
logs:
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
test:
pytest vrobbler

View File

@ -1,160 +1,64 @@
#+title: TODOs
A fun way to keep track of things in the project to fix or improve.
* Backlog [0/17]
** TODO [#A] Tasks from org-mode should properlly update notes and leave them out of the body :vrobbler:bug:tasks:
** TODO [#A] Fix small bug in views for TV series where next episode is now None :vrobbler:bug:personal:videos:
* Version 1.0.0
** TODO What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
** TODO Add a user profile page with ability to change settings :profiles:improvement:
** DONE [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
CLOSED: [2023-04-06 Thu 14:09]
** DONE Add a "stop_timestamp" so we don't rely on content length :improvement:scrobbling:
CLOSED: [2023-04-02 Sun 23:58]
Essentially, we currently have the timestamp as when the content began
scrobbling and then calculate the finish time from the length of the content.
This works pretty well because we know how long most things are.
But in some cases, sports events or long podcasts, we may start mid-way through
an event or finish halfway through but still want to mark it as done. In these
cases, knowing the finish time could be useful, especially when interfacing with
other scrobblers which may have different definitions of when a scrobble
finishes or started.
** DONE Fix bug with Various Artist albums being labeled with first artist as album artist :scrobbling:bug:music:
CLOSED: [2023-03-27 Mon 20:18]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 22:01]--[2023-03-27 Mon 01:07] => 3:06
:END:
** DONE Fix bug with weekly aggregator being blank on Sundays :aggregators:music:bug:
CLOSED: [2023-03-26 Sun 13:52]
** DONE Fix KoReader scrobbling to use pages rather than time of last read :scrobbling:books:improvement:
CLOSED: [2023-03-26 Sun 13:51]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 13:11]--[2023-03-26 Sun 13:51] => 0:40
:END:
** DONE [#A] Add django-storage to store files on S3 :settings:improvement:
CLOSED: [2023-03-24 Fri 14:46]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:47]--[2023-03-24 Fri 14:46] => 3:59
CLOCK: [2023-03-24 Fri 10:36]--[2023-03-24 Fri 10:40] => 0:04
:END:
** DONE Fix vrobbler settings not using booleans :settings:bug:
CLOSED: [2023-03-24 Fri 10:45]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:40]--[2023-03-24 Fri 10:46] => 0:06
:END:
** DONE Update weekly live chart to be 7-day continuous rather than weekly :views:bug:
CLOSED: [2023-03-24 Fri 00:31]
The live view will be blank every Monday, no reason to tie it to a day of the
week. It should be "the last 7 days"
** DONE [#B] Implement a detail view for TV shows :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE [#B] Implement a detail view for Movies :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE Add "service provider" to TV Series, and use that for source when available :bug:scrobbling:
CLOSED: [2023-03-22 Wed 17:04]
** DONE Add view for long-play content (books, video games) to restart them :views:improvement:
CLOSED: [2023-03-22 Wed 17:01]
** DONE Add live chart view like Maloja :improvement:views:
CLOSED: [2023-03-07 Tue 11:13]
** DONE [#C] Figure out how to add to web-scrobbler :improvement:scrobbling:
CLOSED: [2023-03-22 Wed 17:06]
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
This is actually going to be moot because we can import from LastFM, and
web-scrobbler integrates well with LastFM. The only thing to think through here
now is what to do with all the garbage web-scrobbler sometimes pushes to LastFM
from Youtube (all videos get pushed, sigh).
* Version 0.11.4
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
CLOSED: [2023-03-07 Tue 11:11]
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:
CLOSED: [2023-03-07 Tue 11:11]
** DONE [#A] Fix fetching artwork without release group :bug:
CLOSED: [2023-01-29 Sun 14:27]
When we get artwork from Musicbrianz, and it's not found, we should check for
release groups as well. This will stop issues with missing artwork because of
obscure MB release matches.
** DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
CLOSED: [2023-01-30 Mon 18:31]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
:END:
If we play music from Jellyfin and the track reaches 90% completion, the
scrobbling goes crazy and starts creating new scrobbles with every update.
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
as complete for the following conditions:
- Play stopped and percent played beyond 90%
- Play completely finished
But if we keep listening beyond 90, we should basically ignore updates (or just
update the existing scrobble)
** DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
CLOSED: [2023-02-03 Fri 16:52]
An example of the format:
#+begin_src csv
,
#AUDIOSCROBBLER/1.1
#TZ/UNKNOWN
#CLIENT/Rockbox sansaclipplus $Revision$
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Jackolanterns Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
,
#+begin_src python
ERROR django.request:241 log_response Internal Server Error: /series/c24100d1-da45-4abe-86bf-27cfce9b1f89/
Traceback (most recent call last):
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 104, in view
return self.dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/contrib/auth/mixins.py", line 73, in dispatch
return super().dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 143, in dispatch
return handler(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/detail.py", line 109, in get
context = self.get_context_data(object=self.object)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/views.py", line 33, in get_context_data
context_data["next_episode_id"] = "tt" + next_episode_id
~~~~~^~~~~~~~~~~~~~~~~
TypeError: can only concatenate str (not "NoneType") to str
#+end_src
** DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
CLOSED: [2023-02-17 Fri 00:10]
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
** DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
CLOSED: [2023-02-17 Fri 00:11]
** DONE [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
CLOSED: [2023-03-07 Tue 11:09]
** TODO [#A] Send periodic check notifications for board games and beer that ask if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
The page data has the canonical date something was read in it, but it seems to be an hour off. I traced this back to being off during DST, so we just need the importer to be aware of whether a user is using DST or not and roll back an hour for part of the year.
Given a UUID from musicbrainz, we should be able to scrobble an album or
individual track.
** DONE [#C] Implement keeping track of week/month/year chart-toppers :improvement:
CLOSED: [2023-03-07 Tue 11:10]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
:END:
Maloja does this cool thing where artists and tracks get recorded as the top
track of a given week, month or year. They get gold, silver or bronze stars for
their place in the time period.
I could see this being implemented as a separate Chart table which gets
populated at the end of a time period and has a start and end date that defines
a period, along with a one, two, three instance.
Of course, it could also be a data model without a table, where it runs some fun
calculations, stores it's values in Redis as a long-term lookup table and just
has to re-populate when the server restarts.
* Backlog
** TODO Add Amazon scraper to look up books when OL fails :books:improvement:
Also, we'd need to adjust any old scrobbles that took place with DST off to roll them back by an hour.
** TODO [#A] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
Pretty clear, I would love to make trails more useful. Historically I wasn't
hiking a lot, which made the source for this a bit silly. But it's clear that
AllTrails is the best source, though having TrailForks is nice to.
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
Would be nice to have some loose connection to the actual event in my Garmin profile.
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
Could be as simple as a JSON form on the scrobble detail page (do I have have one of those yet?).
** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike:
** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal:
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
** TODO [#C] Fix bug where podcast scrobbling creates duplicate Podcast :project:vrobbler:scrobbling:podcasts:bug:personal:
Rather than pick up an existing Podcast using the podcast title in the mopidy
file name, Vrobbler creates a new podcast with no enriched data. Not a big deal
for my use as the volume of podcasts I listen to makes manual fixes easy. But
it's annoying.
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES:
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
:END:
*** Example payloads from mopidy-webhooks
**** Podcast playback ended
#+begin_src json
@ -445,5 +349,265 @@ has to re-populate when the server restarts.
}
}
#+end_src
** TODO Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
** TODO Fix bug in podcast scrobbling where a second scrobble is created after completion :scrobbling:podcasts:bug:
** TODO [#C] User should be able to enable auto trail tracking via amail reader with Garmin LiveTrack URLs :vrobbler:trails:project:feature:personal:
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
https://codepen.io/oliviale/pen/QYqybo
** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
* Version 17.1 [1/1]
** DONE [#B] Fix task app to only use one tag for the context a task was done in and allow configurable contexts by user profile :personal:vrobbler:feature:tasks:project:
:PROPERTIES:
:ID: 1ec89c57-0bb8-3401-33bd-ba65127ed36b
:END:
* Version 17.0 [6/6]
** DONE [#A] Fix bug in new task label lookup for Emacs/Org-mode :vrobbler:bug:tasks:
:PROPERTIES:
:ID: 683fb109-dfc4-85e4-80f0-ea618434f61e
:END:
** DONE [#C] Replace commas in the bandcamp URL for artists with nothing :vrobbler:music:bug:personal:
:PROPERTIES:
:ID: 9b30d67b-91f0-a480-dfaa-5d9dc090e76c
:END:
- Note taken on [2025-06-16 Mon 09:36]
This firt appeared with Black Country, New Road, where the RYM slug generator
leaves commas in and ends up sending you to a 404. I suspect this wont be the
first tweak we'll need to this, as the RYM link creator is really just
guessing based on the artist name at the path.
** DONE [#A] Investigate new source of video metadata :personal:project:video:imdb:
:PROPERTIES:
:ID: df2b486c-1170-5199-c312-9bc87760d962
:END:
Cinemagoer broke and I probably should find a more reilable source of video data.
- Note taken on [2025-06-13 Fri 11:19]
TMDB is much more reliable, but does require an API key. That's all setup now,
so hopefully this breaking IMDB crap is over.
** DONE [#A] IMDB video lookups are failing :personal:bug:video:imdb:
:PROPERTIES:
:ID: 38f1081f-37b4-f4f2-79aa-c1e87eca4b69
:END:
<2025-06-13 Fri>
- Note taken on [2025-06-13 Fri 08:24]
Looks like Cinemagoer is broken: https://github.com/cinemagoer/cinemagoer/issues/537
** DONE [#A] Emacs is not syncing notes :personal:scrobbling:emacs:bug:
:PROPERTIES:
:ID: c79cd491-b30f-0945-d84b-b8cac7562791
:END:
<2025-06-12 Thu 9:30>
Not sure if the problem is in my Emacs hook sending or Vrobbler itself.
- Note taken on [2025-06-12 Thu 09:47]
Adding a quick note to check on it
- Note taken on [2025-06-12 Thu 09:50]
Ah ha. All the messing about with the source field meant that I was looking
for `emacs` as a source but the hook was initially setting sources to
`orgmode` I think I prefer `orgmode` as the source, so updating it thusly.
Fixed in `490d60cbbb1f8bf90b5fc47d8685b15bdc1d485b`
** DONE [#A] Show the description of a task in the string rep for a scrobble of a Task :personal:project:scrobbling:vrobbler:feature:
:PROPERTIES:
:ID: df58f8d0-fa4a-2037-c7d7-e5388c239042
:END:
* Version 0.16.0
** DONE [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
:PROPERTIES:
:ID: 670e8634-49b5-dce9-1684-14f2ffb797f1
:END:
Effectively, any track that comes in without a MusicBrainz ID does some funky
lookup where it doesn't find a track without an MB id and the track title /
artist combination and creates a new track every time. This has to be cleaned up
by condensing the duplicated tracks into the original proper track.
But it opens a bigger question about how much MB id should the drive the app
lookup. If it can't be depended on to exist from all sources, it really can't be
canonical. Instead, the combination of track title / artist is really the best
we can do. Last.fm also has this problem, where it doesn't know about albums and
definitely does not know or care about MB ids.
** DONE Add a user profile page with ability to change settings :profiles:improvement:
- Note taken on [2025-04-04 Fri 10:51]
[[orgit-rev:~/src/code.unbl.ink/secstate/vrobbler/::93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44][~/src/code.unbl.ink/secstate/vrobbler/ (magit-rev 93c16d8)]]
https://code.unbl.ink/secstate/vrobbler/commit/93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44
** DONE What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
- Note taken on [2025-04-04 Fri 10:46]
Nothing. Over the last few months I built out a youtube model in videos and
use a bookmarklet scrobbling pattern. Now web-scrobbler is just disabled for
Youtube.
May want to revisit this at some point and only scrobble tracks from Youtube,
because many people use YT for music listening.
** DONE [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
CLOSED: [2023-04-06 Thu 14:09]
** DONE Add a "stop_timestamp" so we don't rely on content length :improvement:scrobbling:
CLOSED: [2023-04-02 Sun 23:58]
Essentially, we currently have the timestamp as when the content began
scrobbling and then calculate the finish time from the length of the content.
This works pretty well because we know how long most things are.
But in some cases, sports events or long podcasts, we may start mid-way through
an event or finish halfway through but still want to mark it as done. In these
cases, knowing the finish time could be useful, especially when interfacing with
other scrobblers which may have different definitions of when a scrobble
finishes or started.
** DONE Fix bug with Various Artist albums being labeled with first artist as album artist :scrobbling:bug:music:
CLOSED: [2023-03-27 Mon 20:18]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 22:01]--[2023-03-27 Mon 01:07] => 3:06
:END:
** DONE Fix bug with weekly aggregator being blank on Sundays :aggregators:music:bug:
CLOSED: [2023-03-26 Sun 13:52]
** DONE Fix KoReader scrobbling to use pages rather than time of last read :scrobbling:books:improvement:
CLOSED: [2023-03-26 Sun 13:51]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 13:11]--[2023-03-26 Sun 13:51] => 0:40
:END:
** DONE [#A] Add django-storage to store files on S3 :settings:improvement:
CLOSED: [2023-03-24 Fri 14:46]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:47]--[2023-03-24 Fri 14:46] => 3:59
CLOCK: [2023-03-24 Fri 10:36]--[2023-03-24 Fri 10:40] => 0:04
:END:
** DONE Fix vrobbler settings not using booleans :settings:bug:
CLOSED: [2023-03-24 Fri 10:45]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:40]--[2023-03-24 Fri 10:46] => 0:06
:END:
** DONE Update weekly live chart to be 7-day continuous rather than weekly :views:bug:
CLOSED: [2023-03-24 Fri 00:31]
The live view will be blank every Monday, no reason to tie it to a day of the
week. It should be "the last 7 days"
** DONE [#B] Implement a detail view for TV shows :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE [#B] Implement a detail view for Movies :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE Add "service provider" to TV Series, and use that for source when available :bug:scrobbling:
CLOSED: [2023-03-22 Wed 17:04]
** DONE Add view for long-play content (books, video games) to restart them :views:improvement:
CLOSED: [2023-03-22 Wed 17:01]
** DONE Add live chart view like Maloja :improvement:views:
CLOSED: [2023-03-07 Tue 11:13]
** DONE [#C] Figure out how to add to web-scrobbler :improvement:scrobbling:
CLOSED: [2023-03-22 Wed 17:06]
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
This is actually going to be moot because we can import from LastFM, and
web-scrobbler integrates well with LastFM. The only thing to think through here
now is what to do with all the garbage web-scrobbler sometimes pushes to LastFM
from Youtube (all videos get pushed, sigh).
** DONE Add Amazon scraper to look up books when OL fails :books:improvement:
This turned out to be a non-starter ... Amazon is aggressive at disallowing
scraping quality. And all the OSS tools out there are stuck in an arms race
trying to keep them from breaking.
That said, Google Books actually has a decent API (for now), and I've built this
out using that.
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
This was fixed a while ago, but there's a new manifested bug. Going to create a
separate bug tracking ticket for that.
* Version 0.11.4
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
CLOSED: [2023-03-07 Tue 11:11]
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:
CLOSED: [2023-03-07 Tue 11:11]
** DONE [#A] Fix fetching artwork without release group :bug:
CLOSED: [2023-01-29 Sun 14:27]
When we get artwork from Musicbrianz, and it's not found, we should check for
release groups as well. This will stop issues with missing artwork because of
obscure MB release matches.
** DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
CLOSED: [2023-01-30 Mon 18:31]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
:END:
If we play music from Jellyfin and the track reaches 90% completion, the
scrobbling goes crazy and starts creating new scrobbles with every update.
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
as complete for the following conditions:
- Play stopped and percent played beyond 90%
- Play completely finished
But if we keep listening beyond 90, we should basically ignore updates (or just
update the existing scrobble)
** DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
CLOSED: [2023-02-03 Fri 16:52]
An example of the format:
#+begin_src csv
,
#AUDIOSCROBBLER/1.1
#TZ/UNKNOWN
#CLIENT/Rockbox sansaclipplus $Revision$
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Jackolanterns Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
,
#+end_src
** DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
CLOSED: [2023-02-17 Fri 00:10]
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
** DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
CLOSED: [2023-02-17 Fri 00:11]
** DONE [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
CLOSED: [2023-03-07 Tue 11:09]
Given a UUID from musicbrainz, we should be able to scrobble an album or
individual track.
** DONE [#C] Implement keeping track of week/month/year chart-toppers :improvement:
CLOSED: [2023-03-07 Tue 11:10]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
:END:
Maloja does this cool thing where artists and tracks get recorded as the top
track of a given week, month or year. They get gold, silver or bronze stars for
their place in the time period.
I could see this being implemented as a separate Chart table which gets
populated at the end of a time period and has a start and end date that defines
a period, along with a one, two, three instance.
Of course, it could also be a data model without a table, where it runs some fun
calculations, stores it's values in Redis as a long-term lookup table and just
has to re-populate when the server restarts.

View File

@ -1,4 +1,4 @@
export ENV_PATH=$(poetry env info --path)
source "${ENV_PATH}/bin/activate"
export PYPI_PASSWORD="$(pass personal/apikey/pypi)"
#export PYPI_PASSWORD="$(pass personal/apikey/pypi)"

4724
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,17 +1,18 @@
[tool.poetry]
name = "vrobbler"
version = "0.15.2"
version = "0.16.1"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
[tool.poetry.dependencies]
python = ">=3.9,<4.0"
python = ">=3.9,<3.12"
Django = "^4.0.3"
django-extensions = "^3.1.5"
python-dateutil = "^2.8.2"
python-dotenv = "^0.20.0"
python-json-logger = "^2.0.2"
colorlog = "^6.6.0"
httpx = "<=0.27.2"
djangorestframework = "^3.13.1"
Markdown = "^3.3.6"
django-filter = "^21.1"
@ -38,7 +39,6 @@ honcho = "^1.1.0"
howlongtobeatpy = "^1.0.5"
beautifulsoup4 = "^4.11.2"
django-storages = "^1.13.2"
boto3 = "^1.26.98"
stream-sqlite = "^0.0.41"
ipython = "^8.14.0"
pendulum = "^2.1.2"
@ -46,11 +46,21 @@ trafilatura = "^1.6.3"
django-imagekit = "^5.0.0"
thefuzz = "^0.22.1"
dataclass-wizard = "0.22.0"
webdavclient3 = "^3.14.6"
boto3 = "^1.35.37"
urllib3 = "<2"
django-oauth-toolkit = "^3.0.1"
meta-yt = "^0.1.9"
berserk = "^0.13.2"
poetry-bumpversion = "^0.3.3"
orgparse = "^0.4.20250520"
tmdbv3api = "^1.9.0"
themoviedb = "^1.0.2"
[tool.poetry.group.dev]
[tool.poetry.group.test]
optional = true
[tool.poetry.group.dev.dependencies]
[tool.poetry.group.test.dependencies]
Werkzeug = "2.0.3"
black = "^22.3"
coverage = "^7.0.5"
@ -59,6 +69,7 @@ pytest = "^7.1"
pytest-black = "^0.3.12"
pytest-cov = "^3.0"
pytest-django = "^4.5.2"
pytest-xdist= "^1.0.0"
pytest-flake8 = "^1.1"
pytest-isort = "^3.0"
pytest-runner = "^6.0"

View File

@ -4,8 +4,9 @@ import pytest
from django.contrib.auth import get_user_model
from rest_framework.authtoken.models import Token
from scrobbles.models import Scrobble
from boardgames.models import BoardGame
from music.models import Track, Artist
from scrobbles.models import Scrobble
User = get_user_model()
@ -27,6 +28,15 @@ def boardgame_scrobble():
)
@pytest.fixture
def test_track():
Track.objects.create(
title="Emotion",
artist=Artist.objects.create(name="Carly Rae Jepsen"),
run_time_seconds=60,
)
class MopidyRequest:
name = "Same in the End"
artist = "Sublime"
@ -98,9 +108,11 @@ def mopidy_track_diff_album_request_data(**kwargs):
@pytest.fixture
def mopidy_podcast():
def mopidy_podcast_request_data():
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
return MopidyRequest(mopidy_uri=mopidy_uri)
return MopidyRequest(
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json
class JellyfinTrackRequest:

View File

@ -30,55 +30,25 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
)
@pytest.mark.parametrize(
"seconds, expected_percent_played, expected_scrobble_id",
[
(1, 1, 1),
(58, 96, 1),
(59, 98, 1),
(60, 100, 1),
(1, 1, 2),
(1, 1, 3),
],
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
"music.utils.lookup_album_dict_from_mb",
return_value={"year": "1999", "mb_group_id": 1},
)
@pytest.mark.django_db
def test_scrobble_mopidy_track(
client,
mopidy_track,
valid_auth_token,
seconds,
expected_percent_played,
expected_scrobble_id,
):
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
# Start new scrobble
minutes = 0
calc_seconds = seconds
if seconds >= 60:
minutes = 1
calc_seconds = calc_seconds % 10
with time_machine.travel(datetime(2024, 1, 14, 12, minutes, calc_seconds)):
mopidy_track.request_data["playback_time_ticks"] = seconds * 1000
response = client.post(
url,
mopidy_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": expected_scrobble_id}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.percent_played == expected_percent_played
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Same in the End"
@pytest.mark.skip(reason="Allmusic API is unstable")
@pytest.mark.django_db
@patch("music.utils.lookup_track_from_mb", return_value={})
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
@patch("music.models.Album.fetch_artwork", return_value=None)
@patch("music.models.Album.scrape_allmusic", return_value=None)
def test_scrobble_mopidy_same_track_different_album(
mock_lookup_artist,
mock_lookup_album,
mock_lookup_track,
mock_lookup_artist_tadb,
mock_lookup_album_tadb,
mock_fetch_artwork,
mock_scrape_allmusic,
client,
mopidy_track,
mopidy_track_diff_album_request_data,
@ -107,13 +77,17 @@ def test_scrobble_mopidy_same_track_different_album(
assert response.data == {"scrobble_id": 2}
scrobble = Scrobble.objects.last()
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.album.name == "Gold"
assert scrobble.media_obj.album.name == "Sublime"
assert scrobble.media_obj.title == "Same in the End"
@pytest.mark.django_db
@patch(
"podcasts.sources.podcastindex.lookup_podcast_from_podcastindex",
return_value={},
)
def test_scrobble_mopidy_podcast(
client, mopidy_podcast_request_data, valid_auth_token
mock_lookup_podcast, client, mopidy_podcast_request_data, valid_auth_token
):
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
@ -174,38 +148,101 @@ def test_scrobble_jellyfin_track(
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
with time_machine.travel(datetime(2024, 1, 14, 12, 0, 58)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
"music.utils.lookup_album_dict_from_mb",
return_value={"year": "1999", "mb_group_id": 1},
)
@patch("music.utils.lookup_track_from_mb", return_value={})
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
@patch("music.models.Album.fetch_artwork", return_value=None)
@patch("music.models.Album.scrape_allmusic", return_value=None)
def test_scrobble_jellyfin_track_update(
mock_lookup_artist,
mock_lookup_album,
mock_lookup_track,
mock_lookup_artist_tadb,
mock_lookup_album_tadb,
mock_fetch_artwork,
mock_scrape_allmusic,
test_track,
client,
jellyfin_track,
valid_auth_token,
):
Scrobble.objects.create(
timestamp=timezone.now() - timedelta(minutes=0.5),
track=Track.objects.first(),
user_id=1,
)
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
with time_machine.travel(datetime(2024, 1, 14, 12, 1, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
assert response.status_code == 200
assert response.data == {"scrobble_id": 2}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
"music.utils.lookup_album_dict_from_mb",
return_value={"year": "1999", "mb_group_id": 1},
)
@patch("music.utils.lookup_track_from_mb", return_value={})
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
@patch("music.models.Album.fetch_artwork", return_value=None)
@patch("music.models.Album.scrape_allmusic", return_value=None)
def test_scrobble_jellyfin_track_create_new(
mock_lookup_artist,
mock_lookup_album,
mock_lookup_track,
mock_lookup_artist_tadb,
mock_lookup_album_tadb,
mock_fetch_artwork,
mock_scrape_allmusic,
test_track,
client,
jellyfin_track,
valid_auth_token,
):
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
Scrobble.objects.create(
timestamp=timezone.now() - timedelta(minutes=1),
track=Track.objects.first(),
user_id=1,
)
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 2}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"

View File

@ -1,11 +1,6 @@
import pytest
from videos.imdb import lookup_video_from_imdb
from videos.sources.imdb import lookup_video_from_imdb
@pytest.mark.skip(reason="Need to sort out third party API testing")
def test_lookup_imdb_bad_id(caplog):
data = lookup_video_from_imdb("3409324")
assert data is None
assert caplog.records[0].levelname == "WARNING"
assert caplog.records[0].msg == "IMDB ID should begin with 'tt' 3409324"
def test_lookup_imdb():
metadata = lookup_video_from_imdb("8946378")
assert metadata.title == "Knives Out"

View File

@ -0,0 +1,9 @@
import pytest
from videos.sources.youtube import lookup_video_from_youtube
@pytest.mark.skip(reason="Need to configure Youtube API stuffs in CI")
@pytest.mark.django_db
def test_lookup_youtube_id():
metadata = lookup_video_from_youtube("RZxs9pAv99Y")
assert metadata.title == "No Pun Included's Board Game of the Year 2024"

View File

@ -21,6 +21,10 @@ VROBBLER_THEAUDIODB_API_KEY="<key>"
VROBBLER_IGDB_CLIENT_ID="<id>"
VROBBLER_IGDB_CLIENT_SECRET="<key>"
VROBBLER_COMICVINE_API_KEY="<key>"
VROBBLER_TODOIST_CLIENT_ID="<id>"
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
VROBBLER_GOOGLE_API_KEY="<key>"
VROBBLER_LICHESS_API_KEY = "<key>"
# Storages
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"

View File

@ -0,0 +1,34 @@
from beers.models import Beer, BeerProducer, BeerStyle
from django.contrib import admin
from scrobbles.admin import ScrobbleInline
class BeerInline(admin.TabularInline):
model = Beer
extra = 0
@admin.register(BeerStyle)
class BeerStyle(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(BeerProducer)
class BeerProducer(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(Beer)
class BeerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BeersConfig(AppConfig):
name = "beers"

View File

@ -0,0 +1,133 @@
# Generated by Django 4.2.16 on 2024-10-22 21:26
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0065_alter_scrobble_log"),
]
operations = [
migrations.CreateModel(
name="BeerProducer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("description", models.TextField(blank=True, null=True)),
(
"location",
models.CharField(blank=True, max_length=255, null=True),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.CreateModel(
name="Beer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
"title",
models.CharField(blank=True, max_length=255, null=True),
),
(
"run_time_seconds",
models.IntegerField(blank=True, null=True),
),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
("description", models.TextField(blank=True, null=True)),
("ibu", models.SmallIntegerField(blank=True, null=True)),
("abv", models.FloatField(blank=True, null=True)),
(
"style",
models.CharField(blank=True, max_length=100, null=True),
),
("non_alcoholic", models.BooleanField(default=False)),
(
"beeradvocate_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"beeradvocate_score",
models.SmallIntegerField(blank=True, null=True),
),
(
"untappd_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.16 on 2024-10-22 21:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("beers", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="beer",
name="beeradvocate_image",
field=models.ImageField(
blank=True, null=True, upload_to="beers/beeradvcoate/"
),
),
migrations.AddField(
model_name="beer",
name="producer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="beers.beerproducer",
),
),
migrations.AddField(
model_name="beerproducer",
name="beeradvocate_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,75 @@
# Generated by Django 4.2.16 on 2024-10-22 21:47
from django.db import migrations, models
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"),
]
operations = [
migrations.CreateModel(
name="BeerStyle",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("description", models.TextField(blank=True, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.RemoveField(
model_name="beer",
name="beeradvocate_image",
),
migrations.RemoveField(
model_name="beer",
name="style",
),
migrations.AddField(
model_name="beer",
name="untappd_image",
field=models.ImageField(
blank=True, null=True, upload_to="beers/untappd/"
),
),
migrations.AddField(
model_name="beer",
name="untappd_rating",
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name="beerproducer",
name="untappd_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="beer",
name="styles",
field=models.ManyToManyField(to="beers.beerstyle"),
),
]

View File

@ -0,0 +1,47 @@
# Generated by Django 4.2.16 on 2024-10-22 21:52
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
]
operations = [
migrations.AddField(
model_name="beerproducer",
name="name",
field=models.CharField(default="Untitled", max_length=255),
preserve_default=False,
),
migrations.AddField(
model_name="beerproducer",
name="uuid",
field=models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
migrations.AddField(
model_name="beerstyle",
name="name",
field=models.CharField(default="Untitled", max_length=255),
preserve_default=False,
),
migrations.AddField(
model_name="beerstyle",
name="uuid",
field=models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
migrations.AlterField(
model_name="beer",
name="styles",
field=models.ManyToManyField(
related_name="styles", to="beers.beerstyle"
),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"beers",
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
),
]
operations = [
migrations.AlterField(
model_name="beer",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -0,0 +1,140 @@
from uuid import uuid4
from beers.untappd import get_beer_from_untappd_id, get_rating_from_soup
from django.apps import apps
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BeerLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
class BeerStyle(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
def __str__(self):
return self.name
class BeerProducer(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
location = models.CharField(max_length=255, **BNULL)
beeradvocate_id = models.CharField(max_length=255, **BNULL)
untappd_id = models.CharField(max_length=255, **BNULL)
def find_or_create(cls, title: str) -> "BeerProducer":
return cls.objects.filter(title=title).first()
def __str__(self):
return self.name
class Beer(ScrobblableMixin):
description = models.TextField(**BNULL)
ibu = models.SmallIntegerField(**BNULL)
abv = models.FloatField(**BNULL)
styles = models.ManyToManyField(BeerStyle, related_name="styles")
non_alcoholic = models.BooleanField(default=False)
beeradvocate_id = models.CharField(max_length=255, **BNULL)
beeradvocate_score = models.SmallIntegerField(**BNULL)
untappd_image = models.ImageField(upload_to="beers/untappd/", **BNULL)
untappd_image_small = ImageSpecField(
source="untappd_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
untappd_image_medium = ImageSpecField(
source="untappd_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
untappd_id = models.CharField(max_length=255, **BNULL)
untappd_rating = models.FloatField(**BNULL)
producer = models.ForeignKey(
BeerProducer, on_delete=models.DO_NOTHING, **BNULL
)
def get_absolute_url(self) -> str:
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
def __str__(self):
return f"{self.title} by {self.producer}"
@property
def subtitle(self):
return self.producer.name
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Drinking", tags="beer")
@property
def beeradvocate_link(self) -> str:
link = ""
if self.producer and self.beeradvocate_id:
if self.beeradvocate_id:
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
return link
@property
def untappd_link(self) -> str:
link = ""
if self.untappd_id:
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
return link
@property
def primary_image_url(self) -> str:
url = ""
if self.untappd_image:
url = self.untappd_image.url
return url
@property
def logdata_cls(self):
return BeerLogData
@classmethod
def find_or_create(cls, untappd_id: str) -> "Beer":
beer = cls.objects.filter(untappd_id=untappd_id).first()
if not beer:
beer_dict = get_beer_from_untappd_id(untappd_id)
producer_dict = {}
style_ids = []
for key in list(beer_dict.keys()):
if "producer__" in key:
pkey = key.replace("producer__", "")
producer_dict[pkey] = beer_dict.pop(key)
if "styles" in key:
for style in beer_dict.pop("styles"):
style_inst, created = BeerStyle.objects.get_or_create(
name=style
)
style_ids.append(style_inst.id)
producer, _created = BeerProducer.objects.get_or_create(
**producer_dict
)
beer_dict["producer_id"] = producer.id
beer = Beer.objects.create(**beer_dict)
for style_id in style_ids:
beer.styles.add(style_id)
return beer
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
"-timestamp"
)

View File

@ -0,0 +1,142 @@
import logging
from typing import Optional
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
UNTAPPD_URL = "https://untappd.com/beer/{id}"
def get_first(key: str, result: dict) -> str:
obj = ""
if obj_list := result.get(key):
obj = obj_list[0]
return obj
def get_title_from_soup(soup) -> str:
title = ""
try:
title = soup.find("h1").get_text()
except AttributeError:
pass
except ValueError:
pass
return title
def get_description_from_soup(soup) -> str:
desc = ""
try:
desc = (
soup.find(class_="beer-descrption-read-less")
.get_text()
.replace("Show Less", "")
.strip()
)
except AttributeError:
pass
except ValueError:
pass
return desc
def get_styles_from_soup(soup) -> list[str]:
styles = []
try:
styles = soup.find("p", class_="style").get_text().split(" - ")
except AttributeError:
pass
except ValueError:
pass
return styles
def get_abv_from_soup(soup) -> Optional[float]:
abv = None
try:
abv = soup.find(class_="abv").get_text()
if abv:
abv = float(abv.strip("\n").strip("% ABV").strip())
except AttributeError:
pass
except ValueError:
pass
except TypeError:
pass
return abv
def get_ibu_from_soup(soup) -> Optional[int]:
ibu = None
try:
ibu = soup.find(class_="ibu").get_text()
if ibu:
ibu = int(ibu.strip("\n").strip(" IBU").strip())
except AttributeError:
pass
except ValueError:
ibu = None
return ibu
def get_rating_from_soup(soup) -> str:
rating = ""
try:
rating = float(
soup.find(class_="num").get_text().strip("(").strip(")")
)
except AttributeError:
rating = None
except ValueError:
rating = None
return rating
def get_producer_id_from_soup(soup) -> str:
id = ""
try:
id = soup.find(class_="brewery").find("a")["href"].strip("/")
except ValueError:
pass
except IndexError:
pass
return id
def get_producer_name_from_soup(soup) -> str:
name = ""
try:
name = soup.find(class_="brewery").find("a").get_text()
except AttributeError:
pass
except ValueError:
pass
return name
def get_beer_from_untappd_id(untappd_id: str) -> dict:
beer_url = UNTAPPD_URL.format(id=untappd_id)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(beer_url, headers=headers)
beer_dict = {"untappd_id": untappd_id}
if response.status_code != 200:
logger.warn(
"Bad response from untappd.com", extra={"response": response}
)
return beer_dict
soup = BeautifulSoup(response.text, "html.parser")
beer_dict["title"] = get_title_from_soup(soup)
beer_dict["description"] = get_description_from_soup(soup)
beer_dict["styles"] = get_styles_from_soup(soup)
beer_dict["abv"] = get_abv_from_soup(soup)
beer_dict["ibu"] = get_ibu_from_soup(soup)
beer_dict["untappd_rating"] = get_rating_from_soup(soup)
beer_dict["producer__untappd_id"] = get_producer_id_from_soup(soup)
beer_dict["producer__name"] = get_producer_name_from_soup(soup)
return beer_dict

View File

@ -0,0 +1,14 @@
from django.urls import path
from beers import views
app_name = "beers"
urlpatterns = [
path("beers/", views.BeerListView.as_view(), name="beer_list"),
path(
"beers/<slug:slug>/",
views.BeerDetailView.as_view(),
name="beer_detail",
),
]

View File

@ -0,0 +1,11 @@
from beers.models import Beer
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class BeerListView(ScrobbleableListView):
model = Beer
class BeerDetailView(ScrobbleableDetailView):
model = Beer

View File

@ -1,8 +1,15 @@
import csv
import json
import logging
from typing import Optional
from typing import TYPE_CHECKING, Optional
import requests
from bs4 import BeautifulSoup
from django.contrib.auth import get_user_model
User = get_user_model()
if TYPE_CHECKING:
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
@ -90,3 +97,56 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
}
return game_dict
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
bgg_username = user.profile.bgg_username
bgg_password = user.profile.bgg_password
if not bgg_username or bgg_password:
return
login_payload = {
"credentials": {"username": bgg_username, "password": bgg_password}
}
headers = {"content-type": "application/json"}
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before
with requests.Session() as s:
p = s.post(
"https://boardgamegeek.com/login/api/v1",
data=json.dumps(login_payload),
headers=headers,
)
players = []
if scrobble.metadata:
for player in scrobble.metadata.players:
if player["user_id"]:
player_user = User.objects.filter(
id=player["user_id"]
).first()
if player_user:
if player_user.bgg_username:
player["username"] = player_user.bgg_username
else:
player["name"] = player_user.username
player["win"] = player.get("win")
player["color"] = player.get("color")
player["new"] = player.get("new")
player["score"] = player.get("score")
players.append(player)
play_payload = {
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
"length": scrobble.playback_position_seconds / 60,
"comments": "Uploaded from Vrobbler",
"location": scrobble.log.location or None,
"objectid": scrobble.media_obj.bggeek_id,
"quantity": "1",
"action": "save",
"players": players,
"objecttype": "thing",
"ajax": 1,
}

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0006_alter_boardgame_genre"),
]
operations = [
migrations.AlterField(
model_name="boardgame",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -13,7 +13,7 @@ from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BoardGameLogData
from scrobbles.mixins import ScrobblableMixin
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@ -100,6 +100,10 @@ class BoardGame(ScrobblableMixin):
def logdata_cls(self):
return BoardGameLogData
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Playing", tags="game_die")
def primary_image_url(self) -> str:
url = ""
if self.cover:
@ -112,9 +116,6 @@ class BoardGame(ScrobblableMixin):
link = f"https://boardgamegeek.com/boardgame/{self.bggeek_id}"
return link
def get_start_url(self):
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
if not self.published_date or force_update:
@ -129,6 +130,11 @@ class BoardGame(ScrobblableMixin):
if year:
data["published_date"] = datetime(int(year), 1, 1)
if not data["min_players"]:
data.pop("min_players")
if not data["min_players"]:
data.pop("max_players")
# Fun trick for updating all fields at once
BoardGame.objects.filter(pk=self.id).update(**data)
self.refresh_from_db()

View File

@ -0,0 +1,128 @@
import berserk
from boardgames.models import BoardGame
from django.conf import settings
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
from scrobbles.notifications import NtfyNotification
User = get_user_model()
def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
user = User.objects.get(id=user_id)
client = berserk.Client(
session=berserk.TokenSession(settings.LICHESS_API_KEY)
)
games = client.games.export_by_player(user.profile.lichess_username)
for game_dict in games:
chess, created = BoardGame.objects.get_or_create(title="Chess")
if created:
chess.run_time_seconds = 1800
chess.bggeek_id = 171
chess.save(update_fields=["run_time_seconds", "bggeek_id"])
scrobble = Scrobble.objects.filter(
user_id=user.id,
timestamp=game_dict.get("createdAt"),
board_game_id=chess.id,
).first()
if scrobble:
continue
log_data = {
"variant": game_dict.get("variant"),
"lichess_id": game_dict.get("id"),
"rated": game_dict.get("rated"),
"speed": game_dict.get("speed"),
"moves": game_dict.get("moves"),
"players": [],
}
winner = game_dict.get("winner")
black_player = game_dict.get("players", {}).get("black", {})
white_player = game_dict.get("players", {}).get("white", {})
user_player = {
"user_id": user.id,
"lichess_username": user.profile.lichess_username,
"bgg_username": user.profile.bgg_username,
"color": "",
"win": False,
}
other_player = {"name_str": "", "color": "", "win": False}
if (
black_player.get("user", {}).get("name", "")
== user.profile.lichess_username
):
user_player["color"] = "black"
if "aiLevel" in white_player.keys():
other_player["name_str"] = "aiLevel_" + str(
white_player.get("aiLevel", "")
)
else:
other_player["name_str"] = white_player.get("user", {}).get(
"name", ""
)
other_player["lichess_username"] = other_player["name_str"]
other_player["color"] = "white"
if winner == "black":
user_player["win"] = True
else:
other_player["win"] = True
if (
white_player.get("user", {}).get("name", "")
== user.profile.lichess_username
):
user_player["color"] = "white"
if "aiLevel" in black_player.keys():
other_player["name_str"] = "aiLevel_" + str(
black_player.get("aiLevel", "")
)
else:
other_player["name_str"] = black_player.get("user", {}).get(
"name", ""
)
other_player["lichess_username"] = other_player["name_str"]
other_player["color"] = "black"
if winner == "white":
user_player["win"] = True
else:
other_player["win"] = True
log_data["players"].append(user_player)
log_data["players"].append(other_player)
scrobble_dict = {
"media_type": Scrobble.MediaType.BOARD_GAME,
"user_id": user.id,
"playback_position_seconds": (
game_dict.get("lastMoveAt") - game_dict.get("createdAt")
).seconds,
"in_progress": False,
"played_to_completion": True,
"timestamp": game_dict.get("createdAt"),
"stop_timestamp": game_dict.get("lastMoveAt"),
"board_game_id": chess.id,
"source": "Lichess",
"timezone": user.profile.timezone,
"log": log_data,
}
if commit:
Scrobble.objects.create(**scrobble_dict)
return scrobble_dict
def import_chess_games_for_all_users():
scrobbles_to_create = []
for user in User.objects.filter(profile__lichess_username__isnull=False):
scrobble_dict = import_chess_games_for_user_id(user.id)
scrobbles_to_create.append(Scrobble(**scrobble_dict))
if scrobbles_to_create:
created = Scrobble.objects.bulk_create(scrobbles_to_create)
for scrobble in created:
NtfyNotification(scrobble).send()
return scrobbles_to_create

View File

@ -6,10 +6,12 @@ app_name = "boardgames"
urlpatterns = [
path(
"board-game/", views.BoardGameListView.as_view(), name="boardgame_list"
"board-games/",
views.BoardGameListView.as_view(),
name="boardgame_list",
),
path(
"board-game/<slug:slug>/",
"board-games/<slug:slug>/",
views.BoardGameDetailView.as_view(),
name="boardgame_detail",
),

View File

@ -1,15 +1,14 @@
from django.views import generic
from boardgames.models import BoardGame, BoardGamePublisher
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class BoardGameListView(generic.ListView):
class BoardGameListView(ScrobbleableListView):
model = BoardGame
paginate_by = 20
class BoardGameDetailView(generic.DetailView):
class BoardGameDetailView(ScrobbleableDetailView):
model = BoardGame
slug_field = "uuid"
class BoardGamePublisherDetailView(generic.DetailView):

View File

@ -1,7 +1,5 @@
from books.models import Author, Book, Paper
from django.contrib import admin
from books.models import Author, Book, Page
from scrobbles.admin import ScrobbleInline
@ -18,22 +16,32 @@ class AuthorAdmin(admin.ModelAdmin):
search_fields = ("name",)
@admin.register(Page)
class PageAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_filter = ("book",)
ordering = ("book", "number")
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"isbn",
"subtitle",
"isbn_13",
"first_publish_year",
"pages",
)
search_fields = ("name",)
ordering = ("-created",)
inlines = [
ScrobbleInline,
]
@admin.register(Paper)
class BookAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"subtitle",
"arxiv_id",
"first_publish_year",
"pages",
"openlibrary_id",
)
search_fields = ("name",)
ordering = ("-created",)

View File

@ -0,0 +1,7 @@
#!/usr/bin/env python3
BOOKS_TITLES_TO_IGNORE = [
"KOReader Quickstart Guide",
"zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL",
"zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif",
]

View File

@ -1,4 +1,3 @@
from collections import OrderedDict
import logging
import re
import sqlite3
@ -7,11 +6,12 @@ from enum import Enum
import pytz
import requests
from books.models import Author, Book
from books.openlibrary import get_author_openlibrary_id
from books.constants import BOOKS_TITLES_TO_IGNORE
from django.apps import apps
from django.contrib.auth import get_user_model
from stream_sqlite import stream_sqlite
from scrobbles.notifications import NtfyNotification
from webdav.client import get_webdav_client
logger = logging.getLogger(__name__)
User = get_user_model()
@ -62,6 +62,8 @@ def lookup_or_create_authors_from_author_str(ko_author_str: str) -> list:
"""Takes a string of authors from KoReader and returns a list
of Authors from our database
"""
from books.models import Author
author_str_list = ko_author_str.split(", ")
author_list = []
for author_str in author_str_list:
@ -73,36 +75,49 @@ def lookup_or_create_authors_from_author_str(ko_author_str: str) -> list:
author = Author.objects.filter(name=author_str).first()
if not author:
author = Author.objects.create(
name=author_str,
openlibrary_id=get_author_openlibrary_id(author_str),
)
author.fix_metadata()
author = Author.objects.create(name=author_str)
# TODO Move these to async processes after importing
# author.fix_metadata()
logger.debug(f"Created author {author}")
author_list.append(author)
return author_list
def create_book_from_row(row: list):
from books.models import Book
# No KoReader book yet, create it
author_str = get_author_str_from_row(row)
author_str = get_author_str_from_row(row).replace("\x00", "")
total_pages = row[KoReaderBookColumn.PAGES.value]
run_time = total_pages * Book.AVG_PAGE_READING_SECONDS
book_title = row[KoReaderBookColumn.TITLE.value].replace("\x00", "")
if " - " in book_title:
split_title = book_title.split(" - ")
book_title = split_title[0]
if (not author_str or author_str == "N/A") and len(split_title) > 1:
author_str = split_title[1].split("_")[0]
clean_row = []
for value in row:
if isinstance(value, str):
value = value.replace("\x00", "")
clean_row.append(value)
book = Book.objects.create(
title=row[KoReaderBookColumn.TITLE.value],
title=book_title.replace("_", ":"),
pages=total_pages,
koreader_data_by_hash={
str(row[KoReaderBookColumn.MD5.value]): {
"title": row[KoReaderBookColumn.TITLE.value],
"title": book_title,
"author_str": author_str,
"book_id": row[KoReaderBookColumn.ID.value],
"pages": total_pages,
"raw_row_data": clean_row,
}
},
run_time_seconds=run_time,
)
book.fix_metadata()
# TODO Move these to async processes after importing
# book.fix_metadata()
# Add authors
author_list = lookup_or_create_authors_from_author_str(author_str)
@ -119,15 +134,16 @@ def build_book_map(rows) -> dict:
primary key IDs for page creation.
"""
from books.models import Book
book_id_map = {}
for book_row in rows:
if (
book_row[KoReaderBookColumn.TITLE.value]
== "KOReader Quickstart Guide"
):
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
logger.info(
"Ignoring the KOReader quickstart guide. No on wants that."
"[build_book_map] Ignoring book title that is likely garbage",
extra={"book_row": book_row, "media_type": "Book"},
)
continue
book = Book.objects.filter(
@ -136,6 +152,15 @@ def build_book_map(rows) -> dict:
]
).first()
if not book:
title = (
book_row[KoReaderBookColumn.TITLE.value]
.split(" - ")[0]
.lower()
.replace("\x00", "")
)
book = Book.objects.filter(title=title).first()
if not book:
book = create_book_from_row(book_row)
@ -271,6 +296,26 @@ def build_scrobbles_from_book_map(
)
):
timezone = "Europe/Paris"
if (
datetime(2024, 4, 28).replace(
tzinfo=pytz.timezone("US/Pacific")
)
<= timestamp
<= datetime(2024, 5, 4).replace(
tzinfo=pytz.timezone("US/Pacific")
)
):
timezone = "US/Pacific"
if (
datetime(2024, 8, 4).replace(
tzinfo=pytz.timezone("Canada/Atlantic")
)
<= timestamp
<= datetime(2024, 8, 10).replace(
tzinfo=pytz.timezone("Canada/Atlantic")
)
):
timezone = "Canada/Atlantic"
stop_timestamp = datetime.fromtimestamp(
int(last_page.get("end_ts"))
@ -296,7 +341,7 @@ def build_scrobbles_from_book_map(
log_data = {
"koreader_hash": book_dict.get("hash"),
"page_data": scrobble_page_data,
"pages_read": cur_page_number,
"pages_read": len(scrobble_page_data.keys()),
}
scrobbles_to_create.append(
Scrobble(
@ -314,10 +359,10 @@ def build_scrobbles_from_book_map(
timezone=timezone,
)
)
# Then start over
should_create_scrobble = False
playback_position_seconds = 0
scrobble_page_data = {}
# Then start over
should_create_scrobble = False
playback_position_seconds = 0
scrobble_page_data = {}
# We accumulate pages for the scrobble until we should create a new one
scrobble_page_data[cur_page_number] = stats
@ -397,9 +442,27 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
created = []
if new_scrobbles:
created = Scrobble.objects.bulk_create(new_scrobbles)
if created:
NtfyNotification(created[-1]).send()
fix_long_play_stats_for_scrobbles(created)
logger.info(
f"Created {len(created)} scrobbles",
extra={"created_scrobbles": created},
)
return created
def fetch_file_from_webdav(user_id: int) -> str:
file_path = f"/tmp/{user_id}-koreader-import.sqlite3"
client = get_webdav_client(user_id)
if not client:
logger.warning("could not get webdav client for user")
# TODO maybe we raise an exception here?
return ""
client.download_sync(
remote_path="var/koreader/statistics.sqlite3",
local_path=file_path,
)
return file_path

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3
from enum import Enum
from typing import Optional
from bs4 import BeautifulSoup
import requests

View File

@ -0,0 +1,40 @@
from enum import Enum
from typing import Optional
import pendulum
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
IMDB_VIDEO_URL = "https://www.imdb.com/title/tt"
class BookType:
...
class BookMetadata:
title: str
run_time_seconds: Optional[int]
authors = Optional[str]
goodreads_id = Optional[str]
koreader_data_by_hash = Optional[dict]
isbn = Optional[str]
# isbn_13 = Optional[str]
# isbn_10 = Optional[str]
pages = Optional[int]
language = Optional[str]
first_publish_year = Optional[int]
summary = Optional[str]
# General
cover_url: Optional[str]
genres: list[str]
def __init__(self, title: Optional[str] = ""):
self.title = title
def as_dict_with_authors_cover_and_genres(self) -> tuple:
book_dict = vars(self)
authors = book_dict.pop("authors")
cover = book_dict.pop("cover_url")
genres = book_dict.pop("genres")
return book_dict, authors, cover, genres

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.16 on 2024-10-17 20:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("books", "0020_author_comicvine_data_book_comicvine_data_and_more"),
]
operations = [
migrations.RemoveField(
model_name="book",
name="koreader_authors",
),
migrations.RemoveField(
model_name="book",
name="koreader_id",
),
migrations.RemoveField(
model_name="book",
name="koreader_md5",
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"books",
"0021_remove_book_koreader_authors_remove_book_koreader_id_and_more",
),
]
operations = [
migrations.AlterField(
model_name="book",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -0,0 +1,40 @@
# Generated by Django 4.2.18 on 2025-01-27 04:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0022_alter_book_run_time_seconds"),
]
operations = [
migrations.RenameField(
model_name="book",
old_name="isbn",
new_name="isbn_13",
),
migrations.RemoveField(
model_name="book",
name="comicvine_data",
),
migrations.RemoveField(
model_name="book",
name="goodreads_id",
),
migrations.RemoveField(
model_name="book",
name="locg_slug",
),
migrations.AddField(
model_name="book",
name="isbn_10",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="book",
name="publish_date",
field=models.DateField(blank=True, null=True),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.18 on 2025-01-27 04:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"books",
"0023_rename_isbn_book_isbn_13_remove_book_comicvine_data_and_more",
),
]
operations = [
migrations.AddField(
model_name="book",
name="publisher",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,136 @@
# Generated by Django 4.2.19 on 2025-02-18 05:11
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0067_scrobble_food_alter_scrobble_media_type"),
("books", "0024_book_publisher"),
]
operations = [
migrations.RemoveField(
model_name="author",
name="amazon_id",
),
migrations.RemoveField(
model_name="author",
name="librarything_id",
),
migrations.RemoveField(
model_name="author",
name="locg_slug",
),
migrations.AddField(
model_name="author",
name="semantic_id",
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.CreateModel(
name="Paper",
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,
),
),
("run_time_seconds", models.IntegerField(default=900)),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
("title", models.CharField(max_length=255)),
("semantic_title", models.CharField(max_length=255)),
(
"koreader_data_by_hash",
models.JSONField(blank=True, null=True),
),
(
"semantic_id",
models.CharField(blank=True, max_length=50, null=True),
),
(
"arxiv_id",
models.CharField(blank=True, max_length=50, null=True),
),
(
"corpus_id",
models.CharField(blank=True, max_length=50, null=True),
),
(
"doi_id",
models.CharField(blank=True, max_length=50, null=True),
),
("pages", models.IntegerField(blank=True, null=True)),
(
"language",
models.CharField(blank=True, max_length=4, null=True),
),
(
"first_publish_year",
models.IntegerField(blank=True, null=True),
),
("publish_date", models.DateField(blank=True, null=True)),
(
"journal",
models.CharField(blank=True, max_length=255, null=True),
),
(
"journal_volume",
models.CharField(blank=True, max_length=50, null=True),
),
("abstract", models.TextField(blank=True, null=True)),
("num_citations", models.IntegerField(blank=True, null=True)),
(
"openaccess_pdf_url",
models.CharField(blank=True, max_length=255, null=True),
),
(
"authors",
models.ManyToManyField(blank=True, to="books.author"),
),
(
"genre",
taggit.managers.TaggableManager(
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-02-18 05:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0025_remove_author_amazon_id_and_more"),
]
operations = [
migrations.AlterField(
model_name="paper",
name="semantic_title",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.19 on 2025-02-18 05:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0026_alter_paper_semantic_title"),
]
operations = [
migrations.RemoveField(
model_name="paper",
name="num_citations",
),
migrations.AddField(
model_name="paper",
name="tldr",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.19 on 2025-04-07 17:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("books", "0027_remove_paper_num_citations_paper_tldr"),
]
operations = [
migrations.DeleteModel(
name="Page",
),
]

View File

@ -19,6 +19,7 @@ from imagekit.processors import ResizeToFit
from scrobbles.mixins import (
LongPlayScrobblableMixin,
ObjectWithGenres,
ScrobblableConstants,
ScrobblableMixin,
)
from scrobbles.utils import get_scrobbles_for_media
@ -34,6 +35,8 @@ from vrobbler.apps.books.locg import (
lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug,
)
from vrobbler.apps.books.sources.google import lookup_book_from_google
from vrobbler.apps.books.sources.semantic import lookup_paper_from_semantic
from vrobbler.apps.scrobbles.dataclasses import BookLogData
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
@ -62,22 +65,27 @@ class Author(TimeStampedModel):
)
bio = models.TextField(**BNULL)
wikipedia_url = models.CharField(max_length=255, **BNULL)
isni = models.CharField(max_length=255, **BNULL)
locg_slug = models.CharField(max_length=255, **BNULL)
wikidata_id = models.CharField(max_length=255, **BNULL)
isni = models.CharField(max_length=255, **BNULL)
goodreads_id = models.CharField(max_length=255, **BNULL)
librarything_id = models.CharField(max_length=255, **BNULL)
comicvine_data = models.JSONField(**BNULL)
amazon_id = models.CharField(max_length=255, **BNULL)
semantic_id = models.CharField(max_length=50, **BNULL)
def __str__(self):
return f"{self.name}"
def fix_metadata(self, data_dict: dict = {}):
if not data_dict and self.openlibrary_id:
data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
def enrich_from_semantic(self, overwrite=False):
...
def enrich_from_google_books(self, overwrite=False):
...
def enrich_from_openlibrary(self, overwrite=False):
data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
if not data_dict or not data_dict.get("name"):
logger.warning("Could not find author on openlibrary")
return
headshot_url = data_dict.pop("author_headshot_url", "")
@ -100,20 +108,16 @@ class Book(LongPlayScrobblableMixin):
title = models.CharField(max_length=255)
authors = models.ManyToManyField(Author, blank=True)
goodreads_id = models.CharField(max_length=255, **BNULL)
# All individual koreader fields are deprecated
koreader_id = models.IntegerField(**BNULL)
koreader_authors = models.CharField(max_length=255, **BNULL)
koreader_md5 = models.CharField(max_length=255, **BNULL)
koreader_data_by_hash = models.JSONField(**BNULL)
isbn = models.CharField(max_length=255, **BNULL)
isbn_13 = models.CharField(max_length=255, **BNULL)
isbn_10 = models.CharField(max_length=255, **BNULL)
pages = models.IntegerField(**BNULL)
language = models.CharField(max_length=4, **BNULL)
first_publish_year = models.IntegerField(**BNULL)
publish_date = models.DateField(**BNULL)
publisher = models.CharField(max_length=255, **BNULL)
first_sentence = models.TextField(**BNULL)
openlibrary_id = models.CharField(max_length=255, **BNULL)
locg_slug = models.CharField(max_length=255, **BNULL)
comicvine_data = models.JSONField(**BNULL)
cover = models.ImageField(upload_to="books/covers/", **BNULL)
cover_small = ImageSpecField(
source="cover",
@ -138,6 +142,10 @@ class Book(LongPlayScrobblableMixin):
def subtitle(self):
return f" by {self.author}"
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Reading", tags="book")
@property
def logdata_cls(self):
return BookLogData
@ -149,12 +157,53 @@ class Book(LongPlayScrobblableMixin):
url = self.cover_medium.url
return url
def get_start_url(self):
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
def get_absolute_url(self):
return reverse("books:book_detail", kwargs={"slug": self.uuid})
@classmethod
def get_from_google(cls, title: str, overwrite: bool = False):
book, created = cls.objects.get_or_create(title=title)
if not created and not overwrite:
return book
book_dict = lookup_book_from_google(title)
if created or overwrite:
author_list = []
authors = book_dict.pop("authors")
cover_url = book_dict.pop("cover_url")
try:
genres = book_dict.pop("generes")
except:
genres = []
if authors:
for author_str in authors:
if author_str:
author, a_created = Author.objects.get_or_create(
name=author_str
)
author_list.append(author)
if a_created:
# TODO enrich author
...
for k, v in book_dict.items():
setattr(book, k, v)
book.save()
book.save_image_from_url(cover_url)
book.genre.add(*genres)
book.authors.add(*author_list)
return book
def save_image_from_url(self, url: str, force_update: bool = False):
if not self.cover or (force_update and url):
r = requests.get(url)
if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg"
self.cover.save(fname, ContentFile(r.content), save=True)
def fix_metadata(self, data: dict = {}, force_update=False):
if (not self.openlibrary_id or not self.locg_slug) or force_update:
author_name = ""
@ -342,99 +391,75 @@ class Book(LongPlayScrobblableMixin):
)
return book
book, book_created = cls.objects.get_or_create(isbn=data["isbn"])
book, book_created = cls.objects.get_or_create(
isbn_13=data["isbn"]
)
if book_created:
book.fix_metadata(data=data)
return book
class Page(TimeStampedModel):
"""DEPRECATED, we need to migrate pages into page_data on scrobbles and move on"""
class Paper(LongPlayScrobblableMixin):
"""Keeps track of Academic Papers"""
book = models.ForeignKey(Book, on_delete=models.CASCADE)
number = models.IntegerField()
user = models.ForeignKey(User, on_delete=models.CASCADE)
start_time = models.DateTimeField(**BNULL)
end_time = models.DateTimeField(**BNULL)
duration_seconds = models.IntegerField(**BNULL)
COMPLETION_PERCENT = getattr(settings, "PAPER_COMPLETION_PERCENT", 60)
AVG_PAGE_READING_SECONDS = getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
class Meta:
unique_together = (
"book",
"number",
)
title = models.CharField(max_length=255)
semantic_title = models.CharField(max_length=255, **BNULL)
authors = models.ManyToManyField(Author, blank=True)
koreader_data_by_hash = models.JSONField(**BNULL)
arxiv_id = models.CharField(max_length=50, **BNULL)
semantic_id = models.CharField(max_length=50, **BNULL)
arxiv_id = models.CharField(max_length=50, **BNULL)
corpus_id = models.CharField(max_length=50, **BNULL)
doi_id = models.CharField(max_length=50, **BNULL)
pages = models.IntegerField(**BNULL)
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_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)
def __str__(self):
return f"Page {self.number} of {self.book.pages} in {self.book.title}"
genre = TaggableManager(through=ObjectWithGenres)
def save(self, *args, **kwargs):
if not self.end_time and self.duration_seconds:
self._set_end_time()
@classmethod
def get_from_semantic(cls, title: str, overwrite: bool = False) -> "Paper":
paper, created = cls.objects.get_or_create(title=title)
if not created and not overwrite:
return paper
return super(Page, self).save(*args, **kwargs)
paper_dict = lookup_paper_from_semantic(title)
@property
def next(self):
page = self.book.page_set.filter(number=self.number + 1).first()
if not page:
page = (
self.book.page_set.filter(created__gt=self.created)
.order_by("created")
.first()
)
return page
if created or overwrite:
author_list = []
author_dicts = paper_dict.pop("author_dicts")
if author_dicts:
for author_dict in author_dicts:
if author_dict.get("authorId"):
author, a_created = Author.objects.get_or_create(
semantic_id=author_dict.get("authorId")
)
author_list.append(author)
if a_created:
author.name = author_dict.get("name")
author.save()
# TODO enrich author?
...
@property
def previous(self):
page = self.book.page_set.filter(number=self.number - 1).first()
if not page:
page = (
self.book.page_set.filter(created__lt=self.created)
.order_by("-created")
.first()
)
return page
for k, v in paper_dict.items():
setattr(paper, k, v)
paper.save()
@property
def seconds_to_next_page(self) -> int:
seconds = 999999 # Effectively infnity time as we have no next
if not self.end_time:
self._set_end_time()
if self.next:
seconds = (self.next.start_time - self.end_time).seconds
return seconds
@property
def is_scrobblable(self) -> bool:
"""A page defines the start of a scrobble if the seconds to next page
are greater than an hour, or 3600 seconds, and it's not a single page,
so the next seconds to next_page is less than an hour as well.
As a special case, the first recorded page is a scrobble, so we establish
when the book was started.
"""
is_scrobblable = False
over_an_hour_since_last_page = False
if not self.previous:
is_scrobblable = True
if self.previous:
over_an_hour_since_last_page = (
self.previous.seconds_to_next_page >= 3600
)
blip = self.seconds_to_next_page >= 3600
if over_an_hour_since_last_page and not blip:
is_scrobblable = True
return is_scrobblable
def _set_end_time(self) -> None:
if self.end_time:
return
self.end_time = self.start_time + timedelta(
seconds=self.duration_seconds
)
self.save(update_fields=["end_time"])
if author_list:
paper.authors.add(*author_list)
genres = paper_dict.pop("genres", [])
if genres:
paper.genre.add(*genres)
return paper

View File

View File

@ -0,0 +1,71 @@
import json
import logging
import pendulum
import requests
from django.conf import settings
API_KEY = settings.GOOGLE_API_KEY
GOOGLE_BOOKS_URL = (
"https://www.googleapis.com/books/v1/volumes?q=\"{title}\"&key={key}"
)
logger = logging.getLogger(__name__)
def lookup_book_from_google(title: str) -> dict:
book_dict = {"title": title}
url = GOOGLE_BOOKS_URL.format(title=title, key=API_KEY)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(url, headers=headers)
if response.status_code != 200:
logger.warning(
"Bad response from Google", extra={"response": response}
)
return book_dict
google_result = (
json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
)
publish_date = pendulum.parse(google_result.get("publishedDate"))
isbn_13 = ""
isbn_10 = ""
for ident in google_result.get("industryIdentifiers", []):
if ident.get("type") == "ISBN_13":
isbn_13 = ident.get("identifier")
if ident.get("type") == "ISBN_10":
isbn_10 = ident.get("identifier")
# TODO this may lead to issues with the first get if Google changes our title
# book_metadata.title = google_result.get("title")
# if google_result.get("subtitle"):
# book_metadata["title"] = ": ".join(
# [google_result.get("title"), google_result.get("subtitle")]
# )
# book_dict["subtitle"] = google_result.get("subtitle")
book_dict["authors"] = google_result.get("authors")
book_dict["publisher"] = google_result.get("publisher")
book_dict["first_publish_year"] = publish_date.year
book_dict["pages"] = google_result.get("pageCount")
book_dict["isbn_13"] = isbn_13
book_dict["isbn_10"] = isbn_10
book_dict["publish_date"] = google_result.get("publishedDate")
if len(book_dict["publish_date"]) == 4:
book_dict["publish_date"] = f"{book_dict['publish_date']}-1-1"
book_dict["language"] = google_result.get("language")
book_dict["summary"] = google_result.get("description")
book_dict["genres"] = google_result.get("categories")
book_dict["cover_url"] = (
google_result.get("imageLinks", {})
.get("thumbnail")
.replace("zoom=1", "zoom=15")
.replace("&edge=curl", "")
)
book_dict["run_time_seconds"] = book_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
return book_dict

View File

@ -0,0 +1,76 @@
import json
import logging
from datetime import datetime
import requests
from django.conf import settings
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"
logger = logging.getLogger(__name__)
def get_api_result(url):
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(url, headers=headers)
if response.status_code != 200:
logger.warning(
"Bad response from Semantic", extra={"response": response}
)
return None
return response
def lookup_paper_from_semantic(title: str) -> dict:
paper_dict = {"title": title}
response = get_api_result(PAPER_SEARCH_URL.format(title))
if not response:
return paper_dict
semantic_id = json.loads(response.content).get("data")[0].get("paperId")
response = get_api_result(PAPER_DETAIL_URL.format(semantic_id))
result = json.loads(response.content)
if not result:
return paper_dict
page_str = result.get("journal", {}).get("pages")
if page_str:
try:
start_page = page_str.split(" - ")[0]
end_page = page_str.split(" - ")[1]
paper_dict["pages"] = int(end_page) - int(start_page)
except IndexError:
pass
paper_dict["semantic_id"] = result.get("paperId")
paper_dict["doi_id"] = result.get("externalIds", {}).get("DOI")
paper_dict["arxiv_id"] = result.get("externalIds", {}).get("ArXiv")
paper_dict["pubmed_id"] = result.get("externalIds", {}).get("PubMed")
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["abstract"] = result.get("abstract")
paper_dict["tldr"] = result.get("bib", {}).get("abstract")
paper_dict["journal"] = 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["run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
paper_dict["author_dicts"] = result.get("authors")
paper_dict["genres"] = result.get("fieldsOfStudy")
return paper_dict

View File

@ -5,6 +5,7 @@ import pytest
from books.openlibrary import lookup_book_from_openlibrary
@pytest.mark.skip()
def test_lookup_modern_book():
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
assert book.get("title") == "Matrix"
@ -12,6 +13,7 @@ def test_lookup_modern_book():
assert book.get("ol_author_id") == "OL3675729A"
@pytest.mark.skip()
def test_lookup_classic_book():
book = lookup_book_from_openlibrary(
"The Life of Castruccio Castracani", "Machiavelli"
@ -21,6 +23,7 @@ def test_lookup_classic_book():
assert book.get("ol_author_id") == "OL23135A"
@pytest.mark.skip()
def test_lookup_foreign_book():
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
assert book.get("title") == "Ravage"

View File

@ -5,14 +5,14 @@ app_name = "books"
urlpatterns = [
path("book/", views.BookListView.as_view(), name="book_list"),
path("books/", views.BookListView.as_view(), name="book_list"),
path(
"book/<slug:slug>/",
"books/<slug:slug>/",
views.BookDetailView.as_view(),
name="book_detail",
),
path(
"author/<slug:slug>/",
"authors/<slug:slug>/",
views.AuthorDetailView.as_view(),
name="author_detail",
),

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bricksets", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="brickset",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -16,7 +16,9 @@ class BrickSet(LongPlayScrobblableMixin):
number = models.CharField(max_length=10, **BNULL)
release_year = models.IntegerField(**BNULL)
piece_count = models.IntegerField(**BNULL)
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL)
brickset_rating = models.DecimalField(
max_digits=3, decimal_places=1, **BNULL
)
lego_item_number = models.CharField(max_length=10, **BNULL)
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
box_image_small = ImageSpecField(
@ -48,13 +50,23 @@ class BrickSet(LongPlayScrobblableMixin):
def get_absolute_url(self):
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
def __str__(self) -> str:
name = str(self.title)
if not self.title:
name = str(self.number)
return name
@property
def logdata_cls(self):
return BrickSetLogData
@classmethod
def find_or_create(cls, title: str) -> "BrickSet":
return cls.objects.filter(title=title).first()
def find_or_create(cls, brickset_id: str) -> "BrickSet":
brickset = cls.objects.filter(number=brickset_id).first()
if not brickset:
# TODO: enrich this from the website
brickset = cls.objects.create(number=brickset_id)
return brickset
@property
def primary_image_url(self) -> str:

View File

@ -0,0 +1,28 @@
from foods.models import Food, FoodCategory
from django.contrib import admin
from scrobbles.admin import ScrobbleInline
class FoodInline(admin.TabularInline):
model = Food
extra = 0
@admin.register(FoodCategory)
class FoodCategoryAdmin(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(Food)
class FoodAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

View File

@ -0,0 +1,143 @@
import json
import logging
import urllib
from typing import Optional
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
ALLRECIPE_URL = "https://allrecipe.com/{id}"
def get_first(key: str, result: dict) -> str:
obj = ""
if obj_list := result.get(key):
obj = obj_list[0]
return obj
def get_title_from_soup(soup) -> str:
title = ""
try:
title = soup.find("h1").get_text()
except AttributeError:
pass
except ValueError:
pass
return title
def get_description_from_soup(soup) -> str:
desc = ""
try:
desc = (
soup.find(class_="beer-descrption-read-less")
.get_text()
.replace("Show Less", "")
.strip()
)
except AttributeError:
pass
except ValueError:
pass
return desc
def get_styles_from_soup(soup) -> list[str]:
styles = []
try:
styles = soup.find("p", class_="style").get_text().split(" - ")
except AttributeError:
pass
except ValueError:
pass
return styles
def get_abv_from_soup(soup) -> Optional[float]:
abv = None
try:
abv = soup.find(class_="abv").get_text()
if abv:
abv = float(abv.strip("\n").strip("% ABV").strip())
except AttributeError:
pass
except ValueError:
pass
except TypeError:
pass
return abv
def get_ibu_from_soup(soup) -> Optional[int]:
ibu = None
try:
ibu = soup.find(class_="ibu").get_text()
if ibu:
ibu = int(ibu.strip("\n").strip(" IBU").strip())
except AttributeError:
pass
except ValueError:
ibu = None
return ibu
def get_rating_from_soup(soup) -> str:
rating = ""
try:
rating = float(
soup.find(class_="num").get_text().strip("(").strip(")")
)
except AttributeError:
rating = None
except ValueError:
rating = None
return rating
def get_producer_id_from_soup(soup) -> str:
id = ""
try:
id = soup.find(class_="brewery").find("a")["href"].strip("/")
except ValueError:
pass
except IndexError:
pass
return id
def get_producer_name_from_soup(soup) -> str:
name = ""
try:
name = soup.find(class_="brewery").find("a").get_text()
except AttributeError:
pass
except ValueError:
pass
return name
def get_food_from_allrecipe_id(allrecipe_id: str) -> dict:
url = ALLRECIPE_URL.format(id=allrecipe_id)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(url, headers=headers)
food_dict = {"allrecipe_id": allrecipe_id}
if response.status_code != 200:
logger.warn(
"Bad response from allrecipe", extra={"response": response}
)
return food_dict
import pdb
pdb.set_trace()
soup = BeautifulSoup(response.text, "html.parser")
food_dict["title"] = get_title_from_soup(soup)
food_dict["description"] = get_description_from_soup(soup)
food_dict["allrecipe_rating"] = get_rating_from_soup(soup)
return food_dict

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class FoodsConfig(AppConfig):
name = "foods"

View File

@ -0,0 +1,151 @@
# Generated by Django 4.2.16 on 2024-11-24 14:45
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0066_scrobble_beer_alter_scrobble_media_type"),
]
operations = [
migrations.CreateModel(
name="FoodCategory",
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,
),
),
("name", models.CharField(max_length=255)),
(
"allrecipe_image",
models.ImageField(
blank=True, null=True, upload_to="food/recipe/"
),
),
(
"allrecipe_id",
models.CharField(blank=True, max_length=255, null=True),
),
("description", models.TextField(blank=True, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.CreateModel(
name="Food",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
"title",
models.CharField(blank=True, max_length=255, null=True),
),
(
"run_time_seconds",
models.IntegerField(blank=True, null=True),
),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
("description", models.TextField(blank=True, null=True)),
(
"allrecipe_image",
models.ImageField(
blank=True, null=True, upload_to="food/recipe/"
),
),
(
"allrecipe_id",
models.CharField(blank=True, max_length=255, null=True),
),
("allrecipe_rating", models.FloatField(blank=True, null=True)),
(
"category",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="foods.foodcategory",
),
),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("foods", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="food",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -0,0 +1,113 @@
from uuid import uuid4
from django.apps import apps
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import FoodLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
class FoodCategory(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
allrecipe_image_small = ImageSpecField(
source="recipe_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
allrecipe_image_medium = ImageSpecField(
source="recipe_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
allrecipe_id = models.CharField(max_length=255, **BNULL)
description = models.TextField(**BNULL)
def find_or_create(cls, title: str) -> "FoodCategory":
return cls.objects.filter(title=title).first()
def __str__(self):
return self.name
class Food(ScrobblableMixin):
description = models.TextField(**BNULL)
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
allrecipe_image_small = ImageSpecField(
source="allrecipe_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
allrecipe_image_medium = ImageSpecField(
source="allrecipe_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
allrecipe_id = models.CharField(max_length=255, **BNULL)
allrecipe_rating = models.FloatField(**BNULL)
category = models.ForeignKey(
FoodCategory, on_delete=models.DO_NOTHING, **BNULL
)
def get_absolute_url(self) -> str:
return reverse("foods:food_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self):
return self.category.name
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Eating", tags="food")
@property
def allrecipe_link(self) -> str:
link = ""
if self.producer and self.allrecipe_id:
if self.allrecipe_id:
link = f"https://www.allrecipe.com/{self.allrecipe_id}/"
return link
@property
def primary_image_url(self) -> str:
url = ""
if self.allrecipe_image:
url = self.allrecipe_image.url
return url
@property
def logdata_cls(self):
return FoodLogData
@classmethod
def find_or_create(cls, allrecipe_id: str) -> "Food":
food = cls.objects.filter(allrecipe_id=allrecipe_id).first()
if not food:
food_dict = get_food_from_allrecipe_id(allrecipe_id)
# category_dict = {}
# category, _created = FoodCategory.objects.get_or_create(
# **category_dict
# )
food = Food.objects.create(**food_dict)
# for category_id in category_ids:
# food.category.add(category_id)
return food
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, food=self).order_by(
"-timestamp"
)

View File

@ -0,0 +1,14 @@
from django.urls import path
from foods import views
app_name = "foods"
urlpatterns = [
path("foods/", views.FoodListView.as_view(), name="food_list"),
path(
"foods/<slug:slug>/",
views.FoodDetailView.as_view(),
name="food_detail",
),
]

View File

@ -0,0 +1,11 @@
from foods.models import Food
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class FoodListView(ScrobbleableListView):
model = Food
class FoodDetailView(ScrobbleableDetailView):
model = Food

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("lifeevents", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="lifeevent",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -1,16 +1,13 @@
from django.apps import apps
from django.db import models
from django.urls import reverse
import pendulum
from scrobbles.dataclasses import LifeEventLogData
from scrobbles.mixins import ScrobblableMixin
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
class LifeEvent(ScrobblableMixin):
COMPLETION_PERCENT = 100
description = models.TextField(**BNULL)
def __str__(self):
@ -25,6 +22,10 @@ class LifeEvent(ScrobblableMixin):
def logdata_cls(self):
return LifeEventLogData
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Experiencing", tags="camping")
@classmethod
def find_or_create(cls, title: str) -> "LifeEvent":
return cls.objects.filter(title=title).first()

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("locations", "0006_delete_rawgeolocation"),
]
operations = [
migrations.AlterField(
model_name="geolocation",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -8,7 +8,7 @@ from django.conf import settings
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@ -90,6 +90,12 @@ class GeoLocation(ScrobblableMixin):
return f"{self.lat} x {self.lon}"
return ""
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(
verb="Going", tags="world_map", priority="low"
)
def loc_diff(self, old_lat_lon: tuple) -> tuple:
return (
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moods", "0002_mood_image"),
]
operations = [
migrations.AlterField(
model_name="mood",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -5,7 +5,7 @@ from django.db import models
from django.urls import reverse
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.mixins import ScrobblableMixin
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
@ -38,13 +38,14 @@ class Mood(ScrobblableMixin):
def get_absolute_url(self):
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
def get_start_url(self):
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
@property
def subtitle(self) -> str:
return ""
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Feeling", tags="thinking")
@property
def logdata_cls(self):
return MoodLogData

View File

@ -5,9 +5,9 @@ app_name = "moods"
urlpatterns = [
path("mood/", views.MoodListView.as_view(), name="mood-list"),
path("moods/", views.MoodListView.as_view(), name="mood-list"),
path(
"mood/<slug:slug>/",
"moods/<slug:slug>/",
views.MoodDetailView.as_view(),
name="mood-detail",
),

View File

@ -16,6 +16,7 @@ class AlbumAdmin(admin.ModelAdmin):
"theaudiodb_mood",
"musicbrainz_id",
)
raw_id_fields = ("album_artist",)
list_filter = (
"theaudiodb_score",
"theaudiodb_genre",
@ -53,6 +54,10 @@ class TrackAdmin(admin.ModelAdmin):
"artist",
"musicbrainz_id",
)
raw_id_fields = (
"album",
"artist",
)
list_filter = ("album", "artist")
search_fields = ("title",)
ordering = ("-created",)

View File

@ -1,16 +1,10 @@
import logging
import time
from datetime import datetime, timedelta
import pylast
import pytz
from django.conf import settings
from django.utils import timezone
from music.utils import (
get_or_create_album,
get_or_create_artist,
get_or_create_track,
)
from music.models import Track
logger = logging.getLogger(__name__)
@ -49,20 +43,17 @@ class LastFM:
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for lfm_scrobble in lastfm_scrobbles:
timestamp = lfm_scrobble.pop("timestamp")
artist = get_or_create_artist(lfm_scrobble.pop("artist"))
album = get_or_create_album(lfm_scrobble.pop("album"), artist)
lfm_scrobble["artist"] = artist
if album:
lfm_scrobble["album"] = album
track = get_or_create_track(**lfm_scrobble)
track = Track.find_or_create(
title=lfm_scrobble.get("title"),
artist_name=lfm_scrobble.get("artist"),
album_name=lfm_scrobble.get("album"),
)
timezone = settings.TIME_ZONE
if self.vrobbler_user.profile:
timezone = self.vrobbler_user.profile.timezone
timestamp = lfm_scrobble.get("timestamp")
new_scrobble = Scrobble(
user=self.vrobbler_user,
timestamp=timestamp,
@ -88,9 +79,14 @@ class LastFM:
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
# TODO Add a notification for users that their import is complete
logger.info(
f"Created {len(created)} scrobbles",
extra={"created_scrobbles": created},
f"Last.fm import fnished",
extra={
"scrobbles_created": len(created),
"user_id": self.vrobbler_user,
"lastfm_user": self.user,
},
)
return created
@ -116,30 +112,43 @@ class LastFM:
mbid = None
artist = None
log_dict = {"scrobble": scrobble}
try:
run_time = int(scrobble.track.get_duration() / 1000)
mbid = scrobble.track.get_mbid()
artist = scrobble.track.get_artist().name
log_dict["artist"] = artist
log_dict["mbid"] = mbid
log_dict["run_time"] = run_time
except pylast.MalformedResponseError as e:
logger.warn(e)
logger.warning(e)
except pylast.WSError as e:
logger.warn(
"LastFM barfed trying to get the track for {scrobble.track}"
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
except pylast.NetworkError as e:
logger.warn(
"LastFM barfed trying to get the track for {scrobble.track}"
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
if not artist:
logger.warn(f"Silly LastFM, no artist found for {scrobble}")
logger.info(
f"Silly LastFM, no artist found for scrobble",
extra=log_dict,
)
continue
# TODO figure out if this will actually work
# timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
timestamp = datetime.utcfromtimestamp(
int(scrobble.timestamp)
).replace(tzinfo=pytz.utc)
logger.info(f"{artist},{scrobble.track.title},{timestamp}")
logger.info(
f"Scrobble appended to list for bulk create", extra=log_dict
)
scrobbles.append(
{
"artist": artist,

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0023_alter_track_genre"),
]
operations = [
migrations.AlterField(
model_name="track",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-04-07 00:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0024_alter_track_run_time_seconds"),
]
operations = [
migrations.AddField(
model_name="artist",
name="alt_names",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-04-07 00:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0025_artist_alt_names"),
]
operations = [
migrations.AddField(
model_name="album",
name="alt_names",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -1,13 +1,11 @@
import logging
from tempfile import NamedTemporaryFile
from typing import Dict, Optional
from urllib.request import urlopen
from uuid import uuid4
import musicbrainzngs
import requests
from django.conf import settings
from django.core.files.base import ContentFile, File
from django.core.files.base import ContentFile
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -16,14 +14,25 @@ from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
from music.bandcamp import get_bandcamp_slug
from music.musicbrainz import lookup_album_dict_from_mb, lookup_track_from_mb
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
from scrobbles.mixins import ScrobblableMixin
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
class Artist(TimeStampedModel):
"""Represents a music artist.
# Lookup or create by title alone
>>> Artist.find_or_create(name="Bon Iver")
# Lookup or create by MB id alone
>>> Artist.find_or_create(musicbrainz_id="0307edfc-437c-4b48-8700-80680e66a228")
"""
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
biography = models.TextField(**BNULL)
@ -46,6 +55,7 @@ class Artist(TimeStampedModel):
format="JPEG",
options={"quality": 75},
)
alt_names = models.TextField(**BNULL)
class Meta:
unique_together = [["name", "musicbrainz_id"]]
@ -62,8 +72,10 @@ class Artist(TimeStampedModel):
return ""
@property
def mb_link(self):
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
def mb_link(self) -> str:
if self.musicbrainz_id:
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
return ""
@property
def allmusic_link(self):
@ -104,7 +116,9 @@ class Artist(TimeStampedModel):
if not self.allmusic_id or force:
slug = get_allmusic_slug(self.name)
if not slug:
logger.info(f"No allmsuic link for {self}")
logger.info(
"No allmusic link found", extra={"track_id": self.id}
)
return
self.allmusic_id = slug
self.save(update_fields=["allmusic_id"])
@ -113,7 +127,9 @@ class Artist(TimeStampedModel):
if not self.bandcamp_id or force:
slug = get_bandcamp_slug(self.name)
if not slug:
logger.info(f"No bandcamp link for {self}")
logger.info(
"No bandcamp link found", extra={"track_id": self.id}
)
return
self.bandcamp_id = slug
self.save(update_fields=["bandcamp_id"])
@ -145,7 +161,7 @@ class Artist(TimeStampedModel):
@property
def rym_link(self):
artist_slug = self.name.lower().replace(" ", "-")
artist_slug = self.name.lower().replace(" ", "-").replace(",", "")
return f"https://rateyourmusic.com/artist/{artist_slug}/"
@property
@ -153,6 +169,61 @@ class Artist(TimeStampedModel):
artist = self.name.lower()
return f"https://bandcamp.com/search?q={artist}&item_type=b"
@classmethod
def find_or_create(cls, name: str, musicbrainz_id: str = "") -> "Artist":
from music.musicbrainz import lookup_artist_from_mb
from music.utils import clean_artist_name
if not name:
raise Exception("Must have name to lookup artist")
artist = None
name = clean_artist_name(name)
# Check for name/mbid combo, just mbid and then just name
if musicbrainz_id:
artist = cls.objects.filter(
name=name, musicbrainz_id=musicbrainz_id
).first()
if not artist:
artist = cls.objects.filter(musicbrainz_id=musicbrainz_id).first()
if not artist:
artist = cls.objects.filter(
models.Q(name=name) | models.Q(alt_names__icontains=name)
).first()
# Does not exist, look it up from Musicbrainz
if not artist:
alt_name = None
try:
artist_dict = lookup_artist_from_mb(name)
musicbrainz_id = musicbrainz_id or artist_dict.get("id", "")
if name != artist_dict.get("name", ""):
alt_name = name
name = artist_dict.get("name", "")
except ValueError:
pass
if musicbrainz_id:
artist = cls.objects.filter(
musicbrainz_id=musicbrainz_id
).first()
if artist and alt_name:
if not artist.alt_names:
artist.alt_names = alt_name
else:
artist.alt_names += f"\\{alt_name}"
artist.save(update_fields=["alt_names"])
if not artist:
artist = cls.objects.create(
name=name, musicbrainz_id=musicbrainz_id, alt_names=alt_name
)
# TODO maybe this should be spun off into an async task?
artist.fix_metadata()
return artist
class Album(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
@ -196,9 +267,10 @@ class Album(TimeStampedModel):
wikipedia_slug = models.CharField(max_length=255, **BNULL)
discogs_id = models.CharField(max_length=255, **BNULL)
wikidata_id = models.CharField(max_length=255, **BNULL)
alt_names = models.TextField(**BNULL)
def __str__(self):
return self.name
def __str__(self) -> str:
return "{} by {}".format(self.name, self.album_artist)
def get_absolute_url(self):
return reverse("music:album_detail", kwargs={"slug": self.uuid})
@ -235,8 +307,15 @@ class Album(TimeStampedModel):
self.save(update_fields=["album_artist"])
def scrape_allmusic(self, force=False) -> None:
if not self.name:
logger.warning(
"Album without a name cannot be scraped",
extra={"album_id": self.id},
)
return
if not self.allmusic_id or force:
slug = get_allmusic_slug(self.name, self.album_artist.name)
slug = get_allmusic_slug(self.album_artist.name, self.name)
if not slug:
logger.info(
f"No allmsuic link for {self} by {self.album_artist}"
@ -245,7 +324,9 @@ class Album(TimeStampedModel):
self.allmusic_id = slug
self.save(update_fields=["allmusic_id"])
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
allmusic_data = None
if self.allmusic_link:
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
if not allmusic_data:
logger.info(f"No allmsuic data for {self} by {self.album_artist}")
@ -402,6 +483,69 @@ class Album(TimeStampedModel):
album = self.name.lower()
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
@classmethod
def find_or_create(
cls, name: str, artist_name: str, musicbrainz_id: str = ""
) -> "Album":
if not name or not artist_name:
raise Exception(
"Must have at least name and artist name to lookup album"
)
album = None
if musicbrainz_id:
album = cls.objects.filter(
musicbrainz_id=musicbrainz_id,
name=name,
album_artist__name=artist_name,
).first()
if not album and musicbrainz_id:
album = cls.objects.filter(
musicbrainz_id=musicbrainz_id,
).first()
if not album:
album = cls.objects.filter(
models.Q(name=name) | models.Q(alt_names__icontains=name),
album_artist__name=artist_name,
).first()
if not album:
alt_name = None
try:
album_dict = lookup_album_dict_from_mb(
name, artist_name=artist_name
)
musicbrainz_id = musicbrainz_id or album_dict.get("mb_id", "")
found_name = album_dict.get("title", "")
if found_name and name != found_name:
alt_name = name
name = found_name
except ValueError:
pass
if musicbrainz_id:
album = cls.objects.filter(
musicbrainz_id=musicbrainz_id
).first()
if album and alt_name:
if not album.alt_names:
album.alt_names = alt_name
else:
album.alt_names += f"\\{alt_name}"
album.save(update_fields=["alt_names"])
if not album:
artist = Artist.find_or_create(name=artist_name)
album = cls.objects.create(
name=name,
album_artist=artist,
musicbrainz_id=musicbrainz_id,
alt_names=alt_name,
)
# TODO maybe do this in a separate process?
album.fix_metadata()
return album
class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
@ -425,8 +569,12 @@ class Track(ScrobblableMixin):
return reverse("music:track_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self):
return self.artist
def subtitle(self) -> str:
return str(self.artist)
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Listening", tags="notes")
@property
def mb_link(self):
@ -447,31 +595,85 @@ class Track(ScrobblableMixin):
@classmethod
def find_or_create(
cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
) -> Optional["Track"]:
"""Given a data dict from Jellyfin, does the heavy lifting of looking up
the video and, if need, TV Series, creating both if they don't yet
exist.
cls,
title: str = "",
musicbrainz_id: str = "",
album_name: str = "",
artist_name: str = "",
enrich: bool = True,
run_time_seconds: Optional[int] = None,
) -> "Track":
# TODO we can use Q to build queries here based on whether we have mbid and album name
track = None
# Full look up with MB ID
if album_name:
track = cls.objects.filter(
musicbrainz_id=musicbrainz_id,
title=title,
artist__name=artist_name,
album__name=album_name,
).first()
# Full look up without album
if not track:
track = cls.objects.filter(
musicbrainz_id=musicbrainz_id,
title=title,
artist__name=artist_name,
).first()
"""
if not artist_dict.get("name") or not artist_dict.get(
"musicbrainz_id"
):
logger.warning(
f"No artist or artist musicbrainz ID found in message from source, not scrobbling"
)
return
# Full look up without MB ID
if not track:
track = cls.objects.filter(
title=title,
artist__name=artist_name,
album__name=album_name,
).first()
# Base look up without MB ID or album
if not track:
track = cls.objects.filter(
title=title,
artist__name=artist_name,
).first()
artist, artist_created = Artist.objects.get_or_create(**artist_dict)
album, album_created = Album.objects.get_or_create(**album_dict)
if not track and enrich:
track_dict = lookup_track_from_mb(title, artist_name, album_name)
musicbrainz_id = musicbrainz_id or track_dict.get("id", "")
# TODO This only works some of the time
# try:
# album_name = album_name or track_dict.get("release-list")[
# 0
# ].get("title", "")
# except IndexError:
# pass
if not run_time_seconds:
run_time_seconds = int(
int(track_dict.get("length", 900000)) / 1000
)
if title != track_dict.get("name", "") and track_dict.get(
"name", False
):
album.fix_metadata()
if not album.cover_image:
album.fetch_artwork()
title = track_dict.get("name", "")
track_dict["album_id"] = getattr(album, "id", None)
track_dict["artist_id"] = artist.id
track, created = cls.objects.get_or_create(**track_dict)
if musicbrainz_id:
track = cls.objects.filter(
musicbrainz_id=musicbrainz_id
).first()
if not track:
artist = Artist.find_or_create(name=artist_name)
album = None
if album_name:
album = Album.find_or_create(
name=album_name, artist_name=artist_name
)
track = cls.objects.create(
title=title,
album=album,
musicbrainz_id=musicbrainz_id,
artist=artist,
run_time_seconds=run_time_seconds,
)
# TODO maybe do this in a separate process?
track.fix_metadata()
return track

View File

@ -16,9 +16,8 @@ logger = logging.getLogger(__name__)
from music.models import Album, Artist, Track
def get_or_create_artist(name: str, mbid: str = None) -> Artist:
artist = None
def clean_artist_name(name: str) -> str:
"""Remove featured names from artist string."""
if "feat." in name.lower():
name = re.split("feat.", name, flags=re.IGNORECASE)[0].strip()
if "featuring" in name.lower():
@ -26,18 +25,44 @@ def get_or_create_artist(name: str, mbid: str = None) -> Artist:
if "&" in name.lower():
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
artist_dict = lookup_artist_from_mb(name)
mbid = mbid or artist_dict.get("id", None)
return name
if mbid:
# TODO These are depreacted, remove them eventually
def get_or_create_artist(name: str, mbid: str = "") -> Artist:
"""Get an Artist object from the database.
Check if an artist with this name or Musicbrainz ID already exists.
Otherwise, go lookup artist data from Musicbrainz and create one.
"""
artist = None
name = clean_artist_name(name)
# Check for name/mbid combo, just mbid and then just name
artist = Artist.objects.filter(name=name, mbid=mbid).first()
if not artist:
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
if not artist:
artist = Artist.objects.filter(name=name).first()
# Does not exist, look it up from Musicbrainz
if not artist:
artist_dict = lookup_artist_from_mb(name)
mbid = mbid or artist_dict.get("id", "")
if mbid:
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
if not artist:
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
# TODO maybe this should be spun off into an async task?
artist.fix_metadata()
return artist
# TODO These are depreacted, remove them eventually
def get_or_create_album(
name: str, artist: Artist, mbid: str = None
) -> Optional[Album]:
@ -90,6 +115,7 @@ def get_or_create_album(
return album
# TODO These are depreacted, remove them eventually
def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
try:
track_run_time_seconds = int(
@ -107,15 +133,13 @@ def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
track_title = post_data.get(post_keys.get("TRACK_TITLE"), "")
track_mb_id = post_data.get(post_keys.get("TRACK_MB_ID"), "")
artist = get_or_create_artist(
artist_name,
mbid=artist_mb_id,
)
album = get_or_create_album(
album_title,
artist=artist,
mbid=album_mb_id,
)
artist = Artist.find_or_create(artist_name, artist_mb_id)
album = None
# We may get no album ID or title, in which case, skip
if album_mb_id or album_title:
album = Album.find_or_create(
album_title, str(artist.name), album_mb_id
)
track = None
if not track_mb_id and album:
@ -128,9 +152,19 @@ def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
except TypeError:
pass
if not track_title and not track_mb_id:
logger.info(
"Cannot find track without either title or MB ID",
extra={"post_data": post_data},
)
return
if track_mb_id:
track = Track.objects.filter(musicbrainz_id=track_mb_id).first()
if not track and track_title:
track = Track.objects.filter(title=track_title, artist=artist).first()
if not track:
track = Track.objects.create(
title=track_title,
@ -142,7 +176,7 @@ def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
return track
def get_or_create_various_artists():
def get_or_create_various_artists() -> Artist:
artist = Artist.objects.filter(name="Various Artists").first()
if not artist:
artist = Artist.objects.create(**VARIOUS_ARTIST_DICT)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("podcasts", "0013_rename_episode_podcastepisode"),
]
operations = [
migrations.AlterField(
model_name="podcastepisode",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

View File

@ -0,0 +1,40 @@
# Generated by Django 4.2.19 on 2025-04-07 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("podcasts", "0014_alter_podcastepisode_run_time_seconds"),
]
operations = [
migrations.RemoveField(
model_name="podcast",
name="google_podcasts_url",
),
migrations.AddField(
model_name="podcast",
name="dead_date",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="podcast",
name="itunes_id",
field=models.TextField(blank=True, max_length=15, null=True),
),
migrations.AddField(
model_name="podcast",
name="null",
field=models.CharField(
default="", max_length=150, verbose_name="blank"
),
preserve_default=False,
),
migrations.AddField(
model_name="podcast",
name="site_link",
field=models.URLField(blank=True, null=True),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.19 on 2025-04-07 17:18
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0068_scrobble_paper_alter_scrobble_media_type"),
(
"podcasts",
"0015_remove_podcast_google_podcasts_url_podcast_dead_date_and_more",
),
]
operations = [
migrations.AddField(
model_name="podcast",
name="genre",
field=taggit.managers.TaggableManager(
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-04-07 17:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("podcasts", "0016_podcast_genre"),
]
operations = [
migrations.AddField(
model_name="podcast",
name="podcastindex_id",
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@ -1,5 +1,4 @@
import logging
from typing import Dict, Optional
from uuid import uuid4
import requests
@ -10,8 +9,13 @@ from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from podcasts.scrapers import scrape_data_from_google_podcasts
from scrobbles.mixins import ScrobblableMixin
from podcasts.sources.podcastindex import lookup_podcast_from_podcastindex
from scrobbles.mixins import (
ObjectWithGenres,
ScrobblableConstants,
ScrobblableMixin,
)
from taggit.managers import TaggableManager
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@ -24,6 +28,13 @@ class Producer(TimeStampedModel):
def __str__(self):
return f"{self.name}"
@classmethod
def find_or_create(cls, name):
producer = cls.objects.filter(name__iexact=name).first()
if not producer:
producer = cls.objects.create(name=name)
return producer
class Podcast(TimeStampedModel):
name = models.CharField(max_length=255)
@ -31,11 +42,17 @@ class Podcast(TimeStampedModel):
producer = models.ForeignKey(
Producer, on_delete=models.DO_NOTHING, **BNULL
)
podcastindex_id = models.CharField(max_length=100, **BNULL)
owner = models.CharField(max_length=150, *BNULL)
description = models.TextField(**BNULL)
active = models.BooleanField(default=True)
feed_url = models.URLField(**BNULL)
google_podcasts_url = models.URLField(**BNULL)
site_link = models.URLField(**BNULL)
description = models.TextField(**BNULL)
cover_image = models.ImageField(upload_to="podcasts/covers/", **BNULL)
itunes_id = models.TextField(max_length=15, **BNULL)
dead_date = models.DateField(**BNULL)
genre = TaggableManager(through=ObjectWithGenres)
def __str__(self):
return f"{self.name}"
@ -49,32 +66,43 @@ class Podcast(TimeStampedModel):
user=user, podcast_episode__podcast=self
).order_by("-timestamp")
def scrape_google_podcasts(self, force=False):
podcast_dict = {}
if not self.cover_image or force:
podcast_dict = scrape_data_from_google_podcasts(self.name)
if podcast_dict:
if not self.producer:
self.producer, created = Producer.objects.get_or_create(
name=podcast_dict["producer"]
)
self.description = podcast_dict.get("description")
self.google_podcasts_url = podcast_dict.get("google_url")
self.save(
update_fields=[
"description",
"producer",
"google_podcasts_url",
]
)
@property
def itunes_link(self) -> str:
if not self.itunes_id:
return ""
return f"https://podcasts.apple.com/us/podcast/id{self.itunes_id}"
def fix_metadata(self, force=False):
if self.podcastindex_id and not force:
logger.warning(
"Podcast already has PodcastIndex ID, use force=True to overwrite"
)
return
podcast_dict = lookup_podcast_from_podcastindex(self.name)
if not podcast_dict:
logger.info(
"No podcast data found from PodcastIndex. Are credentials setup?"
)
return
genres = podcast_dict.pop("genres")
if genres:
self.genre.add(*genres)
cover_url = podcast_dict.pop("image_url")
cover_url = podcast_dict.get("image_url")
if (not self.cover_image or force) and cover_url:
r = requests.get(cover_url)
if r.status_code == 200:
fname = f"{self.name}_{self.uuid}.jpg"
self.cover_image.save(fname, ContentFile(r.content), save=True)
for attr, value in podcast_dict.items():
setattr(self, attr, value)
self.save()
class PodcastEpisode(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "PODCAST_COMPLETION_PERCENT", 90)
@ -84,6 +112,11 @@ class PodcastEpisode(ScrobblableMixin):
pub_date = models.DateField(**BNULL)
mopidy_uri = models.CharField(max_length=255, **BNULL)
def get_absolute_url(self):
return reverse(
"podcasts:podcast_detail", kwargs={"slug": self.podcast.uuid}
)
def __str__(self):
return f"{self.title}"
@ -91,6 +124,10 @@ class PodcastEpisode(ScrobblableMixin):
def subtitle(self):
return self.podcast
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Listening", tags="microphone")
@property
def info_link(self):
return ""
@ -104,42 +141,45 @@ class PodcastEpisode(ScrobblableMixin):
@classmethod
def find_or_create(
cls, podcast_dict: Dict, producer_dict: Dict, episode_dict: Dict
) -> Optional["Episode"]:
cls,
title: str,
podcast_name: str,
pub_date: str,
number: int = 0,
mopidy_uri: str = "",
producer_name: str = "",
run_time_seconds: int = 1800,
enrich: bool = True,
) -> "PodcastEpisode":
"""Given a data dict from Mopidy, finds or creates a podcast and
producer before saving the epsiode so it can be scrobbled.
"""
if not podcast_dict.get("name"):
logger.warning(f"No name from source for podcast, not scrobbling")
return
producer = None
if producer_dict.get("name"):
producer, producer_created = Producer.objects.get_or_create(
**producer_dict
if producer_name:
producer = Producer.find_or_create(producer_name)
podcast = Podcast.objects.filter(
name__iexact=podcast_name,
).first()
if not podcast:
podcast = Podcast.objects.create(
name=podcast_name, producer=producer
)
if producer_created:
logger.debug(f"Created new producer {producer}")
else:
logger.debug(f"Found producer {producer}")
if enrich:
podcast.fix_metadata()
if producer:
podcast_dict["producer_id"] = producer.id
podcast, podcast_created = Podcast.objects.get_or_create(
**podcast_dict
)
if podcast_created:
logger.debug(f"Created new podcast {podcast}")
else:
logger.debug(f"Found podcast {podcast}")
episode_dict["podcast_id"] = podcast.id
episode, created = cls.objects.get_or_create(**episode_dict)
if created:
logger.debug(f"Created new episode: {episode}")
else:
logger.debug(f"Found episode {episode}")
episode = cls.objects.filter(
title__iexact=title, podcast=podcast
).first()
if not episode:
episode = cls.objects.create(
title=title,
podcast=podcast,
run_time_seconds=run_time_seconds,
number=number,
pub_date=pub_date,
mopidy_uri=mopidy_uri,
)
return episode

View File

@ -0,0 +1,75 @@
import hashlib
import time
import pytz
import requests
from django.conf import settings
from django.utils import timezone
from scrobbles.utils import timestamp_user_tz_to_utc
PODCASTINDEX_API_KEY = getattr(settings, "PODCASTINDEX_API_KEY")
PODCASTINDEX_API_SECRET = getattr(settings, "PODCASTINDEX_API_SECRET")
def get_auth_headers():
now = int(time.time())
hash_data = hashlib.sha1(
(PODCASTINDEX_API_KEY + PODCASTINDEX_API_SECRET + str(now)).encode(
"utf-8"
)
).hexdigest()
return {
"User-Agent": "MyPodcastApp/1.0",
"X-Auth-Date": str(now),
"X-Auth-Key": PODCASTINDEX_API_KEY,
"Authorization": hash_data,
"Content-Type": "application/json",
}
def lookup_podcast_from_podcastindex(
podcast_name: str, dump_raw_response: bool = False
) -> dict:
url = "https://api.podcastindex.org/api/1.0/search/byterm"
headers = get_auth_headers()
params = {"q": podcast_name}
response = requests.get(url, headers=headers, params=params)
if response.status_code == 200:
data = response.json()
if dump_raw_response:
return data.get("feeds")
if data.get("feeds"):
try:
top_feed_dict = data["feeds"][0]
newest_episode_date = timestamp_user_tz_to_utc(
top_feed_dict.get("newestItemPubdate"), pytz.UTC
)
days_since_last_episode = ()
dead_date = None
if (timezone.now() - newest_episode_date).days > 180:
dead_date = newest_episode_date
return {
"podcastindex_id": top_feed_dict.get("id"),
"title": top_feed_dict.get("title"),
"site_link": top_feed_dict.get("link"),
"description": top_feed_dict.get("description"),
"owner": top_feed_dict.get("ownerName"),
"image_url": top_feed_dict.get("artwork"),
"feed_url": top_feed_dict.get("url"),
"itunes_id": top_feed_dict.get("itunesId"),
"genres": list(top_feed_dict.get("categories").values()),
"dead_date": dead_date,
}
except IndexError:
return {}
else:
print("No podcasts found.")
return {}
else:
print("Failed to fetch data:", response.status_code, response.text)
return {}

View File

@ -12,4 +12,6 @@ class UserProfileAdmin(admin.ModelAdmin):
"twitch_client_secret",
"lastfm_password",
"archivebox_password",
"todoist_auth_key",
"todoist_state",
)

View File

@ -0,0 +1,44 @@
from django import forms
from profiles.models import UserProfile
class UserProfileForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request")
self.profile = UserProfile.objects.filter(
user=self.request.user
).first()
if not self.profile:
raise Exception
super(UserProfileForm, self).__init__(
*args, **kwargs, instance=self.profile
)
class Meta:
model = UserProfile
fields = [
"timezone",
"lastfm_username",
"lastfm_password",
"lastfm_auto_import",
"retroarch_path",
"retroarch_auto_import",
"archivebox_username",
"archivebox_password",
"archivebox_url",
"bgg_username",
"lichess_username",
"webdav_url",
"webdav_user",
"webdav_pass",
"webdav_auto_import",
"ntfy_url",
"ntfy_enabled",
"redirect_to_webpage",
]
widgets = {
"lastfm_password": forms.PasswordInput(render_value=True),
"archivebox_password": forms.PasswordInput(render_value=True),
"webdav_pass": forms.PasswordInput(render_value=True),
}

View File

@ -0,0 +1,601 @@
# Generated by Django 4.2.16 on 2024-09-09 16:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0015_userprofile_bgg_username"),
]
operations = [
migrations.AlterField(
model_name="userprofile",
name="timezone",
field=models.CharField(
choices=[
("Pacific/Midway", "(GMT-1100) Pacific/Midway"),
("Pacific/Niue", "(GMT-1100) Pacific/Niue"),
("Pacific/Pago_Pago", "(GMT-1100) Pacific/Pago_Pago"),
("Pacific/Honolulu", "(GMT-1000) Pacific/Honolulu"),
("Pacific/Rarotonga", "(GMT-1000) Pacific/Rarotonga"),
("Pacific/Tahiti", "(GMT-1000) Pacific/Tahiti"),
("US/Hawaii", "(GMT-1000) US/Hawaii"),
("Pacific/Marquesas", "(GMT-0930) Pacific/Marquesas"),
("America/Adak", "(GMT-0900) America/Adak"),
("Pacific/Gambier", "(GMT-0900) Pacific/Gambier"),
("America/Anchorage", "(GMT-0800) America/Anchorage"),
("America/Juneau", "(GMT-0800) America/Juneau"),
("America/Metlakatla", "(GMT-0800) America/Metlakatla"),
("America/Nome", "(GMT-0800) America/Nome"),
("America/Sitka", "(GMT-0800) America/Sitka"),
("America/Yakutat", "(GMT-0800) America/Yakutat"),
("Pacific/Pitcairn", "(GMT-0800) Pacific/Pitcairn"),
("US/Alaska", "(GMT-0800) US/Alaska"),
("America/Creston", "(GMT-0700) America/Creston"),
("America/Dawson", "(GMT-0700) America/Dawson"),
(
"America/Dawson_Creek",
"(GMT-0700) America/Dawson_Creek",
),
("America/Fort_Nelson", "(GMT-0700) America/Fort_Nelson"),
("America/Hermosillo", "(GMT-0700) America/Hermosillo"),
("America/Los_Angeles", "(GMT-0700) America/Los_Angeles"),
("America/Mazatlan", "(GMT-0700) America/Mazatlan"),
("America/Phoenix", "(GMT-0700) America/Phoenix"),
("America/Tijuana", "(GMT-0700) America/Tijuana"),
("America/Vancouver", "(GMT-0700) America/Vancouver"),
("America/Whitehorse", "(GMT-0700) America/Whitehorse"),
("Canada/Pacific", "(GMT-0700) Canada/Pacific"),
("US/Arizona", "(GMT-0700) US/Arizona"),
("US/Pacific", "(GMT-0700) US/Pacific"),
(
"America/Bahia_Banderas",
"(GMT-0600) America/Bahia_Banderas",
),
("America/Belize", "(GMT-0600) America/Belize"),
("America/Boise", "(GMT-0600) America/Boise"),
(
"America/Cambridge_Bay",
"(GMT-0600) America/Cambridge_Bay",
),
("America/Chihuahua", "(GMT-0600) America/Chihuahua"),
(
"America/Ciudad_Juarez",
"(GMT-0600) America/Ciudad_Juarez",
),
("America/Costa_Rica", "(GMT-0600) America/Costa_Rica"),
("America/Denver", "(GMT-0600) America/Denver"),
("America/Edmonton", "(GMT-0600) America/Edmonton"),
("America/El_Salvador", "(GMT-0600) America/El_Salvador"),
("America/Guatemala", "(GMT-0600) America/Guatemala"),
("America/Inuvik", "(GMT-0600) America/Inuvik"),
("America/Managua", "(GMT-0600) America/Managua"),
("America/Merida", "(GMT-0600) America/Merida"),
("America/Mexico_City", "(GMT-0600) America/Mexico_City"),
("America/Monterrey", "(GMT-0600) America/Monterrey"),
("America/Regina", "(GMT-0600) America/Regina"),
(
"America/Swift_Current",
"(GMT-0600) America/Swift_Current",
),
("America/Tegucigalpa", "(GMT-0600) America/Tegucigalpa"),
("America/Yellowknife", "(GMT-0600) America/Yellowknife"),
("Canada/Mountain", "(GMT-0600) Canada/Mountain"),
("Pacific/Galapagos", "(GMT-0600) Pacific/Galapagos"),
("US/Mountain", "(GMT-0600) US/Mountain"),
("America/Atikokan", "(GMT-0500) America/Atikokan"),
("America/Bogota", "(GMT-0500) America/Bogota"),
("America/Cancun", "(GMT-0500) America/Cancun"),
("America/Cayman", "(GMT-0500) America/Cayman"),
("America/Chicago", "(GMT-0500) America/Chicago"),
("America/Eirunepe", "(GMT-0500) America/Eirunepe"),
("America/Guayaquil", "(GMT-0500) America/Guayaquil"),
(
"America/Indiana/Knox",
"(GMT-0500) America/Indiana/Knox",
),
(
"America/Indiana/Tell_City",
"(GMT-0500) America/Indiana/Tell_City",
),
("America/Jamaica", "(GMT-0500) America/Jamaica"),
("America/Lima", "(GMT-0500) America/Lima"),
("America/Matamoros", "(GMT-0500) America/Matamoros"),
("America/Menominee", "(GMT-0500) America/Menominee"),
(
"America/North_Dakota/Beulah",
"(GMT-0500) America/North_Dakota/Beulah",
),
(
"America/North_Dakota/Center",
"(GMT-0500) America/North_Dakota/Center",
),
(
"America/North_Dakota/New_Salem",
"(GMT-0500) America/North_Dakota/New_Salem",
),
("America/Ojinaga", "(GMT-0500) America/Ojinaga"),
("America/Panama", "(GMT-0500) America/Panama"),
(
"America/Rankin_Inlet",
"(GMT-0500) America/Rankin_Inlet",
),
("America/Resolute", "(GMT-0500) America/Resolute"),
("America/Rio_Branco", "(GMT-0500) America/Rio_Branco"),
("America/Winnipeg", "(GMT-0500) America/Winnipeg"),
("Canada/Central", "(GMT-0500) Canada/Central"),
("Pacific/Easter", "(GMT-0500) Pacific/Easter"),
("US/Central", "(GMT-0500) US/Central"),
("America/Anguilla", "(GMT-0400) America/Anguilla"),
("America/Antigua", "(GMT-0400) America/Antigua"),
("America/Aruba", "(GMT-0400) America/Aruba"),
("America/Asuncion", "(GMT-0400) America/Asuncion"),
("America/Barbados", "(GMT-0400) America/Barbados"),
(
"America/Blanc-Sablon",
"(GMT-0400) America/Blanc-Sablon",
),
("America/Boa_Vista", "(GMT-0400) America/Boa_Vista"),
(
"America/Campo_Grande",
"(GMT-0400) America/Campo_Grande",
),
("America/Caracas", "(GMT-0400) America/Caracas"),
("America/Cuiaba", "(GMT-0400) America/Cuiaba"),
("America/Curacao", "(GMT-0400) America/Curacao"),
("America/Detroit", "(GMT-0400) America/Detroit"),
("America/Dominica", "(GMT-0400) America/Dominica"),
("America/Grand_Turk", "(GMT-0400) America/Grand_Turk"),
("America/Grenada", "(GMT-0400) America/Grenada"),
("America/Guadeloupe", "(GMT-0400) America/Guadeloupe"),
("America/Guyana", "(GMT-0400) America/Guyana"),
("America/Havana", "(GMT-0400) America/Havana"),
(
"America/Indiana/Indianapolis",
"(GMT-0400) America/Indiana/Indianapolis",
),
(
"America/Indiana/Marengo",
"(GMT-0400) America/Indiana/Marengo",
),
(
"America/Indiana/Petersburg",
"(GMT-0400) America/Indiana/Petersburg",
),
(
"America/Indiana/Vevay",
"(GMT-0400) America/Indiana/Vevay",
),
(
"America/Indiana/Vincennes",
"(GMT-0400) America/Indiana/Vincennes",
),
(
"America/Indiana/Winamac",
"(GMT-0400) America/Indiana/Winamac",
),
("America/Iqaluit", "(GMT-0400) America/Iqaluit"),
(
"America/Kentucky/Louisville",
"(GMT-0400) America/Kentucky/Louisville",
),
(
"America/Kentucky/Monticello",
"(GMT-0400) America/Kentucky/Monticello",
),
("America/Kralendijk", "(GMT-0400) America/Kralendijk"),
("America/La_Paz", "(GMT-0400) America/La_Paz"),
(
"America/Lower_Princes",
"(GMT-0400) America/Lower_Princes",
),
("America/Manaus", "(GMT-0400) America/Manaus"),
("America/Marigot", "(GMT-0400) America/Marigot"),
("America/Martinique", "(GMT-0400) America/Martinique"),
("America/Montserrat", "(GMT-0400) America/Montserrat"),
("America/Nassau", "(GMT-0400) America/Nassau"),
("America/New_York", "(GMT-0400) America/New_York"),
(
"America/Port-au-Prince",
"(GMT-0400) America/Port-au-Prince",
),
(
"America/Port_of_Spain",
"(GMT-0400) America/Port_of_Spain",
),
("America/Porto_Velho", "(GMT-0400) America/Porto_Velho"),
("America/Puerto_Rico", "(GMT-0400) America/Puerto_Rico"),
(
"America/Santo_Domingo",
"(GMT-0400) America/Santo_Domingo",
),
(
"America/St_Barthelemy",
"(GMT-0400) America/St_Barthelemy",
),
("America/St_Kitts", "(GMT-0400) America/St_Kitts"),
("America/St_Lucia", "(GMT-0400) America/St_Lucia"),
("America/St_Thomas", "(GMT-0400) America/St_Thomas"),
("America/St_Vincent", "(GMT-0400) America/St_Vincent"),
("America/Toronto", "(GMT-0400) America/Toronto"),
("America/Tortola", "(GMT-0400) America/Tortola"),
("Canada/Eastern", "(GMT-0400) Canada/Eastern"),
("US/Eastern", "(GMT-0400) US/Eastern"),
("America/Araguaina", "(GMT-0300) America/Araguaina"),
(
"America/Argentina/Buenos_Aires",
"(GMT-0300) America/Argentina/Buenos_Aires",
),
(
"America/Argentina/Catamarca",
"(GMT-0300) America/Argentina/Catamarca",
),
(
"America/Argentina/Cordoba",
"(GMT-0300) America/Argentina/Cordoba",
),
(
"America/Argentina/Jujuy",
"(GMT-0300) America/Argentina/Jujuy",
),
(
"America/Argentina/La_Rioja",
"(GMT-0300) America/Argentina/La_Rioja",
),
(
"America/Argentina/Mendoza",
"(GMT-0300) America/Argentina/Mendoza",
),
(
"America/Argentina/Rio_Gallegos",
"(GMT-0300) America/Argentina/Rio_Gallegos",
),
(
"America/Argentina/Salta",
"(GMT-0300) America/Argentina/Salta",
),
(
"America/Argentina/San_Juan",
"(GMT-0300) America/Argentina/San_Juan",
),
(
"America/Argentina/San_Luis",
"(GMT-0300) America/Argentina/San_Luis",
),
(
"America/Argentina/Tucuman",
"(GMT-0300) America/Argentina/Tucuman",
),
(
"America/Argentina/Ushuaia",
"(GMT-0300) America/Argentina/Ushuaia",
),
("America/Bahia", "(GMT-0300) America/Bahia"),
("America/Belem", "(GMT-0300) America/Belem"),
("America/Cayenne", "(GMT-0300) America/Cayenne"),
("America/Fortaleza", "(GMT-0300) America/Fortaleza"),
("America/Glace_Bay", "(GMT-0300) America/Glace_Bay"),
("America/Goose_Bay", "(GMT-0300) America/Goose_Bay"),
("America/Halifax", "(GMT-0300) America/Halifax"),
("America/Maceio", "(GMT-0300) America/Maceio"),
("America/Moncton", "(GMT-0300) America/Moncton"),
("America/Montevideo", "(GMT-0300) America/Montevideo"),
("America/Paramaribo", "(GMT-0300) America/Paramaribo"),
(
"America/Punta_Arenas",
"(GMT-0300) America/Punta_Arenas",
),
("America/Recife", "(GMT-0300) America/Recife"),
("America/Santarem", "(GMT-0300) America/Santarem"),
("America/Santiago", "(GMT-0300) America/Santiago"),
("America/Sao_Paulo", "(GMT-0300) America/Sao_Paulo"),
("America/Thule", "(GMT-0300) America/Thule"),
("Antarctica/Palmer", "(GMT-0300) Antarctica/Palmer"),
("Antarctica/Rothera", "(GMT-0300) Antarctica/Rothera"),
("Atlantic/Bermuda", "(GMT-0300) Atlantic/Bermuda"),
("Atlantic/Stanley", "(GMT-0300) Atlantic/Stanley"),
("Canada/Atlantic", "(GMT-0300) Canada/Atlantic"),
("America/St_Johns", "(GMT-0230) America/St_Johns"),
("Canada/Newfoundland", "(GMT-0230) Canada/Newfoundland"),
("America/Miquelon", "(GMT-0200) America/Miquelon"),
("America/Noronha", "(GMT-0200) America/Noronha"),
("America/Nuuk", "(GMT-0200) America/Nuuk"),
(
"Atlantic/South_Georgia",
"(GMT-0200) Atlantic/South_Georgia",
),
("Atlantic/Cape_Verde", "(GMT-0100) Atlantic/Cape_Verde"),
("Africa/Abidjan", "(GMT+0000) Africa/Abidjan"),
("Africa/Accra", "(GMT+0000) Africa/Accra"),
("Africa/Bamako", "(GMT+0000) Africa/Bamako"),
("Africa/Banjul", "(GMT+0000) Africa/Banjul"),
("Africa/Bissau", "(GMT+0000) Africa/Bissau"),
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
("Africa/Freetown", "(GMT+0000) Africa/Freetown"),
("Africa/Lome", "(GMT+0000) Africa/Lome"),
("Africa/Monrovia", "(GMT+0000) Africa/Monrovia"),
("Africa/Nouakchott", "(GMT+0000) Africa/Nouakchott"),
("Africa/Ouagadougou", "(GMT+0000) Africa/Ouagadougou"),
("Africa/Sao_Tome", "(GMT+0000) Africa/Sao_Tome"),
(
"America/Danmarkshavn",
"(GMT+0000) America/Danmarkshavn",
),
(
"America/Scoresbysund",
"(GMT+0000) America/Scoresbysund",
),
("Atlantic/Azores", "(GMT+0000) Atlantic/Azores"),
("Atlantic/Reykjavik", "(GMT+0000) Atlantic/Reykjavik"),
("Atlantic/St_Helena", "(GMT+0000) Atlantic/St_Helena"),
("GMT", "(GMT+0000) GMT"),
("UTC", "(GMT+0000) UTC"),
("Africa/Algiers", "(GMT+0100) Africa/Algiers"),
("Africa/Bangui", "(GMT+0100) Africa/Bangui"),
("Africa/Brazzaville", "(GMT+0100) Africa/Brazzaville"),
("Africa/Casablanca", "(GMT+0100) Africa/Casablanca"),
("Africa/Douala", "(GMT+0100) Africa/Douala"),
("Africa/El_Aaiun", "(GMT+0100) Africa/El_Aaiun"),
("Africa/Kinshasa", "(GMT+0100) Africa/Kinshasa"),
("Africa/Lagos", "(GMT+0100) Africa/Lagos"),
("Africa/Libreville", "(GMT+0100) Africa/Libreville"),
("Africa/Luanda", "(GMT+0100) Africa/Luanda"),
("Africa/Malabo", "(GMT+0100) Africa/Malabo"),
("Africa/Ndjamena", "(GMT+0100) Africa/Ndjamena"),
("Africa/Niamey", "(GMT+0100) Africa/Niamey"),
("Africa/Porto-Novo", "(GMT+0100) Africa/Porto-Novo"),
("Africa/Tunis", "(GMT+0100) Africa/Tunis"),
("Atlantic/Canary", "(GMT+0100) Atlantic/Canary"),
("Atlantic/Faroe", "(GMT+0100) Atlantic/Faroe"),
("Atlantic/Madeira", "(GMT+0100) Atlantic/Madeira"),
("Europe/Dublin", "(GMT+0100) Europe/Dublin"),
("Europe/Guernsey", "(GMT+0100) Europe/Guernsey"),
("Europe/Isle_of_Man", "(GMT+0100) Europe/Isle_of_Man"),
("Europe/Jersey", "(GMT+0100) Europe/Jersey"),
("Europe/Lisbon", "(GMT+0100) Europe/Lisbon"),
("Europe/London", "(GMT+0100) Europe/London"),
("Africa/Blantyre", "(GMT+0200) Africa/Blantyre"),
("Africa/Bujumbura", "(GMT+0200) Africa/Bujumbura"),
("Africa/Cairo", "(GMT+0200) Africa/Cairo"),
("Africa/Ceuta", "(GMT+0200) Africa/Ceuta"),
("Africa/Gaborone", "(GMT+0200) Africa/Gaborone"),
("Africa/Harare", "(GMT+0200) Africa/Harare"),
("Africa/Johannesburg", "(GMT+0200) Africa/Johannesburg"),
("Africa/Juba", "(GMT+0200) Africa/Juba"),
("Africa/Khartoum", "(GMT+0200) Africa/Khartoum"),
("Africa/Kigali", "(GMT+0200) Africa/Kigali"),
("Africa/Lubumbashi", "(GMT+0200) Africa/Lubumbashi"),
("Africa/Lusaka", "(GMT+0200) Africa/Lusaka"),
("Africa/Maputo", "(GMT+0200) Africa/Maputo"),
("Africa/Maseru", "(GMT+0200) Africa/Maseru"),
("Africa/Mbabane", "(GMT+0200) Africa/Mbabane"),
("Africa/Tripoli", "(GMT+0200) Africa/Tripoli"),
("Africa/Windhoek", "(GMT+0200) Africa/Windhoek"),
("Antarctica/Troll", "(GMT+0200) Antarctica/Troll"),
("Arctic/Longyearbyen", "(GMT+0200) Arctic/Longyearbyen"),
("Europe/Amsterdam", "(GMT+0200) Europe/Amsterdam"),
("Europe/Andorra", "(GMT+0200) Europe/Andorra"),
("Europe/Belgrade", "(GMT+0200) Europe/Belgrade"),
("Europe/Berlin", "(GMT+0200) Europe/Berlin"),
("Europe/Bratislava", "(GMT+0200) Europe/Bratislava"),
("Europe/Brussels", "(GMT+0200) Europe/Brussels"),
("Europe/Budapest", "(GMT+0200) Europe/Budapest"),
("Europe/Busingen", "(GMT+0200) Europe/Busingen"),
("Europe/Copenhagen", "(GMT+0200) Europe/Copenhagen"),
("Europe/Gibraltar", "(GMT+0200) Europe/Gibraltar"),
("Europe/Kaliningrad", "(GMT+0200) Europe/Kaliningrad"),
("Europe/Ljubljana", "(GMT+0200) Europe/Ljubljana"),
("Europe/Luxembourg", "(GMT+0200) Europe/Luxembourg"),
("Europe/Madrid", "(GMT+0200) Europe/Madrid"),
("Europe/Malta", "(GMT+0200) Europe/Malta"),
("Europe/Monaco", "(GMT+0200) Europe/Monaco"),
("Europe/Oslo", "(GMT+0200) Europe/Oslo"),
("Europe/Paris", "(GMT+0200) Europe/Paris"),
("Europe/Podgorica", "(GMT+0200) Europe/Podgorica"),
("Europe/Prague", "(GMT+0200) Europe/Prague"),
("Europe/Rome", "(GMT+0200) Europe/Rome"),
("Europe/San_Marino", "(GMT+0200) Europe/San_Marino"),
("Europe/Sarajevo", "(GMT+0200) Europe/Sarajevo"),
("Europe/Skopje", "(GMT+0200) Europe/Skopje"),
("Europe/Stockholm", "(GMT+0200) Europe/Stockholm"),
("Europe/Tirane", "(GMT+0200) Europe/Tirane"),
("Europe/Vaduz", "(GMT+0200) Europe/Vaduz"),
("Europe/Vatican", "(GMT+0200) Europe/Vatican"),
("Europe/Vienna", "(GMT+0200) Europe/Vienna"),
("Europe/Warsaw", "(GMT+0200) Europe/Warsaw"),
("Europe/Zagreb", "(GMT+0200) Europe/Zagreb"),
("Europe/Zurich", "(GMT+0200) Europe/Zurich"),
("Africa/Addis_Ababa", "(GMT+0300) Africa/Addis_Ababa"),
("Africa/Asmara", "(GMT+0300) Africa/Asmara"),
(
"Africa/Dar_es_Salaam",
"(GMT+0300) Africa/Dar_es_Salaam",
),
("Africa/Djibouti", "(GMT+0300) Africa/Djibouti"),
("Africa/Kampala", "(GMT+0300) Africa/Kampala"),
("Africa/Mogadishu", "(GMT+0300) Africa/Mogadishu"),
("Africa/Nairobi", "(GMT+0300) Africa/Nairobi"),
("Antarctica/Syowa", "(GMT+0300) Antarctica/Syowa"),
("Asia/Aden", "(GMT+0300) Asia/Aden"),
("Asia/Amman", "(GMT+0300) Asia/Amman"),
("Asia/Baghdad", "(GMT+0300) Asia/Baghdad"),
("Asia/Bahrain", "(GMT+0300) Asia/Bahrain"),
("Asia/Beirut", "(GMT+0300) Asia/Beirut"),
("Asia/Damascus", "(GMT+0300) Asia/Damascus"),
("Asia/Famagusta", "(GMT+0300) Asia/Famagusta"),
("Asia/Gaza", "(GMT+0300) Asia/Gaza"),
("Asia/Hebron", "(GMT+0300) Asia/Hebron"),
("Asia/Jerusalem", "(GMT+0300) Asia/Jerusalem"),
("Asia/Kuwait", "(GMT+0300) Asia/Kuwait"),
("Asia/Nicosia", "(GMT+0300) Asia/Nicosia"),
("Asia/Qatar", "(GMT+0300) Asia/Qatar"),
("Asia/Riyadh", "(GMT+0300) Asia/Riyadh"),
("Europe/Athens", "(GMT+0300) Europe/Athens"),
("Europe/Bucharest", "(GMT+0300) Europe/Bucharest"),
("Europe/Chisinau", "(GMT+0300) Europe/Chisinau"),
("Europe/Helsinki", "(GMT+0300) Europe/Helsinki"),
("Europe/Istanbul", "(GMT+0300) Europe/Istanbul"),
("Europe/Kirov", "(GMT+0300) Europe/Kirov"),
("Europe/Kyiv", "(GMT+0300) Europe/Kyiv"),
("Europe/Mariehamn", "(GMT+0300) Europe/Mariehamn"),
("Europe/Minsk", "(GMT+0300) Europe/Minsk"),
("Europe/Moscow", "(GMT+0300) Europe/Moscow"),
("Europe/Riga", "(GMT+0300) Europe/Riga"),
("Europe/Simferopol", "(GMT+0300) Europe/Simferopol"),
("Europe/Sofia", "(GMT+0300) Europe/Sofia"),
("Europe/Tallinn", "(GMT+0300) Europe/Tallinn"),
("Europe/Vilnius", "(GMT+0300) Europe/Vilnius"),
("Europe/Volgograd", "(GMT+0300) Europe/Volgograd"),
("Indian/Antananarivo", "(GMT+0300) Indian/Antananarivo"),
("Indian/Comoro", "(GMT+0300) Indian/Comoro"),
("Indian/Mayotte", "(GMT+0300) Indian/Mayotte"),
("Asia/Tehran", "(GMT+0330) Asia/Tehran"),
("Asia/Baku", "(GMT+0400) Asia/Baku"),
("Asia/Dubai", "(GMT+0400) Asia/Dubai"),
("Asia/Muscat", "(GMT+0400) Asia/Muscat"),
("Asia/Tbilisi", "(GMT+0400) Asia/Tbilisi"),
("Asia/Yerevan", "(GMT+0400) Asia/Yerevan"),
("Europe/Astrakhan", "(GMT+0400) Europe/Astrakhan"),
("Europe/Samara", "(GMT+0400) Europe/Samara"),
("Europe/Saratov", "(GMT+0400) Europe/Saratov"),
("Europe/Ulyanovsk", "(GMT+0400) Europe/Ulyanovsk"),
("Indian/Mahe", "(GMT+0400) Indian/Mahe"),
("Indian/Mauritius", "(GMT+0400) Indian/Mauritius"),
("Indian/Reunion", "(GMT+0400) Indian/Reunion"),
("Asia/Kabul", "(GMT+0430) Asia/Kabul"),
("Antarctica/Mawson", "(GMT+0500) Antarctica/Mawson"),
("Asia/Aqtau", "(GMT+0500) Asia/Aqtau"),
("Asia/Aqtobe", "(GMT+0500) Asia/Aqtobe"),
("Asia/Ashgabat", "(GMT+0500) Asia/Ashgabat"),
("Asia/Atyrau", "(GMT+0500) Asia/Atyrau"),
("Asia/Dushanbe", "(GMT+0500) Asia/Dushanbe"),
("Asia/Karachi", "(GMT+0500) Asia/Karachi"),
("Asia/Oral", "(GMT+0500) Asia/Oral"),
("Asia/Qyzylorda", "(GMT+0500) Asia/Qyzylorda"),
("Asia/Samarkand", "(GMT+0500) Asia/Samarkand"),
("Asia/Tashkent", "(GMT+0500) Asia/Tashkent"),
("Asia/Yekaterinburg", "(GMT+0500) Asia/Yekaterinburg"),
("Indian/Kerguelen", "(GMT+0500) Indian/Kerguelen"),
("Indian/Maldives", "(GMT+0500) Indian/Maldives"),
("Asia/Colombo", "(GMT+0530) Asia/Colombo"),
("Asia/Kolkata", "(GMT+0530) Asia/Kolkata"),
("Asia/Kathmandu", "(GMT+0545) Asia/Kathmandu"),
("Antarctica/Vostok", "(GMT+0600) Antarctica/Vostok"),
("Asia/Almaty", "(GMT+0600) Asia/Almaty"),
("Asia/Bishkek", "(GMT+0600) Asia/Bishkek"),
("Asia/Dhaka", "(GMT+0600) Asia/Dhaka"),
("Asia/Omsk", "(GMT+0600) Asia/Omsk"),
("Asia/Qostanay", "(GMT+0600) Asia/Qostanay"),
("Asia/Thimphu", "(GMT+0600) Asia/Thimphu"),
("Asia/Urumqi", "(GMT+0600) Asia/Urumqi"),
("Indian/Chagos", "(GMT+0600) Indian/Chagos"),
("Asia/Yangon", "(GMT+0630) Asia/Yangon"),
("Indian/Cocos", "(GMT+0630) Indian/Cocos"),
("Antarctica/Davis", "(GMT+0700) Antarctica/Davis"),
("Asia/Bangkok", "(GMT+0700) Asia/Bangkok"),
("Asia/Barnaul", "(GMT+0700) Asia/Barnaul"),
("Asia/Ho_Chi_Minh", "(GMT+0700) Asia/Ho_Chi_Minh"),
("Asia/Hovd", "(GMT+0700) Asia/Hovd"),
("Asia/Jakarta", "(GMT+0700) Asia/Jakarta"),
("Asia/Krasnoyarsk", "(GMT+0700) Asia/Krasnoyarsk"),
("Asia/Novokuznetsk", "(GMT+0700) Asia/Novokuznetsk"),
("Asia/Novosibirsk", "(GMT+0700) Asia/Novosibirsk"),
("Asia/Phnom_Penh", "(GMT+0700) Asia/Phnom_Penh"),
("Asia/Pontianak", "(GMT+0700) Asia/Pontianak"),
("Asia/Tomsk", "(GMT+0700) Asia/Tomsk"),
("Asia/Vientiane", "(GMT+0700) Asia/Vientiane"),
("Indian/Christmas", "(GMT+0700) Indian/Christmas"),
("Asia/Brunei", "(GMT+0800) Asia/Brunei"),
("Asia/Choibalsan", "(GMT+0800) Asia/Choibalsan"),
("Asia/Hong_Kong", "(GMT+0800) Asia/Hong_Kong"),
("Asia/Irkutsk", "(GMT+0800) Asia/Irkutsk"),
("Asia/Kuala_Lumpur", "(GMT+0800) Asia/Kuala_Lumpur"),
("Asia/Kuching", "(GMT+0800) Asia/Kuching"),
("Asia/Macau", "(GMT+0800) Asia/Macau"),
("Asia/Makassar", "(GMT+0800) Asia/Makassar"),
("Asia/Manila", "(GMT+0800) Asia/Manila"),
("Asia/Shanghai", "(GMT+0800) Asia/Shanghai"),
("Asia/Singapore", "(GMT+0800) Asia/Singapore"),
("Asia/Taipei", "(GMT+0800) Asia/Taipei"),
("Asia/Ulaanbaatar", "(GMT+0800) Asia/Ulaanbaatar"),
("Australia/Perth", "(GMT+0800) Australia/Perth"),
("Australia/Eucla", "(GMT+0845) Australia/Eucla"),
("Asia/Chita", "(GMT+0900) Asia/Chita"),
("Asia/Dili", "(GMT+0900) Asia/Dili"),
("Asia/Jayapura", "(GMT+0900) Asia/Jayapura"),
("Asia/Khandyga", "(GMT+0900) Asia/Khandyga"),
("Asia/Pyongyang", "(GMT+0900) Asia/Pyongyang"),
("Asia/Seoul", "(GMT+0900) Asia/Seoul"),
("Asia/Tokyo", "(GMT+0900) Asia/Tokyo"),
("Asia/Yakutsk", "(GMT+0900) Asia/Yakutsk"),
("Pacific/Palau", "(GMT+0900) Pacific/Palau"),
("Australia/Adelaide", "(GMT+0930) Australia/Adelaide"),
(
"Australia/Broken_Hill",
"(GMT+0930) Australia/Broken_Hill",
),
("Australia/Darwin", "(GMT+0930) Australia/Darwin"),
(
"Antarctica/DumontDUrville",
"(GMT+1000) Antarctica/DumontDUrville",
),
(
"Antarctica/Macquarie",
"(GMT+1000) Antarctica/Macquarie",
),
("Asia/Ust-Nera", "(GMT+1000) Asia/Ust-Nera"),
("Asia/Vladivostok", "(GMT+1000) Asia/Vladivostok"),
("Australia/Brisbane", "(GMT+1000) Australia/Brisbane"),
("Australia/Hobart", "(GMT+1000) Australia/Hobart"),
("Australia/Lindeman", "(GMT+1000) Australia/Lindeman"),
("Australia/Melbourne", "(GMT+1000) Australia/Melbourne"),
("Australia/Sydney", "(GMT+1000) Australia/Sydney"),
("Pacific/Chuuk", "(GMT+1000) Pacific/Chuuk"),
("Pacific/Guam", "(GMT+1000) Pacific/Guam"),
(
"Pacific/Port_Moresby",
"(GMT+1000) Pacific/Port_Moresby",
),
("Pacific/Saipan", "(GMT+1000) Pacific/Saipan"),
("Australia/Lord_Howe", "(GMT+1030) Australia/Lord_Howe"),
("Antarctica/Casey", "(GMT+1100) Antarctica/Casey"),
("Asia/Magadan", "(GMT+1100) Asia/Magadan"),
("Asia/Sakhalin", "(GMT+1100) Asia/Sakhalin"),
("Asia/Srednekolymsk", "(GMT+1100) Asia/Srednekolymsk"),
(
"Pacific/Bougainville",
"(GMT+1100) Pacific/Bougainville",
),
("Pacific/Efate", "(GMT+1100) Pacific/Efate"),
("Pacific/Guadalcanal", "(GMT+1100) Pacific/Guadalcanal"),
("Pacific/Kosrae", "(GMT+1100) Pacific/Kosrae"),
("Pacific/Norfolk", "(GMT+1100) Pacific/Norfolk"),
("Pacific/Noumea", "(GMT+1100) Pacific/Noumea"),
("Pacific/Pohnpei", "(GMT+1100) Pacific/Pohnpei"),
("Antarctica/McMurdo", "(GMT+1200) Antarctica/McMurdo"),
("Asia/Anadyr", "(GMT+1200) Asia/Anadyr"),
("Asia/Kamchatka", "(GMT+1200) Asia/Kamchatka"),
("Pacific/Auckland", "(GMT+1200) Pacific/Auckland"),
("Pacific/Fiji", "(GMT+1200) Pacific/Fiji"),
("Pacific/Funafuti", "(GMT+1200) Pacific/Funafuti"),
("Pacific/Kwajalein", "(GMT+1200) Pacific/Kwajalein"),
("Pacific/Majuro", "(GMT+1200) Pacific/Majuro"),
("Pacific/Nauru", "(GMT+1200) Pacific/Nauru"),
("Pacific/Tarawa", "(GMT+1200) Pacific/Tarawa"),
("Pacific/Wake", "(GMT+1200) Pacific/Wake"),
("Pacific/Wallis", "(GMT+1200) Pacific/Wallis"),
("Pacific/Chatham", "(GMT+1245) Pacific/Chatham"),
("Pacific/Apia", "(GMT+1300) Pacific/Apia"),
("Pacific/Fakaofo", "(GMT+1300) Pacific/Fakaofo"),
("Pacific/Kanton", "(GMT+1300) Pacific/Kanton"),
("Pacific/Tongatapu", "(GMT+1300) Pacific/Tongatapu"),
("Pacific/Kiritimati", "(GMT+1400) Pacific/Kiritimati"),
],
default="UTC",
max_length=255,
),
),
]

View File

@ -0,0 +1,612 @@
# Generated by Django 4.2.16 on 2024-10-14 23:23
from django.db import migrations, models
import encrypted_field.fields
class Migration(migrations.Migration):
dependencies = [
("profiles", "0016_alter_userprofile_timezone"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="todoist_auth_key",
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
),
migrations.AddField(
model_name="userprofile",
name="todoist_state",
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
),
migrations.AlterField(
model_name="userprofile",
name="timezone",
field=models.CharField(
choices=[
("Pacific/Midway", "(GMT-1100) Pacific/Midway"),
("Pacific/Niue", "(GMT-1100) Pacific/Niue"),
("Pacific/Pago_Pago", "(GMT-1100) Pacific/Pago_Pago"),
("Pacific/Honolulu", "(GMT-1000) Pacific/Honolulu"),
("Pacific/Rarotonga", "(GMT-1000) Pacific/Rarotonga"),
("Pacific/Tahiti", "(GMT-1000) Pacific/Tahiti"),
("US/Hawaii", "(GMT-1000) US/Hawaii"),
("Pacific/Marquesas", "(GMT-0930) Pacific/Marquesas"),
("America/Adak", "(GMT-0900) America/Adak"),
("Pacific/Gambier", "(GMT-0900) Pacific/Gambier"),
("America/Anchorage", "(GMT-0800) America/Anchorage"),
("America/Juneau", "(GMT-0800) America/Juneau"),
("America/Metlakatla", "(GMT-0800) America/Metlakatla"),
("America/Nome", "(GMT-0800) America/Nome"),
("America/Sitka", "(GMT-0800) America/Sitka"),
("America/Yakutat", "(GMT-0800) America/Yakutat"),
("Pacific/Pitcairn", "(GMT-0800) Pacific/Pitcairn"),
("US/Alaska", "(GMT-0800) US/Alaska"),
("America/Creston", "(GMT-0700) America/Creston"),
("America/Dawson", "(GMT-0700) America/Dawson"),
(
"America/Dawson_Creek",
"(GMT-0700) America/Dawson_Creek",
),
("America/Fort_Nelson", "(GMT-0700) America/Fort_Nelson"),
("America/Hermosillo", "(GMT-0700) America/Hermosillo"),
("America/Los_Angeles", "(GMT-0700) America/Los_Angeles"),
("America/Mazatlan", "(GMT-0700) America/Mazatlan"),
("America/Phoenix", "(GMT-0700) America/Phoenix"),
("America/Tijuana", "(GMT-0700) America/Tijuana"),
("America/Vancouver", "(GMT-0700) America/Vancouver"),
("America/Whitehorse", "(GMT-0700) America/Whitehorse"),
("Canada/Pacific", "(GMT-0700) Canada/Pacific"),
("US/Arizona", "(GMT-0700) US/Arizona"),
("US/Pacific", "(GMT-0700) US/Pacific"),
(
"America/Bahia_Banderas",
"(GMT-0600) America/Bahia_Banderas",
),
("America/Belize", "(GMT-0600) America/Belize"),
("America/Boise", "(GMT-0600) America/Boise"),
(
"America/Cambridge_Bay",
"(GMT-0600) America/Cambridge_Bay",
),
("America/Chihuahua", "(GMT-0600) America/Chihuahua"),
(
"America/Ciudad_Juarez",
"(GMT-0600) America/Ciudad_Juarez",
),
("America/Costa_Rica", "(GMT-0600) America/Costa_Rica"),
("America/Denver", "(GMT-0600) America/Denver"),
("America/Edmonton", "(GMT-0600) America/Edmonton"),
("America/El_Salvador", "(GMT-0600) America/El_Salvador"),
("America/Guatemala", "(GMT-0600) America/Guatemala"),
("America/Inuvik", "(GMT-0600) America/Inuvik"),
("America/Managua", "(GMT-0600) America/Managua"),
("America/Merida", "(GMT-0600) America/Merida"),
("America/Mexico_City", "(GMT-0600) America/Mexico_City"),
("America/Monterrey", "(GMT-0600) America/Monterrey"),
("America/Regina", "(GMT-0600) America/Regina"),
(
"America/Swift_Current",
"(GMT-0600) America/Swift_Current",
),
("America/Tegucigalpa", "(GMT-0600) America/Tegucigalpa"),
("America/Yellowknife", "(GMT-0600) America/Yellowknife"),
("Canada/Mountain", "(GMT-0600) Canada/Mountain"),
("Pacific/Galapagos", "(GMT-0600) Pacific/Galapagos"),
("US/Mountain", "(GMT-0600) US/Mountain"),
("America/Atikokan", "(GMT-0500) America/Atikokan"),
("America/Bogota", "(GMT-0500) America/Bogota"),
("America/Cancun", "(GMT-0500) America/Cancun"),
("America/Cayman", "(GMT-0500) America/Cayman"),
("America/Chicago", "(GMT-0500) America/Chicago"),
("America/Eirunepe", "(GMT-0500) America/Eirunepe"),
("America/Guayaquil", "(GMT-0500) America/Guayaquil"),
(
"America/Indiana/Knox",
"(GMT-0500) America/Indiana/Knox",
),
(
"America/Indiana/Tell_City",
"(GMT-0500) America/Indiana/Tell_City",
),
("America/Jamaica", "(GMT-0500) America/Jamaica"),
("America/Lima", "(GMT-0500) America/Lima"),
("America/Matamoros", "(GMT-0500) America/Matamoros"),
("America/Menominee", "(GMT-0500) America/Menominee"),
(
"America/North_Dakota/Beulah",
"(GMT-0500) America/North_Dakota/Beulah",
),
(
"America/North_Dakota/Center",
"(GMT-0500) America/North_Dakota/Center",
),
(
"America/North_Dakota/New_Salem",
"(GMT-0500) America/North_Dakota/New_Salem",
),
("America/Ojinaga", "(GMT-0500) America/Ojinaga"),
("America/Panama", "(GMT-0500) America/Panama"),
(
"America/Rankin_Inlet",
"(GMT-0500) America/Rankin_Inlet",
),
("America/Resolute", "(GMT-0500) America/Resolute"),
("America/Rio_Branco", "(GMT-0500) America/Rio_Branco"),
("America/Winnipeg", "(GMT-0500) America/Winnipeg"),
("Canada/Central", "(GMT-0500) Canada/Central"),
("Pacific/Easter", "(GMT-0500) Pacific/Easter"),
("US/Central", "(GMT-0500) US/Central"),
("America/Anguilla", "(GMT-0400) America/Anguilla"),
("America/Antigua", "(GMT-0400) America/Antigua"),
("America/Aruba", "(GMT-0400) America/Aruba"),
("America/Barbados", "(GMT-0400) America/Barbados"),
(
"America/Blanc-Sablon",
"(GMT-0400) America/Blanc-Sablon",
),
("America/Boa_Vista", "(GMT-0400) America/Boa_Vista"),
(
"America/Campo_Grande",
"(GMT-0400) America/Campo_Grande",
),
("America/Caracas", "(GMT-0400) America/Caracas"),
("America/Cuiaba", "(GMT-0400) America/Cuiaba"),
("America/Curacao", "(GMT-0400) America/Curacao"),
("America/Detroit", "(GMT-0400) America/Detroit"),
("America/Dominica", "(GMT-0400) America/Dominica"),
("America/Grand_Turk", "(GMT-0400) America/Grand_Turk"),
("America/Grenada", "(GMT-0400) America/Grenada"),
("America/Guadeloupe", "(GMT-0400) America/Guadeloupe"),
("America/Guyana", "(GMT-0400) America/Guyana"),
("America/Havana", "(GMT-0400) America/Havana"),
(
"America/Indiana/Indianapolis",
"(GMT-0400) America/Indiana/Indianapolis",
),
(
"America/Indiana/Marengo",
"(GMT-0400) America/Indiana/Marengo",
),
(
"America/Indiana/Petersburg",
"(GMT-0400) America/Indiana/Petersburg",
),
(
"America/Indiana/Vevay",
"(GMT-0400) America/Indiana/Vevay",
),
(
"America/Indiana/Vincennes",
"(GMT-0400) America/Indiana/Vincennes",
),
(
"America/Indiana/Winamac",
"(GMT-0400) America/Indiana/Winamac",
),
("America/Iqaluit", "(GMT-0400) America/Iqaluit"),
(
"America/Kentucky/Louisville",
"(GMT-0400) America/Kentucky/Louisville",
),
(
"America/Kentucky/Monticello",
"(GMT-0400) America/Kentucky/Monticello",
),
("America/Kralendijk", "(GMT-0400) America/Kralendijk"),
("America/La_Paz", "(GMT-0400) America/La_Paz"),
(
"America/Lower_Princes",
"(GMT-0400) America/Lower_Princes",
),
("America/Manaus", "(GMT-0400) America/Manaus"),
("America/Marigot", "(GMT-0400) America/Marigot"),
("America/Martinique", "(GMT-0400) America/Martinique"),
("America/Montserrat", "(GMT-0400) America/Montserrat"),
("America/Nassau", "(GMT-0400) America/Nassau"),
("America/New_York", "(GMT-0400) America/New_York"),
(
"America/Port-au-Prince",
"(GMT-0400) America/Port-au-Prince",
),
(
"America/Port_of_Spain",
"(GMT-0400) America/Port_of_Spain",
),
("America/Porto_Velho", "(GMT-0400) America/Porto_Velho"),
("America/Puerto_Rico", "(GMT-0400) America/Puerto_Rico"),
(
"America/Santo_Domingo",
"(GMT-0400) America/Santo_Domingo",
),
(
"America/St_Barthelemy",
"(GMT-0400) America/St_Barthelemy",
),
("America/St_Kitts", "(GMT-0400) America/St_Kitts"),
("America/St_Lucia", "(GMT-0400) America/St_Lucia"),
("America/St_Thomas", "(GMT-0400) America/St_Thomas"),
("America/St_Vincent", "(GMT-0400) America/St_Vincent"),
("America/Toronto", "(GMT-0400) America/Toronto"),
("America/Tortola", "(GMT-0400) America/Tortola"),
("Canada/Eastern", "(GMT-0400) Canada/Eastern"),
("US/Eastern", "(GMT-0400) US/Eastern"),
("America/Araguaina", "(GMT-0300) America/Araguaina"),
(
"America/Argentina/Buenos_Aires",
"(GMT-0300) America/Argentina/Buenos_Aires",
),
(
"America/Argentina/Catamarca",
"(GMT-0300) America/Argentina/Catamarca",
),
(
"America/Argentina/Cordoba",
"(GMT-0300) America/Argentina/Cordoba",
),
(
"America/Argentina/Jujuy",
"(GMT-0300) America/Argentina/Jujuy",
),
(
"America/Argentina/La_Rioja",
"(GMT-0300) America/Argentina/La_Rioja",
),
(
"America/Argentina/Mendoza",
"(GMT-0300) America/Argentina/Mendoza",
),
(
"America/Argentina/Rio_Gallegos",
"(GMT-0300) America/Argentina/Rio_Gallegos",
),
(
"America/Argentina/Salta",
"(GMT-0300) America/Argentina/Salta",
),
(
"America/Argentina/San_Juan",
"(GMT-0300) America/Argentina/San_Juan",
),
(
"America/Argentina/San_Luis",
"(GMT-0300) America/Argentina/San_Luis",
),
(
"America/Argentina/Tucuman",
"(GMT-0300) America/Argentina/Tucuman",
),
(
"America/Argentina/Ushuaia",
"(GMT-0300) America/Argentina/Ushuaia",
),
("America/Asuncion", "(GMT-0300) America/Asuncion"),
("America/Bahia", "(GMT-0300) America/Bahia"),
("America/Belem", "(GMT-0300) America/Belem"),
("America/Cayenne", "(GMT-0300) America/Cayenne"),
("America/Fortaleza", "(GMT-0300) America/Fortaleza"),
("America/Glace_Bay", "(GMT-0300) America/Glace_Bay"),
("America/Goose_Bay", "(GMT-0300) America/Goose_Bay"),
("America/Halifax", "(GMT-0300) America/Halifax"),
("America/Maceio", "(GMT-0300) America/Maceio"),
("America/Moncton", "(GMT-0300) America/Moncton"),
("America/Montevideo", "(GMT-0300) America/Montevideo"),
("America/Paramaribo", "(GMT-0300) America/Paramaribo"),
(
"America/Punta_Arenas",
"(GMT-0300) America/Punta_Arenas",
),
("America/Recife", "(GMT-0300) America/Recife"),
("America/Santarem", "(GMT-0300) America/Santarem"),
("America/Santiago", "(GMT-0300) America/Santiago"),
("America/Sao_Paulo", "(GMT-0300) America/Sao_Paulo"),
("America/Thule", "(GMT-0300) America/Thule"),
("Antarctica/Palmer", "(GMT-0300) Antarctica/Palmer"),
("Antarctica/Rothera", "(GMT-0300) Antarctica/Rothera"),
("Atlantic/Bermuda", "(GMT-0300) Atlantic/Bermuda"),
("Atlantic/Stanley", "(GMT-0300) Atlantic/Stanley"),
("Canada/Atlantic", "(GMT-0300) Canada/Atlantic"),
("America/St_Johns", "(GMT-0230) America/St_Johns"),
("Canada/Newfoundland", "(GMT-0230) Canada/Newfoundland"),
("America/Miquelon", "(GMT-0200) America/Miquelon"),
("America/Noronha", "(GMT-0200) America/Noronha"),
("America/Nuuk", "(GMT-0200) America/Nuuk"),
(
"Atlantic/South_Georgia",
"(GMT-0200) Atlantic/South_Georgia",
),
("Atlantic/Cape_Verde", "(GMT-0100) Atlantic/Cape_Verde"),
("Africa/Abidjan", "(GMT+0000) Africa/Abidjan"),
("Africa/Accra", "(GMT+0000) Africa/Accra"),
("Africa/Bamako", "(GMT+0000) Africa/Bamako"),
("Africa/Banjul", "(GMT+0000) Africa/Banjul"),
("Africa/Bissau", "(GMT+0000) Africa/Bissau"),
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
("Africa/Freetown", "(GMT+0000) Africa/Freetown"),
("Africa/Lome", "(GMT+0000) Africa/Lome"),
("Africa/Monrovia", "(GMT+0000) Africa/Monrovia"),
("Africa/Nouakchott", "(GMT+0000) Africa/Nouakchott"),
("Africa/Ouagadougou", "(GMT+0000) Africa/Ouagadougou"),
("Africa/Sao_Tome", "(GMT+0000) Africa/Sao_Tome"),
(
"America/Danmarkshavn",
"(GMT+0000) America/Danmarkshavn",
),
(
"America/Scoresbysund",
"(GMT+0000) America/Scoresbysund",
),
("Atlantic/Azores", "(GMT+0000) Atlantic/Azores"),
("Atlantic/Reykjavik", "(GMT+0000) Atlantic/Reykjavik"),
("Atlantic/St_Helena", "(GMT+0000) Atlantic/St_Helena"),
("GMT", "(GMT+0000) GMT"),
("UTC", "(GMT+0000) UTC"),
("Africa/Algiers", "(GMT+0100) Africa/Algiers"),
("Africa/Bangui", "(GMT+0100) Africa/Bangui"),
("Africa/Brazzaville", "(GMT+0100) Africa/Brazzaville"),
("Africa/Casablanca", "(GMT+0100) Africa/Casablanca"),
("Africa/Douala", "(GMT+0100) Africa/Douala"),
("Africa/El_Aaiun", "(GMT+0100) Africa/El_Aaiun"),
("Africa/Kinshasa", "(GMT+0100) Africa/Kinshasa"),
("Africa/Lagos", "(GMT+0100) Africa/Lagos"),
("Africa/Libreville", "(GMT+0100) Africa/Libreville"),
("Africa/Luanda", "(GMT+0100) Africa/Luanda"),
("Africa/Malabo", "(GMT+0100) Africa/Malabo"),
("Africa/Ndjamena", "(GMT+0100) Africa/Ndjamena"),
("Africa/Niamey", "(GMT+0100) Africa/Niamey"),
("Africa/Porto-Novo", "(GMT+0100) Africa/Porto-Novo"),
("Africa/Tunis", "(GMT+0100) Africa/Tunis"),
("Atlantic/Canary", "(GMT+0100) Atlantic/Canary"),
("Atlantic/Faroe", "(GMT+0100) Atlantic/Faroe"),
("Atlantic/Madeira", "(GMT+0100) Atlantic/Madeira"),
("Europe/Dublin", "(GMT+0100) Europe/Dublin"),
("Europe/Guernsey", "(GMT+0100) Europe/Guernsey"),
("Europe/Isle_of_Man", "(GMT+0100) Europe/Isle_of_Man"),
("Europe/Jersey", "(GMT+0100) Europe/Jersey"),
("Europe/Lisbon", "(GMT+0100) Europe/Lisbon"),
("Europe/London", "(GMT+0100) Europe/London"),
("Africa/Blantyre", "(GMT+0200) Africa/Blantyre"),
("Africa/Bujumbura", "(GMT+0200) Africa/Bujumbura"),
("Africa/Cairo", "(GMT+0200) Africa/Cairo"),
("Africa/Ceuta", "(GMT+0200) Africa/Ceuta"),
("Africa/Gaborone", "(GMT+0200) Africa/Gaborone"),
("Africa/Harare", "(GMT+0200) Africa/Harare"),
("Africa/Johannesburg", "(GMT+0200) Africa/Johannesburg"),
("Africa/Juba", "(GMT+0200) Africa/Juba"),
("Africa/Khartoum", "(GMT+0200) Africa/Khartoum"),
("Africa/Kigali", "(GMT+0200) Africa/Kigali"),
("Africa/Lubumbashi", "(GMT+0200) Africa/Lubumbashi"),
("Africa/Lusaka", "(GMT+0200) Africa/Lusaka"),
("Africa/Maputo", "(GMT+0200) Africa/Maputo"),
("Africa/Maseru", "(GMT+0200) Africa/Maseru"),
("Africa/Mbabane", "(GMT+0200) Africa/Mbabane"),
("Africa/Tripoli", "(GMT+0200) Africa/Tripoli"),
("Africa/Windhoek", "(GMT+0200) Africa/Windhoek"),
("Antarctica/Troll", "(GMT+0200) Antarctica/Troll"),
("Arctic/Longyearbyen", "(GMT+0200) Arctic/Longyearbyen"),
("Europe/Amsterdam", "(GMT+0200) Europe/Amsterdam"),
("Europe/Andorra", "(GMT+0200) Europe/Andorra"),
("Europe/Belgrade", "(GMT+0200) Europe/Belgrade"),
("Europe/Berlin", "(GMT+0200) Europe/Berlin"),
("Europe/Bratislava", "(GMT+0200) Europe/Bratislava"),
("Europe/Brussels", "(GMT+0200) Europe/Brussels"),
("Europe/Budapest", "(GMT+0200) Europe/Budapest"),
("Europe/Busingen", "(GMT+0200) Europe/Busingen"),
("Europe/Copenhagen", "(GMT+0200) Europe/Copenhagen"),
("Europe/Gibraltar", "(GMT+0200) Europe/Gibraltar"),
("Europe/Kaliningrad", "(GMT+0200) Europe/Kaliningrad"),
("Europe/Ljubljana", "(GMT+0200) Europe/Ljubljana"),
("Europe/Luxembourg", "(GMT+0200) Europe/Luxembourg"),
("Europe/Madrid", "(GMT+0200) Europe/Madrid"),
("Europe/Malta", "(GMT+0200) Europe/Malta"),
("Europe/Monaco", "(GMT+0200) Europe/Monaco"),
("Europe/Oslo", "(GMT+0200) Europe/Oslo"),
("Europe/Paris", "(GMT+0200) Europe/Paris"),
("Europe/Podgorica", "(GMT+0200) Europe/Podgorica"),
("Europe/Prague", "(GMT+0200) Europe/Prague"),
("Europe/Rome", "(GMT+0200) Europe/Rome"),
("Europe/San_Marino", "(GMT+0200) Europe/San_Marino"),
("Europe/Sarajevo", "(GMT+0200) Europe/Sarajevo"),
("Europe/Skopje", "(GMT+0200) Europe/Skopje"),
("Europe/Stockholm", "(GMT+0200) Europe/Stockholm"),
("Europe/Tirane", "(GMT+0200) Europe/Tirane"),
("Europe/Vaduz", "(GMT+0200) Europe/Vaduz"),
("Europe/Vatican", "(GMT+0200) Europe/Vatican"),
("Europe/Vienna", "(GMT+0200) Europe/Vienna"),
("Europe/Warsaw", "(GMT+0200) Europe/Warsaw"),
("Europe/Zagreb", "(GMT+0200) Europe/Zagreb"),
("Europe/Zurich", "(GMT+0200) Europe/Zurich"),
("Africa/Addis_Ababa", "(GMT+0300) Africa/Addis_Ababa"),
("Africa/Asmara", "(GMT+0300) Africa/Asmara"),
(
"Africa/Dar_es_Salaam",
"(GMT+0300) Africa/Dar_es_Salaam",
),
("Africa/Djibouti", "(GMT+0300) Africa/Djibouti"),
("Africa/Kampala", "(GMT+0300) Africa/Kampala"),
("Africa/Mogadishu", "(GMT+0300) Africa/Mogadishu"),
("Africa/Nairobi", "(GMT+0300) Africa/Nairobi"),
("Antarctica/Syowa", "(GMT+0300) Antarctica/Syowa"),
("Asia/Aden", "(GMT+0300) Asia/Aden"),
("Asia/Amman", "(GMT+0300) Asia/Amman"),
("Asia/Baghdad", "(GMT+0300) Asia/Baghdad"),
("Asia/Bahrain", "(GMT+0300) Asia/Bahrain"),
("Asia/Beirut", "(GMT+0300) Asia/Beirut"),
("Asia/Damascus", "(GMT+0300) Asia/Damascus"),
("Asia/Famagusta", "(GMT+0300) Asia/Famagusta"),
("Asia/Gaza", "(GMT+0300) Asia/Gaza"),
("Asia/Hebron", "(GMT+0300) Asia/Hebron"),
("Asia/Jerusalem", "(GMT+0300) Asia/Jerusalem"),
("Asia/Kuwait", "(GMT+0300) Asia/Kuwait"),
("Asia/Nicosia", "(GMT+0300) Asia/Nicosia"),
("Asia/Qatar", "(GMT+0300) Asia/Qatar"),
("Asia/Riyadh", "(GMT+0300) Asia/Riyadh"),
("Europe/Athens", "(GMT+0300) Europe/Athens"),
("Europe/Bucharest", "(GMT+0300) Europe/Bucharest"),
("Europe/Chisinau", "(GMT+0300) Europe/Chisinau"),
("Europe/Helsinki", "(GMT+0300) Europe/Helsinki"),
("Europe/Istanbul", "(GMT+0300) Europe/Istanbul"),
("Europe/Kirov", "(GMT+0300) Europe/Kirov"),
("Europe/Kyiv", "(GMT+0300) Europe/Kyiv"),
("Europe/Mariehamn", "(GMT+0300) Europe/Mariehamn"),
("Europe/Minsk", "(GMT+0300) Europe/Minsk"),
("Europe/Moscow", "(GMT+0300) Europe/Moscow"),
("Europe/Riga", "(GMT+0300) Europe/Riga"),
("Europe/Simferopol", "(GMT+0300) Europe/Simferopol"),
("Europe/Sofia", "(GMT+0300) Europe/Sofia"),
("Europe/Tallinn", "(GMT+0300) Europe/Tallinn"),
("Europe/Vilnius", "(GMT+0300) Europe/Vilnius"),
("Europe/Volgograd", "(GMT+0300) Europe/Volgograd"),
("Indian/Antananarivo", "(GMT+0300) Indian/Antananarivo"),
("Indian/Comoro", "(GMT+0300) Indian/Comoro"),
("Indian/Mayotte", "(GMT+0300) Indian/Mayotte"),
("Asia/Tehran", "(GMT+0330) Asia/Tehran"),
("Asia/Baku", "(GMT+0400) Asia/Baku"),
("Asia/Dubai", "(GMT+0400) Asia/Dubai"),
("Asia/Muscat", "(GMT+0400) Asia/Muscat"),
("Asia/Tbilisi", "(GMT+0400) Asia/Tbilisi"),
("Asia/Yerevan", "(GMT+0400) Asia/Yerevan"),
("Europe/Astrakhan", "(GMT+0400) Europe/Astrakhan"),
("Europe/Samara", "(GMT+0400) Europe/Samara"),
("Europe/Saratov", "(GMT+0400) Europe/Saratov"),
("Europe/Ulyanovsk", "(GMT+0400) Europe/Ulyanovsk"),
("Indian/Mahe", "(GMT+0400) Indian/Mahe"),
("Indian/Mauritius", "(GMT+0400) Indian/Mauritius"),
("Indian/Reunion", "(GMT+0400) Indian/Reunion"),
("Asia/Kabul", "(GMT+0430) Asia/Kabul"),
("Antarctica/Mawson", "(GMT+0500) Antarctica/Mawson"),
("Asia/Aqtau", "(GMT+0500) Asia/Aqtau"),
("Asia/Aqtobe", "(GMT+0500) Asia/Aqtobe"),
("Asia/Ashgabat", "(GMT+0500) Asia/Ashgabat"),
("Asia/Atyrau", "(GMT+0500) Asia/Atyrau"),
("Asia/Dushanbe", "(GMT+0500) Asia/Dushanbe"),
("Asia/Karachi", "(GMT+0500) Asia/Karachi"),
("Asia/Oral", "(GMT+0500) Asia/Oral"),
("Asia/Qyzylorda", "(GMT+0500) Asia/Qyzylorda"),
("Asia/Samarkand", "(GMT+0500) Asia/Samarkand"),
("Asia/Tashkent", "(GMT+0500) Asia/Tashkent"),
("Asia/Yekaterinburg", "(GMT+0500) Asia/Yekaterinburg"),
("Indian/Kerguelen", "(GMT+0500) Indian/Kerguelen"),
("Indian/Maldives", "(GMT+0500) Indian/Maldives"),
("Asia/Colombo", "(GMT+0530) Asia/Colombo"),
("Asia/Kolkata", "(GMT+0530) Asia/Kolkata"),
("Asia/Kathmandu", "(GMT+0545) Asia/Kathmandu"),
("Antarctica/Vostok", "(GMT+0600) Antarctica/Vostok"),
("Asia/Almaty", "(GMT+0600) Asia/Almaty"),
("Asia/Bishkek", "(GMT+0600) Asia/Bishkek"),
("Asia/Dhaka", "(GMT+0600) Asia/Dhaka"),
("Asia/Omsk", "(GMT+0600) Asia/Omsk"),
("Asia/Qostanay", "(GMT+0600) Asia/Qostanay"),
("Asia/Thimphu", "(GMT+0600) Asia/Thimphu"),
("Asia/Urumqi", "(GMT+0600) Asia/Urumqi"),
("Indian/Chagos", "(GMT+0600) Indian/Chagos"),
("Asia/Yangon", "(GMT+0630) Asia/Yangon"),
("Indian/Cocos", "(GMT+0630) Indian/Cocos"),
("Antarctica/Davis", "(GMT+0700) Antarctica/Davis"),
("Asia/Bangkok", "(GMT+0700) Asia/Bangkok"),
("Asia/Barnaul", "(GMT+0700) Asia/Barnaul"),
("Asia/Ho_Chi_Minh", "(GMT+0700) Asia/Ho_Chi_Minh"),
("Asia/Hovd", "(GMT+0700) Asia/Hovd"),
("Asia/Jakarta", "(GMT+0700) Asia/Jakarta"),
("Asia/Krasnoyarsk", "(GMT+0700) Asia/Krasnoyarsk"),
("Asia/Novokuznetsk", "(GMT+0700) Asia/Novokuznetsk"),
("Asia/Novosibirsk", "(GMT+0700) Asia/Novosibirsk"),
("Asia/Phnom_Penh", "(GMT+0700) Asia/Phnom_Penh"),
("Asia/Pontianak", "(GMT+0700) Asia/Pontianak"),
("Asia/Tomsk", "(GMT+0700) Asia/Tomsk"),
("Asia/Vientiane", "(GMT+0700) Asia/Vientiane"),
("Indian/Christmas", "(GMT+0700) Indian/Christmas"),
("Asia/Brunei", "(GMT+0800) Asia/Brunei"),
("Asia/Choibalsan", "(GMT+0800) Asia/Choibalsan"),
("Asia/Hong_Kong", "(GMT+0800) Asia/Hong_Kong"),
("Asia/Irkutsk", "(GMT+0800) Asia/Irkutsk"),
("Asia/Kuala_Lumpur", "(GMT+0800) Asia/Kuala_Lumpur"),
("Asia/Kuching", "(GMT+0800) Asia/Kuching"),
("Asia/Macau", "(GMT+0800) Asia/Macau"),
("Asia/Makassar", "(GMT+0800) Asia/Makassar"),
("Asia/Manila", "(GMT+0800) Asia/Manila"),
("Asia/Shanghai", "(GMT+0800) Asia/Shanghai"),
("Asia/Singapore", "(GMT+0800) Asia/Singapore"),
("Asia/Taipei", "(GMT+0800) Asia/Taipei"),
("Asia/Ulaanbaatar", "(GMT+0800) Asia/Ulaanbaatar"),
("Australia/Perth", "(GMT+0800) Australia/Perth"),
("Australia/Eucla", "(GMT+0845) Australia/Eucla"),
("Asia/Chita", "(GMT+0900) Asia/Chita"),
("Asia/Dili", "(GMT+0900) Asia/Dili"),
("Asia/Jayapura", "(GMT+0900) Asia/Jayapura"),
("Asia/Khandyga", "(GMT+0900) Asia/Khandyga"),
("Asia/Pyongyang", "(GMT+0900) Asia/Pyongyang"),
("Asia/Seoul", "(GMT+0900) Asia/Seoul"),
("Asia/Tokyo", "(GMT+0900) Asia/Tokyo"),
("Asia/Yakutsk", "(GMT+0900) Asia/Yakutsk"),
("Pacific/Palau", "(GMT+0900) Pacific/Palau"),
("Australia/Darwin", "(GMT+0930) Australia/Darwin"),
(
"Antarctica/DumontDUrville",
"(GMT+1000) Antarctica/DumontDUrville",
),
("Asia/Ust-Nera", "(GMT+1000) Asia/Ust-Nera"),
("Asia/Vladivostok", "(GMT+1000) Asia/Vladivostok"),
("Australia/Brisbane", "(GMT+1000) Australia/Brisbane"),
("Australia/Lindeman", "(GMT+1000) Australia/Lindeman"),
("Pacific/Chuuk", "(GMT+1000) Pacific/Chuuk"),
("Pacific/Guam", "(GMT+1000) Pacific/Guam"),
(
"Pacific/Port_Moresby",
"(GMT+1000) Pacific/Port_Moresby",
),
("Pacific/Saipan", "(GMT+1000) Pacific/Saipan"),
("Australia/Adelaide", "(GMT+1030) Australia/Adelaide"),
(
"Australia/Broken_Hill",
"(GMT+1030) Australia/Broken_Hill",
),
("Antarctica/Casey", "(GMT+1100) Antarctica/Casey"),
(
"Antarctica/Macquarie",
"(GMT+1100) Antarctica/Macquarie",
),
("Asia/Magadan", "(GMT+1100) Asia/Magadan"),
("Asia/Sakhalin", "(GMT+1100) Asia/Sakhalin"),
("Asia/Srednekolymsk", "(GMT+1100) Asia/Srednekolymsk"),
("Australia/Hobart", "(GMT+1100) Australia/Hobart"),
("Australia/Lord_Howe", "(GMT+1100) Australia/Lord_Howe"),
("Australia/Melbourne", "(GMT+1100) Australia/Melbourne"),
("Australia/Sydney", "(GMT+1100) Australia/Sydney"),
(
"Pacific/Bougainville",
"(GMT+1100) Pacific/Bougainville",
),
("Pacific/Efate", "(GMT+1100) Pacific/Efate"),
("Pacific/Guadalcanal", "(GMT+1100) Pacific/Guadalcanal"),
("Pacific/Kosrae", "(GMT+1100) Pacific/Kosrae"),
("Pacific/Noumea", "(GMT+1100) Pacific/Noumea"),
("Pacific/Pohnpei", "(GMT+1100) Pacific/Pohnpei"),
("Asia/Anadyr", "(GMT+1200) Asia/Anadyr"),
("Asia/Kamchatka", "(GMT+1200) Asia/Kamchatka"),
("Pacific/Fiji", "(GMT+1200) Pacific/Fiji"),
("Pacific/Funafuti", "(GMT+1200) Pacific/Funafuti"),
("Pacific/Kwajalein", "(GMT+1200) Pacific/Kwajalein"),
("Pacific/Majuro", "(GMT+1200) Pacific/Majuro"),
("Pacific/Nauru", "(GMT+1200) Pacific/Nauru"),
("Pacific/Norfolk", "(GMT+1200) Pacific/Norfolk"),
("Pacific/Tarawa", "(GMT+1200) Pacific/Tarawa"),
("Pacific/Wake", "(GMT+1200) Pacific/Wake"),
("Pacific/Wallis", "(GMT+1200) Pacific/Wallis"),
("Antarctica/McMurdo", "(GMT+1300) Antarctica/McMurdo"),
("Pacific/Apia", "(GMT+1300) Pacific/Apia"),
("Pacific/Auckland", "(GMT+1300) Pacific/Auckland"),
("Pacific/Fakaofo", "(GMT+1300) Pacific/Fakaofo"),
("Pacific/Kanton", "(GMT+1300) Pacific/Kanton"),
("Pacific/Tongatapu", "(GMT+1300) Pacific/Tongatapu"),
("Pacific/Chatham", "(GMT+1345) Pacific/Chatham"),
("Pacific/Kiritimati", "(GMT+1400) Pacific/Kiritimati"),
],
default="UTC",
max_length=255,
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.16 on 2024-10-15 18:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0017_userprofile_todoist_auth_key_and_more"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="todoist_user_id",
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.2.16 on 2024-10-20 20:04
from django.db import migrations, models
import encrypted_field.fields
class Migration(migrations.Migration):
dependencies = [
("profiles", "0018_userprofile_todoist_user_id"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="webdav_auto_import",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="webdav_pass",
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
),
migrations.AddField(
model_name="userprofile",
name="webdav_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="userprofile",
name="webdav_user",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,611 @@
# Generated by Django 4.2.16 on 2024-11-05 19:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0019_userprofile_webdav_auto_import_and_more"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="ntfy_enabled",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="ntfy_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="userprofile",
name="timezone",
field=models.CharField(
choices=[
("Pacific/Midway", "(GMT-1100) Pacific/Midway"),
("Pacific/Niue", "(GMT-1100) Pacific/Niue"),
("Pacific/Pago_Pago", "(GMT-1100) Pacific/Pago_Pago"),
("America/Adak", "(GMT-1000) America/Adak"),
("Pacific/Honolulu", "(GMT-1000) Pacific/Honolulu"),
("Pacific/Rarotonga", "(GMT-1000) Pacific/Rarotonga"),
("Pacific/Tahiti", "(GMT-1000) Pacific/Tahiti"),
("US/Hawaii", "(GMT-1000) US/Hawaii"),
("Pacific/Marquesas", "(GMT-0930) Pacific/Marquesas"),
("America/Anchorage", "(GMT-0900) America/Anchorage"),
("America/Juneau", "(GMT-0900) America/Juneau"),
("America/Metlakatla", "(GMT-0900) America/Metlakatla"),
("America/Nome", "(GMT-0900) America/Nome"),
("America/Sitka", "(GMT-0900) America/Sitka"),
("America/Yakutat", "(GMT-0900) America/Yakutat"),
("Pacific/Gambier", "(GMT-0900) Pacific/Gambier"),
("US/Alaska", "(GMT-0900) US/Alaska"),
("America/Los_Angeles", "(GMT-0800) America/Los_Angeles"),
("America/Tijuana", "(GMT-0800) America/Tijuana"),
("America/Vancouver", "(GMT-0800) America/Vancouver"),
("Canada/Pacific", "(GMT-0800) Canada/Pacific"),
("Pacific/Pitcairn", "(GMT-0800) Pacific/Pitcairn"),
("US/Pacific", "(GMT-0800) US/Pacific"),
("America/Boise", "(GMT-0700) America/Boise"),
(
"America/Cambridge_Bay",
"(GMT-0700) America/Cambridge_Bay",
),
(
"America/Ciudad_Juarez",
"(GMT-0700) America/Ciudad_Juarez",
),
("America/Creston", "(GMT-0700) America/Creston"),
("America/Dawson", "(GMT-0700) America/Dawson"),
(
"America/Dawson_Creek",
"(GMT-0700) America/Dawson_Creek",
),
("America/Denver", "(GMT-0700) America/Denver"),
("America/Edmonton", "(GMT-0700) America/Edmonton"),
("America/Fort_Nelson", "(GMT-0700) America/Fort_Nelson"),
("America/Hermosillo", "(GMT-0700) America/Hermosillo"),
("America/Inuvik", "(GMT-0700) America/Inuvik"),
("America/Mazatlan", "(GMT-0700) America/Mazatlan"),
("America/Phoenix", "(GMT-0700) America/Phoenix"),
("America/Whitehorse", "(GMT-0700) America/Whitehorse"),
("America/Yellowknife", "(GMT-0700) America/Yellowknife"),
("Canada/Mountain", "(GMT-0700) Canada/Mountain"),
("US/Arizona", "(GMT-0700) US/Arizona"),
("US/Mountain", "(GMT-0700) US/Mountain"),
(
"America/Bahia_Banderas",
"(GMT-0600) America/Bahia_Banderas",
),
("America/Belize", "(GMT-0600) America/Belize"),
("America/Chicago", "(GMT-0600) America/Chicago"),
("America/Chihuahua", "(GMT-0600) America/Chihuahua"),
("America/Costa_Rica", "(GMT-0600) America/Costa_Rica"),
("America/El_Salvador", "(GMT-0600) America/El_Salvador"),
("America/Guatemala", "(GMT-0600) America/Guatemala"),
(
"America/Indiana/Knox",
"(GMT-0600) America/Indiana/Knox",
),
(
"America/Indiana/Tell_City",
"(GMT-0600) America/Indiana/Tell_City",
),
("America/Managua", "(GMT-0600) America/Managua"),
("America/Matamoros", "(GMT-0600) America/Matamoros"),
("America/Menominee", "(GMT-0600) America/Menominee"),
("America/Merida", "(GMT-0600) America/Merida"),
("America/Mexico_City", "(GMT-0600) America/Mexico_City"),
("America/Monterrey", "(GMT-0600) America/Monterrey"),
(
"America/North_Dakota/Beulah",
"(GMT-0600) America/North_Dakota/Beulah",
),
(
"America/North_Dakota/Center",
"(GMT-0600) America/North_Dakota/Center",
),
(
"America/North_Dakota/New_Salem",
"(GMT-0600) America/North_Dakota/New_Salem",
),
("America/Ojinaga", "(GMT-0600) America/Ojinaga"),
(
"America/Rankin_Inlet",
"(GMT-0600) America/Rankin_Inlet",
),
("America/Regina", "(GMT-0600) America/Regina"),
("America/Resolute", "(GMT-0600) America/Resolute"),
(
"America/Swift_Current",
"(GMT-0600) America/Swift_Current",
),
("America/Tegucigalpa", "(GMT-0600) America/Tegucigalpa"),
("America/Winnipeg", "(GMT-0600) America/Winnipeg"),
("Canada/Central", "(GMT-0600) Canada/Central"),
("Pacific/Galapagos", "(GMT-0600) Pacific/Galapagos"),
("US/Central", "(GMT-0600) US/Central"),
("America/Atikokan", "(GMT-0500) America/Atikokan"),
("America/Bogota", "(GMT-0500) America/Bogota"),
("America/Cancun", "(GMT-0500) America/Cancun"),
("America/Cayman", "(GMT-0500) America/Cayman"),
("America/Detroit", "(GMT-0500) America/Detroit"),
("America/Eirunepe", "(GMT-0500) America/Eirunepe"),
("America/Grand_Turk", "(GMT-0500) America/Grand_Turk"),
("America/Guayaquil", "(GMT-0500) America/Guayaquil"),
("America/Havana", "(GMT-0500) America/Havana"),
(
"America/Indiana/Indianapolis",
"(GMT-0500) America/Indiana/Indianapolis",
),
(
"America/Indiana/Marengo",
"(GMT-0500) America/Indiana/Marengo",
),
(
"America/Indiana/Petersburg",
"(GMT-0500) America/Indiana/Petersburg",
),
(
"America/Indiana/Vevay",
"(GMT-0500) America/Indiana/Vevay",
),
(
"America/Indiana/Vincennes",
"(GMT-0500) America/Indiana/Vincennes",
),
(
"America/Indiana/Winamac",
"(GMT-0500) America/Indiana/Winamac",
),
("America/Iqaluit", "(GMT-0500) America/Iqaluit"),
("America/Jamaica", "(GMT-0500) America/Jamaica"),
(
"America/Kentucky/Louisville",
"(GMT-0500) America/Kentucky/Louisville",
),
(
"America/Kentucky/Monticello",
"(GMT-0500) America/Kentucky/Monticello",
),
("America/Lima", "(GMT-0500) America/Lima"),
("America/Nassau", "(GMT-0500) America/Nassau"),
("America/New_York", "(GMT-0500) America/New_York"),
("America/Panama", "(GMT-0500) America/Panama"),
(
"America/Port-au-Prince",
"(GMT-0500) America/Port-au-Prince",
),
("America/Rio_Branco", "(GMT-0500) America/Rio_Branco"),
("America/Toronto", "(GMT-0500) America/Toronto"),
("Canada/Eastern", "(GMT-0500) Canada/Eastern"),
("Pacific/Easter", "(GMT-0500) Pacific/Easter"),
("US/Eastern", "(GMT-0500) US/Eastern"),
("America/Anguilla", "(GMT-0400) America/Anguilla"),
("America/Antigua", "(GMT-0400) America/Antigua"),
("America/Aruba", "(GMT-0400) America/Aruba"),
("America/Barbados", "(GMT-0400) America/Barbados"),
(
"America/Blanc-Sablon",
"(GMT-0400) America/Blanc-Sablon",
),
("America/Boa_Vista", "(GMT-0400) America/Boa_Vista"),
(
"America/Campo_Grande",
"(GMT-0400) America/Campo_Grande",
),
("America/Caracas", "(GMT-0400) America/Caracas"),
("America/Cuiaba", "(GMT-0400) America/Cuiaba"),
("America/Curacao", "(GMT-0400) America/Curacao"),
("America/Dominica", "(GMT-0400) America/Dominica"),
("America/Glace_Bay", "(GMT-0400) America/Glace_Bay"),
("America/Goose_Bay", "(GMT-0400) America/Goose_Bay"),
("America/Grenada", "(GMT-0400) America/Grenada"),
("America/Guadeloupe", "(GMT-0400) America/Guadeloupe"),
("America/Guyana", "(GMT-0400) America/Guyana"),
("America/Halifax", "(GMT-0400) America/Halifax"),
("America/Kralendijk", "(GMT-0400) America/Kralendijk"),
("America/La_Paz", "(GMT-0400) America/La_Paz"),
(
"America/Lower_Princes",
"(GMT-0400) America/Lower_Princes",
),
("America/Manaus", "(GMT-0400) America/Manaus"),
("America/Marigot", "(GMT-0400) America/Marigot"),
("America/Martinique", "(GMT-0400) America/Martinique"),
("America/Moncton", "(GMT-0400) America/Moncton"),
("America/Montserrat", "(GMT-0400) America/Montserrat"),
(
"America/Port_of_Spain",
"(GMT-0400) America/Port_of_Spain",
),
("America/Porto_Velho", "(GMT-0400) America/Porto_Velho"),
("America/Puerto_Rico", "(GMT-0400) America/Puerto_Rico"),
(
"America/Santo_Domingo",
"(GMT-0400) America/Santo_Domingo",
),
(
"America/St_Barthelemy",
"(GMT-0400) America/St_Barthelemy",
),
("America/St_Kitts", "(GMT-0400) America/St_Kitts"),
("America/St_Lucia", "(GMT-0400) America/St_Lucia"),
("America/St_Thomas", "(GMT-0400) America/St_Thomas"),
("America/St_Vincent", "(GMT-0400) America/St_Vincent"),
("America/Thule", "(GMT-0400) America/Thule"),
("America/Tortola", "(GMT-0400) America/Tortola"),
("Atlantic/Bermuda", "(GMT-0400) Atlantic/Bermuda"),
("Canada/Atlantic", "(GMT-0400) Canada/Atlantic"),
("America/St_Johns", "(GMT-0330) America/St_Johns"),
("Canada/Newfoundland", "(GMT-0330) Canada/Newfoundland"),
("America/Araguaina", "(GMT-0300) America/Araguaina"),
(
"America/Argentina/Buenos_Aires",
"(GMT-0300) America/Argentina/Buenos_Aires",
),
(
"America/Argentina/Catamarca",
"(GMT-0300) America/Argentina/Catamarca",
),
(
"America/Argentina/Cordoba",
"(GMT-0300) America/Argentina/Cordoba",
),
(
"America/Argentina/Jujuy",
"(GMT-0300) America/Argentina/Jujuy",
),
(
"America/Argentina/La_Rioja",
"(GMT-0300) America/Argentina/La_Rioja",
),
(
"America/Argentina/Mendoza",
"(GMT-0300) America/Argentina/Mendoza",
),
(
"America/Argentina/Rio_Gallegos",
"(GMT-0300) America/Argentina/Rio_Gallegos",
),
(
"America/Argentina/Salta",
"(GMT-0300) America/Argentina/Salta",
),
(
"America/Argentina/San_Juan",
"(GMT-0300) America/Argentina/San_Juan",
),
(
"America/Argentina/San_Luis",
"(GMT-0300) America/Argentina/San_Luis",
),
(
"America/Argentina/Tucuman",
"(GMT-0300) America/Argentina/Tucuman",
),
(
"America/Argentina/Ushuaia",
"(GMT-0300) America/Argentina/Ushuaia",
),
("America/Asuncion", "(GMT-0300) America/Asuncion"),
("America/Bahia", "(GMT-0300) America/Bahia"),
("America/Belem", "(GMT-0300) America/Belem"),
("America/Cayenne", "(GMT-0300) America/Cayenne"),
("America/Fortaleza", "(GMT-0300) America/Fortaleza"),
("America/Maceio", "(GMT-0300) America/Maceio"),
("America/Miquelon", "(GMT-0300) America/Miquelon"),
("America/Montevideo", "(GMT-0300) America/Montevideo"),
("America/Paramaribo", "(GMT-0300) America/Paramaribo"),
(
"America/Punta_Arenas",
"(GMT-0300) America/Punta_Arenas",
),
("America/Recife", "(GMT-0300) America/Recife"),
("America/Santarem", "(GMT-0300) America/Santarem"),
("America/Santiago", "(GMT-0300) America/Santiago"),
("America/Sao_Paulo", "(GMT-0300) America/Sao_Paulo"),
("Antarctica/Palmer", "(GMT-0300) Antarctica/Palmer"),
("Antarctica/Rothera", "(GMT-0300) Antarctica/Rothera"),
("Atlantic/Stanley", "(GMT-0300) Atlantic/Stanley"),
("America/Noronha", "(GMT-0200) America/Noronha"),
("America/Nuuk", "(GMT-0200) America/Nuuk"),
(
"Atlantic/South_Georgia",
"(GMT-0200) Atlantic/South_Georgia",
),
(
"America/Scoresbysund",
"(GMT-0100) America/Scoresbysund",
),
("Atlantic/Azores", "(GMT-0100) Atlantic/Azores"),
("Atlantic/Cape_Verde", "(GMT-0100) Atlantic/Cape_Verde"),
("Africa/Abidjan", "(GMT+0000) Africa/Abidjan"),
("Africa/Accra", "(GMT+0000) Africa/Accra"),
("Africa/Bamako", "(GMT+0000) Africa/Bamako"),
("Africa/Banjul", "(GMT+0000) Africa/Banjul"),
("Africa/Bissau", "(GMT+0000) Africa/Bissau"),
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
("Africa/Freetown", "(GMT+0000) Africa/Freetown"),
("Africa/Lome", "(GMT+0000) Africa/Lome"),
("Africa/Monrovia", "(GMT+0000) Africa/Monrovia"),
("Africa/Nouakchott", "(GMT+0000) Africa/Nouakchott"),
("Africa/Ouagadougou", "(GMT+0000) Africa/Ouagadougou"),
("Africa/Sao_Tome", "(GMT+0000) Africa/Sao_Tome"),
(
"America/Danmarkshavn",
"(GMT+0000) America/Danmarkshavn",
),
("Antarctica/Troll", "(GMT+0000) Antarctica/Troll"),
("Atlantic/Canary", "(GMT+0000) Atlantic/Canary"),
("Atlantic/Faroe", "(GMT+0000) Atlantic/Faroe"),
("Atlantic/Madeira", "(GMT+0000) Atlantic/Madeira"),
("Atlantic/Reykjavik", "(GMT+0000) Atlantic/Reykjavik"),
("Atlantic/St_Helena", "(GMT+0000) Atlantic/St_Helena"),
("Europe/Dublin", "(GMT+0000) Europe/Dublin"),
("Europe/Guernsey", "(GMT+0000) Europe/Guernsey"),
("Europe/Isle_of_Man", "(GMT+0000) Europe/Isle_of_Man"),
("Europe/Jersey", "(GMT+0000) Europe/Jersey"),
("Europe/Lisbon", "(GMT+0000) Europe/Lisbon"),
("Europe/London", "(GMT+0000) Europe/London"),
("GMT", "(GMT+0000) GMT"),
("UTC", "(GMT+0000) UTC"),
("Africa/Algiers", "(GMT+0100) Africa/Algiers"),
("Africa/Bangui", "(GMT+0100) Africa/Bangui"),
("Africa/Brazzaville", "(GMT+0100) Africa/Brazzaville"),
("Africa/Casablanca", "(GMT+0100) Africa/Casablanca"),
("Africa/Ceuta", "(GMT+0100) Africa/Ceuta"),
("Africa/Douala", "(GMT+0100) Africa/Douala"),
("Africa/El_Aaiun", "(GMT+0100) Africa/El_Aaiun"),
("Africa/Kinshasa", "(GMT+0100) Africa/Kinshasa"),
("Africa/Lagos", "(GMT+0100) Africa/Lagos"),
("Africa/Libreville", "(GMT+0100) Africa/Libreville"),
("Africa/Luanda", "(GMT+0100) Africa/Luanda"),
("Africa/Malabo", "(GMT+0100) Africa/Malabo"),
("Africa/Ndjamena", "(GMT+0100) Africa/Ndjamena"),
("Africa/Niamey", "(GMT+0100) Africa/Niamey"),
("Africa/Porto-Novo", "(GMT+0100) Africa/Porto-Novo"),
("Africa/Tunis", "(GMT+0100) Africa/Tunis"),
("Arctic/Longyearbyen", "(GMT+0100) Arctic/Longyearbyen"),
("Europe/Amsterdam", "(GMT+0100) Europe/Amsterdam"),
("Europe/Andorra", "(GMT+0100) Europe/Andorra"),
("Europe/Belgrade", "(GMT+0100) Europe/Belgrade"),
("Europe/Berlin", "(GMT+0100) Europe/Berlin"),
("Europe/Bratislava", "(GMT+0100) Europe/Bratislava"),
("Europe/Brussels", "(GMT+0100) Europe/Brussels"),
("Europe/Budapest", "(GMT+0100) Europe/Budapest"),
("Europe/Busingen", "(GMT+0100) Europe/Busingen"),
("Europe/Copenhagen", "(GMT+0100) Europe/Copenhagen"),
("Europe/Gibraltar", "(GMT+0100) Europe/Gibraltar"),
("Europe/Ljubljana", "(GMT+0100) Europe/Ljubljana"),
("Europe/Luxembourg", "(GMT+0100) Europe/Luxembourg"),
("Europe/Madrid", "(GMT+0100) Europe/Madrid"),
("Europe/Malta", "(GMT+0100) Europe/Malta"),
("Europe/Monaco", "(GMT+0100) Europe/Monaco"),
("Europe/Oslo", "(GMT+0100) Europe/Oslo"),
("Europe/Paris", "(GMT+0100) Europe/Paris"),
("Europe/Podgorica", "(GMT+0100) Europe/Podgorica"),
("Europe/Prague", "(GMT+0100) Europe/Prague"),
("Europe/Rome", "(GMT+0100) Europe/Rome"),
("Europe/San_Marino", "(GMT+0100) Europe/San_Marino"),
("Europe/Sarajevo", "(GMT+0100) Europe/Sarajevo"),
("Europe/Skopje", "(GMT+0100) Europe/Skopje"),
("Europe/Stockholm", "(GMT+0100) Europe/Stockholm"),
("Europe/Tirane", "(GMT+0100) Europe/Tirane"),
("Europe/Vaduz", "(GMT+0100) Europe/Vaduz"),
("Europe/Vatican", "(GMT+0100) Europe/Vatican"),
("Europe/Vienna", "(GMT+0100) Europe/Vienna"),
("Europe/Warsaw", "(GMT+0100) Europe/Warsaw"),
("Europe/Zagreb", "(GMT+0100) Europe/Zagreb"),
("Europe/Zurich", "(GMT+0100) Europe/Zurich"),
("Africa/Blantyre", "(GMT+0200) Africa/Blantyre"),
("Africa/Bujumbura", "(GMT+0200) Africa/Bujumbura"),
("Africa/Cairo", "(GMT+0200) Africa/Cairo"),
("Africa/Gaborone", "(GMT+0200) Africa/Gaborone"),
("Africa/Harare", "(GMT+0200) Africa/Harare"),
("Africa/Johannesburg", "(GMT+0200) Africa/Johannesburg"),
("Africa/Juba", "(GMT+0200) Africa/Juba"),
("Africa/Khartoum", "(GMT+0200) Africa/Khartoum"),
("Africa/Kigali", "(GMT+0200) Africa/Kigali"),
("Africa/Lubumbashi", "(GMT+0200) Africa/Lubumbashi"),
("Africa/Lusaka", "(GMT+0200) Africa/Lusaka"),
("Africa/Maputo", "(GMT+0200) Africa/Maputo"),
("Africa/Maseru", "(GMT+0200) Africa/Maseru"),
("Africa/Mbabane", "(GMT+0200) Africa/Mbabane"),
("Africa/Tripoli", "(GMT+0200) Africa/Tripoli"),
("Africa/Windhoek", "(GMT+0200) Africa/Windhoek"),
("Asia/Beirut", "(GMT+0200) Asia/Beirut"),
("Asia/Famagusta", "(GMT+0200) Asia/Famagusta"),
("Asia/Gaza", "(GMT+0200) Asia/Gaza"),
("Asia/Hebron", "(GMT+0200) Asia/Hebron"),
("Asia/Jerusalem", "(GMT+0200) Asia/Jerusalem"),
("Asia/Nicosia", "(GMT+0200) Asia/Nicosia"),
("Europe/Athens", "(GMT+0200) Europe/Athens"),
("Europe/Bucharest", "(GMT+0200) Europe/Bucharest"),
("Europe/Chisinau", "(GMT+0200) Europe/Chisinau"),
("Europe/Helsinki", "(GMT+0200) Europe/Helsinki"),
("Europe/Kaliningrad", "(GMT+0200) Europe/Kaliningrad"),
("Europe/Kyiv", "(GMT+0200) Europe/Kyiv"),
("Europe/Mariehamn", "(GMT+0200) Europe/Mariehamn"),
("Europe/Riga", "(GMT+0200) Europe/Riga"),
("Europe/Sofia", "(GMT+0200) Europe/Sofia"),
("Europe/Tallinn", "(GMT+0200) Europe/Tallinn"),
("Europe/Vilnius", "(GMT+0200) Europe/Vilnius"),
("Africa/Addis_Ababa", "(GMT+0300) Africa/Addis_Ababa"),
("Africa/Asmara", "(GMT+0300) Africa/Asmara"),
(
"Africa/Dar_es_Salaam",
"(GMT+0300) Africa/Dar_es_Salaam",
),
("Africa/Djibouti", "(GMT+0300) Africa/Djibouti"),
("Africa/Kampala", "(GMT+0300) Africa/Kampala"),
("Africa/Mogadishu", "(GMT+0300) Africa/Mogadishu"),
("Africa/Nairobi", "(GMT+0300) Africa/Nairobi"),
("Antarctica/Syowa", "(GMT+0300) Antarctica/Syowa"),
("Asia/Aden", "(GMT+0300) Asia/Aden"),
("Asia/Amman", "(GMT+0300) Asia/Amman"),
("Asia/Baghdad", "(GMT+0300) Asia/Baghdad"),
("Asia/Bahrain", "(GMT+0300) Asia/Bahrain"),
("Asia/Damascus", "(GMT+0300) Asia/Damascus"),
("Asia/Kuwait", "(GMT+0300) Asia/Kuwait"),
("Asia/Qatar", "(GMT+0300) Asia/Qatar"),
("Asia/Riyadh", "(GMT+0300) Asia/Riyadh"),
("Europe/Istanbul", "(GMT+0300) Europe/Istanbul"),
("Europe/Kirov", "(GMT+0300) Europe/Kirov"),
("Europe/Minsk", "(GMT+0300) Europe/Minsk"),
("Europe/Moscow", "(GMT+0300) Europe/Moscow"),
("Europe/Simferopol", "(GMT+0300) Europe/Simferopol"),
("Europe/Volgograd", "(GMT+0300) Europe/Volgograd"),
("Indian/Antananarivo", "(GMT+0300) Indian/Antananarivo"),
("Indian/Comoro", "(GMT+0300) Indian/Comoro"),
("Indian/Mayotte", "(GMT+0300) Indian/Mayotte"),
("Asia/Tehran", "(GMT+0330) Asia/Tehran"),
("Asia/Baku", "(GMT+0400) Asia/Baku"),
("Asia/Dubai", "(GMT+0400) Asia/Dubai"),
("Asia/Muscat", "(GMT+0400) Asia/Muscat"),
("Asia/Tbilisi", "(GMT+0400) Asia/Tbilisi"),
("Asia/Yerevan", "(GMT+0400) Asia/Yerevan"),
("Europe/Astrakhan", "(GMT+0400) Europe/Astrakhan"),
("Europe/Samara", "(GMT+0400) Europe/Samara"),
("Europe/Saratov", "(GMT+0400) Europe/Saratov"),
("Europe/Ulyanovsk", "(GMT+0400) Europe/Ulyanovsk"),
("Indian/Mahe", "(GMT+0400) Indian/Mahe"),
("Indian/Mauritius", "(GMT+0400) Indian/Mauritius"),
("Indian/Reunion", "(GMT+0400) Indian/Reunion"),
("Asia/Kabul", "(GMT+0430) Asia/Kabul"),
("Antarctica/Mawson", "(GMT+0500) Antarctica/Mawson"),
("Asia/Aqtau", "(GMT+0500) Asia/Aqtau"),
("Asia/Aqtobe", "(GMT+0500) Asia/Aqtobe"),
("Asia/Ashgabat", "(GMT+0500) Asia/Ashgabat"),
("Asia/Atyrau", "(GMT+0500) Asia/Atyrau"),
("Asia/Dushanbe", "(GMT+0500) Asia/Dushanbe"),
("Asia/Karachi", "(GMT+0500) Asia/Karachi"),
("Asia/Oral", "(GMT+0500) Asia/Oral"),
("Asia/Qyzylorda", "(GMT+0500) Asia/Qyzylorda"),
("Asia/Samarkand", "(GMT+0500) Asia/Samarkand"),
("Asia/Tashkent", "(GMT+0500) Asia/Tashkent"),
("Asia/Yekaterinburg", "(GMT+0500) Asia/Yekaterinburg"),
("Indian/Kerguelen", "(GMT+0500) Indian/Kerguelen"),
("Indian/Maldives", "(GMT+0500) Indian/Maldives"),
("Asia/Colombo", "(GMT+0530) Asia/Colombo"),
("Asia/Kolkata", "(GMT+0530) Asia/Kolkata"),
("Asia/Kathmandu", "(GMT+0545) Asia/Kathmandu"),
("Antarctica/Vostok", "(GMT+0600) Antarctica/Vostok"),
("Asia/Almaty", "(GMT+0600) Asia/Almaty"),
("Asia/Bishkek", "(GMT+0600) Asia/Bishkek"),
("Asia/Dhaka", "(GMT+0600) Asia/Dhaka"),
("Asia/Omsk", "(GMT+0600) Asia/Omsk"),
("Asia/Qostanay", "(GMT+0600) Asia/Qostanay"),
("Asia/Thimphu", "(GMT+0600) Asia/Thimphu"),
("Asia/Urumqi", "(GMT+0600) Asia/Urumqi"),
("Indian/Chagos", "(GMT+0600) Indian/Chagos"),
("Asia/Yangon", "(GMT+0630) Asia/Yangon"),
("Indian/Cocos", "(GMT+0630) Indian/Cocos"),
("Antarctica/Davis", "(GMT+0700) Antarctica/Davis"),
("Asia/Bangkok", "(GMT+0700) Asia/Bangkok"),
("Asia/Barnaul", "(GMT+0700) Asia/Barnaul"),
("Asia/Ho_Chi_Minh", "(GMT+0700) Asia/Ho_Chi_Minh"),
("Asia/Hovd", "(GMT+0700) Asia/Hovd"),
("Asia/Jakarta", "(GMT+0700) Asia/Jakarta"),
("Asia/Krasnoyarsk", "(GMT+0700) Asia/Krasnoyarsk"),
("Asia/Novokuznetsk", "(GMT+0700) Asia/Novokuznetsk"),
("Asia/Novosibirsk", "(GMT+0700) Asia/Novosibirsk"),
("Asia/Phnom_Penh", "(GMT+0700) Asia/Phnom_Penh"),
("Asia/Pontianak", "(GMT+0700) Asia/Pontianak"),
("Asia/Tomsk", "(GMT+0700) Asia/Tomsk"),
("Asia/Vientiane", "(GMT+0700) Asia/Vientiane"),
("Indian/Christmas", "(GMT+0700) Indian/Christmas"),
("Asia/Brunei", "(GMT+0800) Asia/Brunei"),
("Asia/Choibalsan", "(GMT+0800) Asia/Choibalsan"),
("Asia/Hong_Kong", "(GMT+0800) Asia/Hong_Kong"),
("Asia/Irkutsk", "(GMT+0800) Asia/Irkutsk"),
("Asia/Kuala_Lumpur", "(GMT+0800) Asia/Kuala_Lumpur"),
("Asia/Kuching", "(GMT+0800) Asia/Kuching"),
("Asia/Macau", "(GMT+0800) Asia/Macau"),
("Asia/Makassar", "(GMT+0800) Asia/Makassar"),
("Asia/Manila", "(GMT+0800) Asia/Manila"),
("Asia/Shanghai", "(GMT+0800) Asia/Shanghai"),
("Asia/Singapore", "(GMT+0800) Asia/Singapore"),
("Asia/Taipei", "(GMT+0800) Asia/Taipei"),
("Asia/Ulaanbaatar", "(GMT+0800) Asia/Ulaanbaatar"),
("Australia/Perth", "(GMT+0800) Australia/Perth"),
("Australia/Eucla", "(GMT+0845) Australia/Eucla"),
("Asia/Chita", "(GMT+0900) Asia/Chita"),
("Asia/Dili", "(GMT+0900) Asia/Dili"),
("Asia/Jayapura", "(GMT+0900) Asia/Jayapura"),
("Asia/Khandyga", "(GMT+0900) Asia/Khandyga"),
("Asia/Pyongyang", "(GMT+0900) Asia/Pyongyang"),
("Asia/Seoul", "(GMT+0900) Asia/Seoul"),
("Asia/Tokyo", "(GMT+0900) Asia/Tokyo"),
("Asia/Yakutsk", "(GMT+0900) Asia/Yakutsk"),
("Pacific/Palau", "(GMT+0900) Pacific/Palau"),
("Australia/Darwin", "(GMT+0930) Australia/Darwin"),
(
"Antarctica/DumontDUrville",
"(GMT+1000) Antarctica/DumontDUrville",
),
("Asia/Ust-Nera", "(GMT+1000) Asia/Ust-Nera"),
("Asia/Vladivostok", "(GMT+1000) Asia/Vladivostok"),
("Australia/Brisbane", "(GMT+1000) Australia/Brisbane"),
("Australia/Lindeman", "(GMT+1000) Australia/Lindeman"),
("Pacific/Chuuk", "(GMT+1000) Pacific/Chuuk"),
("Pacific/Guam", "(GMT+1000) Pacific/Guam"),
(
"Pacific/Port_Moresby",
"(GMT+1000) Pacific/Port_Moresby",
),
("Pacific/Saipan", "(GMT+1000) Pacific/Saipan"),
("Australia/Adelaide", "(GMT+1030) Australia/Adelaide"),
(
"Australia/Broken_Hill",
"(GMT+1030) Australia/Broken_Hill",
),
("Antarctica/Casey", "(GMT+1100) Antarctica/Casey"),
(
"Antarctica/Macquarie",
"(GMT+1100) Antarctica/Macquarie",
),
("Asia/Magadan", "(GMT+1100) Asia/Magadan"),
("Asia/Sakhalin", "(GMT+1100) Asia/Sakhalin"),
("Asia/Srednekolymsk", "(GMT+1100) Asia/Srednekolymsk"),
("Australia/Hobart", "(GMT+1100) Australia/Hobart"),
("Australia/Lord_Howe", "(GMT+1100) Australia/Lord_Howe"),
("Australia/Melbourne", "(GMT+1100) Australia/Melbourne"),
("Australia/Sydney", "(GMT+1100) Australia/Sydney"),
(
"Pacific/Bougainville",
"(GMT+1100) Pacific/Bougainville",
),
("Pacific/Efate", "(GMT+1100) Pacific/Efate"),
("Pacific/Guadalcanal", "(GMT+1100) Pacific/Guadalcanal"),
("Pacific/Kosrae", "(GMT+1100) Pacific/Kosrae"),
("Pacific/Noumea", "(GMT+1100) Pacific/Noumea"),
("Pacific/Pohnpei", "(GMT+1100) Pacific/Pohnpei"),
("Asia/Anadyr", "(GMT+1200) Asia/Anadyr"),
("Asia/Kamchatka", "(GMT+1200) Asia/Kamchatka"),
("Pacific/Fiji", "(GMT+1200) Pacific/Fiji"),
("Pacific/Funafuti", "(GMT+1200) Pacific/Funafuti"),
("Pacific/Kwajalein", "(GMT+1200) Pacific/Kwajalein"),
("Pacific/Majuro", "(GMT+1200) Pacific/Majuro"),
("Pacific/Nauru", "(GMT+1200) Pacific/Nauru"),
("Pacific/Norfolk", "(GMT+1200) Pacific/Norfolk"),
("Pacific/Tarawa", "(GMT+1200) Pacific/Tarawa"),
("Pacific/Wake", "(GMT+1200) Pacific/Wake"),
("Pacific/Wallis", "(GMT+1200) Pacific/Wallis"),
("Antarctica/McMurdo", "(GMT+1300) Antarctica/McMurdo"),
("Pacific/Apia", "(GMT+1300) Pacific/Apia"),
("Pacific/Auckland", "(GMT+1300) Pacific/Auckland"),
("Pacific/Fakaofo", "(GMT+1300) Pacific/Fakaofo"),
("Pacific/Kanton", "(GMT+1300) Pacific/Kanton"),
("Pacific/Tongatapu", "(GMT+1300) Pacific/Tongatapu"),
("Pacific/Chatham", "(GMT+1345) Pacific/Chatham"),
("Pacific/Kiritimati", "(GMT+1400) Pacific/Kiritimati"),
],
default="UTC",
max_length=255,
),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.18 on 2025-01-29 04:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"profiles",
"0020_userprofile_ntfy_enabled_userprofile_ntfy_url_and_more",
),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="lichess_username",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,606 @@
# Generated by Django 4.2.19 on 2025-04-03 02:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0021_userprofile_lichess_username"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="task_context_tags_str",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="userprofile",
name="timezone",
field=models.CharField(
choices=[
("Pacific/Midway", "(GMT-1100) Pacific/Midway"),
("Pacific/Niue", "(GMT-1100) Pacific/Niue"),
("Pacific/Pago_Pago", "(GMT-1100) Pacific/Pago_Pago"),
("Pacific/Honolulu", "(GMT-1000) Pacific/Honolulu"),
("Pacific/Rarotonga", "(GMT-1000) Pacific/Rarotonga"),
("Pacific/Tahiti", "(GMT-1000) Pacific/Tahiti"),
("US/Hawaii", "(GMT-1000) US/Hawaii"),
("Pacific/Marquesas", "(GMT-0930) Pacific/Marquesas"),
("America/Adak", "(GMT-0900) America/Adak"),
("Pacific/Gambier", "(GMT-0900) Pacific/Gambier"),
("America/Anchorage", "(GMT-0800) America/Anchorage"),
("America/Juneau", "(GMT-0800) America/Juneau"),
("America/Metlakatla", "(GMT-0800) America/Metlakatla"),
("America/Nome", "(GMT-0800) America/Nome"),
("America/Sitka", "(GMT-0800) America/Sitka"),
("America/Yakutat", "(GMT-0800) America/Yakutat"),
("Pacific/Pitcairn", "(GMT-0800) Pacific/Pitcairn"),
("US/Alaska", "(GMT-0800) US/Alaska"),
("America/Creston", "(GMT-0700) America/Creston"),
("America/Dawson", "(GMT-0700) America/Dawson"),
(
"America/Dawson_Creek",
"(GMT-0700) America/Dawson_Creek",
),
("America/Fort_Nelson", "(GMT-0700) America/Fort_Nelson"),
("America/Hermosillo", "(GMT-0700) America/Hermosillo"),
("America/Los_Angeles", "(GMT-0700) America/Los_Angeles"),
("America/Mazatlan", "(GMT-0700) America/Mazatlan"),
("America/Phoenix", "(GMT-0700) America/Phoenix"),
("America/Tijuana", "(GMT-0700) America/Tijuana"),
("America/Vancouver", "(GMT-0700) America/Vancouver"),
("America/Whitehorse", "(GMT-0700) America/Whitehorse"),
("Canada/Pacific", "(GMT-0700) Canada/Pacific"),
("US/Arizona", "(GMT-0700) US/Arizona"),
("US/Pacific", "(GMT-0700) US/Pacific"),
(
"America/Bahia_Banderas",
"(GMT-0600) America/Bahia_Banderas",
),
("America/Belize", "(GMT-0600) America/Belize"),
("America/Boise", "(GMT-0600) America/Boise"),
(
"America/Cambridge_Bay",
"(GMT-0600) America/Cambridge_Bay",
),
("America/Chihuahua", "(GMT-0600) America/Chihuahua"),
(
"America/Ciudad_Juarez",
"(GMT-0600) America/Ciudad_Juarez",
),
("America/Costa_Rica", "(GMT-0600) America/Costa_Rica"),
("America/Denver", "(GMT-0600) America/Denver"),
("America/Edmonton", "(GMT-0600) America/Edmonton"),
("America/El_Salvador", "(GMT-0600) America/El_Salvador"),
("America/Guatemala", "(GMT-0600) America/Guatemala"),
("America/Inuvik", "(GMT-0600) America/Inuvik"),
("America/Managua", "(GMT-0600) America/Managua"),
("America/Merida", "(GMT-0600) America/Merida"),
("America/Mexico_City", "(GMT-0600) America/Mexico_City"),
("America/Monterrey", "(GMT-0600) America/Monterrey"),
("America/Regina", "(GMT-0600) America/Regina"),
(
"America/Swift_Current",
"(GMT-0600) America/Swift_Current",
),
("America/Tegucigalpa", "(GMT-0600) America/Tegucigalpa"),
("America/Yellowknife", "(GMT-0600) America/Yellowknife"),
("Canada/Mountain", "(GMT-0600) Canada/Mountain"),
("Pacific/Galapagos", "(GMT-0600) Pacific/Galapagos"),
("US/Mountain", "(GMT-0600) US/Mountain"),
("America/Atikokan", "(GMT-0500) America/Atikokan"),
("America/Bogota", "(GMT-0500) America/Bogota"),
("America/Cancun", "(GMT-0500) America/Cancun"),
("America/Cayman", "(GMT-0500) America/Cayman"),
("America/Chicago", "(GMT-0500) America/Chicago"),
("America/Eirunepe", "(GMT-0500) America/Eirunepe"),
("America/Guayaquil", "(GMT-0500) America/Guayaquil"),
(
"America/Indiana/Knox",
"(GMT-0500) America/Indiana/Knox",
),
(
"America/Indiana/Tell_City",
"(GMT-0500) America/Indiana/Tell_City",
),
("America/Jamaica", "(GMT-0500) America/Jamaica"),
("America/Lima", "(GMT-0500) America/Lima"),
("America/Matamoros", "(GMT-0500) America/Matamoros"),
("America/Menominee", "(GMT-0500) America/Menominee"),
(
"America/North_Dakota/Beulah",
"(GMT-0500) America/North_Dakota/Beulah",
),
(
"America/North_Dakota/Center",
"(GMT-0500) America/North_Dakota/Center",
),
(
"America/North_Dakota/New_Salem",
"(GMT-0500) America/North_Dakota/New_Salem",
),
("America/Ojinaga", "(GMT-0500) America/Ojinaga"),
("America/Panama", "(GMT-0500) America/Panama"),
(
"America/Rankin_Inlet",
"(GMT-0500) America/Rankin_Inlet",
),
("America/Resolute", "(GMT-0500) America/Resolute"),
("America/Rio_Branco", "(GMT-0500) America/Rio_Branco"),
("America/Winnipeg", "(GMT-0500) America/Winnipeg"),
("Canada/Central", "(GMT-0500) Canada/Central"),
("Pacific/Easter", "(GMT-0500) Pacific/Easter"),
("US/Central", "(GMT-0500) US/Central"),
("America/Anguilla", "(GMT-0400) America/Anguilla"),
("America/Antigua", "(GMT-0400) America/Antigua"),
("America/Aruba", "(GMT-0400) America/Aruba"),
("America/Asuncion", "(GMT-0400) America/Asuncion"),
("America/Barbados", "(GMT-0400) America/Barbados"),
(
"America/Blanc-Sablon",
"(GMT-0400) America/Blanc-Sablon",
),
("America/Boa_Vista", "(GMT-0400) America/Boa_Vista"),
(
"America/Campo_Grande",
"(GMT-0400) America/Campo_Grande",
),
("America/Caracas", "(GMT-0400) America/Caracas"),
("America/Cuiaba", "(GMT-0400) America/Cuiaba"),
("America/Curacao", "(GMT-0400) America/Curacao"),
("America/Detroit", "(GMT-0400) America/Detroit"),
("America/Dominica", "(GMT-0400) America/Dominica"),
("America/Grand_Turk", "(GMT-0400) America/Grand_Turk"),
("America/Grenada", "(GMT-0400) America/Grenada"),
("America/Guadeloupe", "(GMT-0400) America/Guadeloupe"),
("America/Guyana", "(GMT-0400) America/Guyana"),
("America/Havana", "(GMT-0400) America/Havana"),
(
"America/Indiana/Indianapolis",
"(GMT-0400) America/Indiana/Indianapolis",
),
(
"America/Indiana/Marengo",
"(GMT-0400) America/Indiana/Marengo",
),
(
"America/Indiana/Petersburg",
"(GMT-0400) America/Indiana/Petersburg",
),
(
"America/Indiana/Vevay",
"(GMT-0400) America/Indiana/Vevay",
),
(
"America/Indiana/Vincennes",
"(GMT-0400) America/Indiana/Vincennes",
),
(
"America/Indiana/Winamac",
"(GMT-0400) America/Indiana/Winamac",
),
("America/Iqaluit", "(GMT-0400) America/Iqaluit"),
(
"America/Kentucky/Louisville",
"(GMT-0400) America/Kentucky/Louisville",
),
(
"America/Kentucky/Monticello",
"(GMT-0400) America/Kentucky/Monticello",
),
("America/Kralendijk", "(GMT-0400) America/Kralendijk"),
("America/La_Paz", "(GMT-0400) America/La_Paz"),
(
"America/Lower_Princes",
"(GMT-0400) America/Lower_Princes",
),
("America/Manaus", "(GMT-0400) America/Manaus"),
("America/Marigot", "(GMT-0400) America/Marigot"),
("America/Martinique", "(GMT-0400) America/Martinique"),
("America/Montserrat", "(GMT-0400) America/Montserrat"),
("America/Nassau", "(GMT-0400) America/Nassau"),
("America/New_York", "(GMT-0400) America/New_York"),
(
"America/Port-au-Prince",
"(GMT-0400) America/Port-au-Prince",
),
(
"America/Port_of_Spain",
"(GMT-0400) America/Port_of_Spain",
),
("America/Porto_Velho", "(GMT-0400) America/Porto_Velho"),
("America/Puerto_Rico", "(GMT-0400) America/Puerto_Rico"),
(
"America/Santo_Domingo",
"(GMT-0400) America/Santo_Domingo",
),
(
"America/St_Barthelemy",
"(GMT-0400) America/St_Barthelemy",
),
("America/St_Kitts", "(GMT-0400) America/St_Kitts"),
("America/St_Lucia", "(GMT-0400) America/St_Lucia"),
("America/St_Thomas", "(GMT-0400) America/St_Thomas"),
("America/St_Vincent", "(GMT-0400) America/St_Vincent"),
("America/Toronto", "(GMT-0400) America/Toronto"),
("America/Tortola", "(GMT-0400) America/Tortola"),
("Canada/Eastern", "(GMT-0400) Canada/Eastern"),
("US/Eastern", "(GMT-0400) US/Eastern"),
("America/Araguaina", "(GMT-0300) America/Araguaina"),
(
"America/Argentina/Buenos_Aires",
"(GMT-0300) America/Argentina/Buenos_Aires",
),
(
"America/Argentina/Catamarca",
"(GMT-0300) America/Argentina/Catamarca",
),
(
"America/Argentina/Cordoba",
"(GMT-0300) America/Argentina/Cordoba",
),
(
"America/Argentina/Jujuy",
"(GMT-0300) America/Argentina/Jujuy",
),
(
"America/Argentina/La_Rioja",
"(GMT-0300) America/Argentina/La_Rioja",
),
(
"America/Argentina/Mendoza",
"(GMT-0300) America/Argentina/Mendoza",
),
(
"America/Argentina/Rio_Gallegos",
"(GMT-0300) America/Argentina/Rio_Gallegos",
),
(
"America/Argentina/Salta",
"(GMT-0300) America/Argentina/Salta",
),
(
"America/Argentina/San_Juan",
"(GMT-0300) America/Argentina/San_Juan",
),
(
"America/Argentina/San_Luis",
"(GMT-0300) America/Argentina/San_Luis",
),
(
"America/Argentina/Tucuman",
"(GMT-0300) America/Argentina/Tucuman",
),
(
"America/Argentina/Ushuaia",
"(GMT-0300) America/Argentina/Ushuaia",
),
("America/Bahia", "(GMT-0300) America/Bahia"),
("America/Belem", "(GMT-0300) America/Belem"),
("America/Cayenne", "(GMT-0300) America/Cayenne"),
("America/Fortaleza", "(GMT-0300) America/Fortaleza"),
("America/Glace_Bay", "(GMT-0300) America/Glace_Bay"),
("America/Goose_Bay", "(GMT-0300) America/Goose_Bay"),
("America/Halifax", "(GMT-0300) America/Halifax"),
("America/Maceio", "(GMT-0300) America/Maceio"),
("America/Moncton", "(GMT-0300) America/Moncton"),
("America/Montevideo", "(GMT-0300) America/Montevideo"),
("America/Paramaribo", "(GMT-0300) America/Paramaribo"),
(
"America/Punta_Arenas",
"(GMT-0300) America/Punta_Arenas",
),
("America/Recife", "(GMT-0300) America/Recife"),
("America/Santarem", "(GMT-0300) America/Santarem"),
("America/Santiago", "(GMT-0300) America/Santiago"),
("America/Sao_Paulo", "(GMT-0300) America/Sao_Paulo"),
("America/Thule", "(GMT-0300) America/Thule"),
("Antarctica/Palmer", "(GMT-0300) Antarctica/Palmer"),
("Antarctica/Rothera", "(GMT-0300) Antarctica/Rothera"),
("Atlantic/Bermuda", "(GMT-0300) Atlantic/Bermuda"),
("Atlantic/Stanley", "(GMT-0300) Atlantic/Stanley"),
("Canada/Atlantic", "(GMT-0300) Canada/Atlantic"),
("America/St_Johns", "(GMT-0230) America/St_Johns"),
("Canada/Newfoundland", "(GMT-0230) Canada/Newfoundland"),
("America/Miquelon", "(GMT-0200) America/Miquelon"),
("America/Noronha", "(GMT-0200) America/Noronha"),
("America/Nuuk", "(GMT-0200) America/Nuuk"),
(
"Atlantic/South_Georgia",
"(GMT-0200) Atlantic/South_Georgia",
),
("Atlantic/Cape_Verde", "(GMT-0100) Atlantic/Cape_Verde"),
("Africa/Abidjan", "(GMT+0000) Africa/Abidjan"),
("Africa/Accra", "(GMT+0000) Africa/Accra"),
("Africa/Bamako", "(GMT+0000) Africa/Bamako"),
("Africa/Banjul", "(GMT+0000) Africa/Banjul"),
("Africa/Bissau", "(GMT+0000) Africa/Bissau"),
("Africa/Casablanca", "(GMT+0000) Africa/Casablanca"),
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
("Africa/El_Aaiun", "(GMT+0000) Africa/El_Aaiun"),
("Africa/Freetown", "(GMT+0000) Africa/Freetown"),
("Africa/Lome", "(GMT+0000) Africa/Lome"),
("Africa/Monrovia", "(GMT+0000) Africa/Monrovia"),
("Africa/Nouakchott", "(GMT+0000) Africa/Nouakchott"),
("Africa/Ouagadougou", "(GMT+0000) Africa/Ouagadougou"),
("Africa/Sao_Tome", "(GMT+0000) Africa/Sao_Tome"),
(
"America/Danmarkshavn",
"(GMT+0000) America/Danmarkshavn",
),
(
"America/Scoresbysund",
"(GMT+0000) America/Scoresbysund",
),
("Atlantic/Azores", "(GMT+0000) Atlantic/Azores"),
("Atlantic/Reykjavik", "(GMT+0000) Atlantic/Reykjavik"),
("Atlantic/St_Helena", "(GMT+0000) Atlantic/St_Helena"),
("GMT", "(GMT+0000) GMT"),
("UTC", "(GMT+0000) UTC"),
("Africa/Algiers", "(GMT+0100) Africa/Algiers"),
("Africa/Bangui", "(GMT+0100) Africa/Bangui"),
("Africa/Brazzaville", "(GMT+0100) Africa/Brazzaville"),
("Africa/Douala", "(GMT+0100) Africa/Douala"),
("Africa/Kinshasa", "(GMT+0100) Africa/Kinshasa"),
("Africa/Lagos", "(GMT+0100) Africa/Lagos"),
("Africa/Libreville", "(GMT+0100) Africa/Libreville"),
("Africa/Luanda", "(GMT+0100) Africa/Luanda"),
("Africa/Malabo", "(GMT+0100) Africa/Malabo"),
("Africa/Ndjamena", "(GMT+0100) Africa/Ndjamena"),
("Africa/Niamey", "(GMT+0100) Africa/Niamey"),
("Africa/Porto-Novo", "(GMT+0100) Africa/Porto-Novo"),
("Africa/Tunis", "(GMT+0100) Africa/Tunis"),
("Atlantic/Canary", "(GMT+0100) Atlantic/Canary"),
("Atlantic/Faroe", "(GMT+0100) Atlantic/Faroe"),
("Atlantic/Madeira", "(GMT+0100) Atlantic/Madeira"),
("Europe/Dublin", "(GMT+0100) Europe/Dublin"),
("Europe/Guernsey", "(GMT+0100) Europe/Guernsey"),
("Europe/Isle_of_Man", "(GMT+0100) Europe/Isle_of_Man"),
("Europe/Jersey", "(GMT+0100) Europe/Jersey"),
("Europe/Lisbon", "(GMT+0100) Europe/Lisbon"),
("Europe/London", "(GMT+0100) Europe/London"),
("Africa/Blantyre", "(GMT+0200) Africa/Blantyre"),
("Africa/Bujumbura", "(GMT+0200) Africa/Bujumbura"),
("Africa/Cairo", "(GMT+0200) Africa/Cairo"),
("Africa/Ceuta", "(GMT+0200) Africa/Ceuta"),
("Africa/Gaborone", "(GMT+0200) Africa/Gaborone"),
("Africa/Harare", "(GMT+0200) Africa/Harare"),
("Africa/Johannesburg", "(GMT+0200) Africa/Johannesburg"),
("Africa/Juba", "(GMT+0200) Africa/Juba"),
("Africa/Khartoum", "(GMT+0200) Africa/Khartoum"),
("Africa/Kigali", "(GMT+0200) Africa/Kigali"),
("Africa/Lubumbashi", "(GMT+0200) Africa/Lubumbashi"),
("Africa/Lusaka", "(GMT+0200) Africa/Lusaka"),
("Africa/Maputo", "(GMT+0200) Africa/Maputo"),
("Africa/Maseru", "(GMT+0200) Africa/Maseru"),
("Africa/Mbabane", "(GMT+0200) Africa/Mbabane"),
("Africa/Tripoli", "(GMT+0200) Africa/Tripoli"),
("Africa/Windhoek", "(GMT+0200) Africa/Windhoek"),
("Antarctica/Troll", "(GMT+0200) Antarctica/Troll"),
("Arctic/Longyearbyen", "(GMT+0200) Arctic/Longyearbyen"),
("Europe/Amsterdam", "(GMT+0200) Europe/Amsterdam"),
("Europe/Andorra", "(GMT+0200) Europe/Andorra"),
("Europe/Belgrade", "(GMT+0200) Europe/Belgrade"),
("Europe/Berlin", "(GMT+0200) Europe/Berlin"),
("Europe/Bratislava", "(GMT+0200) Europe/Bratislava"),
("Europe/Brussels", "(GMT+0200) Europe/Brussels"),
("Europe/Budapest", "(GMT+0200) Europe/Budapest"),
("Europe/Busingen", "(GMT+0200) Europe/Busingen"),
("Europe/Copenhagen", "(GMT+0200) Europe/Copenhagen"),
("Europe/Gibraltar", "(GMT+0200) Europe/Gibraltar"),
("Europe/Kaliningrad", "(GMT+0200) Europe/Kaliningrad"),
("Europe/Ljubljana", "(GMT+0200) Europe/Ljubljana"),
("Europe/Luxembourg", "(GMT+0200) Europe/Luxembourg"),
("Europe/Madrid", "(GMT+0200) Europe/Madrid"),
("Europe/Malta", "(GMT+0200) Europe/Malta"),
("Europe/Monaco", "(GMT+0200) Europe/Monaco"),
("Europe/Oslo", "(GMT+0200) Europe/Oslo"),
("Europe/Paris", "(GMT+0200) Europe/Paris"),
("Europe/Podgorica", "(GMT+0200) Europe/Podgorica"),
("Europe/Prague", "(GMT+0200) Europe/Prague"),
("Europe/Rome", "(GMT+0200) Europe/Rome"),
("Europe/San_Marino", "(GMT+0200) Europe/San_Marino"),
("Europe/Sarajevo", "(GMT+0200) Europe/Sarajevo"),
("Europe/Skopje", "(GMT+0200) Europe/Skopje"),
("Europe/Stockholm", "(GMT+0200) Europe/Stockholm"),
("Europe/Tirane", "(GMT+0200) Europe/Tirane"),
("Europe/Vaduz", "(GMT+0200) Europe/Vaduz"),
("Europe/Vatican", "(GMT+0200) Europe/Vatican"),
("Europe/Vienna", "(GMT+0200) Europe/Vienna"),
("Europe/Warsaw", "(GMT+0200) Europe/Warsaw"),
("Europe/Zagreb", "(GMT+0200) Europe/Zagreb"),
("Europe/Zurich", "(GMT+0200) Europe/Zurich"),
("Africa/Addis_Ababa", "(GMT+0300) Africa/Addis_Ababa"),
("Africa/Asmara", "(GMT+0300) Africa/Asmara"),
(
"Africa/Dar_es_Salaam",
"(GMT+0300) Africa/Dar_es_Salaam",
),
("Africa/Djibouti", "(GMT+0300) Africa/Djibouti"),
("Africa/Kampala", "(GMT+0300) Africa/Kampala"),
("Africa/Mogadishu", "(GMT+0300) Africa/Mogadishu"),
("Africa/Nairobi", "(GMT+0300) Africa/Nairobi"),
("Antarctica/Syowa", "(GMT+0300) Antarctica/Syowa"),
("Asia/Aden", "(GMT+0300) Asia/Aden"),
("Asia/Amman", "(GMT+0300) Asia/Amman"),
("Asia/Baghdad", "(GMT+0300) Asia/Baghdad"),
("Asia/Bahrain", "(GMT+0300) Asia/Bahrain"),
("Asia/Beirut", "(GMT+0300) Asia/Beirut"),
("Asia/Damascus", "(GMT+0300) Asia/Damascus"),
("Asia/Famagusta", "(GMT+0300) Asia/Famagusta"),
("Asia/Gaza", "(GMT+0300) Asia/Gaza"),
("Asia/Hebron", "(GMT+0300) Asia/Hebron"),
("Asia/Jerusalem", "(GMT+0300) Asia/Jerusalem"),
("Asia/Kuwait", "(GMT+0300) Asia/Kuwait"),
("Asia/Nicosia", "(GMT+0300) Asia/Nicosia"),
("Asia/Qatar", "(GMT+0300) Asia/Qatar"),
("Asia/Riyadh", "(GMT+0300) Asia/Riyadh"),
("Europe/Athens", "(GMT+0300) Europe/Athens"),
("Europe/Bucharest", "(GMT+0300) Europe/Bucharest"),
("Europe/Chisinau", "(GMT+0300) Europe/Chisinau"),
("Europe/Helsinki", "(GMT+0300) Europe/Helsinki"),
("Europe/Istanbul", "(GMT+0300) Europe/Istanbul"),
("Europe/Kirov", "(GMT+0300) Europe/Kirov"),
("Europe/Kyiv", "(GMT+0300) Europe/Kyiv"),
("Europe/Mariehamn", "(GMT+0300) Europe/Mariehamn"),
("Europe/Minsk", "(GMT+0300) Europe/Minsk"),
("Europe/Moscow", "(GMT+0300) Europe/Moscow"),
("Europe/Riga", "(GMT+0300) Europe/Riga"),
("Europe/Simferopol", "(GMT+0300) Europe/Simferopol"),
("Europe/Sofia", "(GMT+0300) Europe/Sofia"),
("Europe/Tallinn", "(GMT+0300) Europe/Tallinn"),
("Europe/Vilnius", "(GMT+0300) Europe/Vilnius"),
("Europe/Volgograd", "(GMT+0300) Europe/Volgograd"),
("Indian/Antananarivo", "(GMT+0300) Indian/Antananarivo"),
("Indian/Comoro", "(GMT+0300) Indian/Comoro"),
("Indian/Mayotte", "(GMT+0300) Indian/Mayotte"),
("Asia/Tehran", "(GMT+0330) Asia/Tehran"),
("Asia/Baku", "(GMT+0400) Asia/Baku"),
("Asia/Dubai", "(GMT+0400) Asia/Dubai"),
("Asia/Muscat", "(GMT+0400) Asia/Muscat"),
("Asia/Tbilisi", "(GMT+0400) Asia/Tbilisi"),
("Asia/Yerevan", "(GMT+0400) Asia/Yerevan"),
("Europe/Astrakhan", "(GMT+0400) Europe/Astrakhan"),
("Europe/Samara", "(GMT+0400) Europe/Samara"),
("Europe/Saratov", "(GMT+0400) Europe/Saratov"),
("Europe/Ulyanovsk", "(GMT+0400) Europe/Ulyanovsk"),
("Indian/Mahe", "(GMT+0400) Indian/Mahe"),
("Indian/Mauritius", "(GMT+0400) Indian/Mauritius"),
("Indian/Reunion", "(GMT+0400) Indian/Reunion"),
("Asia/Kabul", "(GMT+0430) Asia/Kabul"),
("Antarctica/Mawson", "(GMT+0500) Antarctica/Mawson"),
("Asia/Aqtau", "(GMT+0500) Asia/Aqtau"),
("Asia/Aqtobe", "(GMT+0500) Asia/Aqtobe"),
("Asia/Ashgabat", "(GMT+0500) Asia/Ashgabat"),
("Asia/Atyrau", "(GMT+0500) Asia/Atyrau"),
("Asia/Dushanbe", "(GMT+0500) Asia/Dushanbe"),
("Asia/Karachi", "(GMT+0500) Asia/Karachi"),
("Asia/Oral", "(GMT+0500) Asia/Oral"),
("Asia/Qyzylorda", "(GMT+0500) Asia/Qyzylorda"),
("Asia/Samarkand", "(GMT+0500) Asia/Samarkand"),
("Asia/Tashkent", "(GMT+0500) Asia/Tashkent"),
("Asia/Yekaterinburg", "(GMT+0500) Asia/Yekaterinburg"),
("Indian/Kerguelen", "(GMT+0500) Indian/Kerguelen"),
("Indian/Maldives", "(GMT+0500) Indian/Maldives"),
("Asia/Colombo", "(GMT+0530) Asia/Colombo"),
("Asia/Kolkata", "(GMT+0530) Asia/Kolkata"),
("Asia/Kathmandu", "(GMT+0545) Asia/Kathmandu"),
("Antarctica/Vostok", "(GMT+0600) Antarctica/Vostok"),
("Asia/Almaty", "(GMT+0600) Asia/Almaty"),
("Asia/Bishkek", "(GMT+0600) Asia/Bishkek"),
("Asia/Dhaka", "(GMT+0600) Asia/Dhaka"),
("Asia/Omsk", "(GMT+0600) Asia/Omsk"),
("Asia/Qostanay", "(GMT+0600) Asia/Qostanay"),
("Asia/Thimphu", "(GMT+0600) Asia/Thimphu"),
("Asia/Urumqi", "(GMT+0600) Asia/Urumqi"),
("Indian/Chagos", "(GMT+0600) Indian/Chagos"),
("Asia/Yangon", "(GMT+0630) Asia/Yangon"),
("Indian/Cocos", "(GMT+0630) Indian/Cocos"),
("Antarctica/Davis", "(GMT+0700) Antarctica/Davis"),
("Asia/Bangkok", "(GMT+0700) Asia/Bangkok"),
("Asia/Barnaul", "(GMT+0700) Asia/Barnaul"),
("Asia/Ho_Chi_Minh", "(GMT+0700) Asia/Ho_Chi_Minh"),
("Asia/Hovd", "(GMT+0700) Asia/Hovd"),
("Asia/Jakarta", "(GMT+0700) Asia/Jakarta"),
("Asia/Krasnoyarsk", "(GMT+0700) Asia/Krasnoyarsk"),
("Asia/Novokuznetsk", "(GMT+0700) Asia/Novokuznetsk"),
("Asia/Novosibirsk", "(GMT+0700) Asia/Novosibirsk"),
("Asia/Phnom_Penh", "(GMT+0700) Asia/Phnom_Penh"),
("Asia/Pontianak", "(GMT+0700) Asia/Pontianak"),
("Asia/Tomsk", "(GMT+0700) Asia/Tomsk"),
("Asia/Vientiane", "(GMT+0700) Asia/Vientiane"),
("Indian/Christmas", "(GMT+0700) Indian/Christmas"),
("Asia/Brunei", "(GMT+0800) Asia/Brunei"),
("Asia/Choibalsan", "(GMT+0800) Asia/Choibalsan"),
("Asia/Hong_Kong", "(GMT+0800) Asia/Hong_Kong"),
("Asia/Irkutsk", "(GMT+0800) Asia/Irkutsk"),
("Asia/Kuala_Lumpur", "(GMT+0800) Asia/Kuala_Lumpur"),
("Asia/Kuching", "(GMT+0800) Asia/Kuching"),
("Asia/Macau", "(GMT+0800) Asia/Macau"),
("Asia/Makassar", "(GMT+0800) Asia/Makassar"),
("Asia/Manila", "(GMT+0800) Asia/Manila"),
("Asia/Shanghai", "(GMT+0800) Asia/Shanghai"),
("Asia/Singapore", "(GMT+0800) Asia/Singapore"),
("Asia/Taipei", "(GMT+0800) Asia/Taipei"),
("Asia/Ulaanbaatar", "(GMT+0800) Asia/Ulaanbaatar"),
("Australia/Perth", "(GMT+0800) Australia/Perth"),
("Australia/Eucla", "(GMT+0845) Australia/Eucla"),
("Asia/Chita", "(GMT+0900) Asia/Chita"),
("Asia/Dili", "(GMT+0900) Asia/Dili"),
("Asia/Jayapura", "(GMT+0900) Asia/Jayapura"),
("Asia/Khandyga", "(GMT+0900) Asia/Khandyga"),
("Asia/Pyongyang", "(GMT+0900) Asia/Pyongyang"),
("Asia/Seoul", "(GMT+0900) Asia/Seoul"),
("Asia/Tokyo", "(GMT+0900) Asia/Tokyo"),
("Asia/Yakutsk", "(GMT+0900) Asia/Yakutsk"),
("Pacific/Palau", "(GMT+0900) Pacific/Palau"),
("Australia/Darwin", "(GMT+0930) Australia/Darwin"),
(
"Antarctica/DumontDUrville",
"(GMT+1000) Antarctica/DumontDUrville",
),
("Asia/Ust-Nera", "(GMT+1000) Asia/Ust-Nera"),
("Asia/Vladivostok", "(GMT+1000) Asia/Vladivostok"),
("Australia/Brisbane", "(GMT+1000) Australia/Brisbane"),
("Australia/Lindeman", "(GMT+1000) Australia/Lindeman"),
("Pacific/Chuuk", "(GMT+1000) Pacific/Chuuk"),
("Pacific/Guam", "(GMT+1000) Pacific/Guam"),
(
"Pacific/Port_Moresby",
"(GMT+1000) Pacific/Port_Moresby",
),
("Pacific/Saipan", "(GMT+1000) Pacific/Saipan"),
("Australia/Adelaide", "(GMT+1030) Australia/Adelaide"),
(
"Australia/Broken_Hill",
"(GMT+1030) Australia/Broken_Hill",
),
("Antarctica/Casey", "(GMT+1100) Antarctica/Casey"),
(
"Antarctica/Macquarie",
"(GMT+1100) Antarctica/Macquarie",
),
("Asia/Magadan", "(GMT+1100) Asia/Magadan"),
("Asia/Sakhalin", "(GMT+1100) Asia/Sakhalin"),
("Asia/Srednekolymsk", "(GMT+1100) Asia/Srednekolymsk"),
("Australia/Hobart", "(GMT+1100) Australia/Hobart"),
("Australia/Lord_Howe", "(GMT+1100) Australia/Lord_Howe"),
("Australia/Melbourne", "(GMT+1100) Australia/Melbourne"),
("Australia/Sydney", "(GMT+1100) Australia/Sydney"),
(
"Pacific/Bougainville",
"(GMT+1100) Pacific/Bougainville",
),
("Pacific/Efate", "(GMT+1100) Pacific/Efate"),
("Pacific/Guadalcanal", "(GMT+1100) Pacific/Guadalcanal"),
("Pacific/Kosrae", "(GMT+1100) Pacific/Kosrae"),
("Pacific/Noumea", "(GMT+1100) Pacific/Noumea"),
("Pacific/Pohnpei", "(GMT+1100) Pacific/Pohnpei"),
("Asia/Anadyr", "(GMT+1200) Asia/Anadyr"),
("Asia/Kamchatka", "(GMT+1200) Asia/Kamchatka"),
("Pacific/Fiji", "(GMT+1200) Pacific/Fiji"),
("Pacific/Funafuti", "(GMT+1200) Pacific/Funafuti"),
("Pacific/Kwajalein", "(GMT+1200) Pacific/Kwajalein"),
("Pacific/Majuro", "(GMT+1200) Pacific/Majuro"),
("Pacific/Nauru", "(GMT+1200) Pacific/Nauru"),
("Pacific/Norfolk", "(GMT+1200) Pacific/Norfolk"),
("Pacific/Tarawa", "(GMT+1200) Pacific/Tarawa"),
("Pacific/Wake", "(GMT+1200) Pacific/Wake"),
("Pacific/Wallis", "(GMT+1200) Pacific/Wallis"),
("Antarctica/McMurdo", "(GMT+1300) Antarctica/McMurdo"),
("Pacific/Apia", "(GMT+1300) Pacific/Apia"),
("Pacific/Auckland", "(GMT+1300) Pacific/Auckland"),
("Pacific/Fakaofo", "(GMT+1300) Pacific/Fakaofo"),
("Pacific/Kanton", "(GMT+1300) Pacific/Kanton"),
("Pacific/Tongatapu", "(GMT+1300) Pacific/Tongatapu"),
("Pacific/Chatham", "(GMT+1345) Pacific/Chatham"),
("Pacific/Kiritimati", "(GMT+1400) Pacific/Kiritimati"),
],
default="UTC",
max_length=255,
),
),
]

View File

@ -0,0 +1,601 @@
# Generated by Django 4.2.19 on 2025-04-07 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0022_userprofile_task_context_tags_str_and_more"),
]
operations = [
migrations.AlterField(
model_name="userprofile",
name="timezone",
field=models.CharField(
choices=[
("Pacific/Midway", "(GMT-1100) Pacific/Midway"),
("Pacific/Niue", "(GMT-1100) Pacific/Niue"),
("Pacific/Pago_Pago", "(GMT-1100) Pacific/Pago_Pago"),
("Pacific/Honolulu", "(GMT-1000) Pacific/Honolulu"),
("Pacific/Rarotonga", "(GMT-1000) Pacific/Rarotonga"),
("Pacific/Tahiti", "(GMT-1000) Pacific/Tahiti"),
("US/Hawaii", "(GMT-1000) US/Hawaii"),
("Pacific/Marquesas", "(GMT-0930) Pacific/Marquesas"),
("America/Adak", "(GMT-0900) America/Adak"),
("Pacific/Gambier", "(GMT-0900) Pacific/Gambier"),
("America/Anchorage", "(GMT-0800) America/Anchorage"),
("America/Juneau", "(GMT-0800) America/Juneau"),
("America/Metlakatla", "(GMT-0800) America/Metlakatla"),
("America/Nome", "(GMT-0800) America/Nome"),
("America/Sitka", "(GMT-0800) America/Sitka"),
("America/Yakutat", "(GMT-0800) America/Yakutat"),
("Pacific/Pitcairn", "(GMT-0800) Pacific/Pitcairn"),
("US/Alaska", "(GMT-0800) US/Alaska"),
("America/Creston", "(GMT-0700) America/Creston"),
("America/Dawson", "(GMT-0700) America/Dawson"),
(
"America/Dawson_Creek",
"(GMT-0700) America/Dawson_Creek",
),
("America/Fort_Nelson", "(GMT-0700) America/Fort_Nelson"),
("America/Hermosillo", "(GMT-0700) America/Hermosillo"),
("America/Los_Angeles", "(GMT-0700) America/Los_Angeles"),
("America/Mazatlan", "(GMT-0700) America/Mazatlan"),
("America/Phoenix", "(GMT-0700) America/Phoenix"),
("America/Tijuana", "(GMT-0700) America/Tijuana"),
("America/Vancouver", "(GMT-0700) America/Vancouver"),
("America/Whitehorse", "(GMT-0700) America/Whitehorse"),
("Canada/Pacific", "(GMT-0700) Canada/Pacific"),
("US/Arizona", "(GMT-0700) US/Arizona"),
("US/Pacific", "(GMT-0700) US/Pacific"),
(
"America/Bahia_Banderas",
"(GMT-0600) America/Bahia_Banderas",
),
("America/Belize", "(GMT-0600) America/Belize"),
("America/Boise", "(GMT-0600) America/Boise"),
(
"America/Cambridge_Bay",
"(GMT-0600) America/Cambridge_Bay",
),
("America/Chihuahua", "(GMT-0600) America/Chihuahua"),
(
"America/Ciudad_Juarez",
"(GMT-0600) America/Ciudad_Juarez",
),
("America/Costa_Rica", "(GMT-0600) America/Costa_Rica"),
("America/Denver", "(GMT-0600) America/Denver"),
("America/Edmonton", "(GMT-0600) America/Edmonton"),
("America/El_Salvador", "(GMT-0600) America/El_Salvador"),
("America/Guatemala", "(GMT-0600) America/Guatemala"),
("America/Inuvik", "(GMT-0600) America/Inuvik"),
("America/Managua", "(GMT-0600) America/Managua"),
("America/Merida", "(GMT-0600) America/Merida"),
("America/Mexico_City", "(GMT-0600) America/Mexico_City"),
("America/Monterrey", "(GMT-0600) America/Monterrey"),
("America/Regina", "(GMT-0600) America/Regina"),
(
"America/Swift_Current",
"(GMT-0600) America/Swift_Current",
),
("America/Tegucigalpa", "(GMT-0600) America/Tegucigalpa"),
("America/Yellowknife", "(GMT-0600) America/Yellowknife"),
("Canada/Mountain", "(GMT-0600) Canada/Mountain"),
("Pacific/Easter", "(GMT-0600) Pacific/Easter"),
("Pacific/Galapagos", "(GMT-0600) Pacific/Galapagos"),
("US/Mountain", "(GMT-0600) US/Mountain"),
("America/Atikokan", "(GMT-0500) America/Atikokan"),
("America/Bogota", "(GMT-0500) America/Bogota"),
("America/Cancun", "(GMT-0500) America/Cancun"),
("America/Cayman", "(GMT-0500) America/Cayman"),
("America/Chicago", "(GMT-0500) America/Chicago"),
("America/Eirunepe", "(GMT-0500) America/Eirunepe"),
("America/Guayaquil", "(GMT-0500) America/Guayaquil"),
(
"America/Indiana/Knox",
"(GMT-0500) America/Indiana/Knox",
),
(
"America/Indiana/Tell_City",
"(GMT-0500) America/Indiana/Tell_City",
),
("America/Jamaica", "(GMT-0500) America/Jamaica"),
("America/Lima", "(GMT-0500) America/Lima"),
("America/Matamoros", "(GMT-0500) America/Matamoros"),
("America/Menominee", "(GMT-0500) America/Menominee"),
(
"America/North_Dakota/Beulah",
"(GMT-0500) America/North_Dakota/Beulah",
),
(
"America/North_Dakota/Center",
"(GMT-0500) America/North_Dakota/Center",
),
(
"America/North_Dakota/New_Salem",
"(GMT-0500) America/North_Dakota/New_Salem",
),
("America/Ojinaga", "(GMT-0500) America/Ojinaga"),
("America/Panama", "(GMT-0500) America/Panama"),
(
"America/Rankin_Inlet",
"(GMT-0500) America/Rankin_Inlet",
),
("America/Resolute", "(GMT-0500) America/Resolute"),
("America/Rio_Branco", "(GMT-0500) America/Rio_Branco"),
("America/Winnipeg", "(GMT-0500) America/Winnipeg"),
("Canada/Central", "(GMT-0500) Canada/Central"),
("US/Central", "(GMT-0500) US/Central"),
("America/Anguilla", "(GMT-0400) America/Anguilla"),
("America/Antigua", "(GMT-0400) America/Antigua"),
("America/Aruba", "(GMT-0400) America/Aruba"),
("America/Asuncion", "(GMT-0400) America/Asuncion"),
("America/Barbados", "(GMT-0400) America/Barbados"),
(
"America/Blanc-Sablon",
"(GMT-0400) America/Blanc-Sablon",
),
("America/Boa_Vista", "(GMT-0400) America/Boa_Vista"),
(
"America/Campo_Grande",
"(GMT-0400) America/Campo_Grande",
),
("America/Caracas", "(GMT-0400) America/Caracas"),
("America/Cuiaba", "(GMT-0400) America/Cuiaba"),
("America/Curacao", "(GMT-0400) America/Curacao"),
("America/Detroit", "(GMT-0400) America/Detroit"),
("America/Dominica", "(GMT-0400) America/Dominica"),
("America/Grand_Turk", "(GMT-0400) America/Grand_Turk"),
("America/Grenada", "(GMT-0400) America/Grenada"),
("America/Guadeloupe", "(GMT-0400) America/Guadeloupe"),
("America/Guyana", "(GMT-0400) America/Guyana"),
("America/Havana", "(GMT-0400) America/Havana"),
(
"America/Indiana/Indianapolis",
"(GMT-0400) America/Indiana/Indianapolis",
),
(
"America/Indiana/Marengo",
"(GMT-0400) America/Indiana/Marengo",
),
(
"America/Indiana/Petersburg",
"(GMT-0400) America/Indiana/Petersburg",
),
(
"America/Indiana/Vevay",
"(GMT-0400) America/Indiana/Vevay",
),
(
"America/Indiana/Vincennes",
"(GMT-0400) America/Indiana/Vincennes",
),
(
"America/Indiana/Winamac",
"(GMT-0400) America/Indiana/Winamac",
),
("America/Iqaluit", "(GMT-0400) America/Iqaluit"),
(
"America/Kentucky/Louisville",
"(GMT-0400) America/Kentucky/Louisville",
),
(
"America/Kentucky/Monticello",
"(GMT-0400) America/Kentucky/Monticello",
),
("America/Kralendijk", "(GMT-0400) America/Kralendijk"),
("America/La_Paz", "(GMT-0400) America/La_Paz"),
(
"America/Lower_Princes",
"(GMT-0400) America/Lower_Princes",
),
("America/Manaus", "(GMT-0400) America/Manaus"),
("America/Marigot", "(GMT-0400) America/Marigot"),
("America/Martinique", "(GMT-0400) America/Martinique"),
("America/Montserrat", "(GMT-0400) America/Montserrat"),
("America/Nassau", "(GMT-0400) America/Nassau"),
("America/New_York", "(GMT-0400) America/New_York"),
(
"America/Port-au-Prince",
"(GMT-0400) America/Port-au-Prince",
),
(
"America/Port_of_Spain",
"(GMT-0400) America/Port_of_Spain",
),
("America/Porto_Velho", "(GMT-0400) America/Porto_Velho"),
("America/Puerto_Rico", "(GMT-0400) America/Puerto_Rico"),
("America/Santiago", "(GMT-0400) America/Santiago"),
(
"America/Santo_Domingo",
"(GMT-0400) America/Santo_Domingo",
),
(
"America/St_Barthelemy",
"(GMT-0400) America/St_Barthelemy",
),
("America/St_Kitts", "(GMT-0400) America/St_Kitts"),
("America/St_Lucia", "(GMT-0400) America/St_Lucia"),
("America/St_Thomas", "(GMT-0400) America/St_Thomas"),
("America/St_Vincent", "(GMT-0400) America/St_Vincent"),
("America/Toronto", "(GMT-0400) America/Toronto"),
("America/Tortola", "(GMT-0400) America/Tortola"),
("Canada/Eastern", "(GMT-0400) Canada/Eastern"),
("US/Eastern", "(GMT-0400) US/Eastern"),
("America/Araguaina", "(GMT-0300) America/Araguaina"),
(
"America/Argentina/Buenos_Aires",
"(GMT-0300) America/Argentina/Buenos_Aires",
),
(
"America/Argentina/Catamarca",
"(GMT-0300) America/Argentina/Catamarca",
),
(
"America/Argentina/Cordoba",
"(GMT-0300) America/Argentina/Cordoba",
),
(
"America/Argentina/Jujuy",
"(GMT-0300) America/Argentina/Jujuy",
),
(
"America/Argentina/La_Rioja",
"(GMT-0300) America/Argentina/La_Rioja",
),
(
"America/Argentina/Mendoza",
"(GMT-0300) America/Argentina/Mendoza",
),
(
"America/Argentina/Rio_Gallegos",
"(GMT-0300) America/Argentina/Rio_Gallegos",
),
(
"America/Argentina/Salta",
"(GMT-0300) America/Argentina/Salta",
),
(
"America/Argentina/San_Juan",
"(GMT-0300) America/Argentina/San_Juan",
),
(
"America/Argentina/San_Luis",
"(GMT-0300) America/Argentina/San_Luis",
),
(
"America/Argentina/Tucuman",
"(GMT-0300) America/Argentina/Tucuman",
),
(
"America/Argentina/Ushuaia",
"(GMT-0300) America/Argentina/Ushuaia",
),
("America/Bahia", "(GMT-0300) America/Bahia"),
("America/Belem", "(GMT-0300) America/Belem"),
("America/Cayenne", "(GMT-0300) America/Cayenne"),
("America/Fortaleza", "(GMT-0300) America/Fortaleza"),
("America/Glace_Bay", "(GMT-0300) America/Glace_Bay"),
("America/Goose_Bay", "(GMT-0300) America/Goose_Bay"),
("America/Halifax", "(GMT-0300) America/Halifax"),
("America/Maceio", "(GMT-0300) America/Maceio"),
("America/Moncton", "(GMT-0300) America/Moncton"),
("America/Montevideo", "(GMT-0300) America/Montevideo"),
("America/Paramaribo", "(GMT-0300) America/Paramaribo"),
(
"America/Punta_Arenas",
"(GMT-0300) America/Punta_Arenas",
),
("America/Recife", "(GMT-0300) America/Recife"),
("America/Santarem", "(GMT-0300) America/Santarem"),
("America/Sao_Paulo", "(GMT-0300) America/Sao_Paulo"),
("America/Thule", "(GMT-0300) America/Thule"),
("Antarctica/Palmer", "(GMT-0300) Antarctica/Palmer"),
("Antarctica/Rothera", "(GMT-0300) Antarctica/Rothera"),
("Atlantic/Bermuda", "(GMT-0300) Atlantic/Bermuda"),
("Atlantic/Stanley", "(GMT-0300) Atlantic/Stanley"),
("Canada/Atlantic", "(GMT-0300) Canada/Atlantic"),
("America/St_Johns", "(GMT-0230) America/St_Johns"),
("Canada/Newfoundland", "(GMT-0230) Canada/Newfoundland"),
("America/Miquelon", "(GMT-0200) America/Miquelon"),
("America/Noronha", "(GMT-0200) America/Noronha"),
("America/Nuuk", "(GMT-0200) America/Nuuk"),
(
"Atlantic/South_Georgia",
"(GMT-0200) Atlantic/South_Georgia",
),
("Atlantic/Cape_Verde", "(GMT-0100) Atlantic/Cape_Verde"),
("Africa/Abidjan", "(GMT+0000) Africa/Abidjan"),
("Africa/Accra", "(GMT+0000) Africa/Accra"),
("Africa/Bamako", "(GMT+0000) Africa/Bamako"),
("Africa/Banjul", "(GMT+0000) Africa/Banjul"),
("Africa/Bissau", "(GMT+0000) Africa/Bissau"),
("Africa/Conakry", "(GMT+0000) Africa/Conakry"),
("Africa/Dakar", "(GMT+0000) Africa/Dakar"),
("Africa/Freetown", "(GMT+0000) Africa/Freetown"),
("Africa/Lome", "(GMT+0000) Africa/Lome"),
("Africa/Monrovia", "(GMT+0000) Africa/Monrovia"),
("Africa/Nouakchott", "(GMT+0000) Africa/Nouakchott"),
("Africa/Ouagadougou", "(GMT+0000) Africa/Ouagadougou"),
("Africa/Sao_Tome", "(GMT+0000) Africa/Sao_Tome"),
(
"America/Danmarkshavn",
"(GMT+0000) America/Danmarkshavn",
),
(
"America/Scoresbysund",
"(GMT+0000) America/Scoresbysund",
),
("Atlantic/Azores", "(GMT+0000) Atlantic/Azores"),
("Atlantic/Reykjavik", "(GMT+0000) Atlantic/Reykjavik"),
("Atlantic/St_Helena", "(GMT+0000) Atlantic/St_Helena"),
("GMT", "(GMT+0000) GMT"),
("UTC", "(GMT+0000) UTC"),
("Africa/Algiers", "(GMT+0100) Africa/Algiers"),
("Africa/Bangui", "(GMT+0100) Africa/Bangui"),
("Africa/Brazzaville", "(GMT+0100) Africa/Brazzaville"),
("Africa/Casablanca", "(GMT+0100) Africa/Casablanca"),
("Africa/Douala", "(GMT+0100) Africa/Douala"),
("Africa/El_Aaiun", "(GMT+0100) Africa/El_Aaiun"),
("Africa/Kinshasa", "(GMT+0100) Africa/Kinshasa"),
("Africa/Lagos", "(GMT+0100) Africa/Lagos"),
("Africa/Libreville", "(GMT+0100) Africa/Libreville"),
("Africa/Luanda", "(GMT+0100) Africa/Luanda"),
("Africa/Malabo", "(GMT+0100) Africa/Malabo"),
("Africa/Ndjamena", "(GMT+0100) Africa/Ndjamena"),
("Africa/Niamey", "(GMT+0100) Africa/Niamey"),
("Africa/Porto-Novo", "(GMT+0100) Africa/Porto-Novo"),
("Africa/Tunis", "(GMT+0100) Africa/Tunis"),
("Atlantic/Canary", "(GMT+0100) Atlantic/Canary"),
("Atlantic/Faroe", "(GMT+0100) Atlantic/Faroe"),
("Atlantic/Madeira", "(GMT+0100) Atlantic/Madeira"),
("Europe/Dublin", "(GMT+0100) Europe/Dublin"),
("Europe/Guernsey", "(GMT+0100) Europe/Guernsey"),
("Europe/Isle_of_Man", "(GMT+0100) Europe/Isle_of_Man"),
("Europe/Jersey", "(GMT+0100) Europe/Jersey"),
("Europe/Lisbon", "(GMT+0100) Europe/Lisbon"),
("Europe/London", "(GMT+0100) Europe/London"),
("Africa/Blantyre", "(GMT+0200) Africa/Blantyre"),
("Africa/Bujumbura", "(GMT+0200) Africa/Bujumbura"),
("Africa/Cairo", "(GMT+0200) Africa/Cairo"),
("Africa/Ceuta", "(GMT+0200) Africa/Ceuta"),
("Africa/Gaborone", "(GMT+0200) Africa/Gaborone"),
("Africa/Harare", "(GMT+0200) Africa/Harare"),
("Africa/Johannesburg", "(GMT+0200) Africa/Johannesburg"),
("Africa/Juba", "(GMT+0200) Africa/Juba"),
("Africa/Khartoum", "(GMT+0200) Africa/Khartoum"),
("Africa/Kigali", "(GMT+0200) Africa/Kigali"),
("Africa/Lubumbashi", "(GMT+0200) Africa/Lubumbashi"),
("Africa/Lusaka", "(GMT+0200) Africa/Lusaka"),
("Africa/Maputo", "(GMT+0200) Africa/Maputo"),
("Africa/Maseru", "(GMT+0200) Africa/Maseru"),
("Africa/Mbabane", "(GMT+0200) Africa/Mbabane"),
("Africa/Tripoli", "(GMT+0200) Africa/Tripoli"),
("Africa/Windhoek", "(GMT+0200) Africa/Windhoek"),
("Antarctica/Troll", "(GMT+0200) Antarctica/Troll"),
("Arctic/Longyearbyen", "(GMT+0200) Arctic/Longyearbyen"),
("Europe/Amsterdam", "(GMT+0200) Europe/Amsterdam"),
("Europe/Andorra", "(GMT+0200) Europe/Andorra"),
("Europe/Belgrade", "(GMT+0200) Europe/Belgrade"),
("Europe/Berlin", "(GMT+0200) Europe/Berlin"),
("Europe/Bratislava", "(GMT+0200) Europe/Bratislava"),
("Europe/Brussels", "(GMT+0200) Europe/Brussels"),
("Europe/Budapest", "(GMT+0200) Europe/Budapest"),
("Europe/Busingen", "(GMT+0200) Europe/Busingen"),
("Europe/Copenhagen", "(GMT+0200) Europe/Copenhagen"),
("Europe/Gibraltar", "(GMT+0200) Europe/Gibraltar"),
("Europe/Kaliningrad", "(GMT+0200) Europe/Kaliningrad"),
("Europe/Ljubljana", "(GMT+0200) Europe/Ljubljana"),
("Europe/Luxembourg", "(GMT+0200) Europe/Luxembourg"),
("Europe/Madrid", "(GMT+0200) Europe/Madrid"),
("Europe/Malta", "(GMT+0200) Europe/Malta"),
("Europe/Monaco", "(GMT+0200) Europe/Monaco"),
("Europe/Oslo", "(GMT+0200) Europe/Oslo"),
("Europe/Paris", "(GMT+0200) Europe/Paris"),
("Europe/Podgorica", "(GMT+0200) Europe/Podgorica"),
("Europe/Prague", "(GMT+0200) Europe/Prague"),
("Europe/Rome", "(GMT+0200) Europe/Rome"),
("Europe/San_Marino", "(GMT+0200) Europe/San_Marino"),
("Europe/Sarajevo", "(GMT+0200) Europe/Sarajevo"),
("Europe/Skopje", "(GMT+0200) Europe/Skopje"),
("Europe/Stockholm", "(GMT+0200) Europe/Stockholm"),
("Europe/Tirane", "(GMT+0200) Europe/Tirane"),
("Europe/Vaduz", "(GMT+0200) Europe/Vaduz"),
("Europe/Vatican", "(GMT+0200) Europe/Vatican"),
("Europe/Vienna", "(GMT+0200) Europe/Vienna"),
("Europe/Warsaw", "(GMT+0200) Europe/Warsaw"),
("Europe/Zagreb", "(GMT+0200) Europe/Zagreb"),
("Europe/Zurich", "(GMT+0200) Europe/Zurich"),
("Africa/Addis_Ababa", "(GMT+0300) Africa/Addis_Ababa"),
("Africa/Asmara", "(GMT+0300) Africa/Asmara"),
(
"Africa/Dar_es_Salaam",
"(GMT+0300) Africa/Dar_es_Salaam",
),
("Africa/Djibouti", "(GMT+0300) Africa/Djibouti"),
("Africa/Kampala", "(GMT+0300) Africa/Kampala"),
("Africa/Mogadishu", "(GMT+0300) Africa/Mogadishu"),
("Africa/Nairobi", "(GMT+0300) Africa/Nairobi"),
("Antarctica/Syowa", "(GMT+0300) Antarctica/Syowa"),
("Asia/Aden", "(GMT+0300) Asia/Aden"),
("Asia/Amman", "(GMT+0300) Asia/Amman"),
("Asia/Baghdad", "(GMT+0300) Asia/Baghdad"),
("Asia/Bahrain", "(GMT+0300) Asia/Bahrain"),
("Asia/Beirut", "(GMT+0300) Asia/Beirut"),
("Asia/Damascus", "(GMT+0300) Asia/Damascus"),
("Asia/Famagusta", "(GMT+0300) Asia/Famagusta"),
("Asia/Gaza", "(GMT+0300) Asia/Gaza"),
("Asia/Hebron", "(GMT+0300) Asia/Hebron"),
("Asia/Jerusalem", "(GMT+0300) Asia/Jerusalem"),
("Asia/Kuwait", "(GMT+0300) Asia/Kuwait"),
("Asia/Nicosia", "(GMT+0300) Asia/Nicosia"),
("Asia/Qatar", "(GMT+0300) Asia/Qatar"),
("Asia/Riyadh", "(GMT+0300) Asia/Riyadh"),
("Europe/Athens", "(GMT+0300) Europe/Athens"),
("Europe/Bucharest", "(GMT+0300) Europe/Bucharest"),
("Europe/Chisinau", "(GMT+0300) Europe/Chisinau"),
("Europe/Helsinki", "(GMT+0300) Europe/Helsinki"),
("Europe/Istanbul", "(GMT+0300) Europe/Istanbul"),
("Europe/Kirov", "(GMT+0300) Europe/Kirov"),
("Europe/Kyiv", "(GMT+0300) Europe/Kyiv"),
("Europe/Mariehamn", "(GMT+0300) Europe/Mariehamn"),
("Europe/Minsk", "(GMT+0300) Europe/Minsk"),
("Europe/Moscow", "(GMT+0300) Europe/Moscow"),
("Europe/Riga", "(GMT+0300) Europe/Riga"),
("Europe/Simferopol", "(GMT+0300) Europe/Simferopol"),
("Europe/Sofia", "(GMT+0300) Europe/Sofia"),
("Europe/Tallinn", "(GMT+0300) Europe/Tallinn"),
("Europe/Vilnius", "(GMT+0300) Europe/Vilnius"),
("Europe/Volgograd", "(GMT+0300) Europe/Volgograd"),
("Indian/Antananarivo", "(GMT+0300) Indian/Antananarivo"),
("Indian/Comoro", "(GMT+0300) Indian/Comoro"),
("Indian/Mayotte", "(GMT+0300) Indian/Mayotte"),
("Asia/Tehran", "(GMT+0330) Asia/Tehran"),
("Asia/Baku", "(GMT+0400) Asia/Baku"),
("Asia/Dubai", "(GMT+0400) Asia/Dubai"),
("Asia/Muscat", "(GMT+0400) Asia/Muscat"),
("Asia/Tbilisi", "(GMT+0400) Asia/Tbilisi"),
("Asia/Yerevan", "(GMT+0400) Asia/Yerevan"),
("Europe/Astrakhan", "(GMT+0400) Europe/Astrakhan"),
("Europe/Samara", "(GMT+0400) Europe/Samara"),
("Europe/Saratov", "(GMT+0400) Europe/Saratov"),
("Europe/Ulyanovsk", "(GMT+0400) Europe/Ulyanovsk"),
("Indian/Mahe", "(GMT+0400) Indian/Mahe"),
("Indian/Mauritius", "(GMT+0400) Indian/Mauritius"),
("Indian/Reunion", "(GMT+0400) Indian/Reunion"),
("Asia/Kabul", "(GMT+0430) Asia/Kabul"),
("Antarctica/Mawson", "(GMT+0500) Antarctica/Mawson"),
("Asia/Aqtau", "(GMT+0500) Asia/Aqtau"),
("Asia/Aqtobe", "(GMT+0500) Asia/Aqtobe"),
("Asia/Ashgabat", "(GMT+0500) Asia/Ashgabat"),
("Asia/Atyrau", "(GMT+0500) Asia/Atyrau"),
("Asia/Dushanbe", "(GMT+0500) Asia/Dushanbe"),
("Asia/Karachi", "(GMT+0500) Asia/Karachi"),
("Asia/Oral", "(GMT+0500) Asia/Oral"),
("Asia/Qyzylorda", "(GMT+0500) Asia/Qyzylorda"),
("Asia/Samarkand", "(GMT+0500) Asia/Samarkand"),
("Asia/Tashkent", "(GMT+0500) Asia/Tashkent"),
("Asia/Yekaterinburg", "(GMT+0500) Asia/Yekaterinburg"),
("Indian/Kerguelen", "(GMT+0500) Indian/Kerguelen"),
("Indian/Maldives", "(GMT+0500) Indian/Maldives"),
("Asia/Colombo", "(GMT+0530) Asia/Colombo"),
("Asia/Kolkata", "(GMT+0530) Asia/Kolkata"),
("Asia/Kathmandu", "(GMT+0545) Asia/Kathmandu"),
("Antarctica/Vostok", "(GMT+0600) Antarctica/Vostok"),
("Asia/Almaty", "(GMT+0600) Asia/Almaty"),
("Asia/Bishkek", "(GMT+0600) Asia/Bishkek"),
("Asia/Dhaka", "(GMT+0600) Asia/Dhaka"),
("Asia/Omsk", "(GMT+0600) Asia/Omsk"),
("Asia/Qostanay", "(GMT+0600) Asia/Qostanay"),
("Asia/Thimphu", "(GMT+0600) Asia/Thimphu"),
("Asia/Urumqi", "(GMT+0600) Asia/Urumqi"),
("Indian/Chagos", "(GMT+0600) Indian/Chagos"),
("Asia/Yangon", "(GMT+0630) Asia/Yangon"),
("Indian/Cocos", "(GMT+0630) Indian/Cocos"),
("Antarctica/Davis", "(GMT+0700) Antarctica/Davis"),
("Asia/Bangkok", "(GMT+0700) Asia/Bangkok"),
("Asia/Barnaul", "(GMT+0700) Asia/Barnaul"),
("Asia/Ho_Chi_Minh", "(GMT+0700) Asia/Ho_Chi_Minh"),
("Asia/Hovd", "(GMT+0700) Asia/Hovd"),
("Asia/Jakarta", "(GMT+0700) Asia/Jakarta"),
("Asia/Krasnoyarsk", "(GMT+0700) Asia/Krasnoyarsk"),
("Asia/Novokuznetsk", "(GMT+0700) Asia/Novokuznetsk"),
("Asia/Novosibirsk", "(GMT+0700) Asia/Novosibirsk"),
("Asia/Phnom_Penh", "(GMT+0700) Asia/Phnom_Penh"),
("Asia/Pontianak", "(GMT+0700) Asia/Pontianak"),
("Asia/Tomsk", "(GMT+0700) Asia/Tomsk"),
("Asia/Vientiane", "(GMT+0700) Asia/Vientiane"),
("Indian/Christmas", "(GMT+0700) Indian/Christmas"),
("Asia/Brunei", "(GMT+0800) Asia/Brunei"),
("Asia/Choibalsan", "(GMT+0800) Asia/Choibalsan"),
("Asia/Hong_Kong", "(GMT+0800) Asia/Hong_Kong"),
("Asia/Irkutsk", "(GMT+0800) Asia/Irkutsk"),
("Asia/Kuala_Lumpur", "(GMT+0800) Asia/Kuala_Lumpur"),
("Asia/Kuching", "(GMT+0800) Asia/Kuching"),
("Asia/Macau", "(GMT+0800) Asia/Macau"),
("Asia/Makassar", "(GMT+0800) Asia/Makassar"),
("Asia/Manila", "(GMT+0800) Asia/Manila"),
("Asia/Shanghai", "(GMT+0800) Asia/Shanghai"),
("Asia/Singapore", "(GMT+0800) Asia/Singapore"),
("Asia/Taipei", "(GMT+0800) Asia/Taipei"),
("Asia/Ulaanbaatar", "(GMT+0800) Asia/Ulaanbaatar"),
("Australia/Perth", "(GMT+0800) Australia/Perth"),
("Australia/Eucla", "(GMT+0845) Australia/Eucla"),
("Asia/Chita", "(GMT+0900) Asia/Chita"),
("Asia/Dili", "(GMT+0900) Asia/Dili"),
("Asia/Jayapura", "(GMT+0900) Asia/Jayapura"),
("Asia/Khandyga", "(GMT+0900) Asia/Khandyga"),
("Asia/Pyongyang", "(GMT+0900) Asia/Pyongyang"),
("Asia/Seoul", "(GMT+0900) Asia/Seoul"),
("Asia/Tokyo", "(GMT+0900) Asia/Tokyo"),
("Asia/Yakutsk", "(GMT+0900) Asia/Yakutsk"),
("Pacific/Palau", "(GMT+0900) Pacific/Palau"),
("Australia/Adelaide", "(GMT+0930) Australia/Adelaide"),
(
"Australia/Broken_Hill",
"(GMT+0930) Australia/Broken_Hill",
),
("Australia/Darwin", "(GMT+0930) Australia/Darwin"),
(
"Antarctica/DumontDUrville",
"(GMT+1000) Antarctica/DumontDUrville",
),
(
"Antarctica/Macquarie",
"(GMT+1000) Antarctica/Macquarie",
),
("Asia/Ust-Nera", "(GMT+1000) Asia/Ust-Nera"),
("Asia/Vladivostok", "(GMT+1000) Asia/Vladivostok"),
("Australia/Brisbane", "(GMT+1000) Australia/Brisbane"),
("Australia/Hobart", "(GMT+1000) Australia/Hobart"),
("Australia/Lindeman", "(GMT+1000) Australia/Lindeman"),
("Australia/Melbourne", "(GMT+1000) Australia/Melbourne"),
("Australia/Sydney", "(GMT+1000) Australia/Sydney"),
("Pacific/Chuuk", "(GMT+1000) Pacific/Chuuk"),
("Pacific/Guam", "(GMT+1000) Pacific/Guam"),
(
"Pacific/Port_Moresby",
"(GMT+1000) Pacific/Port_Moresby",
),
("Pacific/Saipan", "(GMT+1000) Pacific/Saipan"),
("Australia/Lord_Howe", "(GMT+1030) Australia/Lord_Howe"),
("Antarctica/Casey", "(GMT+1100) Antarctica/Casey"),
("Asia/Magadan", "(GMT+1100) Asia/Magadan"),
("Asia/Sakhalin", "(GMT+1100) Asia/Sakhalin"),
("Asia/Srednekolymsk", "(GMT+1100) Asia/Srednekolymsk"),
(
"Pacific/Bougainville",
"(GMT+1100) Pacific/Bougainville",
),
("Pacific/Efate", "(GMT+1100) Pacific/Efate"),
("Pacific/Guadalcanal", "(GMT+1100) Pacific/Guadalcanal"),
("Pacific/Kosrae", "(GMT+1100) Pacific/Kosrae"),
("Pacific/Norfolk", "(GMT+1100) Pacific/Norfolk"),
("Pacific/Noumea", "(GMT+1100) Pacific/Noumea"),
("Pacific/Pohnpei", "(GMT+1100) Pacific/Pohnpei"),
("Antarctica/McMurdo", "(GMT+1200) Antarctica/McMurdo"),
("Asia/Anadyr", "(GMT+1200) Asia/Anadyr"),
("Asia/Kamchatka", "(GMT+1200) Asia/Kamchatka"),
("Pacific/Auckland", "(GMT+1200) Pacific/Auckland"),
("Pacific/Fiji", "(GMT+1200) Pacific/Fiji"),
("Pacific/Funafuti", "(GMT+1200) Pacific/Funafuti"),
("Pacific/Kwajalein", "(GMT+1200) Pacific/Kwajalein"),
("Pacific/Majuro", "(GMT+1200) Pacific/Majuro"),
("Pacific/Nauru", "(GMT+1200) Pacific/Nauru"),
("Pacific/Tarawa", "(GMT+1200) Pacific/Tarawa"),
("Pacific/Wake", "(GMT+1200) Pacific/Wake"),
("Pacific/Wallis", "(GMT+1200) Pacific/Wallis"),
("Pacific/Chatham", "(GMT+1245) Pacific/Chatham"),
("Pacific/Apia", "(GMT+1300) Pacific/Apia"),
("Pacific/Fakaofo", "(GMT+1300) Pacific/Fakaofo"),
("Pacific/Kanton", "(GMT+1300) Pacific/Kanton"),
("Pacific/Tongatapu", "(GMT+1300) Pacific/Tongatapu"),
("Pacific/Kiritimati", "(GMT+1400) Pacific/Kiritimati"),
],
default="UTC",
max_length=255,
),
),
]

View File

@ -1,9 +1,8 @@
from datetime import timedelta
import pytz
from django.utils import timezone
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.functional import cached_property
from django_extensions.db.models import TimeStampedModel
from encrypted_field import EncryptedField
from profiles.constants import PRETTY_TIMEZONE_CHOICES
@ -30,7 +29,22 @@ class UserProfile(TimeStampedModel):
archivebox_password = EncryptedField(**BNULL)
archivebox_url = models.CharField(max_length=255, **BNULL)
task_context_tags_str = models.CharField(max_length=255, **BNULL)
bgg_username = models.CharField(max_length=255, **BNULL)
lichess_username = models.CharField(max_length=255, **BNULL)
todoist_auth_key = EncryptedField(**BNULL)
todoist_state = EncryptedField(**BNULL)
todoist_user_id = models.CharField(max_length=100, **BNULL)
webdav_url = models.CharField(max_length=255, **BNULL)
webdav_user = models.CharField(max_length=255, **BNULL)
webdav_pass = EncryptedField(**BNULL)
webdav_auto_import = models.BooleanField(default=False)
ntfy_url = models.CharField(max_length=255, **BNULL)
ntfy_enabled = models.BooleanField(default=False)
redirect_to_webpage = models.BooleanField(default=True)
@ -40,3 +54,15 @@ class UserProfile(TimeStampedModel):
@property
def tzinfo(self):
return pytz.timezone(self.timezone)
@cached_property
def task_context_tags(self) -> list[str]:
tag_list = [
t.strip().capitalize()
for t in self.task_context_tags_str.split(",")
]
if not tag_list:
tag_list = settings.DEFAULT_TASK_CONTEXT_TAG_LIST
return tag_list

View File

@ -0,0 +1,11 @@
from django.urls import path
from profiles import views
app_name = "profiles"
urlpatterns = [
path(
"settings/", views.ProfileFormView.as_view(), name="profile_settings"
),
]

View File

@ -0,0 +1,24 @@
from django.urls import reverse_lazy
from django.http.response import HttpResponseBadRequest
from django.views.generic import FormView
from profiles.forms import UserProfileForm
class ProfileFormView(FormView):
form_class = UserProfileForm
template_name = "profiles/settings_form.html"
success_url = reverse_lazy("profiles:profile_settings")
def get_form_kwargs(self):
"""Passes the request object to the form class.
This is necessary to only display members that belong to a given user"""
kwargs = super(ProfileFormView, self).get_form_kwargs()
kwargs["request"] = self.request
return kwargs
def form_valid(self, form):
form.save()
return super(ProfileFormView, self).form_valid(form)

View File

@ -0,0 +1,28 @@
from puzzles.models import Puzzle, PuzzleManufacturer
from django.contrib import admin
from scrobbles.admin import ScrobbleInline
class PuzzleInline(admin.TabularInline):
model = Puzzle
extra = 0
@admin.register(PuzzleManufacturer)
class PuzzleManufacturerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(Puzzle)
class PuzzleAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class PuzzlesConfig(AppConfig):
name = "puzzles"

View File

@ -0,0 +1,163 @@
# Generated by Django 4.2.19 on 2025-05-11 03:21
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0068_scrobble_paper_alter_scrobble_media_type"),
]
operations = [
migrations.CreateModel(
name="PuzzleManufacturer",
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,
),
),
("name", models.CharField(max_length=255)),
(
"ipdb_id",
models.CharField(blank=True, max_length=200, null=True),
),
("description", models.TextField(blank=True, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.CreateModel(
name="Puzzle",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
"title",
models.CharField(blank=True, max_length=255, null=True),
),
("run_time_seconds", models.IntegerField(default=900)),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
("description", models.TextField(blank=True, null=True)),
(
"orientation",
models.CharField(blank=True, max_length=50, null=True),
),
(
"dimensions_in_inches",
models.CharField(blank=True, max_length=10, null=True),
),
("publish_year", models.IntegerField(blank=True, null=True)),
(
"material",
models.CharField(blank=True, max_length=50, null=True),
),
(
"cut_style",
models.CharField(blank=True, max_length=100, null=True),
),
("pieces_count", models.IntegerField(blank=True, null=True)),
(
"igdb_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"barcode",
models.CharField(blank=True, max_length=13, null=True),
),
(
"igdb_image",
models.ImageField(
blank=True, null=True, upload_to="puzzles/igdb/"
),
),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
(
"manufacturer",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="puzzles.puzzlemanufacturer",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-05-11 03:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("puzzles", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="puzzle",
name="dimensions_in_inches",
field=models.CharField(blank=True, max_length=30, null=True),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.19 on 2025-05-11 03:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("puzzles", "0002_alter_puzzle_dimensions_in_inches"),
]
operations = [
migrations.RenameField(
model_name="puzzle",
old_name="igdb_id",
new_name="ipdb_id",
),
migrations.RemoveField(
model_name="puzzle",
name="igdb_image",
),
migrations.AddField(
model_name="puzzle",
name="ipdb_image",
field=models.ImageField(
blank=True, null=True, upload_to="puzzles/ipdb/"
),
),
]

View File

@ -0,0 +1,124 @@
from uuid import uuid4
import requests
from django.apps import apps
from django.core.files.base import ContentFile
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from puzzles.sources import ipdb
from scrobbles.dataclasses import PuzzleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
class PuzzleManufacturer(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
ipdb_id = models.CharField(max_length=200, **BNULL)
description = models.TextField(**BNULL)
def __str__(self) -> str:
return str(self.name)
class Puzzle(ScrobblableMixin):
description = models.TextField(**BNULL)
orientation = models.CharField(max_length=50, **BNULL)
dimensions_in_inches = models.CharField(max_length=30, **BNULL)
publish_year = models.IntegerField(**BNULL)
material = models.CharField(max_length=50, **BNULL)
cut_style = models.CharField(max_length=100, **BNULL)
pieces_count = models.IntegerField(**BNULL)
ipdb_id = models.CharField(max_length=255, **BNULL)
barcode = models.CharField(max_length=13, **BNULL)
ipdb_image = models.ImageField(upload_to="puzzles/ipdb/", **BNULL)
ipdb_image_small = ImageSpecField(
source="ipdb_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
ipdb_image_medium = ImageSpecField(
source="ipdb_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
manufacturer = models.ForeignKey(
PuzzleManufacturer, on_delete=models.DO_NOTHING, **BNULL
)
def get_absolute_url(self) -> str:
return reverse("puzzles:puzzle_detail", kwargs={"slug": self.uuid})
def __str__(self):
return f"{self.title} ({self.pieces_count}) by {self.manufacturer}"
@property
def subtitle(self):
return self.manufacturer.name
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Solving", tags="puzzle")
@property
def ipdb_link(self) -> str:
link = ""
if self.ipdb_id:
link = f"https://www.ipdb.plus/IPDb/puzzle.php?id={self.ipdb_id}"
return link
@property
def primary_image_url(self) -> str:
url = ""
if self.ipdb_image:
url = self.ipdb_image.url
return url
@property
def logdata_cls(self):
return PuzzleLogData
@classmethod
def find_or_create(cls, ipdb_id: str) -> "Puzzle":
puzzle = cls.objects.filter(ipdb_id=ipdb_id).first()
if not puzzle:
puzzle_dict = ipdb.get_puzzle_from_ipdb_id(ipdb_id)
manufacturer_name = puzzle_dict.pop("manufacturer", None)
if manufacturer_name:
(
manufacturer,
_created,
) = PuzzleManufacturer.objects.get_or_create(
name=manufacturer_name
)
puzzle_dict["manufacturer_id"] = manufacturer.id
genres = puzzle_dict.pop("genres", None)
cover_url = puzzle_dict.pop("ipdb_image_url", None)
puzzle = Puzzle.objects.create(**puzzle_dict)
if genres:
puzzle.genre.add(*genres)
if cover_url:
r = requests.get(cover_url)
if r.status_code == 200:
fname = f"{puzzle.title}_{puzzle.uuid}.jpg"
puzzle.ipdb_image.save(
fname, ContentFile(r.content), save=True
)
return puzzle
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, puzzle=self).order_by(
"-timestamp"
)

Some files were not shown because too many files have changed in this diff Show More