feat: add PendingRewardDialog, RewardConfirmDialog, and TaskConfirmDialog components
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 25s

- Implemented PendingRewardDialog for handling pending reward requests.
- Created RewardConfirmDialog for confirming reward redemption.
- Developed TaskConfirmDialog for task confirmation with child name display.

test: add unit tests for ChildView and ParentView components

- Added comprehensive tests for ChildView including task triggering and SSE event handling.
- Implemented tests for ParentView focusing on override modal and SSE event management.

test: add ScrollingList component tests

- Created tests for ScrollingList to verify item fetching, loading states, and custom item classes.
- Included tests for two-step click interactions and edit button display logic.
- Moved toward hashed passwords.
This commit is contained in:
2026-02-10 20:21:05 -05:00
parent 3dee8b80a2
commit 401c21ad82
45 changed files with 4353 additions and 441 deletions

View File

@@ -6,6 +6,7 @@ from flask import Blueprint, request, jsonify, current_app
from tinydb import Query
import os
import utils.email_sender as email_sender
from werkzeug.security import generate_password_hash, check_password_hash
from api.utils import sanitize_email
from config.paths import get_user_image_dir
@@ -47,7 +48,7 @@ def signup():
first_name=data['first_name'],
last_name=data['last_name'],
email=norm_email,
password=data['password'], # Hash in production!
password=generate_password_hash(data['password']),
verified=False,
verify_token=token,
verify_token_created=now_iso,
@@ -140,7 +141,7 @@ def login():
user_dict = users_db.get(UserQuery.email == norm_email)
user = User.from_dict(user_dict) if user_dict else None
if not user or user.password != password:
if not user or not check_password_hash(user.password, password):
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
if not user.verified:
@@ -254,7 +255,7 @@ def reset_password():
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
user.password = new_password # Hash in production!
user.password = generate_password_hash(new_password)
user.reset_token = None
user.reset_token_created = None
users_db.update(user.to_dict(), UserQuery.email == user.email)

View File

