from time import sleep from datetime import date as date_type, datetime, timezone from flask import Blueprint, request, jsonify from tinydb import Query from api.child_rewards import ChildReward from api.child_tasks import ChildTask from api.pending_confirmation import PendingConfirmationResponse from api.reward_status import RewardStatus from api.utils import send_event_for_current_user, get_validated_user_id from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db from db.tracking import insert_tracking_event from db.child_overrides import get_override, delete_override, delete_overrides_for_child from events.types.child_chore_confirmation import ChildChoreConfirmation from events.types.child_modified import ChildModified from events.types.child_reward_request import ChildRewardRequest from events.types.child_reward_triggered import ChildRewardTriggered from events.types.child_rewards_set import ChildRewardsSet from events.types.child_task_triggered import ChildTaskTriggered from events.types.child_tasks_set import ChildTasksSet from events.types.tracking_event_created import TrackingEventCreated from events.types.event import Event from events.types.event_types import EventType from models.child import Child from models.pending_confirmation import PendingConfirmation from models.pending_reward import PendingReward from models.reward import Reward from models.task import Task from models.tracking_event import TrackingEvent from utils.tracking_logger import log_tracking_event from collections import defaultdict from db.chore_schedules import get_schedule from db.task_extensions import get_extension import logging child_api = Blueprint('child_api', __name__) logger = logging.getLogger(__name__) @child_api.route('/child/', methods=['GET']) @child_api.route('/child/', methods=['GET']) def get_child(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 return jsonify(Child.from_dict(result[0]).to_dict()), 200 @child_api.route('/child/add', methods=['PUT']) def add_child(): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() name = data.get('name') age = data.get('age') image = data.get('image_id', None) if not name: return jsonify({'error': 'Name is required'}), 400 if not image: image = 'boy01' child = Child(name=name, age=age, image_id=image, user_id=user_id) child_db.insert(child.to_dict()) resp = send_event_for_current_user( Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD))) if resp: return resp return jsonify({'message': f'Child {name} added.'}), 201 @child_api.route('/child//edit', methods=['PUT']) def edit_child(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() name = data.get('name', None) age = data.get('age', None) points = data.get('points', None) image = data.get('image_id', None) ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) if name is not None: child.name = name if age is not None: child.age = age if points is not None: child.points = points if image is not None: child.image_id = image # Check if points changed and handle pending rewards if points is not None: PendingQuery = Query() pending_rewards = pending_confirmations_db.search( (PendingQuery.child_id == id) & (PendingQuery.user_id == user_id) & (PendingQuery.entity_type == 'reward') & (PendingQuery.status == 'pending') ) RewardQuery = Query() for pr in pending_rewards: pending = PendingConfirmation.from_dict(pr) reward_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) if reward_result: reward = Reward.from_dict(reward_result) # If child can no longer afford the reward, remove the pending request if child.points < reward.cost: pending_confirmations_db.remove( (PendingQuery.child_id == id) & (PendingQuery.entity_id == reward.id) & (PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id) ) resp = send_event_for_current_user( Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED))) if resp: return resp child_db.update(child.to_dict(), ChildQuery.id == id) resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT))) if resp: return resp return jsonify({'message': f'Child {id} updated.'}), 200 @child_api.route('/child/list', methods=['GET']) def list_children(): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() children = child_db.search(ChildQuery.user_id == user_id) return jsonify({'children': children}), 200 # Child DELETE @child_api.route('/child/', methods=['DELETE']) def delete_child(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() # Cascade delete overrides for this child deleted_count = delete_overrides_for_child(id) if deleted_count > 0: logger.info(f"Cascade deleted {deleted_count} overrides for child {id}") if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)): resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE))) if resp: return resp return jsonify({'message': f'Child {id} deleted.'}), 200 return jsonify({'error': 'Child not found'}), 404 @child_api.route('/child//assign-task', methods=['POST']) def assign_task_to_child(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() task_id = data.get('task_id') if not task_id: return jsonify({'error': 'task_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = result[0] if task_id not in child.get('tasks', []): child['tasks'].append(task_id) child_db.update({'tasks': child['tasks']}, ChildQuery.id == id) return jsonify({'message': f"Task {task_id} assigned to {child.get('name')}."}), 200 # python @child_api.route('/child//set-tasks', methods=['PUT']) def set_child_tasks(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() or {} task_ids = data.get('task_ids') if 'type' not in data: return jsonify({'error': 'type is required (chore, kindness, or penalty)'}), 400 task_type = data.get('type') if task_type not in ['chore', 'kindness', 'penalty']: return jsonify({'error': 'type must be chore, kindness, or penalty', 'code': 'INVALID_TASK_TYPE'}), 400 if not isinstance(task_ids, list): return jsonify({'error': 'task_ids must be a list'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) new_task_ids = set(task_ids) # Add all existing child tasks of other types for task_record in task_db.all(): task_obj = Task.from_dict(task_record) if task_obj.id in child.tasks and task_obj.type != task_type: new_task_ids.add(task_obj.id) # Convert back to list if needed new_tasks = list(new_task_ids) # Identify unassigned tasks and delete their overrides old_task_ids = set(child.tasks) unassigned_task_ids = old_task_ids - new_task_ids for task_id in unassigned_task_ids: # Only delete overrides for task entities override = get_override(id, task_id) if override and override.entity_type == 'task': delete_override(id, task_id) logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}") # Replace tasks with validated IDs child_db.update({'tasks': new_tasks}, ChildQuery.id == id) resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, new_tasks))) if resp: return resp return jsonify({ 'message': f'Tasks set for child {id}.', 'task_ids': new_tasks, 'count': len(new_tasks) }), 200 @child_api.route('/child//remove-task', methods=['POST']) def remove_task_from_child(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() task_id = data.get('task_id') if not task_id: return jsonify({'error': 'task_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = result[0] if task_id in child.get('tasks', []): child['tasks'].remove(task_id) child_db.update({'tasks': child['tasks']}, ChildQuery.id == id) return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200 return jsonify({'error': 'Task not assigned to child'}), 400 @child_api.route('/child//list-tasks', methods=['GET']) def list_child_tasks(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = result[0] task_ids = child.get('tasks', []) TaskQuery = Query() child_tasks = [] for tid in task_ids: task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) if not task: continue task_obj = Task.from_dict(task) # Check for override override = get_override(id, tid) custom_value = override.custom_value if override else None ct = ChildTask(task_obj.name, task_obj.type, task_obj.points, task_obj.image_id, task_obj.id) ct_dict = ct.to_dict() if custom_value is not None: ct_dict['custom_value'] = custom_value # Attach schedule and most recent extension_date for chores (client does all time math) if task_obj.type == 'chore': schedule = get_schedule(id, tid) ct_dict['schedule'] = schedule.to_dict() if schedule else None today_str = date_type.today().isoformat() ext = get_extension(id, tid, today_str) ct_dict['extension_date'] = ext.date if ext else None # Attach pending confirmation status for chores PendingQuery = Query() pending = pending_confirmations_db.get( (PendingQuery.child_id == id) & (PendingQuery.entity_id == tid) & (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) ) if pending: ct_dict['pending_status'] = pending.get('status') ct_dict['approved_at'] = pending.get('approved_at') else: ct_dict['pending_status'] = None ct_dict['approved_at'] = None else: ct_dict['schedule'] = None ct_dict['extension_date'] = None ct_dict['pending_status'] = None ct_dict['approved_at'] = None child_tasks.append(ct_dict) return jsonify({'tasks': child_tasks}), 200 @child_api.route('/child//list-assignable-tasks', methods=['GET']) def list_assignable_tasks(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = result[0] assigned_ids = set(child.get('tasks', [])) # Get all assignable tasks (not already assigned) all_tasks = [t for t in task_db.all() if t and t.get('id') and t.get('id') not in assigned_ids] # Group by name from collections import defaultdict name_to_tasks = defaultdict(list) for t in all_tasks: name_to_tasks[t.get('name')].append(t) filtered_tasks = [] for name, tasks in name_to_tasks.items(): user_tasks = [t for t in tasks if t.get('user_id') is not None] if len(user_tasks) == 0: # Only system task exists filtered_tasks.append(tasks[0]) elif len(user_tasks) == 1: # Only one user task: show it, not system filtered_tasks.append(user_tasks[0]) else: # Multiple user tasks: show all user tasks, not system filtered_tasks.extend(user_tasks) # Wrap in ChildTask and return assignable_tasks = [ChildTask(t.get('name'), Task.from_dict(t).type, t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks] return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200 @child_api.route('/child//list-all-tasks', methods=['GET']) def list_all_tasks(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 has_type = "type" in request.args if has_type and request.args.get('type') not in ['chore', 'kindness', 'penalty']: return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400 filter_type = request.args.get('type', None) if has_type else None child = result[0] assigned_ids = set(child.get('tasks', [])) # Get all tasks from database (not filtering out assigned, since this is 'all') ChildTaskQuery = Query() all_tasks = task_db.search((ChildTaskQuery.user_id == user_id) | (ChildTaskQuery.user_id == None)) name_to_tasks = defaultdict(list) for t in all_tasks: name_to_tasks[t.get('name')].append(t) filtered_tasks = [] for name, tasks in name_to_tasks.items(): user_tasks = [t for t in tasks if t.get('user_id') is not None] if len(user_tasks) == 0: filtered_tasks.append(tasks[0]) elif len(user_tasks) == 1: filtered_tasks.append(user_tasks[0]) else: filtered_tasks.extend(user_tasks) result_tasks = [] for t in filtered_tasks: task_obj = Task.from_dict(t) if has_type and task_obj.type != filter_type: continue ct = ChildTask( task_obj.name, task_obj.type, task_obj.points, task_obj.image_id, task_obj.id ) task_dict = ct.to_dict() task_dict.update({'assigned': t.get('id') in assigned_ids}) result_tasks.append(task_dict) result_tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower())) return jsonify({ 'tasks': result_tasks, 'count': len(result_tasks), 'list_type': 'task' }), 200 @child_api.route('/child//trigger-task', methods=['POST']) def trigger_child_task(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() task_id = data.get('task_id') if not task_id: return jsonify({'error': 'task_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child: Child = Child.from_dict(result[0]) if task_id not in child.tasks: return jsonify({'error': f'Task not found assigned to child {child.name}'}), 404 # look up the task and get the details TaskQuery = Query() task_result = task_db.search((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) if not task_result: return jsonify({'error': 'Task not found in task database'}), 404 task: Task = Task.from_dict(task_result[0]) # Capture points before modification points_before = child.points # Check for override override = get_override(id, task_id) points_value = override.custom_value if override else task.points # update the child's points based on task type if task.is_good: child.points += points_value else: child.points -= points_value child.points = max(child.points, 0) # update the child in the database child_db.update({'points': child.points}, ChildQuery.id == id) # For chores, create an approved PendingConfirmation so child view shows COMPLETED if task.type == 'chore': PendingQuery = Query() # Remove any existing pending confirmation for this chore pending_confirmations_db.remove( (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) ) confirmation = PendingConfirmation( child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id, status='approved', approved_at=datetime.now(timezone.utc).isoformat() ) pending_confirmations_db.insert(confirmation.to_dict()) send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED))) # Create tracking event entity_type = task.type tracking_metadata = { 'task_name': task.name, 'task_type': task.type, 'default_points': task.points } if override: tracking_metadata['custom_points'] = override.custom_value tracking_metadata['has_override'] = True tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=child.id, entity_type=entity_type, entity_id=task.id, action='activated', points_before=points_before, points_after=child.points, metadata=tracking_metadata ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) # Send tracking event via SSE send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, entity_type, 'activated'))) resp = send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points))) if resp: return resp return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200 @child_api.route('/child//assign-reward', methods=['POST']) def assign_reward_to_child(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() reward_id = data.get('reward_id') if not reward_id: return jsonify({'error': 'reward_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = result[0] if reward_id not in child.get('rewards', []): child['rewards'].append(reward_id) child_db.update({'rewards': child['rewards']}, ChildQuery.id == id) return jsonify({'message': f'Reward {reward_id} assigned to {child["name"]}.'}), 200 @child_api.route('/child//list-all-rewards', methods=['GET']) def list_all_rewards(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) assigned_ids = set(child.rewards) # Get all rewards from database ChildRewardQuery = Query() all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None)) from collections import defaultdict name_to_rewards = defaultdict(list) for r in all_rewards: name_to_rewards[r.get('name')].append(r) filtered_rewards = [] for name, rewards in name_to_rewards.items(): user_rewards = [r for r in rewards if r.get('user_id') is not None] if len(user_rewards) == 0: filtered_rewards.append(rewards[0]) elif len(user_rewards) == 1: filtered_rewards.append(user_rewards[0]) else: filtered_rewards.extend(user_rewards) result_rewards = [] for r in filtered_rewards: cr = ChildReward( r.get('name'), r.get('cost'), r.get('image_id'), r.get('id') ) reward_dict = cr.to_dict() reward_dict.update({'assigned': r.get('id') in assigned_ids}) result_rewards.append(reward_dict) result_rewards.sort(key=lambda t: (not t['assigned'], t['name'].lower())) return jsonify({ 'rewards': result_rewards, 'rewards_count': len(result_rewards), 'list_type': 'reward' }), 200 @child_api.route('/child//set-rewards', methods=['PUT']) def set_child_rewards(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() or {} reward_ids = data.get('reward_ids') if not isinstance(reward_ids, list): return jsonify({'error': 'reward_ids must be a list'}), 400 # Deduplicate and drop falsy values new_reward_ids = [rid for rid in dict.fromkeys(reward_ids) if rid] ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) old_reward_ids = set(child.rewards) # Optional: validate reward IDs exist in the reward DB RewardQuery = Query() valid_reward_ids = [] for rid in new_reward_ids: if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))): valid_reward_ids.append(rid) # Identify unassigned rewards and delete their overrides new_reward_ids_set = set(valid_reward_ids) unassigned_reward_ids = old_reward_ids - new_reward_ids_set for reward_id in unassigned_reward_ids: override = get_override(id, reward_id) if override and override.entity_type == 'reward': delete_override(id, reward_id) logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}") # Replace rewards with validated IDs child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id) send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids))) return jsonify({ 'message': f'Rewards set for child {id}.', 'reward_ids': valid_reward_ids, 'count': len(valid_reward_ids) }), 200 @child_api.route('/child//remove-reward', methods=['POST']) def remove_reward_from_child(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() reward_id = data.get('reward_id') if not reward_id: return jsonify({'error': 'reward_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = result[0] if reward_id in child.get('rewards', []): child['rewards'].remove(reward_id) child_db.update({'rewards': child['rewards']}, ChildQuery.id == id) return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200 return jsonify({'error': 'Reward not assigned to child'}), 400 @child_api.route('/child//list-rewards', methods=['GET']) def list_child_rewards(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = result[0] reward_ids = child.get('rewards', []) RewardQuery = Query() child_rewards = [] for rid in reward_ids: reward = reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) if not reward: continue # Check for override override = get_override(id, rid) custom_value = override.custom_value if override else None cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id')) cr_dict = cr.to_dict() if custom_value is not None: cr_dict['custom_value'] = custom_value child_rewards.append(cr_dict) return jsonify({'rewards': child_rewards}), 200 @child_api.route('/child//list-assignable-rewards', methods=['GET']) def list_assignable_rewards(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = result[0] assigned_ids = set(child.get('rewards', [])) # Get all assignable rewards (not already assigned) all_rewards = [r for r in reward_db.all() if r and r.get('id') and r.get('id') not in assigned_ids] # Group by name from collections import defaultdict name_to_rewards = defaultdict(list) for r in all_rewards: name_to_rewards[r.get('name')].append(r) filtered_rewards = [] for name, rewards in name_to_rewards.items(): user_rewards = [r for r in rewards if r.get('user_id') is not None] if len(user_rewards) == 0: filtered_rewards.append(rewards[0]) elif len(user_rewards) == 1: filtered_rewards.append(user_rewards[0]) else: filtered_rewards.extend(user_rewards) assignable_rewards = [ChildReward(r.get('name'), r.get('cost'), r.get('image_id'), r.get('id')).to_dict() for r in filtered_rewards] return jsonify({'rewards': assignable_rewards, 'count': len(assignable_rewards)}), 200 @child_api.route('/child//trigger-reward', methods=['POST']) def trigger_child_reward(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() reward_id = data.get('reward_id') if not reward_id: return jsonify({'error': 'reward_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child: Child = Child.from_dict(result[0]) if reward_id not in child.rewards: return jsonify({'error': f'Reward not found assigned to child {child.name}'}), 404 # look up the task and get the details RewardQuery = Query() reward_result = reward_db.search((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) if not reward_result: return jsonify({'error': 'Reward not found in reward database'}), 404 reward: Reward = Reward.from_dict(reward_result[0]) # Check for override override = get_override(id, reward_id) cost_value = override.custom_value if override else reward.cost # Check if child has enough points logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {cost_value} points') if child.points < cost_value: points_needed = cost_value - child.points return jsonify({ 'error': 'Insufficient points', 'points_needed': points_needed, 'current_points': child.points, 'reward_cost': cost_value }), 400 # Remove matching pending reward requests for this child and reward PendingQuery = Query() removed = pending_confirmations_db.remove( (PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward.id) & (PendingQuery.entity_type == 'reward') ) if removed: send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED))) # Capture points before modification points_before = child.points # update the child's points based on reward cost child.points -= cost_value # update the child in the database child_db.update({'points': child.points}, ChildQuery.id == id) # Create tracking event tracking_metadata = { 'reward_name': reward.name, 'reward_cost': reward.cost, 'default_cost': reward.cost } if override: tracking_metadata['custom_cost'] = override.custom_value tracking_metadata['has_override'] = True tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=child.id, entity_type='reward', entity_id=reward.id, action='redeemed', points_before=points_before, points_after=child.points, metadata=tracking_metadata ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) # Send tracking event via SSE send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'redeemed'))) send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points))) return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200 @child_api.route('/child//affordable-rewards', methods=['GET']) def list_affordable_rewards(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) points = child.points reward_ids = child.rewards RewardQuery = Query() affordable = [ Reward.from_dict(reward).to_dict() for reward_id in reward_ids if (reward := reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))) and points >= Reward.from_dict(reward).cost ] return jsonify({'affordable_rewards': affordable}), 200 @child_api.route('/child//reward-status', methods=['GET']) def reward_status(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) points = child.points reward_ids = child.rewards RewardQuery = Query() statuses = [] for reward_id in reward_ids: reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) if not reward_dict: continue reward: Reward = Reward.from_dict(reward_dict) # Check for override override = get_override(id, reward_id) cost_value = override.custom_value if override else reward.cost points_needed = max(0, cost_value - points) #check to see if this reward id and child id is in the pending confirmations db pending_query = Query() pending = pending_confirmations_db.get( (pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) & (pending_query.entity_type == 'reward') & (pending_query.user_id == user_id) ) status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id) status_dict = status.to_dict() if override: status_dict['custom_value'] = override.custom_value statuses.append(status_dict) statuses.sort(key=lambda s: (not s['redeeming'], s['cost'])) return jsonify({'reward_status': statuses}), 200 @child_api.route('/child//request-reward', methods=['POST']) def request_reward(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() reward_id = data.get('reward_id') if not reward_id: return jsonify({'error': 'reward_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) if reward_id not in child.rewards: return jsonify({'error': f'Reward not assigned to child {child.name}'}), 404 RewardQuery = Query() reward_result = reward_db.search(RewardQuery.id == reward_id) if not reward_result: return jsonify({'error': 'Reward not found in reward database'}), 404 reward = Reward.from_dict(reward_result[0]) # Check if child has enough points logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points') if child.points < reward.cost: points_needed = reward.cost - child.points return jsonify({ 'error': 'Insufficient points', 'points_needed': points_needed, 'current_points': child.points, 'reward_cost': reward.cost }), 400 pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id) pending_confirmations_db.insert(pending.to_dict()) logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}') # Create tracking event (no points change on request) tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=child.id, entity_type='reward', entity_id=reward.id, action='requested', points_before=child.points, points_after=child.points, metadata={'reward_name': reward.name, 'reward_cost': reward.cost} ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) # Send tracking event via SSE send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested'))) send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED))) return jsonify({ 'message': f'Reward request for {reward.name} submitted for {child.name}.', 'reward_id': reward.id, 'reward_name': reward.name, 'child_id': child.id, 'child_name': child.name, 'cost': reward.cost }), 200 @child_api.route('/child//cancel-request-reward', methods=['POST']) def cancel_request_reward(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() reward_id = data.get('reward_id') if not reward_id: return jsonify({'error': 'reward_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) # Fetch reward details for tracking metadata RewardQuery = Query() reward_result = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) reward_name = reward_result.get('name') if reward_result else 'Unknown' reward_cost = reward_result.get('cost', 0) if reward_result else 0 # Remove matching pending reward request PendingQuery = Query() removed = pending_confirmations_db.remove( (PendingQuery.child_id == child.id) & (PendingQuery.entity_id == reward_id) & (PendingQuery.entity_type == 'reward') & (PendingQuery.user_id == user_id) ) if not removed: return jsonify({'error': 'No pending request found for this reward'}), 404 # Create tracking event (no points change on cancel) tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=child.id, entity_type='reward', entity_id=reward_id, action='cancelled', points_before=child.points, points_after=child.points, metadata={'reward_name': reward_name, 'reward_cost': reward_cost} ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) # Send tracking event via SSE send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'cancelled'))) # Notify user that the request was cancelled resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED))) if resp: return resp return jsonify({ 'message': f'Reward request cancelled for {child.name}.', 'child_id': child.id, 'reward_id': reward_id, 'removed_count': len(removed) }), 200 @child_api.route('/pending-confirmations', methods=['GET']) def list_pending_confirmations(): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 PendingQuery = Query() pending_items = pending_confirmations_db.search( (PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending') ) responses = [] RewardQuery = Query() TaskQuery = Query() ChildQuery = Query() for pr in pending_items: pending = PendingConfirmation.from_dict(pr) # Look up child details child_result = child_db.get(ChildQuery.id == pending.child_id) if not child_result: continue child = Child.from_dict(child_result) # Look up entity details based on type if pending.entity_type == 'reward': entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))) else: entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) if not entity_result: continue response = PendingConfirmationResponse( _id=pending.id, child_id=child.id, child_name=child.name, child_image_id=child.image_id, entity_id=pending.entity_id, entity_type=pending.entity_type, entity_name=entity_result.get('name'), entity_image_id=entity_result.get('image_id'), status=pending.status, approved_at=pending.approved_at ) responses.append(response.to_dict()) return jsonify({'confirmations': responses, 'count': len(responses), 'list_type': 'notification'}), 200 # --------------------------------------------------------------------------- # Chore Confirmation Endpoints # --------------------------------------------------------------------------- @child_api.route('/child//confirm-chore', methods=['POST']) def confirm_chore(id): """Child confirms they completed a chore. Creates a pending confirmation.""" user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() task_id = data.get('task_id') if not task_id: return jsonify({'error': 'task_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) if task_id not in child.tasks: return jsonify({'error': 'Task not assigned to child', 'code': 'ENTITY_NOT_ASSIGNED'}), 400 TaskQuery = Query() task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) if not task_result: return jsonify({'error': 'Task not found', 'code': 'TASK_NOT_FOUND'}), 404 task = Task.from_dict(task_result) if task.type != 'chore': return jsonify({'error': 'Only chores can be confirmed', 'code': 'INVALID_TASK_TYPE'}), 400 # Check if already pending or completed today PendingQuery = Query() existing = pending_confirmations_db.get( (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) ) if existing: if existing.get('status') == 'pending': return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400 if existing.get('status') == 'approved': approved_at = existing.get('approved_at', '') today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') if approved_at and approved_at[:10] == today_utc: return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400 confirmation = PendingConfirmation( child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id ) pending_confirmations_db.insert(confirmation.to_dict()) # Create tracking event tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, action='confirmed', points_before=child.points, points_after=child.points, metadata={'task_name': task.name, 'task_type': task.type} ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, id, 'chore', 'confirmed'))) send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED))) return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200 @child_api.route('/child//cancel-confirm-chore', methods=['POST']) def cancel_confirm_chore(id): """Child cancels their pending chore confirmation.""" user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() task_id = data.get('task_id') if not task_id: return jsonify({'error': 'task_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) PendingQuery = Query() existing = pending_confirmations_db.get( (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') & (PendingQuery.user_id == user_id) ) if not existing: return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400 pending_confirmations_db.remove( (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') & (PendingQuery.user_id == user_id) ) # Fetch task name for tracking TaskQuery = Query() task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) task_name = task_result.get('name') if task_result else 'Unknown' tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, action='cancelled', points_before=child.points, points_after=child.points, metadata={'task_name': task_name} ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, id, 'chore', 'cancelled'))) send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CANCELLED))) return jsonify({'message': 'Chore confirmation cancelled.'}), 200 @child_api.route('/child//approve-chore', methods=['POST']) def approve_chore(id): """Parent approves a pending chore confirmation, awarding points.""" user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() task_id = data.get('task_id') if not task_id: return jsonify({'error': 'task_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) PendingQuery = Query() existing = pending_confirmations_db.get( (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') & (PendingQuery.user_id == user_id) ) if not existing: return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400 TaskQuery = Query() task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) if not task_result: return jsonify({'error': 'Task not found'}), 404 task = Task.from_dict(task_result) # Award points override = get_override(id, task_id) points_value = override.custom_value if override else task.points points_before = child.points child.points += points_value child_db.update({'points': child.points}, ChildQuery.id == id) # Update confirmation to approved now_str = datetime.now(timezone.utc).isoformat() pending_confirmations_db.update( {'status': 'approved', 'approved_at': now_str}, (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) ) tracking_metadata = { 'task_name': task.name, 'task_type': task.type, 'default_points': task.points } if override: tracking_metadata['custom_points'] = override.custom_value tracking_metadata['has_override'] = True tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, action='approved', points_before=points_before, points_after=child.points, metadata=tracking_metadata ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, id, 'chore', 'approved'))) send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED))) send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task_id, id, child.points))) return jsonify({ 'message': f'Chore {task.name} approved for {child.name}.', 'points': child.points, 'id': child.id }), 200 @child_api.route('/child//reject-chore', methods=['POST']) def reject_chore(id): """Parent rejects a pending chore confirmation.""" user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() task_id = data.get('task_id') if not task_id: return jsonify({'error': 'task_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) PendingQuery = Query() existing = pending_confirmations_db.get( (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') & (PendingQuery.user_id == user_id) ) if not existing: return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400 pending_confirmations_db.remove( (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) ) TaskQuery = Query() task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) task_name = task_result.get('name') if task_result else 'Unknown' tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, action='rejected', points_before=child.points, points_after=child.points, metadata={'task_name': task_name} ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, id, 'chore', 'rejected'))) send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_REJECTED))) return jsonify({'message': 'Chore confirmation rejected.'}), 200 @child_api.route('/child//reset-chore', methods=['POST']) def reset_chore(id): """Parent resets a completed chore so the child can confirm again.""" user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401 data = request.get_json() task_id = data.get('task_id') if not task_id: return jsonify({'error': 'task_id is required'}), 400 ChildQuery = Query() result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id)) if not result: return jsonify({'error': 'Child not found'}), 404 child = Child.from_dict(result[0]) PendingQuery = Query() existing = pending_confirmations_db.get( (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'approved') & (PendingQuery.user_id == user_id) ) if not existing: return jsonify({'error': 'No completed confirmation found to reset', 'code': 'PENDING_NOT_FOUND'}), 400 pending_confirmations_db.remove( (PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) & (PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id) ) TaskQuery = Query() task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))) task_name = task_result.get('name') if task_result else 'Unknown' tracking_event = TrackingEvent.create_event( user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id, action='reset', points_before=child.points, points_after=child.points, metadata={'task_name': task_name} ) insert_tracking_event(tracking_event) log_tracking_event(tracking_event) send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, id, 'chore', 'reset'))) send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value, ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_RESET))) return jsonify({'message': 'Chore reset to available.'}), 200