Compare commits

..

229 Commits

Author SHA1 Message Date
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
d69d7311d5 [version] Bump to 0.15.2 2024-09-08 00:45:36 -04:00
c484dab210 [books] Fix importing order of page data 2024-09-08 00:44:55 -04:00
3d62a6a227 [videos] Fix error looking up TV shows 2024-09-07 23:07:39 -04:00
1a5d4a6717 [scrobbles] Clean up dataclass inheritence 2024-09-07 11:36:36 -04:00
8f0491a90b [pyproject] Upgrade version to 0.15.0 2024-09-07 11:04:01 -04:00
7e961076b4 [bricksets] Add brick set scrobble type 2024-09-07 02:02:16 -04:00
14062f3b60 [videos] Mock in youtube lookup 2024-09-06 11:02:48 -04:00
0a8acdf33f [videos] Add skatevideo lookup properly 2024-09-06 11:01:45 -04:00
b5d194e74f [music] Fix jellyfin music scrobbling sort of 2024-09-06 10:27:04 -04:00
3a50a8b015 [videos] Fixing imdb lookups and making it more modular 2024-09-06 01:22:54 -04:00
921cf9d8b3 [videogames] Add log data to VideoGame model 2024-08-21 11:09:15 -04:00
ba0be65ed0 [scrobbles] Allow boardgame screenshots and clean up koreader fields 2024-08-20 20:10:32 -04:00
caad6329c9 [scrobbles] Fixing tests and breaking more 2024-08-19 18:59:37 -04:00
cfd6ac861e [scrobbles] Fix datalog return value and message 2024-08-19 12:27:47 -04:00
ca36e25948 [scrobbles] Fix datalog test case for board games 2024-08-19 12:12:42 -04:00
c84acf6ae7 [boardgames] Add str rep for player dataclass 2024-08-19 10:14:10 -04:00
610464e732 [scrobbles] Fix serializing logdata 2024-08-19 10:00:07 -04:00
047dd22069 [moods] Add mood fixture data 2024-08-19 09:46:32 -04:00
2c73121367 [scrobbles] Fix aggregator tests 2024-08-19 09:45:16 -04:00
9affd6e03a [scrobbles] Add some tests around jellyfin and start cleaning up 2024-08-16 12:04:09 -04:00
b414bbf59c [templates] Fix status template logdata call 2024-08-12 00:03:52 -05:00
5e22cb3106 [scrobbling] Refactor webhook and simplify 2024-08-12 00:03:30 -05:00
cc9a2a64df [settings] Fix missing moods app in testing settings 2024-08-10 23:30:38 -04:00
c2b334b926 [moods] Fix missing init file 2024-08-10 23:28:36 -04:00
ad6f0e1b62 [deps] Update poetry reqs to fix HLTB bug 2024-08-10 23:10:26 -04:00
1df8157d8d [music] Fix error when mb fails 2024-08-10 23:10:11 -04:00
7d33e6afdb [scrobbles] Fix migrations 2024-08-10 22:35:37 -04:00
40d112c58c [music] Fix failure when musicbrainz has no track 2024-08-10 22:14:07 -04:00
cdfd8af078 [views] Clean up base scrobblable views 2024-08-10 22:12:36 -04:00
0ce19527a2 [scrobbles] Fix mood starting, and clean up code 2024-08-10 22:12:36 -04:00
f3fc58e2c0 [moods] Add moods 2024-08-10 22:12:24 -04:00
b02b75fa90 [boardgames] Add BGG username to user profile 2024-08-07 14:52:26 -03:00
b626ac583a [books] Move book metadata into the log field 2024-08-07 14:51:21 -03:00
adc10ab43b [lifeevents] Fix bad metadata reference 2024-08-06 11:52:35 -03:00
5555b1b89e [lifeevents] Fix data class bug 2024-08-06 11:46:22 -03:00
7fd90f8cf0 [lifeevents] Clean up life event status with details 2024-08-06 11:35:04 -03:00
f80daba67b [boardgames] Update metadata for board game scrobbles 2024-08-05 12:27:42 -03:00
8bd4fd1d4b [webpags] Redirect to webpage url when we're done 2024-08-04 00:20:22 -04:00
f30e416e8d [webpages] Actually add the forms file 2024-08-04 00:11:48 -04:00
f29a039562 [webpages] Provide default iframe reading method 2024-08-03 23:49:31 -04:00
882367cee6 [views] Actually speed up the dashboard view 2024-07-23 16:35:48 -04:00
74c32bc4d5 [views] Move elaborate charts to the charts page 2024-07-23 16:24:59 -04:00
dcd7196010 Fix drone ntfy version 2024-07-14 01:15:27 -04:00
2e57b2ce07 Add dataclass wizard and fix dataclasses 2024-07-03 23:22:19 -04:00
9d664ba476 Update metadata to use JSONWizard 2024-07-03 15:53:27 -04:00
0fa89af1d9 [scrobbles] Fix timestamp log marker for locations 2024-06-07 01:27:58 -04:00
2c5516ae4e [scrobbles] Add life events to status page 2024-05-23 21:21:58 -04:00
0cba46b103 [scrobbles] Fix how we get our redirect url 2024-05-23 21:20:02 -04:00
5014c4428b [scrobbles] Allow junk in the scrobble log 2024-05-23 00:42:01 -04:00
1d554429f1 [scrobbles] Add metadata accessor 2024-05-23 00:37:20 -04:00
daea268465 Fix display of life events 2024-05-23 00:26:56 -04:00
1cceb2bd63 [lifeevents] Add metadata dataclasses 2024-05-23 00:16:24 -04:00
e15d253d58 [lifeevents] Add life events to scrobbles 2024-05-23 00:14:06 -04:00
8ed0bd3d21 [locations] Fix gps update log format 2024-05-05 22:13:15 -04:00
113f200eb7 [scrobbles] Move scrobble_log to log 2024-05-05 22:09:21 -04:00
1b5ffd2a3c [scrobbles] Fix pendulum bug 2024-04-24 14:06:49 -07:00
f168608cee [scrobbles] Fix scrobbling apocalypse 2024-04-22 14:27:33 -04:00
c28f93a1bb [scrobbles] Little reorg and trying to fix JF stop issue 2024-04-22 14:05:42 -04:00
99ad2f797f [scrobbles] Try to fix Jellyfin stop bug 2024-04-22 12:55:14 -04:00
1f28179ed1 [music] Fix lastfm import missing timezones 2024-04-21 22:36:21 -04:00
1648f988ff [videogames] Fix missing covers 2024-04-21 22:36:21 -04:00
dc3a77f14f [scrobbles] Fix typo in timezone saving 2024-04-21 13:29:36 -04:00
5994dccf23 [scrobbles] Clean up location provider log 2024-04-19 15:34:19 -04:00
bfadc0d87b [scrobbles] Just kidding, dt cannot be seriazlied 2024-04-19 15:28:20 -04:00
78782cc538 [scrobbles] Fix geoloc scrobble notes 2024-04-19 15:22:13 -04:00
06277f21de [scrobbles] Clean up inline admin 2024-04-19 15:13:07 -04:00
8fbe37a163 [scrobbles] Remove playback ticks 2024-04-19 15:01:15 -04:00
20dd4d217a [scrobbles] Remove source_id field 2024-04-19 14:57:27 -04:00
53657a9454 [videogames] Add template for video game list 2024-04-19 11:27:27 -04:00
42066cebb2 [scrobbles] Fix display of pages read 2024-04-19 11:27:15 -04:00
6d60a729b7 [scrobbles] Just push old data into JSON 2024-04-19 11:20:30 -04:00
89541a13f2 [webpages] Push to archivebox in a different place 2024-04-19 11:00:07 -04:00
f87bc5fd55 [scrobbles] Unscrewup scrobble migratiosn 2024-04-19 10:48:49 -04:00
0a8078dee0 [scrobbles] Allow skipping the Archivebox push 2024-04-19 10:35:53 -04:00
37da74708c [scrobbles] Fix scrobble having no user in tests 2024-04-19 10:31:33 -04:00
b470e7acea [scrobbles] Fix a migration and how we save timezones 2024-04-19 10:17:38 -04:00
5cdd12783f [scrobbles] Add timezones to scrobbles for better representation 2024-04-19 09:44:15 -04:00
8bf43f298c [books] Fix bug in koreader import using stats, not first_page 2024-04-19 08:45:37 -04:00
a269949a23 [scrobbles] Add webpages to status 2024-04-16 15:17:23 -04:00
8a5f200b44 [scrobbles] Add sports to the status 2024-04-16 15:14:52 -04:00
1c41ca3e18 [locations] Remove redundant field def 2024-04-16 15:09:21 -04:00
b69963b75f [books] Add helper method to get all page data 2024-04-16 15:08:50 -04:00
1327d5da40 [books] Fix index error issue with some imports 2024-04-16 10:47:10 -04:00
5d5b49f19b [webpages] Add bookmarklet support for URLs 2024-04-16 09:54:05 -04:00
d93c827b80 [books] Fix ordering problem with pages 2024-04-16 03:00:20 -04:00
2349c39487 [templates] Fix div mess in status page 2024-04-13 20:33:52 -04:00
ae96831fe4 [scrobbles] Fix status page container 2024-04-13 15:33:38 -04:00
706be782dc [dev] Ignore all sqlite files 2024-04-13 15:26:08 -04:00
a72e0b0fb9 [scrobbles] Add a status page 2024-04-13 15:25:13 -04:00
f5df6c97a9 [scrobbles] Wrap title and sub in span 2024-04-13 01:24:12 -04:00
e00f2de4b1 [videos] Put some views behind login 2024-04-13 01:16:37 -04:00
b4a7cafa3d [profiles] Hide archivebox password 2024-04-13 00:40:00 -04:00
aebe4f899d [webpages] Add pushing webpages to Archivebox 2024-04-13 00:39:18 -04:00
2bf0ca1a8c [books] Fix streaming SQL requets bug 2024-04-10 18:31:39 -04:00
a0c414135c [scrobbles] Fix bug in migrate script and update logging 2024-04-05 12:00:51 -04:00
1dcd151c65 [scrobbles] Migrate scrobble log to JSON field 2024-04-05 11:56:09 -04:00
2e9d92d9c7 [scrobbles] Add json log migration script 2024-04-05 11:38:52 -04:00
8776f75d12 [podcasts] Skip failing tests, bye Google Podcasts 2024-04-05 11:09:10 -04:00
c2a53875a0 [webpages] Clean up how we fetch webpage data 2024-04-05 10:39:30 -04:00
ffb0b7372d [vrobbler] Poetry update 2024-04-02 00:09:44 -04:00
d03744c240 [webpages] Add title cleaner and better url fetching 2024-04-02 00:09:26 -04:00
d9a7929cb0 [webpages] Update lock file for htmldate 2024-03-26 22:51:33 -04:00
9bc087d824 [webpages] Add annotation JS 2024-03-26 22:44:47 -04:00
e65ab4d8c2 [webpages] Break domains out to model 2024-03-26 22:33:56 -04:00
2d26a7c3bd [books] Add comicvine 2024-03-26 22:02:15 -04:00
1fd3e47656 [webpages] Clean up webpage view and add date 2024-03-26 22:01:57 -04:00
dd0ce967c8 [scrobbles] Fix bad update fields call 2024-03-25 16:45:50 -04:00
1f6a4956d2 [videos] Also fix it in series 2024-03-25 15:52:09 -04:00
22f5d288d4 [videos] Fix typo in imdb static path 2024-03-25 15:43:07 -04:00
77cf67ea09 [videoss] Finally add IMDB logo 2024-03-25 15:02:29 -04:00
d1dfc5502d [webpages] Properly use user agent header 2024-03-25 09:58:01 -04:00
0d83050956 [books] Adjust KO migration script for DST 2024-03-25 09:57:25 -04:00
134bd3f650 [books] Fix timezones and duplicated scrobbles 2024-03-23 02:11:06 -04:00
261f2eb9ce [books] Fix KoReader import at end of session 2024-03-19 00:19:40 -04:00
8f56bfba50 [videogames] Fix how we look up IGDB games 2024-03-18 14:58:46 -04:00
750fa05b15 [videogames] Order by ID desc from IGDB to get better results 2024-03-18 14:41:00 -04:00
302ddf6650 [videogames] Fix index error with IGDB lookups 2024-03-18 14:26:09 -04:00
49dd148f9f [scrobbling] Fix missing location in create 2024-03-12 23:15:48 -04:00
79d4e79f3e [scrobbling] Fix locations in proximity of named locations 2024-03-12 18:28:59 -04:00
874d9dc7d2 [locations] Dramatically simplify checking for mvement 2024-03-12 17:53:09 -04:00
0cd95036da [scrobbling] Fix creation of new location scrobbles 2024-03-12 17:12:36 -04:00
829c6b9978 [scrobbling] Fix bad call to has moved 2024-03-12 17:08:43 -04:00
df1bfd4177 [locations] Refactor has moved to not use multiple points 2024-03-12 16:57:56 -04:00
0fff2ec388 [scrobbling] Little location scrobble log clean up 2024-03-12 12:53:11 -04:00
8a83c9fdb3 [scrobbling] Don't run locations though can_be_updated 2024-03-12 12:51:18 -04:00
651fc7a745 [scrobbling] End around for locations 2024-03-12 12:41:00 -04:00
c22a306840 [scrobbling] Tryin in vain to clean up location loggin 2024-03-12 12:38:33 -04:00
11e6161502 [locations] Fix misisng return value 2024-03-12 12:25:48 -04:00
3af2ad203c [scrobbling] Still trying to understand movement 2024-03-12 11:58:44 -04:00
7697dd006d [scrobbling] Futher clean up logging for locations 2024-03-12 11:43:15 -04:00
73a6277e29 [scrobbling] Fix confusing location logging around creating locations 2024-03-12 11:36:51 -04:00
79fc407c33 [scrobbles] Fix bug in can be updated method 2024-03-08 16:53:44 -05:00
84ec84c018 [scrobbles] Try fixing mopidy within existing framework 2024-03-08 14:59:37 -05:00
beb9569663 [scrobbles] Try to fix mopidy double scrobbles 2024-03-08 14:52:01 -05:00
efcf01b49f [scrobbles] Revert previous change, silly 2024-03-08 14:33:31 -05:00
01270a9e32 [scrobbles] Try to fix mopidy double scrobble 2024-03-08 14:16:06 -05:00
c9280c2bad [scrobbles] Fix broken tests 2024-03-08 13:56:53 -05:00
416fd807f4 [scrobbles] Clean up logs and refactor can_be_updated 2024-03-08 13:51:23 -05:00
3069e93697 [scrobbles] Clean up logging in scrobblers 2024-03-08 13:24:57 -05:00
e47f4905e8 [scrobbles] Radically simplify stopping ... may need to roll this back 2024-03-08 12:45:45 -05:00
bf59fb213e [scrobbles] Add media type to our new log 2024-03-08 12:23:39 -05:00
589bf533ea [scrobbles] Add new log on update for scrobble data 2024-03-08 12:14:09 -05:00
1fb29304a3 [scrobbles] Try tweaking completion time for tracks 2024-03-06 20:30:00 -05:00
42604b9e23 [scrobbles] More robust fix for video and track overruns 2024-03-06 19:56:47 -05:00
4642063510 [scrobbles] Add check for long playing videos 2024-03-06 19:07:29 -05:00
65944eb911 [scrobbling] Add media type to filter on 2024-03-06 18:44:10 -05:00
4cf2ceb2dd [scrobbling] Add logging to scrobbler webhooks 2024-03-06 18:29:04 -05:00
78151e5070 [scrobbling] Missed cleaning one log 2024-03-06 18:07:00 -05:00
f90fa160d7 [scrobbling] Enrich some more logs 2024-03-06 18:01:52 -05:00
3b7498c419 [scrobbling] Clean up a few more logs 2024-03-06 17:53:20 -05:00
a59997dafb [scrobbles] Mostly cleaning up our logs 2024-03-06 17:46:55 -05:00
4cdb6150dc [locations] Fix proximity stuff 2024-03-06 16:14:46 -05:00
7fa21c27ea [locations] Remove RawGeoLocation model and ScrobbledPage model 2024-02-19 19:22:13 -05:00
b981c090c6 [locations] Change name of model function 2024-02-18 11:42:42 -05:00
605435b9ea [locations] Make finding proximity locations easier 2024-02-18 11:39:37 -05:00
f4d00b4a22 [locations] Fix typo in settings 2024-02-18 01:46:32 -05:00
504620fc89 [locations] I think proximity logic was reversed 2024-02-18 01:39:53 -05:00
df3424c68f [scrobbles] We don't have a user here yet 2024-02-18 01:39:40 -05:00
71b5af7615 [locations] Oops, don't want to update location scrobbles 2024-02-18 01:25:23 -05:00
7035f01441 [locations] Check for close named locations before creating new 2024-02-18 01:18:15 -05:00
525c7c4e2b [books] Clean up fragmented KoReader scrobbling 2024-02-13 14:54:29 -05:00
4e330a6f03 [webpages] Fix lookup key for scrobbles 2024-02-12 23:06:29 -05:00
ff18ad8efc [webpages] Remane foreign key to web_page 2024-02-12 22:58:07 -05:00
15c1317395 [locations] One more time, trying to fix this stuff 2024-02-12 15:12:12 -05:00
6b80d107b0 [locations] Fix scrobbling logic for locations 2024-02-12 13:47:15 -05:00
13124aca6b [locations] Need to spoof user in gps webhook 2024-02-12 12:42:27 -05:00
a685c6ff9c [locations] Fix calculating playback time 2024-02-12 12:36:38 -05:00
02200bb1f7 Forgot to return when updating! 2024-02-12 12:16:30 -05:00
0bbd488f0d [scrobbling] Sometimes we wont have timestamps 2024-02-12 12:11:07 -05:00
2c08336e33 [locations] Don't scrobble all the locations! 2024-02-12 11:59:15 -05:00
a637a59a40 [scrobbling] Fix location function name typo 2024-02-12 11:48:53 -05:00
123f7c53f9 Oops, no scrobble maybe 2024-02-12 11:38:14 -05:00
2c26cc01ba [scrobbling] Fix scrobble overwriting bug and refactor location scrobbling 2024-02-12 11:34:06 -05:00
36bc1c2e95 [locations] Tigten up how we finish locations 2024-02-11 00:38:10 -05:00
5651ccb990 [scrobbles] can be updated needs no params now 2024-02-11 00:25:12 -05:00
cd7b06eaa2 [settings] Fix typo 2024-02-11 00:16:48 -05:00
949416059d [locations] Try to fix geolocs finally 2024-02-11 00:10:50 -05:00
ca434cb08a [locations] Fix geoloc proximity comparison
Also, make it configurable
2024-02-10 18:13:39 -05:00
2661aee915 [locations] Refactor finding locations 2024-02-10 16:58:07 -05:00
74f672a2fd [locations] settings needs to be int cast 2024-02-10 15:37:31 -05:00
449b74ae3f [scrobbles] Maybe this was a good thing? 2024-02-10 15:30:23 -05:00
3d1ad8da3a [locations] Need to be specific to geolocs 2024-02-10 15:29:06 -05:00
75e374bb69 [locations] Do not mark geolocs complete automatically 2024-02-10 15:23:37 -05:00
2ba694655b [locations] Fix constant typo 2024-02-10 15:14:42 -05:00
879942d070 [locations] Geo accuracy should be int 2024-02-10 15:12:31 -05:00
bfd210d280 [locations] Turns out we were looking them up wrong 2024-02-10 15:06:16 -05:00
e5c6b5e8d9 [locations] Fix updating locations one more time 2024-02-10 14:43:46 -05:00
3cd1603b91 [locations] Try new logic on whether we've moved 2024-02-10 14:37:01 -05:00
27523bc7ff [locations] Flip the logic on whether we've moved 2024-02-10 14:30:41 -05:00
67ef1a15ec [podcasts] Fix missning enum type for podcasts 2024-02-10 14:16:13 -05:00
288027bfce [locations] Diff loc using Decimal math 2024-02-10 14:11:38 -05:00
bfdf73d4c0 [podcasts] Fix name for Episode model to PodcastEpisode 2024-02-10 13:57:42 -05:00
033e3c3b35 [locations] Try another tack for whether we've moved or not 2024-02-10 13:18:45 -05:00
40504f83e1 Add reuse-db option to pytest 2024-02-08 23:05:28 -05:00
ccca81bbab Add tests for Location models 2024-02-08 23:05:20 -05:00
84f49af163 Need to rethink this, it may be fine 2024-02-04 02:07:35 -05:00
e044c70072 Fix inversion of next and last for scrobbles 2024-02-04 01:45:54 -05:00
d3e09c25bd Fix bug in looking up book MD5 hash 2024-02-03 00:42:11 -05:00
30f78a9290 Fix bug in koreader migrator and delete scrobbles before migrating 2024-02-03 00:41:47 -05:00
a5553966de Add script for building scrobbles from past pages 2024-01-31 01:19:58 -05:00
9e2d7a6bc0 Start to add comicvine lookups and consoldiate koreader data 2024-01-29 01:48:56 -05:00
70c7eda415 [books] Don't auto update metadata on save 2024-01-27 01:16:45 -05:00
0ad7dac6cb Fix bug in end_ts calc for book scrobbles 2024-01-27 01:16:45 -05:00
209875f0e6 Import fix, and don't overwrite KoReader data when OL conflicts 2024-01-27 01:16:45 -05:00
919fa1b0b4 Add fuzzing for book titles 2024-01-27 01:16:45 -05:00
0b3bc53704 Fix openlibrary lookups 2024-01-27 01:16:45 -05:00
dfc1365fa3 Fix koreader importing 2024-01-27 01:16:45 -05:00
f22ef1a163 Set user profile TZ to UTC by default, and setup signal 2024-01-27 01:16:45 -05:00
7cb818b585 Fix issue where pages may not come back from OL 2024-01-27 01:16:45 -05:00
1a5cd5106f Update drone file with dev requirements 2024-01-25 10:30:42 -05:00
3b65144b68 Fine tuning koreader imports on real files 2024-01-25 10:15:15 -05:00
4ae13b3a1a Refactor new KoReader importer a bit 2024-01-25 02:08:03 -05:00
3de2be50cf Start refactoring the koreader importer 2024-01-22 20:26:11 -05:00
80d197bc54 Add new field to scrobbles for page data 2024-01-22 19:40:39 -05:00
a274796405 Fix bug in GeoLocation setting 2024-01-22 00:52:01 -05:00
37fd1d8458 Fix old style poetry groups 2024-01-22 00:50:30 -05:00
45ddce36aa Look in DB for book first 2024-01-21 00:31:07 -05:00
c3ddd01a6f Default to getting a boardgame in the DB 2024-01-21 00:27:31 -05:00
150 changed files with 10471 additions and 3148 deletions

