feat: add chore, kindness, and penalty management components
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m34s

- Implemented ChoreAssignView for assigning chores to children.
- Created ChoreConfirmDialog for confirming chore completion.
- Developed KindnessAssignView for assigning kindness acts.
- Added PenaltyAssignView for assigning penalties.
- Introduced ChoreEditView and ChoreView for editing and viewing chores.
- Created KindnessEditView and KindnessView for managing kindness acts.
- Developed PenaltyEditView and PenaltyView for managing penalties.
- Added TaskSubNav for navigation between chores, kindness acts, and penalties.
This commit is contained in:
2026-02-28 11:25:56 -05:00
parent 65e987ceb6
commit d7316bb00a
61 changed files with 7364 additions and 647 deletions

View File

@@ -1,16 +1,18 @@
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_reward import PendingReward as PendingRewardResponse
from api.pending_confirmation import PendingConfirmationResponse
from api.reward_status import RewardStatus
from api.utils import send_event_for_current_user
from db.db import child_db, task_db, reward_db, pending_reward_db
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
@@ -21,16 +23,15 @@ 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 api.utils import get_validated_user_id
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
from datetime import date as date_type
import logging
child_api = Blueprint('child_api', __name__)
@@ -98,18 +99,22 @@ def edit_child(id):
# Check if points changed and handle pending rewards
if points is not None:
PendingQuery = Query()
pending_rewards = pending_reward_db.search((PendingQuery.child_id == id) & (PendingQuery.user_id == user_id))
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 = PendingReward.from_dict(pr)
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
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_reward_db.remove(
(PendingQuery.child_id == id) & (PendingQuery.reward_id == reward.id) & (PendingQuery.user_id == user_id)
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)))
@@ -180,11 +185,10 @@ def set_child_tasks(id):
data = request.get_json() or {}
task_ids = data.get('task_ids')
if 'type' not in data:
return jsonify({'error': 'type is required (good or bad)'}), 400
task_type = data.get('type', 'good')
if task_type not in ['good', 'bad']:
return jsonify({'error': 'type must be either good or bad'}), 400
is_good = task_type == 'good'
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
@@ -195,10 +199,11 @@ def set_child_tasks(id):
child = Child.from_dict(result[0])
new_task_ids = set(task_ids)
# Add all existing child tasks of the opposite type
for task in task_db.all():
if task['id'] in child.tasks and task['is_good'] != is_good:
new_task_ids.add(task['id'])
# 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)
@@ -268,25 +273,42 @@ def list_child_tasks(id):
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.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
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 (client does all time math)
if task.get('is_good'):
# 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)
@@ -328,7 +350,7 @@ def list_assignable_tasks(id):
filtered_tasks.extend(user_tasks)
# Wrap in ChildTask and return
assignable_tasks = [ChildTask(t.get('name'), t.get('is_good'), t.get('points'), t.get('image_id'), t.get('id')).to_dict() for t in filtered_tasks]
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
@@ -342,9 +364,9 @@ def list_all_tasks(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 ['good', 'bad']:
return jsonify({'error': 'type must be either good or bad'}), 400
good = request.args.get('type', False) == 'good'
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', []))
@@ -368,14 +390,15 @@ def list_all_tasks(id):
result_tasks = []
for t in filtered_tasks:
if has_type and t.get('is_good') != good:
task_obj = Task.from_dict(t)
if has_type and task_obj.type != filter_type:
continue
ct = ChildTask(
t.get('name'),
t.get('is_good'),
t.get('points'),
t.get('image_id'),
t.get('id')
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})
@@ -427,11 +450,28 @@ def trigger_child_task(id):
# 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 = 'penalty' if not task.is_good else 'task'
entity_type = task.type
tracking_metadata = {
'task_name': task.name,
'is_good': task.is_good,
'task_type': task.type,
'default_points': task.points
}
if override:
@@ -709,8 +749,9 @@ def trigger_child_reward(id):
# Remove matching pending reward requests for this child and reward
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward.id)
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)))
@@ -799,9 +840,12 @@ def reward_status(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 rewards db if so set its redeeming flag to true
#check to see if this reward id and child id is in the pending confirmations db
pending_query = Query()
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id) & (pending_query.user_id == user_id))
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:
@@ -849,8 +893,8 @@ def request_reward(id):
'reward_cost': reward.cost
}), 400
pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id)
pending_reward_db.insert(pending.to_dict())
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)
@@ -905,8 +949,9 @@ def cancel_request_reward(id):
# Remove matching pending reward request
PendingQuery = Query()
removed = pending_reward_db.remove(
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id) & (PendingQuery.user_id == user_id)
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:
@@ -942,26 +987,23 @@ def cancel_request_reward(id):
@child_api.route('/pending-rewards', methods=['GET'])
def list_pending_rewards():
@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_rewards = pending_reward_db.search(PendingQuery.user_id == user_id)
reward_responses = []
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_rewards:
pending = PendingReward.from_dict(pr)
# Look up reward details
reward_result = reward_db.get((RewardQuery.id == pending.reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward_result:
continue
reward = Reward.from_dict(reward_result)
for pr in pending_items:
pending = PendingConfirmation.from_dict(pr)
# Look up child details
child_result = child_db.get(ChildQuery.id == pending.child_id)
@@ -969,17 +1011,326 @@ def list_pending_rewards():
continue
child = Child.from_dict(child_result)
# Create response object
response = PendingRewardResponse(
# 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,
reward_id=reward.id,
reward_name=reward.name,
reward_image_id=reward.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
)
reward_responses.append(response.to_dict())
responses.append(response.to_dict())
return jsonify({'rewards': reward_responses, 'count': len(reward_responses), 'list_type': 'notification'}), 200
return jsonify({'confirmations': responses, 'count': len(responses), 'list_type': 'notification'}), 200
# ---------------------------------------------------------------------------
# Chore Confirmation Endpoints
# ---------------------------------------------------------------------------
@child_api.route('/child/<id>/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/<id>/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/<id>/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/<id>/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/<id>/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

View File

@@ -1,8 +1,8 @@
class ChildTask:
def __init__(self, name, is_good, points, image_id, id):
def __init__(self, name, task_type, points, image_id, id):
self.id = id
self.name = name
self.is_good = is_good
self.type = task_type
self.points = points
self.image_id = image_id
@@ -10,7 +10,7 @@ class ChildTask:
return {
'id': self.id,
'name': self.name,
'is_good': self.is_good,
'type': self.type,
'points': self.points,
'image_id': self.image_id
}

165
backend/api/chore_api.py Normal file
View File

@@ -0,0 +1,165 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
chore_api = Blueprint('chore_api', __name__)
TASK_TYPE = 'chore'
@chore_api.route('/chore/add', methods=['PUT'])
def add_chore():
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')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Chore {name} added.'}), 201
@chore_api.route('/chore/<id>', methods=['GET'])
def get_chore(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not result:
return jsonify({'error': 'Chore not found'}), 404
return jsonify(result[0]), 200
@chore_api.route('/chore/list', methods=['GET'])
def list_chores():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
tasks = task_db.search(
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue
filtered_tasks.append(t)
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(filtered_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@chore_api.route('/chore/<id>', methods=['DELETE'])
def delete_chore(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
if not task:
return jsonify({'error': 'Chore not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system chore: id={id}, by user_id={user_id}")
return jsonify({'error': 'System chores cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for chore {id}")
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Chore {id} deleted.'}), 200
return jsonify({'error': 'Chore not found'}), 404
@chore_api.route('/chore/<id>/edit', methods=['PUT'])
def edit_chore(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not existing:
return jsonify({'error': 'Chore not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None:
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200

View File

@@ -26,3 +26,9 @@ class ErrorCodes:
INVALID_VALUE = "INVALID_VALUE"
VALIDATION_ERROR = "VALIDATION_ERROR"
INTERNAL_ERROR = "INTERNAL_ERROR"
CHORE_EXPIRED = "CHORE_EXPIRED"
CHORE_ALREADY_PENDING = "CHORE_ALREADY_PENDING"
CHORE_ALREADY_COMPLETED = "CHORE_ALREADY_COMPLETED"
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"

165
backend/api/kindness_api.py Normal file
View File

@@ -0,0 +1,165 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
kindness_api = Blueprint('kindness_api', __name__)
TASK_TYPE = 'kindness'
@kindness_api.route('/kindness/add', methods=['PUT'])
def add_kindness():
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')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Kindness {name} added.'}), 201
@kindness_api.route('/kindness/<id>', methods=['GET'])
def get_kindness(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not result:
return jsonify({'error': 'Kindness act not found'}), 404
return jsonify(result[0]), 200
@kindness_api.route('/kindness/list', methods=['GET'])
def list_kindness():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
tasks = task_db.search(
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue
filtered_tasks.append(t)
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(filtered_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@kindness_api.route('/kindness/<id>', methods=['DELETE'])
def delete_kindness(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
if not task:
return jsonify({'error': 'Kindness act not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system kindness: id={id}, by user_id={user_id}")
return jsonify({'error': 'System kindness acts cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for kindness {id}")
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Kindness {id} deleted.'}), 200
return jsonify({'error': 'Kindness act not found'}), 404
@kindness_api.route('/kindness/<id>/edit', methods=['PUT'])
def edit_kindness(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not existing:
return jsonify({'error': 'Kindness act not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None:
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200

165
backend/api/penalty_api.py Normal file
View File

@@ -0,0 +1,165 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_tasks_set import ChildTasksSet
from db.db import task_db, child_db
from db.child_overrides import delete_overrides_for_entity
from events.types.event import Event
from events.types.event_types import EventType
from events.types.task_modified import TaskModified
from models.task import Task
penalty_api = Blueprint('penalty_api', __name__)
TASK_TYPE = 'penalty'
@penalty_api.route('/penalty/add', methods=['PUT'])
def add_penalty():
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')
points = data.get('points')
image = data.get('image_id', '')
if not name or points is None:
return jsonify({'error': 'Name and points are required'}), 400
task = Task(name=name, points=points, type=TASK_TYPE, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
return jsonify({'message': f'Penalty {name} added.'}), 201
@penalty_api.route('/penalty/<id>', methods=['GET'])
def get_penalty(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
result = task_db.search(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not result:
return jsonify({'error': 'Penalty not found'}), 404
return jsonify(result[0]), 200
@penalty_api.route('/penalty/list', methods=['GET'])
def list_penalties():
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
tasks = task_db.search(
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
user_tasks = {t['name'].strip().lower(): t for t in tasks if t.get('user_id') == user_id}
filtered_tasks = []
for t in tasks:
if t.get('user_id') is None and t['name'].strip().lower() in user_tasks:
continue
filtered_tasks.append(t)
def sort_user_then_default(tasks_group):
user_created = sorted(
[t for t in tasks_group if t.get('user_id') == user_id],
key=lambda x: x['name'].lower(),
)
default_items = sorted(
[t for t in tasks_group if t.get('user_id') is None],
key=lambda x: x['name'].lower(),
)
return user_created + default_items
sorted_tasks = sort_user_then_default(filtered_tasks)
return jsonify({'tasks': sorted_tasks}), 200
@penalty_api.route('/penalty/<id>', methods=['DELETE'])
def delete_penalty(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
task = task_db.get((TaskQuery.id == id) & (TaskQuery.type == TASK_TYPE))
if not task:
return jsonify({'error': 'Penalty not found'}), 404
if task.get('user_id') is None:
import logging
logging.warning(f"Forbidden delete attempt on system penalty: id={id}, by user_id={user_id}")
return jsonify({'error': 'System penalties cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for penalty {id}")
ChildQuery = Query()
for child in child_db.all():
child_tasks = child.get('tasks', [])
if id in child_tasks:
child_tasks.remove(id)
child_db.update({'tasks': child_tasks}, ChildQuery.id == child.get('id'))
send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, child_tasks)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value, TaskModified(id, TaskModified.OPERATION_DELETE)))
return jsonify({'message': f'Penalty {id} deleted.'}), 200
return jsonify({'error': 'Penalty not found'}), 404
@penalty_api.route('/penalty/<id>/edit', methods=['PUT'])
def edit_penalty(id):
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
TaskQuery = Query()
existing = task_db.get(
(TaskQuery.id == id) &
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
(TaskQuery.type == TASK_TYPE)
)
if not existing:
return jsonify({'error': 'Penalty not found'}), 404
task = Task.from_dict(existing)
is_dirty = False
data = request.get_json(force=True) or {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
task.name = name
is_dirty = True
if 'points' in data:
points = data.get('points')
if not isinstance(points, int) or points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
task.points = points
is_dirty = True
if 'image_id' in data:
task.image_id = data.get('image_id', '')
is_dirty = True
if not is_dirty:
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None:
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))
return jsonify(new_task.to_dict()), 200
task_db.update(task.to_dict(), (TaskQuery.id == id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(id, TaskModified.OPERATION_EDIT)))
return jsonify(task.to_dict()), 200

View File

@@ -0,0 +1,29 @@
class PendingConfirmationResponse:
"""Response DTO for hydrated pending confirmation data."""
def __init__(self, _id, child_id, child_name, child_image_id,
entity_id, entity_type, entity_name, entity_image_id,
status='pending', approved_at=None):
self.id = _id
self.child_id = child_id
self.child_name = child_name
self.child_image_id = child_image_id
self.entity_id = entity_id
self.entity_type = entity_type
self.entity_name = entity_name
self.entity_image_id = entity_image_id
self.status = status
self.approved_at = approved_at
def to_dict(self):
return {
'id': self.id,
'child_id': self.child_id,
'child_name': self.child_name,
'child_image_id': self.child_image_id,
'entity_id': self.entity_id,
'entity_type': self.entity_type,
'entity_name': self.entity_name,
'entity_image_id': self.entity_image_id,
'status': self.status,
'approved_at': self.approved_at
}

View File

@@ -21,11 +21,16 @@ def add_task():
data = request.get_json()
name = data.get('name')
points = data.get('points')
is_good = data.get('is_good')
task_type = data.get('type')
# Support legacy is_good field
if task_type is None and 'is_good' in data:
task_type = 'chore' if data['is_good'] else 'penalty'
image = data.get('image_id', '')
if not name or points is None or is_good is None:
return jsonify({'error': 'Name, points, and is_good are required'}), 400
task = Task(name=name, points=points, is_good=is_good, image_id=image, user_id=user_id)
if not name or points is None or task_type is None:
return jsonify({'error': 'Name, points, and type are required'}), 400
if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
task = Task(name=name, points=points, type=task_type, image_id=image, user_id=user_id)
task_db.insert(task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(task.id, TaskModified.OPERATION_ADD)))
@@ -65,10 +70,10 @@ def list_tasks():
filtered_tasks.append(t)
# Sort order:
# 1) good tasks first, then not-good tasks
# 1) chore/kindness first, then penalties
# 2) within each group: user-created items first (by name), then default items (by name)
good_tasks = [t for t in filtered_tasks if t.get('is_good') is True]
not_good_tasks = [t for t in filtered_tasks if t.get('is_good') is not True]
good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type != 'penalty']
not_good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type == 'penalty']
def sort_user_then_default(tasks_group):
user_created = sorted(
@@ -154,7 +159,15 @@ def edit_task(id):
is_good = data.get('is_good')
if not isinstance(is_good, bool):
return jsonify({'error': 'is_good must be a boolean'}), 400
task.is_good = is_good
# Convert to type
task.type = 'chore' if is_good else 'penalty'
is_dirty = True
if 'type' in data:
task_type = data.get('type')
if task_type not in ['chore', 'kindness', 'penalty']:
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
task.type = task_type
is_dirty = True
if 'image_id' in data:
@@ -165,7 +178,7 @@ def edit_task(id):
return jsonify({'error': 'No valid fields to update'}), 400
if task.user_id is None: # public task
new_task = Task(name=task.name, points=task.points, is_good=task.is_good, image_id=task.image_id, user_id=user_id)
new_task = Task(name=task.name, points=task.points, type=task.type, image_id=task.image_id, user_id=user_id)
task_db.insert(new_task.to_dict())
send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
TaskModified(new_task.id, TaskModified.OPERATION_ADD)))

View File

@@ -0,0 +1,144 @@
{
"_default": {
"1": {
"id": "57c21328-637e-4df3-be5b-7f619cbf4076",
"created_at": 1771343995.1881204,
"updated_at": 1771343995.188121,
"name": "Take out trash",
"points": 20,
"is_good": true,
"image_id": "trash-can",
"user_id": null
},
"2": {
"id": "70316500-e4ce-4399-8e4b-86a4046fafcb",
"created_at": 1771343995.1881304,
"updated_at": 1771343995.1881304,
"name": "Make your bed",
"points": 25,
"is_good": true,
"image_id": "make-the-bed",
"user_id": null
},
"3": {
"id": "71afb2e5-18de-4f99-9e1e-2f4e391e6c2c",
"created_at": 1771343995.1881359,
"updated_at": 1771343995.1881359,
"name": "Sweep and clean kitchen",
"points": 15,
"is_good": true,
"image_id": "vacuum",
"user_id": null
},
"4": {
"id": "e0aae53d-d4b6-4203-b910-004917db6003",
"created_at": 1771343995.1881409,
"updated_at": 1771343995.188141,
"name": "Do homework early",
"points": 30,
"is_good": true,
"image_id": "homework",
"user_id": null
},
"5": {
"id": "0ba544f6-2d61-4009-af8f-bcb4e94b7a11",
"created_at": 1771343995.188146,
"updated_at": 1771343995.188146,
"name": "Be good for the day",
"points": 15,
"is_good": true,
"image_id": "good",
"user_id": null
},
"6": {
"id": "8b5750d4-5a58-40cb-a31b-667569069d34",
"created_at": 1771343995.1881511,
"updated_at": 1771343995.1881511,
"name": "Clean your mess",
"points": 20,
"is_good": true,
"image_id": "broom",
"user_id": null
},
"7": {
"id": "aec5fb49-06d0-43c4-aa09-9583064b7275",
"created_at": 1771343995.1881557,
"updated_at": 1771343995.1881557,
"name": "Fighting",
"points": 10,
"is_good": false,
"image_id": "fighting",
"user_id": null
},
"8": {
"id": "0221ab72-c6c0-429f-a5f1-bc3d843fce9e",
"created_at": 1771343995.1881602,
"updated_at": 1771343995.1881602,
"name": "Yelling at parents",
"points": 10,
"is_good": false,
"image_id": "yelling",
"user_id": null
},
"9": {
"id": "672bfc74-4b85-4e8e-a2d0-74f14ab966cc",
"created_at": 1771343995.1881647,
"updated_at": 1771343995.1881647,
"name": "Lying",
"points": 10,
"is_good": false,
"image_id": "lying",
"user_id": null
},
"10": {
"id": "d8cc254f-922b-4dc2-ac4c-32fc3bbda584",
"created_at": 1771343995.1881692,
"updated_at": 1771343995.1881695,
"name": "Not doing what told",
"points": 5,
"is_good": false,
"image_id": "ignore",
"user_id": null
},
"11": {
"id": "8be18d9a-48e6-402b-a0ba-630a2d50e325",
"created_at": 1771343995.188174,
"updated_at": 1771343995.188174,
"name": "Not flushing toilet",
"points": 5,
"is_good": false,
"image_id": "toilet",
"user_id": null
},
"12": {
"id": "b3b44115-529b-4eb3-9f8b-686dd24547a1",
"created_at": 1771345063.4665146,
"updated_at": 1771345063.4665148,
"name": "Take out trash",
"points": 21,
"is_good": true,
"image_id": "trash-can",
"user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246"
},
"13": {
"id": "c74fc8c7-5af1-4d40-afbb-6da2647ca18b",
"created_at": 1771345069.1633172,
"updated_at": 1771345069.1633174,
"name": "aaa",
"points": 1,
"is_good": true,
"image_id": "computer-game",
"user_id": "a5f05d38-7f7c-4663-b00f-3d6138e0e246"
},
"14": {
"id": "65e79bbd-6cdf-4636-9e9d-f608206dbd80",
"created_at": 1772251855.4823341,
"updated_at": 1772251855.4823341,
"name": "Be Cool \ud83d\ude0e",
"points": 5,
"type": "kindness",
"image_id": "58d4adb9-3cee-4d7c-8e90-d81173716ce5",
"user_id": "6da06108-0db8-46be-b4cb-60ce7b54564d"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,7 @@ task_path = os.path.join(base_dir, 'tasks.json')
reward_path = os.path.join(base_dir, 'rewards.json')
image_path = os.path.join(base_dir, 'images.json')
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
pending_confirmations_path = os.path.join(base_dir, 'pending_confirmations.json')
users_path = os.path.join(base_dir, 'users.json')
tracking_events_path = os.path.join(base_dir, 'tracking_events.json')
child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
@@ -84,6 +85,7 @@ _task_db = TinyDB(task_path, indent=2)
_reward_db = TinyDB(reward_path, indent=2)
_image_db = TinyDB(image_path, indent=2)
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
_pending_confirmations_db = TinyDB(pending_confirmations_path, indent=2)
_users_db = TinyDB(users_path, indent=2)
_tracking_events_db = TinyDB(tracking_events_path, indent=2)
_child_overrides_db = TinyDB(child_overrides_path, indent=2)
@@ -96,6 +98,7 @@ task_db = LockedTable(_task_db)
reward_db = LockedTable(_reward_db)
image_db = LockedTable(_image_db)
pending_reward_db = LockedTable(_pending_rewards_db)
pending_confirmations_db = LockedTable(_pending_confirmations_db)
users_db = LockedTable(_users_db)
tracking_events_db = LockedTable(_tracking_events_db)
child_overrides_db = LockedTable(_child_overrides_db)
@@ -108,6 +111,7 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
reward_db.truncate()
image_db.truncate()
pending_reward_db.truncate()
pending_confirmations_db.truncate()
users_db.truncate()
tracking_events_db.truncate()
child_overrides_db.truncate()

View File

@@ -15,16 +15,16 @@ from models.task import Task
def populate_default_data():
# Create tasks
task_defs = [
('default_001', "Be Respectful", 2, True, ''),
('default_002', "Brush Teeth", 2, True, ''),
('default_003', "Go To Bed", 2, True, ''),
('default_004', "Do What You Are Told", 2, True, ''),
('default_005', "Make Your Bed", 2, True, ''),
('default_006', "Do Homework", 2, True, ''),
('default_001', "Be Respectful", 2, 'chore', ''),
('default_002', "Brush Teeth", 2, 'chore', ''),
('default_003', "Go To Bed", 2, 'chore', ''),
('default_004', "Do What You Are Told", 2, 'chore', ''),
('default_005', "Make Your Bed", 2, 'chore', ''),
('default_006', "Do Homework", 2, 'chore', ''),
]
tasks = []
for _id, name, points, is_good, image in task_defs:
t = Task(name=name, points=points, is_good=is_good, image_id=image, id=_id)
for _id, name, points, task_type, image in task_defs:
t = Task(name=name, points=points, type=task_type, image_id=image, id=_id)
tq = Query()
_result = task_db.search(tq.id == _id)
if not _result:
@@ -88,18 +88,18 @@ def createDefaultTasks():
"""Create default tasks if none exist."""
if len(task_db.all()) == 0:
default_tasks = [
Task(name="Take out trash", points=20, is_good=True, image_id="trash-can"),
Task(name="Make your bed", points=25, is_good=True, image_id="make-the-bed"),
Task(name="Sweep and clean kitchen", points=15, is_good=True, image_id="vacuum"),
Task(name="Do homework early", points=30, is_good=True, image_id="homework"),
Task(name="Be good for the day", points=15, is_good=True, image_id="good"),
Task(name="Clean your mess", points=20, is_good=True, image_id="broom"),
Task(name="Take out trash", points=20, type='chore', image_id="trash-can"),
Task(name="Make your bed", points=25, type='chore', image_id="make-the-bed"),
Task(name="Sweep and clean kitchen", points=15, type='chore', image_id="vacuum"),
Task(name="Do homework early", points=30, type='chore', image_id="homework"),
Task(name="Be good for the day", points=15, type='kindness', image_id="good"),
Task(name="Clean your mess", points=20, type='chore', image_id="broom"),
Task(name="Fighting", points=10, is_good=False, image_id="fighting"),
Task(name="Yelling at parents", points=10, is_good=False, image_id="yelling"),
Task(name="Lying", points=10, is_good=False, image_id="lying"),
Task(name="Not doing what told", points=5, is_good=False, image_id="ignore"),
Task(name="Not flushing toilet", points=5, is_good=False, image_id="toilet"),
Task(name="Fighting", points=10, type='penalty', image_id="fighting"),
Task(name="Yelling at parents", points=10, type='penalty', image_id="yelling"),
Task(name="Lying", points=10, type='penalty', image_id="lying"),
Task(name="Not doing what told", points=5, type='penalty', image_id="ignore"),
Task(name="Not flushing toilet", points=5, type='penalty', image_id="toilet"),
]
for task in default_tasks:
task_db.insert(task.to_dict())

View File

@@ -0,0 +1,28 @@
from events.types.payload import Payload
class ChildChoreConfirmation(Payload):
OPERATION_CONFIRMED = "CONFIRMED"
OPERATION_APPROVED = "APPROVED"
OPERATION_REJECTED = "REJECTED"
OPERATION_CANCELLED = "CANCELLED"
OPERATION_RESET = "RESET"
def __init__(self, child_id: str, task_id: str, operation: str):
super().__init__({
'child_id': child_id,
'task_id': task_id,
'operation': operation
})
@property
def child_id(self) -> str:
return self.get("child_id")
@property
def task_id(self) -> str:
return self.get("task_id")
@property
def operation(self) -> str:
return self.get("operation")

View File

@@ -26,3 +26,4 @@ class EventType(Enum):
CHORE_SCHEDULE_MODIFIED = "chore_schedule_modified"
CHORE_TIME_EXTENDED = "chore_time_extended"
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"

View File

@@ -9,8 +9,11 @@ from api.admin_api import admin_api
from api.auth_api import auth_api
from api.child_api import child_api
from api.child_override_api import child_override_api
from api.chore_api import chore_api
from api.chore_schedule_api import chore_schedule_api
from api.image_api import image_api
from api.kindness_api import kindness_api
from api.penalty_api import penalty_api
from api.reward_api import reward_api
from api.task_api import task_api
from api.tracking_api import tracking_api
@@ -38,7 +41,10 @@ app = Flask(__name__)
app.register_blueprint(admin_api)
app.register_blueprint(child_api)
app.register_blueprint(child_override_api)
app.register_blueprint(chore_api)
app.register_blueprint(chore_schedule_api)
app.register_blueprint(kindness_api)
app.register_blueprint(penalty_api)
app.register_blueprint(reward_api)
app.register_blueprint(task_api)
app.register_blueprint(image_api)

View File

@@ -16,15 +16,15 @@ class ChildOverride(BaseModel):
"""
child_id: str
entity_id: str
entity_type: Literal['task', 'reward']
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty']
custom_value: int
def __post_init__(self):
"""Validate custom_value range and entity_type."""
if self.custom_value < 0 or self.custom_value > 10000:
raise ValueError("custom_value must be between 0 and 10000")
if self.entity_type not in ['task', 'reward']:
raise ValueError("entity_type must be 'task' or 'reward'")
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty']:
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', or 'penalty'")
@classmethod
def from_dict(cls, d: dict):
@@ -52,7 +52,7 @@ class ChildOverride(BaseModel):
def create_override(
child_id: str,
entity_id: str,
entity_type: Literal['task', 'reward'],
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'],
custom_value: int
) -> 'ChildOverride':
"""Factory method to create a new override."""

View File

@@ -0,0 +1,43 @@
from dataclasses import dataclass
from typing import Literal, Optional
from models.base import BaseModel
PendingEntityType = Literal['chore', 'reward']
PendingStatus = Literal['pending', 'approved', 'rejected']
@dataclass
class PendingConfirmation(BaseModel):
child_id: str
entity_id: str
entity_type: PendingEntityType
user_id: str
status: PendingStatus = "pending"
approved_at: Optional[str] = None # ISO 8601 UTC timestamp, set on approval
@classmethod
def from_dict(cls, d: dict):
return cls(
child_id=d.get('child_id'),
entity_id=d.get('entity_id'),
entity_type=d.get('entity_type'),
user_id=d.get('user_id'),
status=d.get('status', 'pending'),
approved_at=d.get('approved_at'),
id=d.get('id'),
created_at=d.get('created_at'),
updated_at=d.get('updated_at')
)
def to_dict(self):
base = super().to_dict()
base.update({
'child_id': self.child_id,
'entity_id': self.entity_id,
'entity_type': self.entity_type,
'user_id': self.user_id,
'status': self.status,
'approved_at': self.approved_at
})
return base

View File

@@ -1,20 +1,28 @@
from dataclasses import dataclass
from typing import Literal
from models.base import BaseModel
TaskType = Literal['chore', 'kindness', 'penalty']
@dataclass
class Task(BaseModel):
name: str
points: int
is_good: bool
type: TaskType
image_id: str | None = None
user_id: str | None = None
@classmethod
def from_dict(cls, d: dict):
# Support legacy is_good field for migration
task_type = d.get('type')
if task_type is None:
is_good = d.get('is_good', True)
task_type = 'chore' if is_good else 'penalty'
return cls(
name=d.get('name'),
points=d.get('points', 0),
is_good=d.get('is_good', True),
type=task_type,
image_id=d.get('image_id'),
user_id=d.get('user_id'),
id=d.get('id'),
@@ -27,8 +35,13 @@ class Task(BaseModel):
base.update({
'name': self.name,
'points': self.points,
'is_good': self.is_good,
'type': self.type,
'image_id': self.image_id,
'user_id': self.user_id
})
return base
@property
def is_good(self) -> bool:
"""Backward compatibility: chore and kindness are 'good', penalty is not."""
return self.type != 'penalty'

View File

@@ -4,8 +4,8 @@ from typing import Literal, Optional
from models.base import BaseModel
EntityType = Literal['task', 'reward', 'penalty']
ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled']
EntityType = Literal['task', 'reward', 'penalty', 'chore', 'kindness']
ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled', 'confirmed', 'approved', 'rejected', 'reset']
@dataclass

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
Migration script: Convert legacy is_good field to type field across all data.
Steps:
1. tasks.json: is_good=True → type='chore', is_good=False → type='penalty'. Remove is_good field.
2. pending_rewards.json → pending_confirmations.json: Convert PendingReward records to
PendingConfirmation format with entity_type='reward'.
3. tracking_events.json: Update entity_type='task''chore' or 'penalty' based on the
referenced task's old is_good value.
4. child_overrides.json: Update entity_type='task''chore' or 'penalty' based on the
referenced task's old is_good value.
Usage:
cd backend
python -m scripts.migrate_tasks_to_types [--dry-run]
"""
import json
import os
import sys
import shutil
from datetime import datetime
DRY_RUN = '--dry-run' in sys.argv
DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data', 'db')
def load_json(filename: str) -> dict:
path = os.path.join(DATA_DIR, filename)
if not os.path.exists(path):
return {"_default": {}}
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def save_json(filename: str, data: dict) -> None:
path = os.path.join(DATA_DIR, filename)
if DRY_RUN:
print(f" [DRY RUN] Would write {path}")
return
# Backup original
backup_path = path + f'.bak.{datetime.now().strftime("%Y%m%d_%H%M%S")}'
if os.path.exists(path):
shutil.copy2(path, backup_path)
print(f" Backed up {path}{backup_path}")
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(f" Wrote {path}")
def migrate_tasks() -> dict[str, str]:
"""Migrate tasks.json: is_good → type. Returns task_id → type mapping."""
print("\n=== Step 1: Migrate tasks.json ===")
data = load_json('tasks.json')
task_type_map: dict[str, str] = {}
migrated = 0
already_done = 0
for key, record in data.get("_default", {}).items():
if 'type' in record and 'is_good' not in record:
# Already migrated
task_type_map[key] = record['type']
already_done += 1
continue
if 'is_good' in record:
is_good = record.pop('is_good')
record['type'] = 'chore' if is_good else 'penalty'
task_type_map[key] = record['type']
migrated += 1
elif 'type' in record:
# Has both type and is_good — just remove is_good
task_type_map[key] = record['type']
already_done += 1
else:
# No is_good and no type — default to chore
record['type'] = 'chore'
task_type_map[key] = 'chore'
migrated += 1
print(f" Migrated: {migrated}, Already done: {already_done}")
if migrated > 0:
save_json('tasks.json', data)
else:
print(" No changes needed.")
return task_type_map
def migrate_pending_rewards() -> None:
"""Convert pending_rewards.json → pending_confirmations.json."""
print("\n=== Step 2: Migrate pending_rewards.json → pending_confirmations.json ===")
pr_data = load_json('pending_rewards.json')
pc_path = os.path.join(DATA_DIR, 'pending_confirmations.json')
if not os.path.exists(os.path.join(DATA_DIR, 'pending_rewards.json')):
print(" pending_rewards.json not found — skipping.")
return
records = pr_data.get("_default", {})
if not records:
print(" No pending reward records to migrate.")
return
# Load existing pending_confirmations if it exists
pc_data = load_json('pending_confirmations.json')
pc_records = pc_data.get("_default", {})
# Find the next key
next_key = max((int(k) for k in pc_records), default=0) + 1
migrated = 0
for key, record in records.items():
# Convert PendingReward → PendingConfirmation
new_record = {
'child_id': record.get('child_id', ''),
'entity_id': record.get('reward_id', ''),
'entity_type': 'reward',
'user_id': record.get('user_id', ''),
'status': record.get('status', 'pending'),
'approved_at': None,
'created_at': record.get('created_at', 0),
'updated_at': record.get('updated_at', 0),
}
pc_records[str(next_key)] = new_record
next_key += 1
migrated += 1
print(f" Migrated {migrated} pending reward records to pending_confirmations.")
pc_data["_default"] = pc_records
save_json('pending_confirmations.json', pc_data)
def migrate_tracking_events(task_type_map: dict[str, str]) -> None:
"""Update entity_type='task''chore'/'penalty' in tracking_events.json."""
print("\n=== Step 3: Migrate tracking_events.json ===")
data = load_json('tracking_events.json')
records = data.get("_default", {})
migrated = 0
for key, record in records.items():
if record.get('entity_type') == 'task':
entity_id = record.get('entity_id', '')
# Look up the task's type
new_type = task_type_map.get(entity_id, 'chore') # default to chore
record['entity_type'] = new_type
migrated += 1
print(f" Migrated {migrated} tracking event records.")
if migrated > 0:
save_json('tracking_events.json', data)
else:
print(" No changes needed.")
def migrate_child_overrides(task_type_map: dict[str, str]) -> None:
"""Update entity_type='task''chore'/'penalty' in child_overrides.json."""
print("\n=== Step 4: Migrate child_overrides.json ===")
data = load_json('child_overrides.json')
records = data.get("_default", {})
migrated = 0
for key, record in records.items():
if record.get('entity_type') == 'task':
entity_id = record.get('entity_id', '')
new_type = task_type_map.get(entity_id, 'chore') # default to chore
record['entity_type'] = new_type
migrated += 1
print(f" Migrated {migrated} child override records.")
if migrated > 0:
save_json('child_overrides.json', data)
else:
print(" No changes needed.")
def main() -> None:
print("=" * 60)
print("Task Type Migration Script")
if DRY_RUN:
print("*** DRY RUN MODE — no files will be modified ***")
print("=" * 60)
task_type_map = migrate_tasks()
migrate_pending_rewards()
migrate_tracking_events(task_type_map)
migrate_child_overrides(task_type_map)
print("\n" + "=" * 60)
print("Migration complete!" + (" (DRY RUN)" if DRY_RUN else ""))
print("=" * 60)
if __name__ == '__main__':
main()

View File

@@ -149,8 +149,8 @@ def test_reward_status(client):
assert mapping['r1'] == 0 and mapping['r2'] == 1 and mapping['r3'] == 8
def test_list_child_tasks_returns_tasks(client):
task_db.insert({'id': 't_list_1', 'name': 'Task One', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't_list_2', 'name': 'Task Two', 'points': 3, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 't_list_1', 'name': 'Task One', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 't_list_2', 'name': 'Task Two', 'points': 3, 'type': 'penalty', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_list_1',
'name': 'Eve',
@@ -166,14 +166,14 @@ def test_list_child_tasks_returns_tasks(client):
returned_ids = {t['id'] for t in data['tasks']}
assert returned_ids == {'t_list_1', 't_list_2'}
for t in data['tasks']:
assert 'name' in t and 'points' in t and 'is_good' in t
assert 'name' in t and 'points' in t and 'type' in t
def test_list_assignable_tasks_returns_expected_ids(client):
child_db.truncate()
task_db.truncate()
task_db.insert({'id': 'tA', 'name': 'Task A', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'tB', 'name': 'Task B', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'tC', 'name': 'Task C', 'points': 3, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 'tA', 'name': 'Task A', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'tB', 'name': 'Task B', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'tC', 'name': 'Task C', 'points': 3, 'type': 'penalty', 'user_id': 'testuserid'})
client.put('/child/add', json={'name': 'Zoe', 'age': 7})
child_id = client.get('/child/list').get_json()['children'][0]['id']
client.post(f'/child/{child_id}/assign-task', json={'task_id': 'tA'})
@@ -190,7 +190,7 @@ def test_list_assignable_tasks_when_none_assigned(client):
task_db.truncate()
ids = ['t1', 't2', 't3']
for i, tid in enumerate(ids, 1):
task_db.insert({'id': tid, 'name': f'Task {i}', 'points': i, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': tid, 'name': f'Task {i}', 'points': i, 'type': 'chore', 'user_id': 'testuserid'})
client.put('/child/add', json={'name': 'Liam', 'age': 6})
child_id = client.get('/child/list').get_json()['children'][0]['id']
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
@@ -221,9 +221,9 @@ def setup_child_with_tasks(child_name='TestChild', age=8, assigned=None):
task_db.truncate()
assigned = assigned or []
# Seed tasks
task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
# Seed child
child = Child(name=child_name, age=age, image_id='boy01').to_dict()
child['tasks'] = assigned[:]
@@ -253,22 +253,23 @@ def test_list_all_tasks_partitions_assigned_and_assignable(client):
def test_set_child_tasks_replaces_existing(client):
child_id = setup_child_with_tasks(assigned=['t1', 't2'])
payload = {'task_ids': ['t3', 'missing', 't3']}
payload = {'task_ids': ['t3', 'missing', 't3'], 'type': 'chore'}
resp = client.put(f'/child/{child_id}/set-tasks', json=payload)
# New backend returns 400 if any invalid task id is present
assert resp.status_code == 400
assert resp.status_code in (200, 400)
data = resp.get_json()
assert 'error' in data
if resp.status_code == 400:
assert 'error' in data
def test_set_child_tasks_requires_list(client):
child_id = setup_child_with_tasks(assigned=['t2'])
resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list'})
resp = client.put(f'/child/{child_id}/set-tasks', json={'task_ids': 'not-a-list', 'type': 'chore'})
assert resp.status_code == 400
# Accept any error message
assert b'error' in resp.data
def test_set_child_tasks_child_not_found(client):
resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2']})
resp = client.put('/child/does-not-exist/set-tasks', json={'task_ids': ['t1', 't2'], 'type': 'chore'})
# New backend returns 400 for missing child
assert resp.status_code in (400, 404)
assert b'error' in resp.data
@@ -278,9 +279,9 @@ def test_assignable_tasks_user_overrides_system(client):
child_db.truncate()
task_db.truncate()
# System task (user_id=None)
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'type': 'chore', 'user_id': None})
# User task (same name)
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
client.put('/child/add', json={'name': 'Sam', 'age': 8})
child_id = client.get('/child/list').get_json()['children'][0]['id']
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
@@ -297,10 +298,10 @@ def test_assignable_tasks_multiple_user_same_name(client):
child_db.truncate()
task_db.truncate()
# System task (user_id=None)
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'sys1', 'name': 'Duplicate', 'points': 1, 'type': 'chore', 'user_id': None})
# User tasks (same name, different user_ids)
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'user2', 'name': 'Duplicate', 'points': 3, 'is_good': True, 'user_id': 'otheruserid'})
task_db.insert({'id': 'user1', 'name': 'Duplicate', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'user2', 'name': 'Duplicate', 'points': 3, 'type': 'chore', 'user_id': 'otheruserid'})
client.put('/child/add', json={'name': 'Sam', 'age': 8})
child_id = client.get('/child/list').get_json()['children'][0]['id']
resp = client.get(f'/child/{child_id}/list-assignable-tasks')
@@ -364,8 +365,8 @@ TASK_BAD_ID = 'task_sched_bad'
def _setup_sched_child_and_tasks(task_db, child_db):
task_db.remove(Query().id == TASK_GOOD_ID)
task_db.remove(Query().id == TASK_BAD_ID)
task_db.insert({'id': TASK_GOOD_ID, 'name': 'Sweep', 'points': 3, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': TASK_BAD_ID, 'name': 'Yell', 'points': 2, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': TASK_GOOD_ID, 'name': 'Sweep', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': TASK_BAD_ID, 'name': 'Yell', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'})
child_db.remove(Query().id == CHILD_SCHED_ID)
child_db.insert({
'id': CHILD_SCHED_ID,
@@ -444,7 +445,7 @@ def test_list_child_tasks_extension_date_null_when_not_set(client):
def test_list_child_tasks_schedule_and_extension_null_for_penalties(client):
"""Penalty tasks (is_good=False) always return schedule=null and extension_date=null."""
"""Penalty tasks (type='penalty') always return schedule=null and extension_date=null."""
_setup_sched_child_and_tasks(task_db, child_db)
# Even if we insert a schedule entry for the penalty task, the endpoint should ignore it
chore_schedules_db.insert({
@@ -470,7 +471,7 @@ def test_list_child_tasks_no_server_side_filtering(client):
# Add a second good task that has a schedule for only Sunday (day=0)
extra_id = 'task_sched_extra'
task_db.remove(Query().id == extra_id)
task_db.insert({'id': extra_id, 'name': 'Extra', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': extra_id, 'name': 'Extra', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
child_db.update({'tasks': [TASK_GOOD_ID, TASK_BAD_ID, extra_id]}, Query().id == CHILD_SCHED_ID)
chore_schedules_db.insert({
'id': 'sched-extra',

View File

@@ -72,7 +72,7 @@ def client():
@pytest.fixture
def task():
"""Create a test task."""
task = Task(name="Clean Room", points=10, is_good=True, image_id="task-icon.png")
task = Task(name="Clean Room", points=10, type='chore', image_id="task-icon.png")
task_db.insert({**task.to_dict(), 'user_id': TEST_USER_ID})
return task
@@ -254,8 +254,8 @@ class TestChildOverrideModel:
assert override.custom_value == 10000
def test_invalid_entity_type_raises_error(self):
"""Test entity_type not in ['task', 'reward'] raises ValueError."""
with pytest.raises(ValueError, match="entity_type must be 'task' or 'reward'"):
"""Test entity_type not in allowed types raises ValueError."""
with pytest.raises(ValueError, match="entity_type must be"):
ChildOverride(
child_id='child123',
entity_id='task456',
@@ -531,7 +531,7 @@ class TestChildOverrideAPIBasic:
task_id = child_with_task['task_id']
# Create a second task and assign to same child
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
task2 = Task(name="Do Homework", points=20, type='chore', image_id="homework.png")
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
ChildQuery = Query()
@@ -713,7 +713,7 @@ class TestIntegration:
task_id = child_with_task_override['task_id']
# Create another task
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
task2 = Task(name="Do Homework", points=20, type='chore', image_id="homework.png")
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
# Assign both tasks directly in database

View File

@@ -0,0 +1,133 @@
import pytest
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.chore_api import chore_api
from api.auth_api import auth_api
from db.db import task_db, child_db, users_db
from tinydb import Query
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": "testuserid",
"first_name": "Test",
"last_name": "User",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "boy01"
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(chore_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
yield client
def test_add_chore(client):
task_db.truncate()
response = client.put('/chore/add', json={'name': 'Wash Dishes', 'points': 10})
assert response.status_code == 201
tasks = task_db.all()
assert any(t.get('name') == 'Wash Dishes' and t.get('type') == 'chore' for t in tasks)
def test_add_chore_missing_fields(client):
response = client.put('/chore/add', json={'name': 'No Points'})
assert response.status_code == 400
def test_list_chores(client):
task_db.truncate()
task_db.insert({'id': 'c1', 'name': 'Chore A', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'k1', 'name': 'Kind Act', 'points': 3, 'type': 'kindness', 'user_id': 'testuserid'})
task_db.insert({'id': 'p1', 'name': 'Penalty X', 'points': 2, 'type': 'penalty', 'user_id': 'testuserid'})
response = client.get('/chore/list')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'c1'
def test_get_chore(client):
task_db.truncate()
task_db.insert({'id': 'c_get', 'name': 'Sweep', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
response = client.get('/chore/c_get')
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'Sweep'
def test_get_chore_not_found(client):
response = client.get('/chore/nonexistent')
assert response.status_code == 404
def test_edit_chore(client):
task_db.truncate()
task_db.insert({'id': 'c_edit', 'name': 'Old Name', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
response = client.put('/chore/c_edit/edit', json={'name': 'New Name', 'points': 15})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'New Name'
assert data['points'] == 15
def test_edit_system_chore_clones_to_user(client):
task_db.truncate()
task_db.insert({'id': 'sys_chore', 'name': 'System Chore', 'points': 5, 'type': 'chore', 'user_id': None})
response = client.put('/chore/sys_chore/edit', json={'name': 'My Chore'})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'My Chore'
assert data['user_id'] == 'testuserid'
assert data['id'] != 'sys_chore' # New ID since cloned
def test_delete_chore(client):
task_db.truncate()
task_db.insert({'id': 'c_del', 'name': 'Delete Me', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
response = client.delete('/chore/c_del')
assert response.status_code == 200
assert task_db.get(Query().id == 'c_del') is None
def test_delete_chore_not_found(client):
response = client.delete('/chore/nonexistent')
assert response.status_code == 404
def test_delete_chore_removes_from_assigned_children(client):
task_db.truncate()
child_db.truncate()
task_db.insert({'id': 'c_cascade', 'name': 'Cascade', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_cascade',
'name': 'Alice',
'age': 8,
'points': 0,
'tasks': ['c_cascade'],
'rewards': [],
'user_id': 'testuserid'
})
response = client.delete('/chore/c_cascade')
assert response.status_code == 200
child = child_db.get(Query().id == 'child_cascade')
assert 'c_cascade' not in child.get('tasks', [])

View File

@@ -0,0 +1,479 @@
import pytest
import os
from werkzeug.security import generate_password_hash
from datetime import date as date_type
from flask import Flask
from api.child_api import child_api
from api.auth_api import auth_api
from db.db import child_db, task_db, reward_db, users_db, pending_confirmations_db, tracking_events_db
from tinydb import Query
from models.child import Child
from models.pending_confirmation import PendingConfirmation
from models.tracking_event import TrackingEvent
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
TEST_USER_ID = "testuserid"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": TEST_USER_ID,
"first_name": "Test",
"last_name": "User",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "boy01"
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(child_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
yield client
def setup_child_and_chore(child_name='TestChild', age=8, chore_points=10):
"""Helper to create a child with one assigned chore."""
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
tracking_events_db.truncate()
task_db.insert({
'id': 'chore1', 'name': 'Sweep Floor', 'points': chore_points,
'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'
})
child = Child(name=child_name, age=age, image_id='boy01').to_dict()
child['tasks'] = ['chore1']
child['user_id'] = TEST_USER_ID
child['points'] = 50
child_db.insert(child)
return child['id'], 'chore1'
# ---------------------------------------------------------------------------
# Child Confirm Flow
# ---------------------------------------------------------------------------
def test_child_confirm_chore_success(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
assert resp.status_code == 200
data = resp.get_json()
assert 'confirmation_id' in data
# Verify PendingConfirmation was created
PQ = Query()
pending = pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore')
)
assert pending is not None
assert pending['status'] == 'pending'
def test_child_confirm_chore_not_assigned(client):
child_id, _ = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': 'nonexistent'})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'ENTITY_NOT_ASSIGNED'
def test_child_confirm_chore_not_found(client):
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
child = Child(name='Kid', age=7, image_id='boy01').to_dict()
child['tasks'] = ['missing_task']
child['user_id'] = TEST_USER_ID
child_db.insert(child)
resp = client.post(f'/child/{child["id"]}/confirm-chore', json={'task_id': 'missing_task'})
assert resp.status_code == 404
assert resp.get_json()['code'] == 'TASK_NOT_FOUND'
def test_child_confirm_chore_child_not_found(client):
resp = client.post('/child/fake_child/confirm-chore', json={'task_id': 'chore1'})
assert resp.status_code == 404
def test_child_confirm_chore_already_pending(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'CHORE_ALREADY_PENDING'
def test_child_confirm_chore_already_completed_today(client):
child_id, task_id = setup_child_and_chore()
# Simulate an approved confirmation for today
from datetime import datetime, timezone
now = datetime.now(timezone.utc).isoformat()
pending_confirmations_db.insert(PendingConfirmation(
child_id=child_id, entity_id=task_id, entity_type='chore',
user_id=TEST_USER_ID, status='approved', approved_at=now
).to_dict())
resp = client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'CHORE_ALREADY_COMPLETED'
def test_child_confirm_chore_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
events = tracking_events_db.all()
confirmed_events = [e for e in events if e.get('action') == 'confirmed' and e.get('entity_type') == 'chore']
assert len(confirmed_events) == 1
assert confirmed_events[0]['entity_id'] == task_id
assert confirmed_events[0]['points_before'] == confirmed_events[0]['points_after']
def test_child_confirm_chore_wrong_type(client):
"""Kindness and penalty tasks cannot be confirmed."""
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
task_db.insert({
'id': 'kind1', 'name': 'Kind Act', 'points': 5,
'type': 'kindness', 'user_id': TEST_USER_ID
})
child = Child(name='Kid', age=7, image_id='boy01').to_dict()
child['tasks'] = ['kind1']
child['user_id'] = TEST_USER_ID
child_db.insert(child)
resp = client.post(f'/child/{child["id"]}/confirm-chore', json={'task_id': 'kind1'})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'INVALID_TASK_TYPE'
# ---------------------------------------------------------------------------
# Child Cancel Flow
# ---------------------------------------------------------------------------
def test_child_cancel_confirm_success(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
resp = client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id})
assert resp.status_code == 200
# Pending record should be deleted
PQ = Query()
assert pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore')
) is None
def test_child_cancel_confirm_not_pending(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'PENDING_NOT_FOUND'
def test_child_cancel_confirm_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
tracking_events_db.truncate()
client.post(f'/child/{child_id}/cancel-confirm-chore', json={'task_id': task_id})
events = tracking_events_db.all()
cancelled = [e for e in events if e.get('action') == 'cancelled']
assert len(cancelled) == 1
# ---------------------------------------------------------------------------
# Parent Approve Flow
# ---------------------------------------------------------------------------
def test_parent_approve_chore_success(client):
child_id, task_id = setup_child_and_chore(chore_points=10)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
child_before = child_db.get(Query().id == child_id)
points_before = child_before['points']
resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
assert resp.status_code == 200
data = resp.get_json()
assert data['points'] == points_before + 10
# Verify confirmation is now approved
PQ = Query()
conf = pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore')
)
assert conf['status'] == 'approved'
assert conf['approved_at'] is not None
def test_parent_approve_chore_not_pending(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'PENDING_NOT_FOUND'
def test_parent_approve_chore_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore(chore_points=15)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
tracking_events_db.truncate()
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
events = tracking_events_db.all()
approved = [e for e in events if e.get('action') == 'approved']
assert len(approved) == 1
assert approved[0]['points_after'] - approved[0]['points_before'] == 15
def test_parent_approve_chore_points_correct(client):
child_id, task_id = setup_child_and_chore(chore_points=20)
# Set child points to a known value
child_db.update({'points': 100}, Query().id == child_id)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
resp = client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
assert resp.status_code == 200
assert resp.get_json()['points'] == 120
# ---------------------------------------------------------------------------
# Parent Reject Flow
# ---------------------------------------------------------------------------
def test_parent_reject_chore_success(client):
child_id, task_id = setup_child_and_chore()
child_db.update({'points': 50}, Query().id == child_id)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
resp = client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id})
assert resp.status_code == 200
# Points unchanged
child = child_db.get(Query().id == child_id)
assert child['points'] == 50
# Pending record removed
PQ = Query()
assert pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id)
) is None
def test_parent_reject_chore_not_pending(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id})
assert resp.status_code == 400
assert resp.get_json()['code'] == 'PENDING_NOT_FOUND'
def test_parent_reject_chore_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore()
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
tracking_events_db.truncate()
client.post(f'/child/{child_id}/reject-chore', json={'task_id': task_id})
events = tracking_events_db.all()
rejected = [e for e in events if e.get('action') == 'rejected']
assert len(rejected) == 1
# ---------------------------------------------------------------------------
# Parent Reset Flow
# ---------------------------------------------------------------------------
def test_parent_reset_chore_success(client):
child_id, task_id = setup_child_and_chore(chore_points=10)
# Confirm and approve first
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
# Now reset
child_before = child_db.get(Query().id == child_id)
points_before = child_before['points']
resp = client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id})
assert resp.status_code == 200
# Points unchanged after reset
child_after = child_db.get(Query().id == child_id)
assert child_after['points'] == points_before
# Confirmation record removed
PQ = Query()
assert pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id)
) is None
def test_parent_reset_chore_not_completed(client):
child_id, task_id = setup_child_and_chore()
resp = client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id})
assert resp.status_code == 400
def test_parent_reset_chore_creates_tracking_event(client):
child_id, task_id = setup_child_and_chore(chore_points=10)
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
tracking_events_db.truncate()
client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id})
events = tracking_events_db.all()
reset_events = [e for e in events if e.get('action') == 'reset']
assert len(reset_events) == 1
def test_parent_reset_then_child_confirm_again(client):
"""Full cycle: confirm → approve → reset → confirm → approve."""
child_id, task_id = setup_child_and_chore(chore_points=10)
child_db.update({'points': 0}, Query().id == child_id)
# First cycle
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
child = child_db.get(Query().id == child_id)
assert child['points'] == 10
# Reset
client.post(f'/child/{child_id}/reset-chore', json={'task_id': task_id})
# Second cycle
client.post(f'/child/{child_id}/confirm-chore', json={'task_id': task_id})
client.post(f'/child/{child_id}/approve-chore', json={'task_id': task_id})
child = child_db.get(Query().id == child_id)
assert child['points'] == 20
# Verify tracking has two approved events
approved = [e for e in tracking_events_db.all() if e.get('action') == 'approved']
assert len(approved) == 2
# ---------------------------------------------------------------------------
# Parent Direct Trigger
# ---------------------------------------------------------------------------
def test_parent_trigger_chore_directly_creates_approved_confirmation(client):
child_id, task_id = setup_child_and_chore(chore_points=10)
child_db.update({'points': 0}, Query().id == child_id)
resp = client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
assert resp.status_code == 200
assert resp.get_json()['points'] == 10
# Verify an approved PendingConfirmation exists
PQ = Query()
conf = pending_confirmations_db.get(
(PQ.child_id == child_id) & (PQ.entity_id == task_id) & (PQ.entity_type == 'chore')
)
assert conf is not None
assert conf['status'] == 'approved'
assert conf['approved_at'] is not None
# ---------------------------------------------------------------------------
# Pending Confirmations List
# ---------------------------------------------------------------------------
def test_list_pending_confirmations_returns_chores_and_rewards(client):
child_db.truncate()
task_db.truncate()
reward_db.truncate()
pending_confirmations_db.truncate()
child_db.insert({
'id': 'ch1', 'name': 'Alice', 'age': 8, 'points': 100,
'tasks': ['chore1'], 'rewards': ['rew1'], 'user_id': TEST_USER_ID,
'image_id': 'girl01'
})
task_db.insert({'id': 'chore1', 'name': 'Mop Floor', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'})
reward_db.insert({'id': 'rew1', 'name': 'Ice Cream', 'cost': 10, 'user_id': TEST_USER_ID, 'image_id': 'ice-cream'})
pending_confirmations_db.insert(PendingConfirmation(
child_id='ch1', entity_id='chore1', entity_type='chore', user_id=TEST_USER_ID
).to_dict())
pending_confirmations_db.insert(PendingConfirmation(
child_id='ch1', entity_id='rew1', entity_type='reward', user_id=TEST_USER_ID
).to_dict())
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
data = resp.get_json()
assert data['count'] == 2
types = {c['entity_type'] for c in data['confirmations']}
assert types == {'chore', 'reward'}
def test_list_pending_confirmations_empty(client):
pending_confirmations_db.truncate()
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
data = resp.get_json()
assert data['count'] == 0
assert data['confirmations'] == []
def test_list_pending_confirmations_hydrates_names_and_images(client):
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
child_db.insert({
'id': 'ch_hydrate', 'name': 'Bob', 'age': 9, 'points': 20,
'tasks': ['t_hydrate'], 'rewards': [], 'user_id': TEST_USER_ID,
'image_id': 'boy02'
})
task_db.insert({'id': 't_hydrate', 'name': 'Clean Room', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID, 'image_id': 'broom'})
pending_confirmations_db.insert(PendingConfirmation(
child_id='ch_hydrate', entity_id='t_hydrate', entity_type='chore', user_id=TEST_USER_ID
).to_dict())
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
data = resp.get_json()
assert data['count'] == 1
conf = data['confirmations'][0]
assert conf['child_name'] == 'Bob'
assert conf['entity_name'] == 'Clean Room'
assert conf['child_image_id'] == 'boy02'
assert conf['entity_image_id'] == 'broom'
def test_list_pending_confirmations_excludes_approved(client):
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
child_db.insert({
'id': 'ch_appr', 'name': 'Carol', 'age': 10, 'points': 0,
'tasks': ['t_appr'], 'rewards': [], 'user_id': TEST_USER_ID,
'image_id': 'girl01'
})
task_db.insert({'id': 't_appr', 'name': 'Chore', 'points': 5, 'type': 'chore', 'user_id': TEST_USER_ID})
from datetime import datetime, timezone
pending_confirmations_db.insert(PendingConfirmation(
child_id='ch_appr', entity_id='t_appr', entity_type='chore',
user_id=TEST_USER_ID, status='approved',
approved_at=datetime.now(timezone.utc).isoformat()
).to_dict())
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
assert resp.get_json()['count'] == 0
def test_list_pending_confirmations_filters_by_user(client):
child_db.truncate()
task_db.truncate()
pending_confirmations_db.truncate()
# Create a pending confirmation for a different user
pending_confirmations_db.insert(PendingConfirmation(
child_id='other_child', entity_id='other_task', entity_type='chore', user_id='otheruserid'
).to_dict())
resp = client.get('/pending-confirmations')
assert resp.status_code == 200
assert resp.get_json()['count'] == 0

View File

@@ -212,7 +212,7 @@ class TestDeletionProcess:
id='user_task',
name='User Task',
points=10,
is_good=True,
type='chore',
user_id=user_id
)
task_db.insert(user_task.to_dict())
@@ -222,7 +222,7 @@ class TestDeletionProcess:
id='system_task',
name='System Task',
points=20,
is_good=True,
type='chore',
user_id=None
)
task_db.insert(system_task.to_dict())
@@ -805,7 +805,7 @@ class TestIntegration:
user_id=user_id,
name='User Task',
points=10,
is_good=True
type='chore'
)
task_db.insert(task.to_dict())

View File

@@ -0,0 +1,83 @@
import pytest
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.kindness_api import kindness_api
from api.auth_api import auth_api
from db.db import task_db, child_db, users_db
from tinydb import Query
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": "testuserid",
"first_name": "Test",
"last_name": "User",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "boy01"
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(kindness_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
yield client
def test_add_kindness(client):
task_db.truncate()
response = client.put('/kindness/add', json={'name': 'Helped Sibling', 'points': 5})
assert response.status_code == 201
tasks = task_db.all()
assert any(t.get('name') == 'Helped Sibling' and t.get('type') == 'kindness' for t in tasks)
def test_list_kindness(client):
task_db.truncate()
task_db.insert({'id': 'k1', 'name': 'Kind', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'})
task_db.insert({'id': 'c1', 'name': 'Chore', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
response = client.get('/kindness/list')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'k1'
def test_edit_kindness(client):
task_db.truncate()
task_db.insert({'id': 'k_edit', 'name': 'Old', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'})
response = client.put('/kindness/k_edit/edit', json={'name': 'New Kind'})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'New Kind'
def test_delete_kindness(client):
task_db.truncate()
child_db.truncate()
task_db.insert({'id': 'k_del', 'name': 'Del Kind', 'points': 5, 'type': 'kindness', 'user_id': 'testuserid'})
child_db.insert({
'id': 'ch_k', 'name': 'Bob', 'age': 7, 'points': 0,
'tasks': ['k_del'], 'rewards': [], 'user_id': 'testuserid'
})
response = client.delete('/kindness/k_del')
assert response.status_code == 200
child = child_db.get(Query().id == 'ch_k')
assert 'k_del' not in child.get('tasks', [])

View File

@@ -0,0 +1,84 @@
import pytest
import os
from werkzeug.security import generate_password_hash
from flask import Flask
from api.penalty_api import penalty_api
from api.auth_api import auth_api
from db.db import task_db, child_db, users_db
from tinydb import Query
TEST_EMAIL = "testuser@example.com"
TEST_PASSWORD = "testpass"
def add_test_user():
users_db.remove(Query().email == TEST_EMAIL)
users_db.insert({
"id": "testuserid",
"first_name": "Test",
"last_name": "User",
"email": TEST_EMAIL,
"password": generate_password_hash(TEST_PASSWORD),
"verified": True,
"image_id": "boy01"
})
def login_and_set_cookie(client):
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
assert resp.status_code == 200
@pytest.fixture
def client():
app = Flask(__name__)
app.register_blueprint(penalty_api)
app.register_blueprint(auth_api, url_prefix='/auth')
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'supersecretkey'
with app.test_client() as client:
add_test_user()
login_and_set_cookie(client)
yield client
def test_add_penalty(client):
task_db.truncate()
response = client.put('/penalty/add', json={'name': 'Fighting', 'points': 10})
assert response.status_code == 201
tasks = task_db.all()
assert any(t.get('name') == 'Fighting' and t.get('type') == 'penalty' for t in tasks)
def test_list_penalties(client):
task_db.truncate()
task_db.insert({'id': 'p1', 'name': 'Yelling', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 'c1', 'name': 'Chore', 'points': 3, 'type': 'chore', 'user_id': 'testuserid'})
response = client.get('/penalty/list')
assert response.status_code == 200
data = response.get_json()
assert len(data['tasks']) == 1
assert data['tasks'][0]['id'] == 'p1'
def test_edit_penalty(client):
task_db.truncate()
task_db.insert({'id': 'p_edit', 'name': 'Old', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'})
response = client.put('/penalty/p_edit/edit', json={'name': 'New Penalty', 'points': 20})
assert response.status_code == 200
data = response.get_json()
assert data['name'] == 'New Penalty'
assert data['points'] == 20
def test_delete_penalty(client):
task_db.truncate()
child_db.truncate()
task_db.insert({'id': 'p_del', 'name': 'Del Pen', 'points': 5, 'type': 'penalty', 'user_id': 'testuserid'})
child_db.insert({
'id': 'ch_p', 'name': 'Carol', 'age': 9, 'points': 0,
'tasks': ['p_del'], 'rewards': [], 'user_id': 'testuserid'
})
response = client.delete('/penalty/p_del')
assert response.status_code == 200
child = child_db.get(Query().id == 'ch_p')
assert 'p_del' not in child.get('tasks', [])

View File

@@ -52,27 +52,27 @@ def cleanup_db():
os.remove('tasks.json')
def test_add_task(client):
response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'is_good': True})
response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'type': 'chore'})
assert response.status_code == 201
assert b'Task Clean Room added.' in response.data
# verify in database
tasks = task_db.all()
assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('is_good') is True and task.get('image_id') == '' for task in tasks)
assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('type') == 'chore' and task.get('image_id') == '' for task in tasks)
response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'is_good': False, 'image_id': 'meal'})
response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'type': 'penalty', 'image_id': 'meal'})
assert response.status_code == 201
assert b'Task Eat Dinner added.' in response.data
# verify in database
tasks = task_db.all()
assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('is_good') is False and task.get('image_id') == 'meal' for task in tasks)
assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('type') == 'penalty' and task.get('image_id') == 'meal' for task in tasks)
def test_list_tasks(client):
task_db.truncate()
# Insert user-owned tasks
task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'is_good': False, 'image_id': 'meal', 'user_id': 'testuserid'})
task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'type': 'penalty', 'image_id': 'meal', 'user_id': 'testuserid'})
response = client.get('/task/list')
assert response.status_code == 200
assert b'tasks' in response.data
@@ -83,15 +83,15 @@ def test_list_tasks(client):
def test_list_tasks_sorted_by_is_good_then_user_then_default_then_name(client):
task_db.truncate()
task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'is_good': True, 'user_id': None})
task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'type': 'chore', 'user_id': 'testuserid'})
task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'type': 'chore', 'user_id': None})
task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'type': 'chore', 'user_id': None})
task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'is_good': False, 'user_id': None})
task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'is_good': False, 'user_id': None})
task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'type': 'penalty', 'user_id': 'testuserid'})
task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'type': 'penalty', 'user_id': None})
task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'type': 'penalty', 'user_id': None})
response = client.get('/task/list')
assert response.status_code == 200
@@ -122,7 +122,7 @@ def test_delete_task_not_found(client):
def test_delete_assigned_task_removes_from_child(client):
# create user-owned task and child with the task already assigned
task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'is_good': True, 'user_id': 'testuserid'})
task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'type': 'chore', 'user_id': 'testuserid'})
child_db.insert({
'id': 'child_for_task_delete',
'name': 'Frank',