@@ -10,6 +10,7 @@ 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 db.tracking import insert_tracking_event
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
from events.types.child_modified import ChildModified
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
@@ -133,6 +134,12 @@ def delete_child(id):
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
ChildQuery = Query()
# Cascade delete overrides for this child
deleted_count = delete_overrides_for_child(id)
if deleted_count > 0:
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)):
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
if resp:
@@ -192,6 +199,17 @@ def set_child_tasks(id):
# Convert back to list if needed
new_tasks = list(new_task_ids)
# Identify unassigned tasks and delete their overrides
old_task_ids = set(child.tasks)
unassigned_task_ids = old_task_ids - new_task_ids
for task_id in unassigned_task_ids:
# Only delete overrides for task entities
override = get_override(id, task_id)
if override and override.entity_type == 'task':
delete_override(id, task_id)
logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}")
# Replace tasks with validated IDs
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, new_tasks)))
@@ -246,8 +264,16 @@ def list_child_tasks(id):
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
if not task:
continue
# 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'))
child_tasks.append(ct.to_dict())
ct_dict = ct.to_dict()
if custom_value is not None:
ct_dict['custom_value'] = custom_value
child_tasks.append(ct_dict)
return jsonify({'tasks': child_tasks}), 200
@@ -372,11 +398,15 @@ def trigger_child_task(id):
# Capture points before modification
points_before = child.points
# Check for override
override = get_override(id, task_id)
points_value = override.custom_value if override else task.points
# update the child's points based on task type
if task.is_good:
child.points += task.points
child.points += points_value
else:
child.points -= task.points
child.points -= points_value
child.points = max(child.points, 0)
# update the child in the database
@@ -384,6 +414,15 @@ def trigger_child_task(id):
# Create tracking event
entity_type = 'penalty' if not task.is_good else 'task'
tracking_metadata = {
'task_name': task.name,
'is_good': task.is_good,
'default_points': task.points
}
if override:
tracking_metadata['custom_points'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
@@ -392,7 +431,7 @@ def trigger_child_task(id):
action='activated',
points_before=points_before,
points_after=child.points,
metadata={'task_name': task.name, 'is_good': task.is_good}
metadata=tracking_metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
@@ -494,6 +533,9 @@ def set_child_rewards(id):
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
if not result:
return jsonify({'error': 'Child not found'}), 404
child = Child.from_dict(result[0])
old_reward_ids = set(child.rewards)
# Optional: validate reward IDs exist in the reward DB
RewardQuery = Query()
@@ -501,6 +543,15 @@ def set_child_rewards(id):
for rid in new_reward_ids:
if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
valid_reward_ids.append(rid)
# Identify unassigned rewards and delete their overrides
new_reward_ids_set = set(valid_reward_ids)
unassigned_reward_ids = old_reward_ids - new_reward_ids_set
for reward_id in unassigned_reward_ids:
override = get_override(id, reward_id)
if override and override.entity_type == 'reward':
delete_override(id, reward_id)
logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}")
# Replace rewards with validated IDs
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
@@ -553,8 +604,16 @@ def list_child_rewards(id):
reward = reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward:
continue
# Check for override
override = get_override(id, rid)
custom_value = override.custom_value if override else None
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
child_rewards.append(cr.to_dict())
cr_dict = cr.to_dict()
if custom_value is not None:
cr_dict['custom_value'] = custom_value
child_rewards.append(cr_dict)
return jsonify({'rewards': child_rewards}), 200
@@ -618,15 +677,19 @@ def trigger_child_reward(id):
return jsonify({'error': 'Reward not found in reward database'}), 404
reward: Reward = Reward.from_dict(reward_result[0])
# Check for override
override = get_override(id, reward_id)
cost_value = override.custom_value if override else reward.cost
# Check if child has enough points
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points')
if child.points < reward.cost:
points_needed = reward.cost - child.points
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {cost_value} points')
if child.points < cost_value:
points_needed = cost_value - child.points
return jsonify({
'error': 'Insufficient points',
'points_needed': points_needed,
'current_points': child.points,
'reward_cost': reward.cost
'reward_cost': cost_value
}), 400
# Remove matching pending reward requests for this child and reward
@@ -641,11 +704,20 @@ def trigger_child_reward(id):
points_before = child.points
# update the child's points based on reward cost
child.points -= reward.cost
child.points -= cost_value
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
# Create tracking event
tracking_metadata = {
'reward_name': reward.name,
'reward_cost': reward.cost,
'default_cost': reward.cost
}
if override:
tracking_metadata['custom_cost'] = override.custom_value
tracking_metadata['has_override'] = True
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
@@ -654,7 +726,7 @@ def trigger_child_reward(id):
action='redeemed',
points_before=points_before,
points_after=child.points,
metadata={'reward_name': reward.name, 'reward_cost': reward.cost}
metadata=tracking_metadata
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
@@ -702,15 +774,24 @@ def reward_status(id):
RewardQuery = Query()
statuses = []
for reward_id in reward_ids:
reward: Reward = Reward.from_dict(reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))))
if not reward:
reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
if not reward_dict:
continue
points_needed = max(0, reward.cost - points)
reward: Reward = Reward.from_dict(reward_dict)
# Check for override
override = get_override(id, reward_id)
cost_value = override.custom_value if override else reward.cost
points_needed = max(0, cost_value - points)
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true
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))
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, pending is not None, reward.image_id)
statuses.append(status.to_dict())
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id)
status_dict = status.to_dict()
if override:
status_dict['custom_value'] = override.custom_value
statuses.append(status_dict)
statuses.sort(key=lambda s: (not s['redeeming'], s['cost']))
return jsonify({'reward_status': statuses}), 200

View File

