From cb20abfb8577a95ffb7a3282ab14ff0bd0991db8 Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Wed, 12 Nov 2025 18:14:28 +0100 Subject: [PATCH 1/2] added showcase data, users and CI workflow to refresh data daily --- .gitea/workflows/refresh_showcase_data.yml | 41 +++ scripts/seed_showcase_data.py | 185 ++++++++++++++ scripts/showcase_data_definitions.py | 281 +++++++++++++++++++++ 3 files changed, 507 insertions(+) create mode 100644 .gitea/workflows/refresh_showcase_data.yml create mode 100644 scripts/seed_showcase_data.py create mode 100644 scripts/showcase_data_definitions.py diff --git a/.gitea/workflows/refresh_showcase_data.yml b/.gitea/workflows/refresh_showcase_data.yml new file mode 100644 index 0000000..9ac3637 --- /dev/null +++ b/.gitea/workflows/refresh_showcase_data.yml @@ -0,0 +1,41 @@ +name: Refresh Showcase Data + +on: + schedule: + # Run daily at 3 AM UTC + - cron: '0 3 * * *' + workflow_dispatch: # Allow manual triggering + +jobs: + refresh-data: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ vars.PYTHON_VERSION }} + + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Install dependencies + run: | + uv sync --no-dev --all-extras + + - name: Run showcase data seeding script + env: + POSTGRES_URI: ${{ secrets.POSTGRES_URI }} + run: | + uv run python scripts/seed_showcase_data.py + + - name: Notify on success + if: success() + run: echo "✅ Showcase data refreshed successfully" + + - name: Notify on failure + if: failure() + run: echo "❌ Failed to refresh showcase data" diff --git a/scripts/seed_showcase_data.py b/scripts/seed_showcase_data.py new file mode 100644 index 0000000..795349a --- /dev/null +++ b/scripts/seed_showcase_data.py @@ -0,0 +1,185 @@ +"""Seed test data into the database for demo purposes.""" + +import sys +import os +from pathlib import Path +from datetime import datetime + +# Add parent directory to path to import baby_monitor modules +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +# Import after path is set up +from baby_monitor.repositories.dependencies.get_database import ( # noqa: E402 + get_database, + init_db, +) +from baby_monitor.repositories.user.sqlite_user import ( # noqa: E402 + DatabaseUserRepository, +) +from baby_monitor.repositories.child.sqlite_child import ( # noqa: E402 + DatabaseChildRepository, +) +from baby_monitor.repositories.feeding.sqlite_feeding import ( # noqa: E402 + DatabaseFeedingRepository, +) +from baby_monitor.repositories.diaper_change.sqlite_diaper_change import ( # noqa: E402, E501 + DatabaseDiaperChangeRepository, +) +from baby_monitor.repositories.sleep.sqlite_sleep import ( # noqa: E402 + DatabaseSleepRepository, +) +from baby_monitor.routers.auth import hash_password # noqa: E402 +from showcase_data_definitions import generate_showcase_data # noqa: E402 + + +def seed_database(reference_time: datetime | None = None) -> None: + """ + Seed the database with test data. + + This function is idempotent - it will check if test users exist + and either create them or update timestamps of existing data. + + Args: + reference_time: The "now" time to use for generating timestamps. + Defaults to current time. + """ + # Initialize database + init_db() + db = next(get_database()) + + try: + # Initialize repositories + user_repo = DatabaseUserRepository(db) + child_repo = DatabaseChildRepository(db) + feeding_repo = DatabaseFeedingRepository(db) + diaper_repo = DatabaseDiaperChangeRepository(db) + sleep_repo = DatabaseSleepRepository(db) + + # Generate showcase data + test_data = generate_showcase_data(reference_time) + + print("Starting database seeding...") + print(f"Reference time: {test_data['reference_time']}") + + # Create or get showcase users + user_ids = [] + for user_data in test_data["users"]: + # Check if user exists + existing_user = user_repo.get_by_username(user_data["username"]) + + if existing_user: + print(f"✓ User '{user_data['username']}' already exists") + user_ids.append(existing_user["id"]) + else: + # Create new user + hashed_pw = hash_password(user_data["password"]) + user_id = user_repo.create( + username=user_data["username"], + hashed_password=hashed_pw, + ) + user_ids.append(user_id["id"]) + print(f"✓ Created user '{user_data['username']}'") + + # Create or get shared child + child_data = test_data["child"] + + # Check if child exists for first user + existing_children = child_repo.get_by_user_id(user_ids[0]) + child_id: int | None = None + + # Look for existing child with matching name + for child in existing_children: + if child["name"] == child_data["name"]: + child_id = child["id"] + print(f"✓ Child '{child_data['name']}' already exists") + break + + if child_id is None: + # Create new child + child_id = child_repo.create( + name=child_data["name"], + birth_time=child_data["birth_time"], + birth_weight=child_data["birth_weight"], + user_id=user_ids[0], + ) + print(f"✓ Created child '{child_data['name']}'") + + # Share child with second user if not already shared + parent_ids = child_repo.get_parent_ids(child_id) + if user_ids[1] not in parent_ids: + child_repo.add_parent(child_id, user_ids[1]) + msg = f"✓ Shared child with '{test_data['users'][1]['username']}'" + print(msg) + + # Ensure child_id is set + assert child_id is not None, "Child ID must be set at this point" + + # Clear existing logs to avoid duplicates + print("Clearing existing logs...") + + # Get all logs for this child + existing_feedings = feeding_repo.get_by_child_id(child_id) + for feeding in existing_feedings: + feeding_repo.delete(feeding["id"]) + + existing_diapers = diaper_repo.get_by_child_id(child_id) + for diaper in existing_diapers: + diaper_repo.delete(diaper["id"]) + + existing_sleep = sleep_repo.get_by_child_id(child_id) + for sleep in existing_sleep: + sleep_repo.delete(sleep["id"]) + + print("✓ Cleared existing logs") + + # Add feeding logs + print(f"Adding {len(test_data['feedings'])} feeding logs...") + for feeding in test_data["feedings"]: + feeding_repo.create( + child_id=child_id, + start_time=feeding["start_time"], + end_time=feeding["end_time"], + feeding_type=feeding["feeding_type"], + ) + print(f"✓ Added {len(test_data['feedings'])} feedings") + + # Add diaper changes + print(f"Adding {len(test_data['diapers'])} diaper changes...") + for diaper in test_data["diapers"]: + diaper_repo.create( + child_id=child_id, + change_time=diaper["time"], + poop_color=diaper.get("poop_color"), + poop_amount=diaper.get("poop_amount"), + pee_color=diaper.get("pee_color"), + pee_amount=diaper.get("pee_amount"), + ) + print(f"✓ Added {len(test_data['diapers'])} diaper changes") + + # Add sleep sessions + print(f"Adding {len(test_data['sleep'])} sleep sessions...") + for sleep in test_data["sleep"]: + sleep_repo.create( + child_id=child_id, + start_time=sleep["start_time"], + end_time=sleep["end_time"], + ) + print(f"✓ Added {len(test_data['sleep'])} sleep sessions") + + db.commit() + print("\n✅ Database seeding completed successfully!") + print("\nTest user credentials:") + print(f" Username: {test_data['users'][0]['username']}") + print(f" Password: {test_data['users'][0]['password']}") + print(" (Both users share the same password)") + + except Exception as e: + db.rollback() + print(f"\n❌ Error seeding database: {e}") + raise + finally: + db.close() + + +if __name__ == "__main__": + seed_database() diff --git a/scripts/showcase_data_definitions.py b/scripts/showcase_data_definitions.py new file mode 100644 index 0000000..ea37a5c --- /dev/null +++ b/scripts/showcase_data_definitions.py @@ -0,0 +1,281 @@ +"""Definition of showcase data to be used for presenting the app.""" + + +from datetime import datetime, timedelta +import random + +# User credentials +SHOWCASE_USERS = [ + { + "username": "user", + "password": "demo123", # Will be hashed when inserted + }, + { + "username": "user2", + "password": "demo123", # Will be hashed when inserted + }, +] + +# Shared child (14 days old) +CHILD_AGE_DAYS = 14 + + +def get_child_data(reference_time: datetime) -> dict: + """Get child data with birth time relative to reference time.""" + birth_time = reference_time - timedelta(days=CHILD_AGE_DAYS) + return { + "name": "Emma Rose", + "birth_time": birth_time, + "birth_weight": 3420, # grams (7.5 lbs) + } + + +def get_feeding_schedule(reference_time: datetime) -> list[dict]: + """ + Generate realistic feeding schedule for the last 14 days. + + Newborns feed every 2-3 hours initially, gradually spacing out + to 3-4 hours. + """ + feedings: list[dict] = [] + current_time = reference_time - timedelta(days=CHILD_AGE_DAYS) + end_time = reference_time + + feeding_types = ["left_breast", "right_breast", "bottle"] + + # Day-by-day feeding patterns + day_number = 0 + while current_time < end_time: + age_in_days = day_number + + # Newborns feed more frequently + if age_in_days < 3: + interval_hours = random.uniform(2, 2.5) + duration_minutes = random.randint(15, 25) + elif age_in_days < 7: + interval_hours = random.uniform(2.5, 3) + duration_minutes = random.randint(20, 30) + else: + interval_hours = random.uniform(3, 3.5) + duration_minutes = random.randint(20, 35) + + # Alternate between breasts and occasional bottle + if random.random() < 0.2: # 20% bottle feeding + feeding_type = "bottle" + else: + # Alternate breasts + feeding_type = feeding_types[len(feedings) % 2] + + start_time = current_time + end_time_feeding = start_time + timedelta(minutes=duration_minutes) + + feedings.append({ + "start_time": start_time, + "end_time": end_time_feeding, + "feeding_type": feeding_type, + }) + + current_time += timedelta(hours=interval_hours) + + # Move to next day + prev_date = ( + current_time - timedelta(hours=interval_hours) + ).date() + if current_time.date() != prev_date: + day_number += 1 + + return feedings + + +def get_diaper_changes(reference_time: datetime) -> list[dict]: + """ + Generate realistic diaper changes for the last 14 days. + + - First 2-3 days: Black/dark (meconium), minimal pee + - Days 3-5: Transition to yellow, more frequent + - Days 5+: Regular yellow stools, normal pee patterns + - 8-10 diaper changes per day initially, settling to 6-8 + """ + diapers = [] + current_time = reference_time - timedelta(days=CHILD_AGE_DAYS) + end_time = reference_time + + day_number = 0 + + while current_time < end_time: + age_in_days = day_number + + # Determine number of diaper changes per day based on age + if age_in_days < 3: + changes_per_day = random.randint(8, 10) + elif age_in_days < 7: + changes_per_day = random.randint(7, 9) + else: + changes_per_day = random.randint(6, 8) + + # Distribute changes throughout the day + day_start = current_time.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + day_end = day_start + timedelta(days=1) + + for _ in range(changes_per_day): + # Random time during the day + random_seconds = random.randint(0, 86400) + change_time = day_start + timedelta(seconds=random_seconds) + + if change_time >= end_time: + break + + # Determine diaper contents + has_poop = random.random() < 0.4 # 40% have poop + # 90% have pee, or always if no poop + has_pee = random.random() < 0.9 or not has_poop + + diaper = { + "time": change_time, + "has_poop": has_poop, + "has_pee": has_pee, + } + + # Poop characteristics based on age + if has_poop: + if age_in_days < 2: + # Meconium phase + diaper["poop_color"] = "black" + diaper["poop_amount"] = "light" + elif age_in_days < 4: + # Transition phase + colors = ["black", "green", "yellow"] + diaper["poop_color"] = random.choice(colors) + amounts = ["light", "medium"] + diaper["poop_amount"] = random.choice(amounts) + else: + # Normal phase + colors = ["yellow", "yellow", "yellow", "green"] + diaper["poop_color"] = random.choice(colors) + # Amount increases with age + if age_in_days < 7: + amounts = ["light", "medium"] + diaper["poop_amount"] = random.choice(amounts) + else: + amounts = ["medium", "heavy"] + diaper["poop_amount"] = random.choice(amounts) + + # Pee characteristics based on age + if has_pee: + if age_in_days < 2: + # Very light pee initially + diaper["pee_amount"] = "light" + diaper["pee_color"] = random.choice(["clear", "yellow"]) + elif age_in_days < 5: + # Increasing output + diaper["pee_amount"] = random.choice(["light", "medium"]) + diaper["pee_color"] = random.choice(["clear", "yellow"]) + else: + # Normal output + if age_in_days < 10: + amounts = ["medium", "medium", "heavy"] + diaper["pee_amount"] = random.choice(amounts) + else: + amounts = ["medium", "heavy", "heavy"] + diaper["pee_amount"] = random.choice(amounts) + colors = ["clear", "yellow", "yellow"] + diaper["pee_color"] = random.choice(colors) + + diapers.append(diaper) + + day_number += 1 + current_time = day_end + + # Sort by time + diapers.sort(key=lambda x: x["time"]) + return diapers + + +def get_sleep_sessions(reference_time: datetime) -> list[dict]: + """ + Generate realistic sleep patterns for the last 14 days. + + Newborns sleep 16-18 hours per day in short bursts (30min - 4 hours). + Sleep gradually consolidates over time. + """ + sleep_sessions = [] + current_time = reference_time - timedelta(days=CHILD_AGE_DAYS) + end_time = reference_time + + day_number = 0 + + while current_time < end_time: + age_in_days = day_number + + # Sleep patterns evolve with age + if age_in_days < 3: + # Very short, frequent naps + avg_wake_time = 1.5 # hours + sleep_duration_range = (30, 120) # minutes + elif age_in_days < 7: + # Slightly longer sleep periods + avg_wake_time = 2 + sleep_duration_range = (45, 180) + else: + # More consolidated sleep + avg_wake_time = 2.5 + sleep_duration_range = (60, 240) + + # Random wake time with variation + wake_minutes = random.randint( + int(avg_wake_time * 45), + int(avg_wake_time * 75) + ) + + # Sleep start time + start_time = current_time + timedelta(minutes=wake_minutes) + + if start_time >= end_time: + break + + # Sleep duration + duration_minutes = random.randint(*sleep_duration_range) + + # Longer sleep at night + if 20 <= start_time.hour or start_time.hour < 6: + duration_minutes = int(duration_minutes * 1.3) + + end_time_sleep = start_time + timedelta(minutes=duration_minutes) + + sleep_sessions.append({ + "start_time": start_time, + "end_time": end_time_sleep, + }) + + current_time = end_time_sleep + birth_time = reference_time - timedelta(days=CHILD_AGE_DAYS) + day_number = (current_time - birth_time).days + + return sleep_sessions + + +def generate_showcase_data(reference_time: datetime | None = None) -> dict: + """ + Generate all test data with timestamps relative to reference time. + + Args: + reference_time: The "now" time to use as reference. + Defaults to current time. + + Returns: + Dictionary containing all test data + """ + if reference_time is None: + reference_time = datetime.now() + + return { + "users": SHOWCASE_USERS, + "child": get_child_data(reference_time), + "feedings": get_feeding_schedule(reference_time), + "diapers": get_diaper_changes(reference_time), + "sleep": get_sleep_sessions(reference_time), + "reference_time": reference_time, + } + From 824358f8463ac47c9eff44b2615987595c68fb4d Mon Sep 17 00:00:00 2001 From: Brian Bjarke Jensen Date: Wed, 12 Nov 2025 18:18:17 +0100 Subject: [PATCH 2/2] CQ fixes --- .gitea/workflows/refresh_showcase_data.yml | 4 +- pyproject.toml | 4 + scripts/seed_showcase_data.py | 10 +- scripts/showcase_data_definitions.py | 117 ++++++++++----------- 4 files changed, 68 insertions(+), 67 deletions(-) diff --git a/.gitea/workflows/refresh_showcase_data.yml b/.gitea/workflows/refresh_showcase_data.yml index 9ac3637..5f3d2d8 100644 --- a/.gitea/workflows/refresh_showcase_data.yml +++ b/.gitea/workflows/refresh_showcase_data.yml @@ -3,13 +3,13 @@ name: Refresh Showcase Data on: schedule: # Run daily at 3 AM UTC - - cron: '0 3 * * *' + - cron: "0 3 * * *" workflow_dispatch: # Allow manual triggering jobs: refresh-data: runs-on: ubuntu-latest - + steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index eef103a..0edb6d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,3 +71,7 @@ markers = [ [[tool.mypy.overrides]] module = "redis.*" ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "showcase_data_definitions.*" +ignore_missing_imports = true diff --git a/scripts/seed_showcase_data.py b/scripts/seed_showcase_data.py index 795349a..b7e2435 100644 --- a/scripts/seed_showcase_data.py +++ b/scripts/seed_showcase_data.py @@ -1,7 +1,6 @@ """Seed test data into the database for demo purposes.""" import sys -import os from pathlib import Path from datetime import datetime @@ -96,14 +95,18 @@ def seed_database(reference_time: datetime | None = None) -> None: if child_id is None: # Create new child - child_id = child_repo.create( + created_child = child_repo.create( name=child_data["name"], birth_time=child_data["birth_time"], birth_weight=child_data["birth_weight"], user_id=user_ids[0], ) + child_id = created_child["id"] print(f"✓ Created child '{child_data['name']}'") + # Ensure child_id is set + assert child_id is not None, "Child ID must be set at this point" + # Share child with second user if not already shared parent_ids = child_repo.get_parent_ids(child_id) if user_ids[1] not in parent_ids: @@ -111,9 +114,6 @@ def seed_database(reference_time: datetime | None = None) -> None: msg = f"✓ Shared child with '{test_data['users'][1]['username']}'" print(msg) - # Ensure child_id is set - assert child_id is not None, "Child ID must be set at this point" - # Clear existing logs to avoid duplicates print("Clearing existing logs...") diff --git a/scripts/showcase_data_definitions.py b/scripts/showcase_data_definitions.py index ea37a5c..397485d 100644 --- a/scripts/showcase_data_definitions.py +++ b/scripts/showcase_data_definitions.py @@ -1,6 +1,5 @@ """Definition of showcase data to be used for presenting the app.""" - from datetime import datetime, timedelta import random @@ -40,14 +39,14 @@ def get_feeding_schedule(reference_time: datetime) -> list[dict]: feedings: list[dict] = [] current_time = reference_time - timedelta(days=CHILD_AGE_DAYS) end_time = reference_time - + feeding_types = ["left_breast", "right_breast", "bottle"] - + # Day-by-day feeding patterns day_number = 0 while current_time < end_time: age_in_days = day_number - + # Newborns feed more frequently if age_in_days < 3: interval_hours = random.uniform(2, 2.5) @@ -58,39 +57,39 @@ def get_feeding_schedule(reference_time: datetime) -> list[dict]: else: interval_hours = random.uniform(3, 3.5) duration_minutes = random.randint(20, 35) - + # Alternate between breasts and occasional bottle if random.random() < 0.2: # 20% bottle feeding feeding_type = "bottle" else: # Alternate breasts feeding_type = feeding_types[len(feedings) % 2] - + start_time = current_time end_time_feeding = start_time + timedelta(minutes=duration_minutes) - - feedings.append({ - "start_time": start_time, - "end_time": end_time_feeding, - "feeding_type": feeding_type, - }) - + + feedings.append( + { + "start_time": start_time, + "end_time": end_time_feeding, + "feeding_type": feeding_type, + } + ) + current_time += timedelta(hours=interval_hours) - + # Move to next day - prev_date = ( - current_time - timedelta(hours=interval_hours) - ).date() + prev_date = (current_time - timedelta(hours=interval_hours)).date() if current_time.date() != prev_date: day_number += 1 - + return feedings def get_diaper_changes(reference_time: datetime) -> list[dict]: """ Generate realistic diaper changes for the last 14 days. - + - First 2-3 days: Black/dark (meconium), minimal pee - Days 3-5: Transition to yellow, more frequent - Days 5+: Regular yellow stools, normal pee patterns @@ -99,12 +98,12 @@ def get_diaper_changes(reference_time: datetime) -> list[dict]: diapers = [] current_time = reference_time - timedelta(days=CHILD_AGE_DAYS) end_time = reference_time - + day_number = 0 - + while current_time < end_time: age_in_days = day_number - + # Determine number of diaper changes per day based on age if age_in_days < 3: changes_per_day = random.randint(8, 10) @@ -112,32 +111,30 @@ def get_diaper_changes(reference_time: datetime) -> list[dict]: changes_per_day = random.randint(7, 9) else: changes_per_day = random.randint(6, 8) - + # Distribute changes throughout the day - day_start = current_time.replace( - hour=0, minute=0, second=0, microsecond=0 - ) + day_start = current_time.replace(hour=0, minute=0, second=0, microsecond=0) day_end = day_start + timedelta(days=1) - + for _ in range(changes_per_day): # Random time during the day random_seconds = random.randint(0, 86400) change_time = day_start + timedelta(seconds=random_seconds) - + if change_time >= end_time: break - + # Determine diaper contents has_poop = random.random() < 0.4 # 40% have poop # 90% have pee, or always if no poop has_pee = random.random() < 0.9 or not has_poop - + diaper = { "time": change_time, "has_poop": has_poop, "has_pee": has_pee, } - + # Poop characteristics based on age if has_poop: if age_in_days < 2: @@ -161,7 +158,7 @@ def get_diaper_changes(reference_time: datetime) -> list[dict]: else: amounts = ["medium", "heavy"] diaper["poop_amount"] = random.choice(amounts) - + # Pee characteristics based on age if has_pee: if age_in_days < 2: @@ -182,33 +179,35 @@ def get_diaper_changes(reference_time: datetime) -> list[dict]: diaper["pee_amount"] = random.choice(amounts) colors = ["clear", "yellow", "yellow"] diaper["pee_color"] = random.choice(colors) - + diapers.append(diaper) - + day_number += 1 current_time = day_end - - # Sort by time - diapers.sort(key=lambda x: x["time"]) + + # Sort by time (type: ignore for mypy - we know time is datetime) + diapers.sort( + key=lambda x: x["time"] # type: ignore[arg-type,return-value] + ) return diapers def get_sleep_sessions(reference_time: datetime) -> list[dict]: """ Generate realistic sleep patterns for the last 14 days. - + Newborns sleep 16-18 hours per day in short bursts (30min - 4 hours). Sleep gradually consolidates over time. """ sleep_sessions = [] current_time = reference_time - timedelta(days=CHILD_AGE_DAYS) end_time = reference_time - + day_number = 0 - + while current_time < end_time: age_in_days = day_number - + # Sleep patterns evolve with age if age_in_days < 3: # Very short, frequent naps @@ -222,37 +221,36 @@ def get_sleep_sessions(reference_time: datetime) -> list[dict]: # More consolidated sleep avg_wake_time = 2.5 sleep_duration_range = (60, 240) - + # Random wake time with variation - wake_minutes = random.randint( - int(avg_wake_time * 45), - int(avg_wake_time * 75) - ) - + wake_minutes = random.randint(int(avg_wake_time * 45), int(avg_wake_time * 75)) + # Sleep start time start_time = current_time + timedelta(minutes=wake_minutes) - + if start_time >= end_time: break - + # Sleep duration duration_minutes = random.randint(*sleep_duration_range) - + # Longer sleep at night if 20 <= start_time.hour or start_time.hour < 6: duration_minutes = int(duration_minutes * 1.3) - + end_time_sleep = start_time + timedelta(minutes=duration_minutes) - - sleep_sessions.append({ - "start_time": start_time, - "end_time": end_time_sleep, - }) - + + sleep_sessions.append( + { + "start_time": start_time, + "end_time": end_time_sleep, + } + ) + current_time = end_time_sleep birth_time = reference_time - timedelta(days=CHILD_AGE_DAYS) day_number = (current_time - birth_time).days - + return sleep_sessions @@ -269,7 +267,7 @@ def generate_showcase_data(reference_time: datetime | None = None) -> dict: """ if reference_time is None: reference_time = datetime.now() - + return { "users": SHOWCASE_USERS, "child": get_child_data(reference_time), @@ -278,4 +276,3 @@ def generate_showcase_data(reference_time: datetime | None = None) -> dict: "sleep": get_sleep_sessions(reference_time), "reference_time": reference_time, } -