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)))
|
||||
|
||||
Reference in New Issue
Block a user