[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:
2025-07-19 01:54:27 -04:00
parent 7c6e895ae4
commit 4db8793d5c
6 changed files with 126 additions and 55 deletions

View File

@ -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:

View 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),
),
]

View File

@ -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]:

View File

@ -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",)

View File

@ -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:

View File

@ -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 = [