feat: add enable/disable toggle for chore scheduling in ScheduleModal
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
All checks were successful
Chore App Build, Test, and Push Docker Images / build-and-push (push) Successful in 2m39s
- Introduced a toggle button to enable or disable chores in the scheduler. - The toggle will be available in both types of schedulers. - Added UI design proposal and considerations for mobile dimensions. - Included E2E tests to ensure toggle state persistence and correct behavior in child view.
This commit is contained in:
@@ -43,6 +43,12 @@ ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
||||
E2E_TEST_EMAIL = 'e2e@test.com'
|
||||
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
||||
E2E_TEST_PIN = '1234'
|
||||
E2E_DELETE_EMAIL = 'e2e-delete@test.com'
|
||||
E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
|
||||
E2E_DELETE_PIN = '5678'
|
||||
E2E_CC_EMAIL = 'e2e-cc@test.com'
|
||||
E2E_CC_PASSWORD = 'E2eCCPass1!'
|
||||
E2E_CC_PIN = '3456'
|
||||
|
||||
|
||||
def send_verification_email(to_email, token):
|
||||
@@ -470,6 +476,52 @@ def logout():
|
||||
return resp, 200
|
||||
|
||||
|
||||
@auth_api.route('/e2e-create-delete-user', methods=['POST'])
|
||||
def e2e_create_delete_user():
|
||||
"""Create a secondary e2e test user for deletion testing. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
norm_email = normalize_email(E2E_DELETE_EMAIL)
|
||||
users_db.remove(UserQuery.email == norm_email)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='Delete',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_DELETE_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_DELETE_PIN,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
|
||||
|
||||
@auth_api.route('/e2e-create-cc-user', methods=['POST'])
|
||||
def e2e_create_cc_user():
|
||||
"""Create an isolated e2e test user for create-child tests. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
norm_email = normalize_email(E2E_CC_EMAIL)
|
||||
# Remove old user and all their children so deleteAllChildren() starts clean.
|
||||
existing = users_db.get(UserQuery.email == norm_email)
|
||||
if existing:
|
||||
child_db.remove(Query().user_id == existing.get('id'))
|
||||
users_db.remove(UserQuery.email == norm_email)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='CreateChild',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_CC_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_CC_PIN,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
|
||||
|
||||
@auth_api.route('/e2e-seed', methods=['POST'])
|
||||
def e2e_seed():
|
||||
"""Reset the database and insert a verified test user. Only available outside production."""
|
||||
|
||||
@@ -54,6 +54,10 @@ def set_chore_schedule(child_id, task_id):
|
||||
if mode not in ('days', 'interval'):
|
||||
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
enabled = data.get('enabled', True)
|
||||
if not isinstance(enabled, bool):
|
||||
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
if mode == 'days':
|
||||
day_configs = data.get('day_configs', [])
|
||||
if not isinstance(day_configs, list):
|
||||
@@ -69,6 +73,7 @@ def set_chore_schedule(child_id, task_id):
|
||||
default_hour=default_hour,
|
||||
default_minute=default_minute,
|
||||
default_has_deadline=default_has_deadline,
|
||||
enabled=enabled,
|
||||
)
|
||||
else:
|
||||
interval_days = data.get('interval_days', 2)
|
||||
@@ -91,6 +96,7 @@ def set_chore_schedule(child_id, task_id):
|
||||
interval_has_deadline=interval_has_deadline,
|
||||
interval_hour=interval_hour,
|
||||
interval_minute=interval_minute,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
delete_extension_for_child_task(child_id, task_id)
|
||||
|
||||
@@ -64,7 +64,7 @@ def sse_response_for_user(user_id: str):
|
||||
# This prevents Werkzeug's dev server from starving other connections.
|
||||
message = user_queue.get(timeout=15)
|
||||
yield message
|
||||
logger.info(f"Sent message to {user_id} connection {connection_id}")
|
||||
logger.debug(f"Sent message to {user_id} connection {connection_id}")
|
||||
except queue.Empty:
|
||||
# Send an SSE comment as a keepalive ping to maintain the connection.
|
||||
yield b': ping\n\n'
|
||||
|
||||
@@ -35,6 +35,9 @@ logging.basicConfig(
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
||||
logging.getLogger("events.sse").setLevel(logging.WARNING)
|
||||
|
||||
app = Flask(__name__)
|
||||
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
|
||||
#Todo - add prefix to all these routes instead of in each blueprint
|
||||
|
||||
@@ -44,6 +44,8 @@ class ChoreSchedule(BaseModel):
|
||||
interval_hour: int = 0
|
||||
interval_minute: int = 0
|
||||
|
||||
enabled: bool = True # False = schedule paused; chore won't appear for child
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> 'ChoreSchedule':
|
||||
return cls(
|
||||
@@ -59,6 +61,7 @@ class ChoreSchedule(BaseModel):
|
||||
interval_has_deadline=d.get('interval_has_deadline', True),
|
||||
interval_hour=d.get('interval_hour', 0),
|
||||
interval_minute=d.get('interval_minute', 0),
|
||||
enabled=d.get('enabled', True),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at'),
|
||||
@@ -79,5 +82,6 @@ class ChoreSchedule(BaseModel):
|
||||
'interval_has_deadline': self.interval_has_deadline,
|
||||
'interval_hour': self.interval_hour,
|
||||
'interval_minute': self.interval_minute,
|
||||
'enabled': self.enabled,
|
||||
})
|
||||
return base
|
||||
|
||||
@@ -319,3 +319,96 @@ def test_extend_chore_time_missing_date(client):
|
||||
def test_extend_chore_time_bad_child(client):
|
||||
resp = client.post('/child/bad-child/task/bad-task/extend', json={"date": "2025-01-15"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# enabled field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_schedule_enabled_by_default(client):
|
||||
"""Omitting 'enabled' should persist as True."""
|
||||
payload = {"mode": "days", "day_configs": [{"day": 1, "hour": 8, "minute": 0}]}
|
||||
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["enabled"] is True
|
||||
|
||||
|
||||
def test_create_schedule_with_enabled_false(client):
|
||||
"""Explicitly setting enabled=False persists correctly."""
|
||||
payload = {
|
||||
"mode": "days",
|
||||
"day_configs": [{"day": 1, "hour": 8, "minute": 0}],
|
||||
"enabled": False,
|
||||
}
|
||||
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["enabled"] is False
|
||||
|
||||
|
||||
def test_update_schedule_toggle_enabled(client):
|
||||
"""Toggle enabled off, then back on; both changes persist."""
|
||||
base = {"mode": "days", "day_configs": [{"day": 2, "hour": 9, "minute": 0}]}
|
||||
|
||||
r1 = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={**base, "enabled": True})
|
||||
assert r1.get_json()["enabled"] is True
|
||||
|
||||
r2 = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={**base, "enabled": False})
|
||||
assert r2.get_json()["enabled"] is False
|
||||
|
||||
r3 = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json={**base, "enabled": True})
|
||||
assert r3.get_json()["enabled"] is True
|
||||
|
||||
|
||||
def test_enabled_field_in_get_response(client):
|
||||
"""GET response includes the 'enabled' field."""
|
||||
payload = {"mode": "interval", "interval_days": 2, "anchor_date": "", "enabled": False}
|
||||
client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
|
||||
|
||||
resp = client.get(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule')
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "enabled" in data
|
||||
assert data["enabled"] is False
|
||||
|
||||
|
||||
def test_enabled_invalid_value_returns_400(client):
|
||||
"""Non-boolean value for enabled returns 400."""
|
||||
payload = {"mode": "days", "day_configs": [], "enabled": "yes"}
|
||||
resp = client.put(f'/child/{TEST_CHILD_ID}/task/{TEST_TASK_ID}/schedule', json=payload)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChoreSchedule model: enabled serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_chore_schedule_from_dict_defaults_enabled():
|
||||
"""from_dict without 'enabled' defaults to True."""
|
||||
from models.chore_schedule import ChoreSchedule
|
||||
s = ChoreSchedule.from_dict({
|
||||
"child_id": "c1", "task_id": "t1", "mode": "days",
|
||||
"day_configs": [], "interval_days": 2, "anchor_date": "",
|
||||
"interval_has_deadline": True, "interval_hour": 0, "interval_minute": 0,
|
||||
})
|
||||
assert s.enabled is True
|
||||
|
||||
|
||||
def test_chore_schedule_from_dict_enabled_false():
|
||||
"""from_dict with enabled=False preserves the value."""
|
||||
from models.chore_schedule import ChoreSchedule
|
||||
s = ChoreSchedule.from_dict({
|
||||
"child_id": "c1", "task_id": "t1", "mode": "days",
|
||||
"day_configs": [], "interval_days": 2, "anchor_date": "",
|
||||
"interval_has_deadline": True, "interval_hour": 0, "interval_minute": 0,
|
||||
"enabled": False,
|
||||
})
|
||||
assert s.enabled is False
|
||||
|
||||
|
||||
def test_chore_schedule_to_dict_includes_enabled():
|
||||
"""to_dict includes the 'enabled' key with the correct value."""
|
||||
from models.chore_schedule import ChoreSchedule
|
||||
s = ChoreSchedule(child_id="c1", task_id="t1", mode="days", enabled=False)
|
||||
d = s.to_dict()
|
||||
assert "enabled" in d
|
||||
assert d["enabled"] is False
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from flask import current_app
|
||||
from flask_mail import Mail, Message
|
||||
import os
|
||||
|
||||
def send_verification_email(to_email: str, token: str) -> None:
|
||||
verify_url = f"{current_app.config['FRONTEND_URL']}/auth/verify?token={token}"
|
||||
@@ -11,6 +12,8 @@ def send_verification_email(to_email: str, token: str) -> None:
|
||||
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
||||
)
|
||||
try:
|
||||
if os.environ.get('DB_ENV') =='e2e':
|
||||
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs
|
||||
Mail(current_app).send(msg)
|
||||
print(f"[EMAIL to {to_email}] Verification: {verify_url}")
|
||||
except Exception:
|
||||
@@ -26,6 +29,8 @@ def send_reset_password_email(to_email: str, token: str) -> None:
|
||||
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
||||
)
|
||||
try:
|
||||
if os.environ.get('DB_ENV') =='e2e':
|
||||
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs
|
||||
Mail(current_app).send(msg)
|
||||
print(f"[EMAIL to {to_email}] Reset password: {reset_url}")
|
||||
except Exception:
|
||||
@@ -50,6 +55,8 @@ def send_pin_setup_email(to_email: str, code: str) -> None:
|
||||
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
||||
)
|
||||
try:
|
||||
if os.environ.get('DB_ENV') =='e2e':
|
||||
return # Skip sending emails during e2e tests to avoid cluttering inboxes and logs
|
||||
Mail(current_app).send(msg)
|
||||
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user