[drinks] Add water drink notifications

This commit is contained in:
2026-07-13 23:17:14 -04:00
parent 6861e28ac6
commit f7d99f8b55
12 changed files with 171 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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