View File

@ -14,7 +14,7 @@ steps:
# Install dependencies
- cp vrobbler.conf.test vrobbler.conf
- pip install poetry
- poetry install
- poetry install --with dev
# 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
environment:
@ -30,7 +30,7 @@ steps:
- vrobbler.service
username: root
ssh_key:
from_secret: ssh_key
from_secret: jail_key
command_timeout: 2m
script:
- pip uninstall -y vrobbler
@ -42,7 +42,7 @@ steps:
branch:
- main
- name: build success notification
image: parrazam/drone-ntfy
image: parrazam/drone-ntfy:0.3-linux-amd64
when:
status: [success]
settings:
@ -50,11 +50,10 @@ steps:
topic: drone
priority: low
tags:
- cd
- failure
- vrobbler
- name: build failure notification
image: parrazam/drone-ntfy
image: parrazam/drone-ntfy:0.3-linux-amd64
when:
status: [failure]
settings:
@ -62,7 +61,6 @@ steps:
topic: drone
priority: high
tags:
- cd
- success
- vrobbler
volumes:

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
db.sqlite3
db.sqlite3*
vrobbler.conf
media/
dist/

3
data/moods.json Normal file

File diff suppressed because one or more lines are too long

3865
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "0.11.12"
version = "0.15.4"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -38,14 +38,20 @@ 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"
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.14"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
Werkzeug = "2.0.3"
black = "^22.3"
coverage = "^7.0.5"
@ -57,7 +63,6 @@ pytest-django = "^4.5.2"
pytest-flake8 = "^1.1"
pytest-isort = "^3.0"
pytest-runner = "^6.0"
pytest-selenium = "^2.0.1"
time-machine = "^2.9.0"
types-pytz = "^2022.1"
types-requests = "^2.27"
@ -65,7 +70,7 @@ bandit = "^1.7.4"
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q"
addopts = "-ra -q --reuse-db"
testpaths = ["tests"]
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'

View File

