This commit has no recorded session.
diff
commit f7d99f8b55561c863fc020edd8288459f6eecaceAuthor: Colin Powell <colin@unbl.ink>Date: Mon Jul 13 23:17:14 2026 -0400 [drinks] Add water drink notifications@@ -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@@ -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 = {@@ -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"+ ),+ ),+ ]@@ -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),+ ),+ ]@@ -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(@@ -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")@@ -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,+ },+ )@@ -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",@@ -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)."""@@ -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@@ -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"),@@ -281,10 +281,7 @@ <a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a> </div> <div class="nav-item text-nowrap">- <form method="post" action="{% url "drinks:quick_water_scrobble" %}" style="display:inline;">- {% csrf_token %}- <button type="submit" class="nav-link px-3 btn btn-link" style="text-decoration:none;">Drink water</button>- </form>+ <a class="nav-link px-3" href="{% url "drinks:quick_water_scrobble" %}">Drink water</a> </div> <div class="nav-item text-nowrap"> <a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a>