[books] Allow timezone changes when importing from KOReader
Turns out you need a city-based timezone for DST stuff to work properly. The US/Eastern timezone doesn't mess with DST because it can be so wonky in different regions. So while we fix timezone defaulting to a DST-friendly timezone too.
This commit is contained in:
@ -4,7 +4,8 @@ import sqlite3
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
import pytz
|
import pendulum
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
import requests
|
import requests
|
||||||
from books.constants import BOOKS_TITLES_TO_IGNORE
|
from books.constants import BOOKS_TITLES_TO_IGNORE
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
@ -208,6 +209,35 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
|
|||||||
)
|
)
|
||||||
return book_map
|
return book_map
|
||||||
|
|
||||||
|
def one_off_fix_colins_profile(profile):
|
||||||
|
home_tz = "America/New_York"
|
||||||
|
|
||||||
|
europe = "2023-10-15"
|
||||||
|
europe_tz = "Europe/Paris"
|
||||||
|
europe_end = "2023-12-15"
|
||||||
|
|
||||||
|
washington = "2023-10-15"
|
||||||
|
washington_tz = "America/Los_Angeles"
|
||||||
|
washington_end = "2023-05-04"
|
||||||
|
|
||||||
|
camp = "2024-08-04"
|
||||||
|
camp_end = "2024-08-10"
|
||||||
|
camp_tz = "America/Halifax"
|
||||||
|
|
||||||
|
summer = "2025-07-10"
|
||||||
|
summer_end = "2025-07-13"
|
||||||
|
summer_tz = "America/Los_Angeles"
|
||||||
|
|
||||||
|
profile.timezone_change_log = ""
|
||||||
|
profile.timezone_change_log += f"{europe_tz} - {pendulum.parse(europe)}\n"
|
||||||
|
profile.timezone_change_log += f"{home_tz} - {pendulum.parse(europe_end)}\n"
|
||||||
|
profile.timezone_change_log += f"{washington_tz} - {pendulum.parse(washington)}\n"
|
||||||
|
profile.timezone_change_log += f"{home_tz} - {pendulum.parse(washington_end)}\n"
|
||||||
|
profile.timezone_change_log += f"{camp_tz} - {pendulum.parse(camp)}\n"
|
||||||
|
profile.timezone_change_log += f"{home_tz} - {pendulum.parse(camp_end)}\n"
|
||||||
|
profile.timezone_change_log += f"{summer_tz} - {pendulum.parse(summer)}\n"
|
||||||
|
profile.timezone_change_log += f"{home_tz} - {pendulum.parse(summer_end)}\n"
|
||||||
|
profile.save()
|
||||||
|
|
||||||
def build_scrobbles_from_book_map(
|
def build_scrobbles_from_book_map(
|
||||||
book_map: dict, user: "User"
|
book_map: dict, user: "User"
|
||||||
@ -278,55 +308,18 @@ def build_scrobbles_from_book_map(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
timezone = user.profile.timezone
|
timestamp = user.profile.get_timestamp_with_tz(datetime.fromtimestamp(int(first_page.get("start_ts"))))
|
||||||
|
stop_timestamp = user.profile.get_timestamp_with_tz(datetime.fromtimestamp(int(last_page.get("end_ts"))))
|
||||||
|
|
||||||
timestamp = datetime.fromtimestamp(
|
if user.id == 1 and not user.profile.timezone_change_log:
|
||||||
int(first_page.get("start_ts"))
|
one_off_fix_colins_profile(user.profile)
|
||||||
).replace(tzinfo=pytz.timezone(timezone))
|
|
||||||
|
|
||||||
# Add a shim here temporarily to fix imports while we were in France
|
# Adjust for Daylight Saving Time
|
||||||
# if date is between 10/15 and 12/15, cast it to Europe/Central
|
if timestamp.dst() == timedelta(0) or stop_timestamp.dst() == timedelta(0):
|
||||||
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"
|
|
||||||
if (
|
|
||||||
datetime(2024, 4, 28).replace(
|
|
||||||
tzinfo=pytz.timezone("US/Pacific")
|
|
||||||
)
|
|
||||||
<= timestamp
|
|
||||||
<= datetime(2024, 5, 4).replace(
|
|
||||||
tzinfo=pytz.timezone("US/Pacific")
|
|
||||||
)
|
|
||||||
):
|
|
||||||
timezone = "US/Pacific"
|
|
||||||
if (
|
|
||||||
datetime(2024, 8, 4).replace(
|
|
||||||
tzinfo=pytz.timezone("Canada/Atlantic")
|
|
||||||
)
|
|
||||||
<= timestamp
|
|
||||||
<= datetime(2024, 8, 10).replace(
|
|
||||||
tzinfo=pytz.timezone("Canada/Atlantic")
|
|
||||||
)
|
|
||||||
):
|
|
||||||
timezone = "Canada/Atlantic"
|
|
||||||
|
|
||||||
stop_timestamp = datetime.fromtimestamp(
|
|
||||||
int(last_page.get("end_ts"))
|
|
||||||
).replace(tzinfo=pytz.timezone(timezone))
|
|
||||||
|
|
||||||
if (
|
|
||||||
timestamp.tzinfo._dst.seconds == 0
|
|
||||||
or stop_timestamp.tzinfo._dst.seconds == 0
|
|
||||||
):
|
|
||||||
timestamp = timestamp - timedelta(hours=1)
|
timestamp = timestamp - timedelta(hours=1)
|
||||||
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
stop_timestamp = stop_timestamp - timedelta(hours=1)
|
||||||
|
else:
|
||||||
|
print("In DST! ", timestamp)
|
||||||
|
|
||||||
scrobble = Scrobble.objects.filter(
|
scrobble = Scrobble.objects.filter(
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
@ -356,7 +349,7 @@ def build_scrobbles_from_book_map(
|
|||||||
in_progress=False,
|
in_progress=False,
|
||||||
played_to_completion=True,
|
played_to_completion=True,
|
||||||
long_play_complete=False,
|
long_play_complete=False,
|
||||||
timezone=timezone,
|
timezone=timestamp.tzinfo.name
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Then start over
|
# Then start over
|
||||||
@ -398,9 +391,9 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
|
|||||||
|
|
||||||
new_scrobbles = []
|
new_scrobbles = []
|
||||||
user = User.objects.filter(id=user_id).first()
|
user = User.objects.filter(id=user_id).first()
|
||||||
tz = pytz.utc
|
tz = ZoneInfo("UTC")
|
||||||
if user:
|
if user:
|
||||||
tz = user.profile.timezone
|
tz = user.profile.tzinfo
|
||||||
|
|
||||||
is_os_file = "https://" not in file_path
|
is_os_file = "https://" not in file_path
|
||||||
if is_os_file:
|
if is_os_file:
|
||||||
|
|||||||
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.19 on 2025-07-11 22:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('profiles', '0025_rename_bgstat_id_userprofile_bgstats_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userprofile',
|
||||||
|
name='timezone_change_log',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,4 +1,8 @@
|
|||||||
import pytz
|
from zoneinfo import ZoneInfo
|
||||||
|
import pendulum
|
||||||
|
from datetime import datetime
|
||||||
|
from django.utils import timezone
|
||||||
|
import logging
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@ -10,6 +14,7 @@ from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
|||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
BNULL = {"blank": True, "null": True}
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class UserProfile(TimeStampedModel):
|
class UserProfile(TimeStampedModel):
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
@ -18,6 +23,7 @@ class UserProfile(TimeStampedModel):
|
|||||||
timezone = models.CharField(
|
timezone = models.CharField(
|
||||||
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default="UTC"
|
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default="UTC"
|
||||||
)
|
)
|
||||||
|
timezone_change_log = models.TextField(**BNULL)
|
||||||
lastfm_username = models.CharField(max_length=255, **BNULL)
|
lastfm_username = models.CharField(max_length=255, **BNULL)
|
||||||
lastfm_password = EncryptedField(**BNULL)
|
lastfm_password = EncryptedField(**BNULL)
|
||||||
lastfm_auto_import = models.BooleanField(default=False)
|
lastfm_auto_import = models.BooleanField(default=False)
|
||||||
@ -59,7 +65,51 @@ class UserProfile(TimeStampedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def tzinfo(self):
|
def tzinfo(self):
|
||||||
return pytz.timezone(self.timezone)
|
return ZoneInfo(self.timezone)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self._state.adding:
|
||||||
|
old_instance = UserProfile.objects.get(pk=self.pk)
|
||||||
|
is_timezone_change = self.timezone != old_instance.timezone
|
||||||
|
if is_timezone_change:
|
||||||
|
logger.info("Updating timezone changelog for user", extra={"profile_id": self.id})
|
||||||
|
previous_changes = old_instance.timezone_change_log
|
||||||
|
now = timezone.now().replace(microsecond=0)
|
||||||
|
new_log = f"{self.timezone} - {now}"
|
||||||
|
if previous_changes:
|
||||||
|
new_log = previous_changes + f"\n{new_log}"
|
||||||
|
self.timezone_change_log = new_log
|
||||||
|
super(UserProfile, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def historic_timezone_changes(self) -> list:
|
||||||
|
"""Return a list of datetimes with timezones for the specific changed time"""
|
||||||
|
history = [pendulum.datetime(1900, 1, 1, 0, 0, 0, tz=self.tzinfo.key)]
|
||||||
|
if self.timezone_change_log:
|
||||||
|
for change in self.timezone_change_log.split("\n"):
|
||||||
|
if " - " in change:
|
||||||
|
tz, date = change.split(" - ")
|
||||||
|
history.append(pendulum.parse(date).in_timezone(tz))
|
||||||
|
return history
|
||||||
|
|
||||||
|
def get_timestamp_with_tz(self, timestamp):
|
||||||
|
timezone = self.tzinfo
|
||||||
|
if self.timezone_change_log:
|
||||||
|
change_list = self.historic_timezone_changes
|
||||||
|
for idx, start in enumerate(change_list):
|
||||||
|
try:
|
||||||
|
end = change_list[idx+1]
|
||||||
|
except IndexError:
|
||||||
|
end = None
|
||||||
|
|
||||||
|
if end:
|
||||||
|
if start <= timestamp.replace(tzinfo=end.timezone) <= end:
|
||||||
|
timezone = start.timezone
|
||||||
|
else:
|
||||||
|
if start <= timestamp.replace(tzinfo=start.timezone):
|
||||||
|
timezone = start.timezone
|
||||||
|
|
||||||
|
return timestamp.replace(tzinfo=timezone)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def task_context_tags(self) -> list[str]:
|
def task_context_tags(self) -> list[str]:
|
||||||
|
|||||||
@ -139,6 +139,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
|||||||
"media_type",
|
"media_type",
|
||||||
"long_play_complete",
|
"long_play_complete",
|
||||||
"source",
|
"source",
|
||||||
|
"timezone",
|
||||||
)
|
)
|
||||||
ordering = ("-timestamp",)
|
ordering = ("-timestamp",)
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import logging
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
import pendulum
|
import pendulum
|
||||||
import pytz
|
import pytz
|
||||||
from beers.models import Beer
|
from beers.models import Beer
|
||||||
@ -750,7 +750,16 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def tzinfo(self):
|
def tzinfo(self):
|
||||||
return pytz.timezone(self.timezone)
|
return ZoneInfo(self.timezone)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def local_timestamp(self):
|
||||||
|
return timezone.localtime(self.timestamp, timezone=self.tzinfo)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def local_stop_timestamp(self):
|
||||||
|
if self.stop_tiemstamp:
|
||||||
|
return timezone.localtime(self.stop_timestamp, timezone=self.tzinfo)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scrobble_media_key(self) -> str:
|
def scrobble_media_key(self) -> str:
|
||||||
|
|||||||
@ -92,7 +92,7 @@ DEFAULT_TASK_CONTEXT_TAGS = [
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern")
|
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
@ -111,7 +111,7 @@ CELERY_TASK_ALWAYS_EAGER = (
|
|||||||
)
|
)
|
||||||
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
|
CELERY_BROKER_URL = REDIS_URL if REDIS_URL else "memory://localhost/"
|
||||||
CELERY_RESULT_BACKEND = "django-db"
|
CELERY_RESULT_BACKEND = "django-db"
|
||||||
CELERY_TIMEZONE = os.getenv("VROBBLER_TIME_ZONE", "US/Eastern")
|
CELERY_TIMEZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
|
||||||
CELERY_TASK_TRACK_STARTED = True
|
CELERY_TASK_TRACK_STARTED = True
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
|||||||
Reference in New Issue
Block a user