@ -1,3 +1,4 @@
import pytest
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
expected_desc_snippet = (
@ -8,6 +9,7 @@ expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0Cf
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
@pytest.mark.skip("Google Podcasts is gone")
def test_get_not_allowed_from_mopidy():
query = "Up First"
result_dict = scrape_data_from_google_podcasts(query)

View File

@ -1,19 +1,39 @@
import json
import pytest
from rest_framework.authtoken.models import Token
import pytest
from django.contrib.auth import get_user_model
from rest_framework.authtoken.models import Token
from scrobbles.models import Scrobble
from boardgames.models import BoardGame
User = get_user_model()
@pytest.fixture
def boardgame_scrobble():
user = User.objects.create(
email="test@exmaple.com", first_name="Test", last_name="User"
)
return Scrobble.objects.create(
board_game=BoardGame.objects.create(title="Test Board Game"),
media_type="BoardGame",
played_to_completion=True,
log={
"players": [
{"user_id": user.id, "win": True, "score": 30, "color": "Blue"}
]
},
)
class MopidyRequest:
name = "Same in the End"
artist = "Sublime"
album = "Sublime"
track_number = 4
run_time_ticks = 156604
run_time = "156"
run_time = 60
playback_time_ticks = 15045
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
@ -65,8 +85,8 @@ def valid_auth_token():
@pytest.fixture
def mopidy_track_request_data():
return MopidyRequest().request_json
def mopidy_track():
return MopidyRequest()
@pytest.fixture
@ -78,6 +98,61 @@ def mopidy_track_diff_album_request_data(**kwargs):
@pytest.fixture
def mopidy_podcast_request_data():
def mopidy_podcast():
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
return MopidyRequest(mopidy_uri=mopidy_uri).request_json
return MopidyRequest(mopidy_uri=mopidy_uri)
class JellyfinTrackRequest:
name = "Emotion"
artist = "Carly Rae Jepsen"
album = "Emotion"
track_number = 1
item_type = "Audio"
timestamp = "2024-01-14 12:00:19"
run_time_ticks = 156604
run_time = "00:00:60"
playback_time_ticks = 15045
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
status = "resumed"
def __init__(self, **kwargs):
self.request_data = {
"Name": kwargs.get("name", self.name),
"Artist": kwargs.get("artist", self.artist),
"Album": kwargs.get("album", self.album),
"TrackNumber": int(kwargs.get("track_number", self.track_number)),
"RunTime": kwargs.get("run_time", self.run_time),
"ItemType": kwargs.get("item_type", self.item_type),
"UtcTimestamp": kwargs.get("timestamp", self.timestamp),
"PlaybackPositionTicks": int(
kwargs.get("playback_time_ticks", self.playback_time_ticks)
),
"Provider_musicbrainztrack": kwargs.get(
"musicbrainz_track_id", self.musicbrainz_track_id
),
"Provider_musicbrainzalbum": kwargs.get(
"musicbrainz_album_id", self.musicbrainz_album_id
),
"Provider_musicbrainzartist": kwargs.get(
"musicbrainz_artist_id", self.musicbrainz_artist_id
),
"Status": kwargs.get("status", self.status),
}
def __eq__(self, other):
for key in self.request_data.keys():
if self.request_data[key] != getattr(self, key):
return False
return True
@property
def request_json(self):
return json.dumps(self.request_data)
@pytest.fixture
def jellyfin_track():
return JellyfinTrackRequest()

View File

@ -1,6 +1,4 @@
from datetime import datetime, timedelta
from unittest import mock
from unittest.mock import patch
import pytest
import time_machine
@ -13,12 +11,13 @@ from profiles.models import UserProfile
from scrobbles.models import Scrobble
def build_scrobbles(client, request_data, num=7, spacing=2):
def build_scrobbles(client, request_json, num=7, spacing=2):
url = reverse("scrobbles:mopidy-webhook")
user = get_user_model().objects.create(username="Test User")
UserProfile.objects.create(user=user, timezone="US/Eastern")
user.profile.timezone = "US/Eastern"
user.profile.save()
for i in range(num):
client.post(url, request_data, content_type="application/json")
client.post(url, request_json, content_type="application/json")
s = Scrobble.objects.last()
s.user = user
s.timestamp = timezone.now() - timedelta(days=i * spacing)
@ -28,8 +27,8 @@ def build_scrobbles(client, request_data, num=7, spacing=2):
@pytest.mark.django_db
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
def test_scrobble_counts_data(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data)
def test_scrobble_counts_data(client, mopidy_track):
build_scrobbles(client, mopidy_track.request_json)
user = get_user_model().objects.first()
count_dict = scrobble_counts(user)
assert count_dict == {
@ -43,8 +42,8 @@ def test_scrobble_counts_data(client, mopidy_track_request_data):
@pytest.mark.django_db
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
def test_live_charts(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
def test_live_charts(client, mopidy_track):
build_scrobbles(client, mopidy_track.request_json, 7, 1)
user = get_user_model().objects.first()
week = week_of_scrobbles(user)

View File

@ -0,0 +1,31 @@
import pytest
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
@pytest.mark.django_db
def test_boardgame_log_data(boardgame_scrobble):
assert not boardgame_scrobble.geo_location
assert boardgame_scrobble.logdata == BoardGameLogData(
players=[
BoardGameScoreLogData(
user_id=1,
name_str="",
bgg_username="",
color="Blue",
character=None,
team=None,
score=30,
win=True,
new=None,
)
],
location=None,
geo_location_id=None,
difficulty=None,
solo=None,
two_handed=None,
)
assert len(boardgame_scrobble.logdata.players) == 1
assert boardgame_scrobble.logdata.players[0].user.id == 1
assert boardgame_scrobble.logdata.players[0].name == "Test"

View File

@ -1,7 +1,12 @@
from datetime import datetime, timedelta
from unittest.mock import patch
from django.utils import timezone
import pytest
import time_machine
from django.urls import reverse
from music.models import Track
from podcasts.models import Episode
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
@ -25,31 +30,57 @@ 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
def test_scrobble_mopidy_track(
client, mopidy_track_request_data, valid_auth_token
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}"}
response = client.post(
url,
mopidy_track_request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Same in the End"
# 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="API is unstable")
@pytest.mark.skip(reason="Allmusic API is unstable")
@pytest.mark.django_db
def test_scrobble_mopidy_same_track_different_album(
client,
mopidy_track_request_data,
mopidy_track,
mopidy_track_diff_album_request_data,
valid_auth_token,
):
@ -57,7 +88,7 @@ def test_scrobble_mopidy_same_track_different_album(
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track_request_data,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
@ -96,5 +127,85 @@ def test_scrobble_mopidy_podcast(
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Episode
assert scrobble.media_obj.__class__ == PodcastEpisode
assert scrobble.media_obj.title == "Up First"
@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(
mock_lookup_artist,
mock_lookup_album,
mock_lookup_track,
mock_lookup_artist_tadb,
mock_lookup_album_tadb,
mock_fetch_artwork,
mock_scrape_allmusic,
client,
jellyfin_track,
valid_auth_token,
):
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.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}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
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": 2}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"

View File

@ -20,6 +20,7 @@ VROBBLER_THESPORTSDB_API_KEY="<key>"
VROBBLER_THEAUDIODB_API_KEY="<key>"
VROBBLER_IGDB_CLIENT_ID="<id>"
VROBBLER_IGDB_CLIENT_SECRET="<key>"
VROBBLER_COMICVINE_API_KEY="<key>"
# Storages
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"

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

@ -12,8 +12,8 @@ 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 BoardGameLogData
from scrobbles.mixins import ScrobblableMixin
from vrobbler.apps.boardgames.bgg import lookup_boardgame_id_from_bgg
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@ -96,6 +96,10 @@ class BoardGame(ScrobblableMixin):
"boardgames:boardgame_detail", kwargs={"slug": self.uuid}
)
@property
def logdata_cls(self):
return BoardGameLogData
def primary_image_url(self) -> str:
url = ""
if self.cover:
@ -151,12 +155,12 @@ class BoardGame(ScrobblableMixin):
cls, lookup_id: str, data: Optional[dict] = {}
) -> Optional["BoardGame"]:
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
boardgame = None
boardgame = cls.objects.filter(bggeek_id=lookup_id).first()
if not data:
if not data or not boardgame:
data = lookup_boardgame_from_bgg(lookup_id)
if data:
if data and not boardgame:
boardgame, created = cls.objects.get_or_create(
title=data["title"], bggeek_id=lookup_id
)

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

@ -55,9 +55,7 @@ def scrape_data_from_amazon(url) -> dict:
r = requests.get(url, headers=headers)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "html.parser")
import pdb
pdb.set_trace()
# TODO Fix this scraper
data_dict["rating"] = get_rating_from_soup(soup)
data_dict["review"] = get_review_from_soup(soup)
return data_dict

View File

@ -0,0 +1,233 @@
"""
ComicVine API Information & Documentation:
https://comicvine.gamespot.com/api/
https://comicvine.gamespot.com/api/documentation
"""
import json
import logging
from django.conf import settings
import requests
logger = logging.getLogger(__name__)
class ComicVineClient(object):
"""
Interacts with the ``search`` resource of the ComicVine API. Requires an
account on https://comicvine.gamespot.com/ in order to obtain an API key.
"""
# All API requests made by this client will be made to this URL.
API_URL = "https://www.comicvine.com/api/search/"
# A valid User-Agent header must be set in order for our API requests to
# be accepted, otherwise our request will be rejected with a
# **403 - Forbidden** error.
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:7.0) "
"Gecko/20130825 Firefox/36.0"
}
# A set of valid resource types to return in results.
RESOURCE_TYPES = {
"character",
"issue",
"location",
"object",
"person",
"publisher",
"story_arc",
"team",
"volume",
}
def __init__(self, api_key, expire_after=300):
"""
Store the API key in a class variable, and install the requests cache,
configuring it using the ``expire_after`` parameter.
:param api_key: Your personal ComicVine API key.
:type api_key: str
:param expire_after: The number of seconds to retain an entry in cache.
:type expire_after: int or None
"""
self.api_key = api_key
def search(self, query, offset=0, limit=10, resources=None):
"""
Perform a search against the API, using the provided query term. If
required, a list of resource types to filter search results to can
be included.
Take the JSON contained in the response and provide it to the custom
``Response`` object's constructor. Return the ``Response`` object.
:param query: The search query with which to make the request.
:type query: str
:param offset: The index of the first record returned.
:type offset: int or None
:param limit: How many records to return **(max 10)**
:type limit: int or None
:param resources: A list of resources to include in the search results.
:type resources: list or None
:type use_cache: bool
:return: The response object containing the results of the search
query.
:rtype: comicvine_search.response.Response
"""
params = self._request_params(query, offset, limit, resources)
json_data = self._query_api(params)
return json_data
def _request_params(self, query, offset, limit, resources):
"""
Construct a dict containing the required key-value pairs of parameters
required in order to make the API request.
The documentation for the ``search`` resource can be found at
https://comicvine.gamespot.com/api/documentation#toc-0-30.
Regarding 'limit', as per the documentation:
The number of results to display per page. This value defaults to
10 and can not exceed this number.
:param query: The search query with which to make the request.
:type query: str
:param offset: The index of the first record returned.
:type offset: int
:param limit: How many records to return **(max 10)**
:type limit: int
:param resources: A list of resources to include in the search results.
:type resources: list or None
:return: A dictionary of request parameters.
:rtype: dict
"""
return {
"api_key": self.api_key,
"format": "json",
"limit": min(10, limit), # hard limit of 10
"offset": max(0, offset), # cannot provide negative offset
"query": query,
"resources": self._validate_resources(resources),
}
def _validate_resources(self, resources):
"""
Provided a list of resources, first convert it to a set and perform an
intersection with the set of valid resource types, ``RESOURCE_TYPES``.
Return a comma-separted string of the remaining valid resources, or
None if the set is empty.
:param resources: A list of resources to include in the search results.
:type resources: list or None
:return: A comma-separated string of valid resources.
:rtype: str or None
"""
if not resources:
return None
valid_resources = self.RESOURCE_TYPES & set(resources)
return ",".join(valid_resources) if valid_resources else None
def _query_api(self, params):
"""
Query the ComicVine API's ``search`` resource, providing the required
headers and parameters with the request. Optionally allow the caller
of the function to disable the request cache.
If an error occurs during the request, handle it accordingly. Upon
success, return the JSON from the response.
:param params: Parameters to include with the request.
:type params: dict
:param use_cache: Toggle the use of requests_cache.
:type use_cache: bool
:return: The JSON contained in the response.
:rtype: dict
"""
# Since we're performing the identical action regardless of whether
# or not the request cache is to be used, store the procedure in a
# local function to avoid repetition.
def __httpget():
response = requests.get(
self.API_URL, headers=self.HEADERS, params=params
)
if not response.ok:
self._handle_http_error(response)
return response.json()
return __httpget()
def _handle_http_error(self, response):
"""
Provided a ``requests.Response`` object, if the status code is
anything other than **200**, we will treat it as an error.
Using the response's status code, determine which type of exception to
raise. Construct an exception message from the response's status code
and reason properties before raising the exception.
:param response: The requests.Response object returned by the HTTP
request.
:type response: requests.Response
:raises ComicVineUnauthorizedException: if no API key provided.
:raises ComicVineForbiddenException: if no User-Agent header provided.
:raises ComicVineApiException: if an unidentified error occurs.
"""
exception = {
401: Exception,
403: Exception,
}.get(response.status_code, Exception)
message = f"{response.status_code} {response.reason}"
raise exception(message)
def lookup_comic_from_comicvine(title: str) -> dict:
api_key = getattr(settings, "COMICVINE_API_KEY", "")
if not api_key:
logger.warn("No ComicVine API key configured, not looking anything up")
return {}
client = ComicVineClient(
api_key=getattr(settings, "COMICVINE_API_KEY", None)
)
result = [
r
for r in client.search(title).get("results")
if r.get("resource_type") == "volume"
][0]
if "volume" not in result.keys():
logger.warn("No result found on ComicVine", extra={"title": title})
return {}
title = " ".join([result.get("volume").get("name"), result.get("name)")])
data_dict = {
"title": title,
"cover_url": result.get("image").get("original_url"),
"comicvine_data": {
"id": result.get("id"),
"site_detail_url": result.get("site_detail_url"),
"description": result.get("description"),
"image": result.get("image").get("original_url"),
},
}
return data_dict

View File

