diff --git a/api/child_api.py b/api/child_api.py index f36a2ba..793352d 100644 --- a/api/child_api.py +++ b/api/child_api.py @@ -4,6 +4,16 @@ from db.db import child_db, task_db, reward_db from api.reward_status import RewardStatus from api.child_tasks import ChildTask from api.child_rewards import ChildReward +from events.sse import send_to_user, send_event_to_user +from events.types.child_add import ChildAdd +from events.types.child_delete import ChildDelete +from events.types.child_update import ChildUpdate +from events.types.event import Event +from events.types.event_types import EventType +from events.types.reward_set import RewardSet +from events.types.reward_update import RewardUpdate +from events.types.task_set import TaskSet +from events.types.task_update import TaskUpdate from models.child import Child from models.task import Task @@ -31,8 +41,9 @@ def add_child(): if not image: image = 'boy01' - child = Child(name, age, image_id=image) + child = Child(name=name, age=age, image_id=image) child_db.insert(child.to_dict()) + send_event_to_user("user123", Event(EventType.CHILD_ADD.value, ChildAdd(child.id, "set"))) return jsonify({'message': f'Child {name} added.'}), 201 @child_api.route('/child//edit', methods=['PUT']) @@ -56,6 +67,7 @@ def edit_child(id): if image is not None: child['image_id'] = image child_db.update(child, ChildQuery.id == id) + send_event_to_user("user123", Event(EventType.CHILD_UPDATE.value, ChildUpdate(id, "set"))) return jsonify({'message': f'Child {id} updated.'}), 200 @child_api.route('/child/list', methods=['GET']) @@ -68,6 +80,8 @@ def list_children(): def delete_child(id): ChildQuery = Query() if child_db.remove(ChildQuery.id == id): + send_event_to_user("user123", + Event(EventType.CHILD_DELETE.value, ChildDelete(id, "deleted"))) return jsonify({'message': f'Child {id} deleted.'}), 200 return jsonify({'error': 'Child not found'}), 404 @@ -89,6 +103,37 @@ def assign_task_to_child(id): child_db.update({'tasks': child['tasks']}, ChildQuery.id == id) return jsonify({'message': f'Task {task_id} assigned to {child['name']}.'}), 200 +# python +@child_api.route('/child//set-tasks', methods=['PUT']) +def set_child_tasks(id): + data = request.get_json() or {} + task_ids = data.get('task_ids') + if not isinstance(task_ids, list): + return jsonify({'error': 'task_ids must be a list'}), 400 + + # Deduplicate and drop falsy values + new_task_ids = [tid for tid in dict.fromkeys(task_ids) if tid] + + ChildQuery = Query() + result = child_db.search(ChildQuery.id == id) + if not result: + return jsonify({'error': 'Child not found'}), 404 + + # Optional: validate task IDs exist in the task DB + TaskQuery = Query() + valid_task_ids = [] + for tid in new_task_ids: + if task_db.get(TaskQuery.id == tid): + valid_task_ids.append(tid) + # Replace tasks with validated IDs + child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id) + send_event_to_user("user123", Event(EventType.TASK_SET.value, TaskSet(id, "set"))) + return jsonify({ + 'message': f'Tasks set for child {id}.', + 'task_ids': valid_task_ids, + 'count': len(valid_task_ids) + }), 200 + @child_api.route('/child//remove-task', methods=['POST']) @@ -159,6 +204,48 @@ def list_assignable_tasks(id): return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200 + +@child_api.route('/child//list-all-tasks', methods=['GET']) +def list_all_tasks(id): + ChildQuery = Query() + result = child_db.search(ChildQuery.id == id) + if not result: + return jsonify({'error': 'Child not found'}), 404 + + child = result[0] + assigned_ids = set(child.get('tasks', [])) + + # Get all tasks from database + all_tasks = task_db.all() + + assigned_tasks = [] + assignable_tasks = [] + + for task in all_tasks: + if not task or not task.get('id'): + continue + + ct = ChildTask( + task.get('name'), + task.get('is_good'), + task.get('points'), + task.get('image_id'), + task.get('id') + ) + + if task.get('id') in assigned_ids: + assigned_tasks.append(ct.to_dict()) + else: + assignable_tasks.append(ct.to_dict()) + + return jsonify({ + 'assigned_tasks': assigned_tasks, + 'assignable_tasks': assignable_tasks, + 'assigned_count': len(assigned_tasks), + 'assignable_count': len(assignable_tasks) + }), 200 + + @child_api.route('/child//trigger-task', methods=['POST']) def trigger_child_task(id): data = request.get_json() @@ -188,6 +275,7 @@ def trigger_child_task(id): child.points = max(child.points, 0) # update the child in the database child_db.update({'points': child.points}, ChildQuery.id == id) + send_event_to_user("user123", Event(EventType.TASK_UPDATE.value, TaskUpdate(task.id, child.id,"complete", child.points))) return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200 @@ -209,6 +297,78 @@ def assign_reward_to_child(id): child_db.update({'rewards': child['rewards']}, ChildQuery.id == id) return jsonify({'message': f'Reward {reward_id} assigned to {child["name"]}.'}), 200 +@child_api.route('/child//list-all-rewards', methods=['GET']) +def list_all_rewards(id): + ChildQuery = Query() + result = child_db.search(ChildQuery.id == id) + if not result: + return jsonify({'error': 'Child not found'}), 404 + + child = result[0] + assigned_ids = set(child.get('rewards', [])) + + # Get all rewards from database + all_rewards = reward_db.all() + + assigned_rewards = [] + assignable_rewards = [] + + for reward in all_rewards: + if not reward or not reward.get('id'): + continue + + cr = ChildReward( + reward.get('name'), + reward.get('cost'), + reward.get('image_id'), + reward.get('id') + ) + + if reward.get('id') in assigned_ids: + assigned_rewards.append(cr.to_dict()) + else: + assignable_rewards.append(cr.to_dict()) + + return jsonify({ + 'assigned_rewards': assigned_rewards, + 'assignable_rewards': assignable_rewards, + 'assigned_count': len(assigned_rewards), + 'assignable_count': len(assignable_rewards) + }), 200 + + +@child_api.route('/child//set-rewards', methods=['PUT']) +def set_child_rewards(id): + data = request.get_json() or {} + reward_ids = data.get('reward_ids') + if not isinstance(reward_ids, list): + return jsonify({'error': 'reward_ids must be a list'}), 400 + + # Deduplicate and drop falsy values + new_reward_ids = [rid for rid in dict.fromkeys(reward_ids) if rid] + + ChildQuery = Query() + result = child_db.search(ChildQuery.id == id) + if not result: + return jsonify({'error': 'Child not found'}), 404 + + # Optional: validate reward IDs exist in the reward DB + RewardQuery = Query() + valid_reward_ids = [] + for rid in new_reward_ids: + if reward_db.get(RewardQuery.id == rid): + valid_reward_ids.append(rid) + + # Replace rewards with validated IDs + child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id) + send_event_to_user("user123", Event(EventType.REWARD_SET.value, RewardSet(id, "set"))) + return jsonify({ + 'message': f'Rewards set for child {id}.', + 'reward_ids': valid_reward_ids, + 'count': len(valid_reward_ids) + }), 200 + + @child_api.route('/child//remove-reward', methods=['POST']) def remove_reward_from_child(id): data = request.get_json() @@ -298,6 +458,7 @@ def trigger_child_reward(id): child.points -= reward.cost # update the child in the database child_db.update({'points': child.points}, ChildQuery.id == id) + send_event_to_user("user123", Event(EventType.REWARD_UPDATE.value, RewardUpdate(reward.id, child.id, "redeemed", child.points))) return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200 @child_api.route('/child//affordable-rewards', methods=['GET']) @@ -331,11 +492,11 @@ def reward_status(id): RewardQuery = Query() statuses = [] for reward_id in reward_ids: - reward = reward_db.get(RewardQuery.id == reward_id) + reward: Reward = Reward.from_dict(reward_db.get(RewardQuery.id == reward_id)) if not reward: continue - points_needed = max(0, reward.get('cost', 0) - points) - status = RewardStatus(reward.get('id'), reward.get('name'), points_needed, reward.get('image_id')) + points_needed = max(0, reward.cost - points) + status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, reward.image_id) statuses.append(status.to_dict()) statuses.sort(key=lambda s: s['points_needed']) diff --git a/api/image_api.py b/api/image_api.py index f3a2f2e..a23f285 100644 --- a/api/image_api.py +++ b/api/image_api.py @@ -61,8 +61,8 @@ def upload(): format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'} extension = format_extension_map.get(original_format, '.png') - _id = str(uuid.uuid4()) - filename = _id + extension + image_record = Image(extension=extension, permanent=perm, type=image_type) + filename = image_record.id + extension filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename)) try: @@ -76,10 +76,9 @@ def upload(): except Exception: return jsonify({'error': 'Failed to save processed image'}), 500 - image_record = Image(image_type, extension, permanent=perm, id=_id) image_db.insert(image_record.to_dict()) - return jsonify({'message': 'Image uploaded successfully', 'filename': filename, 'id': _id}), 200 + return jsonify({'message': 'Image uploaded successfully', 'filename': filename, 'id': image_record.id}), 200 @image_api.route('/image/request/', methods=['GET']) def request_image(id): diff --git a/api/reward_api.py b/api/reward_api.py index 8c372a4..edb59e3 100644 --- a/api/reward_api.py +++ b/api/reward_api.py @@ -1,5 +1,12 @@ from flask import Blueprint, request, jsonify from tinydb import Query + +from events.sse import send_event_to_user +from events.types.event import Event +from events.types.event_types import EventType +from events.types.reward_created import RewardCreated +from events.types.reward_deleted import RewardDeleted +from events.types.reward_edited import RewardEdited from models.reward import Reward from db.db import reward_db, child_db @@ -15,8 +22,11 @@ def add_reward(): image = data.get('image_id', '') if not name or description is None or cost is None: return jsonify({'error': 'Name, description, and cost are required'}), 400 - reward = Reward(name, description, cost, image_id=image) + reward = Reward(name=name, description=description, cost=cost, image_id=image) reward_db.insert(reward.to_dict()) + send_event_to_user("user123", Event(EventType.REWARD_CREATED.value, + RewardCreated(reward.id, "created"))) + return jsonify({'message': f'Reward {name} added.'}), 201 @@ -46,6 +56,9 @@ def delete_reward(id): if id in rewards: rewards.remove(id) child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id')) + send_event_to_user("user123", Event(EventType.REWARD_DELETED.value, + RewardDeleted(id, "created"))) + return jsonify({'message': f'Reward {id} deleted.'}), 200 return jsonify({'error': 'Reward not found'}), 404 @@ -87,4 +100,7 @@ def edit_reward(id): reward_db.update(updates, RewardQuery.id == id) updated = reward_db.get(RewardQuery.id == id) + send_event_to_user("user123", Event(EventType.REWARD_EDITED.value, + RewardEdited(id, "created"))) + return jsonify(updated), 200 diff --git a/api/reward_status.py b/api/reward_status.py index 803ca6a..01238ea 100644 --- a/api/reward_status.py +++ b/api/reward_status.py @@ -1,8 +1,9 @@ class RewardStatus: - def __init__(self, id, name, points_needed, image_id): + def __init__(self, id, name, points_needed, cost, image_id): self.id = id self.name = name self.points_needed = points_needed + self.cost = cost self.image_id = image_id def to_dict(self): @@ -10,5 +11,6 @@ class RewardStatus: 'id': self.id, 'name': self.name, 'points_needed': self.points_needed, + 'cost': self.cost, 'image_id': self.image_id } \ No newline at end of file diff --git a/api/task_api.py b/api/task_api.py index 28ede5c..9eba7e7 100644 --- a/api/task_api.py +++ b/api/task_api.py @@ -1,5 +1,12 @@ from flask import Blueprint, request, jsonify from tinydb import Query + +from events.sse import send_event_to_user +from events.types.event import Event +from events.types.event_types import EventType +from events.types.task_created import TaskCreated +from events.types.task_deleted import TaskDeleted +from events.types.task_edited import TaskEdited from models.task import Task from db.db import task_db, child_db @@ -15,8 +22,10 @@ def add_task(): 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, points, is_good, image_id=image) + task = Task(name=name, points=points, is_good=is_good, image_id=image) task_db.insert(task.to_dict()) + send_event_to_user("user123", Event(EventType.TASK_CREATED.value, + TaskCreated(task.id, "created"))) return jsonify({'message': f'Task {name} added.'}), 201 @task_api.route('/task/', methods=['GET']) @@ -44,6 +53,9 @@ def delete_task(id): if id in tasks: tasks.remove(id) child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id')) + send_event_to_user("user123", Event(EventType.TASK_DELETED.value, + TaskDeleted(id, "deleted"))) + return jsonify({'message': f'Task {id} deleted.'}), 200 return jsonify({'error': 'Task not found'}), 404 @@ -85,4 +97,6 @@ def edit_task(id): task_db.update(updates, TaskQuery.id == id) updated = task_db.get(TaskQuery.id == id) + send_event_to_user("user123", Event(EventType.TASK_EDITED.value, + TaskEdited(id, "edited"))) return jsonify(updated), 200 diff --git a/events/broadcaster.py b/events/broadcaster.py new file mode 100644 index 0000000..3784f33 --- /dev/null +++ b/events/broadcaster.py @@ -0,0 +1,10 @@ +import time +from threading import Thread + +class Broadcaster(Thread): + """Background thread sending periodic notifications.""" + + def run(self): + while True: + #push event to all users + time.sleep(5) # Send every 5 seconds diff --git a/events/sse.py b/events/sse.py new file mode 100644 index 0000000..573b66a --- /dev/null +++ b/events/sse.py @@ -0,0 +1,74 @@ +import json +import queue +import uuid +from typing import Dict, Any + +from flask import Response + +from events.types.event import Event + +# Maps user_id → dict of {connection_id: queue} +user_queues: Dict[str, Dict[str, queue.Queue]] = {} + + +def get_queue(user_id: str, connection_id: str) -> queue.Queue: + """Get or create a queue for a specific user connection.""" + if user_id not in user_queues: + user_queues[user_id] = {} + + if connection_id not in user_queues[user_id]: + user_queues[user_id][connection_id] = queue.Queue() + + return user_queues[user_id][connection_id] + + +def send_to_user(user_id: str, data: Dict[str, Any]): + """Send data to all connections for a specific user.""" + if user_id in user_queues: + # Format as SSE message once + message = f"data: {json.dumps(data)}\n\n".encode('utf-8') + + # Send to all connections for this user + for connection_id, q in user_queues[user_id].items(): + try: + q.put(message, block=False) + except queue.Full: + # Skip if queue is full (connection might be dead) + pass + + +def send_event_to_user(user_id: str, event: Event): + """Send an Event to all connections for a specific user.""" + send_to_user(user_id, event.to_dict()) + + +def sse_response_for_user(user_id: str): + """Create SSE response for a user connection.""" + # Generate unique connection ID + connection_id = str(uuid.uuid4()) + user_queue = get_queue(user_id, connection_id) + + def generate(): + try: + while True: + # Get message from queue (blocks until available) + message = user_queue.get() + yield message + except GeneratorExit: + # Clean up when client disconnects + if user_id in user_queues and connection_id in user_queues[user_id]: + del user_queues[user_id][connection_id] + + # Remove user entry if no connections remain + if not user_queues[user_id]: + del user_queues[user_id] + + return Response( + generate(), + mimetype='text/event-stream', + headers={ + 'Cache-Control': 'no-cache', + 'X-Accel-Buffering': 'no', + 'Connection': 'keep-alive' + } + ) diff --git a/events/types/child_add.py b/events/types/child_add.py new file mode 100644 index 0000000..74d0422 --- /dev/null +++ b/events/types/child_add.py @@ -0,0 +1,18 @@ +from events.types.payload import Payload + + +class ChildAdd(Payload): + def __init__(self, child_id: str, status: str): + super().__init__({ + 'child_id': child_id, + 'status': status, + }) + + @property + def child_id(self) -> str: + return self.get("child_id") + + @property + def status(self) -> str: + return self.get("status") + diff --git a/events/types/child_delete.py b/events/types/child_delete.py new file mode 100644 index 0000000..17ebc6b --- /dev/null +++ b/events/types/child_delete.py @@ -0,0 +1,18 @@ +from events.types.payload import Payload + + +class ChildDelete(Payload): + def __init__(self, child_id: str, status: str): + super().__init__({ + 'child_id': child_id, + 'status': status, + }) + + @property + def child_id(self) -> str: + return self.get("child_id") + + @property + def status(self) -> str: + return self.get("status") + diff --git a/events/types/child_update.py b/events/types/child_update.py new file mode 100644 index 0000000..d386c07 --- /dev/null +++ b/events/types/child_update.py @@ -0,0 +1,18 @@ +from events.types.payload import Payload + + +class ChildUpdate(Payload): + def __init__(self, child_id: str, status: str): + super().__init__({ + 'child_id': child_id, + 'status': status, + }) + + @property + def child_id(self) -> str: + return self.get("child_id") + + @property + def status(self) -> str: + return self.get("status") + diff --git a/events/types/event.py b/events/types/event.py new file mode 100644 index 0000000..b2a1207 --- /dev/null +++ b/events/types/event.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass, field +import time + +from events.types.payload import Payload + + +@dataclass +class Event: + type: str + payload: Payload + timestamp: float = field(default_factory=time.time) + + def to_dict(self): + return { + 'type': self.type, + 'payload': self.payload.data, + 'timestamp': self.timestamp + } \ No newline at end of file diff --git a/events/types/event_types.py b/events/types/event_types.py new file mode 100644 index 0000000..317ff33 --- /dev/null +++ b/events/types/event_types.py @@ -0,0 +1,19 @@ +from enum import Enum + + +class EventType(Enum): + TASK_UPDATE = "task_update" + TASK_SET = "task_set" + TASK_TEST = "task_test" + REWARD_UPDATE = "reward_update" + REWARD_SET = "reward_set" + CHILD_UPDATE = "child_update" + CHILD_ADD = "child_add" + CHILD_DELETE = "child_delete" + TASK_CREATED = "task_created" + TASK_DELETED = "task_deleted" + TASK_EDITED = "task_edited" + REWARD_CREATED = "reward_created" + REWARD_DELETED = "reward_deleted" + REWARD_EDITED = "reward_edited" + # Add more event types here \ No newline at end of file diff --git a/events/types/payload.py b/events/types/payload.py new file mode 100644 index 0000000..2682e0b --- /dev/null +++ b/events/types/payload.py @@ -0,0 +1,6 @@ +class Payload: + def __init__(self, data: dict): + self.data = data + + def get(self, key: str, default=None): + return self.data.get(key, default) \ No newline at end of file diff --git a/events/types/reward_created.py b/events/types/reward_created.py new file mode 100644 index 0000000..1724154 --- /dev/null +++ b/events/types/reward_created.py @@ -0,0 +1,19 @@ +from events.types.payload import Payload + + +class RewardCreated(Payload): + def __init__(self, reward_id: str, status: str): + super().__init__({ + 'reward_id': reward_id, + 'status': status + }) + + @property + def reward_id(self) -> str: + return self.get("reward_id") + + @property + def status(self) -> str: + return self.get("status") + + diff --git a/events/types/reward_deleted.py b/events/types/reward_deleted.py new file mode 100644 index 0000000..887ec87 --- /dev/null +++ b/events/types/reward_deleted.py @@ -0,0 +1,19 @@ +from events.types.payload import Payload + + +class RewardDeleted(Payload): + def __init__(self, reward_id: str, status: str): + super().__init__({ + 'reward_id': reward_id, + 'status': status + }) + + @property + def reward_id(self) -> str: + return self.get("reward_id") + + @property + def status(self) -> str: + return self.get("status") + + diff --git a/events/types/reward_edited.py b/events/types/reward_edited.py new file mode 100644 index 0000000..851f249 --- /dev/null +++ b/events/types/reward_edited.py @@ -0,0 +1,19 @@ +from events.types.payload import Payload + + +class RewardEdited(Payload): + def __init__(self, reward_id: str, status: str): + super().__init__({ + 'reward_id': reward_id, + 'status': status + }) + + @property + def reward_id(self) -> str: + return self.get("reward_id") + + @property + def status(self) -> str: + return self.get("status") + + diff --git a/events/types/reward_set.py b/events/types/reward_set.py new file mode 100644 index 0000000..473f48a --- /dev/null +++ b/events/types/reward_set.py @@ -0,0 +1,19 @@ +from events.types.payload import Payload + + +class RewardSet(Payload): + def __init__(self, child_id: str, status: str): + super().__init__({ + 'child_id': child_id, + 'status': status + }) + + @property + def child_id(self) -> str: + return self.get("child_id") + + @property + def status(self) -> str: + return self.get("status") + + diff --git a/events/types/reward_update.py b/events/types/reward_update.py new file mode 100644 index 0000000..07b6ab4 --- /dev/null +++ b/events/types/reward_update.py @@ -0,0 +1,29 @@ +from events.types.payload import Payload + + +class RewardUpdate(Payload): + def __init__(self, reward_id: str, child_id: str, status: str, points: int): + super().__init__({ + 'reward_id': reward_id, + 'child_id': child_id, + 'status': status, + 'points': points + }) + + @property + def reward_id(self) -> str: + return self.get("reward_id") + + @property + def child_id(self) -> str: + return self.get("child_id") + + @property + def status(self) -> str: + return self.get("status") + + @property + def points(self) -> int: + return self.get("points", 0) + + diff --git a/events/types/task_created.py b/events/types/task_created.py new file mode 100644 index 0000000..2b6e44f --- /dev/null +++ b/events/types/task_created.py @@ -0,0 +1,19 @@ +from events.types.payload import Payload + + +class TaskCreated(Payload): + def __init__(self, task_id: str, status: str): + super().__init__({ + 'task_id': task_id, + 'status': status + }) + + @property + def task_id(self) -> str: + return self.get("task_id") + + @property + def status(self) -> str: + return self.get("status") + + diff --git a/events/types/task_deleted.py b/events/types/task_deleted.py new file mode 100644 index 0000000..254bcd5 --- /dev/null +++ b/events/types/task_deleted.py @@ -0,0 +1,19 @@ +from events.types.payload import Payload + + +class TaskDeleted(Payload): + def __init__(self, task_id: str, status: str): + super().__init__({ + 'task_id': task_id, + 'status': status + }) + + @property + def task_id(self) -> str: + return self.get("task_id") + + @property + def status(self) -> str: + return self.get("status") + + diff --git a/events/types/task_edited.py b/events/types/task_edited.py new file mode 100644 index 0000000..0bb766a --- /dev/null +++ b/events/types/task_edited.py @@ -0,0 +1,19 @@ +from events.types.payload import Payload + + +class TaskEdited(Payload): + def __init__(self, task_id: str, status: str): + super().__init__({ + 'task_id': task_id, + 'status': status + }) + + @property + def task_id(self) -> str: + return self.get("task_id") + + @property + def status(self) -> str: + return self.get("status") + + diff --git a/events/types/task_set.py b/events/types/task_set.py new file mode 100644 index 0000000..5fa250e --- /dev/null +++ b/events/types/task_set.py @@ -0,0 +1,19 @@ +from events.types.payload import Payload + + +class TaskSet(Payload): + def __init__(self, child_id: str, status: str): + super().__init__({ + 'child_id': child_id, + 'status': status + }) + + @property + def child_id(self) -> str: + return self.get("child_id") + + @property + def status(self) -> str: + return self.get("status") + + diff --git a/events/types/task_test.py b/events/types/task_test.py new file mode 100644 index 0000000..6a8754f --- /dev/null +++ b/events/types/task_test.py @@ -0,0 +1,17 @@ +from events.types.payload import Payload + + +class TaskTest(Payload): + def __init__(self, task_id: str, message: str): + super().__init__({ + 'task_id': task_id, + 'message': message + }) + + @property + def task_id(self) -> str: + return self.get("task_id") + + @property + def message(self) -> str: + return self.get("message") diff --git a/events/types/task_update.py b/events/types/task_update.py new file mode 100644 index 0000000..aee1594 --- /dev/null +++ b/events/types/task_update.py @@ -0,0 +1,29 @@ +from events.types.payload import Payload + + +class TaskUpdate(Payload): + def __init__(self, task_id: str, child_id: str, status: str, points: int): + super().__init__({ + 'task_id': task_id, + 'child_id': child_id, + 'status': status, + 'points': points + }) + + @property + def task_id(self) -> str: + return self.get("task_id") + + @property + def child_id(self) -> str: + return self.get("child_id") + + @property + def status(self) -> str: + return self.get("status") + + @property + def points(self) -> int: + return self.get("points", 0) + + diff --git a/main.py b/main.py index 9ff234d..a16db46 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,12 @@ -from flask import Flask +from flask import Flask, request from flask_cors import CORS + from api.child_api import child_api +from api.image_api import image_api from api.reward_api import reward_api from api.task_api import task_api -from api.image_api import image_api +from events.broadcaster import Broadcaster +from events.sse import sse_response_for_user, send_to_user app = Flask(__name__) #CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}}) @@ -13,7 +16,36 @@ app.register_blueprint(task_api) app.register_blueprint(image_api) CORS(app) +@app.route("/events") +def events(): + # Authenticate user or read a token + user_id = request.args.get("user_id") + + if not user_id: + return {"error": "Missing user_id"}, 400 + + + return sse_response_for_user(user_id) + + +@app.route("/notify/") +def notify_user(user_id): + # Example trigger + send_to_user(user_id, { + "type": "notification", + "message": f"Hello {user_id}, this is a private message!" + }) + + return {"status": "sent"} + +def start_background_threads(): + broadcaster = Broadcaster() + broadcaster.daemon = True + broadcaster.start() + +# Initialize background workers on server start +start_background_threads() if __name__ == '__main__': - app.run(debug=False, host='0.0.0.0', port=5000) \ No newline at end of file + app.run(debug=False, host='0.0.0.0', port=5000, threaded=True) \ No newline at end of file diff --git a/models/base.py b/models/base.py index 79b9878..86545cc 100644 --- a/models/base.py +++ b/models/base.py @@ -1,18 +1,13 @@ +# python from dataclasses import dataclass, field import uuid import time -@dataclass +@dataclass(kw_only=True) class BaseModel: - id: str = field(init=False) - created_at: float = field(init=False) - updated_at: float = field(init=False) - - def __post_init__(self): - self.id = str(uuid.uuid4()) - now = time.time() - self.created_at = now - self.updated_at = now + id: str = field(default_factory=lambda: str(uuid.uuid4())) + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) def touch(self): self.updated_at = time.time() @@ -23,10 +18,3 @@ class BaseModel: 'created_at': self.created_at, 'updated_at': self.updated_at } - - @classmethod - def _apply_base_fields(cls, obj, d: dict): - obj.id = d.get('id', obj.id) - obj.created_at = d.get('created_at', obj.created_at) - obj.updated_at = d.get('updated_at', obj.updated_at) - return obj diff --git a/models/child.py b/models/child.py index 33331f2..768e638 100644 --- a/models/child.py +++ b/models/child.py @@ -12,15 +12,18 @@ class Child(BaseModel): @classmethod def from_dict(cls, d: dict): - obj = cls( + return cls( name=d.get('name'), age=d.get('age'), tasks=d.get('tasks', []), rewards=d.get('rewards', []), points=d.get('points', 0), - image_id=d.get('image_id') + image_id=d.get('image_id'), + id=d.get('id'), + created_at=d.get('created_at'), + updated_at=d.get('updated_at') + ) - return cls._apply_base_fields(obj, d) def to_dict(self): base = super().to_dict() diff --git a/models/image.py b/models/image.py index 046cf67..76b56b7 100644 --- a/models/image.py +++ b/models/image.py @@ -1,3 +1,4 @@ +# python from dataclasses import dataclass from models.base import BaseModel @@ -9,12 +10,15 @@ class Image(BaseModel): @classmethod def from_dict(cls, d: dict): - obj = cls( + # Supports overriding base fields (id, created_at, updated_at) if present + return cls( type=d.get('type'), extension=d.get('extension'), - permanent=d.get('permanent', False) + permanent=d.get('permanent', False), + id=d.get('id'), + created_at=d.get('created_at'), + updated_at=d.get('updated_at') ) - return cls._apply_base_fields(obj, d) def to_dict(self): base = super().to_dict() diff --git a/models/reward.py b/models/reward.py index c8ae1ac..2d05dfa 100644 --- a/models/reward.py +++ b/models/reward.py @@ -1,3 +1,4 @@ +# python from dataclasses import dataclass from models.base import BaseModel @@ -10,13 +11,16 @@ class Reward(BaseModel): @classmethod def from_dict(cls, d: dict): - obj = cls( + # Base fields are keyword-only; can be overridden if provided + return cls( name=d.get('name'), description=d.get('description'), cost=d.get('cost', 0), - image_id=d.get('image_id') + image_id=d.get('image_id'), + id=d.get('id'), + created_at=d.get('created_at'), + updated_at=d.get('updated_at') ) - return cls._apply_base_fields(obj, d) def to_dict(self): base = super().to_dict() diff --git a/models/task.py b/models/task.py index 0a22bc1..3cc109b 100644 --- a/models/task.py +++ b/models/task.py @@ -10,13 +10,15 @@ class Task(BaseModel): @classmethod def from_dict(cls, d: dict): - obj = cls( + return cls( name=d.get('name'), points=d.get('points', 0), is_good=d.get('is_good', True), - image_id=d.get('image_id') + image_id=d.get('image_id'), + id=d.get('id'), + created_at=d.get('created_at'), + updated_at=d.get('updated_at') ) - return cls._apply_base_fields(obj, d) def to_dict(self): base = super().to_dict() diff --git a/tests/test_child_api.py b/tests/test_child_api.py index 41f098b..c28b100 100644 --- a/tests/test_child_api.py +++ b/tests/test_child_api.py @@ -125,9 +125,9 @@ def test_list_child_tasks_returns_tasks(client): resp = client.get('/child/child_list_1/list-tasks') assert resp.status_code == 200 data = resp.get_json() - returned_ids = {t['id'] for t in data['child_tasks']} + returned_ids = {t['id'] for t in data['tasks']} assert returned_ids == {'t_list_1', 't_list_2'} - for t in data['child_tasks']: + for t in data['tasks']: assert 'name' in t and 'points' in t and 'is_good' in t def test_list_assignable_tasks_returns_expected_ids(client): @@ -143,8 +143,8 @@ def test_list_assignable_tasks_returns_expected_ids(client): resp = client.get(f'/child/{child_id}/list-assignable-tasks') assert resp.status_code == 200 data = resp.get_json() - assert len(data['assignable_tasks']) == 1 - assert data['assignable_tasks'][0]['id'] == 'tB' + assert len(data['tasks']) == 1 + assert data['tasks'][0]['id'] == 'tB' assert data['count'] == 1 def test_list_assignable_tasks_when_none_assigned(client): @@ -158,7 +158,7 @@ def test_list_assignable_tasks_when_none_assigned(client): resp = client.get(f'/child/{child_id}/list-assignable-tasks') assert resp.status_code == 200 data = resp.get_json() - returned_ids = {t['id'] for t in data['assignable_tasks']} + returned_ids = {t['id'] for t in data['tasks']} assert returned_ids == set(ids) assert data['count'] == len(ids) @@ -170,10 +170,60 @@ def test_list_assignable_tasks_empty_task_db(client): resp = client.get(f'/child/{child_id}/list-assignable-tasks') assert resp.status_code == 200 data = resp.get_json() - assert data['assignable_tasks'] == [] + assert data['tasks'] == [] assert data['count'] == 0 def test_list_assignable_tasks_child_not_found(client): resp = client.get('/child/does-not-exist/list-assignable-tasks') assert resp.status_code == 404 assert b'Child not found' in resp.data + +def setup_child_with_tasks(child_name='TestChild', age=8, assigned=None): + child_db.truncate() + task_db.truncate() + assigned = assigned or [] + # Seed tasks + task_db.insert({'id': 't1', 'name': 'Task 1', 'points': 1, 'is_good': True}) + task_db.insert({'id': 't2', 'name': 'Task 2', 'points': 2, 'is_good': False}) + task_db.insert({'id': 't3', 'name': 'Task 3', 'points': 3, 'is_good': True}) + # Seed child + child = Child(name=child_name, age=age, image_id='boy01').to_dict() + child['tasks'] = assigned[:] + child_db.insert(child) + return child['id'] + +def test_list_all_tasks_partitions_assigned_and_assignable(client): + child_id = setup_child_with_tasks(assigned=['t1', 't3']) + resp = client.get(f'/child/{child_id}/list-all-tasks') + assert resp.status_code == 200 + data = resp.get_json() + assigned_ids = {t['id'] for t in data['assigned_tasks']} + assignable_ids = {t['id'] for t in data['assignable_tasks']} + assert assigned_ids == {'t1', 't3'} + assert assignable_ids == {'t2'} + assert data['assigned_count'] == 2 + assert data['assignable_count'] == 1 + +def test_set_child_tasks_replaces_existing(client): + child_id = setup_child_with_tasks(assigned=['t1', 't2']) + # Provide new set including a valid and an invalid id (invalid should be filtered) + payload = {'task_ids': ['t3', 'missing', 't3']} + resp = client.put(f'/child/{child_id}/set-tasks', json=payload) + assert resp.status_code == 200 + data = resp.get_json() + assert data['task_ids'] == ['t3'] + assert data['count'] == 1 + ChildQuery = Query() + child = child_db.get(ChildQuery.id == child_id) + assert child['tasks'] == ['t3'] + +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'}) + assert resp.status_code == 400 + assert b'task_ids must be a list' 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']}) + assert resp.status_code == 404 + assert b'Child not found' in resp.data diff --git a/web/vue-app/src/App.vue b/web/vue-app/src/App.vue index fd4183e..f4cafe0 100644 --- a/web/vue-app/src/App.vue +++ b/web/vue-app/src/App.vue @@ -1,4 +1,7 @@ - + @@ -74,7 +205,7 @@ onMounted(async () => { max-width: 1200px; margin: 0 auto; min-height: 100vh; - padding: 2rem; + padding: 0.5rem; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-sizing: border-box; } diff --git a/web/vue-app/src/components/child/ParentView.vue b/web/vue-app/src/components/child/ParentView.vue index c3c0d07..b043ef4 100644 --- a/web/vue-app/src/components/child/ParentView.vue +++ b/web/vue-app/src/components/child/ParentView.vue @@ -1,45 +1,188 @@ @@ -89,16 +319,6 @@ const refreshRewards = () => { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-sizing: border-box; } -.back-btn { - background: white; - border: 0; - padding: 0.6rem 1rem; - border-radius: 8px; - cursor: pointer; - margin-bottom: 1.5rem; - color: #667eea; - font-weight: 600; -} .loading, .error { color: white; @@ -118,9 +338,6 @@ const refreshRewards = () => { display: flex; justify-content: center; align-items: flex-start; - /* Remove grid styles */ - /* grid-template-columns: 1fr 320px; */ - /* gap: 1.5rem; */ } .main { display: flex; @@ -130,22 +347,84 @@ const refreshRewards = () => { width: 100%; max-width: 600px; /* or whatever width fits your content best */ } -.side { - display: flex; - flex-direction: column; - gap: 1rem; -} -.placeholder { - background: rgba(255, 255, 255, 0.08); - color: white; - padding: 1rem; - border-radius: 8px; - min-height: 120px; + +/* Modal styles */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; + z-index: 1200; +} +.modal { + background: #fff; + color: #222; + padding: 1.5rem 2rem; + border-radius: 12px; + min-width: 240px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2); text-align: center; } +.task-info { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} +.task-image { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 8px; + background: #eee; +} +.task-details { + display: flex; + flex-direction: column; + align-items: flex-start; +} +.task-name { + font-weight: 600; + font-size: 1.1rem; +} +.task-points, +.task-points.good, +.task-points.bad { + font-weight: 600; + font-size: 1.1rem; +} +.task-points.good { + color: #38c172; +} +.task-points.bad { + color: #ef4444; +} +.actions { + margin-top: 1.2rem; + display: flex; + gap: 1rem; + justify-content: center; +} +.actions button { + padding: 0.5rem 1.2rem; + border-radius: 8px; + border: none; + font-weight: 600; + cursor: pointer; +} +.actions button:first-child { + background: #667eea; + color: #fff; +} +.actions button:last-child { + background: #f3f3f3; + color: #666; +} +.actions button:last-child:hover { + background: #e2e8f0; +} /* Mobile adjustments */ @media (max-width: 900px) { @@ -158,17 +437,83 @@ const refreshRewards = () => { .container { padding: 1rem; } - .back-btn { - padding: 0.45rem 0.75rem; - font-size: 0.95rem; - margin-bottom: 1rem; - } .main { gap: 1rem; } - .placeholder { - padding: 0.75rem; - min-height: 80px; - } +} + +.dialog-message { + font-size: 1.08rem; + color: #444; + font-weight: 500; +} +.dialog-message .child-name { + color: #667eea; + font-weight: 700; + margin-left: 2px; +} +.reward-info { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} +.reward-image { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 8px; + background: #eee; +} +.reward-details { + display: flex; + flex-direction: column; + align-items: flex-start; +} +.reward-name { + font-weight: 600; + font-size: 1.1rem; +} +.reward-points { + color: #667eea; + font-weight: 500; + font-size: 1rem; +} +.assign-buttons { + display: flex; + gap: 1rem; + justify-content: center; + margin: 2rem 0; +} +.assign-task-btn, +.assign-bad-btn, +.assign-reward-btn { + font-weight: 600; + border: none; + border-radius: 8px; + padding: 0.7rem 1.5rem; + font-size: 1.1rem; + cursor: pointer; + box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08); + transition: background 0.18s; + color: #fff; + background: #667eea; +} +.assign-task-btn:hover, +.assign-bad-btn:hover, +.assign-reward-btn:hover { + background: #5a67d8; +} +.assign-bad-btn { + background: #ef4444; +} +.assign-bad-btn:hover { + background: #dc2626; +} +.assign-reward-btn { + background: #38c172; +} +.assign-reward-btn:hover { + background: #2f855a; } diff --git a/web/vue-app/src/components/child/RewardAssignView.vue b/web/vue-app/src/components/child/RewardAssignView.vue new file mode 100644 index 0000000..2fbc516 --- /dev/null +++ b/web/vue-app/src/components/child/RewardAssignView.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/web/vue-app/src/components/child/TaskAssignView.vue b/web/vue-app/src/components/child/TaskAssignView.vue new file mode 100644 index 0000000..f072187 --- /dev/null +++ b/web/vue-app/src/components/child/TaskAssignView.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/web/vue-app/src/components/reward/ChildRewardList.vue b/web/vue-app/src/components/reward/ChildRewardList.vue index de24bed..a9f552a 100644 --- a/web/vue-app/src/components/reward/ChildRewardList.vue +++ b/web/vue-app/src/components/reward/ChildRewardList.vue @@ -2,23 +2,18 @@ import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' import { defineProps, defineEmits, defineExpose } from 'vue' import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache' - -interface Reward { - id: string - name: string - points_needed: number - image_id: string | null -} +import type { RewardStatus } from '@/common/models' const imageCacheName = 'images-v1' const props = defineProps<{ childId: string | number | null + childPoints: number isParentAuthenticated: boolean }>() -const emit = defineEmits(['points-updated']) +const emit = defineEmits(['points-updated', 'trigger-reward']) -const rewards = ref([]) +const rewards = ref([]) const loading = ref(true) const error = ref(null) @@ -53,7 +48,7 @@ const fetchRewards = async (id: string | number | null) => { } } -const fetchImage = async (reward: Reward) => { +const fetchImage = async (reward: RewardStatus) => { if (!reward.image_id) { console.log(`No image ID for reward: ${reward.id}`) return @@ -86,8 +81,6 @@ const centerReward = async (rewardId: string) => { } const handleRewardClick = async (rewardId: string) => { - if (!props.isParentAuthenticated) return // Only allow if logged in - await nextTick() const wrapper = scrollWrapper.value const card = rewardRefs.value[rewardId] @@ -111,24 +104,10 @@ const handleRewardClick = async (rewardId: string) => { readyRewardId.value = null } -const triggerReward = async (rewardId: string) => { - if (!props.childId) return +const triggerReward = (rewardId: string) => { const reward = rewards.value.find((rew) => rew.id === rewardId) - if (!reward || reward.points_needed > 0) return // Don't trigger if not allowed - if (!props.isParentAuthenticated) return // Only allow if logged in - try { - const resp = await fetch(`/api/child/${props.childId}/trigger-reward`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ reward_id: rewardId }), - }) - if (!resp.ok) return - const data = await resp.json() - // Emit the new points so the parent can update the child points - emit('points-updated', { id: props.childId, points: data.points }) - } catch (err) { - console.error('Failed to trigger reward:', err) - } + if (!reward) return // Don't trigger if not allowed + emit('trigger-reward', reward, reward.points_needed <= 0) } onMounted(() => fetchRewards(props.childId)) @@ -136,6 +115,18 @@ watch( () => props.childId, (v) => fetchRewards(v), ) +watch( + () => props.childPoints, + () => { + // Option 1: If reward eligibility depends on points, recompute eligibility here + // Option 2: If you need to refetch from the backend, call fetchRewards(props.childId) + // For most cases, just recompute eligibility locally + // Example: + rewards.value.forEach((reward) => { + reward.points_needed = Math.max(0, reward.cost - props.childPoints) + }) + }, +) // revoke created object URLs when component unmounts to avoid memory leaks onBeforeUnmount(() => { @@ -162,7 +153,6 @@ defineExpose({ refresh: () => fetchRewards(props.childId) }) class="reward-card" :class="{ ready: readyRewardId === r.id, - disabled: r.points_needed > 0, }" :ref="(el) => (rewardRefs[r.id] = el)" @click="() => handleRewardClick(r.id)" @@ -191,7 +181,7 @@ defineExpose({ refresh: () => fetchRewards(props.childId) }) } .reward-list-container h3 { - margin: 0 0 0.75rem 0; + margin: 0; font-size: 1.05rem; font-weight: 600; } diff --git a/web/vue-app/src/components/reward/RewardList.vue b/web/vue-app/src/components/reward/RewardList.vue index 90052d3..ef80bb7 100644 --- a/web/vue-app/src/components/reward/RewardList.vue +++ b/web/vue-app/src/components/reward/RewardList.vue @@ -1,10 +1,12 @@