Skip to content

Commit c47d940

Browse files
committed
feat: add seed_data management command for manual testing
Creates realistic test data with 3 users (mom/dad/caretaker), 3 children at different ages, sharing relationships, and 14 days of tracking events (feedings, diapers, naps). Uses deterministic RNG for reproducibility. Idempotent by default; supports --flush to recreate.
1 parent cdb9587 commit c47d940

File tree

3 files changed

+381
-0
lines changed

3 files changed

+381
-0
lines changed

children/management/__init__.py

Whitespace-only changes.

children/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
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

Comments
 (0)