From f7d99f8b55561c863fc020edd8288459f6eecace Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Mon, 13 Jul 2026 23:17:14 -0400 Subject: [PATCH] [drinks] Add water drink notifications --- PROJECT.org | 6 ++- vrobbler/apps/profiles/forms.py | 3 ++ .../migrations/0042_add_water_defaults.py | 39 +++++++++++++++++++ .../migrations/0043_add_water_reminder.py | 18 +++++++++ vrobbler/apps/profiles/models.py | 25 ++++++++++++ .../commands/send_water_reminders.py | 9 +++++ vrobbler/apps/scrobbles/notifications.py | 25 +++++++++++- vrobbler/apps/scrobbles/scrobblers.py | 12 +++++- vrobbler/apps/scrobbles/tasks.py | 16 +++++--- vrobbler/apps/scrobbles/utils.py | 29 +++++++++++--- vrobbler/settings.py | 4 ++ vrobbler/templates/base.html | 5 +-- 12 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 vrobbler/apps/profiles/migrations/0042_add_water_defaults.py create mode 100644 vrobbler/apps/profiles/migrations/0043_add_water_reminder.py create mode 100644 vrobbler/apps/scrobbles/management/commands/send_water_reminders.py diff --git a/PROJECT.org b/PROJECT.org index 687cac5..717d682 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [1/25] :vrobbler:project:personal: +* Backlog [2/26] :vrobbler:project:personal: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES: @@ -619,6 +619,10 @@ The Edit log form should have from top to bottom: - Expansion ids (which should a multi-select widget of expansions for this game) - Location (which should be a drop down of BoardGameLocations for this user) +** DONE [#B] Send water drinking notifications :drinks:notifications: +:PROPERTIES: +:ID: 6c0db717-a0c7-34a1-57e1-941db0188875 +:END: ** DONE [#B] Add wine as a ScrobbleItem in a drinks app and move beers into drinks app :drinks:beer: :PROPERTIES: :ID: add1be0d-eb33-3273-37bf-0f2cab7e67c5 diff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.py index f3c8bc4..448a54e 100644 --- a/vrobbler/apps/profiles/forms.py +++ b/vrobbler/apps/profiles/forms.py @@ -42,6 +42,9 @@ class UserProfileForm(forms.ModelForm): "live_now_playing", "weigh_in_units", "volume_unit", + "default_water_format", + "default_water_size", + "water_reminder_enabled", "trends_disabled", ] widgets = { diff --git a/vrobbler/apps/profiles/migrations/0042_add_water_defaults.py b/vrobbler/apps/profiles/migrations/0042_add_water_defaults.py new file mode 100644 index 0000000..0db7a81 --- /dev/null +++ b/vrobbler/apps/profiles/migrations/0042_add_water_defaults.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.29 on 2026-07-14 00:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("profiles", "0041_userprofile_volume_unit"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="default_water_format", + field=models.CharField( + choices=[ + ("aluminum_can", "Aluminum Can"), + ("glass_bottle", "Glass Bottle"), + ("plastic_bottle", "Plastic Bottle"), + ("metal_bottle", "Metal Bottle"), + ("keg", "Keg"), + ("by_the_glass", "By the Glass"), + ("draft", "Draft"), + ("growler", "Growler"), + ("box", "Box"), + ], + default="by_the_glass", + max_length=20, + ), + ), + migrations.AddField( + model_name="userprofile", + name="default_water_size", + field=models.PositiveIntegerField( + default=250, help_text="Default water scrobble size in mL" + ), + ), + ] diff --git a/vrobbler/apps/profiles/migrations/0043_add_water_reminder.py b/vrobbler/apps/profiles/migrations/0043_add_water_reminder.py new file mode 100644 index 0000000..ff43610 --- /dev/null +++ b/vrobbler/apps/profiles/migrations/0043_add_water_reminder.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-07-14 03:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("profiles", "0042_add_water_defaults"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="water_reminder_enabled", + field=models.BooleanField(default=False), + ), + ] diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.py index 21c1c85..1ae9b69 100644 --- a/vrobbler/apps/profiles/models.py +++ b/vrobbler/apps/profiles/models.py @@ -33,6 +33,18 @@ class VolumeUnit(models.TextChoices): IMPERIAL = "imperial", "Imperial (oz)" +class WaterFormat(models.TextChoices): + ALUMINUM_CAN = "aluminum_can", "Aluminum Can" + GLASS_BOTTLE = "glass_bottle", "Glass Bottle" + PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle" + METAL_BOTTLE = "metal_bottle", "Metal Bottle" + KEG = "keg", "Keg" + BY_THE_GLASS = "by_the_glass", "By the Glass" + DRAFT = "draft", "Draft" + GROWLER = "growler", "Growler" + BOX = "box", "Box" + + class UserProfile(TimeStampedModel): user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") timezone = models.CharField( @@ -121,6 +133,19 @@ class UserProfile(TimeStampedModel): default=VolumeUnit.METRIC, ) + default_water_format = models.CharField( + max_length=20, + choices=WaterFormat.choices, + default=WaterFormat.BY_THE_GLASS, + ) + + default_water_size = models.PositiveIntegerField( + default=250, + help_text="Default water scrobble size in mL", + ) + + water_reminder_enabled = models.BooleanField(default=False) + trends_disabled = models.BooleanField(default=False) disabled_trends = models.JSONField( diff --git a/vrobbler/apps/scrobbles/management/commands/send_water_reminders.py b/vrobbler/apps/scrobbles/management/commands/send_water_reminders.py new file mode 100644 index 0000000..517df3c --- /dev/null +++ b/vrobbler/apps/scrobbles/management/commands/send_water_reminders.py @@ -0,0 +1,9 @@ +from django.core.management.base import BaseCommand + +from vrobbler.apps.scrobbles.utils import send_water_reminder_notifications + + +class Command(BaseCommand): + def handle(self, *args, **options): + sent_count = send_water_reminder_notifications() + print(f"Sent {sent_count} water reminder notifications") diff --git a/vrobbler/apps/scrobbles/notifications.py b/vrobbler/apps/scrobbles/notifications.py index 5aa3d70..2270864 100644 --- a/vrobbler/apps/scrobbles/notifications.py +++ b/vrobbler/apps/scrobbles/notifications.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod -import requests +import requests from django.conf import settings from django.contrib.sites.models import Site from django.urls import reverse @@ -119,3 +119,26 @@ class MoodNtfyNotification(BasicNtfyNotification): "Click": self.click_url, }, ) + + +class WaterReminderNtfyNotification(BasicNtfyNotification): + def __init__(self, profile, **kwargs): + super().__init__(profile) + self.ntfy_str: str = "Time to drink some water!" + self.click_url = self.url_tmpl.format( + path=reverse("drinks:quick_water_scrobble") + ) + self.title = "Water Reminder" + + 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": "droplet, check", + "Click": self.click_url, + }, + ) diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index a501015..506a78c 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -15,7 +15,7 @@ from bricksets.models import BrickSet from dateutil.parser import parse from discgolf.models import DiscGolfCourse from django.utils import timezone -from drinks.models import Beer, Coffee, Wine, Drink, DrinkFormat +from drinks.models import Beer, Coffee, Drink, DrinkFormat, Wine from foods.models import Food from foods.sources.rscraper import RecipeScraperService from locations.constants import LOCATION_PROVIDERS @@ -1361,12 +1361,20 @@ def manual_scrobble_water( logger.error("Could not find or create Water instance") return + water_format = DrinkFormat.BY_THE_GLASS + water_size_ml = 250 + + profile = UserProfile.objects.filter(user_id=user_id).first() + if profile: + water_format = profile.default_water_format or water_format + water_size_ml = profile.default_water_size or water_size_ml + scrobble_dict = { "user_id": user_id, "timestamp": timezone.now(), "playback_position_seconds": 0, "source": source, - "log": {"format": DrinkFormat.BY_THE_GLASS, "size_ml": 250}, + "log": {"format": water_format, "size_ml": water_size_ml}, } logger.info( "[vrobbler-scrobble] water scrobble request received", diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.py index 6736050..07cc407 100644 --- a/vrobbler/apps/scrobbles/tasks.py +++ b/vrobbler/apps/scrobbles/tasks.py @@ -276,15 +276,11 @@ def push_scrobble_to_archivebox(scrobble_id): scrobble = Scrobble.objects.filter(id=scrobble_id).first() if not scrobble: - logger.warning( - "Scrobble %s not found for archivebox push", scrobble_id - ) + logger.warning("Scrobble %s not found for archivebox push", scrobble_id) return webpage = scrobble.web_page if not webpage: - logger.warning( - "Scrobble %s has no web_page for archivebox push", scrobble_id - ) + logger.warning("Scrobble %s has no web_page for archivebox push", scrobble_id) return webpage.push_to_archivebox(scrobble.user) @@ -582,6 +578,14 @@ def send_mood_checkin(): send_mood_checkin_reminders() +@shared_task +def send_water_reminder(): + """Send water reminder notifications during daylight hours.""" + from vrobbler.apps.scrobbles.utils import send_water_reminder_notifications + + send_water_reminder_notifications() + + @shared_task def backfill_scrobble_sentiment(): """Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron).""" diff --git a/vrobbler/apps/scrobbles/utils.py b/vrobbler/apps/scrobbles/utils.py index 63dba69..02a1bbb 100644 --- a/vrobbler/apps/scrobbles/utils.py +++ b/vrobbler/apps/scrobbles/utils.py @@ -1,7 +1,6 @@ import hashlib import html import logging -import requests import re from datetime import date, datetime, timedelta from typing import TYPE_CHECKING, Optional @@ -10,6 +9,7 @@ from zoneinfo import ZoneInfo import pendulum import pytz +import requests from django.apps import apps from django.contrib.auth import get_user_model from django.db import models @@ -23,6 +23,7 @@ from scrobbles.constants import LONG_PLAY_MEDIA from scrobbles.notifications import ( MoodNtfyNotification, ScrobbleNtfyNotification, + WaterReminderNtfyNotification, ) from scrobbles.tasks import ( process_koreader_import, @@ -154,8 +155,7 @@ def import_lastfm_for_all_users(restart=False): last_processed = lfm_import.processed_finished else: logger.info( - "No existing LastFM import for user %s, " - "starting a full parse", + "No existing LastFM import for user %s, " "starting a full parse", user_id, ) last_processed = None @@ -350,6 +350,20 @@ def send_mood_checkin_reminders() -> int: return notifications_sent +def send_water_reminder_notifications() -> int: + """Send water reminders to users with the setting enabled, during daylight hours. + + Only sends if the user's local time is between 6 AM and 11 PM.""" + notifications_sent = 0 + for profile in UserProfile.objects.filter(water_reminder_enabled=True): + local_hour = timezone.localtime(timezone.now(), profile.tzinfo).hour + if 6 <= local_hour <= 22: + WaterReminderNtfyNotification(profile).send() + notifications_sent += 1 + + return notifications_sent + + def extract_domain(url): parsed_url = urlparse(url) domain = parsed_url.netloc.split(".")[-2] + "." + parsed_url.netloc.split(".")[-1] @@ -460,7 +474,8 @@ def _ensure_mopidy_playlist(profile): pass result = _mopidy_rpc( - profile, "core.playlists.create", + profile, + "core.playlists.create", {"name": playlist_name, "uri_scheme": "m3u"}, ) logger.info( @@ -588,7 +603,8 @@ def remove_track_from_mopidy_favorites_playlist(favorite): if playlist and playlist.get("uri"): existing_tracks = playlist.get("tracks") or [] filtered = [ - t for t in existing_tracks + t + for t in existing_tracks if not (isinstance(t, dict) and t.get("uri") == mopidy_uri) ] if len(filtered) == len(existing_tracks): @@ -639,7 +655,8 @@ def _ensure_mopidy_playlist_by_name(profile, playlist_name): pass result = _mopidy_rpc( - profile, "core.playlists.create", + profile, + "core.playlists.create", {"name": playlist_name, "uri_scheme": "m3u"}, ) return result diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 5324314..6ef61b9 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -188,6 +188,10 @@ CELERY_BEAT_SCHEDULE = { "task": "scrobbles.tasks.send_mood_checkin", "schedule": crontab(hour="*/4", minute=0), }, + "send-water-reminder": { + "task": "scrobbles.tasks.send_water_reminder", + "schedule": crontab(hour="*/3", minute=0), + }, "backfill-scrobble-sentiment": { "task": "scrobbles.tasks.backfill_scrobble_sentiment", "schedule": crontab(minute="0"), diff --git a/vrobbler/templates/base.html b/vrobbler/templates/base.html index 4978d65..b0c183f 100644 --- a/vrobbler/templates/base.html +++ b/vrobbler/templates/base.html @@ -281,10 +281,7 @@ Check-in