Compare commits

..

7 Commits
18.12 ... 19.1

15 changed files with 161 additions and 34 deletions

View File

@ -443,6 +443,11 @@ it's annoying.
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature: ** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
https://codepen.io/oliviale/pen/QYqybo https://codepen.io/oliviale/pen/QYqybo
** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks: ** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
* Version 19.0
** DONE Add periodic check for mood :vrobbler:feature:moods:personal:project:
:PROPERTIES:
:ID: 55404488-c69f-0dd5-838e-1d1e15c873eb
:END:
* Version 18.7 * Version 18.7
** DONE Use the timezone history log to fix old Scrobbles that fall into those timezone blocks :vrobbler:chore:scrobbles:project:personal: ** DONE Use the timezone history log to fix old Scrobbles that fall into those timezone blocks :vrobbler:chore:scrobbles:project:personal:
:PROPERTIES: :PROPERTIES:

File diff suppressed because one or more lines are too long

View File

@ -3,7 +3,7 @@ from boardgames.models import BoardGame
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 scrobbles.models import Scrobble from scrobbles.models import Scrobble
from scrobbles.notifications import NtfyNotification from scrobbles.notifications import ScrobbleNtfyNotification
User = get_user_model() User = get_user_model()
@ -124,5 +124,5 @@ def import_chess_games_for_all_users():
if scrobbles_to_create: if scrobbles_to_create:
created = Scrobble.objects.bulk_create(scrobbles_to_create) created = Scrobble.objects.bulk_create(scrobbles_to_create)
for scrobble in created: for scrobble in created:
NtfyNotification(scrobble).send() ScrobbleNtfyNotification(scrobble).send()
return scrobbles_to_create return scrobbles_to_create

View File

@ -9,7 +9,7 @@ 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
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from scrobbles.notifications import NtfyNotification from scrobbles.notifications import ScrobbleNtfyNotification
from stream_sqlite import stream_sqlite from stream_sqlite import stream_sqlite
from webdav.client import get_webdav_client from webdav.client import get_webdav_client
@ -409,7 +409,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
if new_scrobbles: if new_scrobbles:
created = Scrobble.objects.bulk_create(new_scrobbles) created = Scrobble.objects.bulk_create(new_scrobbles)
if created: if created:
NtfyNotification(created[-1]).send() ScrobbleNtfyNotification(created[-1]).send()
fix_long_play_stats_for_scrobbles(created) fix_long_play_stats_for_scrobbles(created)
logger.info( logger.info(
f"Created {len(created)} scrobbles", f"Created {len(created)} scrobbles",

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.19 on 2025-07-30 22:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('profiles', '0026_userprofile_timezone_change_log'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='mood_checkin_enabled',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='userprofile',
name='mood_checkin_frequency',
field=models.CharField(default='hourly', max_length=20),
),
]

View File

@ -1,6 +1,5 @@
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import pendulum import pendulum
from datetime import datetime
from django.utils import timezone from django.utils import timezone
import logging import logging
from django.conf import settings from django.conf import settings
@ -56,6 +55,9 @@ class UserProfile(TimeStampedModel):
imap_pass = EncryptedField(**BNULL) imap_pass = EncryptedField(**BNULL)
imap_auto_import = models.BooleanField(default=False) imap_auto_import = models.BooleanField(default=False)
mood_checkin_enabled = models.BooleanField(default=False)
mood_checkin_frequency = models.CharField(max_length=20, default="hourly")
ntfy_url = models.CharField(max_length=255, **BNULL) ntfy_url = models.CharField(max_length=255, **BNULL)
ntfy_enabled = models.BooleanField(default=False) ntfy_enabled = models.BooleanField(default=False)

View File

@ -78,6 +78,14 @@ class BoardGameScoreLogData(JSONDataclass):
rank: Optional[int] = None rank: Optional[int] = None
seat_order: Optional[int] = None seat_order: Optional[int] = None
role: Optional[str] = None role: Optional[str] = None
rank: Optional[int] = None
seat_order: Optional[int] = None
role: Optional[str] = None
lichess_username: Optional[str] = None
# Legacy
user_id: Optional[int] = None
name_str: Optional[str] = None
@property @property
def person(self) -> Optional[Person]: def person(self) -> Optional[Person]:
@ -111,7 +119,19 @@ class BoardGameLogData(LongPlayLogData):
solo: Optional[bool] = None solo: Optional[bool] = None
two_handed: Optional[bool] = None two_handed: Optional[bool] = None
expansion_ids: Optional[int] = None expansion_ids: Optional[int] = None
moves: Optional[list] = None
rated: Optional[str] = None
speed: Optional[str] = None
variant: Optional[str] = None
lichess_id: Optional[int] = None
board: Optional[str] = None
rounds: Optional[int] = None
details: Optional[str] = None
# Legacy
learning: Optional[bool] = None
location: Optional[str] = None
geo_location_id: Optional[int] = None
scenario: Optional[str] = None
@cached_property @cached_property
def geo_location(self) -> Optional[GeoLocation]: def geo_location(self) -> Optional[GeoLocation]:
if self.geo_location_id: if self.geo_location_id:
@ -119,7 +139,9 @@ class BoardGameLogData(LongPlayLogData):
@cached_property @cached_property
def player_log(self) -> str: def player_log(self) -> str:
return ", ".join([BoardGameScoreLogData(**player).__str__() for player in self.players]) if self.players:
return ", ".join([BoardGameScoreLogData(**player).__str__() for player in self.players])
return ""
@dataclass @dataclass
class BookPageLogData(JSONDataclass): class BookPageLogData(JSONDataclass):

View File

@ -2,6 +2,7 @@ import codecs
import csv import csv
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import requests import requests
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -61,10 +62,10 @@ def import_audioscrobbler_tsv_file(file_path, user_id):
}, },
) )
continue continue
timestamp = datetime.fromtimestamp(
timestamp = user.profile.get_timestamp_with_tz( int(row[AsTsvColumn["TIMESTAMP"].value])
datetime.fromtimestamp(int(row[AsTsvColumn["TIMESTAMP"].value])) ).astimezone(ZoneInfo("UTC"))
) timestamp = user.profile.get_timestamp_with_tz(timestamp)
stop_timestamp = timestamp + timedelta(seconds=track.run_time_seconds) stop_timestamp = timestamp + timedelta(seconds=track.run_time_seconds)
new_scrobble = Scrobble( new_scrobble = Scrobble(

View File

@ -0,0 +1,10 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.scrobbles.utils import (
send_mood_checkin_reminders
)
class Command(BaseCommand):
def handle(self, *args, **options):
sent_count = send_mood_checkin_reminders()
print(f"Sent {sent_count} mood check-in notifications")

View File

@ -42,7 +42,7 @@ from puzzles.models import Puzzle
from scrobbles import dataclasses as logdata from scrobbles import dataclasses as logdata
from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
from scrobbles.importers.lastfm import LastFM from scrobbles.importers.lastfm import LastFM
from scrobbles.notifications import NtfyNotification from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.stats import build_charts from scrobbles.stats import build_charts
from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
from sports.models import SportEvent from sports.models import SportEvent
@ -1316,7 +1316,7 @@ class Scrobble(TimeStampedModel):
scrobble_data: dict, scrobble_data: dict,
) -> "Scrobble": ) -> "Scrobble":
scrobble = cls.objects.create(**scrobble_data) scrobble = cls.objects.create(**scrobble_data)
NtfyNotification(scrobble).send() ScrobbleNtfyNotification(scrobble).send()
return scrobble return scrobble
def stop(self, timestamp=None, force_finish=False) -> None: def stop(self, timestamp=None, force_finish=False) -> None:

