feat: Implement routines feature with CRUD operations and child assignment
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 3m0s

- Add backend routines management with add, get, update, delete, and list functionalities.
- Create models for Routine, RoutineItem, RoutineSchedule, and RoutineExtension.
- Develop event types for routine confirmation and modification.
- Implement frontend components for routine assignment, confirmation dialog, and routine management views.
- Add unit tests for routine API and integration tests for routine CRUD flow.
- Create end-to-end test plan for routines feature covering parent and child interactions.
This commit is contained in:
2026-05-05 09:08:19 -04:00
parent 082097b4f9
commit eb775ba7d8
41 changed files with 2847 additions and 29 deletions

View File

@@ -0,0 +1,528 @@
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/<id>/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/<id>/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/<id>/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/<id>/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/<id>/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/<id>/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/<id>/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/<id>/approve-routine/<confirmation_id>', 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/<id>/reject-routine/<confirmation_id>', 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/<id>/reset-routine/<confirmation_id>', 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