@ -1,22 +1,20 @@
import codecs
from collections import OrderedDict
import logging
import os
import re
import sqlite3
from datetime import datetime
from datetime import datetime, timedelta
from enum import Enum
from typing import Iterable, List
import pytz
import requests
from books.models import Author, Book, Page
from books.models import Author, Book
from books.openlibrary import get_author_openlibrary_id
from django.db.models import Sum
from pylast import httpx, tempfile
from scrobbles.models import Scrobble
from django.apps import apps
from django.contrib.auth import get_user_model
from stream_sqlite import stream_sqlite
logger = logging.getLogger(__name__)
User = get_user_model()
class KoReaderBookColumn(Enum):
@ -43,11 +41,79 @@ class KoReaderPageStatColumn(Enum):
def _sqlite_bytes(sqlite_url):
with httpx.stream("GET", sqlite_url) as r:
yield from r.iter_bytes(chunk_size=65_536)
with requests.get(sqlite_url, stream=True) as r:
yield from r.iter_content(chunk_size=65_536)
def get_book_map_from_sqlite(rows: Iterable) -> dict:
# Grace period between page reads for it to be a new scrobble
SESSION_GAP_SECONDS = 1800 # a half hour
def get_author_str_from_row(row):
"""Given a the raw author string from KoReader, convert it to a single line and
strip the middle initials, as OpenLibrary lookup usually fails with those.
"""
ko_authors = row[KoReaderBookColumn.AUTHORS.value].replace("\n", ", ")
# Strip middle initials, OpenLibrary often fails with these
return re.sub(" [A-Z]. ", " ", ko_authors)
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
"""
author_str_list = ko_author_str.split(", ")
author_list = []
for author_str in author_str_list:
logger.debug(f"Looking up author {author_str}")
# KoReader gave us nothing, bail
if author_str == "N/A":
logger.warn(f"KoReader author string is N/A, no authors to find")
continue
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()
logger.debug(f"Created author {author}")
author_list.append(author)
return author_list
def create_book_from_row(row: list):
# No KoReader book yet, create it
author_str = get_author_str_from_row(row)
total_pages = row[KoReaderBookColumn.PAGES.value]
run_time = total_pages * Book.AVG_PAGE_READING_SECONDS
book = Book.objects.create(
title=row[KoReaderBookColumn.TITLE.value],
pages=total_pages,
koreader_data_by_hash={
str(row[KoReaderBookColumn.MD5.value]): {
"title": row[KoReaderBookColumn.TITLE.value],
"author_str": author_str,
"book_id": row[KoReaderBookColumn.ID.value],
"pages": total_pages,
}
},
run_time_seconds=run_time,
)
book.fix_metadata()
# Add authors
author_list = lookup_or_create_authors_from_author_str(author_str)
if author_list:
book.authors.add(*author_list)
# self._lookup_authors
return book
def build_book_map(rows) -> dict:
"""Given an interable of sqlite rows from the books table, lookup existing
books, create ones that don't exist, and return a mapping of koreader IDs to
primary key IDs for page creation.
@ -56,207 +122,284 @@ def get_book_map_from_sqlite(rows: Iterable) -> dict:
book_id_map = {}
for book_row in rows:
if (
book_row[KoReaderBookColumn.TITLE.value]
== "KOReader Quickstart Guide"
):
logger.info(
"Ignoring the KOReader quickstart guide. No on wants that."
)
continue
book = Book.objects.filter(
koreader_md5=book_row[KoReaderBookColumn.MD5.value]
koreader_data_by_hash__icontains=book_row[
KoReaderBookColumn.MD5.value
]
).first()
if not book:
book, created = Book.objects.get_or_create(
title=book_row[KoReaderBookColumn.TITLE.value]
)
if created:
total_pages = book_row[KoReaderBookColumn.PAGES.value]
run_time = total_pages * book.AVG_PAGE_READING_SECONDS
ko_authors = book_row[
KoReaderBookColumn.AUTHORS.value
].replace("\n", ", ")
# Strip middle initials, OpenLibrary often fails with these
ko_authors = re.sub(" [A-Z]. ", " ", ko_authors)
book_dict = {
"title": book_row[KoReaderBookColumn.TITLE.value],
"pages": total_pages,
"koreader_md5": book_row[KoReaderBookColumn.MD5.value],
"koreader_id": int(book_row[KoReaderBookColumn.ID.value]),
"koreader_authors": ko_authors,
"run_time_seconds": run_time,
}
Book.objects.filter(pk=book.id).update(**book_dict)
# Add authors
authors = ko_authors.split(", ")
author_list = []
for author_str in authors:
logger.debug(f"Looking up author {author_str}")
if author_str == "N/A":
continue
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()
logger.debug(f"Created author {author}")
book.authors.add(author)
# This will try to fix metadata by looking it up on OL
book.fix_metadata()
book = create_book_from_row(book_row)
book.refresh_from_db()
total_seconds = 0
if book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]:
total_seconds = book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]
book_id_map[book_row[KoReaderBookColumn.ID.value]] = (
book.id,
total_seconds,
)
book_id_map[book_row[KoReaderBookColumn.ID.value]] = {
"book_id": book.id,
"hash": book_row[KoReaderBookColumn.MD5.value],
"total_seconds": total_seconds,
}
return book_id_map
def build_scrobbles_from_pages(
rows: Iterable, book_id_map: dict, user_id: int
) -> List[Scrobble]:
new_scrobbles = []
def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
"""Given rows of page data from KoReader, parse each row and build
scrobbles for our user, loading the page data into the page_data
field on the scrobble instance.
"""
book_ids_not_found = []
for page_row in page_rows:
koreader_book_id = page_row[KoReaderPageStatColumn.ID_BOOK.value]
new_scrobbles = []
pages_found = []
book_read_time_map = {}
for page_row in rows:
koreader_id = page_row[KoReaderPageStatColumn.ID_BOOK.value]
if koreader_id not in book_id_map.keys():
if koreader_book_id not in book_map.keys():
book_ids_not_found.append(koreader_book_id)
continue
if "pages" not in book_map[koreader_book_id].keys():
book_map[koreader_book_id]["pages"] = {}
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
ts = page_row[KoReaderPageStatColumn.START_TIME.value]
book_id = book_id_map[koreader_id][0]
book_read_time_map[book_id] = book_id_map[koreader_id][1]
duration = page_row[KoReaderPageStatColumn.DURATION.value]
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
page, page_created = Page.objects.get_or_create(
book_id=book_id, number=page_number, user_id=user_id
book_map[koreader_book_id]["pages"][page_number] = {
"duration": duration,
"start_ts": start_ts,
"end_ts": start_ts + duration,
}
if book_ids_not_found:
logger.info(
f"Found pages for books not in file: {set(book_ids_not_found)}"
)
if page_created:
page.start_time = datetime.utcfromtimestamp(ts).replace(
tzinfo=pytz.utc
return book_map
def build_scrobbles_from_book_map(
book_map: dict, user: "User"
) -> list["Scrobble"]:
Scrobble = apps.get_model("scrobbles", "Scrobble")
scrobbles_to_create = []
pages_not_found = []
for koreader_book_id, book_dict in book_map.items():
book_id = book_dict["book_id"]
if "pages" not in book_dict.keys():
pages_not_found.append(book_id)
continue
should_create_scrobble = False
scrobble_page_data = {}
playback_position_seconds = 0
prev_page_stats = {}
last_page_number = 0
pages_processed = 0
total_pages_read = len(book_map[koreader_book_id]["pages"])
ordered_pages = sorted(
book_map[koreader_book_id]["pages"].items(),
key=lambda x: x[1]["start_ts"],
)
for cur_page_number, stats in ordered_pages:
pages_processed += 1
seconds_from_last_page = 0
if prev_page_stats:
seconds_from_last_page = stats.get(
"end_ts"
) - prev_page_stats.get("start_ts")
playback_position_seconds = playback_position_seconds + stats.get(
"duration"
)
page.duration_seconds = page_row[
KoReaderPageStatColumn.DURATION.value
]
page.save(update_fields=["start_time", "duration_seconds"])
pages_found.append(page)
playback_position_seconds = 0
for page in set(pages_found):
# Add up page seconds to set the aggregate time of all pages to reading time
playback_position_seconds = (
playback_position_seconds + page.duration_seconds
)
if page.is_scrobblable:
# Check to see if a scrobble with this timestamp, book and user already exists
scrobble = Scrobble.objects.filter(
timestamp=page.start_time,
book_id=page.book_id,
user_id=user_id,
).first()
if not scrobble:
logger.debug(
f"Queueing scrobble for {page.book}, page {page.number}"
end_of_reading = pages_processed == total_pages_read
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
if (
is_session_gap and not big_jump_to_this_page
) or end_of_reading:
should_create_scrobble = True
if should_create_scrobble:
scrobble_page_data = dict(
sorted(
scrobble_page_data.items(),
key=lambda x: x[1]["start_ts"],
)
)
new_scrobble = Scrobble(
book_id=page.book_id,
user_id=user_id,
source="KOReader",
media_type=Scrobble.MediaType.BOOK,
timestamp=page.start_time,
played_to_completion=True,
playback_position_seconds=playback_position_seconds,
in_progress=False,
book_pages_read=page.number,
long_play_complete=False,
)
new_scrobbles.append(new_scrobble)
# After setting a scrobblable page, reset our accumulator
playback_position_seconds = 0
return new_scrobbles
try:
first_page = scrobble_page_data.get(
list(scrobble_page_data.keys())[0]
)
last_page = scrobble_page_data.get(
list(scrobble_page_data.keys())[-1]
)
except IndexError:
logger.error(
"Could not process book, no page data found",
extra={"scrobble_page_data": scrobble_page_data},
)
continue
timezone = user.profile.timezone
timestamp = datetime.fromtimestamp(
int(first_page.get("start_ts"))
).replace(tzinfo=pytz.timezone(timezone))
# Add a shim here temporarily to fix imports while we were in France
# if date is between 10/15 and 12/15, cast it to Europe/Central
if (
datetime(2023, 10, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
<= timestamp
<= datetime(2023, 12, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
):
timezone = "Europe/Paris"
stop_timestamp = datetime.fromtimestamp(
int(last_page.get("end_ts"))
).replace(tzinfo=pytz.timezone(timezone))
if (
timestamp.tzinfo._dst.seconds == 0
or stop_timestamp.tzinfo._dst.seconds == 0
):
timestamp = timestamp - timedelta(hours=1)
stop_timestamp = stop_timestamp - timedelta(hours=1)
scrobble = Scrobble.objects.filter(
timestamp=timestamp,
book_id=book_id,
user_id=user.id,
).first()
if not scrobble:
logger.info(
f"Queueing scrobble for {book_id}, page {cur_page_number}"
)
log_data = {
"koreader_hash": book_dict.get("hash"),
"page_data": scrobble_page_data,
"pages_read": len(scrobble_page_data.keys()),
}
scrobbles_to_create.append(
Scrobble(
book_id=book_id,
user_id=user.id,
source="KOReader",
media_type=Scrobble.MediaType.BOOK,
timestamp=timestamp,
log=log_data,
stop_timestamp=stop_timestamp,
playback_position_seconds=playback_position_seconds,
in_progress=False,
played_to_completion=True,
long_play_complete=False,
timezone=timezone,
)
)
# 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
last_page_number = cur_page_number
prev_page_stats = stats
if pages_not_found:
logger.info(f"Pages not found for books: {set(pages_not_found)}")
return scrobbles_to_create
def enrich_koreader_scrobbles(scrobbles: list) -> None:
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
"""Given a list of scrobbles, update pages read, long play seconds and check
for media completion"""
for scrobble in scrobbles:
scrobble.book_pages_read = scrobble.book.page_set.last().number
# But if there's a next scrobble, set pages read to their starting page
#
if scrobble.next:
scrobble.book_pages_read = scrobble.next.book_pages_read - 1
scrobble.long_play_seconds = scrobble.book.page_set.filter(
number__lte=scrobble.book_pages_read
).aggregate(Sum("duration_seconds"))["duration_seconds__sum"]
scrobble.save(update_fields=["book_pages_read", "long_play_seconds"])
def process_koreader_sqlite_url(file_url, user_id) -> list:
book_id_map = {}
new_scrobbles = []
for table_name, pragma_table_info, rows in stream_sqlite(
_sqlite_bytes(file_url), max_buffer_size=1_048_576
):
logger.debug(f"Found table {table_name} - processing")
if table_name == "book":
book_id_map = get_book_map_from_sqlite(rows)
if table_name == "page_stat_data":
new_scrobbles = build_scrobbles_from_pages(
rows, book_id_map, user_id
if scrobble.previous and not scrobble.previous.long_play_complete:
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
scrobble.previous.long_play_seconds or 0
)
logger.debug(f"Creating {len(new_scrobbles)} new scrobbles")
else:
scrobble.long_play_seconds = scrobble.playback_position_seconds
scrobble.log["book_pages_read"] = scrobble.calc_pages_read()
created = []
if new_scrobbles:
created = Scrobble.objects.bulk_create(new_scrobbles)
enrich_koreader_scrobbles(created)
logger.info(
f"Created {len(created)} scrobbles",
extra={"created_scrobbles": created},
)
return created
scrobble.save(update_fields=["log", "long_play_seconds"])
def process_koreader_sqlite_file(file_path, user_id) -> list:
"""Given a sqlite file from KoReader, open the book table, iterate
over rows creating scrobbles from each book found"""
# Create a SQL connection to our SQLite database
con = sqlite3.connect(file_path)
cur = con.cursor()
Scrobble = apps.get_model("scrobbles", "Scrobble")
book_id_map = get_book_map_from_sqlite(cur.execute("SELECT * FROM book"))
new_scrobbles = build_scrobbles_from_pages(
cur.execute("SELECT * from page_stat_data"), book_id_map, user_id
)
new_scrobbles = []
user = User.objects.filter(id=user_id).first()
tz = pytz.utc
if user:
tz = user.profile.timezone
is_os_file = "https://" not in file_path
if is_os_file:
# Loading sqlite file from local filesystem
con = sqlite3.connect(file_path)
cur = con.cursor()
try:
book_map = build_book_map(cur.execute("SELECT * FROM book"))
except sqlite3.OperationalError:
logger.warning("KOReader sqlite file had not table: book")
return new_scrobbles
book_map = build_page_data(
cur.execute(
"SELECT * from page_stat_data ORDER BY id_book, start_time"
),
book_map,
tz,
)
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
else:
# Streaming the sqlite file off S3
book_map = {}
for table_name, pragma_table_info, rows in stream_sqlite(
_sqlite_bytes(file_path), max_buffer_size=1_048_576
):
logger.debug(f"Found table {table_name} - processing")
if table_name == "book":
book_map = build_book_map(rows)
for table_name, pragma_table_info, rows in stream_sqlite(
_sqlite_bytes(file_path), max_buffer_size=1_048_576
):
if table_name == "page_stat_data":
book_map = build_page_data(rows, book_map, tz)
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
logger.info(f"Creating {len(new_scrobbles)} new scrobbles")
created = []
if new_scrobbles:
created = Scrobble.objects.bulk_create(new_scrobbles)
enrich_koreader_scrobbles(created)
fix_long_play_stats_for_scrobbles(created)
logger.info(
f"Created {len(created)} scrobbles",
extra={"created_scrobbles": created},
)
return created
def process_koreader_sqlite(file_path: str, user_id: int) -> list:
is_os_file = "https://" not in file_path
if is_os_file:
created = process_koreader_sqlite_file(file_path, user_id)
else:
created = process_koreader_sqlite_url(file_path, user_id)
return created

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,42 @@
#!/usr/bin/env python3
import logging
import pytz
from datetime import datetime, timedelta
from books.models import Book
from django.core.management.base import BaseCommand
from scrobbles.models import Scrobble
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
logger = logging.getLogger(__name__)
# Grace period between page reads for it to be a new scrobble
SESSION_GAP_SECONDS = 1800 # a half hour
def update_scrobble_from_page_data(scrobble, commit=True):
page_list = list(scrobble.book_page_data.items())
first_page_start_ts = datetime.fromtimestamp(page_list[0][1]["start_ts"])
last_page_end_ts = datetime.fromtimestamp(page_list[-1][1]["end_ts"])
if (
datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15)
):
first_page_start_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
last_page_end_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
else:
first_page_start_ts.replace(tzinfo=pytz.timezone("US/Eastern"))
last_page_end_ts.replace(tzinfo=pytz.timezone("US/Eastern"))
scrobble.timestamp = first_page_start_ts
scrobble.stop_timestamp = last_page_end_ts
if commit:
scrobble.save(update_fields=["timestamp", "stop_timestamp"])
class Command(BaseCommand):
def handle(self, *args, **options):
scrobbles_to_create = []
for scrobble in Scrobble.objects.filter(media_type="Book", source="KOReader"):
update_scrobble_from_page_data(scrobble)

View File

@ -0,0 +1,160 @@
import logging
import pytz
from datetime import datetime, timedelta
from books.models import Book
from django.core.management.base import BaseCommand
from scrobbles.models import Scrobble
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
logger = logging.getLogger(__name__)
# Grace period between page reads for it to be a new scrobble
SESSION_GAP_SECONDS = 1800 # a half hour
class Command(BaseCommand):
def handle(self, *args, **options):
scrobbles_to_create = []
for book in Book.objects.filter(koreader_id__isnull=False):
book.scrobble_set.all().delete()
koreader_data = book.koreader_data_by_hash or {}
if book.koreader_md5:
koreader_data[book.koreader_md5] = {
"title": book.title,
"book_id": book.koreader_id,
"author_str": book.koreader_authors,
"pages": book.pages,
}
book.koreader_data_by_hash = koreader_data
book.save(update_fields=["koreader_data_by_hash"])
# Next parse all this book's pages into new scrobbles
should_create_scrobble = False
scrobble_page_data = {}
playback_position_seconds = 0
prev_page = None
pages_processed = 0
total_pages = book.pages
for page in book.page_set.order_by("number"):
user = page.user
book_id = page.book.id
pages_processed += 1
scrobble_page_data[page.number] = {
"duration": page.duration_seconds,
"start_ts": page.start_time.timestamp(),
"end_ts": page.end_time.timestamp(),
}
seconds_from_last_page = 0
if prev_page:
seconds_from_last_page = (
page.end_time.timestamp()
- prev_page.start_time.timestamp()
)
playback_position_seconds = (
playback_position_seconds + page.duration_seconds
)
end_of_reading = pages_processed == total_pages
big_jump_to_this_page = False
if prev_page:
big_jump_to_this_page = (
page.number - prev_page.number
) > 10
if (
seconds_from_last_page > SESSION_GAP_SECONDS
and not big_jump_to_this_page
):
should_create_scrobble = True
if should_create_scrobble:
first_page = scrobble_page_data.get(
list(scrobble_page_data.keys())[0]
)
last_page = scrobble_page_data.get(
list(scrobble_page_data.keys())[-1]
)
start_ts = int(first_page.get("start_ts"))
end_ts = start_ts + playback_position_seconds
timestamp = datetime.fromtimestamp(start_ts).replace(
tzinfo=user.profile.tzinfo
)
stop_timestamp = datetime.fromtimestamp(end_ts).replace(
tzinfo=user.profile.tzinfo
)
# Add a shim here temporarily to fix imports while we were in France
# if date is between 10/15 and 12/15, cast it to Europe/Central
if (
datetime(2023, 10, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
<= timestamp
<= datetime(2023, 12, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
):
timestamp.replace(tzinfo=pytz.timezone("Europe/Paris"))
elif (
timestamp.tzinfo._dst.seconds == 0
or stop_timestamp.tzinfo._dst.seconds == 0
):
timestamp = timestamp - timedelta(hours=1)
stop_timestamp = stop_timestamp - timedelta(hours=1)
scrobble = Scrobble.objects.filter(
timestamp=timestamp,
book_id=book_id,
user_id=user.id,
).first()
if scrobble:
logger.info(
f"Found existing scrobble {scrobble}, updating"
)
scrobble.book_page_data = scrobble_page_data
scrobble.playback_position_seconds = (
scrobble.calc_reading_duration()
)
scrobble.save(
update_fields=[
"book_page_data",
"playback_position_seconds",
]
)
if not scrobble:
logger.info(
f"Queueing scrobble for {book_id}, page {page.number}"
)
scrobbles_to_create.append(
Scrobble(
book_id=book_id,
user_id=user.id,
source="KOReader",
media_type=Scrobble.MediaType.BOOK,
timestamp=timestamp,
stop_timestamp=stop_timestamp,
playback_position_seconds=playback_position_seconds,
book_koreader_hash=list(
book.koreader_data_by_hash.keys()
)[0],
book_page_data=scrobble_page_data,
book_pages_read=page.number,
in_progress=False,
played_to_completion=True,
long_play_complete=False,
)
)
# Then start over
should_create_scrobble = False
playback_position_seconds = 0
scrobble_page_data = {}
prev_page = page
created = Scrobble.objects.bulk_create(scrobbles_to_create)
fix_long_play_stats_for_scrobbles(created)

View File

@ -0,0 +1,29 @@
import logging
import pytz
from datetime import datetime, timedelta
from books.models import Book
from django.core.management.base import BaseCommand
from scrobbles.models import Scrobble
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
logger = logging.getLogger(__name__)
class Command(BaseCommand):
def handle(self, *args, **options):
total_books = Book.objects.all().count()
processed_books = 0
for book in Book.objects.all():
for scrobble in book.scrobble_set.all():
log_data = {
"koreader_hash": scrobble.book_koreader_hash,
"page_data": scrobble.book_page_data,
"pages_read": scrobble.book_pages_read,
}
scrobble.log = log_data
scrobble.save(update_fields=["log"])
processed_books += 1
logger.info(f"Processed book {processed_books} of {total_books}")

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.9 on 2024-01-29 05:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0019_alter_book_authors"),
]
operations = [
migrations.AddField(
model_name="author",
name="comicvine_data",
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name="book",
name="comicvine_data",
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name="book",
name="koreader_data_by_hash",
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -1,5 +1,6 @@
from collections import OrderedDict
import logging
from datetime import timedelta
from datetime import timedelta, datetime
from uuid import uuid4
import requests
@ -22,12 +23,20 @@ from scrobbles.mixins import (
)
from scrobbles.utils import get_scrobbles_for_media
from taggit.managers import TaggableManager
from thefuzz import fuzz
from vrobbler.apps.books.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
from vrobbler.apps.books.locg import (
lookup_comic_by_locg_slug,
lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug,
)
from vrobbler.apps.scrobbles.dataclasses import BookLogData
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
logger = logging.getLogger(__name__)
User = get_user_model()
@ -58,6 +67,7 @@ class Author(TimeStampedModel):
wikidata_id = 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)
def __str__(self):
@ -91,9 +101,11 @@ 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)
pages = models.IntegerField(**BNULL)
language = models.CharField(max_length=4, **BNULL)
@ -101,6 +113,7 @@ class Book(LongPlayScrobblableMixin):
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",
@ -125,6 +138,10 @@ class Book(LongPlayScrobblableMixin):
def subtitle(self):
return f" by {self.author}"
@property
def logdata_cls(self):
return BookLogData
@property
def primary_image_url(self) -> str:
url = ""
@ -144,27 +161,35 @@ class Book(LongPlayScrobblableMixin):
if self.author:
author_name = self.author.name
if not data:
logger.warn(f"Checking openlibrary for {self.title}")
if self.openlibrary_id and force_update:
data = lookup_book_from_openlibrary(
str(self.openlibrary_id)
)
else:
data = lookup_book_from_openlibrary(
str(self.title), author_name
)
if not data:
if self.locg_slug:
logger.warn(
f"Checking LOCG for {self.title} with slug {self.locg_slug}"
)
data = lookup_comic_by_locg_slug(str(self.locg_slug))
else:
logger.warn(f"Checking LOCG for {self.title}")
data = lookup_comic_from_locg(str(self.title))
if not data:
logger.warn(
f"Book not found on LOCG, checking OL {self.title}"
)
if self.openlibrary_id and force_update:
data = lookup_book_from_openlibrary(
str(self.openlibrary_id)
)
else:
data = lookup_book_from_openlibrary(
str(self.title), author_name
)
if not data:
logger.warn(f"Book not found in OL {self.title}")
return
if not data and COMICVINE_API_KEY:
logger.warn(f"Checking ComicVine for {self.title}")
cv_client = ComicVineClient(api_key=COMICVINE_API_KEY)
data = lookup_comic_from_comicvine(str(self.title))
if not data:
logger.warn(f"Book not found in any sources: {self.title}")
return
# We can discard the author name from OL for now, we'll lookup details below
data.pop("ol_author_name", "")
@ -174,15 +199,31 @@ class Book(LongPlayScrobblableMixin):
self.get_author_from_locg(data.pop("locg_writer_slug", ""))
ol_title = data.get("title", "")
data.pop("ol_author_id", "")
# Kick out a little warning if we're about to change KoReader's title
if ol_title.lower() != str(self.title).lower():
if (
fuzz.ratio(ol_title.lower(), str(self.title).lower()) < 80
and not force_update
):
logger.warn(
f"OL and KoReader disagree on this book title {self.title} != {ol_title}"
f"OL and KoReader disagree on this book title {self.title} != {ol_title}, check manually"
)
self.openlibrary_id = data.get("openlibrary_id")
self.save(update_fields=["openlibrary_id"])
return
# If we don't know pages, don't overwrite existing with None
if data.get("pages") == None:
if "pages" in data.keys() and data.get("pages") == None:
data.pop("pages")
if (
not isinstance(data.get("pages"), int)
and "pages" in data.keys()
):
logger.info(
f"Pages for {self} from OL expected to be int, but got {data.get('pages')}"
)
data.pop("pages")
# Pop this, so we can look it up later
@ -242,6 +283,27 @@ class Book(LongPlayScrobblableMixin):
author.headshot.save(fname, ContentFile(r.content), save=True)
self.authors.add(author)
def page_data_for_user(
self, user_id: int, convert_timestamps: bool = True
) -> dict:
scrobbles = self.scrobble_set.filter(user=user_id)
pages = {}
for scrobble in scrobbles:
if scrobble.logdata.page_data:
for page, data in scrobble.logdata.page_data.items():
if convert_timestamps:
data["start_ts"] = datetime.fromtimestamp(
data["start_ts"]
)
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
pages[page] = data
sorted_pages = OrderedDict(
sorted(pages.items(), key=lambda x: x[1]["start_ts"])
)
return sorted_pages
@property
def author(self):
return self.authors.first()
@ -264,34 +326,35 @@ class Book(LongPlayScrobblableMixin):
last_scrobble = get_scrobbles_for_media(self, user).last()
progress = 0
if last_scrobble:
progress = int((last_scrobble.book_pages_read / self.pages) * 100)
progress = int((last_scrobble.last_page_read / self.pages) * 100)
return progress
@classmethod
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
data = lookup_book_from_openlibrary(lookup_id, author)
book = cls.objects.filter(openlibrary_id=lookup_id).first()
book, book_created = cls.objects.get_or_create(isbn=data["isbn"])
if book_created:
book.fix_metadata(data=data)
if not book:
data = lookup_book_from_openlibrary(lookup_id, author)
if not data:
logger.error(
f"No book found on openlibrary, or in our database for {lookup_id}"
)
return book
book, book_created = cls.objects.get_or_create(isbn=data["isbn"])
if book_created:
book.fix_metadata(data=data)
return book
def save(self, *args, **kwargs):
if (
(not self.isbn and not self.cover)
and (self.locg_slug or self.openlibrary_id)
and self.id
):
self.fix_metadata(force_update=True)
return super(Book, self).save(*args, **kwargs)
class Page(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
"""DEPRECATED, we need to migrate pages into page_data on scrobbles and move on"""
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)

View File

@ -5,6 +5,8 @@ import urllib
import requests
from thefuzz import fuzz
logger = logging.getLogger(__name__)
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
@ -36,8 +38,11 @@ def get_author_openlibrary_id(name: str) -> str:
logger.warn(f"No author results found from search for {name}")
return ""
result = results.get("docs", [])
return result[0].get("key")
try:
result = results.get("docs", [])[0]
except IndexError:
result = {"key": ""}
return result.get("key")
def lookup_author_from_openlibrary(olid: str) -> dict:
@ -99,11 +104,22 @@ def lookup_book_from_openlibrary(
top = None
for result in results.get("docs"):
# These Summary things suck and ruin our one-shot search
if "Summary of" not in result.get("title"):
if fuzz.ratio(title.lower(), result.get("title", "").lower()) > 90:
top = result
break
if not top:
for result in results.get("docs"):
# These Summary things suck and ruin our one-shot search
if "Summary of" in result.get("title"):
continue
if title.lower() in result.get("title", "").lower():
top = result
if not top and len(results.get("docs")) > 0:
top = results.get("docs")[0]
if not top:
logger.warn(f"No book found for query {query}")
return {}

View File

@ -0,0 +1,123 @@
import hashlib
import random
import pytest
from django.contrib.auth import get_user_model
from vrobbler.apps.books.koreader import KoReaderBookColumn
User = get_user_model()
ordinal = lambda n: "%d%s" % (
n,
"tsnrhtdd"[(n // 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4],
)
AVERAGE_PAGE_READING_SECONDS = 60
class DummyResponse:
status_code = 200
def status_code(self):
return self.status_code
@pytest.fixture
def valid_response():
return DummyResponse()
class KoReaderBookRows:
id = 1
DEFAULT_STR = "N/A"
DEFAULT_INT = 0
DEFAULT_TIME = 1703800469
BOOK_ROWS = []
PAGE_STATS_ROWS = []
def _gen_random_row(self, i):
wiggle = random.randrange(15)
title = f"Memoirs, Volume {i}"
return [
i,
title,
f"Lord Beaverbrook the {ordinal(i)}",
self.DEFAULT_INT + wiggle / 10,
self.DEFAULT_TIME + i * wiggle,
0,
300 + wiggle,
self.DEFAULT_STR,
self.DEFAULT_STR,
hashlib.md5(title.encode()).hexdigest(),
i * wiggle * 20,
120,
]
def _generate_random_book_rows(self, book_count):
if book_count > 0:
for i in range(1, book_count + 1):
self.BOOK_ROWS.append(self._gen_random_row(i))
def _generate_custom_book_row(self, **kwargs):
title = kwargs.get("title", self.DEFAULT_STR)
if title and title != "N/A":
self.BOOK_ROWS.append(
[
kwargs.get("id", self.id),
kwargs.get("title", self.DEFAULT_STR),
kwargs.get("authors", self.DEFAULT_STR),
kwargs.get("notes", self.DEFAULT_INT),
kwargs.get("last_open", self.DEFAULT_TIME),
kwargs.get("highlights", self.DEFAULT_INT),
kwargs.get("pages", self.DEFAULT_INT),
kwargs.get("series", self.DEFAULT_STR),
kwargs.get("language", self.DEFAULT_STR),
hashlib.md5(title.encode()),
kwargs.get("total_read_time", self.DEFAULT_INT),
kwargs.get("total_read_pages", self.DEFAULT_INT),
]
)
def _generate_random_page_stats_rows(self):
for book in self.BOOK_ROWS:
pages = book[KoReaderBookColumn.PAGES.value]
pages_per_session = 20
start_time = book[KoReaderBookColumn.LAST_OPEN.value]
end_session = False
for page_num in range(
1, book[KoReaderBookColumn.TOTAL_READ_PAGES.value] + 1
):
wiggle = random.randrange(5)
self.PAGE_STATS_ROWS.append(
[
book[KoReaderBookColumn.ID.value],
page_num,
start_time,
AVERAGE_PAGE_READING_SECONDS + wiggle,
pages,
]
)
if end_session:
start_time += 3600 # one second over an hour, marking a new reading session
end_session = False
else:
start_time += AVERAGE_PAGE_READING_SECONDS
if page_num % pages_per_session == 0:
end_session = True
def __init__(self, book_count=0, **kwargs):
self._generate_random_book_rows(book_count)
self._generate_custom_book_row(**kwargs)
self._generate_random_page_stats_rows()
@pytest.fixture
def koreader_rows():
return KoReaderBookRows(book_count=1)
@pytest.fixture
def demo_user():
return User.objects.create(email="demo@example.com")

View File

@ -0,0 +1,54 @@
import pytest
from unittest import mock
from books.koreader import (
KoReaderBookColumn,
build_book_map,
build_page_data,
build_scrobbles_from_book_map,
)
@pytest.mark.django_db
@mock.patch("requests.get")
def test_build_book_map(get_mock, koreader_rows, valid_response):
get_mock.return_value = valid_response
book_map = build_book_map(koreader_rows.BOOK_ROWS)
assert len(book_map) == 1
@pytest.mark.django_db
@mock.patch("requests.get")
def test_load_page_data_to_map(get_mock, koreader_rows, valid_response):
get_mock.return_value = valid_response
book_map = build_page_data(
koreader_rows.PAGE_STATS_ROWS,
build_book_map(koreader_rows.BOOK_ROWS),
)
assert (
len(book_map[1]["pages"])
== koreader_rows.BOOK_ROWS[0][
KoReaderBookColumn.TOTAL_READ_PAGES.value
]
)
@pytest.mark.django_db
@mock.patch("requests.get")
def test_build_scrobbles_from_pages(
get_mock, koreader_rows, demo_user, valid_response
):
get_mock.return_value = valid_response
book_map = build_book_map(koreader_rows.BOOK_ROWS)
book_map = build_page_data(koreader_rows.PAGE_STATS_ROWS, book_map)
scrobbles = build_scrobbles_from_book_map(book_map, demo_user)
# Corresponds to number of sessions per book ( 20 pages per session, 120 +/- 15 pages read )
expected_scrobbles = 6 * len(book_map.keys())
assert len(scrobbles) == expected_scrobbles
assert len(scrobbles[0].logdata.page_data.keys()) == 21
assert len(scrobbles[1].logdata.page_data.keys()) == 20
assert len(scrobbles[2].logdata.page_data.keys()) == 20
assert len(scrobbles[3].logdata.page_data.keys()) == 20
assert len(scrobbles[4].logdata.page_data.keys()) == 20
assert len(scrobbles[5].logdata.page_data.keys()) == 18

View File

@ -0,0 +1,36 @@
from unittest import skip
import pytest
from books.openlibrary import lookup_book_from_openlibrary
def test_lookup_modern_book():
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
assert book.get("title") == "Matrix"
assert book.get("openlibrary_id") == "OL32170218M"
assert book.get("ol_author_id") == "OL3675729A"
def test_lookup_classic_book():
book = lookup_book_from_openlibrary(
"The Life of Castruccio Castracani", "Machiavelli"
)
assert book.get("title") == "The Life of Castruccio Castracani of Lucca"
assert book.get("openlibrary_id") == "OL8950869M"
assert book.get("ol_author_id") == "OL23135A"
def test_lookup_foreign_book():
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
assert book.get("title") == "Ravage"
assert book.get("openlibrary_id") == "OL8837839M"
assert book.get("ol_author_id") == "OL152472A"
@skip("This is rotten in OL, updated but waiting for it to update")
def test_lookup_book():
book = lookup_book_from_openlibrary("Hark! A Vagrant")
assert book.get("title") == "Hark! A Vagrant"
assert book.get("openlibrary_id") == "OL8837839M"
assert book.get("ol_author_id") == "OL152472A"

View File

@ -1,15 +1,15 @@
from django.views import generic
from books.models import Book, Author
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class BookListView(generic.ListView):
class BookListView(ScrobbleableListView):
model = Book
paginate_by = 20
class BookDetailView(generic.DetailView):
class BookDetailView(ScrobbleableDetailView):
model = Book
slug_field = "uuid"
class AuthorDetailView(generic.DetailView):

View File

View File

@ -0,0 +1,23 @@
from django.contrib import admin
from bricksets.models import BrickSet
from scrobbles.admin import ScrobbleInline
class BrickSetInline(admin.TabularInline):
model = BrickSet
extra = 0
@admin.register(BrickSet)
class BrickSetAdmin(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 BrickSetsConfig(AppConfig):
name = "bricksets"

View File

@ -0,0 +1,106 @@
# Generated by Django 4.2.15 on 2024-09-07 05:38
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0059_remove_scrobble_book_koreader_hash_and_more"),
]
operations = [
migrations.CreateModel(
name="BrickSet",
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),
),
(
"number",
models.CharField(blank=True, max_length=10, null=True),
),
("release_year", models.IntegerField(blank=True, null=True)),
("piece_count", models.IntegerField(blank=True, null=True)),
(
"brickset_rating",
models.DecimalField(
blank=True, decimal_places=1, max_digits=3, null=True
),
),
(
"lego_item_number",
models.CharField(blank=True, max_length=10, null=True),
),
(
"box_image",
models.ImageField(
blank=True, null=True, upload_to="brickset/boxes/"
),
),
(
"set_image",
models.ImageField(
blank=True, null=True, upload_to="brickset/sets/"
),
),
(
"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,63 @@
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 BrickSetLogData
from scrobbles.mixins import LongPlayScrobblableMixin
BNULL = {"blank": True, "null": True}
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)
lego_item_number = models.CharField(max_length=10, **BNULL)
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
box_image_small = ImageSpecField(
source="box_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
box_image_medium = ImageSpecField(
source="box_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
set_image = models.ImageField(upload_to="brickset/sets/", **BNULL)
set_image_small = ImageSpecField(
source="set_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
set_image_medium = ImageSpecField(
source="set_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
def get_absolute_url(self):
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
@property
def logdata_cls(self):
return BrickSetLogData
@classmethod
def find_or_create(cls, title: str) -> "BrickSet":
return cls.objects.filter(title=title).first()
@property
def primary_image_url(self) -> str:
if self.box_image:
return self.box_image.url
return ""

View File

@ -0,0 +1,14 @@
from django.urls import path
from bricksets import views
app_name = "bricksets"
urlpatterns = [
path("bricksets/", views.BrickSetListView.as_view(), name="brickset_list"),
path(
"bricksets/<slug:slug>/",
views.BrickSetDetailView.as_view(),
name="brickset_detail",
),
]

View File

@ -0,0 +1,10 @@
from bricksets.models import BrickSet
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class BrickSetListView(ScrobbleableListView):
model = BrickSet
class BrickSetDetailView(ScrobbleableDetailView):
model = BrickSet

View File

@ -0,0 +1,16 @@
from django.contrib import admin
from lifeevents.models import LifeEvent
from scrobbles.admin import ScrobbleInline
@admin.register(LifeEvent)
class EventAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("title",)
search_fields = ("title",)
ordering = ("-created",)
inlines = [
ScrobbleInline,
]

View File

@ -0,0 +1,79 @@
# Generated by Django 4.2.11 on 2024-05-07 13:37
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0055_rename_scrobble_log_scrobble_log"),
]
operations = [
migrations.CreateModel(
name="LifeEvent",
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)),
(
"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,33 @@
from django.apps import apps
from django.db import models
from django.urls import reverse
from scrobbles.dataclasses import LifeEventLogData
from scrobbles.mixins import ScrobblableMixin
BNULL = {"blank": True, "null": True}
class LifeEvent(ScrobblableMixin):
description = models.TextField(**BNULL)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse(
"life-events:life-event_detail", kwargs={"slug": self.uuid}
)
@property
def logdata_cls(self):
return LifeEventLogData
@classmethod
def find_or_create(cls, title: str) -> "LifeEvent":
return cls.objects.filter(title=title).first()
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(
user_id=user_id, life_event=self
).order_by("-timestamp")

View File

@ -0,0 +1,16 @@
from django.urls import path
from lifeevents import views
app_name = "lifeevents"
urlpatterns = [
path(
"lifeevents/", views.LifeEventListView.as_view(), name="lifeevent_list"
),
path(
"lifeevent/<slug:slug>/",
views.LifeEventDetailView.as_view(),
name="life-event_detail",
),
]

View File

@ -0,0 +1,12 @@
from django.views import generic
from lifeevents.models import LifeEvent
class LifeEventListView(generic.ListView):
model = LifeEvent
paginate_by = 20
class LifeEventDetailView(generic.DetailView):
model = LifeEvent
slug_field = "uuid"

View File

@ -1,6 +1,6 @@
from django.contrib import admin
from locations.models import GeoLocation, RawGeoLocation
from locations.models import GeoLocation
from scrobbles.admin import ScrobbleInline
@ -19,18 +19,3 @@ class GeoLocationAdmin(admin.ModelAdmin):
inlines = [
ScrobbleInline,
]
@admin.register(RawGeoLocation)
class RawGeoLocationAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"lat",
"lon",
"altitude",
"speed",
)
ordering = (
"lat",
"lon",
)

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.9 on 2024-02-20 00:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("locations", "0005_alter_rawgeolocation_options_and_more"),
]
operations = [
migrations.DeleteModel(
name="RawGeoLocation",
),
]

View File

@ -1,3 +1,4 @@
from decimal import Decimal, getcontext
import logging
from typing import Dict
from uuid import uuid4
@ -13,12 +14,13 @@ logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
User = get_user_model()
ACCURACY = getattr(settings, "GEOLOC_ACCURACY", 3)
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
class GeoLocation(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
lat = models.FloatField()
lon = models.FloatField()
truncated_lat = models.FloatField(**BNULL)
@ -72,7 +74,6 @@ class GeoLocation(ScrobblableMixin):
location = cls.objects.filter(
lat=data_dict.get("lat"),
lon=data_dict.get("lon"),
altitude=data_dict.get("altitude"),
).first()
if not location:
@ -83,11 +84,49 @@ class GeoLocation(ScrobblableMixin):
)
return location
@property
def subtitle(self) -> str:
if self.title:
return f"{self.lat} x {self.lon}"
return ""
class RawGeoLocation(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
lat = models.FloatField()
lon = models.FloatField()
altitude = models.FloatField(**BNULL)
speed = models.FloatField(**BNULL)
timestamp = models.DateTimeField(**BNULL)
def loc_diff(self, old_lat_lon: tuple) -> tuple:
return (
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
abs(Decimal(old_lat_lon[1]) - Decimal(self.lon)),
)
def has_moved(self, previous_location: "GeoLocation") -> bool:
has_moved = False
loc_diff = self.loc_diff(
(previous_location.lat, previous_location.lon)
)
if loc_diff[0] > GEOLOC_PROXIMITY or loc_diff[1] > GEOLOC_PROXIMITY:
has_moved = True
logger.debug(
f"[locations] checked whether location has moved against proximity setting",
extra={
"location_id": self.id,
"loc_diff": loc_diff,
"has_moved": has_moved,
"previous_location_id": previous_location.id,
"geoloc_proximity": GEOLOC_PROXIMITY,
},
)
return has_moved
def in_proximity(self, named=False) -> models.QuerySet:
lat_min = Decimal(self.lat) - GEOLOC_PROXIMITY
lat_max = Decimal(self.lat) + GEOLOC_PROXIMITY
lon_min = Decimal(self.lon) - GEOLOC_PROXIMITY
lon_max = Decimal(self.lon) + GEOLOC_PROXIMITY
is_title_null = not named
close_locations = GeoLocation.objects.filter(
title__isnull=is_title_null,
lat__lte=lat_max,
lat__gte=lat_min,
lon__lte=lon_max,
lon__gte=lon_min,
).exclude(id=self.id)
return close_locations

View File

@ -0,0 +1,90 @@
import pytest
import logging
from locations.models import GeoLocation
logger = logging.getLogger(__name__)
def test_find_or_create(caplog):
assert not GeoLocation.find_or_create({})
assert "No lat or lon keys in data dict" in caplog.text
@pytest.mark.django_db
def test_find_or_create_truncation():
loc = GeoLocation.find_or_create(
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
)
assert loc.lat == 44.234
assert loc.lon == -68.234
assert loc.altitude == 60
@pytest.mark.django_db
def test_find_or_create_finds_existing():
extant = GeoLocation.objects.create(lat=44.234, lon=-68.234, altitude=50)
loc = GeoLocation.find_or_create(
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
)
assert loc.id == extant.id
@pytest.mark.django_db
def test_find_or_create_creates_new():
extant = GeoLocation.objects.create(lat=44.234, lon=-69.234, altitude=60)
loc = GeoLocation.find_or_create(
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
)
assert not loc.id == extant.id
@pytest.mark.django_db
def test_found_in_proximity_location():
lat = 44.234
lon = -69.234
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
close = GeoLocation.objects.create(
lat=lat + 0.0001, lon=lon - 0.0001, altitude=60
)
assert close not in loc.in_proximity(named=True)
assert close in loc.in_proximity()
@pytest.mark.django_db
def test_not_found_in_proximity_location():
lat = 44.234
lon = -69.234
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
far = GeoLocation.objects.create(
lat=lat + 0.0002, lon=lon - 0.0001, altitude=60
)
assert far not in loc.in_proximity()
@pytest.mark.django_db
def test_has_moved(caplog):
lat = 44.234
lon = -69.234
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
past = GeoLocation.objects.get_or_create(
lat=lat + 0.0009, lon=lon - 0.002, altitude=60
)[0]
assert loc.has_moved(past)
@pytest.mark.django_db
def test_has_not_moved():
lat = 44.234
lon = -69.234
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
past = GeoLocation.objects.get_or_create(
lat=lat + 0.00009, lon=lon - 0.00009, altitude=60
)[0]
assert not loc.has_moved(past)

View File

View File

@ -0,0 +1,23 @@
from django.contrib import admin
from moods.models import Mood
from scrobbles.admin import ScrobbleInline
class MoodInline(admin.TabularInline):
model = Mood
extra = 0
@admin.register(Mood)
class MoodAdmin(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 MoodsConfig(AppConfig):
name = "moods"

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,79 @@
# Generated by Django 4.2.13 on 2024-08-10 20:01
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
]
operations = [
migrations.CreateModel(
name="Mood",
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)),
(
"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.13 on 2024-08-10 20:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moods", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="mood",
name="image",
field=models.ImageField(blank=True, null=True, upload_to="moods/"),
),
]

View File

@ -0,0 +1,56 @@
import logging
from django.contrib.auth import get_user_model
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 vrobbler.apps.scrobbles.dataclasses import MoodLogData
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
User = get_user_model()
class Mood(ScrobblableMixin):
description = models.TextField(**BNULL)
image = models.ImageField(upload_to="moods/", **BNULL)
image_small = ImageSpecField(
source="thumbnail",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
image_medium = ImageSpecField(
source="thumbnail",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
def __str__(self):
if self.title:
return self.title
return str(self.uuid)
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 logdata_cls(self):
return MoodLogData
@property
def primary_image_url(self) -> str:
if self.image:
return self.image.url
return ""

View File

@ -0,0 +1,14 @@
from django.urls import path
from moods import views
app_name = "moods"
urlpatterns = [
path("moods/", views.MoodListView.as_view(), name="mood-list"),
path(
"moods/<slug:slug>/",
views.MoodDetailView.as_view(),
name="mood-detail",
),
]

View File

@ -0,0 +1,10 @@
from moods.models import Mood
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class MoodListView(ScrobbleableListView):
model = Mood
class MoodDetailView(ScrobbleableDetailView):
model = Mood

View File

@ -1,3 +1,78 @@
jellyfin_keys = [
"ServerId",
"ServerName",
"ServerVersion",
"ServerUrl",
"NotificationType",
"Timestamp",
"UtcTimestamp",
"Name",
"Overview",
"Tagline",
"ItemId",
"RunTimeTicks",
"RunTime",
"Year",
"Provider_tmdb",
"Provider_imdb",
"Provider_tmdbcollection",
"Provider_tmdbset",
"Video_0_Title",
"Video_0_Type",
"Video_0_Codec",
"Video_0_Profile",
"Video_0_Level",
"Video_0_Height",
"Video_0_Width",
"Video_0_AspectRatio",
"Video_0_Interlaced",
"Video_0_FrameRate",
"Video_0_VideoRange",
"Video_0_ColorSpace",
"Video_0_ColorTransfer",
"Video_0_ColorPrimaries",
"Video_0_PixelFormat",
"Video_0_RefFrames",
"Audio_0_Title",
"Audio_0_Type",
"Audio_0_Language",
"Audio_0_Codec",
"Audio_0_Channels",
"Audio_0_Bitrate",
"Audio_0_SampleRate",
"Audio_0_Default",
"Subtitle_0_Title",
"Subtitle_0_Type",
"Subtitle_0_Language",
"Subtitle_0_Codec",
"Subtitle_0_Default",
"Subtitle_0_Forced",
"Subtitle_0_External",
"Subtitle_1_Title",
"Subtitle_1_Type",
"Subtitle_1_Language",
"Subtitle_1_Codec",
"Subtitle_1_Default",
"Subtitle_1_Forced",
"Subtitle_1_External",
"Subtitle_2_Title",
"Subtitle_2_Type",
"Subtitle_2_Language",
"Subtitle_2_Codec",
"Subtitle_2_Default",
"Subtitle_2_Forced",
"Subtitle_2_External",
"PlaybackPositionTicks",
"PlaybackPosition",
"MediaSourceId",
"IsPaused",
"IsAutomated",
"DeviceId",
"DeviceName",
"ClientName",
"NotificationUsername",
"UserId",
]
VARIOUS_ARTIST_DICT = {
"name": "Various Artists",
"theaudiodb_id": "113641",
@ -6,10 +81,13 @@ VARIOUS_ARTIST_DICT = {
JELLYFIN_POST_KEYS = {
"ITEM_TYPE": "ItemType",
"ITEM_ID": "ItemId",
"RUN_TIME": "RunTime",
"TITLE": "Name",
"TRACK_TITLE": "Name",
"TIMESTAMP": "UtcTimestamp",
"YEAR": "Year",
"OVERVIEW": "Overview",
"TAGLINE": "TAGLINE",
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
"PLAYBACK_POSITION": "PlaybackPosition",
"ARTIST_MB_ID": "Provider_musicbrainzartist",
@ -18,4 +96,25 @@ JELLYFIN_POST_KEYS = {
"TRACK_MB_ID": "Provider_musicbrainztrack",
"ALBUM_NAME": "Album",
"ARTIST_NAME": "Artist",
"STATUS": "Status",
"SOURCE": "ClientName",
"VIDEO_TITLE": "Name",
"IMDB_ID": "Provider_imdb",
"TMDB_ID": "Provider_tmdb",
}
MOPIDY_POST_KEYS = {
"ITEM_TYPE": None,
"RUN_TIME": "run_time",
"TRACK_TITLE": "name",
"TIMESTAMP": None,
"YEAR": None,
"PLAYBACK_POSITION_TICKS": "playback_time_ticks",
"ARTIST_MB_ID": "muscibrainz_artist_id",
"ALBUM_MB_ID": "musicbrainz_album_id",
"RELEASEGROUP_MB_ID": None,
"TRACK_MB_ID": "musicbrainz_track_id",
"ALBUM_NAME": "album",
"ARTIST_NAME": "artist",
"STATUS": "status",
}

View File

@ -46,7 +46,6 @@ class LastFM:
new_scrobbles = []
source = "Last.fm"
source_id = ""
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for lfm_scrobble in lastfm_scrobbles:
@ -60,12 +59,16 @@ class LastFM:
lfm_scrobble["album"] = album
track = get_or_create_track(**lfm_scrobble)
timezone = settings.TIME_ZONE
if self.vrobbler_user.profile:
timezone = self.vrobbler_user.profile.timezone
new_scrobble = Scrobble(
user=self.vrobbler_user,
timestamp=timestamp,
source=source,
source_id=source_id,
track=track,
timezone=timezone,
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRACK,

View File

@ -404,7 +404,7 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 90)
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
class Opinion(models.IntegerChoices):
DOWN = -1, "Thumbs down"

View File

@ -104,19 +104,37 @@ def lookup_artist_from_mb(artist_name: str) -> dict:
def lookup_track_from_mb(
track_name: str, artist_mbid: str, album_mbid: str
) -> str:
track_name: str, artist_mb_id: str, album_mb_id: str
) -> dict:
logger.info(
"[lookup_track_from_mb] called",
extra={
"track_name": track_name,
"artist_mb_id": artist_mb_id,
"album_mb_id": album_mb_id,
},
)
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
top_result = musicbrainzngs.search_recordings(
query=track_name, artist=artist_mbid, release=album_mbid
)["recording-list"][0]
try:
results = musicbrainzngs.search_recordings(
query=track_name, artist=artist_mb_id, release=album_mb_id
)["recording-list"]
logger.info(
"[lookup_track_from_mb] musicbrainz recordings search results",
extra={"results": results},
)
top_result = results[0]
except IndexError:
logger.error("[lookup_track_from_mb] no results found")
return {}
score = int(top_result.get("ext:score"))
if score < 85:
logger.debug(
"Track lookup score below 85 threshold",
extra={"result": top_result},
logger.info(
"[lookup_track_from_mb] no results above 85% certainty ",
extra={"results": results},
)
return ""
return {}
return top_result

View File

@ -8,6 +8,7 @@ from music.musicbrainz import (
lookup_track_from_mb,
)
from music.constants import VARIOUS_ARTIST_DICT
from scrobbles.utils import convert_to_seconds
logger = logging.getLogger(__name__)
@ -89,32 +90,55 @@ def get_or_create_album(
return album
def get_or_create_track(
title: str,
artist: Artist,
album: Album = None,
mbid: str = None,
run_time_seconds=None,
) -> Track:
track = None
if not mbid and album:
mbid = lookup_track_from_mb(
title,
artist.musicbrainz_id,
album.musicbrainz_id,
)["id"]
def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
try:
track_run_time_seconds = int(
post_data.get(post_keys.get("RUN_TIME"), 0)
)
except ValueError: # Sometimes we get run time as a string like "01:35"
track_run_time_seconds = convert_to_seconds(
post_data.get(post_keys.get("RUN_TIME"), 0)
)
track = Track.objects.filter(musicbrainz_id=mbid).first()
artist_name = post_data.get(post_keys.get("ARTIST_NAME"), "")
artist_mb_id = post_data.get(post_keys.get("ARTIST_MB_ID"), "")
album_title = post_data.get(post_keys.get("ALBUM_NAME"), "")
album_mb_id = post_data.get(post_keys.get("ALBUM_MB_ID"), "")
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,
)
track = None
if not track_mb_id and album:
try:
track_mb_id = lookup_track_from_mb(
track_title,
artist.musicbrainz_id,
album.musicbrainz_id,
).get("id", 0)
except TypeError:
pass
if track_mb_id:
track = Track.objects.filter(musicbrainz_id=track_mb_id).first()
if not track:
track = Track.objects.create(
title=title,
title=track_title,
artist=artist,
album=album,
musicbrainz_id=mbid,
run_time_seconds=run_time_seconds,
musicbrainz_id=track_mb_id,
run_time_seconds=track_run_time_seconds,
)
return track

View File

@ -4,20 +4,15 @@ from music.models import Album, Artist, Track
from scrobbles.models import ChartRecord
from scrobbles.stats import get_scrobble_count_qs
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class TrackListView(generic.ListView):
class TrackListView(ScrobbleableListView):
model = Track
paginate_by = 200
def get_queryset(self):
return get_scrobble_count_qs(user=self.request.user).order_by(
"-scrobble_count"
)
class TrackDetailView(generic.DetailView):
class TrackDetailView(ScrobbleableDetailView):
model = Track
slug_field = "uuid"
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from podcasts.models import Episode, Podcast, Producer
from podcasts.models import PodcastEpisode, Podcast, Producer
from scrobbles.admin import ScrobbleInline
@ -21,8 +21,8 @@ class PodcastAdmin(admin.ModelAdmin):
ordering = ("name",)
@admin.register(Episode)
class EpisodeAdmin(admin.ModelAdmin):
@admin.register(PodcastEpisode)
class PodcastEpisodeAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.9 on 2024-02-10 18:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0048_alter_scrobble_media_type"),
("podcasts", "0012_alter_episode_genre"),
]
operations = [
migrations.RenameModel(
old_name="Episode",
new_name="PodcastEpisode",
),
]

View File

@ -76,7 +76,7 @@ class Podcast(TimeStampedModel):
self.cover_image.save(fname, ContentFile(r.content), save=True)
class Episode(ScrobblableMixin):
class PodcastEpisode(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "PODCAST_COMPLETION_PERCENT", 90)
podcast = models.ForeignKey(Podcast, on_delete=models.DO_NOTHING)

View File

@ -0,0 +1,79 @@
import logging
import os
from urllib.parse import unquote
from dateutil.parser import ParserError, parse
from podcasts.models import PodcastEpisode
logger = logging.getLogger(__name__)
# TODO This should be configurable in settings or per deploy
PODCAST_DATE_FORMAT = "YYYY-MM-DD"
def parse_mopidy_uri(uri: str) -> dict:
logger.debug(f"Parsing URI: {uri}")
parsed_uri = os.path.splitext(unquote(uri))[0].split("/")
episode_str = parsed_uri[-1]
podcast_name = parsed_uri[-2].strip()
episode_num = None
episode_num_pad = 0
try:
# Without episode numbers the date will lead
pub_date = parse(episode_str[0:10])
except ParserError:
episode_num = int(episode_str.split("-")[0])
episode_num_pad = len(str(episode_num)) + 1
try:
# Beacuse we have epsiode numbers on
pub_date = parse(
episode_str[
episode_num_pad : len(PODCAST_DATE_FORMAT)
+ episode_num_pad
]
)
except ParserError:
pub_date = ""
gap_to_strip = 0
if pub_date:
gap_to_strip += len(PODCAST_DATE_FORMAT)
if episode_num:
gap_to_strip += episode_num_pad
episode_name = episode_str[gap_to_strip:].replace("-", " ").strip()
return {
"episode_filename": episode_name,
"episode_num": episode_num,
"podcast_name": podcast_name,
"pub_date": pub_date,
}
def get_or_create_podcast(post_data: dict) -> PodcastEpisode:
mopidy_uri = post_data.get("mopidy_uri", "")
parsed_data = parse_mopidy_uri(mopidy_uri)
producer_dict = {"name": post_data.get("artist")}
podcast_name = post_data.get("album")
if not podcast_name:
podcast_name = parsed_data.get("podcast_name")
podcast_dict = {"name": podcast_name}
episode_name = parsed_data.get("episode_filename")
episode_dict = {
"title": episode_name,
"run_time_seconds": post_data.get("run_time"),
"number": parsed_data.get("episode_num"),
"pub_date": parsed_data.get("pub_date"),
"mopidy_uri": mopidy_uri,
}
return PodcastEpisode.find_or_create(
podcast_dict, producer_dict, episode_dict
)

View File

@ -11,4 +11,5 @@ class UserProfileAdmin(admin.ModelAdmin):
"twitch_token",
"twitch_client_secret",
"lastfm_password",
"archivebox_password",
)

View File

@ -0,0 +1,8 @@
from django.apps import AppConfig
class ProfilesConfig(AppConfig):
name = "profiles"
def ready(self):
import profiles.signals

View File

@ -0,0 +1,601 @@
# Generated by Django 4.2.9 on 2024-01-27 04:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0010_userprofile_redirect_to_webpage"),
]
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"),
("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,601 @@
# Generated by Django 4.2.9 on 2024-03-27 01:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0011_alter_userprofile_timezone"),
]
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",
),
(
"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/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",
),
("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/Ceuta", "(GMT+0100) Africa/Ceuta"),
("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"),
("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,617 @@
# Generated by Django 4.2.11 on 2024-04-13 02:03
from django.db import migrations, models
import encrypted_field.fields
class Migration(migrations.Migration):
dependencies = [
("profiles", "0012_alter_userprofile_timezone"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="archivebox_password",
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
),
migrations.AddField(
model_name="userprofile",
name="archivebox_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="userprofile",
name="archivebox_username",
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/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/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/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,601 @@
# Generated by Django 4.2.11 on 2024-04-19 13:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0013_userprofile_archivebox_password_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

@ -0,0 +1,18 @@
# Generated by Django 4.2.13 on 2024-08-06 15:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0014_alter_userprofile_timezone"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="bgg_username",
field=models.CharField(blank=True, max_length=255, null=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

@ -17,9 +17,7 @@ class UserProfile(TimeStampedModel):
User, on_delete=models.CASCADE, related_name="profile"
)
timezone = models.CharField(
max_length=255,
choices=PRETTY_TIMEZONE_CHOICES,
**BNULL,
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default="UTC"
)
lastfm_username = models.CharField(max_length=255, **BNULL)
lastfm_password = EncryptedField(**BNULL)
@ -28,6 +26,12 @@ class UserProfile(TimeStampedModel):
retroarch_path = models.CharField(max_length=255, **BNULL)
retroarch_auto_import = models.BooleanField(default=False)
archivebox_username = models.CharField(max_length=255, **BNULL)
archivebox_password = EncryptedField(**BNULL)
archivebox_url = models.CharField(max_length=255, **BNULL)
bgg_username = models.CharField(max_length=255, **BNULL)
redirect_to_webpage = models.BooleanField(default=True)
def __str__(self):

View File

@ -7,7 +7,6 @@ from scrobbles.models import (
LastFmImport,
RetroarchImport,
Scrobble,
ScrobbledPage,
)
from scrobbles.mixins import Genre
@ -24,10 +23,17 @@ class ScrobbleInline(admin.TabularInline):
"sport_event",
"board_game",
"geo_location",
"webpage",
"trail",
"web_page",
"life_event",
"user",
)
exclude = ("source_id", "scrobble_log")
exclude = (
"scrobble_log",
"timezone",
"videogame_save_data",
"screenshot",
)
class ImportBaseAdmin(admin.ModelAdmin):
@ -90,7 +96,7 @@ class ChartRecordAdmin(admin.ModelAdmin):
@admin.register(Scrobble)
class ScrobbleAdmin(admin.ModelAdmin):
date_hierarchy = "timestamp"
# date_hierarchy = "timestamp"
list_display = (
"timestamp",
"media_name",
@ -110,7 +116,8 @@ class ScrobbleAdmin(admin.ModelAdmin):
"video_game",
"board_game",
"geo_location",
"webpage",
"web_page",
"life_event",
)
list_filter = (
"is_paused",
@ -126,13 +133,3 @@ class ScrobbleAdmin(admin.ModelAdmin):
def playback_percent(self, obj):
return obj.percent_played
@admin.register(ScrobbledPage)
class ScrobbledPageAdmin(admin.ModelAdmin):
list_display = (
"number",
"scrobble",
"notes",
)
raw_id_fields = ("scrobble",)

View File

@ -6,14 +6,20 @@ JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
LONG_PLAY_MEDIA = {
"videogames": "VideoGame",
"books": "Book",
"bricksets": "BrickSet",
}
PLAY_AGAIN_MEDIA = {
"videogames": "VideoGame",
"books": "Book",
"boardgames": "BoardGame",
"moods": "Mood",
"bricksets": "BrickSet",
}
MEDIA_END_PADDING_SECONDS = {
"Video": 3600, # 60 min
}
EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)

View File

@ -0,0 +1,191 @@
from functools import cached_property
import inspect
import json
from dataclasses import asdict, dataclass
from typing import Optional
from dataclass_wizard import JSONWizard
from django.contrib.auth import get_user_model
from locations.models import GeoLocation
User = get_user_model()
class ScrobbleLogDataEncoder(json.JSONEncoder):
def default(self, o):
return o.__dict__
class ScrobbleLogDataDecoder(json.JSONDecoder):
def default(self, o):
return o.__dict__
class JSONDataclass(JSONWizard):
@property
def asdict(self):
return asdict(self)
@property
def json(self):
return json.dumps(self.asdict)
class LongPlayLogData(JSONDataclass):
serial_scrobble_id: Optional[int]
long_play_complete: bool = False
class WithOthersLogData(JSONDataclass):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
@property
def with_names(self) -> list[str]:
with_names = []
if self.with_user_ids:
with_names += [u.full_name for u in self.with_users if u]
if self.with_names_str:
with_names += [u for u in self.with_names_str]
return with_names
@property
def with_users(self) -> list[User]:
with_users = []
if self.with_user_ids:
with_users = [
User.objects.filter(id=i).first() for i in self.with_user_ids
]
return with_users
@dataclass
class BoardGameScoreLogData(JSONDataclass):
user_id: Optional[int] = None
name_str: str = ""
bgg_username: str = ""
color: Optional[str] = None
character: Optional[str] = None
team: Optional[str] = None
score: Optional[int] = None
win: Optional[bool] = None
new: Optional[bool] = None
@property
def user(self) -> Optional[User]:
user = None
if self.user_id:
user = User.objects.filter(id=self.user_id).first()
return user
@property
def name(self) -> str:
name = self.name_str
if self.user_id:
name = self.user.first_name
return name
def __str__(self) -> str:
out = self.name
if self.score:
out += f" {self.score}"
if self.color:
out += f" ({self.color})"
if self.win:
out += f" [W]"
return out
@dataclass
class BoardGameLogData(LongPlayLogData):
serial_scrobble_id: Optional[int]
long_play_complete: bool = False
players: Optional[list[BoardGameScoreLogData]] = None
location: Optional[str] = None
geo_location_id: Optional[int] = None
difficulty: Optional[int] = None
solo: Optional[bool] = None
two_handed: Optional[bool] = None
@cached_property
def geo_location(self) -> Optional[GeoLocation]:
if self.geo_location_id:
return GeoLocation.objects.filter(id=self.geo_location_id).first()
@dataclass
class BookPageLogData(JSONDataclass):
page_number: Optional[int] = None
end_ts: Optional[int] = None
start_ts: Optional[int] = None
duration: Optional[int] = None
@dataclass
class BookLogData(LongPlayLogData):
long_play_complete: Optional[bool] = None
koreader_hash: Optional[str] = None
page_data: Optional[dict[int, BookPageLogData]] = None
pages_read: Optional[int] = None
page_start: Optional[int] = None
page_end: Optional[int] = None
serial_scrobble_id: Optional[int] = None
@dataclass
class LifeEventLogData(WithOthersLogData):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
location: Optional[str] = None
geo_location_id: Optional[int] = None
details: Optional[str] = None
def geo_location(self):
return GeoLocation.objects.filter(id=self.geo_location_id).first()
@dataclass
class MoodLogData(JSONDataclass):
reasons: Optional[str] = None
@dataclass
class VideoLogData(JSONDataclass):
title: str
video_type: str
run_time_seconds: int
kind: str
year: Optional[int]
episode_number: Optional[int] = None
source_url: Optional[str] = None
imdbID: Optional[str] = None
season_number: Optional[int] = None
cover_url: Optional[str] = None
next_imdb_id: Optional[int] = None
tv_series_id: Optional[str] = None
@dataclass
class VideoGameLogData(LongPlayLogData):
serial_scrobble_id: Optional[int]
long_play_complete: bool = False
console: str = ""
emulated: bool = False
emulator: Optional[str] = None
@dataclass
class BrickSetLogData(LongPlayLogData, WithOthersLogData):
serial_scrobble_id: Optional[int]
long_play_complete: bool = False
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
@dataclass
class TrailLogData(WithOthersLogData):
with_user_ids: Optional[list[int]] = None
with_names_str: Optional[list[str]] = None
details: Optional[str] = None
effort: Optional[str] = None
difficulty: Optional[str] = None

View File

@ -1,5 +1,5 @@
from books.models import Book
from django.core.management.base import BaseCommand, no_translations
from django.core.management.base import BaseCommand
from scrobbles.models import ScrobbledPage

View File

@ -0,0 +1,48 @@
from django.core.management.base import BaseCommand
from scrobbles.models import Scrobble
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Actually migrate data",
)
def handle(self, *args, **options):
dry_run = True
if options["commit"]:
dry_run = False
scrobbles_with_logs = (
Scrobble.objects.filter(scrobble_log__isnull=False)
.exclude(scrobble_log="")
.exclude(scrobble_log="\n")
)
updated_scrobble_count = 0
for scrobble in scrobbles_with_logs:
if "\n" in scrobble.scrobble_log:
lines = scrobble.scrobble_log.split("\n")
else:
lines = [scrobble.scrobble_log]
old_data = {}
for line_num, line in enumerate(lines):
if line_num == 0 or line == "":
continue
old_data[str(line_num)] = line
if old_data:
scrobble.scrobble_log = {"legacy_data": old_data}
updated_scrobble_count += 1
if not dry_run:
scrobble.save(update_fields=["scrobble_log"])
else:
print(
f"Scrobble {scrobble} scrobble_log updated to {old_data}"
)
print(f"Migrated scrobble logs for {updated_scrobble_count} scrobbles")

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.9 on 2024-01-22 00:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0045_scrobble_webpage_alter_scrobble_media_type"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="book_page_data",
field=models.JSONField(blank=True, null=True),
),
migrations.AlterField(
model_name="chartrecord",
name="year",
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.9 on 2024-01-29 06:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0046_scrobble_book_page_data_alter_chartrecord_year"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="book_koreader_hash",
field=models.CharField(blank=True, max_length=50, null=True),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 4.2.9 on 2024-02-10 18:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0047_scrobble_book_koreader_hash"),
]
operations = [
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("WebPage", "Web Page"),
],
default="Video",
max_length=14,
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.9 on 2024-02-13 03:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0048_alter_scrobble_media_type"),
]
operations = [
migrations.RenameField(
model_name="scrobble",
old_name="webpage",
new_name="web_page",
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.9 on 2024-02-20 00:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0049_rename_webpage_scrobble_web_page"),
]
operations = [
migrations.DeleteModel(
name="ScrobbledPage",
),
]

View File

@ -0,0 +1,46 @@
# Generated by Django 4.2.11 on 2024-04-19 13:10
from datetime import datetime
import pytz
from django.conf import settings
from django.db import migrations, models
def set_default_timezone(apps, schema_editor):
Scrobble = apps.get_model("scrobbles", "Scrobble")
for s in Scrobble.objects.all():
if not s.timezone:
s.timezone = settings.TIME_ZONE
if s.user and s.user.profile:
s.timezone = s.user.profile.timezone
# A shim to adjust for our change to European time for 3 months
if (
datetime(2023, 10, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
<= s.timestamp
<= datetime(2023, 12, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
):
s.timezone = "Europe/Paris"
s.save(update_fields=["timezone"])
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0050_delete_scrobbledpage"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="timezone",
field=models.CharField(blank=True, max_length=50, null=True),
),
migrations.RunPython(set_default_timezone),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.9 on 2024-04-05 14:39
import json
from django.db import migrations, models
def convert_log_to_json(apps, schema_editor):
Scrobble = apps.get_model("scrobbles", "Scrobble")
for s in Scrobble.objects.filter(scrobble_log__isnull=False):
s.scrobble_log = json.dumps({"migrated_data": s.scrobble_log})
s.save(update_fields=["scrobble_log"])
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0051_scrobble_timezone"),
]
operations = [
migrations.RunPython(convert_log_to_json),
migrations.AlterField(
model_name="scrobble",
name="scrobble_log",
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.9 on 2024-04-19 18:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0052_alter_scrobble_scroble_log"),
]
operations = [
migrations.RemoveField(
model_name="scrobble",
name="source_id",
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.9 on 2024-04-19 19:00
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0053_remove_scrobble_source_id"),
]
operations = [
migrations.RemoveField(
model_name="scrobble",
name="notes",
),
migrations.RemoveField(
model_name="scrobble",
name="playback_position_ticks",
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.11 on 2024-05-06 02:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0054_remove_scrobble_notes_and_more"),
]
operations = [
migrations.RenameField(
model_name="scrobble",
old_name="scrobble_log",
new_name="log",
),
]

View File

@ -0,0 +1,45 @@
# Generated by Django 4.2.11 on 2024-05-07 13:37
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("lifeevents", "0001_initial"),
("scrobbles", "0055_rename_scrobble_log_scrobble_log"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="life_event",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="lifeevents.lifeevent",
),
),
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
],
default="Video",
max_length=14,
),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.13 on 2024-08-06 15:27
from django.db import migrations, models
import scrobbles.dataclasses
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
]
operations = [
migrations.AlterField(
model_name="scrobble",
name="log",
field=models.JSONField(
blank=True,
decoder=scrobbles.dataclasses.ScrobbleLogDataDecoder,
encoder=scrobbles.dataclasses.ScrobbleLogDataEncoder,
null=True,
),
),
]

View File

@ -0,0 +1,57 @@
# Generated by Django 4.2.13 on 2024-08-10 20:01
from django.db import migrations, models
import django.db.models.deletion
import scrobbles.dataclasses
class Migration(migrations.Migration):
dependencies = [
("moods", "0001_initial"),
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="mood",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="moods.mood",
),
),
migrations.AlterField(
model_name="scrobble",
name="log",
field=models.JSONField(
blank=True,
decoder=scrobbles.dataclasses.ScrobbleLogDataDecoder,
encoder=scrobbles.dataclasses.ScrobbleLogDataEncoder,
null=True,
),
),
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
],
default="Video",
max_length=14,
),
),
]

View File

@ -0,0 +1,13 @@
# Generated by Django 4.2.13 on 2024-08-11 02:34
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0057_alter_scrobble_log"),
("scrobbles", "0057_scrobble_mood_alter_scrobble_log_and_more"),
]
operations = []

View File

@ -0,0 +1,47 @@
# Generated by Django 4.2.15 on 2024-08-20 14:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0058_merge_20240810_2234"),
]
operations = [
migrations.RemoveField(
model_name="scrobble",
name="book_koreader_hash",
),
migrations.RemoveField(
model_name="scrobble",
name="book_page_data",
),
migrations.RemoveField(
model_name="scrobble",
name="book_pages_read",
),
migrations.RemoveField(
model_name="scrobble",
name="videogame_screenshot",
),
migrations.AddField(
model_name="scrobble",
name="screenshot",
field=models.ImageField(
blank=True,
null=True,
upload_to="scrobbles/videogame_screenshot/",
),
),
migrations.AlterField(
model_name="scrobble",
name="videogame_save_data",
field=models.FileField(
blank=True,
null=True,
upload_to="scrobbles/videogame_save_data/",
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.15 on 2024-09-07 05:38
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bricksets", "0001_initial"),
("scrobbles", "0059_remove_scrobble_book_koreader_hash_and_more"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="brickset",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="bricksets.brickset",
),
),
]

View File

@ -0,0 +1,35 @@
# Generated by Django 4.2.15 on 2024-09-07 05:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0060_scrobble_brickset"),
]
operations = [
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
],
default="Video",
max_length=14,
),
),
]

