from collections import defaultdict from datetime import datetime, timezone from flask import Blueprint, request, jsonify from tinydb import Query from api.error_codes import ErrorCodes from api.utils import get_validated_user_id, send_event_for_current_user from db.child_overrides import delete_override, get_override from db.db import child_db, pending_confirmations_db, routine_db, users_db from db.routine_extensions import delete_extension_for_child_routine, get_extension_for_child_routine from db.routine_items import get_items_for_routine from db.routine_schedules import delete_schedule, get_schedule from events.types.child_routine_confirmation import ChildRoutineConfirmation from events.types.child_routines_set import ChildRoutinesSet 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.routine import Routine from utils.digest_token import create_action_token from utils.push_sender import send_push_to_user child_routine_api = Blueprint('child_routine_api', __name__) def _is_iso_timestamp_today_utc(timestamp: str | None, today_utc: str) -> bool: return bool(timestamp) and timestamp[:10] == today_utc def _is_epoch_timestamp_today_utc(epoch_ts, today_utc: str) -> bool: if epoch_ts is None: return False try: return datetime.fromtimestamp(float(epoch_ts), timezone.utc).strftime('%Y-%m-%d') == today_utc except (TypeError, ValueError, OSError): return False class ChildRoutine: def __init__(self, name, points, image_id, _id): self.id = _id self.name = name self.points = points self.image_id = image_id def to_dict(self): return { 'id': self.id, 'name': self.name, 'points': self.points, 'image_id': self.image_id, } def _validate_child_for_user(child_id: str, user_id: str): child_q = Query() result = child_db.search((child_q.id == child_id) & (child_q.user_id == user_id)) return Child.from_dict(result[0]) if result else None def _resolve_routine_for_user(routine_id: str, user_id: str): routine_q = Query() routine_result = routine_db.get( (routine_q.id == routine_id) & ((routine_q.user_id == user_id) | (routine_q.user_id == None)) ) return Routine.from_dict(routine_result) if routine_result else None @child_routine_api.route('/child//assign-routine', methods=['POST']) def assign_routine_to_child(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 data = request.get_json() or {} routine_id = data.get('routine_id') if not routine_id: return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 routine = _resolve_routine_for_user(routine_id, user_id) if not routine: return jsonify({'error': 'Routine not found'}), 404 routine_ids = list(child.routines) if routine_id not in routine_ids: routine_ids.append(routine_id) child_db.update({'routines': routine_ids}, Query().id == id) send_event_for_current_user( Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids)) ) return jsonify({'message': f'Routine {routine_id} assigned to {child.name}.'}), 200 @child_routine_api.route('/child//remove-routine', methods=['POST']) def remove_routine_from_child(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 data = request.get_json() or {} routine_id = data.get('routine_id') if not routine_id: return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 routine_ids = list(child.routines) if routine_id not in routine_ids: return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400 routine_ids.remove(routine_id) child_db.update({'routines': routine_ids}, Query().id == id) override = get_override(id, routine_id) if override and override.entity_type == 'routine': delete_override(id, routine_id) delete_schedule(id, routine_id) delete_extension_for_child_routine(id, routine_id) pending_q = Query() pending_confirmations_db.remove( (pending_q.child_id == id) & (pending_q.entity_id == routine_id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id) ) send_event_for_current_user( Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids)) ) send_event_for_current_user( Event( EventType.CHILD_ROUTINE_CONFIRMATION.value, ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET) ) ) return jsonify({'message': f'Routine {routine_id} removed from {child.name}.'}), 200 @child_routine_api.route('/child//set-routines', methods=['PUT']) def set_child_routines(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 data = request.get_json() or {} routine_ids = data.get('routine_ids') if not isinstance(routine_ids, list): return jsonify({'error': 'routine_ids must be a list'}), 400 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 routine_q = Query() valid_ids = [] for rid in dict.fromkeys(routine_ids): if routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))): valid_ids.append(rid) old_ids = set(child.routines) new_ids = set(valid_ids) unassigned_ids = old_ids - new_ids pending_q = Query() for rid in unassigned_ids: override = get_override(id, rid) if override and override.entity_type == 'routine': delete_override(id, rid) delete_schedule(id, rid) delete_extension_for_child_routine(id, rid) pending_confirmations_db.remove( (pending_q.child_id == id) & (pending_q.entity_id == rid) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id) ) child_db.update({'routines': valid_ids}, Query().id == id) send_event_for_current_user(Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, valid_ids))) return jsonify({'message': f'Routines set for child {id}.', 'routine_ids': valid_ids, 'count': len(valid_ids)}), 200 @child_routine_api.route('/child//list-routines', methods=['GET']) def list_child_routines(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 routine_q = Query() pending_q = Query() today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') child_routines = [] for rid in child.routines: routine_record = routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))) if not routine_record: continue routine = Routine.from_dict(routine_record) override = get_override(id, rid) custom_value = override.custom_value if override and override.entity_type == 'routine' else None cr = ChildRoutine(routine.name, routine.points, routine.image_id, routine.id) cr_dict = cr.to_dict() if custom_value is not None: cr_dict['custom_value'] = custom_value schedule = get_schedule(id, rid) cr_dict['schedule'] = schedule.to_dict() if schedule else None extension = get_extension_for_child_routine(id, rid) cr_dict['extension_date'] = extension.date if extension else None items = get_items_for_routine(rid) cr_dict['items'] = [item.to_dict() for item in items] pending = pending_confirmations_db.get( (pending_q.child_id == id) & (pending_q.entity_id == rid) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id) ) if pending: status = pending.get('status') approved_at = pending.get('approved_at') created_at = pending.get('created_at') if status == 'approved' and _is_iso_timestamp_today_utc(approved_at, today_utc): cr_dict['pending_status'] = 'approved' cr_dict['approved_at'] = approved_at elif status == 'pending' and _is_epoch_timestamp_today_utc(created_at, today_utc): cr_dict['pending_status'] = 'pending' cr_dict['approved_at'] = None else: pending_id = pending.get('id') if pending_id: pending_confirmations_db.remove(pending_q.id == pending_id) cr_dict['pending_status'] = None cr_dict['approved_at'] = None else: cr_dict['pending_status'] = None cr_dict['approved_at'] = None child_routines.append(cr_dict) return jsonify({'routines': child_routines}), 200 @child_routine_api.route('/child//list-assignable-routines', methods=['GET']) def list_assignable_routines(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 assigned_ids = set(child.routines) all_routines = [r for r in routine_db.all() if r and r.get('id') and r.get('id') not in assigned_ids] name_to_routines = defaultdict(list) for routine in all_routines: name_to_routines[routine.get('name')].append(routine) filtered_routines = [] for _, routines in name_to_routines.items(): user_routines = [r for r in routines if r.get('user_id') is not None] if len(user_routines) == 0: filtered_routines.append(routines[0]) elif len(user_routines) == 1: filtered_routines.append(user_routines[0]) else: filtered_routines.extend(user_routines) assignable = [ ChildRoutine(r.get('name'), r.get('points'), r.get('image_id'), r.get('id')).to_dict() for r in filtered_routines ] return jsonify({'routines': assignable, 'count': len(assignable)}), 200 @child_routine_api.route('/child//confirm-routine', methods=['POST']) def confirm_routine(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 data = request.get_json() or {} routine_id = data.get('routine_id') if not routine_id: return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 if routine_id not in child.routines: return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400 routine = _resolve_routine_for_user(routine_id, user_id) if not routine: return jsonify({'error': 'Routine not found'}), 404 pending_q = Query() existing = pending_confirmations_db.get( (pending_q.child_id == id) & (pending_q.entity_id == routine_id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id) ) if existing: today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d') if existing.get('status') == 'pending': if _is_epoch_timestamp_today_utc(existing.get('created_at'), today_utc): return jsonify({'error': 'Routine already pending confirmation', 'code': 'ROUTINE_ALREADY_PENDING'}), 400 pending_id = existing.get('id') if pending_id: pending_confirmations_db.remove(pending_q.id == pending_id) if existing.get('status') == 'approved': approved_at = existing.get('approved_at', '') if _is_iso_timestamp_today_utc(approved_at, today_utc): return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400 pending_id = existing.get('id') if pending_id: pending_confirmations_db.remove(pending_q.id == pending_id) confirmation = PendingConfirmation( child_id=id, entity_id=routine_id, entity_type='routine', user_id=user_id, ) pending_confirmations_db.insert(confirmation.to_dict()) send_event_for_current_user( Event( EventType.CHILD_ROUTINE_CONFIRMATION.value, ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_PENDING) ) ) push_user = users_db.get(Query().id == user_id) if push_user and push_user.get('push_notifications_enabled', True): try: approve_token = create_action_token(user_id, id, routine_id, 'routine', 'approve') deny_token = create_action_token(user_id, id, routine_id, 'routine', 'deny') push_payload = { 'type': 'routine_confirmed', 'title': 'Routine Pending', 'body': f'{child.name} completed {routine.name}', 'user_id': user_id, 'child_id': id, 'child_name': child.name, 'entity_id': routine_id, 'entity_type': 'routine', 'entity_name': routine.name, 'approve_token': approve_token.id, 'deny_token': deny_token.id, } send_push_to_user(user_id, push_payload) except Exception: pass return jsonify({'message': f'Routine {routine.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200 @child_routine_api.route('/child//cancel-routine-confirmation', methods=['POST']) def cancel_routine_confirmation(id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 data = request.get_json() or {} routine_id = data.get('routine_id') if not routine_id: return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 pending_q = Query() existing = pending_confirmations_db.get( (pending_q.child_id == id) & (pending_q.entity_id == routine_id) & (pending_q.entity_type == 'routine') & (pending_q.status == 'pending') & (pending_q.user_id == user_id) ) if not existing: return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400 pending_confirmations_db.remove( (pending_q.child_id == id) & (pending_q.entity_id == routine_id) & (pending_q.entity_type == 'routine') & (pending_q.status == 'pending') & (pending_q.user_id == user_id) ) send_event_for_current_user( Event( EventType.CHILD_ROUTINE_CONFIRMATION.value, ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET) ) ) return jsonify({'message': 'Routine confirmation cancelled.'}), 200 @child_routine_api.route('/child//approve-routine/', methods=['POST']) def approve_routine(id, confirmation_id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 pending_q = Query() confirmation = pending_confirmations_db.get( (pending_q.id == confirmation_id) & (pending_q.child_id == id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id) ) if not confirmation: return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404 if confirmation.get('status') != 'pending': return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400 routine_id = confirmation.get('entity_id') routine = _resolve_routine_for_user(routine_id, user_id) if not routine: return jsonify({'error': 'Routine not found'}), 404 override = get_override(id, routine_id) points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points new_points = max(0, child.points + points_value) child_db.update({'points': new_points}, Query().id == id) approved_at = datetime.now(timezone.utc).isoformat() pending_confirmations_db.update( {'status': 'approved', 'approved_at': approved_at}, pending_q.id == confirmation_id ) send_event_for_current_user( Event( EventType.CHILD_ROUTINE_CONFIRMATION.value, ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED) ) ) return jsonify({ 'message': f'Routine {routine.name} approved for {child.name}.', 'points': new_points, 'id': child.id, }), 200 @child_routine_api.route('/child//reject-routine/', methods=['POST']) def reject_routine(id, confirmation_id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 pending_q = Query() confirmation = pending_confirmations_db.get( (pending_q.id == confirmation_id) & (pending_q.child_id == id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id) ) if not confirmation: return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404 if confirmation.get('status') != 'pending': return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400 pending_confirmations_db.update({'status': 'rejected', 'approved_at': None}, pending_q.id == confirmation_id) send_event_for_current_user( Event( EventType.CHILD_ROUTINE_CONFIRMATION.value, ChildRoutineConfirmation(id, confirmation.get('entity_id'), ChildRoutineConfirmation.OPERATION_REJECTED) ) ) return jsonify({'message': 'Routine confirmation rejected.'}), 200 @child_routine_api.route('/child//reset-routine/', methods=['POST']) def reset_routine(id, confirmation_id): user_id = get_validated_user_id() if not user_id: return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401 child = _validate_child_for_user(id, user_id) if not child: return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404 pending_q = Query() confirmation = pending_confirmations_db.get( (pending_q.id == confirmation_id) & (pending_q.child_id == id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id) ) if not confirmation: return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404 routine_id = confirmation.get('entity_id') pending_confirmations_db.remove(pending_q.id == confirmation_id) send_event_for_current_user( Event( EventType.CHILD_ROUTINE_CONFIRMATION.value, ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET) ) ) return jsonify({'message': 'Routine reset to available.'}), 200