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
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
165
backend/api/chore_api.py
Normal 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
|
||||
@@ -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
165
backend/api/kindness_api.py
Normal 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
165
backend/api/penalty_api.py
Normal 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
|
||||
29
backend/api/pending_confirmation.py
Normal file
29
backend/api/pending_confirmation.py
Normal 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
|
||||
}
|
||||
@@ -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)))
|
||||
|
||||
144
backend/data/db/tasks.json.bak.20260228_104347
Normal file
144
backend/data/db/tasks.json.bak.20260228_104347
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
2615
backend/data/db/tracking_events.json.bak.20260228_104347
Normal file
2615
backend/data/db/tracking_events.json.bak.20260228_104347
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
28
backend/events/types/child_chore_confirmation.py
Normal file
28
backend/events/types/child_chore_confirmation.py
Normal 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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
43
backend/models/pending_confirmation.py
Normal file
43
backend/models/pending_confirmation.py
Normal 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
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
196
backend/scripts/migrate_tasks_to_types.py
Normal file
196
backend/scripts/migrate_tasks_to_types.py
Normal 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()
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
133
backend/tests/test_chore_api.py
Normal file
133
backend/tests/test_chore_api.py
Normal 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', [])
|
||||
479
backend/tests/test_chore_confirmation.py
Normal file
479
backend/tests/test_chore_confirmation.py
Normal 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
|
||||
@@ -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())
|
||||
|
||||
|
||||
83
backend/tests/test_kindness_api.py
Normal file
83
backend/tests/test_kindness_api.py
Normal 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', [])
|
||||
84
backend/tests/test_penalty_api.py
Normal file
84
backend/tests/test_penalty_api.py
Normal 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', [])
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user