View File

@ -0,0 +1,48 @@
# Generated by Django 4.2.16 on 2024-09-09 16:11
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("trails", "0001_initial"),
("scrobbles", "0061_alter_scrobble_media_type"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="trail",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="trails.trail",
),
),
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
],
default="Video",
max_length=14,
),
),
]

View File

@ -2,12 +2,14 @@ import logging
from typing import Optional
from uuid import uuid4
from django.apps import apps
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from taggit.managers import TaggableManager
from scrobbles.utils import get_scrobbles_for_media
from taggit.models import TagBase, GenericTaggedItemBase
from taggit.managers import TaggableManager
from taggit.models import GenericTaggedItemBase, TagBase
BNULL = {"blank": True, "null": True}
@ -33,6 +35,7 @@ class ObjectWithGenres(GenericTaggedItemBase):
class ScrobblableMixin(TimeStampedModel):
SECONDS_TO_STALE = 1600
COMPLETION_PERCENT = 100
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255, **BNULL)
@ -44,22 +47,59 @@ class ScrobblableMixin(TimeStampedModel):
class Meta:
abstract = True
def scrobble_for_user(
self,
user_id,
source: str = "Vrobbler",
playback_position_seconds: int = 0,
status: str = "started",
):
Scrobble = apps.get_model("scrobbles", "Scrobble")
scrobble_data = {
"user_id": user_id,
"timestamp": timezone.now(),
"source": source,
"playback_position_seconds": playback_position_seconds,
"status": status,
}
logger.info(
"[scrobble_for_user] called",
extra={
"id": self.id,
"media_type": self.__class__,
"user_id": user_id,
"scrobble_data": scrobble_data,
"media_type": Scrobble.MediaType.WEBPAGE,
},
)
return Scrobble.create_or_update(self, user_id, scrobble_data)
@property
def primary_image_url(self) -> str:
logger.warn(f"Not implemented yet")
logger.warning(f"Not implemented yet")
return ""
def fix_metadata(self):
logger.warn("fix_metadata() not implemented yet")
@property
def logdata_cls(self) -> None:
logger.warning("logdata_cls() not implemented yet")
return None
@property
def subtitle(self) -> str:
return ""
def fix_metadata(self) -> None:
logger.warning("fix_metadata() not implemented yet")
@classmethod
def find_or_create(cls):
logger.warn("find_or_create() not implemented yet")
def find_or_create(cls) -> None:
logger.warning("find_or_create() not implemented yet")
def scrobble(self, user_id, **kwargs):
"""Given a user ID and a dictionary of data, attempts to scrobble it"""
from scrobbles.models import Scrobble
Scrobble.create_or_update(self, user_id, **kwargs)
def __str__(self):
if self.title:
return self.title
return str(self.uuid)
class LongPlayScrobblableMixin(ScrobblableMixin):

File diff suppressed because it is too large Load Diff

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