View File

@ -3,13 +3,26 @@ import requests
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.urls import reverse
class Notification(ABC): class BasicNtfyNotification(ABC):
scrobble: "Scrobble"
ntfy_headers: dict = {} ntfy_headers: dict = {}
ntfy_url: str = "" ntfy_url: str = ""
title: str = "" title: str = ""
def __init__(self, profile: "UserProfile"):
self.profile = profile.user
protocol = "http" if settings.DEBUG else "https"
domain = Site.objects.get_current().domain
self.url_tmpl = f'{protocol}://{domain}' + '{path}'
@abstractmethod
def send(self) -> None:
pass
class ScrobbleNotification(BasicNtfyNotification):
scrobble: "Scrobble"
def __init__(self, scrobble: "Scrobble"): def __init__(self, scrobble: "Scrobble"):
self.scrobble = scrobble self.scrobble = scrobble
self.user = scrobble.user self.user = scrobble.user
@ -19,13 +32,12 @@ class Notification(ABC):
self.url_tmpl = f'{protocol}://{domain}' + '{path}' self.url_tmpl = f'{protocol}://{domain}' + '{path}'
@abstractmethod @abstractmethod
def send(self) -> None: def send(self) -> None:
pass pass
class NtfyNotification(Notification): class ScrobbleNtfyNotification(ScrobbleNotification):
def __init__(self, scrobble, **kwargs): def __init__(self, scrobble, **kwargs):
super().__init__(scrobble) super().__init__(scrobble)
self.ntfy_str: str = f"{self.scrobble.media_obj}" self.ntfy_str: str = f"{self.scrobble.media_obj}"
@ -55,3 +67,27 @@ class NtfyNotification(Notification):
"Click": self.click_url, "Click": self.click_url,
}, },
) )
class MoodNtfyNotification(BasicNtfyNotification):
def __init__(self, profile, **kwargs):
super().__init__(profile)
self.ntfy_str: str = "Would you like to check in about your mood?"
self.click_url = self.url_tmpl.format(path=reverse("moods:mood-list"))
self.title = "Mood Check-in!"
def send(self):
if (
self.profile
and self.profile.ntfy_enabled
and self.profile.ntfy_url
):
requests.post(
self.profile.ntfy_url,
data=self.ntfy_str.encode(encoding="utf-8"),
headers={
"Title": self.title,
"Priority": "high",
"Tags": "smiley, check",
"Click": self.click_url,
},
)

