feat: Implement task and reward tracking feature
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 24s

- Added tracking events for tasks, penalties, and rewards with timestamps.
- Created new TinyDB table for tracking records to maintain audit history.
- Developed backend API for querying tracking events with filters and pagination.
- Implemented logging for tracking events with per-user rotating log files.
- Added unit tests for tracking event creation, querying, and anonymization.
- Deferred frontend changes for future implementation.
- Established acceptance criteria and documentation for the tracking feature.

feat: Introduce account deletion scheduler

- Implemented a scheduler to delete accounts marked for deletion after a configurable threshold.
- Added new fields to the User model to manage deletion status and attempts.
- Created admin API endpoints for managing deletion thresholds and viewing the deletion queue.
- Integrated error handling and logging for the deletion process.
- Developed unit tests for the deletion scheduler and related API endpoints.
- Documented the deletion process and acceptance criteria.
This commit is contained in:
2026-02-09 15:39:43 -05:00
parent 27f02224ab
commit 3dee8b80a2
20 changed files with 1450 additions and 0 deletions

View File

@@ -9,19 +9,23 @@ from api.pending_reward import PendingReward as PendingRewardResponse
from api.reward_status import RewardStatus
from api.utils import send_event_for_current_user
from db.db import child_db, task_db, reward_db, pending_reward_db
from db.tracking import insert_tracking_event
from events.types.child_modified import ChildModified
from events.types.child_reward_request import ChildRewardRequest
from events.types.child_reward_triggered import ChildRewardTriggered
from events.types.child_rewards_set import ChildRewardsSet
from events.types.child_task_triggered import ChildTaskTriggered
from events.types.child_tasks_set import ChildTasksSet
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_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
import logging
@@ -364,14 +368,38 @@ def trigger_child_task(id):
if not task_result:
return jsonify({'error': 'Task not found in task database'}), 404
task: Task = Task.from_dict(task_result[0])
# Capture points before modification
points_before = child.points
# update the child's points based on task type
if task.is_good:
child.points += task.points
else:
child.points -= task.points
child.points = max(child.points, 0)
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
# Create tracking event
entity_type = 'penalty' if not task.is_good else 'task'
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
entity_type=entity_type,
entity_id=task.id,
action='activated',
points_before=points_before,
points_after=child.points,
metadata={'task_name': task.name, 'is_good': task.is_good}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send tracking event via SSE
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, entity_type, 'activated')))
resp = send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
if resp:
return resp
@@ -609,10 +637,31 @@ def trigger_child_reward(id):
if removed:
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
# Capture points before modification
points_before = child.points
# update the child's points based on reward cost
child.points -= reward.cost
# update the child in the database
child_db.update({'points': child.points}, ChildQuery.id == id)
# Create tracking event
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
entity_type='reward',
entity_id=reward.id,
action='redeemed',
points_before=points_before,
points_after=child.points,
metadata={'reward_name': reward.name, 'reward_cost': reward.cost}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send tracking event via SSE
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'redeemed')))
send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
@@ -707,6 +756,24 @@ def request_reward(id):
pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id)
pending_reward_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)
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
entity_type='reward',
entity_id=reward.id,
action='requested',
points_before=child.points,
points_after=child.points,
metadata={'reward_name': reward.name, 'reward_cost': reward.cost}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send tracking event via SSE
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested')))
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
return jsonify({
'message': f'Reward request for {reward.name} submitted for {child.name}.',
@@ -734,6 +801,12 @@ def cancel_request_reward(id):
child = Child.from_dict(result[0])
# Fetch reward details for tracking metadata
RewardQuery = Query()
reward_result = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
reward_name = reward_result.get('name') if reward_result else 'Unknown'
reward_cost = reward_result.get('cost', 0) if reward_result else 0
# Remove matching pending reward request
PendingQuery = Query()
removed = pending_reward_db.remove(
@@ -743,6 +816,23 @@ def cancel_request_reward(id):
if not removed:
return jsonify({'error': 'No pending request found for this reward'}), 404
# Create tracking event (no points change on cancel)
tracking_event = TrackingEvent.create_event(
user_id=user_id,
child_id=child.id,
entity_type='reward',
entity_id=reward_id,
action='cancelled',
points_before=child.points,
points_after=child.points,
metadata={'reward_name': reward_name, 'reward_cost': reward_cost}
)
insert_tracking_event(tracking_event)
log_tracking_event(tracking_event)
# Send tracking event via SSE
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'cancelled')))
# Notify user that the request was cancelled
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
if resp:

122
backend/api/tracking_api.py Normal file
View File

@@ -0,0 +1,122 @@
from flask import Blueprint, request, jsonify
from api.utils import get_validated_user_id
from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
from models.tracking_event import TrackingEvent
from functools import wraps
import jwt
from tinydb import Query
from db.db import users_db
from models.user import User
tracking_api = Blueprint('tracking_api', __name__)
def admin_required(f):
"""
Decorator to require admin role for endpoints.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Get JWT token from cookie
token = request.cookies.get('token')
if not token:
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
try:
# Verify JWT token
payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256'])
user_id = payload.get('user_id')
if not user_id:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
# Get user from database
Query_ = Query()
user_dict = users_db.get(Query_.id == user_id)
if not user_dict:
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
user = User.from_dict(user_dict)
# Check if user has admin role
if user.role != 'admin':
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
# Store user_id in request context
request.admin_user_id = user_id
return f(*args, **kwargs)
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
return decorated_function
@tracking_api.route('/admin/tracking', methods=['GET'])
@admin_required
def get_tracking():
"""
Admin endpoint to query tracking events with filters and pagination.
Query params:
- child_id: Filter by child ID (optional)
- user_id: Filter by user ID (optional, admin only)
- entity_type: Filter by entity type (task/reward/penalty) (optional)
- action: Filter by action type (activated/requested/redeemed/cancelled) (optional)
- limit: Max results (default 50, max 500)
- offset: Pagination offset (default 0)
"""
child_id = request.args.get('child_id')
filter_user_id = request.args.get('user_id')
entity_type = request.args.get('entity_type')
action = request.args.get('action')
limit = int(request.args.get('limit', 50))
offset = int(request.args.get('offset', 0))
# Validate limit
limit = min(max(limit, 1), 500)
offset = max(offset, 0)
# Validate filters
if entity_type and entity_type not in ['task', 'reward', 'penalty']:
return jsonify({'error': 'Invalid entity_type', 'code': 'INVALID_ENTITY_TYPE'}), 400
if action and action not in ['activated', 'requested', 'redeemed', 'cancelled']:
return jsonify({'error': 'Invalid action', 'code': 'INVALID_ACTION'}), 400
# Query tracking events
if child_id:
events, total = get_tracking_events_by_child(
child_id=child_id,
limit=limit,
offset=offset,
entity_type=entity_type,
action=action
)
elif filter_user_id:
events, total = get_tracking_events_by_user(
user_id=filter_user_id,
limit=limit,
offset=offset,
entity_type=entity_type
)
else:
return jsonify({
'error': 'Either child_id or user_id is required',
'code': 'MISSING_FILTER'
}), 400
# Convert to dict
events_data = [event.to_dict() for event in events]
return jsonify({
'tracking_events': events_data,
'total': total,
'limit': limit,
'offset': offset,
'count': len(events_data)
}), 200