[drinks] Add water drink notifications
This commit is contained in:
@ -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 = {
|
||||
|
||||
39
vrobbler/apps/profiles/migrations/0042_add_water_defaults.py
Normal file
39
vrobbler/apps/profiles/migrations/0042_add_water_defaults.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/profiles/migrations/0043_add_water_reminder.py
Normal file
18
vrobbler/apps/profiles/migrations/0043_add_water_reminder.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user