View File

@ -26,7 +26,7 @@ from scrobbles.constants import (
SCROBBLE_CONTENT_URLS, SCROBBLE_CONTENT_URLS,
) )
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
from scrobbles.notifications import NtfyNotification from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.utils import convert_to_seconds, extract_domain from scrobbles.utils import convert_to_seconds, extract_domain
from sports.models import SportEvent from sports.models import SportEvent
from sports.thesportsdb import lookup_event_from_thesportsdb from sports.thesportsdb import lookup_event_from_thesportsdb
@ -505,7 +505,7 @@ def email_scrobble_board_game(
scrobble.played_to_completion = True scrobble.played_to_completion = True
scrobble.save() scrobble.save()
scrobbles_created.append(scrobble) scrobbles_created.append(scrobble)
NtfyNotification(scrobble).send() ScrobbleNtfyNotification(scrobble).send()
return scrobbles_created return scrobbles_created

View File

@ -13,7 +13,7 @@ from django.utils import timezone
from profiles.models import UserProfile from profiles.models import UserProfile
from profiles.utils import now_user_timezone from profiles.utils import now_user_timezone
from scrobbles.constants import LONG_PLAY_MEDIA from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.notifications import NtfyNotification from scrobbles.notifications import MoodNtfyNotification, ScrobbleNtfyNotification
from scrobbles.tasks import ( from scrobbles.tasks import (
process_koreader_import, process_koreader_import,
process_lastfm_import, process_lastfm_import,
@ -318,7 +318,20 @@ def send_stop_notifications_for_in_progress_scrobbles() -> int:
).seconds ).seconds
if elapsed_scrobble_seconds > scrobble.media_obj.run_time_seconds: if elapsed_scrobble_seconds > scrobble.media_obj.run_time_seconds:
NtfyNotification(scrobble, end=True).send() ScrobbleNtfyNotification(scrobble, end=True).send()
notifications_sent += 1
return notifications_sent
def send_mood_checkin_reminders() -> int:
"""Get all profiles with mood check-ins enabled and checkin!"""
from profiles.models import UserProfile
now = timezone.now()
notifications_sent = 0
for profile in UserProfile.objects.filter(mood_checkin_enabled=True):
if profile.mood_checkin_frequency == "hourly" and now.minute == 0:
MoodNtfyNotification(profile).send()
notifications_sent += 1 notifications_sent += 1
return notifications_sent return notifications_sent

View File

@ -56,6 +56,7 @@ from scrobbles.utils import (
get_long_plays_completed, get_long_plays_completed,
get_long_plays_in_progress, get_long_plays_in_progress,
) )
from moods.models import Mood
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -66,15 +67,17 @@ class ScrobbleableListView(ListView):
def get_queryset(self): def get_queryset(self):
queryset = super().get_queryset() queryset = super().get_queryset()
if self.model == Mood:
return queryset
user_filter = Q() user_filter = Q()
if not self.request.user.is_anonymous: if not self.request.user.is_anonymous:
user_filter = Q(scrobble__user=self.request.user) user_filter = Q(scrobble__user=self.request.user)
queryset = ( queryset = (
queryset.annotate( queryset.filter(user_filter).annotate(
scrobble_count=Count("scrobble"), scrobble_count=Count("scrobble")
) ).filter(scrobble_count__gt=0).order_by("-scrobble_count")
.filter(user_filter, scrobble_count__gt=0)
.order_by("-scrobble_count")
) )
return queryset return queryset

View File

@ -18,19 +18,33 @@ class TaskLogData(JSONDataclass):
description: Optional[str] = None description: Optional[str] = None
title: Optional[str] = None title: Optional[str] = None
project: Optional[str] = None project: Optional[str] = None
notes: Optional[dict] = None
updated_at: Optional[str] = None
todoist_id: Optional[str] = None todoist_id: Optional[str] = None
todoist_event: Optional[str] = None todoist_event: Optional[str] = None
todoist_type: Optional[str] = None todoist_type: Optional[str] = None
notes: Optional[dict] = None todoist_type: Optional[str] = None
todoist_label_list: Optional[list] = None
todoist_project_id: Optional[str] = None
body: Optional[str] = None
state: Optional[str] = None
labels: Optional[str] = None
properties: Optional[list] = None
drawers: Optional[list] = None
source: Optional[str] = None
source_id: Optional[str] = None
timestamps: Optional[list] = None
def notes_as_str(self) -> str: def notes_as_str(self) -> str:
"""Return formatted notes with line breaks and no keys""" """Return formatted notes with line breaks and no keys"""
note_block = "" note_block = ""
if not self.notes: if isinstance(self.notes, list):
return note_block note_block = "</br>".join(self.notes)
for id, content in self.notes.items(): if isinstance(self.notes, dict):
note_block += content + "</br>" for id, content in self.notes.items():
note_block += content + "</br>"
return note_block return note_block