This commit is contained in:
265
backend/api/auth_api.py
Normal file
265
backend/api/auth_api.py
Normal file
@@ -0,0 +1,265 @@
|
||||
import logging
|
||||
import secrets, jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from models.user import User
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_mail import Mail, Message
|
||||
from tinydb import Query
|
||||
import os
|
||||
|
||||
from api.utils import sanitize_email
|
||||
from config.paths import get_user_image_dir
|
||||
|
||||
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \
|
||||
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
|
||||
NOT_VERIFIED
|
||||
from db.db import users_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
auth_api = Blueprint('auth_api', __name__)
|
||||
UserQuery = Query()
|
||||
mail = Mail()
|
||||
TOKEN_EXPIRY_MINUTES = 60*4
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||
|
||||
|
||||
def send_verification_email(to_email, token):
|
||||
verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}"
|
||||
html_body = f'Click <a href="{verify_url}">here</a> to verify your account.'
|
||||
msg = Message(
|
||||
subject="Verify your account",
|
||||
recipients=[to_email],
|
||||
html=html_body,
|
||||
sender=current_app.config['MAIL_DEFAULT_SENDER']
|
||||
)
|
||||
mail.send(msg)
|
||||
|
||||
def send_reset_password_email(to_email, token):
|
||||
reset_url = f"{current_app.config['FRONTEND_URL']}/auth/reset-password?token={token}"
|
||||
html_body = f'Click <a href="{reset_url}">here</a> to reset your password.'
|
||||
msg = Message(
|
||||
subject="Reset your password",
|
||||
recipients=[to_email],
|
||||
html=html_body,
|
||||
sender=current_app.config['MAIL_DEFAULT_SENDER']
|
||||
)
|
||||
mail.send(msg)
|
||||
|
||||
@auth_api.route('/signup', methods=['POST'])
|
||||
def signup():
|
||||
data = request.get_json()
|
||||
required_fields = ['first_name', 'last_name', 'email', 'password']
|
||||
if not all(field in data for field in required_fields):
|
||||
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
|
||||
|
||||
if users_db.search(UserQuery.email == data['email']):
|
||||
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user = User(
|
||||
first_name=data['first_name'],
|
||||
last_name=data['last_name'],
|
||||
email=data['email'],
|
||||
password=data['password'], # Hash in production!
|
||||
verified=False,
|
||||
verify_token=token,
|
||||
verify_token_created=now_iso,
|
||||
image_id="boy01"
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
send_verification_email(data['email'], token)
|
||||
return jsonify({'message': 'User created, verification email sent'}), 201
|
||||
|
||||
@auth_api.route('/verify', methods=['GET'])
|
||||
def verify():
|
||||
token = request.args.get('token')
|
||||
status = 'success'
|
||||
reason = ''
|
||||
code = ''
|
||||
user_dict = None
|
||||
user = None
|
||||
|
||||
if not token:
|
||||
status = 'error'
|
||||
reason = 'Missing token'
|
||||
code = MISSING_TOKEN
|
||||
else:
|
||||
user_dict = users_db.get(Query().verify_token == token)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
status = 'error'
|
||||
reason = 'Invalid token'
|
||||
code = INVALID_TOKEN
|
||||
else:
|
||||
created_str = user.verify_token_created
|
||||
if not created_str:
|
||||
status = 'error'
|
||||
reason = 'Token timestamp missing'
|
||||
code = TOKEN_TIMESTAMP_MISSING
|
||||
else:
|
||||
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=TOKEN_EXPIRY_MINUTES):
|
||||
status = 'error'
|
||||
reason = 'Token expired'
|
||||
code = TOKEN_EXPIRED
|
||||
else:
|
||||
user.verified = True
|
||||
user.verify_token = None
|
||||
user.verify_token_created = None
|
||||
users_db.update(user.to_dict(), Query().email == user.email)
|
||||
|
||||
http_status = 200 if status == 'success' else 400
|
||||
if http_status == 200 and user is not None:
|
||||
if not user.email:
|
||||
logger.error("Verified user has no email field.")
|
||||
else:
|
||||
user_image_dir = get_user_image_dir(sanitize_email(user.email))
|
||||
os.makedirs(user_image_dir, exist_ok=True)
|
||||
|
||||
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
|
||||
|
||||
@auth_api.route('/resend-verify', methods=['POST'])
|
||||
def resend_verify():
|
||||
data = request.get_json()
|
||||
email = data.get('email')
|
||||
if not email:
|
||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||
|
||||
if user.verified:
|
||||
return jsonify({'error': 'Account already verified', 'code': ALREADY_VERIFIED}), 400
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user.verify_token = token
|
||||
user.verify_token_created = now_iso
|
||||
users_db.update(user.to_dict(), UserQuery.email == email)
|
||||
send_verification_email(email, token)
|
||||
return jsonify({'message': 'Verification email resent'}), 200
|
||||
|
||||
@auth_api.route('/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
email = data.get('email')
|
||||
password = data.get('password')
|
||||
if not email or not password:
|
||||
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user or user.password != password:
|
||||
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
|
||||
|
||||
if not user.verified:
|
||||
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
||||
|
||||
payload = {
|
||||
'email': email,
|
||||
'exp': datetime.utcnow() + timedelta(hours=24*7)
|
||||
}
|
||||
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
resp = jsonify({'message': 'Login successful'})
|
||||
resp.set_cookie('token', token, httponly=True, secure=True, samesite='Strict')
|
||||
return resp, 200
|
||||
|
||||
@auth_api.route('/me', methods=['GET'])
|
||||
def me():
|
||||
token = request.cookies.get('token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
email = payload.get('email')
|
||||
user_dict = users_db.get(UserQuery.email == email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||
return jsonify({
|
||||
'email': user.email,
|
||||
'id': sanitize_email(user.email),
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'verified': user.verified
|
||||
}), 200
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
|
||||
|
||||
@auth_api.route('/request-password-reset', methods=['POST'])
|
||||
def request_password_reset():
|
||||
data = request.get_json()
|
||||
email = data.get('email')
|
||||
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
|
||||
|
||||
if not email:
|
||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if user:
|
||||
token = secrets.token_urlsafe(32)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user.reset_token = token
|
||||
user.reset_token_created = now_iso
|
||||
users_db.update(user.to_dict(), UserQuery.email == email)
|
||||
send_reset_password_email(email, token)
|
||||
|
||||
return jsonify({'message': success_msg}), 200
|
||||
|
||||
@auth_api.route('/validate-reset-token', methods=['GET'])
|
||||
def validate_reset_token():
|
||||
token = request.args.get('token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.reset_token == token)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
|
||||
|
||||
created_str = user.reset_token_created
|
||||
if not created_str:
|
||||
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
|
||||
|
||||
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||
|
||||
return jsonify({'message': 'Token is valid'}), 200
|
||||
|
||||
@auth_api.route('/reset-password', methods=['POST'])
|
||||
def reset_password():
|
||||
data = request.get_json()
|
||||
token = data.get('token')
|
||||
new_password = data.get('password')
|
||||
|
||||
if not token or not new_password:
|
||||
return jsonify({'error': 'Missing token or password'}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.reset_token == token)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
|
||||
|
||||
created_str = user.reset_token_created
|
||||
if not created_str:
|
||||
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
|
||||
|
||||
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||
|
||||
user.password = new_password # Hash in production!
|
||||
user.reset_token = None
|
||||
user.reset_token_created = None
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
|
||||
return jsonify({'message': 'Password has been reset'}), 200
|
||||
690
backend/api/child_api.py
Normal file
690
backend/api/child_api.py
Normal file
@@ -0,0 +1,690 @@
|
||||
from time import sleep
|
||||
|
||||
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.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 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.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
|
||||
import logging
|
||||
|
||||
child_api = Blueprint('child_api', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@child_api.route('/child/<name>', methods=['GET'])
|
||||
@child_api.route('/child/<id>', methods=['GET'])
|
||||
def get_child(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
return jsonify(Child.from_dict(result[0]).to_dict()), 200
|
||||
|
||||
@child_api.route('/child/add', methods=['PUT'])
|
||||
def add_child():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
age = data.get('age')
|
||||
image = data.get('image_id', None)
|
||||
if not name:
|
||||
return jsonify({'error': 'Name is required'}), 400
|
||||
if not image:
|
||||
image = 'boy01'
|
||||
|
||||
child = Child(name=name, age=age, image_id=image)
|
||||
child_db.insert(child.to_dict())
|
||||
resp = send_event_for_current_user(
|
||||
Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({'message': f'Child {name} added.'}), 201
|
||||
|
||||
@child_api.route('/child/<id>/edit', methods=['PUT'])
|
||||
def edit_child(id):
|
||||
data = request.get_json()
|
||||
name = data.get('name', None)
|
||||
age = data.get('age', None)
|
||||
points = data.get('points', None)
|
||||
image = data.get('image_id', None)
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
child = Child.from_dict(result[0])
|
||||
|
||||
if name is not None:
|
||||
child.name = name
|
||||
if age is not None:
|
||||
child.age = age
|
||||
if points is not None:
|
||||
child.points = points
|
||||
if image is not None:
|
||||
child.image_id = image
|
||||
|
||||
# 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)
|
||||
|
||||
RewardQuery = Query()
|
||||
for pr in pending_rewards:
|
||||
pending = PendingReward.from_dict(pr)
|
||||
reward_result = reward_db.get(RewardQuery.id == pending.reward_id)
|
||||
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)
|
||||
)
|
||||
resp = send_event_for_current_user(
|
||||
Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||
if resp:
|
||||
return resp
|
||||
child_db.update(child.to_dict(), ChildQuery.id == id)
|
||||
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({'message': f'Child {id} updated.'}), 200
|
||||
|
||||
@child_api.route('/child/list', methods=['GET'])
|
||||
def list_children():
|
||||
children = child_db.all()
|
||||
return jsonify({'children': children}), 200
|
||||
|
||||
# Child DELETE
|
||||
@child_api.route('/child/<id>', methods=['DELETE'])
|
||||
def delete_child(id):
|
||||
ChildQuery = Query()
|
||||
if child_db.remove(ChildQuery.id == id):
|
||||
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({'message': f'Child {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
@child_api.route('/child/<id>/assign-task', methods=['POST'])
|
||||
def assign_task_to_child(id):
|
||||
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)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if task_id not in child.get('tasks', []):
|
||||
child['tasks'].append(task_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/<id>/set-tasks', methods=['PUT'])
|
||||
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'
|
||||
if not isinstance(task_ids, list):
|
||||
return jsonify({'error': 'task_ids must be a list'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
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'])
|
||||
|
||||
# Convert back to list if needed
|
||||
new_tasks = list(new_task_ids)
|
||||
# Replace tasks with validated IDs
|
||||
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
|
||||
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, new_tasks)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({
|
||||
'message': f'Tasks set for child {id}.',
|
||||
'task_ids': new_tasks,
|
||||
'count': len(new_tasks)
|
||||
}), 200
|
||||
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/remove-task', methods=['POST'])
|
||||
def remove_task_from_child(id):
|
||||
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)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if task_id in child.get('tasks', []):
|
||||
child['tasks'].remove(task_id)
|
||||
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
|
||||
return jsonify({'error': 'Task not assigned to child'}), 400
|
||||
|
||||
@child_api.route('/child/<id>/list-tasks', methods=['GET'])
|
||||
def list_child_tasks(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
task_ids = child.get('tasks', [])
|
||||
|
||||
TaskQuery = Query()
|
||||
child_tasks = []
|
||||
for tid in task_ids:
|
||||
task = task_db.get(TaskQuery.id == tid)
|
||||
if not task:
|
||||
continue
|
||||
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
|
||||
child_tasks.append(ct.to_dict())
|
||||
|
||||
return jsonify({'tasks': child_tasks}), 200
|
||||
|
||||
@child_api.route('/child/<id>/list-assignable-tasks', methods=['GET'])
|
||||
def list_assignable_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', []))
|
||||
|
||||
# Collect all task ids from the task database
|
||||
all_task_ids = [t.get('id') for t in task_db.all() if t and t.get('id')]
|
||||
|
||||
# Filter out already assigned
|
||||
assignable_ids = [tid for tid in all_task_ids if tid not in assigned_ids]
|
||||
|
||||
# Fetch full task details and wrap in ChildTask
|
||||
TaskQuery = Query()
|
||||
assignable_tasks = []
|
||||
for tid in assignable_ids:
|
||||
task = task_db.get(TaskQuery.id == tid)
|
||||
if not task:
|
||||
continue
|
||||
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
|
||||
assignable_tasks.append(ct.to_dict())
|
||||
|
||||
return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/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
|
||||
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'
|
||||
|
||||
child = result[0]
|
||||
assigned_ids = set(child.get('tasks', []))
|
||||
|
||||
# Get all tasks from database
|
||||
all_tasks = task_db.all()
|
||||
|
||||
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')
|
||||
)
|
||||
task_dict = ct.to_dict()
|
||||
if has_type and task.get('is_good') != good:
|
||||
continue
|
||||
|
||||
task_dict.update({'assigned': task.get('id') in assigned_ids})
|
||||
tasks.append(task_dict)
|
||||
tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
||||
|
||||
return jsonify({ 'tasks': tasks, 'count': len(tasks), 'list_type': 'task' }), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
|
||||
def trigger_child_task(id):
|
||||
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)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child: Child = Child.from_dict(result[0])
|
||||
if task_id not in child.tasks:
|
||||
return jsonify({'error': f'Task not found assigned to child {child.name}'}), 404
|
||||
# look up the task and get the details
|
||||
TaskQuery = Query()
|
||||
task_result = task_db.search(TaskQuery.id == task_id)
|
||||
if not task_result:
|
||||
return jsonify({'error': 'Task not found in task database'}), 404
|
||||
task: Task = Task.from_dict(task_result[0])
|
||||
# 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)
|
||||
resp = send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({'message': f'{task.name} points assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||
|
||||
@child_api.route('/child/<id>/assign-reward', methods=['POST'])
|
||||
def assign_reward_to_child(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if reward_id not in child.get('rewards', []):
|
||||
child['rewards'].append(reward_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/<id>/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()
|
||||
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')
|
||||
)
|
||||
|
||||
reward_dict = cr.to_dict()
|
||||
|
||||
reward_dict.update({'assigned': reward.get('id') in assigned_ids})
|
||||
rewards.append(reward_dict)
|
||||
rewards.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
||||
|
||||
return jsonify({
|
||||
'rewards': rewards,
|
||||
'rewards_count': len(rewards),
|
||||
'list_type': 'reward'
|
||||
}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/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)
|
||||
resp = send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({
|
||||
'message': f'Rewards set for child {id}.',
|
||||
'reward_ids': valid_reward_ids,
|
||||
'count': len(valid_reward_ids)
|
||||
}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/remove-reward', methods=['POST'])
|
||||
def remove_reward_from_child(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
if reward_id in child.get('rewards', []):
|
||||
child['rewards'].remove(reward_id)
|
||||
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
|
||||
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
|
||||
return jsonify({'error': 'Reward not assigned to child'}), 400
|
||||
|
||||
@child_api.route('/child/<id>/list-rewards', methods=['GET'])
|
||||
def list_child_rewards(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = result[0]
|
||||
reward_ids = child.get('rewards', [])
|
||||
|
||||
RewardQuery = Query()
|
||||
child_rewards = []
|
||||
for rid in reward_ids:
|
||||
reward = reward_db.get(RewardQuery.id == rid)
|
||||
if not reward:
|
||||
continue
|
||||
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
|
||||
child_rewards.append(cr.to_dict())
|
||||
|
||||
return jsonify({'rewards': child_rewards}), 200
|
||||
|
||||
@child_api.route('/child/<id>/list-assignable-rewards', methods=['GET'])
|
||||
def list_assignable_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', []))
|
||||
|
||||
all_reward_ids = [r.get('id') for r in reward_db.all() if r and r.get('id')]
|
||||
assignable_ids = [rid for rid in all_reward_ids if rid not in assigned_ids]
|
||||
|
||||
RewardQuery = Query()
|
||||
assignable_rewards = []
|
||||
for rid in assignable_ids:
|
||||
reward = reward_db.get(RewardQuery.id == rid)
|
||||
if not reward:
|
||||
continue
|
||||
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
|
||||
assignable_rewards.append(cr.to_dict())
|
||||
|
||||
return jsonify({'rewards': assignable_rewards, 'count': len(assignable_rewards)}), 200
|
||||
|
||||
@child_api.route('/child/<id>/trigger-reward', methods=['POST'])
|
||||
def trigger_child_reward(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child: Child = Child.from_dict(result[0])
|
||||
if reward_id not in child.rewards:
|
||||
return jsonify({'error': f'Reward not found assigned to child {child.name}'}), 404
|
||||
# look up the task and get the details
|
||||
RewardQuery = Query()
|
||||
reward_result = reward_db.search(RewardQuery.id == reward_id)
|
||||
if not reward_result:
|
||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||
reward: Reward = Reward.from_dict(reward_result[0])
|
||||
|
||||
# Check if child has enough points
|
||||
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points')
|
||||
if child.points < reward.cost:
|
||||
points_needed = reward.cost - child.points
|
||||
return jsonify({
|
||||
'error': 'Insufficient points',
|
||||
'points_needed': points_needed,
|
||||
'current_points': child.points,
|
||||
'reward_cost': reward.cost
|
||||
}), 400
|
||||
|
||||
# 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)
|
||||
)
|
||||
if removed:
|
||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||
|
||||
# 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)
|
||||
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
|
||||
|
||||
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
||||
def list_affordable_rewards(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
points = child.points
|
||||
reward_ids = child.rewards
|
||||
RewardQuery = Query()
|
||||
affordable = [
|
||||
Reward.from_dict(reward).to_dict() for reward_id in reward_ids
|
||||
if (reward := reward_db.get(RewardQuery.id == reward_id)) and points >= Reward.from_dict(reward).cost
|
||||
]
|
||||
return jsonify({'affordable_rewards': affordable}), 200
|
||||
|
||||
@child_api.route('/child/<id>/reward-status', methods=['GET'])
|
||||
def reward_status(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
points = child.points
|
||||
reward_ids = child.rewards
|
||||
|
||||
RewardQuery = Query()
|
||||
statuses = []
|
||||
for reward_id in reward_ids:
|
||||
reward: Reward = Reward.from_dict(reward_db.get(RewardQuery.id == reward_id))
|
||||
if not reward:
|
||||
continue
|
||||
points_needed = max(0, reward.cost - points)
|
||||
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true
|
||||
pending_query = Query()
|
||||
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id))
|
||||
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, pending is not None, reward.image_id)
|
||||
statuses.append(status.to_dict())
|
||||
|
||||
statuses.sort(key=lambda s: (not s['redeeming'], s['cost']))
|
||||
return jsonify({'reward_status': statuses}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/request-reward', methods=['POST'])
|
||||
def request_reward(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
if reward_id not in child.rewards:
|
||||
return jsonify({'error': f'Reward not assigned to child {child.name}'}), 404
|
||||
|
||||
RewardQuery = Query()
|
||||
reward_result = reward_db.search(RewardQuery.id == reward_id)
|
||||
if not reward_result:
|
||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||
|
||||
reward = Reward.from_dict(reward_result[0])
|
||||
|
||||
# Check if child has enough points
|
||||
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {reward.cost} points')
|
||||
if child.points < reward.cost:
|
||||
points_needed = reward.cost - child.points
|
||||
return jsonify({
|
||||
'error': 'Insufficient points',
|
||||
'points_needed': points_needed,
|
||||
'current_points': child.points,
|
||||
'reward_cost': reward.cost
|
||||
}), 400
|
||||
|
||||
pending = PendingReward(child_id=child.id, reward_id=reward.id)
|
||||
pending_reward_db.insert(pending.to_dict())
|
||||
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
|
||||
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}.',
|
||||
'reward_id': reward.id,
|
||||
'reward_name': reward.name,
|
||||
'child_id': child.id,
|
||||
'child_name': child.name,
|
||||
'cost': reward.cost
|
||||
}), 200
|
||||
|
||||
@child_api.route('/child/<id>/cancel-request-reward', methods=['POST'])
|
||||
def cancel_request_reward(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
|
||||
# Remove matching pending reward request
|
||||
PendingQuery = Query()
|
||||
removed = pending_reward_db.remove(
|
||||
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id)
|
||||
)
|
||||
|
||||
if not removed:
|
||||
return jsonify({'error': 'No pending request found for this reward'}), 404
|
||||
|
||||
# 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:
|
||||
return resp
|
||||
return jsonify({
|
||||
'message': f'Reward request cancelled for {child.name}.',
|
||||
'child_id': child.id,
|
||||
'reward_id': reward_id,
|
||||
'removed_count': len(removed)
|
||||
}), 200
|
||||
|
||||
|
||||
|
||||
@child_api.route('/pending-rewards', methods=['GET'])
|
||||
def list_pending_rewards():
|
||||
pending_rewards = pending_reward_db.all()
|
||||
reward_responses = []
|
||||
|
||||
RewardQuery = 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)
|
||||
if not reward_result:
|
||||
continue
|
||||
reward = Reward.from_dict(reward_result)
|
||||
|
||||
# Look up child details
|
||||
child_result = child_db.get(ChildQuery.id == pending.child_id)
|
||||
if not child_result:
|
||||
continue
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
# Create response object
|
||||
response = PendingRewardResponse(
|
||||
_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
|
||||
)
|
||||
reward_responses.append(response.to_dict())
|
||||
|
||||
return jsonify({'rewards': reward_responses, 'count': len(reward_responses), 'list_type': 'notification'}), 200
|
||||
|
||||
15
backend/api/child_rewards.py
Normal file
15
backend/api/child_rewards.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# api/child_rewards.py
|
||||
class ChildReward:
|
||||
def __init__(self, name: str, cost: int, image_id: str, reward_id: str):
|
||||
self.name = name
|
||||
self.cost = cost
|
||||
self.image_id = image_id
|
||||
self.id = reward_id
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'cost': self.cost,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
16
backend/api/child_tasks.py
Normal file
16
backend/api/child_tasks.py
Normal file
@@ -0,0 +1,16 @@
|
||||
class ChildTask:
|
||||
def __init__(self, name, is_good, points, image_id, id):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.is_good = is_good
|
||||
self.points = points
|
||||
self.image_id = image_id
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'is_good': self.is_good,
|
||||
'points': self.points,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
12
backend/api/error_codes.py
Normal file
12
backend/api/error_codes.py
Normal file
@@ -0,0 +1,12 @@
|
||||
MISSING_FIELDS = "MISSING_FIELDS"
|
||||
EMAIL_EXISTS = "EMAIL_EXISTS"
|
||||
MISSING_TOKEN = "MISSING_TOKEN"
|
||||
INVALID_TOKEN = "INVALID_TOKEN"
|
||||
TOKEN_TIMESTAMP_MISSING = "TOKEN_TIMESTAMP_MISSING"
|
||||
TOKEN_EXPIRED = "TOKEN_EXPIRED"
|
||||
MISSING_EMAIL = "MISSING_EMAIL"
|
||||
USER_NOT_FOUND = "USER_NOT_FOUND"
|
||||
ALREADY_VERIFIED = "ALREADY_VERIFIED"
|
||||
MISSING_EMAIL_OR_PASSWORD = "MISSING_EMAIL_OR_PASSWORD"
|
||||
INVALID_CREDENTIALS = "INVALID_CREDENTIALS"
|
||||
NOT_VERIFIED = "NOT_VERIFIED"
|
||||
108
backend/api/image_api.py
Normal file
108
backend/api/image_api.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
|
||||
from PIL import Image as PILImage, UnidentifiedImageError
|
||||
from flask import Blueprint, request, jsonify, send_file
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import get_current_user_id, sanitize_email
|
||||
from config.paths import get_user_image_dir
|
||||
|
||||
from db.db import image_db
|
||||
from models.image import Image
|
||||
|
||||
image_api = Blueprint('image_api', __name__)
|
||||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png'}
|
||||
IMAGE_TYPE_PROFILE = 1
|
||||
IMAGE_TYPE_ICON = 2
|
||||
MAX_DIMENSION = 512
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
@image_api.route('/image/upload', methods=['POST'])
|
||||
def upload():
|
||||
user_id = get_current_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'User not authenticated'}), 401
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file part in the request'}), 400
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({'error': 'No selected file'}), 400
|
||||
|
||||
image_type = request.form.get('type', None)
|
||||
if not image_type:
|
||||
return jsonify({'error': 'Image type is required'}), 400
|
||||
try:
|
||||
image_type = int(image_type)
|
||||
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
|
||||
return jsonify({'error': 'Invalid image type. Must be 1 or 2'}), 400
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Image type must be an integer'}), 400
|
||||
|
||||
perm = request.form.get('permanent', "false").lower() == 'true'
|
||||
|
||||
if not file or not allowed_file(file.filename):
|
||||
return jsonify({'error': 'Invalid file type or no file selected'}), 400
|
||||
|
||||
try:
|
||||
pil_image = PILImage.open(file.stream)
|
||||
original_format = pil_image.format # store before convert
|
||||
pil_image.verify() # quick integrity check
|
||||
file.stream.seek(0)
|
||||
pil_image = PILImage.open(file.stream).convert('RGBA' if pil_image.mode in ('RGBA', 'LA') else 'RGB')
|
||||
except UnidentifiedImageError:
|
||||
return jsonify({'error': 'Uploaded file is not a valid image'}), 400
|
||||
except Exception:
|
||||
return jsonify({'error': 'Failed to process image'}), 400
|
||||
|
||||
if original_format not in ('JPEG', 'PNG'):
|
||||
return jsonify({'error': 'Only JPEG and PNG images are allowed'}), 400
|
||||
|
||||
# Resize preserving aspect ratio (in-place thumbnail)
|
||||
pil_image.thumbnail((MAX_DIMENSION, MAX_DIMENSION), PILImage.Resampling.LANCZOS)
|
||||
|
||||
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
|
||||
extension = format_extension_map.get(original_format, '.png')
|
||||
image_record = Image(extension=extension, permanent=perm, type=image_type, user=user_id)
|
||||
filename = image_record.id + extension
|
||||
filepath = os.path.abspath(os.path.join(get_user_image_dir(sanitize_email(user_id)), filename))
|
||||
|
||||
try:
|
||||
# Save with appropriate format
|
||||
save_params = {}
|
||||
if pil_image.format == 'JPEG':
|
||||
save_params['quality'] = 90
|
||||
save_params['optimize'] = True
|
||||
pil_image.save(filepath, format=pil_image.format, **save_params)
|
||||
except Exception:
|
||||
return jsonify({'error': 'Failed to save processed image'}), 500
|
||||
|
||||
image_db.insert(image_record.to_dict())
|
||||
|
||||
return jsonify({'message': 'Image uploaded successfully', 'filename': filename, 'id': image_record.id}), 200
|
||||
|
||||
@image_api.route('/image/request/<id>', methods=['GET'])
|
||||
def request_image(id):
|
||||
ImageQuery = Query()
|
||||
image: Image = Image.from_dict(image_db.get(ImageQuery.id == id))
|
||||
if not image:
|
||||
return jsonify({'error': 'Image not found'}), 404
|
||||
filename = f"{image.id}{image.extension}"
|
||||
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user), filename))
|
||||
if not os.path.exists(filepath):
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
return send_file(filepath)
|
||||
|
||||
@image_api.route('/image/list', methods=['GET'])
|
||||
def list_images():
|
||||
image_type = request.args.get('type', type=int)
|
||||
ImageQuery = Query()
|
||||
if image_type is not None:
|
||||
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
|
||||
return jsonify({'error': 'Invalid image type'}), 400
|
||||
images = image_db.search(ImageQuery.type == image_type)
|
||||
else:
|
||||
images = image_db.all()
|
||||
image_ids = [img['id'] for img in images]
|
||||
return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200
|
||||
20
backend/api/pending_reward.py
Normal file
20
backend/api/pending_reward.py
Normal file
@@ -0,0 +1,20 @@
|
||||
class PendingReward:
|
||||
def __init__(self, _id, child_id, child_name, child_image_id, reward_id, reward_name, reward_image_id):
|
||||
self.id = _id
|
||||
self.child_id = child_id
|
||||
self.child_name = child_name
|
||||
self.child_image_id = child_image_id
|
||||
self.reward_id = reward_id
|
||||
self.reward_name = reward_name
|
||||
self.reward_image_id = reward_image_id
|
||||
|
||||
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,
|
||||
'reward_id': self.reward_id,
|
||||
'reward_name': self.reward_name,
|
||||
'reward_image_id': self.reward_image_id
|
||||
}
|
||||
108
backend/api/reward_api.py
Normal file
108
backend/api/reward_api.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import send_event_for_current_user
|
||||
from db.db import reward_db, child_db
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.reward_modified import RewardModified
|
||||
from models.reward import Reward
|
||||
|
||||
reward_api = Blueprint('reward_api', __name__)
|
||||
|
||||
# Reward endpoints
|
||||
@reward_api.route('/reward/add', methods=['PUT'])
|
||||
def add_reward():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
description = data.get('description')
|
||||
cost = data.get('cost')
|
||||
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=name, description=description, cost=cost, image_id=image)
|
||||
reward_db.insert(reward.to_dict())
|
||||
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
||||
RewardModified(reward.id, RewardModified.OPERATION_ADD)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({'message': f'Reward {name} added.'}), 201
|
||||
|
||||
|
||||
|
||||
@reward_api.route('/reward/<id>', methods=['GET'])
|
||||
def get_reward(id):
|
||||
RewardQuery = Query()
|
||||
result = reward_db.search(RewardQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
@reward_api.route('/reward/list', methods=['GET'])
|
||||
def list_rewards():
|
||||
rewards = reward_db.all()
|
||||
return jsonify({'rewards': rewards}), 200
|
||||
|
||||
@reward_api.route('/reward/<id>', methods=['DELETE'])
|
||||
def delete_reward(id):
|
||||
RewardQuery = Query()
|
||||
removed = reward_db.remove(RewardQuery.id == id)
|
||||
if removed:
|
||||
# remove the reward id from any child's reward list
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
rewards = child.get('rewards', [])
|
||||
if id in rewards:
|
||||
rewards.remove(id)
|
||||
child_db.update({'rewards': rewards}, ChildQuery.id == child.get('id'))
|
||||
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
||||
RewardModified(id, RewardModified.OPERATION_DELETE)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({'message': f'Reward {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
|
||||
@reward_api.route('/reward/<id>/edit', methods=['PUT'])
|
||||
def edit_reward(id):
|
||||
RewardQuery = Query()
|
||||
existing = reward_db.get(RewardQuery.id == id)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Reward not found'}), 404
|
||||
|
||||
data = request.get_json(force=True) or {}
|
||||
updates = {}
|
||||
|
||||
if 'name' in data:
|
||||
name = (data.get('name') or '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||
updates['name'] = name
|
||||
|
||||
if 'description' in data:
|
||||
desc = (data.get('description') or '').strip()
|
||||
if not desc:
|
||||
return jsonify({'error': 'Description cannot be empty'}), 400
|
||||
updates['description'] = desc
|
||||
|
||||
if 'cost' in data:
|
||||
cost = data.get('cost')
|
||||
if not isinstance(cost, int):
|
||||
return jsonify({'error': 'Cost must be an integer'}), 400
|
||||
if cost <= 0:
|
||||
return jsonify({'error': 'Cost must be a positive integer'}), 400
|
||||
updates['cost'] = cost
|
||||
|
||||
if 'image_id' in data:
|
||||
updates['image_id'] = data.get('image_id', '')
|
||||
|
||||
if not updates:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
reward_db.update(updates, RewardQuery.id == id)
|
||||
updated = reward_db.get(RewardQuery.id == id)
|
||||
resp = send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
||||
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
return jsonify(updated), 200
|
||||
18
backend/api/reward_status.py
Normal file
18
backend/api/reward_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
class RewardStatus:
|
||||
def __init__(self, id, name, points_needed, cost, redeeming, image_id):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.points_needed = points_needed
|
||||
self.cost = cost
|
||||
self.redeeming = redeeming
|
||||
self.image_id = image_id
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'points_needed': self.points_needed,
|
||||
'cost': self.cost,
|
||||
'redeeming': self.redeeming,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
110
backend/api/task_api.py
Normal file
110
backend/api/task_api.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import send_event_for_current_user
|
||||
from db.db import task_db, child_db
|
||||
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
|
||||
|
||||
task_api = Blueprint('task_api', __name__)
|
||||
|
||||
# Task endpoints
|
||||
@task_api.route('/task/add', methods=['PUT'])
|
||||
def add_task():
|
||||
data = request.get_json()
|
||||
name = data.get('name')
|
||||
points = data.get('points')
|
||||
is_good = data.get('is_good')
|
||||
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)
|
||||
task_db.insert(task.to_dict())
|
||||
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(task.id, TaskModified.OPERATION_ADD)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify({'message': f'Task {name} added.'}), 201
|
||||
|
||||
@task_api.route('/task/<id>', methods=['GET'])
|
||||
def get_task(id):
|
||||
TaskQuery = Query()
|
||||
result = task_db.search(TaskQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
@task_api.route('/task/list', methods=['GET'])
|
||||
def list_tasks():
|
||||
ids_param = request.args.get('ids')
|
||||
tasks = task_db.all()
|
||||
if ids_param:
|
||||
ids = set(ids_param.split(','))
|
||||
tasks = [task for task in tasks if task.get('id') in ids]
|
||||
return jsonify({'tasks': tasks}), 200
|
||||
|
||||
@task_api.route('/task/<id>', methods=['DELETE'])
|
||||
def delete_task(id):
|
||||
TaskQuery = Query()
|
||||
removed = task_db.remove(TaskQuery.id == id)
|
||||
if removed:
|
||||
# remove the task id from any child's task list
|
||||
ChildQuery = Query()
|
||||
for child in child_db.all():
|
||||
tasks = child.get('tasks', [])
|
||||
if id in tasks:
|
||||
tasks.remove(id)
|
||||
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
|
||||
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(id, TaskModified.OPERATION_DELETE)))
|
||||
if resp:
|
||||
return resp
|
||||
|
||||
return jsonify({'message': f'Task {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
|
||||
@task_api.route('/task/<id>/edit', methods=['PUT'])
|
||||
def edit_task(id):
|
||||
TaskQuery = Query()
|
||||
existing = task_db.get(TaskQuery.id == id)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Task not found'}), 404
|
||||
|
||||
data = request.get_json(force=True) or {}
|
||||
updates = {}
|
||||
|
||||
if 'name' in data:
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||
updates['name'] = name
|
||||
|
||||
if 'points' in data:
|
||||
points = data.get('points')
|
||||
if not isinstance(points, int):
|
||||
return jsonify({'error': 'Points must be an integer'}), 400
|
||||
if points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
updates['points'] = points
|
||||
|
||||
if 'is_good' in data:
|
||||
is_good = data.get('is_good')
|
||||
if not isinstance(is_good, bool):
|
||||
return jsonify({'error': 'is_good must be a boolean'}), 400
|
||||
updates['is_good'] = is_good
|
||||
|
||||
if 'image_id' in data:
|
||||
updates['image_id'] = data.get('image_id', '')
|
||||
|
||||
if not updates:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
task_db.update(updates, TaskQuery.id == id)
|
||||
updated = task_db.get(TaskQuery.id == id)
|
||||
resp = send_event_for_current_user(Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
||||
if resp:
|
||||
return resp
|
||||
return jsonify(updated), 200
|
||||
45
backend/api/user_api.py
Normal file
45
backend/api/user_api.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from models.user import User
|
||||
from tinydb import Query
|
||||
from db.db import users_db
|
||||
import jwt
|
||||
|
||||
user_api = Blueprint('user_api', __name__)
|
||||
UserQuery = Query()
|
||||
|
||||
def get_current_user():
|
||||
token = request.cookies.get('token')
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
email = payload.get('email')
|
||||
user_dict = users_db.get(UserQuery.email == email)
|
||||
return User.from_dict(user_dict) if user_dict else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@user_api.route('/user/profile', methods=['GET'])
|
||||
def get_profile():
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return jsonify({
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'email': user.email,
|
||||
'image_id': user.image_id
|
||||
}), 200
|
||||
|
||||
@user_api.route('/user/image', methods=['PUT'])
|
||||
def update_image():
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
data = request.get_json()
|
||||
image_id = data.get('image_id')
|
||||
if not image_id:
|
||||
return jsonify({'error': 'Missing image_id'}), 400
|
||||
user.image_id = image_id
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
return jsonify({'message': 'Image updated', 'image_id': image_id}), 200
|
||||
28
backend/api/utils.py
Normal file
28
backend/api/utils.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import jwt
|
||||
from flask import request, current_app, jsonify
|
||||
|
||||
from events.sse import send_event_to_user
|
||||
|
||||
|
||||
def sanitize_email(email):
|
||||
return email.replace('@', '_at_').replace('.', '_dot_')
|
||||
|
||||
def get_current_user_id():
|
||||
token = request.cookies.get('token')
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
email = payload.get('email')
|
||||
if not email:
|
||||
return None
|
||||
return sanitize_email(email)
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
||||
def send_event_for_current_user(event):
|
||||
user_id = get_current_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
send_event_to_user(user_id, event)
|
||||
return None
|
||||
Reference in New Issue
Block a user