All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m14s
- Implemented push subscription API with tests for subscribing and unsubscribing users. - Created web push notification tests triggered by child actions. - Added digest scheduler to send email digests to users at 9 PM local time. - Developed utility functions for creating and validating digest action tokens. - Integrated web push sender to handle sending notifications to users. - Added service worker for handling push notifications in the frontend. - Created a push opt-in component for user notification preferences. - Implemented tests for the push opt-in component to ensure correct behavior. - Updated frontend services to manage push subscriptions and permissions.
258 lines
11 KiB
Python
258 lines
11 KiB
Python
"""Shared business logic for chore confirmation and reward actions.
|
|
|
|
Called from both child_api.py (JWT-authenticated endpoints) and
|
|
digest_action_api.py (token-authenticated endpoints). All functions take
|
|
user_id explicitly rather than reading it from the Flask request context.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
from tinydb import Query
|
|
|
|
from db.db import child_db, task_db, reward_db, pending_confirmations_db
|
|
from db.child_overrides import get_override
|
|
from db.chore_schedules import get_schedule
|
|
from db.tracking import insert_tracking_event
|
|
from events.sse import send_event_to_user
|
|
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
|
from events.types.child_reward_request import ChildRewardRequest
|
|
from events.types.child_reward_triggered import ChildRewardTriggered
|
|
from events.types.child_task_triggered import ChildTaskTriggered
|
|
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.reward import Reward
|
|
from models.task import Task
|
|
from models.tracking_event import TrackingEvent
|
|
from utils.tracking_logger import log_tracking_event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None:
|
|
"""Award points for a completed chore and mark the pending confirmation approved.
|
|
|
|
Returns a result dict on success, or None if the confirmation was already resolved.
|
|
Raises ValueError if the child or task cannot be found.
|
|
"""
|
|
ChildQ = Query()
|
|
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
|
if not child_result:
|
|
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
|
child = Child.from_dict(child_result)
|
|
|
|
PendingQ = Query()
|
|
existing = pending_confirmations_db.get(
|
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
|
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
|
(PendingQ.user_id == user_id)
|
|
)
|
|
if not existing:
|
|
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
|
return None
|
|
|
|
TaskQ = Query()
|
|
task_result = task_db.get(
|
|
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
|
|
)
|
|
if not task_result:
|
|
raise ValueError(f'Task {task_id} not found')
|
|
task = Task.from_dict(task_result)
|
|
|
|
override = get_override(child_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}, ChildQ.id == child_id)
|
|
|
|
schedule = get_schedule(child_id, task_id)
|
|
if schedule:
|
|
now_str = datetime.now(timezone.utc).isoformat()
|
|
pending_confirmations_db.update(
|
|
{'status': 'approved', 'approved_at': now_str},
|
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
|
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
|
)
|
|
else:
|
|
pending_confirmations_db.remove(
|
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
|
(PendingQ.entity_type == 'chore') & (PendingQ.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=child_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_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
|
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved')))
|
|
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
|
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
|
|
send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value,
|
|
ChildTaskTriggered(task_id, child_id, child.points)))
|
|
|
|
return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
|
|
|
|
|
def reject_chore(user_id: str, child_id: str, task_id: str) -> None:
|
|
"""Reject a pending chore confirmation. No-op if already resolved."""
|
|
PendingQ = Query()
|
|
existing = pending_confirmations_db.get(
|
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
|
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
|
(PendingQ.user_id == user_id)
|
|
)
|
|
if not existing:
|
|
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
|
return
|
|
|
|
pending_confirmations_db.remove(
|
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
|
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
|
)
|
|
|
|
ChildQ = Query()
|
|
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
|
if child_result:
|
|
child = Child.from_dict(child_result)
|
|
TaskQ = Query()
|
|
task_result = task_db.get(
|
|
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.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=child_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_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
|
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected')))
|
|
|
|
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
|
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
|
|
|
|
|
|
def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict:
|
|
"""Approve a child's pending reward request: deduct points and fire SSE events.
|
|
|
|
Returns a result dict on success.
|
|
Raises ValueError if child/reward not found or the child has insufficient points.
|
|
"""
|
|
ChildQ = Query()
|
|
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
|
if not child_result:
|
|
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
|
child = Child.from_dict(child_result)
|
|
|
|
RewardQ = Query()
|
|
reward_result = reward_db.get(
|
|
(RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
|
|
)
|
|
if not reward_result:
|
|
raise ValueError(f'Reward {reward_id} not found')
|
|
reward = Reward.from_dict(reward_result)
|
|
|
|
override = get_override(child_id, reward_id)
|
|
cost_value = override.custom_value if override else reward.cost
|
|
|
|
if child.points < cost_value:
|
|
raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}')
|
|
|
|
PendingQ = Query()
|
|
removed = pending_confirmations_db.remove(
|
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
|
(PendingQ.entity_type == 'reward')
|
|
)
|
|
if removed:
|
|
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
|
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED)))
|
|
|
|
points_before = child.points
|
|
child.points -= cost_value
|
|
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
|
|
|
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, entity_type='reward', entity_id=reward_id,
|
|
action='redeemed', points_before=points_before, points_after=child.points,
|
|
metadata=tracking_metadata,
|
|
)
|
|
insert_tracking_event(tracking_event)
|
|
log_tracking_event(tracking_event)
|
|
|
|
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
|
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed')))
|
|
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value,
|
|
ChildRewardTriggered(reward_id, child_id, child.points)))
|
|
|
|
return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
|
|
|
|
|
def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
|
|
"""Deny a child's pending reward request. No-op if already resolved.
|
|
|
|
Returns a result dict on success, or None if already resolved.
|
|
"""
|
|
PendingQ = Query()
|
|
existing = pending_confirmations_db.get(
|
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
|
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
|
|
(PendingQ.user_id == user_id)
|
|
)
|
|
if not existing:
|
|
logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved')
|
|
return None
|
|
|
|
pending_confirmations_db.remove(
|
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
|
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
|
)
|
|
|
|
ChildQ = Query()
|
|
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
|
child_name = 'Unknown'
|
|
if child_result:
|
|
child = Child.from_dict(child_result)
|
|
child_name = child.name
|
|
|
|
tracking_event = TrackingEvent.create_event(
|
|
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
|
|
action='denied', points_before=child.points, points_after=child.points,
|
|
metadata={},
|
|
)
|
|
insert_tracking_event(tracking_event)
|
|
log_tracking_event(tracking_event)
|
|
|
|
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
|
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied')))
|
|
|
|
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
|
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
|
|
|
|
return {'child_name': child_name}
|