Files
vrobbler/vrobbler/apps/drinks/management/commands/migrate_coffee_scrobbles.py

475 lines
16 KiB
Python

from bisect import bisect_right
from django.core.management.base import BaseCommand
from drinks.models import BrewingMethod, Coffee, CoffeeRoaster, RoastLevel
from scrobbles.models import FavoriteMedia, Scrobble
BASE_RUN_TIME_SECONDS = 2400
# French Press (Food ID 10)
FOOD_ID_FRENCH_PRESS = 10
UNKNOWN_ROAST_TITLE = "Unknown Roast"
TIMBERWYCK_ROASTER_ID = 1
FRENCH_PRESS_CALORIES = 50
FRENCH_PRESS_BASE_TAGS = {"sweet maria", "green coffee"}
# Medium Roast (Food ID 27)
FOOD_ID_MEDIUM_ROAST = 27
MEDIUM_ROAST_TITLE = "Medium Roast"
MEDIUM_ROAST_ROASTER_NAME = "Dunkin' Donuts"
MEDIUM_ROAST_CALORIES = 150
MEDIUM_ROAST_BASE_TAGS = {"dunkin"}
# Lavazza (Food ID 12)
FOOD_ID_LAVAZZA = 12
LAVAZZA_TITLE = "Qualità Oro"
LAVAZZA_ROASTER_NAME = "Lavazza"
LAVAZZA_ORIGIN = "Blended"
LAVAZZA_CALORIES = 50
LAVAZZA_BASE_TAGS = {"lavazza", "espresso"}
# Drip Coffee (Food ID 61)
FOOD_ID_DRIP_COFFEE = 61
DRIP_COFFEE_CALORIES = 50
class Command(BaseCommand):
help = (
"Migrate French Press, Medium Roast, and Lavazza food scrobbles "
"to Coffee scrobbles."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without making changes",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
if dry_run:
self.stdout.write(
self.style.WARNING("DRY RUN - no changes will be saved\n")
)
# French Press migration
self.stdout.write(self.style.NOTICE("=== French Press Coffee ==="))
french_migrated, french_skipped = self._migrate_french_press(dry_run)
# Medium Roast migration
self.stdout.write(self.style.NOTICE("\n=== Medium Roast ==="))
medium_migrated, medium_skipped = self._migrate_medium_roast(dry_run)
# Lavazza migration
self.stdout.write(self.style.NOTICE("\n=== Lavazza ==="))
lavazza_migrated, lavazza_skipped = self._migrate_lavazza(dry_run)
# Drip Coffee migration
self.stdout.write(self.style.NOTICE("\n=== Drip Coffee ==="))
drip_migrated, drip_skipped = self._migrate_drip_coffee(dry_run)
self.stdout.write("")
total_migrated = (
french_migrated + medium_migrated + lavazza_migrated + drip_migrated
)
total_skipped = french_skipped + medium_skipped + lavazza_skipped + drip_skipped
if dry_run:
self.stdout.write(
self.style.WARNING(
f"Dry run: would migrate {total_migrated} scrobbles, "
f"skip {total_skipped} already migrated. "
f"Use without --dry-run to apply."
)
)
else:
self.stdout.write(
self.style.SUCCESS(
f"Migrated {total_migrated} scrobbles, skipped {total_skipped} "
f"already migrated."
)
)
def _migrate_french_press(self, dry_run):
roast_coffees = self._build_roast_coffee_timeline()
if not roast_coffees:
self.stdout.write(
self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")
)
return 0, 0
unknown_roast = self._get_or_create_unknown_roast(dry_run)
food_scrobbles = self._get_food_scrobbles(FOOD_ID_FRENCH_PRESS)
favorite_foods = self._get_favorite_foods(FOOD_ID_FRENCH_PRESS)
timestamps = [ts for ts, _ in roast_coffees]
migrated = 0
skipped = 0
for scrobble in food_scrobbles.iterator():
if scrobble.coffee_id is not None:
skipped += 1
continue
idx = bisect_right(timestamps, scrobble.timestamp) - 1
if idx >= 0:
_, coffee = roast_coffees[idx]
else:
coffee = unknown_roast
log = scrobble.log or {}
log["brewing_method"] = BrewingMethod.FRENCH_PRESS
log["roast_level"] = RoastLevel.FULL_CITY_PLUS
self.stdout.write(
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
f"Coffee '{coffee}' ({scrobble.timestamp})"
)
if not dry_run:
scrobble.coffee = coffee
scrobble.food = None
scrobble.media_type = Scrobble.MediaType.COFFEE
scrobble.log = log
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
migrated += 1
self._migrate_favorites(
favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run
)
return migrated, skipped
def _migrate_medium_roast(self, dry_run):
coffee = self._get_or_create_medium_roast(dry_run)
food_scrobbles = self._get_food_scrobbles(FOOD_ID_MEDIUM_ROAST)
favorite_foods = self._get_favorite_foods(FOOD_ID_MEDIUM_ROAST)
migrated = 0
skipped = 0
for scrobble in food_scrobbles.iterator():
if scrobble.coffee_id is not None:
skipped += 1
continue
log = scrobble.log or {}
log["brewing_method"] = BrewingMethod.DRIP
log["roast_level"] = RoastLevel.FULL_CITY
self.stdout.write(
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
f"Coffee '{coffee}' ({scrobble.timestamp})"
)
if not dry_run:
scrobble.coffee = coffee
scrobble.food = None
scrobble.media_type = Scrobble.MediaType.COFFEE
scrobble.log = log
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
migrated += 1
self._migrate_medium_roast_favorites(favorite_foods, coffee, dry_run)
return migrated, skipped
def _migrate_lavazza(self, dry_run):
coffee = self._get_or_create_lavazza(dry_run)
food_scrobbles = self._get_food_scrobbles(FOOD_ID_LAVAZZA)
favorite_foods = self._get_favorite_foods(FOOD_ID_LAVAZZA)
migrated = 0
skipped = 0
for scrobble in food_scrobbles.iterator():
if scrobble.coffee_id is not None:
skipped += 1
continue
log = scrobble.log or {}
log["brewing_method"] = BrewingMethod.MOKA_POT
self.stdout.write(
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
f"Coffee '{coffee}' ({scrobble.timestamp})"
)
if not dry_run:
scrobble.coffee = coffee
scrobble.food = None
scrobble.media_type = Scrobble.MediaType.COFFEE
scrobble.log = log
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
migrated += 1
self._migrate_lavazza_favorites(favorite_foods, coffee, dry_run)
return migrated, skipped
def _migrate_lavazza_favorites(self, favorites, coffee, dry_run):
updated = 0
for fav in favorites:
self.stdout.write(
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
)
if not dry_run:
fav.coffee = coffee
fav.food = None
fav.media_type = Scrobble.MediaType.COFFEE
fav.save(update_fields=["coffee", "food", "media_type"])
updated += 1
if updated:
self.stdout.write(
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
)
def _migrate_drip_coffee(self, dry_run):
roast_coffees = self._build_roast_coffee_timeline()
if not roast_coffees:
self.stdout.write(
self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")
)
return 0, 0
unknown_roast = self._get_or_create_unknown_roast(dry_run)
food_scrobbles = self._get_food_scrobbles(FOOD_ID_DRIP_COFFEE)
favorite_foods = self._get_favorite_foods(FOOD_ID_DRIP_COFFEE)
timestamps = [ts for ts, _ in roast_coffees]
migrated = 0
skipped = 0
for scrobble in food_scrobbles.iterator():
if scrobble.coffee_id is not None:
skipped += 1
continue
idx = bisect_right(timestamps, scrobble.timestamp) - 1
if idx >= 0:
_, coffee = roast_coffees[idx]
else:
coffee = unknown_roast
log = scrobble.log or {}
log["brewing_method"] = BrewingMethod.DRIP
log["roast_level"] = RoastLevel.CITY
self.stdout.write(
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
f"Coffee '{coffee}' ({scrobble.timestamp})"
)
if not dry_run:
scrobble.coffee = coffee
scrobble.food = None
scrobble.media_type = Scrobble.MediaType.COFFEE
scrobble.log = log
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
migrated += 1
self._migrate_drip_coffee_favorites(
favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run
)
return migrated, skipped
def _migrate_drip_coffee_favorites(
self, favorites, roast_coffees, timestamps, unknown_roast, dry_run
):
updated = 0
for fav in favorites:
idx = bisect_right(timestamps, fav.created) - 1
coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast
self.stdout.write(
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
)
if not dry_run:
fav.coffee = coffee
fav.food = None
fav.media_type = Scrobble.MediaType.COFFEE
fav.save(update_fields=["coffee", "food", "media_type"])
updated += 1
if updated:
self.stdout.write(
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
)
def _get_or_create_lavazza(self, dry_run):
roaster, _ = CoffeeRoaster.objects.get_or_create(name=LAVAZZA_ROASTER_NAME)
defaults = {
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
"roaster": roaster,
"calories": LAVAZZA_CALORIES,
"origin": LAVAZZA_ORIGIN,
}
if dry_run:
try:
return Coffee.objects.get(title=LAVAZZA_TITLE, roaster=roaster)
except Coffee.DoesNotExist:
return Coffee(title=LAVAZZA_TITLE, **defaults)
coffee, created = Coffee.objects.get_or_create(
title=LAVAZZA_TITLE,
roaster=roaster,
defaults=defaults,
)
if created:
tags = set(LAVAZZA_TITLE.lower().split()) | LAVAZZA_BASE_TAGS
coffee.tags.add(*tags)
self.stdout.write(f" Created Coffee: '{LAVAZZA_TITLE}' (tags={tags})")
return coffee
def _migrate_medium_roast_favorites(self, favorites, coffee, dry_run):
updated = 0
for fav in favorites:
self.stdout.write(
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
)
if not dry_run:
fav.coffee = coffee
fav.food = None
fav.media_type = Scrobble.MediaType.COFFEE
fav.save(update_fields=["coffee", "food", "media_type"])
updated += 1
if updated:
self.stdout.write(
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
)
def _build_roast_coffee_timeline(self):
"""Find Task scrobbles where task title='Chore' and log title='Roast coffee'.
Returns a list of (timestamp, Coffee) sorted by timestamp.
"""
task_scrobbles = (
Scrobble.objects.filter(
media_type=Scrobble.MediaType.TASK,
task__title="Chore",
log__title="Roast coffee",
)
.exclude(log__notes=None)
.exclude(log__notes=[])
.order_by("timestamp")
)
coffees = []
for scrobble in task_scrobbles:
notes = scrobble.log.get("notes") or []
if not notes:
continue
first_note = notes[0] if isinstance(notes, list) else str(notes)
origin, title = self._parse_coffee_name(first_note)
coffee = self._get_or_create_coffee(title, origin)
coffees.append((scrobble.timestamp, coffee))
return coffees
def _parse_coffee_name(self, note):
"""Parse 'Mexico - Veracruz Huatusco' into (origin, title)."""
if " - " in note:
parts = note.split(" - ", 1)
return parts[0].strip(), parts[1].strip()
return None, note.strip()
def _get_or_create_coffee(self, title, origin):
coffee, created = Coffee.objects.get_or_create(
title=title,
origin=origin,
defaults={
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
"roaster_id": TIMBERWYCK_ROASTER_ID,
"calories": FRENCH_PRESS_CALORIES,
},
)
if created:
tags = set(title.lower().split()) | FRENCH_PRESS_BASE_TAGS
coffee.tags.add(*tags)
self.stdout.write(
f" Created Coffee: '{title}' (origin={origin}, tags={tags})"
)
return coffee
def _get_or_create_unknown_roast(self, dry_run):
defaults = {
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
"roaster_id": TIMBERWYCK_ROASTER_ID,
"calories": FRENCH_PRESS_CALORIES,
}
if dry_run:
try:
return Coffee.objects.get(title=UNKNOWN_ROAST_TITLE)
except Coffee.DoesNotExist:
return Coffee(title=UNKNOWN_ROAST_TITLE, **defaults)
coffee, created = Coffee.objects.get_or_create(
title=UNKNOWN_ROAST_TITLE,
defaults=defaults,
)
if created:
tags = {"unknown", "roast"} | FRENCH_PRESS_BASE_TAGS
coffee.tags.add(*tags)
self.stdout.write(
f" Created Coffee: '{UNKNOWN_ROAST_TITLE}' (tags={tags})"
)
return coffee
def _get_or_create_medium_roast(self, dry_run):
roaster, _ = CoffeeRoaster.objects.get_or_create(name=MEDIUM_ROAST_ROASTER_NAME)
defaults = {
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
"roaster": roaster,
"calories": MEDIUM_ROAST_CALORIES,
"origin": None,
}
if dry_run:
try:
return Coffee.objects.get(title=MEDIUM_ROAST_TITLE, roaster=roaster)
except Coffee.DoesNotExist:
return Coffee(title=MEDIUM_ROAST_TITLE, **defaults)
coffee, created = Coffee.objects.get_or_create(
title=MEDIUM_ROAST_TITLE,
roaster=roaster,
defaults=defaults,
)
if created:
tags = set(MEDIUM_ROAST_TITLE.lower().split()) | MEDIUM_ROAST_BASE_TAGS
coffee.tags.add(*tags)
self.stdout.write(f" Created Coffee: '{MEDIUM_ROAST_TITLE}' (tags={tags})")
return coffee
def _get_food_scrobbles(self, food_id):
return Scrobble.objects.filter(
food_id=food_id,
media_type=Scrobble.MediaType.FOOD,
).order_by("timestamp")
def _get_favorite_foods(self, food_id):
return FavoriteMedia.objects.filter(
food_id=food_id,
media_type=Scrobble.MediaType.FOOD,
)
def _migrate_favorites(
self, favorites, roast_coffees, timestamps, unknown_roast, dry_run
):
updated = 0
for fav in favorites:
idx = bisect_right(timestamps, fav.created) - 1
coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast
self.stdout.write(
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
)
if not dry_run:
fav.coffee = coffee
fav.food = None
fav.media_type = Scrobble.MediaType.COFFEE
fav.save(update_fields=["coffee", "food", "media_type"])
updated += 1
if updated:
self.stdout.write(
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
)