|
| 1 | +import random |
| 2 | +import secrets |
| 3 | +from datetime import date, datetime, time, timedelta |
| 4 | +from decimal import Decimal |
| 5 | +from zoneinfo import ZoneInfo |
| 6 | + |
| 7 | +from django.core.management.base import BaseCommand |
| 8 | +from django.db import transaction |
| 9 | +from django.utils import timezone |
| 10 | + |
| 11 | +from accounts.models import CustomUser |
| 12 | +from children.models import Child, ChildShare, ShareInvite |
| 13 | +from diapers.models import DiaperChange |
| 14 | +from feedings.models import Feeding |
| 15 | +from naps.models import Nap |
| 16 | +from notifications.models import ( |
| 17 | + FeedingReminderLog, |
| 18 | + Notification, |
| 19 | + NotificationPreference, |
| 20 | + QuietHours, |
| 21 | +) |
| 22 | + |
| 23 | +SEED_DOMAIN = "@seed.poopyfeed.local" |
| 24 | +SEED_PASSWORD = "seedpass123" # noqa: S105 # nosec B105 - intentional seed data |
| 25 | + |
| 26 | + |
| 27 | +class Command(BaseCommand): |
| 28 | + help = "Seed the database with realistic test data for manual testing." |
| 29 | + |
| 30 | + def add_arguments(self, parser): |
| 31 | + parser.add_argument( |
| 32 | + "--flush", |
| 33 | + action="store_true", |
| 34 | + help="Delete existing seed data before recreating.", |
| 35 | + ) |
| 36 | + |
| 37 | + def handle(self, *args, **options): |
| 38 | + seed_users = CustomUser.objects.filter(email__endswith=SEED_DOMAIN) |
| 39 | + |
| 40 | + if seed_users.exists() and not options["flush"]: |
| 41 | + self.stdout.write( |
| 42 | + self.style.WARNING( |
| 43 | + "Seed data already exists. Use --flush to delete and recreate." |
| 44 | + ) |
| 45 | + ) |
| 46 | + return |
| 47 | + |
| 48 | + if seed_users.exists(): |
| 49 | + count = seed_users.count() |
| 50 | + seed_users.delete() |
| 51 | + self.stdout.write(f"Flushed {count} seed user(s) and all related data.") |
| 52 | + |
| 53 | + with transaction.atomic(): |
| 54 | + self._seed() |
| 55 | + |
| 56 | + def _seed(self): |
| 57 | + rng = random.Random(42) # nosec B311 - deterministic seed data, not crypto |
| 58 | + now = timezone.now() |
| 59 | + today = now.date() |
| 60 | + |
| 61 | + # === Users === |
| 62 | + sarah = self._create_user("sarah", "Sarah", "Johnson", "America/New_York") |
| 63 | + michael = self._create_user("michael", "Michael", "Johnson", "America/New_York") |
| 64 | + maria = self._create_user("maria", "Maria", "Garcia", "America/Chicago") |
| 65 | + self.stdout.write("Created 3 users.") |
| 66 | + |
| 67 | + # === Children (owned by Sarah) === |
| 68 | + emma = Child.objects.create( |
| 69 | + parent=sarah, |
| 70 | + name="Emma", |
| 71 | + date_of_birth=today - timedelta(days=90), |
| 72 | + gender="F", |
| 73 | + custom_bottle_low_oz=Decimal("2.0"), |
| 74 | + custom_bottle_mid_oz=Decimal("3.0"), |
| 75 | + custom_bottle_high_oz=Decimal("4.0"), |
| 76 | + feeding_reminder_interval=3, |
| 77 | + ) |
| 78 | + liam = Child.objects.create( |
| 79 | + parent=sarah, |
| 80 | + name="Liam", |
| 81 | + date_of_birth=today - timedelta(days=540), |
| 82 | + gender="M", |
| 83 | + custom_bottle_low_oz=Decimal("4.0"), |
| 84 | + custom_bottle_mid_oz=Decimal("6.0"), |
| 85 | + custom_bottle_high_oz=Decimal("8.0"), |
| 86 | + feeding_reminder_interval=None, |
| 87 | + ) |
| 88 | + noah = Child.objects.create( |
| 89 | + parent=sarah, |
| 90 | + name="Noah", |
| 91 | + date_of_birth=today - timedelta(days=180), |
| 92 | + gender="M", |
| 93 | + custom_bottle_low_oz=Decimal("3.0"), |
| 94 | + custom_bottle_mid_oz=Decimal("4.0"), |
| 95 | + custom_bottle_high_oz=Decimal("5.0"), |
| 96 | + feeding_reminder_interval=4, |
| 97 | + ) |
| 98 | + children = [emma, liam, noah] |
| 99 | + self.stdout.write("Created 3 children (Emma, Liam, Noah).") |
| 100 | + |
| 101 | + # === Sharing === |
| 102 | + for child in children: |
| 103 | + ChildShare.objects.create( |
| 104 | + child=child, user=michael, role="CO", created_by=sarah |
| 105 | + ) |
| 106 | + for child in [emma, noah]: |
| 107 | + ChildShare.objects.create( |
| 108 | + child=child, user=maria, role="CG", created_by=sarah |
| 109 | + ) |
| 110 | + ShareInvite.objects.create( |
| 111 | + child=emma, |
| 112 | + token=secrets.token_urlsafe(32), |
| 113 | + role="CG", |
| 114 | + created_by=sarah, |
| 115 | + is_active=True, |
| 116 | + ) |
| 117 | + self.stdout.write("Created sharing relationships and 1 invite.") |
| 118 | + |
| 119 | + # === Tracking events (14 days) === |
| 120 | + feedings = [] |
| 121 | + diapers = [] |
| 122 | + naps = [] |
| 123 | + |
| 124 | + for day_offset in range(14, 0, -1): |
| 125 | + day = today - timedelta(days=day_offset) |
| 126 | + feedings += self._generate_feedings(rng, emma, day, "newborn") |
| 127 | + feedings += self._generate_feedings(rng, noah, day, "infant") |
| 128 | + feedings += self._generate_feedings(rng, liam, day, "toddler") |
| 129 | + diapers += self._generate_diapers(rng, emma, day, "newborn") |
| 130 | + diapers += self._generate_diapers(rng, noah, day, "infant") |
| 131 | + diapers += self._generate_diapers(rng, liam, day, "toddler") |
| 132 | + naps += self._generate_naps(rng, emma, day, "newborn") |
| 133 | + naps += self._generate_naps(rng, noah, day, "infant") |
| 134 | + naps += self._generate_naps(rng, liam, day, "toddler") |
| 135 | + |
| 136 | + Feeding.objects.bulk_create(feedings) |
| 137 | + DiaperChange.objects.bulk_create(diapers) |
| 138 | + Nap.objects.bulk_create(naps) |
| 139 | + self.stdout.write( |
| 140 | + f"Created {len(feedings)} feedings, {len(diapers)} diapers, " |
| 141 | + f"{len(naps)} naps over 14 days." |
| 142 | + ) |
| 143 | + |
| 144 | + # === Notifications === |
| 145 | + self._create_notification_prefs(sarah, michael, maria, children) |
| 146 | + self._create_sample_notifications(rng, sarah, michael, maria, children, now) |
| 147 | + self.stdout.write("Created notification preferences and sample notifications.") |
| 148 | + |
| 149 | + # === Summary === |
| 150 | + self.stdout.write("") |
| 151 | + self.stdout.write(self.style.SUCCESS("=" * 50)) |
| 152 | + self.stdout.write(self.style.SUCCESS("Seed data created successfully!")) |
| 153 | + self.stdout.write(self.style.SUCCESS("=" * 50)) |
| 154 | + self.stdout.write("") |
| 155 | + self.stdout.write("Login credentials (password for all: seedpass123):") |
| 156 | + self.stdout.write(f" Mom: sarah@seed.poopyfeed.local") |
| 157 | + self.stdout.write(f" Dad: michael@seed.poopyfeed.local") |
| 158 | + self.stdout.write(f" Caretaker: maria@seed.poopyfeed.local") |
| 159 | + self.stdout.write("") |
| 160 | + self.stdout.write(f"Children: Emma (~3mo), Liam (~18mo), Noah (~6mo)") |
| 161 | + self.stdout.write( |
| 162 | + f"Events: {len(feedings)} feedings, {len(diapers)} " |
| 163 | + f"diapers, {len(naps)} naps" |
| 164 | + ) |
| 165 | + |
| 166 | + def _create_user(self, username_prefix, first_name, last_name, tz): |
| 167 | + email = f"{username_prefix}{SEED_DOMAIN}" |
| 168 | + user = CustomUser.objects.create_user( |
| 169 | + username=email, |
| 170 | + email=email, |
| 171 | + password=SEED_PASSWORD, |
| 172 | + first_name=first_name, |
| 173 | + last_name=last_name, |
| 174 | + timezone=tz, |
| 175 | + ) |
| 176 | + return user |
| 177 | + |
| 178 | + # --- Feeding generation --- |
| 179 | + |
| 180 | + def _generate_feedings(self, rng, child, day, profile): |
| 181 | + configs = { |
| 182 | + "newborn": { |
| 183 | + "count": (8, 12), |
| 184 | + "breast_pct": 0.6, |
| 185 | + "bottle_oz": (Decimal("2.0"), Decimal("4.0")), |
| 186 | + "breast_min": (5, 25), |
| 187 | + "wake_start": 6, |
| 188 | + "wake_end": 23, |
| 189 | + }, |
| 190 | + "infant": { |
| 191 | + "count": (5, 7), |
| 192 | + "breast_pct": 0.3, |
| 193 | + "bottle_oz": (Decimal("3.0"), Decimal("6.0")), |
| 194 | + "breast_min": (8, 20), |
| 195 | + "wake_start": 6, |
| 196 | + "wake_end": 21, |
| 197 | + }, |
| 198 | + "toddler": { |
| 199 | + "count": (3, 5), |
| 200 | + "breast_pct": 0.0, |
| 201 | + "bottle_oz": (Decimal("4.0"), Decimal("8.0")), |
| 202 | + "breast_min": (0, 0), |
| 203 | + "wake_start": 7, |
| 204 | + "wake_end": 20, |
| 205 | + }, |
| 206 | + } |
| 207 | + cfg = configs[profile] |
| 208 | + count = rng.randint(*cfg["count"]) |
| 209 | + results = [] |
| 210 | + |
| 211 | + for slot in self._spread_times( |
| 212 | + rng, day, count, cfg["wake_start"], cfg["wake_end"] |
| 213 | + ): |
| 214 | + if rng.random() < cfg["breast_pct"]: |
| 215 | + results.append( |
| 216 | + Feeding( |
| 217 | + child=child, |
| 218 | + feeding_type="breast", |
| 219 | + fed_at=slot, |
| 220 | + amount_oz=None, |
| 221 | + duration_minutes=rng.randint(*cfg["breast_min"]), |
| 222 | + side=rng.choice(["left", "right", "both"]), |
| 223 | + ) |
| 224 | + ) |
| 225 | + else: |
| 226 | + oz_low, oz_high = cfg["bottle_oz"] |
| 227 | + # Generate in 0.5 oz steps |
| 228 | + steps = int((oz_high - oz_low) / Decimal("0.5")) |
| 229 | + amount = oz_low + Decimal("0.5") * rng.randint(0, steps) |
| 230 | + results.append( |
| 231 | + Feeding( |
| 232 | + child=child, |
| 233 | + feeding_type="bottle", |
| 234 | + fed_at=slot, |
| 235 | + amount_oz=amount, |
| 236 | + duration_minutes=None, |
| 237 | + side="", |
| 238 | + ) |
| 239 | + ) |
| 240 | + return results |
| 241 | + |
| 242 | + # --- Diaper generation --- |
| 243 | + |
| 244 | + def _generate_diapers(self, rng, child, day, profile): |
| 245 | + configs = { |
| 246 | + "newborn": {"count": (8, 12)}, |
| 247 | + "infant": {"count": (6, 8)}, |
| 248 | + "toddler": {"count": (4, 6)}, |
| 249 | + } |
| 250 | + cfg = configs[profile] |
| 251 | + count = rng.randint(*cfg["count"]) |
| 252 | + results = [] |
| 253 | + |
| 254 | + for slot in self._spread_times(rng, day, count, 6, 22): |
| 255 | + change_type = rng.choices(["wet", "dirty", "both"], weights=[50, 25, 25])[0] |
| 256 | + results.append( |
| 257 | + DiaperChange( |
| 258 | + child=child, |
| 259 | + change_type=change_type, |
| 260 | + changed_at=slot, |
| 261 | + ) |
| 262 | + ) |
| 263 | + return results |
| 264 | + |
| 265 | + # --- Nap generation --- |
| 266 | + |
| 267 | + def _generate_naps(self, rng, child, day, profile): |
| 268 | + configs = { |
| 269 | + "newborn": {"count": (4, 6), "duration": (30, 90)}, |
| 270 | + "infant": {"count": (2, 3), "duration": (45, 120)}, |
| 271 | + "toddler": {"count": (1, 2), "duration": (60, 150)}, |
| 272 | + } |
| 273 | + cfg = configs[profile] |
| 274 | + count = rng.randint(*cfg["count"]) |
| 275 | + results = [] |
| 276 | + |
| 277 | + for slot in self._spread_times(rng, day, count, 8, 18): |
| 278 | + duration = rng.randint(*cfg["duration"]) |
| 279 | + results.append( |
| 280 | + Nap( |
| 281 | + child=child, |
| 282 | + napped_at=slot, |
| 283 | + ended_at=slot + timedelta(minutes=duration), |
| 284 | + ) |
| 285 | + ) |
| 286 | + return results |
| 287 | + |
| 288 | + # --- Time distribution helper --- |
| 289 | + |
| 290 | + def _spread_times(self, rng, day, count, start_hour, end_hour): |
| 291 | + """Distribute `count` events across a day's wake window with jitter.""" |
| 292 | + window_minutes = (end_hour - start_hour) * 60 |
| 293 | + interval = window_minutes / max(count, 1) |
| 294 | + times = [] |
| 295 | + for i in range(count): |
| 296 | + base_min = int(i * interval) |
| 297 | + jitter = rng.randint(0, max(int(interval * 0.6), 1)) |
| 298 | + total_min = start_hour * 60 + base_min + jitter |
| 299 | + hour = total_min // 60 |
| 300 | + minute = total_min % 60 |
| 301 | + dt = datetime( |
| 302 | + day.year, |
| 303 | + day.month, |
| 304 | + day.day, |
| 305 | + hour, |
| 306 | + minute, |
| 307 | + tzinfo=ZoneInfo("UTC"), |
| 308 | + ) |
| 309 | + times.append(dt) |
| 310 | + return times |
| 311 | + |
| 312 | + # --- Notifications --- |
| 313 | + |
| 314 | + def _create_notification_prefs(self, sarah, michael, maria, children): |
| 315 | + prefs = [] |
| 316 | + for user in [sarah, michael, maria]: |
| 317 | + for child in children: |
| 318 | + # Maria only has access to Emma and Noah |
| 319 | + if user == maria and child.name == "Liam": |
| 320 | + continue |
| 321 | + prefs.append( |
| 322 | + NotificationPreference( |
| 323 | + user=user, |
| 324 | + child=child, |
| 325 | + notify_feedings=True, |
| 326 | + notify_diapers=True, |
| 327 | + notify_naps=True, |
| 328 | + ) |
| 329 | + ) |
| 330 | + NotificationPreference.objects.bulk_create(prefs) |
| 331 | + |
| 332 | + # Quiet hours |
| 333 | + QuietHours.objects.create( |
| 334 | + user=sarah, enabled=True, start_time=time(22, 0), end_time=time(6, 0) |
| 335 | + ) |
| 336 | + QuietHours.objects.create( |
| 337 | + user=michael, enabled=True, start_time=time(23, 0), end_time=time(7, 0) |
| 338 | + ) |
| 339 | + |
| 340 | + def _create_sample_notifications(self, rng, sarah, michael, maria, children, now): |
| 341 | + emma, liam, noah = children |
| 342 | + notifs = [] |
| 343 | + templates = [ |
| 344 | + (michael, sarah, emma, "feeding", "Sarah logged a feeding for Emma"), |
| 345 | + (michael, sarah, emma, "diaper", "Sarah changed Emma's diaper"), |
| 346 | + (michael, sarah, noah, "nap", "Sarah started a nap for Noah"), |
| 347 | + (sarah, michael, liam, "feeding", "Michael logged a feeding for Liam"), |
| 348 | + (sarah, michael, emma, "diaper", "Michael changed Emma's diaper"), |
| 349 | + (maria, sarah, emma, "feeding", "Sarah logged a feeding for Emma"), |
| 350 | + (maria, sarah, noah, "diaper", "Sarah changed Noah's diaper"), |
| 351 | + (sarah, None, emma, "feeding_reminder", "Emma hasn't been fed in 3 hours"), |
| 352 | + (sarah, None, noah, "feeding_reminder", "Noah hasn't been fed in 4 hours"), |
| 353 | + (michael, sarah, noah, "feeding", "Sarah logged a feeding for Noah"), |
| 354 | + (sarah, maria, emma, "nap", "Maria started a nap for Emma"), |
| 355 | + (michael, maria, emma, "feeding", "Maria logged a feeding for Emma"), |
| 356 | + (sarah, michael, liam, "nap", "Michael started a nap for Liam"), |
| 357 | + (michael, sarah, liam, "diaper", "Sarah changed Liam's diaper"), |
| 358 | + ] |
| 359 | + |
| 360 | + for i, (recipient, actor, child, event_type, message) in enumerate(templates): |
| 361 | + notifs.append( |
| 362 | + Notification( |
| 363 | + recipient=recipient, |
| 364 | + actor=actor, |
| 365 | + child=child, |
| 366 | + event_type=event_type, |
| 367 | + message=message, |
| 368 | + is_read=(i % 3 == 0), # ~1/3 read |
| 369 | + ) |
| 370 | + ) |
| 371 | + |
| 372 | + Notification.objects.bulk_create(notifs) |
| 373 | + |
| 374 | + # Manually set created_at to spread over last 2 days |
| 375 | + all_notifs = Notification.objects.filter( |
| 376 | + recipient__email__endswith=SEED_DOMAIN |
| 377 | + ).order_by("id") |
| 378 | + for i, notif in enumerate(all_notifs): |
| 379 | + Notification.objects.filter(pk=notif.pk).update( |
| 380 | + created_at=now - timedelta(hours=i * 3) |
| 381 | + ) |
0 commit comments