[drinks] Add water drink notifications
This commit is contained in:
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** 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] 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:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
: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)
|
- 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)
|
- 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:
|
** DONE [#B] Add wine as a ScrobbleItem in a drinks app and move beers into drinks app :drinks:beer:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: add1be0d-eb33-3273-37bf-0f2cab7e67c5
|
:ID: add1be0d-eb33-3273-37bf-0f2cab7e67c5
|
||||||
|
|||||||
@ -42,6 +42,9 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"live_now_playing",
|
"live_now_playing",
|
||||||
"weigh_in_units",
|
"weigh_in_units",
|
||||||
"volume_unit",
|
"volume_unit",
|
||||||
|
"default_water_format",
|
||||||
|
"default_water_size",
|
||||||
|
"water_reminder_enabled",
|
||||||
"trends_disabled",
|
"trends_disabled",
|
||||||
]
|
]
|
||||||
widgets = {
|
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)"
|
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):
|
class UserProfile(TimeStampedModel):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||||
timezone = models.CharField(
|
timezone = models.CharField(
|
||||||
@ -121,6 +133,19 @@ class UserProfile(TimeStampedModel):
|
|||||||
default=VolumeUnit.METRIC,
|
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)
|
trends_disabled = models.BooleanField(default=False)
|
||||||
|
|
||||||
disabled_trends = models.JSONField(
|
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
|
from abc import ABC, abstractmethod
|
||||||
import requests
|
|
||||||
|
|
||||||
|
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
|
from django.urls import reverse
|
||||||
@ -119,3 +119,26 @@ class MoodNtfyNotification(BasicNtfyNotification):
|
|||||||
"Click": self.click_url,
|
"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 dateutil.parser import parse
|
||||||
from discgolf.models import DiscGolfCourse
|
from discgolf.models import DiscGolfCourse
|
||||||
from django.utils import timezone
|
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.models import Food
|
||||||
from foods.sources.rscraper import RecipeScraperService
|
from foods.sources.rscraper import RecipeScraperService
|
||||||
from locations.constants import LOCATION_PROVIDERS
|
from locations.constants import LOCATION_PROVIDERS
|
||||||
@ -1361,12 +1361,20 @@ def manual_scrobble_water(
|
|||||||
logger.error("Could not find or create Water instance")
|
logger.error("Could not find or create Water instance")
|
||||||
return
|
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 = {
|
scrobble_dict = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"timestamp": timezone.now(),
|
"timestamp": timezone.now(),
|
||||||
"playback_position_seconds": 0,
|
"playback_position_seconds": 0,
|
||||||
"source": source,
|
"source": source,
|
||||||
"log": {"format": DrinkFormat.BY_THE_GLASS, "size_ml": 250},
|
"log": {"format": water_format, "size_ml": water_size_ml},
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
"[vrobbler-scrobble] water scrobble request received",
|
"[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()
|
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||||
if not scrobble:
|
if not scrobble:
|
||||||
logger.warning(
|
logger.warning("Scrobble %s not found for archivebox push", scrobble_id)
|
||||||
"Scrobble %s not found for archivebox push", scrobble_id
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
webpage = scrobble.web_page
|
webpage = scrobble.web_page
|
||||||
if not webpage:
|
if not webpage:
|
||||||
logger.warning(
|
logger.warning("Scrobble %s has no web_page for archivebox push", scrobble_id)
|
||||||
"Scrobble %s has no web_page for archivebox push", scrobble_id
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
webpage.push_to_archivebox(scrobble.user)
|
webpage.push_to_archivebox(scrobble.user)
|
||||||
|
|
||||||
@ -582,6 +578,14 @@ def send_mood_checkin():
|
|||||||
send_mood_checkin_reminders()
|
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
|
@shared_task
|
||||||
def backfill_scrobble_sentiment():
|
def backfill_scrobble_sentiment():
|
||||||
"""Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron)."""
|
"""Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron)."""
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import requests
|
|
||||||
import re
|
import re
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
@ -10,6 +9,7 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
import pytz
|
import pytz
|
||||||
|
import requests
|
||||||
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 django.db import models
|
from django.db import models
|
||||||
@ -23,6 +23,7 @@ from scrobbles.constants import LONG_PLAY_MEDIA
|
|||||||
from scrobbles.notifications import (
|
from scrobbles.notifications import (
|
||||||
MoodNtfyNotification,
|
MoodNtfyNotification,
|
||||||
ScrobbleNtfyNotification,
|
ScrobbleNtfyNotification,
|
||||||
|
WaterReminderNtfyNotification,
|
||||||
)
|
)
|
||||||
from scrobbles.tasks import (
|
from scrobbles.tasks import (
|
||||||
process_koreader_import,
|
process_koreader_import,
|
||||||
@ -154,8 +155,7 @@ def import_lastfm_for_all_users(restart=False):
|
|||||||
last_processed = lfm_import.processed_finished
|
last_processed = lfm_import.processed_finished
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"No existing LastFM import for user %s, "
|
"No existing LastFM import for user %s, " "starting a full parse",
|
||||||
"starting a full parse",
|
|
||||||
user_id,
|
user_id,
|
||||||
)
|
)
|
||||||
last_processed = None
|
last_processed = None
|
||||||
@ -350,6 +350,20 @@ def send_mood_checkin_reminders() -> int:
|
|||||||
return notifications_sent
|
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):
|
def extract_domain(url):
|
||||||
parsed_url = urlparse(url)
|
parsed_url = urlparse(url)
|
||||||
domain = parsed_url.netloc.split(".")[-2] + "." + parsed_url.netloc.split(".")[-1]
|
domain = parsed_url.netloc.split(".")[-2] + "." + parsed_url.netloc.split(".")[-1]
|
||||||
@ -460,7 +474,8 @@ def _ensure_mopidy_playlist(profile):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
result = _mopidy_rpc(
|
result = _mopidy_rpc(
|
||||||
profile, "core.playlists.create",
|
profile,
|
||||||
|
"core.playlists.create",
|
||||||
{"name": playlist_name, "uri_scheme": "m3u"},
|
{"name": playlist_name, "uri_scheme": "m3u"},
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -588,7 +603,8 @@ def remove_track_from_mopidy_favorites_playlist(favorite):
|
|||||||
if playlist and playlist.get("uri"):
|
if playlist and playlist.get("uri"):
|
||||||
existing_tracks = playlist.get("tracks") or []
|
existing_tracks = playlist.get("tracks") or []
|
||||||
filtered = [
|
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 not (isinstance(t, dict) and t.get("uri") == mopidy_uri)
|
||||||
]
|
]
|
||||||
if len(filtered) == len(existing_tracks):
|
if len(filtered) == len(existing_tracks):
|
||||||
@ -639,7 +655,8 @@ def _ensure_mopidy_playlist_by_name(profile, playlist_name):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
result = _mopidy_rpc(
|
result = _mopidy_rpc(
|
||||||
profile, "core.playlists.create",
|
profile,
|
||||||
|
"core.playlists.create",
|
||||||
{"name": playlist_name, "uri_scheme": "m3u"},
|
{"name": playlist_name, "uri_scheme": "m3u"},
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@ -188,6 +188,10 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
"task": "scrobbles.tasks.send_mood_checkin",
|
"task": "scrobbles.tasks.send_mood_checkin",
|
||||||
"schedule": crontab(hour="*/4", minute=0),
|
"schedule": crontab(hour="*/4", minute=0),
|
||||||
},
|
},
|
||||||
|
"send-water-reminder": {
|
||||||
|
"task": "scrobbles.tasks.send_water_reminder",
|
||||||
|
"schedule": crontab(hour="*/3", minute=0),
|
||||||
|
},
|
||||||
"backfill-scrobble-sentiment": {
|
"backfill-scrobble-sentiment": {
|
||||||
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
|
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
|
||||||
"schedule": crontab(minute="0"),
|
"schedule": crontab(minute="0"),
|
||||||
|
|||||||
@ -281,10 +281,7 @@
|
|||||||
<a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a>
|
<a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item text-nowrap">
|
<div class="nav-item text-nowrap">
|
||||||
<form method="post" action="{% url "drinks:quick_water_scrobble" %}" style="display:inline;">
|
<a class="nav-link px-3" href="{% url "drinks:quick_water_scrobble" %}">Drink water</a>
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="nav-link px-3 btn btn-link" style="text-decoration:none;">Drink water</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item text-nowrap">
|
<div class="nav-item text-nowrap">
|
||||||
<a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a>
|
<a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user