@@ -0,0 +1,173 @@
from flask import Blueprint, request, jsonify
from tinydb import Query
from api.utils import get_validated_user_id, send_event_for_current_user
from api.error_codes import ErrorCodes
from db.db import child_db, task_db, reward_db
from db.child_overrides import (
insert_override,
get_override,
get_overrides_for_child,
delete_override
)
from models.child_override import ChildOverride
from events.types.event import Event
from events.types.event_types import EventType
from events.types.child_override_set import ChildOverrideSetPayload
from events.types.child_override_deleted import ChildOverrideDeletedPayload
import logging
child_override_api = Blueprint('child_override_api', __name__)
logger = logging.getLogger(__name__)
@child_override_api.route('/child/<child_id>/override', methods=['PUT'])
def set_child_override(child_id):
"""
Set or update a custom value for a task/reward for a specific child.
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
# Validate child exists and belongs to user
ChildQuery = Query()
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
if not child_result:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
child_dict = child_result[0]
# Parse request data
data = request.get_json() or {}
entity_id = data.get('entity_id')
entity_type = data.get('entity_type')
custom_value = data.get('custom_value')
# Validate required fields
if not entity_id:
return jsonify({'error': 'entity_id is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_id'}), 400
if not entity_type:
return jsonify({'error': 'entity_type is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_type'}), 400
if custom_value is None:
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
# Validate entity_type
if entity_type not in ['task', 'reward']:
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
# Validate custom_value range
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
return jsonify({'error': 'custom_value must be an integer between 0 and 10000', 'code': ErrorCodes.INVALID_VALUE, 'field': 'custom_value'}), 400
# Validate entity exists and is assigned to child
if entity_type == 'task':
EntityQuery = Query()
entity_result = task_db.search(
(EntityQuery.id == entity_id) &
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
)
if not entity_result:
return jsonify({'error': 'Task not found', 'code': ErrorCodes.TASK_NOT_FOUND}), 404
# Check if task is assigned to child
assigned_tasks = child_dict.get('tasks', [])
if entity_id not in assigned_tasks:
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
else: # reward
EntityQuery = Query()
entity_result = reward_db.search(
(EntityQuery.id == entity_id) &
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
)
if not entity_result:
return jsonify({'error': 'Reward not found', 'code': ErrorCodes.REWARD_NOT_FOUND}), 404
# Check if reward is assigned to child
assigned_rewards = child_dict.get('rewards', [])
if entity_id not in assigned_rewards:
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
# Create and insert override
try:
override = ChildOverride.create_override(
child_id=child_id,
entity_id=entity_id,
entity_type=entity_type,
custom_value=custom_value
)
insert_override(override)
# Send SSE event
resp = send_event_for_current_user(
Event(EventType.CHILD_OVERRIDE_SET.value, ChildOverrideSetPayload(override))
)
if resp:
return resp
return jsonify({'override': override.to_dict()}), 200
except ValueError as e:
return jsonify({'error': str(e), 'code': ErrorCodes.VALIDATION_ERROR}), 400
except Exception as e:
logger.error(f"Error setting override: {e}")
return jsonify({'error': 'Internal server error', 'code': ErrorCodes.INTERNAL_ERROR}), 500
@child_override_api.route('/child/<child_id>/overrides', methods=['GET'])
def get_child_overrides(child_id):
"""
Get all overrides for a specific child.
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
# Validate child exists and belongs to user
ChildQuery = Query()
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
if not child_result:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
# Get all overrides for child
overrides = get_overrides_for_child(child_id)
return jsonify({'overrides': [o.to_dict() for o in overrides]}), 200
@child_override_api.route('/child/<child_id>/override/<entity_id>', methods=['DELETE'])
def delete_child_override(child_id, entity_id):
"""
Delete an override (reset to default).
"""
user_id = get_validated_user_id()
if not user_id:
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
# Validate child exists and belongs to user
ChildQuery = Query()
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
if not child_result:
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
# Get override to determine entity_type for event
override = get_override(child_id, entity_id)
if not override:
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
entity_type = override.entity_type
# Delete override
deleted = delete_override(child_id, entity_id)
if not deleted:
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
# Send SSE event
resp = send_event_for_current_user(
Event(EventType.CHILD_OVERRIDE_DELETED.value,
ChildOverrideDeletedPayload(child_id, entity_id, entity_type))
)
if resp:
return resp
return jsonify({'message': 'Override deleted'}), 200

View File

@@ -11,4 +11,18 @@ MISSING_EMAIL_OR_PASSWORD = "MISSING_EMAIL_OR_PASSWORD"
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
NOT_VERIFIED = "NOT_VERIFIED"
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
ALREADY_MARKED = "ALREADY_MARKED"
ALREADY_MARKED = "ALREADY_MARKED"
class ErrorCodes:
"""Centralized error codes for API responses."""
UNAUTHORIZED = "UNAUTHORIZED"
CHILD_NOT_FOUND = "CHILD_NOT_FOUND"
TASK_NOT_FOUND = "TASK_NOT_FOUND"
REWARD_NOT_FOUND = "REWARD_NOT_FOUND"
ENTITY_NOT_ASSIGNED = "ENTITY_NOT_ASSIGNED"
OVERRIDE_NOT_FOUND = "OVERRIDE_NOT_FOUND"
MISSING_FIELD = "MISSING_FIELD"
INVALID_VALUE = "INVALID_VALUE"
VALIDATION_ERROR = "VALIDATION_ERROR"
INTERNAL_ERROR = "INTERNAL_ERROR"

View File

@@ -4,6 +4,7 @@ from tinydb import Query
from api.utils import send_event_for_current_user, get_validated_user_id
from events.types.child_rewards_set import ChildRewardsSet
from db.db import reward_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.reward_modified import RewardModified
@@ -81,6 +82,12 @@ def delete_reward(id):
return jsonify({'error': 'System rewards cannot be deleted.'}), 403
removed = reward_db.remove((RewardQuery.id == id) & (RewardQuery.user_id == user_id))
if removed:
# Cascade delete overrides for this reward
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for reward {id}")
# remove the reward id from any child's reward list
ChildQuery = Query()
for child in child_db.all():

View File

@@ -4,6 +4,7 @@ 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
@@ -79,6 +80,12 @@ def delete_task(id):
return jsonify({'error': 'System tasks cannot be deleted.'}), 403
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
if removed:
# Cascade delete overrides for this task
deleted_count = delete_overrides_for_entity(id)
if deleted_count > 0:
import logging
logging.info(f"Cascade deleted {deleted_count} overrides for task {id}")
# remove the task id from any child's task list
ChildQuery = Query()
for child in child_db.all():