Compare commits
1 Commits
master
...
01673d05b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 01673d05b8 |
@@ -1 +0,0 @@
|
|||||||
FRONTEND_URL=https://yourdomain.com
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
name: Chore App Build, Test, and Push Docker Images
|
|
||||||
run-name: ${{ gitea.actor }} is building Chores [${{ gitea.ref_name }}@${{ gitea.sha }}] 🚀
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
- next
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out repository code
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Determine Image Tag
|
|
||||||
id: vars
|
|
||||||
run: |
|
|
||||||
version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
|
|
||||||
current_date=$(date +%Y%m%d)
|
|
||||||
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
|
|
||||||
echo "tag=$version" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "tag=next-$version-$current_date" >> $GITHUB_OUTPUT
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Set up Python for backend tests
|
|
||||||
uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.11"
|
|
||||||
|
|
||||||
- name: Install backend dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r backend/requirements.txt
|
|
||||||
|
|
||||||
- name: Run backend unit tests
|
|
||||||
run: |
|
|
||||||
cd backend
|
|
||||||
pytest -q
|
|
||||||
|
|
||||||
- name: Set up Node.js for frontend tests
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: "20.19.0"
|
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: frontend/vue-app/package-lock.json
|
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
|
||||||
run: npm ci
|
|
||||||
working-directory: frontend/vue-app
|
|
||||||
|
|
||||||
- name: Run frontend unit tests
|
|
||||||
run: npm run test:unit --if-present
|
|
||||||
working-directory: frontend/vue-app
|
|
||||||
|
|
||||||
- name: Build Backend Docker Image
|
|
||||||
run: |
|
|
||||||
docker build -t git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} ./backend
|
|
||||||
|
|
||||||
- name: Build Frontend Docker Image
|
|
||||||
run: |
|
|
||||||
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
|
|
||||||
|
|
||||||
- name: Log in to Registry
|
|
||||||
uses: docker/login-action@v2
|
|
||||||
with:
|
|
||||||
registry: git.ryankegel.com:3000
|
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Push Backend Image to Gitea Registry
|
|
||||||
run: |
|
|
||||||
for i in {1..3}; do
|
|
||||||
echo "Attempt $i to push backend image..."
|
|
||||||
if docker push git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }}; then
|
|
||||||
echo "Backend push succeeded on attempt $i"
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo "Backend push failed on attempt $i"
|
|
||||||
if [ $i -lt 3 ]; then
|
|
||||||
sleep 10
|
|
||||||
else
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
|
|
||||||
docker tag git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:latest
|
|
||||||
docker push git.ryankegel.com:3000/kegel/chores/backend:latest
|
|
||||||
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
|
|
||||||
docker tag git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:next
|
|
||||||
docker push git.ryankegel.com:3000/kegel/chores/backend:next
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Push Frontend Image to Gitea Registry
|
|
||||||
run: |
|
|
||||||
for i in {1..3}; do
|
|
||||||
echo "Attempt $i to push frontend image..."
|
|
||||||
if docker push git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }}; then
|
|
||||||
echo "Frontend push succeeded on attempt $i"
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo "Frontend push failed on attempt $i"
|
|
||||||
if [ $i -lt 3 ]; then
|
|
||||||
sleep 10
|
|
||||||
else
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
|
|
||||||
docker tag git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:latest
|
|
||||||
docker push git.ryankegel.com:3000/kegel/chores/frontend:latest
|
|
||||||
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
|
|
||||||
docker tag git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:next
|
|
||||||
docker push git.ryankegel.com:3000/kegel/chores/frontend:next
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Deploy Test Environment
|
|
||||||
if: gitea.ref == 'refs/heads/next'
|
|
||||||
uses: appleboy/ssh-action@v1.0.3 # Or equivalent Gitea action; adjust version if needed
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.DEPLOY_TEST_HOST }}
|
|
||||||
username: ${{ secrets.DEPLOY_TEST_USER }}
|
|
||||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
port: 22 # Default SSH port; change if different
|
|
||||||
script: |
|
|
||||||
cd /tmp
|
|
||||||
# Pull the repository to get the latest docker-compose.dev.yml
|
|
||||||
if [ -d "chore" ]; then
|
|
||||||
cd chore
|
|
||||||
git pull origin next || true # Pull latest changes; ignore if it fails (e.g., first run)
|
|
||||||
else
|
|
||||||
git clone --branch next https://git.ryankegel.com/ryan/chore.git
|
|
||||||
cd chore
|
|
||||||
fi
|
|
||||||
echo "Bringing down previous test environment..."
|
|
||||||
docker-compose -f docker-compose.test.yml down --volumes --remove-orphans || true
|
|
||||||
echo "Starting new test environment..."
|
|
||||||
docker-compose -f docker-compose.test.yml pull # Ensure latest images are pulled
|
|
||||||
docker-compose -f docker-compose.test.yml up -d
|
|
||||||
19
.github/alias.txt
vendored
19
.github/alias.txt
vendored
@@ -1,19 +0,0 @@
|
|||||||
|
|
||||||
**Powershell
|
|
||||||
git config --global alias.save-wip "!f() { git add . ; if (git log -1 --format=%s -eq 'wip') { git commit --amend --no-edit } else { git commit -m 'wip' }; git push origin `$(git branch --show-current):wip-sync --force-with-lease; }; f"
|
|
||||||
git config --global alias.load-wip "!f() { if (git diff-index --quiet HEAD --) { git fetch origin wip-sync; git merge origin/wip-sync; if (git log -1 --format=%s -eq 'wip') { git reset --soft HEAD~1; echo 'WIP Loaded and unwrapped.' } else { echo 'No WIP found. Merge complete.' } } else { echo 'Error: Uncommitted changes detected.'; exit 1 }; }; f"
|
|
||||||
git config --global alias.abort-wip "git reset --hard HEAD"
|
|
||||||
|
|
||||||
**Git Bash
|
|
||||||
git config --global alias.save-wip '!f() { git add . ; if [ "$(git log -1 --format=%s)" = "wip" ]; then git commit --amend --no-edit; else git commit -m "wip"; fi; git push origin $(git branch --show-current):wip-sync --force-with-lease; }; f'
|
|
||||||
git config --global alias.load-wip '!f() { if ! git diff-index --quiet HEAD --; then echo "Error: Uncommitted changes detected."; exit 1; fi; git fetch origin wip-sync && git merge origin/wip-sync && if [ "$(git log -1 --format=%s)" = "wip" ]; then git reset --soft HEAD~1; echo "WIP Loaded and unwrapped."; else echo "No WIP found. Merge complete."; fi; }; f'
|
|
||||||
git config --global alias.abort-wip 'git reset --hard HEAD'
|
|
||||||
|
|
||||||
|
|
||||||
**Mac
|
|
||||||
git config --global alias.save-wip '!f() { git add . ; if [ "$(git log -1 --format=%s)" = "wip" ]; then git commit --amend --no-edit; else git commit -m "wip"; fi; git push origin $(git branch --show-current):wip-sync --force-with-lease; }; f'
|
|
||||||
git config --global alias.load-wip '!f() { if ! git diff-index --quiet HEAD --; then echo "Error: Uncommitted changes detected."; exit 1; fi; git fetch origin wip-sync && git merge origin/wip-sync && if [ "$(git log -1 --format=%s)" = "wip" ]; then git reset --soft HEAD~1; echo "WIP Loaded and unwrapped."; else echo "No WIP found. Merge complete."; fi; }; f'
|
|
||||||
git config --global alias.abort-wip 'git reset --hard HEAD'
|
|
||||||
|
|
||||||
***Reset wip-sync
|
|
||||||
git push origin --delete wip-sync
|
|
||||||
63
.github/copilot-instructions.md
vendored
63
.github/copilot-instructions.md
vendored
@@ -1,63 +0,0 @@
|
|||||||
# Reward Project: AI Coding Agent Instructions
|
|
||||||
|
|
||||||
## 🏗️ Architecture & Data Flow
|
|
||||||
|
|
||||||
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`).
|
|
||||||
- **API**: RESTful endpoints in `api/`, grouped by entity (child, reward, task, user, image, etc). Each API file maps to a business domain.
|
|
||||||
- **Nginx Proxy**: Frontend nginx proxies `/api/*` to backend, stripping the `/api` prefix. Backend endpoints should NOT include `/api` in their route definitions. Example: Backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
|
||||||
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/vue-app/src/common/models.ts`).
|
|
||||||
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts.
|
|
||||||
- **Events**: Real-time updates via Server-Sent Events (SSE). Every mutation (add/edit/delete/trigger) must call `send_event_for_current_user` (see `backend/events/`).
|
|
||||||
- **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
|
|
||||||
- **Specs**: If specs have a checklist, all items must be completed and marked done.
|
|
||||||
|
|
||||||
## 🧩 Key Patterns & Conventions
|
|
||||||
|
|
||||||
- **Frontend Styling**: Use only `:root` CSS variables from `colors.css` for all colors, spacing, and tokens. Example: `--btn-primary`, `--list-item-bg-good`.
|
|
||||||
- **Scoped Styles**: All `.vue` files must use `<style scoped>`. Reference global variables for theme consistency.
|
|
||||||
- **API Error Handling**: Backend returns JSON with `error` and `code` (see `backend/api/error_codes.py`). Frontend extracts `{ msg, code }` using `parseErrorResponse(res)` from `api.ts`.
|
|
||||||
- **JWT Auth**: Tokens are stored in HttpOnly, Secure, SameSite=Strict cookies.
|
|
||||||
- **Code Style**:
|
|
||||||
1. Follow PEP 8 for Python, and standard TypeScript conventions.
|
|
||||||
2. Use type annotations everywhere in Python.
|
|
||||||
3. Place all imports at the top of the file.
|
|
||||||
4. Vue files should specifically place `<template>`, `<script>`, then `<style>` in that order. Make sure to put ts code in `<script>` only.
|
|
||||||
|
|
||||||
## 🚦 Frontend Logic & Event Bus
|
|
||||||
|
|
||||||
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/vue-app/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
|
|
||||||
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
|
|
||||||
|
|
||||||
## ⚖️ Business Logic & Safeguards
|
|
||||||
|
|
||||||
- **Token Expiry**: Verification tokens expire in 4 hours; password reset tokens in 10 minutes.
|
|
||||||
- **Image Assets**: Models use `image_id` for storage; frontend resolves to `image_url` for rendering.
|
|
||||||
|
|
||||||
## 🛠️ Developer Workflows
|
|
||||||
|
|
||||||
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from the `backend/` directory. Main entry: `backend/main.py`.
|
|
||||||
- **Virtual Env**: Python is running from a virtual environment located at `backend/.venv/`.
|
|
||||||
- **Frontend**: From `frontend/vue-app/`, run `npm install` then `npm run dev`.
|
|
||||||
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/vue-app/components/__tests__/`.
|
|
||||||
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams.
|
|
||||||
|
|
||||||
## 📁 Key Files & Directories
|
|
||||||
|
|
||||||
- `backend/api/` — Flask API endpoints (one file per entity)
|
|
||||||
- `backend/models/` — Python dataclasses (business logic, serialization)
|
|
||||||
- `backend/db/` — TinyDB setup and helpers
|
|
||||||
- `backend/events/` — SSE event types, broadcaster, payloads
|
|
||||||
- `frontend/vue-app/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`) - Where tests are run from
|
|
||||||
- `frontend/vue-app/src/common/models.ts` — TypeScript interfaces (mirror Python models)
|
|
||||||
- `frontend/vue-app/src/common/api.ts` — API helpers, error parsing, validation
|
|
||||||
- `frontend/vue-app/src/common/backendEvents.ts` — SSE event types and handlers
|
|
||||||
|
|
||||||
## 🧠 Integration & Cross-Component Patterns
|
|
||||||
|
|
||||||
- **Every backend mutation must trigger an SSE event** for the current user.
|
|
||||||
- **Frontend state is event-driven**: always listen for and react to SSE events for real-time updates.
|
|
||||||
- **Model changes require updating both Python and TypeScript definitions** to maintain parity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For any unclear or missing conventions, review the referenced files or ask for clarification. Keep this document concise and actionable for AI agents.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Bug: When a user task or reward exists with the same name as a system user or task, both are shown in the assign list.
|
|
||||||
|
|
||||||
## The Problem
|
|
||||||
|
|
||||||
- **Actual:** When the user creates a task/reward from a system task/reward (copy on edit), and then goes to assign the task/reward, both the system and user task/reward are shown and can be assigned.
|
|
||||||
- **Expected:** When a user task/reward is created from a system (or even if it has the same name) - show the user item instead in the assign views.
|
|
||||||
|
|
||||||
## Investigation Notes
|
|
||||||
|
|
||||||
- When a copy on edit happens of a 'good' task and it is changed to 'bad', I can see the 'good' task when assigning tasks and the 'bad' penalty when assigning the penalty
|
|
||||||
- The backend will have to change to probably check if the names are the same on tasks/rewards and if so, choose to return the user items instead.
|
|
||||||
- In the case of two items having the same name AND having different user_ids that are not null, then we should show both items.
|
|
||||||
- The task view and reward view correctly hides the system item. However, the Task Assign View and RewardAssignView are still showing both items.
|
|
||||||
|
|
||||||
## The "Red" Tests
|
|
||||||
|
|
||||||
- [x] Create a test that performs a copy on edit and then makes sure only that item shows instead of the system item
|
|
||||||
- [x] Create a test that performs has 2 user items with the same name as a system item. Verify that the user items are shown, but not the system item.
|
|
||||||
- [x] Create a test where if a system and identically named user task exist that the user tasks is the only one shown in the task assign view and reward assign view.
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
# Feature: Account Deletion Scheduler
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Goal:** Implement a scheduler in the backend that will delete accounts that are marked for deletion after a period of time.
|
|
||||||
|
|
||||||
**User Story:**
|
|
||||||
As an administrator, I want accounts that are marked for deletion to be deleted around X amount of hours after they were marked. I want the time to be adjustable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
- `ACCOUNT_DELETION_THRESHOLD_HOURS`: Hours to wait before deleting marked accounts (default: 720 hours / 30 days)
|
|
||||||
- **Minimum:** 24 hours (enforced for safety)
|
|
||||||
- **Maximum:** 720 hours (30 days)
|
|
||||||
- Configurable via environment variable with validation on startup
|
|
||||||
|
|
||||||
### Scheduler Settings
|
|
||||||
|
|
||||||
- **Check Interval:** Every 1 hour
|
|
||||||
- **Implementation:** APScheduler (BackgroundScheduler)
|
|
||||||
- **Restart Handling:** On app restart, scheduler checks for users with `deletion_in_progress = True` and retries them
|
|
||||||
- **Retry Logic:** Maximum 3 attempts per user; tracked via `deletion_attempted_at` timestamp
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model Changes
|
|
||||||
|
|
||||||
### User Model (`backend/models/user.py`)
|
|
||||||
|
|
||||||
Add two new fields to the `User` dataclass:
|
|
||||||
|
|
||||||
- `deletion_in_progress: bool` - Default `False`. Set to `True` when deletion is actively running
|
|
||||||
- `deletion_attempted_at: datetime | None` - Default `None`. Timestamp of last deletion attempt
|
|
||||||
|
|
||||||
**Serialization:**
|
|
||||||
|
|
||||||
- Both fields must be included in `to_dict()` and `from_dict()` methods
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deletion Process & Order
|
|
||||||
|
|
||||||
When a user is due for deletion (current time >= `marked_for_deletion_at` + threshold), the scheduler performs deletion in this order:
|
|
||||||
|
|
||||||
1. **Set Flag:** `deletion_in_progress = True` (prevents concurrent deletion)
|
|
||||||
2. **Pending Rewards:** Remove all pending rewards for user's children
|
|
||||||
3. **Children:** Remove all children belonging to the user
|
|
||||||
4. **Tasks:** Remove all user-created tasks (where `user_id` matches)
|
|
||||||
5. **Rewards:** Remove all user-created rewards (where `user_id` matches)
|
|
||||||
6. **Images (Database):** Remove user's uploaded images from `image_db`
|
|
||||||
7. **Images (Filesystem):** Delete `data/images/[user_id]` directory and all contents
|
|
||||||
8. **User Record:** Remove the user from `users_db`
|
|
||||||
9. **Clear Flag:** `deletion_in_progress = False` (only if deletion failed; otherwise user is deleted)
|
|
||||||
10. **Update Timestamp:** Set `deletion_attempted_at` to current time (if deletion failed)
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- If any step fails, log the error and continue to next step
|
|
||||||
- If deletion fails completely, update `deletion_attempted_at` and set `deletion_in_progress = False`
|
|
||||||
- If a user has 3 failed attempts, log a critical error but continue processing other users
|
|
||||||
- Missing directories or empty tables are not considered errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin API Endpoints
|
|
||||||
|
|
||||||
### New Blueprint: `backend/api/admin_api.py`
|
|
||||||
|
|
||||||
All endpoints require JWT authentication and admin privileges.
|
|
||||||
|
|
||||||
**Note:** Endpoint paths below are as defined in Flask (without `/api` prefix). Frontend accesses them via nginx proxy at `/api/admin/*`.
|
|
||||||
|
|
||||||
#### `GET /admin/deletion-queue`
|
|
||||||
|
|
||||||
Returns list of users pending deletion.
|
|
||||||
|
|
||||||
**Response:** JSON with `count` and `users` array containing user objects with fields: `id`, `email`, `marked_for_deletion_at`, `deletion_due_at`, `deletion_in_progress`, `deletion_attempted_at`
|
|
||||||
|
|
||||||
#### `GET /admin/deletion-threshold`
|
|
||||||
|
|
||||||
Returns current deletion threshold configuration.
|
|
||||||
|
|
||||||
**Response:** JSON with `threshold_hours`, `threshold_min`, and `threshold_max` fields
|
|
||||||
|
|
||||||
#### `PUT /admin/deletion-threshold`
|
|
||||||
|
|
||||||
Updates deletion threshold (requires admin auth).
|
|
||||||
|
|
||||||
**Request:** JSON with `threshold_hours` field
|
|
||||||
|
|
||||||
**Response:** JSON with `message` and updated `threshold_hours`
|
|
||||||
|
|
||||||
**Validation:**
|
|
||||||
|
|
||||||
- Must be between 24 and 720 hours
|
|
||||||
- Returns 400 error if out of range
|
|
||||||
|
|
||||||
#### `POST /admin/deletion-queue/trigger`
|
|
||||||
|
|
||||||
Manually triggers the deletion scheduler (processes entire queue immediately).
|
|
||||||
|
|
||||||
**Response:** JSON with `message`, `processed`, `deleted`, and `failed` counts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SSE Event
|
|
||||||
|
|
||||||
### New Event Type: `USER_DELETED`
|
|
||||||
|
|
||||||
**File:** `backend/events/types/user_deleted.py`
|
|
||||||
|
|
||||||
**Payload fields:**
|
|
||||||
|
|
||||||
- `user_id: str` - ID of deleted user
|
|
||||||
- `email: str` - Email of deleted user
|
|
||||||
- `deleted_at: str` - ISO format timestamp of deletion
|
|
||||||
|
|
||||||
**Broadcasting:**
|
|
||||||
|
|
||||||
- Event is sent only to **admin users** (not broadcast to all users)
|
|
||||||
- Triggered immediately after successful user deletion
|
|
||||||
- Frontend admin clients can listen to this event to update UI
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
- `backend/config/deletion_config.py` - Configuration with env variable
|
|
||||||
- `backend/utils/account_deletion_scheduler.py` - Scheduler logic
|
|
||||||
- `backend/api/admin_api.py` - New admin endpoints
|
|
||||||
- `backend/events/types/user_deleted.py` - New SSE event
|
|
||||||
|
|
||||||
### Scheduler Startup
|
|
||||||
|
|
||||||
In `backend/main.py`, import and call `start_deletion_scheduler()` after Flask app setup
|
|
||||||
|
|
||||||
### Logging Strategy
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
|
|
||||||
- Use dedicated logger: `account_deletion_scheduler`
|
|
||||||
- Log to both stdout (for Docker/dev) and rotating file (for persistence)
|
|
||||||
- File: `logs/account_deletion.log`
|
|
||||||
- Rotation: 10MB max file size, keep 5 backups
|
|
||||||
- Format: `%(asctime)s - %(name)s - %(levelname)s - %(message)s`
|
|
||||||
|
|
||||||
**Log Levels:**
|
|
||||||
|
|
||||||
- **INFO:** Each deletion step (e.g., "Deleted 5 children for user {user_id}")
|
|
||||||
- **INFO:** Summary after each run (e.g., "Deletion scheduler run: 3 users processed, 2 deleted, 1 failed")
|
|
||||||
- **ERROR:** Individual step failures (e.g., "Failed to delete images for user {user_id}: {error}")
|
|
||||||
- **CRITICAL:** User with 3+ failed attempts (e.g., "User {user_id} has failed deletion 3 times")
|
|
||||||
- **WARNING:** Threshold set below 168 hours (7 days)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria (Definition of Done)
|
|
||||||
|
|
||||||
### Data Model
|
|
||||||
|
|
||||||
- [x] Add `deletion_in_progress` field to User model
|
|
||||||
- [x] Add `deletion_attempted_at` field to User model
|
|
||||||
- [x] Update `to_dict()` and `from_dict()` methods for serialization
|
|
||||||
- [x] Update TypeScript User interface in frontend
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
- [x] Create `backend/config/deletion_config.py` with `ACCOUNT_DELETION_THRESHOLD_HOURS`
|
|
||||||
- [x] Add environment variable support with default (720 hours)
|
|
||||||
- [x] Enforce minimum threshold of 24 hours
|
|
||||||
- [x] Enforce maximum threshold of 720 hours
|
|
||||||
- [x] Log warning if threshold is less than 168 hours
|
|
||||||
|
|
||||||
### Backend Implementation
|
|
||||||
|
|
||||||
- [x] Create `backend/utils/account_deletion_scheduler.py`
|
|
||||||
- [x] Implement APScheduler with 1-hour check interval
|
|
||||||
- [x] Implement deletion logic in correct order (pending_rewards → children → tasks → rewards → images → directory → user)
|
|
||||||
- [x] Add comprehensive error handling (log and continue)
|
|
||||||
- [x] Add restart handling (check `deletion_in_progress` flag on startup)
|
|
||||||
- [x] Add retry logic (max 3 attempts per user)
|
|
||||||
- [x] Integrate scheduler into `backend/main.py` startup
|
|
||||||
|
|
||||||
### Admin API
|
|
||||||
|
|
||||||
- [x] Create `backend/api/admin_api.py` blueprint
|
|
||||||
- [x] Implement `GET /admin/deletion-queue` endpoint
|
|
||||||
- [x] Implement `GET /admin/deletion-threshold` endpoint
|
|
||||||
- [x] Implement `PUT /admin/deletion-threshold` endpoint
|
|
||||||
- [x] Implement `POST /admin/deletion-queue/trigger` endpoint
|
|
||||||
- [x] Add JWT authentication checks for all admin endpoints
|
|
||||||
- [x] Add admin role validation
|
|
||||||
|
|
||||||
### SSE Event
|
|
||||||
|
|
||||||
- [x] Create `backend/events/types/user_deleted.py`
|
|
||||||
- [x] Add `USER_DELETED` to `event_types.py`
|
|
||||||
- [x] Implement admin-only event broadcasting
|
|
||||||
- [x] Trigger event after successful deletion
|
|
||||||
|
|
||||||
### Backend Unit Tests
|
|
||||||
|
|
||||||
#### Configuration Tests
|
|
||||||
|
|
||||||
- [x] Test default threshold value (720 hours)
|
|
||||||
- [x] Test environment variable override
|
|
||||||
- [x] Test minimum threshold enforcement (24 hours)
|
|
||||||
- [x] Test maximum threshold enforcement (720 hours)
|
|
||||||
- [x] Test invalid threshold values (negative, non-numeric)
|
|
||||||
|
|
||||||
#### Scheduler Tests
|
|
||||||
|
|
||||||
- [x] Test scheduler identifies users ready for deletion (past threshold)
|
|
||||||
- [x] Test scheduler ignores users not yet due for deletion
|
|
||||||
- [x] Test scheduler handles empty database
|
|
||||||
- [x] Test scheduler runs at correct interval (1 hour)
|
|
||||||
- [x] Test scheduler handles restart with `deletion_in_progress = True`
|
|
||||||
- [x] Test scheduler respects retry limit (max 3 attempts)
|
|
||||||
|
|
||||||
#### Deletion Process Tests
|
|
||||||
|
|
||||||
- [x] Test deletion removes pending_rewards for user's children
|
|
||||||
- [x] Test deletion removes children for user
|
|
||||||
- [x] Test deletion removes user's tasks (not system tasks)
|
|
||||||
- [x] Test deletion removes user's rewards (not system rewards)
|
|
||||||
- [x] Test deletion removes user's images from database
|
|
||||||
- [x] Test deletion removes user directory from filesystem
|
|
||||||
- [x] Test deletion removes user record from database
|
|
||||||
- [x] Test deletion handles missing directory gracefully
|
|
||||||
- [x] Test deletion order is correct (children before user, etc.)
|
|
||||||
- [x] Test `deletion_in_progress` flag is set during deletion
|
|
||||||
- [x] Test `deletion_attempted_at` is updated on failure
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
|
|
||||||
- [x] Test deletion with user who has no children
|
|
||||||
- [x] Test deletion with user who has no custom tasks/rewards
|
|
||||||
- [x] Test deletion with user who has no uploaded images
|
|
||||||
- [x] Test partial deletion failure (continue with other users)
|
|
||||||
- [x] Test concurrent deletion attempts (flag prevents double-deletion)
|
|
||||||
- [x] Test user with exactly 3 failed attempts (logs critical, no retry)
|
|
||||||
|
|
||||||
#### Admin API Tests
|
|
||||||
|
|
||||||
- [x] Test `GET /admin/deletion-queue` returns correct users
|
|
||||||
- [x] Test `GET /admin/deletion-queue` requires authentication
|
|
||||||
- [x] Test `GET /admin/deletion-threshold` returns current threshold
|
|
||||||
- [x] Test `PUT /admin/deletion-threshold` updates threshold
|
|
||||||
- [x] Test `PUT /admin/deletion-threshold` validates min/max
|
|
||||||
- [x] Test `PUT /admin/deletion-threshold` requires admin role
|
|
||||||
- [x] Test `POST /admin/deletion-queue/trigger` triggers scheduler
|
|
||||||
- [x] Test `POST /admin/deletion-queue/trigger` returns summary
|
|
||||||
|
|
||||||
#### Integration Tests
|
|
||||||
|
|
||||||
- [x] Test full deletion flow from marking to deletion
|
|
||||||
- [x] Test multiple users deleted in same scheduler run
|
|
||||||
- [x] Test deletion with restart midway (recovery)
|
|
||||||
|
|
||||||
### Logging & Monitoring
|
|
||||||
|
|
||||||
- [x] Configure dedicated scheduler logger with rotating file handler
|
|
||||||
- [x] Create `logs/` directory for log files
|
|
||||||
- [x] Log each deletion step with INFO level
|
|
||||||
- [x] Log summary after each scheduler run (users processed, deleted, failed)
|
|
||||||
- [x] Log errors with user ID for debugging
|
|
||||||
- [x] Log critical error for users with 3+ failed attempts
|
|
||||||
- [x] Log warning if threshold is set below 168 hours
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- [x] Create `README.md` at project root
|
|
||||||
- [x] Document scheduler feature and behavior
|
|
||||||
- [x] Document environment variable `ACCOUNT_DELETION_THRESHOLD_HOURS`
|
|
||||||
- [x] Document deletion process and order
|
|
||||||
- [x] Document admin API endpoints
|
|
||||||
- [x] Document restart/retry behavior
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
All tests should use `DB_ENV=test` and operate on test databases in `backend/test_data/`.
|
|
||||||
|
|
||||||
### Unit Test Files
|
|
||||||
|
|
||||||
- `backend/tests/test_deletion_config.py` - Configuration validation
|
|
||||||
- `backend/tests/test_deletion_scheduler.py` - Scheduler logic
|
|
||||||
- `backend/tests/test_admin_api.py` - Admin endpoints
|
|
||||||
|
|
||||||
### Test Fixtures
|
|
||||||
|
|
||||||
- Create users with various `marked_for_deletion_at` timestamps
|
|
||||||
- Create users with children, tasks, rewards, images
|
|
||||||
- Create users with `deletion_in_progress = True` (for restart tests)
|
|
||||||
|
|
||||||
### Assertions
|
|
||||||
|
|
||||||
- Database records are removed in correct order
|
|
||||||
- Filesystem directories are deleted
|
|
||||||
- Flags and timestamps are updated correctly
|
|
||||||
- Error handling works (log and continue)
|
|
||||||
- Admin API responses match expected format
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
- Archive deleted accounts instead of hard deletion
|
|
||||||
- Email notification to admin when deletion completes
|
|
||||||
- Configurable retry count (currently hardcoded to 3)
|
|
||||||
- Soft delete with recovery option (within grace period)
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# Tracking Feature Implementation Summary
|
|
||||||
|
|
||||||
## ✅ Implementation Complete
|
|
||||||
|
|
||||||
All acceptance criteria from [feat-tracking.md](.github/specs/active/feat-dynamic-points/feat-tracking.md) have been implemented and tested.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 What Was Delivered
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
1. **Data Model** ([tracking_event.py](backend/models/tracking_event.py))
|
|
||||||
- `TrackingEvent` dataclass with full type safety
|
|
||||||
- Factory method `create_event()` for server-side timestamp generation
|
|
||||||
- Delta invariant validation (`delta == points_after - points_before`)
|
|
||||||
|
|
||||||
2. **Database Layer** ([tracking.py](backend/db/tracking.py))
|
|
||||||
- New TinyDB table: `tracking_events.json`
|
|
||||||
- Helper functions: `insert_tracking_event`, `get_tracking_events_by_child`, `get_tracking_events_by_user`, `anonymize_tracking_events_for_user`
|
|
||||||
- Offset-based pagination with sorting by `occurred_at` (desc)
|
|
||||||
|
|
||||||
3. **Audit Logging** ([tracking_logger.py](backend/utils/tracking_logger.py))
|
|
||||||
- Per-user rotating file handlers (`logs/tracking_user_<user_id>.log`)
|
|
||||||
- 10MB max file size, 5 backups
|
|
||||||
- Structured log format with all event metadata
|
|
||||||
|
|
||||||
4. **API Integration** ([child_api.py](backend/api/child_api.py))
|
|
||||||
- Tracking added to:
|
|
||||||
- `POST /child/<id>/trigger-task` → action: `activated`
|
|
||||||
- `POST /child/<id>/request-reward` → action: `requested`
|
|
||||||
- `POST /child/<id>/trigger-reward` → action: `redeemed`
|
|
||||||
- `POST /child/<id>/cancel-request-reward` → action: `cancelled`
|
|
||||||
|
|
||||||
5. **Admin API** ([tracking_api.py](backend/api/tracking_api.py))
|
|
||||||
- `GET /admin/tracking` with filters:
|
|
||||||
- `child_id` (required if no `user_id`)
|
|
||||||
- `user_id` (admin only)
|
|
||||||
- `entity_type` (task|reward|penalty)
|
|
||||||
- `action` (activated|requested|redeemed|cancelled)
|
|
||||||
- `limit` (default 50, max 500)
|
|
||||||
- `offset` (default 0)
|
|
||||||
- Returns total count for future pagination UI
|
|
||||||
|
|
||||||
6. **SSE Events** ([event_types.py](backend/events/types/event_types.py), [tracking_event_created.py](backend/events/types/tracking_event_created.py))
|
|
||||||
- New event type: `TRACKING_EVENT_CREATED`
|
|
||||||
- Payload: `tracking_event_id`, `child_id`, `entity_type`, `action`
|
|
||||||
- Emitted on every tracking event creation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
1. **TypeScript Models** ([models.ts](frontend/vue-app/src/common/models.ts))
|
|
||||||
- `TrackingEvent` interface (1:1 parity with Python)
|
|
||||||
- Type aliases: `EntityType`, `ActionType`
|
|
||||||
- `TrackingEventCreatedPayload` for SSE events
|
|
||||||
|
|
||||||
2. **API Helpers** ([api.ts](frontend/vue-app/src/common/api.ts))
|
|
||||||
- `getTrackingEventsForChild()` function with all filter params
|
|
||||||
|
|
||||||
3. **SSE Registration**
|
|
||||||
- Event type registered in type union
|
|
||||||
- Ready for future UI components
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
**Backend Unit Tests** ([test_tracking.py](backend/tests/test_tracking.py)):
|
|
||||||
|
|
||||||
- ✅ Tracking event creation with factory method
|
|
||||||
- ✅ Delta invariant validation
|
|
||||||
- ✅ Insert and query tracking events
|
|
||||||
- ✅ Filtering by `entity_type` and `action`
|
|
||||||
- ✅ Offset-based pagination
|
|
||||||
- ✅ User anonymization on deletion
|
|
||||||
- ✅ Points change correctness (positive/negative/zero delta)
|
|
||||||
- ✅ No points change for request/cancel actions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔑 Key Design Decisions
|
|
||||||
|
|
||||||
1. **Append-only tracking table** - No deletions, only anonymization on user deletion
|
|
||||||
2. **Server timestamps** - `occurred_at` always uses server time (UTC) to avoid client clock drift
|
|
||||||
3. **Separate logging** - Per-user audit logs independent of database
|
|
||||||
4. **Offset pagination** - Simpler than cursors, sufficient for expected scale
|
|
||||||
5. **No UI (yet)** - API/models/SSE only; UI deferred to future phase
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Usage Examples
|
|
||||||
|
|
||||||
### Backend: Create a tracking event
|
|
||||||
|
|
||||||
```python
|
|
||||||
from models.tracking_event import TrackingEvent
|
|
||||||
from db.tracking import insert_tracking_event
|
|
||||||
from utils.tracking_logger import log_tracking_event
|
|
||||||
|
|
||||||
event = TrackingEvent.create_event(
|
|
||||||
user_id='user123',
|
|
||||||
child_id='child456',
|
|
||||||
entity_type='task',
|
|
||||||
entity_id='task789',
|
|
||||||
action='activated',
|
|
||||||
points_before=50,
|
|
||||||
points_after=60,
|
|
||||||
metadata={'task_name': 'Homework'}
|
|
||||||
)
|
|
||||||
|
|
||||||
insert_tracking_event(event)
|
|
||||||
log_tracking_event(event)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend: Query tracking events
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { getTrackingEventsForChild } from "@/common/api";
|
|
||||||
|
|
||||||
const res = await getTrackingEventsForChild({
|
|
||||||
childId: "child456",
|
|
||||||
entityType: "task",
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
// { tracking_events: [...], total: 42, count: 20, limit: 20, offset: 0 }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Migration Notes
|
|
||||||
|
|
||||||
1. **New database file**: `backend/data/db/tracking_events.json` will be created automatically on first tracking event.
|
|
||||||
2. **New log directory**: `backend/logs/tracking_user_<user_id>.log` files will be created per user.
|
|
||||||
3. **No breaking changes** to existing APIs or data models.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔮 Future Enhancements (Not in This Phase)
|
|
||||||
|
|
||||||
- Admin/parent UI for viewing tracking history
|
|
||||||
- Badges and certificates based on tracking data
|
|
||||||
- Analytics and reporting dashboards
|
|
||||||
- Export tracking data (CSV, JSON)
|
|
||||||
- Time-based filters (date range queries)
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
@@ -1,519 +0,0 @@
|
|||||||
# Feature: Dynamic Point and Cost Customization
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Goal:** Allow parents to customize the point value of tasks/penalties and the cost of rewards on a per-child basis after assignment.
|
|
||||||
|
|
||||||
**User Story:**
|
|
||||||
As a parent, I want to assign different point values to the same task for different children, so I can tailor rewards to each child's needs and motivations. For example, "Clean Room" might be worth 10 points for one child but 5 points for another.
|
|
||||||
|
|
||||||
**Process:**
|
|
||||||
|
|
||||||
1. **Assignment First**: Tasks, penalties, and rewards must be assigned to a child before their points/cost can be customized.
|
|
||||||
2. **Edit Button Access**: After the first click on an item in ScrollingList (when it centers), an edit button appears in the corner (34x34px, using `edit.png` icon).
|
|
||||||
3. **Modal Customization**: Clicking the edit button opens a modal with a number input field allowing values from **0 to 10000**.
|
|
||||||
4. **Default Values**: The field defaults to the last user-set value or the entity's default points/cost if never customized.
|
|
||||||
5. **Visual Indicator**: Items with custom values show a ✏️ emoji badge next to the points/cost number.
|
|
||||||
6. **Activation Behavior**: The second click on an item activates it (triggers task/reward), not the first click.
|
|
||||||
|
|
||||||
**Architecture Decisions:**
|
|
||||||
|
|
||||||
- **Storage**: Use a separate `child_overrides.json` table (not embedded in child model) to store per-child customizations.
|
|
||||||
- **Lifecycle**: Overrides reset to default when a child is unassigned from a task/reward. Overrides are deleted when the entity or child is deleted (cascade).
|
|
||||||
- **Validation**: Allow 0 points/cost (not minimum 1). Disable save button on invalid input (empty, negative, >10000).
|
|
||||||
- **UI Flow**: First click centers item and shows edit button. Second click activates entity. Edit button opens modal for customization.
|
|
||||||
|
|
||||||
**UI:**
|
|
||||||
|
|
||||||
- Before first click: [feat-dynamic-points-before.png](feat-dynamic-points-before.png)
|
|
||||||
- After first click: [feat-dynamic-points-after.png](feat-dynamic-points-after.png)
|
|
||||||
- Edit button icon: `frontend/vue-app/public/edit.png` (34x34px)
|
|
||||||
- Button position: Corner of ScrollingList item, not interfering with text
|
|
||||||
- Badge: ✏️ emoji displayed next to points/cost number when override exists
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
**No new configuration required.** Range validation (0-10000) is hardcoded per requirements.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model Changes
|
|
||||||
|
|
||||||
### New Model: `ChildOverride`
|
|
||||||
|
|
||||||
**Python** (`backend/models/child_override.py`):
|
|
||||||
|
|
||||||
Create a dataclass that inherits from `BaseModel` with the following fields:
|
|
||||||
|
|
||||||
- `child_id` (str): ID of the child this override applies to
|
|
||||||
- `entity_id` (str): ID of the task/penalty/reward being customized
|
|
||||||
- `entity_type` (Literal['task', 'reward']): Type of entity
|
|
||||||
- `custom_value` (int): Custom points or cost value
|
|
||||||
|
|
||||||
Validation requirements:
|
|
||||||
|
|
||||||
- `custom_value` must be between 0 and 10000 (inclusive)
|
|
||||||
- `entity_type` must be either 'task' or 'reward'
|
|
||||||
- Include `__post_init__` method to enforce these validations
|
|
||||||
- Include static factory method `create_override()` that accepts the four main fields and returns a new instance
|
|
||||||
|
|
||||||
**TypeScript** (`frontend/vue-app/src/common/models.ts`):
|
|
||||||
|
|
||||||
Create an interface with 1:1 parity to the Python model:
|
|
||||||
|
|
||||||
- Define `EntityType` as a union type: 'task' | 'reward'
|
|
||||||
- Include all fields: `id`, `child_id`, `entity_id`, `entity_type`, `custom_value`, `created_at`, `updated_at`
|
|
||||||
- All string fields except `custom_value` which is number
|
|
||||||
|
|
||||||
### Database Table
|
|
||||||
|
|
||||||
**New Table**: `child_overrides.json`
|
|
||||||
|
|
||||||
**Indexes**:
|
|
||||||
|
|
||||||
- `child_id` (for lookup by child)
|
|
||||||
- `entity_id` (for lookup by task/reward)
|
|
||||||
- Composite `(child_id, entity_id)` (for uniqueness constraint)
|
|
||||||
|
|
||||||
**Database Helper** (`backend/db/child_overrides.py`):
|
|
||||||
|
|
||||||
Create database helper functions using TinyDB and the `child_overrides_db` table:
|
|
||||||
|
|
||||||
- `insert_override(override)`: Insert or update (upsert) based on composite key (child_id, entity_id). Only one override allowed per child-entity pair.
|
|
||||||
- `get_override(child_id, entity_id)`: Return Optional[ChildOverride] for a specific child and entity combination
|
|
||||||
- `get_overrides_for_child(child_id)`: Return List[ChildOverride] for all overrides belonging to a child
|
|
||||||
- `delete_override(child_id, entity_id)`: Delete specific override, return bool indicating success
|
|
||||||
- `delete_overrides_for_child(child_id)`: Delete all overrides for a child, return count deleted
|
|
||||||
- `delete_overrides_for_entity(entity_id)`: Delete all overrides for an entity, return count deleted
|
|
||||||
|
|
||||||
All functions should use `from_dict()` and `to_dict()` for model serialization.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SSE Events
|
|
||||||
|
|
||||||
### 1. `child_override_set`
|
|
||||||
|
|
||||||
**Emitted When**: A parent sets or updates a custom value for a task/reward.
|
|
||||||
|
|
||||||
**Payload** (`backend/events/types/child_override_set.py`):
|
|
||||||
|
|
||||||
Create a dataclass `ChildOverrideSetPayload` that inherits from `EventPayload` with a single field:
|
|
||||||
|
|
||||||
- `override` (ChildOverride): The override object that was set
|
|
||||||
|
|
||||||
**TypeScript** (`frontend/vue-app/src/common/backendEvents.ts`):
|
|
||||||
|
|
||||||
Create an interface `ChildOverrideSetPayload` with:
|
|
||||||
|
|
||||||
- `override` (ChildOverride): The override object that was set
|
|
||||||
|
|
||||||
### 2. `child_override_deleted`
|
|
||||||
|
|
||||||
**Emitted When**: An override is deleted (manual reset, unassignment, or cascade).
|
|
||||||
|
|
||||||
**Payload** (`backend/events/types/child_override_deleted.py`):
|
|
||||||
|
|
||||||
Create a dataclass `ChildOverrideDeletedPayload` that inherits from `EventPayload` with three fields:
|
|
||||||
|
|
||||||
- `child_id` (str): ID of the child
|
|
||||||
- `entity_id` (str): ID of the entity
|
|
||||||
- `entity_type` (str): Type of entity ('task' or 'reward')
|
|
||||||
|
|
||||||
**TypeScript** (`frontend/vue-app/src/common/backendEvents.ts`):
|
|
||||||
|
|
||||||
Create an interface `ChildOverrideDeletedPayload` with:
|
|
||||||
|
|
||||||
- `child_id` (string): ID of the child
|
|
||||||
- `entity_id` (string): ID of the entity
|
|
||||||
- `entity_type` (string): Type of entity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## API Design
|
|
||||||
|
|
||||||
### 1. **PUT** `/child/<child_id>/override`
|
|
||||||
|
|
||||||
**Purpose**: Set or update a custom value for a task/reward.
|
|
||||||
|
|
||||||
**Auth**: User must own the child.
|
|
||||||
|
|
||||||
**Request Body**:
|
|
||||||
|
|
||||||
JSON object with three required fields:
|
|
||||||
|
|
||||||
- `entity_id` (string): UUID of the task or reward
|
|
||||||
- `entity_type` (string): Either "task" or "reward"
|
|
||||||
- `custom_value` (number): Integer between 0 and 10000
|
|
||||||
|
|
||||||
**Validation**:
|
|
||||||
|
|
||||||
- `entity_type` must be "task" or "reward"
|
|
||||||
- `custom_value` must be 0-10000
|
|
||||||
- Entity must be assigned to child
|
|
||||||
- Child must exist and belong to user
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
JSON object with a single key `override` containing the complete ChildOverride object with all fields (id, child_id, entity_id, entity_type, custom_value, created_at, updated_at in ISO format).
|
|
||||||
|
|
||||||
**Errors**:
|
|
||||||
|
|
||||||
- 404: Child not found or not owned
|
|
||||||
- 404: Entity not assigned to child
|
|
||||||
- 400: Invalid entity_type
|
|
||||||
- 400: custom_value out of range
|
|
||||||
|
|
||||||
**SSE**: Emits `child_override_set` to user.
|
|
||||||
|
|
||||||
### 2. **GET** `/child/<child_id>/overrides`
|
|
||||||
|
|
||||||
**Purpose**: Get all overrides for a child.
|
|
||||||
|
|
||||||
**Auth**: User must own the child.
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
JSON object with a single key `overrides` containing an array of ChildOverride objects. Each object includes all standard fields (id, child_id, entity_id, entity_type, custom_value, created_at, updated_at).
|
|
||||||
|
|
||||||
**Errors**:
|
|
||||||
|
|
||||||
- 404: Child not found or not owned
|
|
||||||
|
|
||||||
### 3. **DELETE** `/child/<child_id>/override/<entity_id>`
|
|
||||||
|
|
||||||
**Purpose**: Delete an override (reset to default).
|
|
||||||
|
|
||||||
**Auth**: User must own the child.
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
JSON object with `message` field set to "Override deleted".
|
|
||||||
|
|
||||||
**Errors**:
|
|
||||||
|
|
||||||
- 404: Child not found or not owned
|
|
||||||
- 404: Override not found
|
|
||||||
|
|
||||||
**SSE**: Emits `child_override_deleted` to user.
|
|
||||||
|
|
||||||
### Modified Endpoints
|
|
||||||
|
|
||||||
Update these existing endpoints to include override information:
|
|
||||||
|
|
||||||
1. **GET** `/child/<child_id>/list-tasks` - Include `custom_value` in task objects if override exists
|
|
||||||
2. **GET** `/child/<child_id>/list-rewards` - Include `custom_value` in reward objects if override exists
|
|
||||||
3. **POST** `/child/<child_id>/trigger-task` - Use `custom_value` if override exists when awarding points
|
|
||||||
4. **POST** `/child/<child_id>/trigger-reward` - Use `custom_value` if override exists when deducting points
|
|
||||||
5. **PUT** `/child/<child_id>/set-tasks` - Delete overrides for unassigned tasks
|
|
||||||
6. **PUT** `/child/<child_id>/set-rewards` - Delete overrides for unassigned rewards
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
**Backend**:
|
|
||||||
|
|
||||||
- `backend/models/child_override.py` - ChildOverride model
|
|
||||||
- `backend/db/child_overrides.py` - Database helpers
|
|
||||||
- `backend/api/child_override_api.py` - New API endpoints (PUT, GET, DELETE)
|
|
||||||
- `backend/events/types/child_override_set.py` - SSE event payload
|
|
||||||
- `backend/events/types/child_override_deleted.py` - SSE event payload
|
|
||||||
- `backend/events/types/event_types.py` - Add CHILD_OVERRIDE_SET, CHILD_OVERRIDE_DELETED enums
|
|
||||||
- `backend/tests/test_child_override_api.py` - Unit tests
|
|
||||||
|
|
||||||
**Frontend**:
|
|
||||||
|
|
||||||
- `frontend/vue-app/src/common/models.ts` - Add ChildOverride interface
|
|
||||||
- `frontend/vue-app/src/common/api.ts` - Add setChildOverride(), getChildOverrides(), deleteChildOverride()
|
|
||||||
- `frontend/vue-app/src/common/backendEvents.ts` - Add event types
|
|
||||||
- `frontend/vue-app/src/components/OverrideEditModal.vue` - New modal component
|
|
||||||
- `frontend/vue-app/src/components/ScrollingList.vue` - Add edit button and ✏️ badge
|
|
||||||
- `frontend/vue-app/components/__tests__/OverrideEditModal.spec.ts` - Component tests
|
|
||||||
|
|
||||||
### Logging Strategy
|
|
||||||
|
|
||||||
**Backend**: Log override operations to per-user rotating log files (same pattern as tracking).
|
|
||||||
|
|
||||||
**Log Messages**:
|
|
||||||
|
|
||||||
- `Override set: child={child_id}, entity={entity_id}, type={entity_type}, value={custom_value}`
|
|
||||||
- `Override deleted: child={child_id}, entity={entity_id}`
|
|
||||||
- `Overrides cascade deleted for child: child_id={child_id}, count={count}`
|
|
||||||
- `Overrides cascade deleted for entity: entity_id={entity_id}, count={count}`
|
|
||||||
|
|
||||||
**Frontend**: No additional logging beyond standard error handling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria (Definition of Done)
|
|
||||||
|
|
||||||
### Data Model
|
|
||||||
|
|
||||||
- [x] `ChildOverride` Python dataclass created with validation (0-10000 range, entity_type literal)
|
|
||||||
- [x] `ChildOverride` TypeScript interface created (1:1 parity with Python)
|
|
||||||
- [x] `child_overrides.json` TinyDB table created in `backend/db/db.py`
|
|
||||||
- [x] Database helper functions created (insert, get, delete by child, delete by entity)
|
|
||||||
- [x] Composite uniqueness constraint enforced (child_id, entity_id)
|
|
||||||
|
|
||||||
### Backend Implementation
|
|
||||||
|
|
||||||
- [x] PUT `/child/<child_id>/override` endpoint created with validation
|
|
||||||
- [x] GET `/child/<child_id>/overrides` endpoint created
|
|
||||||
- [x] DELETE `/child/<child_id>/override/<entity_id>` endpoint created
|
|
||||||
- [x] GET `/child/<child_id>/list-tasks` modified to include `custom_value` when override exists
|
|
||||||
- [x] GET `/child/<child_id>/list-rewards` modified to include `custom_value` when override exists
|
|
||||||
- [x] POST `/child/<child_id>/trigger-task` modified to use override value
|
|
||||||
- [x] POST `/child/<child_id>/trigger-reward` modified to use override value
|
|
||||||
- [x] PUT `/child/<child_id>/set-tasks` modified to delete overrides for unassigned tasks
|
|
||||||
- [x] PUT `/child/<child_id>/set-rewards` modified to delete overrides for unassigned rewards
|
|
||||||
- [x] Cascade delete implemented: deleting child removes all its overrides
|
|
||||||
- [x] Cascade delete implemented: deleting task/reward removes all its overrides
|
|
||||||
- [x] Authorization checks: user must own child to access overrides
|
|
||||||
- [x] Validation: entity must be assigned to child before override can be set
|
|
||||||
|
|
||||||
### SSE Events
|
|
||||||
|
|
||||||
- [x] `child_override_set` event type added to event_types.py
|
|
||||||
- [x] `child_override_deleted` event type added to event_types.py
|
|
||||||
- [x] `ChildOverrideSetPayload` class created (Python)
|
|
||||||
- [x] `ChildOverrideDeletedPayload` class created (Python)
|
|
||||||
- [x] PUT endpoint emits `child_override_set` event
|
|
||||||
- [x] DELETE endpoint emits `child_override_deleted` event
|
|
||||||
- [x] Frontend TypeScript interfaces for event payloads created
|
|
||||||
|
|
||||||
### Frontend Implementation
|
|
||||||
|
|
||||||
- [x] `OverrideEditModal.vue` component created
|
|
||||||
- [x] Modal has number input field with 0-10000 validation
|
|
||||||
- [x] Modal disables save button on invalid input (empty, negative, >10000)
|
|
||||||
- [x] Modal defaults to current override value or entity default
|
|
||||||
- [x] Modal calls PUT `/child/<id>/override` API on save
|
|
||||||
- [x] Edit button (34x34px) added to ScrollingList items
|
|
||||||
- [x] Edit button only appears after first click (when item is centered)
|
|
||||||
- [x] Edit button uses `edit.png` icon from public folder
|
|
||||||
- [x] ✏️ emoji badge displayed next to points/cost when override exists
|
|
||||||
- [x] Badge only shows for items with active overrides
|
|
||||||
- [x] Second click on item activates entity (not first click)
|
|
||||||
- [x] SSE listeners registered for `child_override_set` and `child_override_deleted`
|
|
||||||
- [x] Real-time UI updates when override events received
|
|
||||||
|
|
||||||
### Backend Unit Tests
|
|
||||||
|
|
||||||
#### API Tests (`backend/tests/test_child_override_api.py`)
|
|
||||||
|
|
||||||
- [x] Test PUT creates new override with valid data
|
|
||||||
- [x] Test PUT updates existing override
|
|
||||||
- [x] Test PUT returns 400 for custom_value < 0
|
|
||||||
- [x] Test PUT returns 400 for custom_value > 10000
|
|
||||||
- [x] Test PUT returns 400 for invalid entity_type
|
|
||||||
- [ ] Test PUT returns 404 for non-existent child
|
|
||||||
- [ ] Test PUT returns 404 for unassigned entity
|
|
||||||
- [ ] Test PUT returns 403 for child not owned by user
|
|
||||||
- [ ] Test PUT emits child_override_set event
|
|
||||||
- [x] Test GET returns all overrides for child
|
|
||||||
- [ ] Test GET returns empty array when no overrides
|
|
||||||
- [ ] Test GET returns 404 for non-existent child
|
|
||||||
- [ ] Test GET returns 403 for child not owned by user
|
|
||||||
- [x] Test DELETE removes override
|
|
||||||
- [ ] Test DELETE returns 404 when override doesn't exist
|
|
||||||
- [ ] Test DELETE returns 404 for non-existent child
|
|
||||||
- [ ] Test DELETE returns 403 for child not owned by user
|
|
||||||
- [ ] Test DELETE emits child_override_deleted event
|
|
||||||
|
|
||||||
#### Integration Tests
|
|
||||||
|
|
||||||
- [ ] Test list-tasks includes custom_value for overridden tasks
|
|
||||||
- [ ] Test list-tasks shows default points for non-overridden tasks
|
|
||||||
- [ ] Test list-rewards includes custom_value for overridden rewards
|
|
||||||
- [ ] Test trigger-task uses custom_value when awarding points
|
|
||||||
- [ ] Test trigger-task uses default points when no override
|
|
||||||
- [ ] Test trigger-reward uses custom_value when deducting points
|
|
||||||
- [ ] Test trigger-reward uses default cost when no override
|
|
||||||
- [ ] Test set-tasks deletes overrides for unassigned tasks
|
|
||||||
- [ ] Test set-tasks preserves overrides for still-assigned tasks
|
|
||||||
- [ ] Test set-rewards deletes overrides for unassigned rewards
|
|
||||||
- [ ] Test set-rewards preserves overrides for still-assigned rewards
|
|
||||||
|
|
||||||
#### Cascade Delete Tests
|
|
||||||
|
|
||||||
- [x] Test deleting child removes all its overrides
|
|
||||||
- [x] Test deleting task removes all overrides for that task
|
|
||||||
- [x] Test deleting reward removes all overrides for that reward
|
|
||||||
- [x] Test unassigning task from child deletes override
|
|
||||||
- [x] Test reassigning task to child resets override (not preserved)
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
|
|
||||||
- [x] Test custom_value = 0 is allowed
|
|
||||||
- [x] Test custom_value = 10000 is allowed
|
|
||||||
- [ ] Test cannot set override for entity not assigned to child
|
|
||||||
- [ ] Test cannot set override for non-existent entity
|
|
||||||
- [ ] Test multiple children can have different overrides for same entity
|
|
||||||
|
|
||||||
### Frontend Unit Tests
|
|
||||||
|
|
||||||
#### Component Tests (`components/__tests__/OverrideEditModal.spec.ts`)
|
|
||||||
|
|
||||||
- [x] Test modal renders with default value
|
|
||||||
- [x] Test modal renders with existing override value
|
|
||||||
- [x] Test save button disabled when input is empty
|
|
||||||
- [x] Test save button disabled when value < 0
|
|
||||||
- [x] Test save button disabled when value > 10000
|
|
||||||
- [x] Test save button enabled when value is 0-10000
|
|
||||||
- [x] Test modal calls API with correct parameters on save
|
|
||||||
- [x] Test modal emits close event after successful save
|
|
||||||
- [x] Test modal shows error message on API failure
|
|
||||||
- [x] Test cancel button closes modal without saving
|
|
||||||
|
|
||||||
#### Component Tests (`components/__tests__/ScrollingList.spec.ts`)
|
|
||||||
|
|
||||||
- [ ] Test edit button hidden before first click
|
|
||||||
- [ ] Test edit button appears after first click (when centered)
|
|
||||||
- [ ] Test edit button opens OverrideEditModal
|
|
||||||
- [ ] Test ✏️ badge displayed when override exists
|
|
||||||
- [ ] Test ✏️ badge hidden when no override exists
|
|
||||||
- [ ] Test second click activates entity (not first click)
|
|
||||||
- [ ] Test edit button positioned correctly (34x34px, corner)
|
|
||||||
- [ ] Test edit button doesn't interfere with text
|
|
||||||
|
|
||||||
#### Integration Tests
|
|
||||||
|
|
||||||
- [ ] Test SSE event updates UI when override is set
|
|
||||||
- [ ] Test SSE event updates UI when override is deleted
|
|
||||||
- [ ] Test override value displayed in task/reward list
|
|
||||||
- [ ] Test points calculation uses override when triggering task
|
|
||||||
- [ ] Test cost calculation uses override when triggering reward
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
|
|
||||||
- [ ] Test 0 points/cost displays correctly
|
|
||||||
- [ ] Test 10000 points/cost displays correctly
|
|
||||||
- [ ] Test badge updates immediately after setting override
|
|
||||||
- [ ] Test badge disappears immediately after deleting override
|
|
||||||
|
|
||||||
### Logging & Monitoring
|
|
||||||
|
|
||||||
- [ ] Override set operations logged to per-user log files
|
|
||||||
- [ ] Override delete operations logged
|
|
||||||
- [ ] Cascade delete operations logged with count
|
|
||||||
- [ ] Log messages include child_id, entity_id, entity_type, custom_value
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- [ ] API endpoints documented in this spec
|
|
||||||
- [ ] Data model documented in this spec
|
|
||||||
- [ ] SSE events documented in this spec
|
|
||||||
- [ ] UI behavior documented in this spec
|
|
||||||
- [ ] Edge cases and validation rules documented
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### Unit Test Files
|
|
||||||
|
|
||||||
**Backend** (`backend/tests/test_child_override_api.py`):
|
|
||||||
|
|
||||||
Create six test classes:
|
|
||||||
|
|
||||||
1. **TestChildOverrideModel**: Test model validation (6 tests)
|
|
||||||
- Valid override creation
|
|
||||||
- Negative custom_value raises ValueError
|
|
||||||
- custom_value > 10000 raises ValueError
|
|
||||||
- custom_value = 0 is allowed
|
|
||||||
- custom_value = 10000 is allowed
|
|
||||||
- Invalid entity_type raises ValueError
|
|
||||||
|
|
||||||
2. **TestChildOverrideDB**: Test database operations (8 tests)
|
|
||||||
- Insert new override
|
|
||||||
- Insert updates existing (upsert behavior)
|
|
||||||
- Get existing override returns object
|
|
||||||
- Get nonexistent override returns None
|
|
||||||
- Get all overrides for a child
|
|
||||||
- Delete specific override
|
|
||||||
- Delete all overrides for a child (returns count)
|
|
||||||
- Delete all overrides for an entity (returns count)
|
|
||||||
|
|
||||||
3. **TestChildOverrideAPI**: Test all three API endpoints (18 tests)
|
|
||||||
- PUT creates new override
|
|
||||||
- PUT updates existing override
|
|
||||||
- PUT returns 400 for negative value
|
|
||||||
- PUT returns 400 for value > 10000
|
|
||||||
- PUT returns 400 for invalid entity_type
|
|
||||||
- PUT returns 404 for nonexistent child
|
|
||||||
- PUT returns 404 for unassigned entity
|
|
||||||
- PUT returns 403 for child not owned by user
|
|
||||||
- PUT emits child_override_set event
|
|
||||||
- GET returns all overrides for child
|
|
||||||
- GET returns empty array when no overrides
|
|
||||||
- GET returns 404 for nonexistent child
|
|
||||||
- GET returns 403 for child not owned
|
|
||||||
- DELETE removes override successfully
|
|
||||||
- DELETE returns 404 when override doesn't exist
|
|
||||||
- DELETE returns 404 for nonexistent child
|
|
||||||
- DELETE returns 403 for child not owned
|
|
||||||
- DELETE emits child_override_deleted event
|
|
||||||
|
|
||||||
4. **TestIntegration**: Test override integration with existing endpoints (11 tests)
|
|
||||||
- list-tasks includes custom_value for overridden tasks
|
|
||||||
- list-tasks shows default points for non-overridden tasks
|
|
||||||
- list-rewards includes custom_value for overridden rewards
|
|
||||||
- trigger-task uses custom_value when awarding points
|
|
||||||
- trigger-task uses default points when no override
|
|
||||||
- trigger-reward uses custom_value when deducting points
|
|
||||||
- trigger-reward uses default cost when no override
|
|
||||||
- set-tasks deletes overrides for unassigned tasks
|
|
||||||
- set-tasks preserves overrides for still-assigned tasks
|
|
||||||
- set-rewards deletes overrides for unassigned rewards
|
|
||||||
- set-rewards preserves overrides for still-assigned rewards
|
|
||||||
|
|
||||||
5. **TestCascadeDelete**: Test cascade deletion behavior (5 tests)
|
|
||||||
- Deleting child removes all its overrides
|
|
||||||
- Deleting task removes all overrides for that task
|
|
||||||
- Deleting reward removes all overrides for that reward
|
|
||||||
- Unassigning task deletes override
|
|
||||||
- Reassigning task resets override (not preserved)
|
|
||||||
|
|
||||||
6. **TestEdgeCases**: Test boundary conditions (5 tests)
|
|
||||||
- custom_value = 0 is allowed
|
|
||||||
- custom_value = 10000 is allowed
|
|
||||||
- Cannot set override for unassigned entity
|
|
||||||
- Cannot set override for nonexistent entity
|
|
||||||
- Multiple children can have different overrides for same entity
|
|
||||||
|
|
||||||
### Test Fixtures
|
|
||||||
|
|
||||||
Create pytest fixtures for common test scenarios:
|
|
||||||
|
|
||||||
- `child_with_task`: Uses existing `child` and `task` fixtures, calls set-tasks endpoint to assign task to child, asserts 200 response, returns child dict
|
|
||||||
- `child_with_task_override`: Builds on `child_with_task`, calls PUT override endpoint to set custom_value=15 for the task, asserts 200 response, returns child dict
|
|
||||||
- Similar fixtures for rewards: `child_with_reward`, `child_with_reward_override`
|
|
||||||
- `child_with_overrides`: Child with multiple overrides for testing bulk operations
|
|
||||||
|
|
||||||
### Assertions
|
|
||||||
|
|
||||||
Test assertions should verify three main areas:
|
|
||||||
|
|
||||||
1. **API Response Correctness**: Check status code (200, 400, 403, 404), verify returned override object has correct values for all fields (custom_value, child_id, entity_id, etc.)
|
|
||||||
|
|
||||||
2. **SSE Event Emission**: Use mock_sse fixture to assert `send_event_for_current_user` was called exactly once with the correct EventType (CHILD_OVERRIDE_SET or CHILD_OVERRIDE_DELETED)
|
|
||||||
|
|
||||||
3. **Points Calculation**: After triggering tasks/rewards, verify the child's points reflect the custom_value (not the default). For example, if default is 10 but override is 15, child.points should increase by 15.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
1. **Bulk Override Management**: Add endpoint to set/get/delete multiple overrides at once for performance.
|
|
||||||
2. **Override History**: Track changes to override values over time for analytics.
|
|
||||||
3. **Copy Overrides**: Allow copying overrides from one child to another.
|
|
||||||
4. **Override Templates**: Save common override patterns as reusable templates.
|
|
||||||
5. **Percentage-Based Overrides**: Allow setting overrides as percentage of default (e.g., "150% of default").
|
|
||||||
6. **Override Expiration**: Add optional expiration dates for temporary adjustments.
|
|
||||||
7. **Undo Override**: Add "Restore Default" button in UI that deletes override with one click.
|
|
||||||
8. **Admin Dashboard**: Show overview of all overrides across all children for analysis.
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
# Feature: Task and Reward Tracking
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Goal:** Tasks, Penalties, and Rewards should be recorded when completed (activated), requested, redeemed, and cancelled. A record of the date and time should also be kept for these actions. A log file shall be produced that shows the child's points before and after the action happened.
|
|
||||||
|
|
||||||
**User Story:**
|
|
||||||
As an administrator, I want to know what kind and when a task, penalty, or reward was activated.
|
|
||||||
As an administrator, I want a log created detailing when a task, penalty, or reward was activated and how points for the affected child has changed.
|
|
||||||
As a user (parent), when I activate a task or penalty, I want to record the time and what task or penalty was activated.
|
|
||||||
As a user (parent), when I redeem a reward, I want to record the time and what reward was redeeemed.
|
|
||||||
As a user (parent/child), when I cancel a reward, I want to record the time and what reward was cancelled.
|
|
||||||
As a user (child), when I request a reward, I want to record the time and what reward was requested.
|
|
||||||
|
|
||||||
**Questions:**
|
|
||||||
|
|
||||||
- Tasks/Penalty, rewards should be tracked per child. Should the tracking be recorded in the child database, or should a new database be used linking the tracking to the child?
|
|
||||||
- If using a new database, should tracking also be linking to user in case of account deletion?
|
|
||||||
- Does there need to be any frontend changes for now?
|
|
||||||
|
|
||||||
**Decisions:**
|
|
||||||
|
|
||||||
- Use a **new TinyDB table** (`tracking_events.json`) for tracking records (append-only). Do **not** embed tracking in `child` to avoid large child docs and preserve audit history. Each record includes `child_id` and `user_id`.
|
|
||||||
- Track events for: task/penalty activated, reward requested, reward redeemed, reward cancelled.
|
|
||||||
- Store timestamps in **UTC ISO 8601** with timezone (e.g. `2026-02-09T18:42:15Z`). Always use **server time** for `occurred_at` to avoid client clock drift.
|
|
||||||
- On user deletion: **anonymize** tracking records by setting `user_id` to `null`, preserving child activity history for compliance/audit.
|
|
||||||
- Keep an **audit log file per user** (e.g. `tracking_user_<user_id>.log`) with points before/after and event metadata. Use rotating file handler.
|
|
||||||
- Use **offset-based pagination** for tracking queries (simpler with TinyDB, sufficient for expected scale).
|
|
||||||
- **Frontend changes deferred**: Ship backend API, models, and SSE events only. No UI components in this phase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
## Acceptance Criteria (Definition of Done)
|
|
||||||
|
|
||||||
### Data Model
|
|
||||||
|
|
||||||
- [x] Add `TrackingEvent` model in `backend/models/` with `from_dict()`/`to_dict()` and 1:1 TS interface in [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts)
|
|
||||||
- [x] `TrackingEvent` fields include: `id`, `user_id`, `child_id`, `entity_type` (task|reward|penalty), `entity_id`, `action` (activated|requested|redeemed|cancelled), `points_before`, `points_after`, `delta`, `occurred_at`, `created_at`, `metadata` (optional dict)
|
|
||||||
- [x] Ensure `delta == points_after - points_before` invariant
|
|
||||||
|
|
||||||
### Backend Implementation
|
|
||||||
|
|
||||||
- [x] Create TinyDB table (e.g., `tracking_events.json`) with helper functions in `backend/db/`
|
|
||||||
- [x] Add tracking write in all mutation endpoints:
|
|
||||||
- task/penalty activation
|
|
||||||
- reward request
|
|
||||||
- reward redeem
|
|
||||||
- reward cancel
|
|
||||||
- [x] Build `TrackingEvent` instances from models (no raw dict writes)
|
|
||||||
- [x] Add server-side validation for required fields and action/entity enums
|
|
||||||
- [x] Add `send_event_for_current_user` calls for tracking mutations
|
|
||||||
|
|
||||||
### Frontend Implementation
|
|
||||||
|
|
||||||
- [x] Add `TrackingEvent` interface and enums to [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts)
|
|
||||||
- [x] Add API helpers for tracking (list per child, optional filters) in [frontend/vue-app/src/common/api.ts](frontend/vue-app/src/common/api.ts)
|
|
||||||
- [x] Register SSE event type `tracking_event_created` in [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts)
|
|
||||||
- [x] **No UI components** — deferred to future phase
|
|
||||||
|
|
||||||
### Admin API
|
|
||||||
|
|
||||||
- [x] Add admin endpoint to query tracking by `child_id`, date range, and `entity_type` (e.g. `GET /admin/tracking`)
|
|
||||||
- [x] Add offset-based pagination parameters (`limit`, `offset`) with sensible defaults (e.g. limit=50, max=500)
|
|
||||||
- [x] Return total count for pagination UI (future)
|
|
||||||
|
|
||||||
### SSE Event
|
|
||||||
|
|
||||||
- [x] Add event type `tracking_event_created` with payload containing `tracking_event_id` and minimal denormalized info
|
|
||||||
- [x] Update [backend/events/types/event_types.py](backend/events/types/event_types.py) and [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts)
|
|
||||||
|
|
||||||
### Backend Unit Tests
|
|
||||||
|
|
||||||
- [x] Create tests for tracking creation on each mutation endpoint (task/penalty activated, reward requested/redeemed/cancelled)
|
|
||||||
- [x] Validate `points_before/after` and `delta` are correct
|
|
||||||
- [x] Ensure tracking write does not block core mutation (failure behavior defined)
|
|
||||||
|
|
||||||
### Frontend Unit Tests
|
|
||||||
|
|
||||||
- [x] Test API helper functions for tracking queries
|
|
||||||
- [x] Test TypeScript interface matches backend model (type safety)
|
|
||||||
|
|
||||||
#### Edge Cases
|
|
||||||
|
|
||||||
- [x] Reward cancel after redeem should not create duplicate inconsistent entries
|
|
||||||
- [x] Multiple activations in rapid sequence must be ordered by `occurred_at` then `created_at`
|
|
||||||
- [x] Child deleted: tracking records retained and still queryable by admin (archive mode)
|
|
||||||
- [x] User deleted: anonymize tracking by setting `user_id` to `null`, retain all other fields for audit history
|
|
||||||
|
|
||||||
#### Integration Tests
|
|
||||||
|
|
||||||
- [x] End-to-end: activate task -> tracking created -> SSE event emitted -> audit log written
|
|
||||||
- [x] Verify user deletion anonymizes tracking records without breaking queries
|
|
||||||
|
|
||||||
### Logging & Monitoring
|
|
||||||
|
|
||||||
- [x] Add dedicated tracking logger with **per-user rotating file handler** (e.g. `logs/tracking_user_<user_id>.log`)
|
|
||||||
- [x] Log one line per tracking event with `user_id`, `child_id`, `entity_type`, `entity_id`, `action`, `points_before`, `points_after`, `delta`, `occurred_at`
|
|
||||||
- [x] Configure max file size and backup count (e.g. 10MB, 5 backups)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- [x] Update README or docs to include tracking endpoints, schema, and sample responses
|
|
||||||
- [x] Add migration note for new `tracking_events.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
- Reward tracking will be used to determine child ranking (badges and certificates!)
|
|
||||||
- is_good vs not is_good in task tracking can be used to show the child their balance in good vs not good
|
|
||||||
87
.github/specs/archive/feat-hashed-passwords.md
vendored
87
.github/specs/archive/feat-hashed-passwords.md
vendored
@@ -1,87 +0,0 @@
|
|||||||
# Feature: Hash passwords in database
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Goal:** Currently passwords for users are stored in the database as plain text. They need to be hashed using a secure algorithm to prevent exposure in case of a data breach.
|
|
||||||
|
|
||||||
**User Story:**
|
|
||||||
As a user, when I create an account with a password, the password needs to be hashed in the database.
|
|
||||||
As an admin, I would like a script that will convert the current user database passwords into a hash.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model Changes
|
|
||||||
|
|
||||||
### Backend Model (`backend/models/user.py`)
|
|
||||||
|
|
||||||
No changes required to the `User` dataclass fields. Passwords will remain as strings, but they will now be hashed values instead of plain text.
|
|
||||||
|
|
||||||
### Frontend Model (`frontend/vue-app/src/common/models.ts`)
|
|
||||||
|
|
||||||
No changes required. The `User` interface does not expose passwords.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Implementation
|
|
||||||
|
|
||||||
### Password Hashing
|
|
||||||
|
|
||||||
- Use `werkzeug.security.generate_password_hash()` with default settings (PBKDF2 with SHA256, salt, and iterations) for hashing new passwords.
|
|
||||||
- Use `werkzeug.security.check_password_hash()` for verification during login and password reset.
|
|
||||||
- Update the following endpoints to hash passwords on input and verify hashes on output:
|
|
||||||
- `POST /signup` (hash password before storing; existing length/complexity checks apply).
|
|
||||||
- `POST /login` (verify hash against input).
|
|
||||||
- `POST /reset-password` (hash new password before storing; existing length/complexity checks apply).
|
|
||||||
|
|
||||||
### Migration Script (`backend/scripts/hash_passwords.py`)
|
|
||||||
|
|
||||||
Create a new script to hash existing plain text passwords in the database:
|
|
||||||
|
|
||||||
- Read all users from `users_db`.
|
|
||||||
- For each user, check if the password is already hashed (starts with `scrypt:` or `$pbkdf2-sha256$`); if so, skip.
|
|
||||||
- For plain text passwords, hash using `generate_password_hash()`.
|
|
||||||
- Update the user record in the database.
|
|
||||||
- Log the number of users updated.
|
|
||||||
- Run this script once after deployment to migrate existing data.
|
|
||||||
|
|
||||||
**Usage:** `python backend/scripts/hash_passwords.py`
|
|
||||||
|
|
||||||
**Security Notes:**
|
|
||||||
|
|
||||||
- The script should only be run in a secure environment (e.g., admin access).
|
|
||||||
- After migration, verify a few users can log in.
|
|
||||||
- Delete or secure the script post-migration to avoid reuse.
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
No new error codes needed. Existing authentication errors (e.g., invalid credentials) remain unchanged.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Backend Tests (`backend/tests/test_auth_api.py`)
|
|
||||||
|
|
||||||
- [x] Test signup with password hashing: Verify stored password is hashed (starts with `scrypt:`).
|
|
||||||
- [x] Test login with correct password: Succeeds.
|
|
||||||
- [x] Test login with incorrect password: Fails with appropriate error.
|
|
||||||
- [x] Test password reset: New password is hashed.
|
|
||||||
- [x] Test migration script: Hashes existing plain text passwords without data loss; skips already-hashed passwords.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
- Monitor for deprecated hashing algorithms and plan upgrades (e.g., to Argon2 if needed).
|
|
||||||
- Implement password strength requirements on signup/reset if not already present.
|
|
||||||
- Consider rate limiting on login attempts to prevent brute-force attacks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria (Definition of Done)
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- [x] Update `/signup` to hash passwords using `werkzeug.security.generate_password_hash()`.
|
|
||||||
- [x] Update `/login` to verify passwords using `werkzeug.security.check_password_hash()`.
|
|
||||||
- [x] Update `/reset-password` to hash new passwords.
|
|
||||||
- [x] Create `backend/scripts/hash_passwords.py` script for migrating existing plain text passwords.
|
|
||||||
- [x] All backend tests pass, including new hashing tests.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Feature: Do Not Allow System Tasks or System Rewards To Be Deleted
|
|
||||||
|
|
||||||
## Context:
|
|
||||||
|
|
||||||
- **Goal:** In Task List view and Reward List view, do not allow items to be deleted by the user if they are system tasks.
|
|
||||||
- **User Story:** As a [user], I want to only be able to press the delete button on a task or reward if that item is not a system task or reward so that shared system tasks are not deleted for other users.
|
|
||||||
|
|
||||||
## Technical Requirements
|
|
||||||
|
|
||||||
- **File Affected:** ItemList.vue, TaskView.vue, RewardView.vue, task_api.py, reward_api.py
|
|
||||||
- **Logic:**
|
|
||||||
1. Starting with ItemList.vue, we should check to see if any item in the list has an "user_id" property and if that property is null.
|
|
||||||
2. If the property is null, that means the item is not owned by a user, so do no display a delete button.
|
|
||||||
3. If the ItemList has it's deletable property as false, don't bother checking each item for user_id as the delete button will not display.
|
|
||||||
4. As a safeguard, on the backend, the DELETE api requests should check to see if the "user_id" property of the requested task or reward is null. This is done by requesting the item from the database. The request provides the item's id. If the item is a system item, return 403. Let the return tell the requestor that the item is a system item and cannot be deleted.
|
|
||||||
5. As a safeguard, make PUT/PATCH operations perform a copy-on-edit of the item. This is already implemented.
|
|
||||||
6. Bulk deletion is not possible, don't make changes for this.
|
|
||||||
7. For any item in the frontend or backend that does not have a "user_id" property, treat that as a system item (user_id=null)
|
|
||||||
8. For both task and reward api create an application level constraint on the database that checks for user_id before mutation logic.
|
|
||||||
|
|
||||||
## Acceptance Criteria (The "Definition of Done")
|
|
||||||
|
|
||||||
- [x] Logic: Task or Reward does not display the delete button when props.deletable is true and a list item is a system item.
|
|
||||||
- [x] UI: Doesn't show delete button for system items.
|
|
||||||
- [x] Backend Tests: Unit tests cover a delete API request for a system task or reward and returns a 403.
|
|
||||||
- [x] Frontend Tests: Add vitest for this feature in the frontend to make sure the delete button hidden or shown.
|
|
||||||
141
.github/specs/archive/feat-parent-mode-expire.md
vendored
141
.github/specs/archive/feat-parent-mode-expire.md
vendored
@@ -1,141 +0,0 @@
|
|||||||
# Feature: Persistent and non-persistent parent mode
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
When a parent is prompted to input the parent PIN, a checkbox should also be available that asks if the parent wants to 'stay' in parent mode. If that is checked, the parent mode remains persistent on the device until child mode is entered or until an expiry time of 2 days.
|
|
||||||
When the checkbox is not enabled (default) the parent authentication should expire in 1 minute or the next reload of the site.
|
|
||||||
|
|
||||||
**Goal:**
|
|
||||||
A parent that has a dedicated device should stay in parent mode for a max of 2 days before having to re-enter the PIN, a device dedicated to the child should not stay in parent mode for more than a minute before reverting back to child mode.
|
|
||||||
|
|
||||||
**User Story:**
|
|
||||||
As a parent, I want my personal device to be able to stay in parent mode until I enter child mode or 2 days expire.
|
|
||||||
As a parent, on my child's device, I want to be able to enter parent mode to make a change or two and not have to worry about exiting parent mode.
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
Use .github/copilot-instructions.md
|
|
||||||
|
|
||||||
**Common files:**
|
|
||||||
frontend\vue-app\src\components\shared\LoginButton.vue
|
|
||||||
frontend\vue-app\src\stores\auth.ts
|
|
||||||
frontend\vue-app\src\router\index.ts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model Changes
|
|
||||||
|
|
||||||
### Backend Model
|
|
||||||
|
|
||||||
No backend changes required. PIN validation is already handled server-side via `POST /user/check-pin`. Parent mode session duration is a purely client-side concern.
|
|
||||||
|
|
||||||
### Frontend Model
|
|
||||||
|
|
||||||
**`localStorage['parentAuth']`** (written only for persistent mode):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "expiresAt": 1234567890123 }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Present only when "Stay in parent mode" was checked at PIN entry.
|
|
||||||
- Removed when the user clicks "Child Mode", on explicit logout, or when found expired on store init.
|
|
||||||
|
|
||||||
**Auth store state additions** (`frontend/vue-app/src/stores/auth.ts`):
|
|
||||||
|
|
||||||
- `parentAuthExpiresAt: Ref<number | null>` — epoch ms timestamp; `null` when not authenticated. Memory-only for non-persistent sessions, restored from `localStorage` for persistent ones.
|
|
||||||
- `isParentPersistent: Ref<boolean>` — `true` when the current parent session was marked "stay".
|
|
||||||
- `isParentAuthenticated: Ref<boolean>` — plain ref set to `true` by `authenticateParent()` and `false` by `logoutParent()`. Expiry is enforced by the 15-second background watcher and the router guard calling `enforceParentExpiry()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Implementation
|
|
||||||
|
|
||||||
No backend changes required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Tests
|
|
||||||
|
|
||||||
- [x] No new backend tests required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Implementation
|
|
||||||
|
|
||||||
### 1. Refactor `auth.ts` — expiry-aware state
|
|
||||||
|
|
||||||
- Remove the plain `ref<boolean>` `isParentAuthenticated` and the `watch` that wrote `'true'/'false'` to `localStorage['isParentAuthenticated']`.
|
|
||||||
- Add `parentAuthExpiresAt: ref<number | null>` (initialized to `null`).
|
|
||||||
- Add `isParentPersistent: ref<boolean>` (initialized to `false`).
|
|
||||||
- Keep `isParentAuthenticated` as a plain `ref<boolean>` — set explicitly by `authenticateParent()` and `logoutParent()`. A background watcher and router guard enforce expiry by calling `logoutParent()` when `Date.now() >= parentAuthExpiresAt.value`.
|
|
||||||
- Update `authenticateParent(persistent: boolean)`:
|
|
||||||
- Non-persistent: set `parentAuthExpiresAt.value = Date.now() + 60_000`, `isParentPersistent.value = false`. Write nothing to `localStorage`. State is lost on page reload naturally.
|
|
||||||
- Persistent: set `parentAuthExpiresAt.value = Date.now() + 172_800_000` (2 days), `isParentPersistent.value = true`. Write `{ expiresAt }` to `localStorage['parentAuth']`.
|
|
||||||
- Both: set `isParentAuthenticated.value = true`, call `startParentExpiryWatcher()`.
|
|
||||||
- Update `logoutParent()`: clear all three refs (`null`/`false`/`false`), remove `localStorage['parentAuth']`, call `stopParentExpiryWatcher()`.
|
|
||||||
- Update `loginUser()`: call `logoutParent()` internally (already resets parent state on fresh login).
|
|
||||||
- On store initialization: read `localStorage['parentAuth']`; if present and `expiresAt > Date.now()`, restore as persistent auth; otherwise remove the stale key.
|
|
||||||
|
|
||||||
### 2. Add background expiry watcher to `auth.ts`
|
|
||||||
|
|
||||||
- Export `startParentExpiryWatcher()` and `stopParentExpiryWatcher()` that manage a 15-second `setInterval`.
|
|
||||||
- The interval checks `Date.now() >= parentAuthExpiresAt.value`; if true, calls `logoutParent()` and navigates to `/child` via `window.location.href`. This enforces expiry even while a parent is mid-page on a `/parent` route.
|
|
||||||
|
|
||||||
### 3. Update router navigation guard — `router/index.ts`
|
|
||||||
|
|
||||||
- Import `logoutParent` and `enforceParentExpiry` from the auth store.
|
|
||||||
- Before checking parent route access, call `enforceParentExpiry()` which evaluates `Date.now() >= parentAuthExpiresAt.value` directly and calls `logoutParent()` if expired.
|
|
||||||
- If not authenticated after the check: call `logoutParent()` (cleanup) then redirect to `/child`.
|
|
||||||
|
|
||||||
### 4. Update PIN modal in `LoginButton.vue` — checkbox
|
|
||||||
|
|
||||||
- Add `stayInParentMode: ref<boolean>` (default `false`).
|
|
||||||
- Add a checkbox below the PIN input, labelled **"Stay in parent mode on this device"**.
|
|
||||||
- Style checkbox with `:root` CSS variables from `colors.css`.
|
|
||||||
- Update `submit()` to call `authenticateParent(stayInParentMode.value)`.
|
|
||||||
- Reset `stayInParentMode.value = false` when the modal closes.
|
|
||||||
|
|
||||||
### 5. Add lock badge to avatar button — `LoginButton.vue`
|
|
||||||
|
|
||||||
- Import `isParentPersistent` from the auth store.
|
|
||||||
- Wrap the existing avatar button in a `position: relative` container.
|
|
||||||
- When `isParentAuthenticated && isParentPersistent`, render a small `🔒` emoji element absolutely positioned at `bottom: -2px; left: -2px` with a font size of ~10px.
|
|
||||||
- This badge disappears automatically when "Child Mode" is clicked (clears `isParentPersistent`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Tests
|
|
||||||
|
|
||||||
- [x] `auth.ts` — non-persistent: `authenticateParent(false)` sets expiry to `now + 60s`; `isParentAuthenticated` returns `false` after watcher fires past expiry (via fake timers).
|
|
||||||
- [x] `auth.ts` — persistent: `authenticateParent(true)` sets `parentAuthExpiresAt` to `now + 2 days`; `isParentAuthenticated` returns `false` after watcher fires past 2-day expiry.
|
|
||||||
- [x] `auth.ts` — `logoutParent()` clears refs, stops watcher.
|
|
||||||
- [x] `auth.ts` — `loginUser()` calls `logoutParent()` clearing all parent auth state.
|
|
||||||
- [x] `LoginButton.vue` — checkbox is unchecked by default; checking it and submitting calls `authenticateParent(true)`.
|
|
||||||
- [x] `LoginButton.vue` — submitting without checkbox calls `authenticateParent(false)`.
|
|
||||||
- [x] `LoginButton.vue` — lock badge `🔒` is visible only when `isParentAuthenticated && isParentPersistent`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
- Could offer a configurable expiry duration (e.g. 1 day, 3 days, 7 days) rather than a fixed 2-day cap.
|
|
||||||
- Could show a "session expiring soon" warning for the persistent mode (e.g. banner appears 1 hour before the 2-day expiry).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria (Definition of Done)
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- [x] No backend changes required; all work is frontend-only.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- [x] PIN modal includes an unchecked "Stay in parent mode on this device" checkbox.
|
|
||||||
- [x] Non-persistent mode: parent auth is memory-only, expires after 1 minute, and is lost on page reload.
|
|
||||||
- [x] Persistent mode: `localStorage['parentAuth']` is written with a 2-day `expiresAt` timestamp; auth survives page reload and new tabs.
|
|
||||||
- [x] Router guard redirects silently to `/child` if parent mode has expired when navigating to any `/parent` route.
|
|
||||||
- [x] Background 15-second interval also enforces expiry while the user is mid-page on a `/parent` route.
|
|
||||||
- [x] "Child Mode" button clears both persistent and non-persistent auth state completely.
|
|
||||||
- [x] A `🔒` emoji badge appears on the lower-left of the parent avatar button only when persistent mode is active.
|
|
||||||
- [x] Opening a new tab while in persistent mode correctly restores parent mode from `localStorage`.
|
|
||||||
- [x] All frontend tests listed above pass.
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
# Feature: Account Deletion (Mark for Removal)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Goal:** Allow users to mark their account for deletion from the Profile page.
|
|
||||||
|
|
||||||
**User Story:**
|
|
||||||
As a user, I want to delete my account from the Profile page. When I click "Delete My Account", I want a confirmation dialog that warns me about data loss. After confirming by entering my email, my account will be marked for deletion, I will be signed out, and I will not be able to log in again.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model Changes
|
|
||||||
|
|
||||||
### Backend Model (`backend/models/user.py`)
|
|
||||||
|
|
||||||
Add the following fields to the `User` class:
|
|
||||||
|
|
||||||
```python
|
|
||||||
marked_for_deletion: bool = False
|
|
||||||
marked_for_deletion_at: datetime | None = None
|
|
||||||
```
|
|
||||||
|
|
||||||
- Update `to_dict()` and `from_dict()` methods to serialize these fields.
|
|
||||||
- Import `datetime` from Python standard library if not already imported.
|
|
||||||
|
|
||||||
### Frontend Model (`frontend/vue-app/src/common/models.ts`)
|
|
||||||
|
|
||||||
Add matching fields to the `User` interface:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
marked_for_deletion: boolean;
|
|
||||||
marked_for_deletion_at: string | null;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Implementation
|
|
||||||
|
|
||||||
### New Error Codes (`backend/api/error_codes.py`)
|
|
||||||
|
|
||||||
Add the following error code:
|
|
||||||
|
|
||||||
```python
|
|
||||||
ACCOUNT_MARKED_FOR_DELETION = 'ACCOUNT_MARKED_FOR_DELETION'
|
|
||||||
ALREADY_MARKED = 'ALREADY_MARKED'
|
|
||||||
```
|
|
||||||
|
|
||||||
### New API Endpoint (`backend/api/user_api.py`)
|
|
||||||
|
|
||||||
**Endpoint:** `POST /api/user/mark-for-deletion`
|
|
||||||
|
|
||||||
**Authentication:** Requires valid JWT (authenticated user).
|
|
||||||
|
|
||||||
**Request:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{}
|
|
||||||
```
|
|
||||||
|
|
||||||
(Empty body; user is identified from JWT token)
|
|
||||||
|
|
||||||
**Response:**
|
|
||||||
|
|
||||||
- **Success (200):**
|
|
||||||
```json
|
|
||||||
{ "success": true }
|
|
||||||
```
|
|
||||||
- **Error (400/401/403):**
|
|
||||||
```json
|
|
||||||
{ "error": "Error message", "code": "INVALID_USER" | "ALREADY_MARKED" }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Logic:**
|
|
||||||
|
|
||||||
1. Extract current user from JWT token.
|
|
||||||
2. Validate user exists in database.
|
|
||||||
3. Check if already marked for deletion:
|
|
||||||
- If `marked_for_deletion == True`, return error with code `ALREADY_MARKED` (or make idempotent and return success).
|
|
||||||
4. Set `marked_for_deletion = True` and `marked_for_deletion_at = datetime.now(timezone.utc)`.
|
|
||||||
5. Save user to database using `users_db.update()`.
|
|
||||||
6. Trigger SSE event: `send_event_for_current_user('user_marked_for_deletion', { 'user_id': user.id })`.
|
|
||||||
7. Return success response.
|
|
||||||
|
|
||||||
### Login Blocking (`backend/api/auth_api.py`)
|
|
||||||
|
|
||||||
In the `/api/login` endpoint, after validating credentials:
|
|
||||||
|
|
||||||
1. Check if `user.marked_for_deletion == True`.
|
|
||||||
2. If yes, return:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "This account has been marked for deletion and cannot be accessed.",
|
|
||||||
"code": "ACCOUNT_MARKED_FOR_DELETION"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
with HTTP status `403`.
|
|
||||||
|
|
||||||
### Password Reset Blocking (`backend/api/user_api.py`)
|
|
||||||
|
|
||||||
In the `/api/user/request-reset` endpoint:
|
|
||||||
|
|
||||||
1. After finding the user by email, check if `user.marked_for_deletion == True`.
|
|
||||||
2. If yes, **silently ignore the request**:
|
|
||||||
- Do not send an email.
|
|
||||||
- Return success response (to avoid leaking account status).
|
|
||||||
|
|
||||||
### SSE Event (`backend/events/types/event_types.py`)
|
|
||||||
|
|
||||||
Add new event type:
|
|
||||||
|
|
||||||
```python
|
|
||||||
USER_MARKED_FOR_DELETION = 'user_marked_for_deletion'
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Implementation
|
|
||||||
|
|
||||||
### Files Affected
|
|
||||||
|
|
||||||
- `frontend/vue-app/src/components/parent/UserProfile.vue`
|
|
||||||
- `frontend/vue-app/src/common/models.ts`
|
|
||||||
- `frontend/vue-app/src/common/errorCodes.ts`
|
|
||||||
|
|
||||||
### Error Codes (`frontend/vue-app/src/common/errorCodes.ts`)
|
|
||||||
|
|
||||||
Add:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION";
|
|
||||||
export const ALREADY_MARKED = "ALREADY_MARKED";
|
|
||||||
```
|
|
||||||
|
|
||||||
### UI Components (`UserProfile.vue`)
|
|
||||||
|
|
||||||
#### 1. Delete Account Button
|
|
||||||
|
|
||||||
- **Label:** "Delete My Account"
|
|
||||||
- **Style:** `.btn-danger-link` (use `--danger` color from `colors.css`)
|
|
||||||
- **Placement:** Below "Change Password" link, with `24px` margin-top
|
|
||||||
- **Behavior:** Opens warning modal on click
|
|
||||||
|
|
||||||
#### 2. Warning Modal (uses `ModalDialog.vue`)
|
|
||||||
|
|
||||||
- **Title:** "Delete Your Account?"
|
|
||||||
- **Body:**
|
|
||||||
"This will permanently delete your account and all associated data. This action cannot be undone."
|
|
||||||
- **Email Confirmation Input:**
|
|
||||||
- Require user to type their email address to confirm.
|
|
||||||
- Display message: "Type your email address to confirm:"
|
|
||||||
- Input field with `v-model` bound to `confirmEmail` ref.
|
|
||||||
- **Buttons:**
|
|
||||||
- **"Cancel"** (`.btn-secondary`) — closes modal
|
|
||||||
- **"Delete My Account"** (`.btn-danger`) — disabled until `confirmEmail` matches user email, triggers API call
|
|
||||||
|
|
||||||
#### 3. Loading State
|
|
||||||
|
|
||||||
- Disable "Delete My Account" button during API call.
|
|
||||||
- Show loading spinner or "Deleting..." text.
|
|
||||||
|
|
||||||
#### 4. Success Modal
|
|
||||||
|
|
||||||
- **Title:** "Account Deleted"
|
|
||||||
- **Body:**
|
|
||||||
"Your account has been marked for deletion. You will now be signed out."
|
|
||||||
- **Button:** "OK" (closes modal, triggers `logoutUser()` and redirects to `/auth/login`)
|
|
||||||
|
|
||||||
#### 5. Error Modal
|
|
||||||
|
|
||||||
- **Title:** "Error"
|
|
||||||
- **Body:** Display error message from API using `parseErrorResponse(res).msg`.
|
|
||||||
- **Button:** "Close"
|
|
||||||
|
|
||||||
### Frontend Logic
|
|
||||||
|
|
||||||
1. User clicks "Delete My Account" button.
|
|
||||||
2. Warning modal opens with email confirmation input.
|
|
||||||
3. User types email and clicks "Delete My Account".
|
|
||||||
4. Frontend calls `POST /api/user/mark-for-deletion`.
|
|
||||||
5. On success:
|
|
||||||
- Close warning modal.
|
|
||||||
- Show success modal.
|
|
||||||
- On "OK" click: call `logoutUser()` from `stores/auth.ts`, redirect to `/auth/login`.
|
|
||||||
6. On error:
|
|
||||||
- Close warning modal.
|
|
||||||
- Show error modal with message from API.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Backend Tests (`backend/tests/test_user_api.py`)
|
|
||||||
|
|
||||||
- [x] Test marking a valid user account (200, `marked_for_deletion = True`, `marked_for_deletion_at` is set).
|
|
||||||
- [x] Test marking an already-marked account (return error with `ALREADY_MARKED` or be idempotent).
|
|
||||||
- [x] Test marking with invalid JWT (401).
|
|
||||||
- [x] Test marking with missing JWT (401).
|
|
||||||
- [x] Test login attempt by marked user (403, `ACCOUNT_MARKED_FOR_DELETION`).
|
|
||||||
- [x] Test password reset request by marked user (silently ignored, returns 200 but no email sent).
|
|
||||||
- [x] Test SSE event is triggered after marking.
|
|
||||||
|
|
||||||
### Frontend Tests (`frontend/vue-app/src/components/__tests__/UserProfile.spec.ts`)
|
|
||||||
|
|
||||||
- [x] Test "Delete My Account" button renders.
|
|
||||||
- [x] Test warning modal opens on button click.
|
|
||||||
- [x] Test "Delete My Account" button in modal is disabled until email matches.
|
|
||||||
- [x] Test API call is made when user confirms with correct email.
|
|
||||||
- [x] Test success modal shows after successful API response.
|
|
||||||
- [x] Test error modal shows on API failure (with error message).
|
|
||||||
- [x] Test user is signed out after success (calls `logoutUser()`).
|
|
||||||
- [x] Test redirect to login page after sign-out.
|
|
||||||
- [x] Test button is disabled during loading.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
- A background scheduler will be implemented to physically delete marked accounts after a grace period (e.g., 30 days).
|
|
||||||
- Admin panel to view and manage marked accounts.
|
|
||||||
- Email notification to user when account is marked for deletion (with grace period details).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria (Definition of Done)
|
|
||||||
|
|
||||||
### Data Model
|
|
||||||
|
|
||||||
- [x] Add `marked_for_deletion` and `marked_for_deletion_at` fields to `User` model (backend).
|
|
||||||
- [x] Add matching fields to `User` interface (frontend).
|
|
||||||
- [x] Update `to_dict()` and `from_dict()` methods in `User` model.
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- [x] Create `POST /api/user/mark-for-deletion` endpoint.
|
|
||||||
- [x] Add `ACCOUNT_MARKED_FOR_DELETION` and `ALREADY_MARKED` error codes.
|
|
||||||
- [x] Block login for marked users in `/api/login`.
|
|
||||||
- [x] Block password reset for marked users in `/api/user/request-reset`.
|
|
||||||
- [x] Trigger `user_marked_for_deletion` SSE event after marking.
|
|
||||||
- [x] All backend tests pass.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- [x] Add "Delete My Account" button to `UserProfile.vue` below "Change Password".
|
|
||||||
- [x] Implement warning modal with email confirmation.
|
|
||||||
- [x] Implement success modal.
|
|
||||||
- [x] Implement error modal.
|
|
||||||
- [x] Implement loading state during API call.
|
|
||||||
- [x] Sign out user after successful account marking.
|
|
||||||
- [x] Redirect to login page after sign-out.
|
|
||||||
- [x] Add `ACCOUNT_MARKED_FOR_DELETION` and `ALREADY_MARKED` to `errorCodes.ts`.
|
|
||||||
- [x] All frontend tests pass.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# Feature: Replace the text-based "Parent" button with an image icon and modernize the dropdown menu
|
|
||||||
|
|
||||||
## Visual Reference:
|
|
||||||
|
|
||||||
- **Sample Design:** #mockup.png
|
|
||||||
- **Design:**
|
|
||||||
1. Dropdown header colors need to match color theme inside #colors.css
|
|
||||||
2. The icon button shall be circular and use all the space of it's container. It should be centered in it's container.
|
|
||||||
3. The three dropdown items should be "Profile", "Child Mode", and "Sign out"
|
|
||||||
4. Currently, the dropdown shows "Log out" for "Child Mode", that should be changed to "Child Mode"
|
|
||||||
|
|
||||||
## Context:
|
|
||||||
|
|
||||||
- **Goal:** I want a user image icon to display in place of the current "Parent" button
|
|
||||||
- **User Story:** As a [user], I want to see the image assigned in my profile as an icon button at the top right of the screen. When I click the button I want to see a dropdown appear if I'm in 'parent mode.' I to have the options to see/edit my profile, go back to child mode, or sign out. In child mode, I want the button to trigger the parent pin modal if clicked.
|
|
||||||
|
|
||||||
## Technical Requirements
|
|
||||||
|
|
||||||
- **File Affected:** LoginButton.vue, ParentLayout.vue, ChildLayout.vue, AuthLayout.vue
|
|
||||||
- **Backend:** When LoginButton loads, it should query the backend for the current user data (/user/profile) The returned data will provide the image_id and first_name of the user.
|
|
||||||
- **Navigation:**
|
|
||||||
1. When the avatar button is focused, pressing Enter or Space opens the dropdown.
|
|
||||||
2. When the dropdown is open:
|
|
||||||
- Up/Down arrow keys move focus between menu items.
|
|
||||||
- Enter or Space activates the focused menu item.
|
|
||||||
- Esc closes the dropdown and returns focus to the avatar button.
|
|
||||||
3. Tabbing away from the dropdown closes it.
|
|
||||||
- **ARIA:**
|
|
||||||
1. The avatar button must have aria-haspopup="menu" and aria-expanded reflecting the dropdown state.
|
|
||||||
2. The dropdown menu must use role="menu", and each item must use role="menuitem".
|
|
||||||
3. The currently focused menu item should have aria-selected="true".
|
|
||||||
- **Focus Ring:** All interactive elements (avatar button and dropdown menu items) must display a visible focus ring when focused via keyboard navigation. The focus ring color should use a theme variable from colors.css and meet accessibility contrast guidelines.
|
|
||||||
- **Mobile & Layout:**
|
|
||||||
1. The avatar icon button must always be positioned at the top right of the screen, regardless of device size.
|
|
||||||
2. The icon must never exceed 44px in width or height.
|
|
||||||
3. On mobile, ensure the button is at least 44x44px for touch accessibility.
|
|
||||||
- **Avatar Fallback:** If user.first_name does not exist, display a ? as the fallback initial.
|
|
||||||
- **Dropdown Placement and Animation:**
|
|
||||||
1. The dropdown menu must always appear directly below the avatar icon, right-aligned to the screen edge.
|
|
||||||
2. Use a slide down/up animation for showing/hiding the dropdown.
|
|
||||||
- **State Requirements:**
|
|
||||||
- Collapsed: Button shows the user.image_id or a fallback icon with the initial of the user.first_name
|
|
||||||
- Expanded: Shows the dropdown with the three menu options shown in the #mockup.png. -**Menu Item Icons:**: For now, use a stub element or placeholder for each menu item icon, to be replaced with real icons later.
|
|
||||||
- **Logic:**
|
|
||||||
1. Clicking an item in the dropdown should already be implemented. Do not change this.
|
|
||||||
2. When clicking a menu item or clicking outside the menu, collapse the menu.
|
|
||||||
3. When in 'child mode' (parent not authenticated), show the parent PIN modal or create PIN view (/parent/pin-setup) if user.pin doesn't exist or is empty. (this is already implemented)
|
|
||||||
|
|
||||||
## UI Acceptance Criteria (The "Definition of Done")
|
|
||||||
|
|
||||||
- [x] UI: Swap the "Parent" button with the user's avatar image.
|
|
||||||
- [x] UI: Refactor #LoginButton.vue to use new CSS generated from #mockup.png
|
|
||||||
- [x] Logic: Make sure the dropdown does not show when in child mode.
|
|
||||||
- [x] Logic: Make sure the parent PIN modal shows when the button is pressed in child mode.
|
|
||||||
- [x] Logic: Make sure the parent PIN creation view shows when the button is pressed in child mode if no user.pin doesn't exist or is empty.
|
|
||||||
- [x] Frontend Tests: Add vitest for this feature in the frontend to make sure the logic for button clicking in parent mode and child mode act correctly.
|
|
||||||
1. [x] Avatar button renders image, initial, or ? as fallback
|
|
||||||
2. [x] Dropdown opens/closes via click, Enter, Space, Esc, and outside click.
|
|
||||||
3. [x] Dropdown is positioned and animated correctly.
|
|
||||||
4. [x] Keyboard navigation (Up/Down, Enter, Space, Esc) works as specified.
|
|
||||||
5. [x] ARIA attributes and roles are set correctly.
|
|
||||||
6. [x] Focus ring is visible and uses theme color.
|
|
||||||
7. [x] Avatar button meets size and position requirements on all devices.
|
|
||||||
8. [x] Menu logic for parent/child mode is correct.
|
|
||||||
9. [x] Stub icons are rendered for menu items.
|
|
||||||
BIN
.github/specs/archive/profile-button-menu/mockup.png
vendored
BIN
.github/specs/archive/profile-button-menu/mockup.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
49
.github/specs/template/feat-template.md
vendored
49
.github/specs/template/feat-template.md
vendored
@@ -1,49 +0,0 @@
|
|||||||
# Feature:
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
**Goal:**
|
|
||||||
|
|
||||||
**User Story:**
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Model Changes
|
|
||||||
|
|
||||||
### Backend Model
|
|
||||||
|
|
||||||
### Frontend Model
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Backend Implementation
|
|
||||||
|
|
||||||
## Backend Tests
|
|
||||||
|
|
||||||
- [ ]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Frontend Implementation
|
|
||||||
|
|
||||||
## Frontend Tests
|
|
||||||
|
|
||||||
- [ ]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Acceptance Criteria (Definition of Done)
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- [ ]
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- [ ]
|
|
||||||
85
.gitignore
vendored
85
.gitignore
vendored
@@ -1,9 +1,78 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
PIPFILE.lock
|
||||||
|
|
||||||
|
# Virtual Environment
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.iws
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# VS Code
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
data/db/*.json
|
||||||
|
|
||||||
|
# Flask
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
backend/test_data/db/children.json
|
.env.local
|
||||||
backend/test_data/db/images.json
|
.env.*.local
|
||||||
backend/test_data/db/pending_rewards.json
|
|
||||||
backend/test_data/db/rewards.json
|
# Node.js / Vue (web directory)
|
||||||
backend/test_data/db/tasks.json
|
web/node_modules/
|
||||||
backend/test_data/db/users.json
|
web/npm-debug.log*
|
||||||
logs/account_deletion.log
|
web/yarn-debug.log*
|
||||||
backend/test_data/db/tracking_events.json
|
web/yarn-error.log*
|
||||||
|
web/dist/
|
||||||
|
web/.nuxt/
|
||||||
|
web/.cache/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
|||||||
5
.idea/.gitignore
generated
vendored
5
.idea/.gitignore
generated
vendored
@@ -1,5 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
/httpRequests/
|
|
||||||
15
.idea/Reward.iml
generated
15
.idea/Reward.iml
generated
@@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="PYTHON_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="jdk" jdkName="Python 3.12 (Reward)" jdkType="Python SDK" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
<component name="PyDocumentationSettings">
|
|
||||||
<option name="format" value="PLAIN" />
|
|
||||||
<option name="myDocStringFormat" value="Plain" />
|
|
||||||
</component>
|
|
||||||
<component name="TestRunnerService">
|
|
||||||
<option name="PROJECT_TEST_RUNNER" value="py.test" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
6
.idea/copilot.data.migration.agent.xml
generated
6
.idea/copilot.data.migration.agent.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="AgentMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/copilot.data.migration.ask2agent.xml
generated
6
.idea/copilot.data.migration.ask2agent.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Ask2AgentMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/copilot.data.migration.edit.xml
generated
6
.idea/copilot.data.migration.edit.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="EditMigrationStateService">
|
|
||||||
<option name="migrationStatus" value="COMPLETED" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
10
.idea/inspectionProfiles/Project_Default.xml
generated
10
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,10 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<profile version="1.0">
|
|
||||||
<option name="myName" value="Project Default" />
|
|
||||||
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
|
||||||
<option name="ignoredPackages">
|
|
||||||
<list />
|
|
||||||
</option>
|
|
||||||
</inspection_tool>
|
|
||||||
</profile>
|
|
||||||
</component>
|
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
6
.idea/inspectionProfiles/profiles_settings.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<settings>
|
|
||||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
|
||||||
<version value="1.0" />
|
|
||||||
</settings>
|
|
||||||
</component>
|
|
||||||
4
.idea/misc.xml
generated
4
.idea/misc.xml
generated
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (Reward)" project-jdk-type="Python SDK" />
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/Reward.iml" filepath="$PROJECT_DIR$/.idea/Reward.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
9
.vscode/extensions.json
vendored
9
.vscode/extensions.json
vendored
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"Vue.volar",
|
|
||||||
"vitest.explorer",
|
|
||||||
"dbaeumer.vscode-eslint",
|
|
||||||
"EditorConfig.EditorConfig",
|
|
||||||
"esbenp.prettier-vscode"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
88
.vscode/launch.json
vendored
88
.vscode/launch.json
vendored
@@ -1,88 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Python: Flask",
|
|
||||||
"type": "python",
|
|
||||||
"request": "launch",
|
|
||||||
"module": "flask",
|
|
||||||
"python": "${command:python.interpreterPath}",
|
|
||||||
"env": {
|
|
||||||
"FLASK_APP": "backend/main.py",
|
|
||||||
"FLASK_DEBUG": "1"
|
|
||||||
},
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"--host=0.0.0.0",
|
|
||||||
"--port=5000",
|
|
||||||
"--no-debugger",
|
|
||||||
"--no-reload"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/backend",
|
|
||||||
"console": "integratedTerminal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Vue: Dev Server",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": [
|
|
||||||
"run",
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
|
||||||
"console": "integratedTerminal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Chrome: Launch (Vue App)",
|
|
||||||
"type": "pwa-chrome",
|
|
||||||
"request": "launch",
|
|
||||||
"url": "http://localhost:5173",
|
|
||||||
"webRoot": "${workspaceFolder}/frontend/vue-app"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Python: Backend Tests",
|
|
||||||
"type": "python",
|
|
||||||
"request": "launch",
|
|
||||||
"module": "pytest",
|
|
||||||
"args": [
|
|
||||||
"tests/"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/backend",
|
|
||||||
"console": "integratedTerminal",
|
|
||||||
"env": {
|
|
||||||
"PYTHONPATH": "${workspaceFolder}/backend"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Vue: Frontend Tests",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"windows": {
|
|
||||||
"runtimeExecutable": "npm.cmd"
|
|
||||||
},
|
|
||||||
"runtimeArgs": [
|
|
||||||
"run",
|
|
||||||
"test:unit"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
|
||||||
"console": "integratedTerminal",
|
|
||||||
"osx": {
|
|
||||||
"env": {
|
|
||||||
"PATH": "/opt/homebrew/bin:${env:PATH}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"compounds": [
|
|
||||||
{
|
|
||||||
"name": "Full Stack (Backend + Frontend)",
|
|
||||||
"configurations": [
|
|
||||||
"Python: Flask",
|
|
||||||
"Vue: Dev Server",
|
|
||||||
"Chrome: Launch (Vue App)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
77
.vscode/launch.json.bak
vendored
77
.vscode/launch.json.bak
vendored
@@ -1,77 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Python: Flask",
|
|
||||||
"type": "debugpy",
|
|
||||||
"request": "launch",
|
|
||||||
"module": "flask",
|
|
||||||
"python": "${command:python.interpreterPath}",
|
|
||||||
"env": {
|
|
||||||
"FLASK_APP": "backend/main.py",
|
|
||||||
"FLASK_DEBUG": "1"
|
|
||||||
},
|
|
||||||
"args": [
|
|
||||||
"run",
|
|
||||||
"--host=0.0.0.0",
|
|
||||||
"--port=5000",
|
|
||||||
"--no-debugger",
|
|
||||||
"--no-reload"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Vue: Dev Server",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": [
|
|
||||||
"run",
|
|
||||||
"dev"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
|
||||||
"console": "integratedTerminal"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Chrome: Attach to Vue App",
|
|
||||||
"type": "chrome",
|
|
||||||
"request": "launch",
|
|
||||||
"url": "https://localhost:5173", // or your Vite dev server port
|
|
||||||
"webRoot": "${workspaceFolder}/frontend/vue-app"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Python: Backend Tests",
|
|
||||||
"type": "python",
|
|
||||||
"request": "launch",
|
|
||||||
"program": "${workspaceFolder}/backend/.venv/Scripts/pytest.exe",
|
|
||||||
"args": [
|
|
||||||
"tests/"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/backend",
|
|
||||||
"console": "integratedTerminal",
|
|
||||||
"env": {
|
|
||||||
"PYTHONPATH": "${workspaceFolder}/backend"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Vue: Frontend Tests",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"runtimeExecutable": "npx",
|
|
||||||
"runtimeArgs": [
|
|
||||||
"vitest"
|
|
||||||
],
|
|
||||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
|
||||||
"console": "integratedTerminal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"compounds": [
|
|
||||||
{
|
|
||||||
"name": "Full Stack (Backend + Frontend)",
|
|
||||||
"configurations": [
|
|
||||||
"Python: Flask",
|
|
||||||
"Vue: Dev Server",
|
|
||||||
"Chrome: Attach to Vue App"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
23
.vscode/settings.json
vendored
23
.vscode/settings.json
vendored
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"explorer.fileNesting.enabled": true,
|
|
||||||
"explorer.fileNesting.patterns": {
|
|
||||||
"tsconfig.json": "tsconfig.*.json, env.d.ts",
|
|
||||||
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
|
|
||||||
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig"
|
|
||||||
},
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"[json]": {
|
|
||||||
"editor.tabSize": 2,
|
|
||||||
"editor.insertSpaces": true
|
|
||||||
},
|
|
||||||
"[jsonc]": {
|
|
||||||
"editor.defaultFormatter": "vscode.json-language-features"
|
|
||||||
},
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll.eslint": "explicit"
|
|
||||||
},
|
|
||||||
"chat.tools.terminal.autoApprove": {
|
|
||||||
"&": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
.vscode/tasks.json
vendored
45
.vscode/tasks.json
vendored
@@ -1,45 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "2.0.0",
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"label": "Git: Save WIP",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "git save-wip",
|
|
||||||
"group": "build",
|
|
||||||
"presentation": {
|
|
||||||
"reveal": "always",
|
|
||||||
"panel": "shared"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Git: Load WIP",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "git load-wip",
|
|
||||||
"group": "build",
|
|
||||||
"presentation": {
|
|
||||||
"reveal": "always",
|
|
||||||
"panel": "shared"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Git: Reset Cloud WIP",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "git push origin --delete wip-sync",
|
|
||||||
"presentation": {
|
|
||||||
"reveal": "always",
|
|
||||||
"panel": "shared"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"label": "Git: Abort WIP (Reset Local)",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "git abort-wip",
|
|
||||||
"group": "none",
|
|
||||||
"presentation": {
|
|
||||||
"reveal": "always",
|
|
||||||
"panel": "shared",
|
|
||||||
"echo": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
0
backend/Jenkinsfile → Jenkinsfile
vendored
0
backend/Jenkinsfile → Jenkinsfile
vendored
173
README.md
173
README.md
@@ -1,173 +0,0 @@
|
|||||||
# Reward - Chore & Reward Management System
|
|
||||||
|
|
||||||
A family-friendly application for managing chores, tasks, and rewards for children.
|
|
||||||
|
|
||||||
## 🏗️ Architecture
|
|
||||||
|
|
||||||
- **Backend**: Flask (Python) with TinyDB for data persistence
|
|
||||||
- **Frontend**: Vue 3 (TypeScript) with real-time SSE updates
|
|
||||||
- **Deployment**: Docker with nginx reverse proxy
|
|
||||||
|
|
||||||
## 🚀 Getting Started
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python -m venv .venv
|
|
||||||
.venv\Scripts\activate # Windows
|
|
||||||
source .venv/bin/activate # Linux/Mac
|
|
||||||
pip install -r requirements.txt
|
|
||||||
python -m flask run --host=0.0.0.0 --port=5000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend/vue-app
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
| ---------------------------------- | --------------------------------------------- | ------------- |
|
|
||||||
| `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) |
|
|
||||||
| `DB_ENV` | Database environment (`prod` or `test`) | `prod` |
|
|
||||||
| `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` |
|
|
||||||
|
|
||||||
### Account Deletion Scheduler
|
|
||||||
|
|
||||||
The application includes an automated account deletion scheduler that removes user accounts marked for deletion after a configurable threshold period.
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
|
|
||||||
- Runs every hour checking for accounts due for deletion
|
|
||||||
- Configurable threshold between 24 hours (minimum) and 720 hours (maximum)
|
|
||||||
- Automatic retry on failure (max 3 attempts)
|
|
||||||
- Restart-safe: recovers from interruptions during deletion
|
|
||||||
|
|
||||||
**Deletion Process:**
|
|
||||||
When an account is marked for deletion, the scheduler will automatically:
|
|
||||||
|
|
||||||
1. Remove all pending rewards for the user's children
|
|
||||||
2. Remove all children belonging to the user
|
|
||||||
3. Remove all user-created tasks
|
|
||||||
4. Remove all user-created rewards
|
|
||||||
5. Remove uploaded images from database
|
|
||||||
6. Delete user's image directory from filesystem
|
|
||||||
7. Remove the user account
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
Set the deletion threshold via environment variable:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export ACCOUNT_DELETION_THRESHOLD_HOURS=168 # 7 days
|
|
||||||
```
|
|
||||||
|
|
||||||
**Monitoring:**
|
|
||||||
|
|
||||||
- Logs are written to `logs/account_deletion.log` with rotation (10MB max, 5 backups)
|
|
||||||
- Check logs for deletion summaries and any errors
|
|
||||||
|
|
||||||
## 🔌 API Endpoints
|
|
||||||
|
|
||||||
### Admin Endpoints
|
|
||||||
|
|
||||||
All admin endpoints require JWT authentication and **admin role**.
|
|
||||||
|
|
||||||
**Note:** Admin users must be created manually or via the provided script (`backend/scripts/create_admin.py`). The admin role cannot be assigned through the signup API for security reasons.
|
|
||||||
|
|
||||||
**Creating an Admin User:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python scripts/create_admin.py
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Account Deletion Management
|
|
||||||
|
|
||||||
- `GET /api/admin/deletion-queue` - View users pending deletion
|
|
||||||
- `GET /api/admin/deletion-threshold` - Get current deletion threshold
|
|
||||||
- `PUT /api/admin/deletion-threshold` - Update deletion threshold (24-720 hours)
|
|
||||||
- `POST /api/admin/deletion-queue/trigger` - Manually trigger deletion scheduler
|
|
||||||
|
|
||||||
### User Endpoints
|
|
||||||
|
|
||||||
- `POST /api/user/mark-for-deletion` - Mark current user's account for deletion
|
|
||||||
- `GET /api/me` - Get current user info
|
|
||||||
- `POST /api/login` - User login
|
|
||||||
- `POST /api/logout` - User logout
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
### Backend Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
pytest tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend/vue-app
|
|
||||||
npm run test
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Features
|
|
||||||
|
|
||||||
- ✅ User authentication with JWT tokens
|
|
||||||
- ✅ Child profile management
|
|
||||||
- ✅ Task assignment and tracking
|
|
||||||
- ✅ Reward system
|
|
||||||
- ✅ Real-time updates via SSE
|
|
||||||
- ✅ Image upload and management
|
|
||||||
- ✅ Account deletion with grace period
|
|
||||||
- ✅ Automated cleanup scheduler
|
|
||||||
|
|
||||||
## 🔒 Security
|
|
||||||
|
|
||||||
- JWT tokens stored in HttpOnly, Secure, SameSite=Strict cookies
|
|
||||||
- **Role-Based Access Control (RBAC)**: Admin endpoints protected by admin role validation
|
|
||||||
- Admin users can only be created via direct database manipulation or provided script
|
|
||||||
- Regular users cannot escalate privileges to admin
|
|
||||||
- Account deletion requires email confirmation
|
|
||||||
- Marked accounts blocked from login immediately
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
.
|
|
||||||
├── backend/
|
|
||||||
│ ├── api/ # REST API endpoints
|
|
||||||
│ ├── config/ # Configuration files
|
|
||||||
│ ├── db/ # TinyDB setup
|
|
||||||
│ ├── events/ # SSE event system
|
|
||||||
│ ├── models/ # Data models
|
|
||||||
│ ├── tests/ # Backend tests
|
|
||||||
│ └── utils/ # Utilities (scheduler, etc)
|
|
||||||
├── frontend/
|
|
||||||
│ └── vue-app/
|
|
||||||
│ └── src/
|
|
||||||
│ ├── common/ # Shared utilities
|
|
||||||
│ ├── components/ # Vue components
|
|
||||||
│ └── layout/ # Layout components
|
|
||||||
└── .github/
|
|
||||||
└── specs/ # Feature specifications
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🛠️ Development
|
|
||||||
|
|
||||||
For detailed development patterns and conventions, see [`.github/copilot-instructions.md`](.github/copilot-instructions.md).
|
|
||||||
|
|
||||||
## 📚 References
|
|
||||||
|
|
||||||
- Reset flow (token validation, JWT invalidation, cross-tab logout sync): [`docs/reset-password-reference.md`](docs/reset-password-reference.md)
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
Private project - All rights reserved.
|
|
||||||
@@ -3,32 +3,47 @@ import secrets, jwt
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from flask_mail import Mail, Message
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
import os
|
import os
|
||||||
import utils.email_sender as email_sender
|
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
|
|
||||||
from api.utils import sanitize_email
|
from api.utils import sanitize_email
|
||||||
from config.paths import get_user_image_dir
|
from config.paths import get_user_image_dir
|
||||||
|
|
||||||
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \
|
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, \
|
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
|
||||||
NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION
|
NOT_VERIFIED
|
||||||
from db.db import users_db
|
from db.db import users_db
|
||||||
from api.utils import normalize_email
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
auth_api = Blueprint('auth_api', __name__)
|
auth_api = Blueprint('auth_api', __name__)
|
||||||
UserQuery = Query()
|
UserQuery = Query()
|
||||||
|
mail = Mail()
|
||||||
TOKEN_EXPIRY_MINUTES = 60*4
|
TOKEN_EXPIRY_MINUTES = 60*4
|
||||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||||
|
|
||||||
|
|
||||||
def send_verification_email(to_email, token):
|
def send_verification_email(to_email, token):
|
||||||
email_sender.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):
|
def send_reset_password_email(to_email, token):
|
||||||
email_sender.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'])
|
@auth_api.route('/signup', methods=['POST'])
|
||||||
def signup():
|
def signup():
|
||||||
@@ -36,14 +51,8 @@ def signup():
|
|||||||
required_fields = ['first_name', 'last_name', 'email', 'password']
|
required_fields = ['first_name', 'last_name', 'email', 'password']
|
||||||
if not all(field in data for field in required_fields):
|
if not all(field in data for field in required_fields):
|
||||||
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
|
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
|
||||||
email = data.get('email', '')
|
|
||||||
norm_email = normalize_email(email)
|
|
||||||
|
|
||||||
existing = users_db.get(UserQuery.email == norm_email)
|
if users_db.search(UserQuery.email == data['email']):
|
||||||
if existing:
|
|
||||||
user = User.from_dict(existing)
|
|
||||||
if user.marked_for_deletion:
|
|
||||||
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
|
||||||
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
|
return jsonify({'error': 'Email already exists', 'code': EMAIL_EXISTS}), 400
|
||||||
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
@@ -51,15 +60,15 @@ def signup():
|
|||||||
user = User(
|
user = User(
|
||||||
first_name=data['first_name'],
|
first_name=data['first_name'],
|
||||||
last_name=data['last_name'],
|
last_name=data['last_name'],
|
||||||
email=norm_email,
|
email=data['email'],
|
||||||
password=generate_password_hash(data['password']),
|
password=data['password'], # Hash in production!
|
||||||
verified=False,
|
verified=False,
|
||||||
verify_token=token,
|
verify_token=token,
|
||||||
verify_token_created=now_iso,
|
verify_token_created=now_iso,
|
||||||
image_id="boy01"
|
image_id="boy01"
|
||||||
)
|
)
|
||||||
users_db.insert(user.to_dict())
|
users_db.insert(user.to_dict())
|
||||||
send_verification_email(norm_email, token)
|
send_verification_email(data['email'], token)
|
||||||
return jsonify({'message': 'User created, verification email sent'}), 201
|
return jsonify({'message': 'User created, verification email sent'}), 201
|
||||||
|
|
||||||
@auth_api.route('/verify', methods=['GET'])
|
@auth_api.route('/verify', methods=['GET'])
|
||||||
@@ -82,10 +91,6 @@ def verify():
|
|||||||
status = 'error'
|
status = 'error'
|
||||||
reason = 'Invalid token'
|
reason = 'Invalid token'
|
||||||
code = INVALID_TOKEN
|
code = INVALID_TOKEN
|
||||||
elif user.marked_for_deletion:
|
|
||||||
status = 'error'
|
|
||||||
reason = 'Account marked for deletion'
|
|
||||||
code = ACCOUNT_MARKED_FOR_DELETION
|
|
||||||
else:
|
else:
|
||||||
created_str = user.verify_token_created
|
created_str = user.verify_token_created
|
||||||
if not created_str:
|
if not created_str:
|
||||||
@@ -109,7 +114,7 @@ def verify():
|
|||||||
if not user.email:
|
if not user.email:
|
||||||
logger.error("Verified user has no email field.")
|
logger.error("Verified user has no email field.")
|
||||||
else:
|
else:
|
||||||
user_image_dir = get_user_image_dir(user.id)
|
user_image_dir = get_user_image_dir(sanitize_email(user.email))
|
||||||
os.makedirs(user_image_dir, exist_ok=True)
|
os.makedirs(user_image_dir, exist_ok=True)
|
||||||
|
|
||||||
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
|
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
|
||||||
@@ -117,12 +122,11 @@ def verify():
|
|||||||
@auth_api.route('/resend-verify', methods=['POST'])
|
@auth_api.route('/resend-verify', methods=['POST'])
|
||||||
def resend_verify():
|
def resend_verify():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
email = data.get('email', '')
|
email = data.get('email')
|
||||||
if not email:
|
if not email:
|
||||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||||
norm_email = normalize_email(email)
|
|
||||||
|
|
||||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
user_dict = users_db.get(UserQuery.email == email)
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||||
@@ -134,36 +138,29 @@ def resend_verify():
|
|||||||
now_iso = datetime.utcnow().isoformat()
|
now_iso = datetime.utcnow().isoformat()
|
||||||
user.verify_token = token
|
user.verify_token = token
|
||||||
user.verify_token_created = now_iso
|
user.verify_token_created = now_iso
|
||||||
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
users_db.update(user.to_dict(), UserQuery.email == email)
|
||||||
send_verification_email(norm_email, token)
|
send_verification_email(email, token)
|
||||||
return jsonify({'message': 'Verification email resent'}), 200
|
return jsonify({'message': 'Verification email resent'}), 200
|
||||||
|
|
||||||
@auth_api.route('/login', methods=['POST'])
|
@auth_api.route('/login', methods=['POST'])
|
||||||
def login():
|
def login():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
email = data.get('email', '')
|
email = data.get('email')
|
||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
if not email or not password:
|
if not email or not password:
|
||||||
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
|
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
|
||||||
norm_email = normalize_email(email)
|
|
||||||
|
|
||||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
user_dict = users_db.get(UserQuery.email == email)
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
if not user or not check_password_hash(user.password, password):
|
if not user or user.password != password:
|
||||||
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
|
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
|
||||||
|
|
||||||
if not user.verified:
|
if not user.verified:
|
||||||
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
||||||
|
|
||||||
# Block login for marked accounts
|
|
||||||
if user.marked_for_deletion:
|
|
||||||
return jsonify({'error': 'This account has been marked for deletion and cannot be accessed.', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'email': norm_email,
|
'email': email,
|
||||||
'user_id': user.id,
|
'exp': datetime.utcnow() + timedelta(hours=24*7)
|
||||||
'token_version': user.token_version,
|
|
||||||
'exp': datetime.utcnow() + timedelta(days=62)
|
|
||||||
}
|
}
|
||||||
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||||
|
|
||||||
@@ -179,19 +176,14 @@ def me():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||||
user_id = payload.get('user_id', '')
|
email = payload.get('email')
|
||||||
token_version = payload.get('token_version', 0)
|
user_dict = users_db.get(UserQuery.email == email)
|
||||||
user_dict = users_db.get(UserQuery.id == user_id)
|
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||||
if token_version != user.token_version:
|
|
||||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
|
|
||||||
if user.marked_for_deletion:
|
|
||||||
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'id': user_id,
|
'id': sanitize_email(user.email),
|
||||||
'first_name': user.first_name,
|
'first_name': user.first_name,
|
||||||
'last_name': user.last_name,
|
'last_name': user.last_name,
|
||||||
'verified': user.verified
|
'verified': user.verified
|
||||||
@@ -204,25 +196,21 @@ def me():
|
|||||||
@auth_api.route('/request-password-reset', methods=['POST'])
|
@auth_api.route('/request-password-reset', methods=['POST'])
|
||||||
def request_password_reset():
|
def request_password_reset():
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
email = data.get('email', '')
|
email = data.get('email')
|
||||||
norm_email = normalize_email(email)
|
|
||||||
|
|
||||||
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
|
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
|
||||||
|
|
||||||
if not email:
|
if not email:
|
||||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||||
|
|
||||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
user_dict = users_db.get(UserQuery.email == email)
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
if user:
|
if user:
|
||||||
if user.marked_for_deletion:
|
|
||||||
return jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION}), 403
|
|
||||||
token = secrets.token_urlsafe(32)
|
token = secrets.token_urlsafe(32)
|
||||||
now_iso = datetime.utcnow().isoformat()
|
now_iso = datetime.utcnow().isoformat()
|
||||||
user.reset_token = token
|
user.reset_token = token
|
||||||
user.reset_token_created = now_iso
|
user.reset_token_created = now_iso
|
||||||
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
users_db.update(user.to_dict(), UserQuery.email == email)
|
||||||
send_reset_password_email(norm_email, token)
|
send_reset_password_email(email, token)
|
||||||
|
|
||||||
return jsonify({'message': success_msg}), 200
|
return jsonify({'message': success_msg}), 200
|
||||||
|
|
||||||
@@ -269,19 +257,9 @@ def reset_password():
|
|||||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
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({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||||
|
|
||||||
user.password = generate_password_hash(new_password)
|
user.password = new_password # Hash in production!
|
||||||
user.reset_token = None
|
user.reset_token = None
|
||||||
user.reset_token_created = None
|
user.reset_token_created = None
|
||||||
user.token_version += 1
|
|
||||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||||
|
|
||||||
resp = jsonify({'message': 'Password has been reset'})
|
return jsonify({'message': 'Password has been reset'}), 200
|
||||||
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
|
|
||||||
return resp, 200
|
|
||||||
|
|
||||||
@auth_api.route('/logout', methods=['POST'])
|
|
||||||
def logout():
|
|
||||||
resp = jsonify({'message': 'Logged out'})
|
|
||||||
# Remove the token cookie by setting it to empty and expiring it
|
|
||||||
resp.set_cookie('token', '', expires=0, httponly=True, secure=True, samesite='Strict')
|
|
||||||
return resp, 200
|
|
||||||
676
api/child_api.py
Normal file
676
api/child_api.py
Normal file
@@ -0,0 +1,676 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
child_api = Blueprint('child_api', __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 not isinstance(task_ids, list):
|
||||||
|
return jsonify({'error': 'task_ids must be a list'}), 400
|
||||||
|
|
||||||
|
# Deduplicate and drop falsy values
|
||||||
|
new_task_ids = [tid for tid in dict.fromkeys(task_ids) if tid]
|
||||||
|
|
||||||
|
ChildQuery = Query()
|
||||||
|
result = child_db.search(ChildQuery.id == id)
|
||||||
|
if not result:
|
||||||
|
return jsonify({'error': 'Child not found'}), 404
|
||||||
|
|
||||||
|
# Optional: validate task IDs exist in the task DB
|
||||||
|
TaskQuery = Query()
|
||||||
|
valid_task_ids = []
|
||||||
|
for tid in new_task_ids:
|
||||||
|
if task_db.get(TaskQuery.id == tid):
|
||||||
|
valid_task_ids.append(tid)
|
||||||
|
# Replace tasks with validated IDs
|
||||||
|
child_db.update({'tasks': valid_task_ids}, ChildQuery.id == id)
|
||||||
|
resp = send_event_for_current_user(Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Tasks set for child {id}.',
|
||||||
|
'task_ids': valid_task_ids,
|
||||||
|
'count': len(valid_task_ids)
|
||||||
|
}), 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
|
||||||
|
|
||||||
|
child = result[0]
|
||||||
|
assigned_ids = set(child.get('tasks', []))
|
||||||
|
|
||||||
|
# Get all tasks from database
|
||||||
|
all_tasks = task_db.all()
|
||||||
|
|
||||||
|
assigned_tasks = []
|
||||||
|
assignable_tasks = []
|
||||||
|
|
||||||
|
for task in all_tasks:
|
||||||
|
if not task or not task.get('id'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
ct = ChildTask(
|
||||||
|
task.get('name'),
|
||||||
|
task.get('is_good'),
|
||||||
|
task.get('points'),
|
||||||
|
task.get('image_id'),
|
||||||
|
task.get('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
if task.get('id') in assigned_ids:
|
||||||
|
assigned_tasks.append(ct.to_dict())
|
||||||
|
else:
|
||||||
|
assignable_tasks.append(ct.to_dict())
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'assigned_tasks': assigned_tasks,
|
||||||
|
'assignable_tasks': assignable_tasks,
|
||||||
|
'assigned_count': len(assigned_tasks),
|
||||||
|
'assignable_count': len(assignable_tasks)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_api.route('/child/<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()
|
||||||
|
|
||||||
|
assigned_rewards = []
|
||||||
|
assignable_rewards = []
|
||||||
|
|
||||||
|
for reward in all_rewards:
|
||||||
|
if not reward or not reward.get('id'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cr = ChildReward(
|
||||||
|
reward.get('name'),
|
||||||
|
reward.get('cost'),
|
||||||
|
reward.get('image_id'),
|
||||||
|
reward.get('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
if reward.get('id') in assigned_ids:
|
||||||
|
assigned_rewards.append(cr.to_dict())
|
||||||
|
else:
|
||||||
|
assignable_rewards.append(cr.to_dict())
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'assigned_rewards': assigned_rewards,
|
||||||
|
'assignable_rewards': assignable_rewards,
|
||||||
|
'assigned_count': len(assigned_rewards),
|
||||||
|
'assignable_count': len(assignable_rewards)
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_api.route('/child/<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])
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
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
|
||||||
|
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())
|
||||||
|
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||||
|
if resp:
|
||||||
|
return resp
|
||||||
|
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}), 200
|
||||||
|
|
||||||
12
api/error_codes.py
Normal file
12
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"
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
UPLOAD_FOLDER = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../data/images'))
|
|
||||||
import os
|
|
||||||
|
|
||||||
from PIL import Image as PILImage, UnidentifiedImageError
|
from PIL import Image as PILImage, UnidentifiedImageError
|
||||||
from flask import Blueprint, request, jsonify, send_file
|
from flask import Blueprint, request, jsonify, send_file
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
|
||||||
from api.utils import get_current_user_id, sanitize_email, get_validated_user_id
|
from api.utils import get_current_user_id, sanitize_email
|
||||||
from config.paths import get_user_image_dir
|
from config.paths import get_user_image_dir
|
||||||
|
|
||||||
from db.db import image_db
|
from db.db import image_db
|
||||||
@@ -23,9 +21,9 @@ def allowed_file(filename):
|
|||||||
|
|
||||||
@image_api.route('/image/upload', methods=['POST'])
|
@image_api.route('/image/upload', methods=['POST'])
|
||||||
def upload():
|
def upload():
|
||||||
user_id = get_validated_user_id()
|
user_id = get_current_user_id()
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
return jsonify({'error': 'User not authenticated'}), 401
|
||||||
if 'file' not in request.files:
|
if 'file' not in request.files:
|
||||||
return jsonify({'error': 'No file part in the request'}), 400
|
return jsonify({'error': 'No file part in the request'}), 400
|
||||||
file = request.files['file']
|
file = request.files['file']
|
||||||
@@ -66,11 +64,8 @@ def upload():
|
|||||||
|
|
||||||
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
|
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
|
||||||
extension = format_extension_map.get(original_format, '.png')
|
extension = format_extension_map.get(original_format, '.png')
|
||||||
image_record = Image(extension=extension, permanent=perm, type=image_type, user_id=user_id)
|
image_record = Image(extension=extension, permanent=perm, type=image_type, user=user_id)
|
||||||
filename = image_record.id + extension
|
filename = image_record.id + extension
|
||||||
user_image_dir = get_user_image_dir(user_id)
|
|
||||||
os.makedirs(user_image_dir, exist_ok=True)
|
|
||||||
|
|
||||||
filepath = os.path.abspath(os.path.join(get_user_image_dir(sanitize_email(user_id)), filename))
|
filepath = os.path.abspath(os.path.join(get_user_image_dir(sanitize_email(user_id)), filename))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -89,38 +84,25 @@ def upload():
|
|||||||
|
|
||||||
@image_api.route('/image/request/<id>', methods=['GET'])
|
@image_api.route('/image/request/<id>', methods=['GET'])
|
||||||
def request_image(id):
|
def request_image(id):
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
ImageQuery = Query()
|
ImageQuery = Query()
|
||||||
image_record = image_db.get(ImageQuery.id == id)
|
image: Image = Image.from_dict(image_db.get(ImageQuery.id == id))
|
||||||
if not image_record:
|
if not image:
|
||||||
return jsonify({'error': 'Image not found'}), 404
|
return jsonify({'error': 'Image not found'}), 404
|
||||||
image = Image.from_dict(image_record)
|
|
||||||
# Allow if image.user_id is None (public image), or matches user_id
|
|
||||||
if image.user_id is not None and image.user_id != user_id:
|
|
||||||
return jsonify({'error': 'Forbidden: image does not belong to user', 'code': 'FORBIDDEN'}), 403
|
|
||||||
filename = f"{image.id}{image.extension}"
|
filename = f"{image.id}{image.extension}"
|
||||||
if image.user_id is None:
|
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user), filename))
|
||||||
filepath = os.path.abspath(os.path.join(get_user_image_dir("default"), filename))
|
|
||||||
else:
|
|
||||||
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user_id), filename))
|
|
||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
return jsonify({'error': 'File not found'}), 404
|
return jsonify({'error': 'File not found'}), 404
|
||||||
return send_file(filepath)
|
return send_file(filepath)
|
||||||
|
|
||||||
@image_api.route('/image/list', methods=['GET'])
|
@image_api.route('/image/list', methods=['GET'])
|
||||||
def list_images():
|
def list_images():
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
image_type = request.args.get('type', type=int)
|
image_type = request.args.get('type', type=int)
|
||||||
ImageQuery = Query()
|
ImageQuery = Query()
|
||||||
if image_type is not None:
|
if image_type is not None:
|
||||||
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
|
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
|
||||||
return jsonify({'error': 'Invalid image type'}), 400
|
return jsonify({'error': 'Invalid image type'}), 400
|
||||||
images = image_db.search((ImageQuery.type == image_type) & ((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None)))
|
images = image_db.search(ImageQuery.type == image_type)
|
||||||
else:
|
else:
|
||||||
images = image_db.search((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None))
|
images = image_db.all()
|
||||||
image_ids = [img['id'] for img in images]
|
image_ids = [img['id'] for img in images]
|
||||||
return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200
|
return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200
|
||||||
108
api/reward_api.py
Normal file
108
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
|
||||||
106
api/task_api.py
Normal file
106
api/task_api.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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():
|
||||||
|
tasks = task_db.all()
|
||||||
|
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
api/user_api.py
Normal file
45
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
api/utils.py
Normal file
28
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
|
||||||
83
backend/.gitignore
vendored
83
backend/.gitignore
vendored
@@ -1,83 +0,0 @@
|
|||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
PIPFILE.lock
|
|
||||||
|
|
||||||
# Virtual Environment
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env/
|
|
||||||
.venv
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
.idea/
|
|
||||||
*.iml
|
|
||||||
*.iws
|
|
||||||
.idea_modules/
|
|
||||||
|
|
||||||
# VS Code
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# Database files
|
|
||||||
*.db
|
|
||||||
*.sqlite
|
|
||||||
*.sqlite3
|
|
||||||
data/db/*.json
|
|
||||||
data/images/
|
|
||||||
test_data/
|
|
||||||
|
|
||||||
# Flask
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# Node.js / Vue (web directory)
|
|
||||||
web/node_modules/
|
|
||||||
web/npm-debug.log*
|
|
||||||
web/yarn-debug.log*
|
|
||||||
web/yarn-error.log*
|
|
||||||
web/dist/
|
|
||||||
web/.nuxt/
|
|
||||||
web/.cache/
|
|
||||||
|
|
||||||
# OS files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
logs/
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
.pytest_cache/
|
|
||||||
.coverage
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
|
|
||||||
/chore.bundle
|
|
||||||
/tree.json
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from tinydb import Query
|
|
||||||
import jwt
|
|
||||||
from functools import wraps
|
|
||||||
|
|
||||||
from db.db import users_db
|
|
||||||
from models.user import User
|
|
||||||
from config.deletion_config import (
|
|
||||||
ACCOUNT_DELETION_THRESHOLD_HOURS,
|
|
||||||
MIN_THRESHOLD_HOURS,
|
|
||||||
MAX_THRESHOLD_HOURS,
|
|
||||||
validate_threshold
|
|
||||||
)
|
|
||||||
from utils.account_deletion_scheduler import trigger_deletion_manually
|
|
||||||
|
|
||||||
admin_api = Blueprint('admin_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
|
|
||||||
|
|
||||||
# Pass user to the endpoint
|
|
||||||
request.current_user = user
|
|
||||||
|
|
||||||
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 f(*args, **kwargs)
|
|
||||||
|
|
||||||
return decorated_function
|
|
||||||
|
|
||||||
@admin_api.route('/admin/deletion-queue', methods=['GET'])
|
|
||||||
@admin_required
|
|
||||||
def get_deletion_queue():
|
|
||||||
"""
|
|
||||||
Get list of users pending deletion.
|
|
||||||
Returns users marked for deletion with their deletion due dates.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
Query_ = Query()
|
|
||||||
marked_users = users_db.search(Query_.marked_for_deletion == True)
|
|
||||||
|
|
||||||
users_data = []
|
|
||||||
for user_dict in marked_users:
|
|
||||||
user = User.from_dict(user_dict)
|
|
||||||
|
|
||||||
# Calculate deletion_due_at
|
|
||||||
deletion_due_at = None
|
|
||||||
if user.marked_for_deletion_at:
|
|
||||||
try:
|
|
||||||
marked_at = datetime.fromisoformat(user.marked_for_deletion_at)
|
|
||||||
due_at = marked_at + timedelta(hours=ACCOUNT_DELETION_THRESHOLD_HOURS)
|
|
||||||
deletion_due_at = due_at.isoformat()
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
users_data.append({
|
|
||||||
'id': user.id,
|
|
||||||
'email': user.email,
|
|
||||||
'marked_for_deletion_at': user.marked_for_deletion_at,
|
|
||||||
'deletion_due_at': deletion_due_at,
|
|
||||||
'deletion_in_progress': user.deletion_in_progress,
|
|
||||||
'deletion_attempted_at': user.deletion_attempted_at
|
|
||||||
})
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'count': len(users_data),
|
|
||||||
'users': users_data
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
|
||||||
|
|
||||||
@admin_api.route('/admin/deletion-threshold', methods=['GET'])
|
|
||||||
@admin_required
|
|
||||||
def get_deletion_threshold():
|
|
||||||
"""
|
|
||||||
Get current deletion threshold configuration.
|
|
||||||
"""
|
|
||||||
return jsonify({
|
|
||||||
'threshold_hours': ACCOUNT_DELETION_THRESHOLD_HOURS,
|
|
||||||
'threshold_min': MIN_THRESHOLD_HOURS,
|
|
||||||
'threshold_max': MAX_THRESHOLD_HOURS
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
@admin_api.route('/admin/deletion-threshold', methods=['PUT'])
|
|
||||||
@admin_required
|
|
||||||
def update_deletion_threshold():
|
|
||||||
"""
|
|
||||||
Update deletion threshold.
|
|
||||||
Note: This updates the runtime value but doesn't persist to environment variables.
|
|
||||||
For permanent changes, update the ACCOUNT_DELETION_THRESHOLD_HOURS env variable.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
data = request.get_json()
|
|
||||||
|
|
||||||
if not data or 'threshold_hours' not in data:
|
|
||||||
return jsonify({
|
|
||||||
'error': 'threshold_hours is required',
|
|
||||||
'code': 'MISSING_THRESHOLD'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
new_threshold = data['threshold_hours']
|
|
||||||
|
|
||||||
# Validate type
|
|
||||||
if not isinstance(new_threshold, int):
|
|
||||||
return jsonify({
|
|
||||||
'error': 'threshold_hours must be an integer',
|
|
||||||
'code': 'INVALID_TYPE'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Validate range
|
|
||||||
if new_threshold < MIN_THRESHOLD_HOURS:
|
|
||||||
return jsonify({
|
|
||||||
'error': f'threshold_hours must be at least {MIN_THRESHOLD_HOURS}',
|
|
||||||
'code': 'THRESHOLD_TOO_LOW'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
if new_threshold > MAX_THRESHOLD_HOURS:
|
|
||||||
return jsonify({
|
|
||||||
'error': f'threshold_hours must be at most {MAX_THRESHOLD_HOURS}',
|
|
||||||
'code': 'THRESHOLD_TOO_HIGH'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Update the global config
|
|
||||||
import config.deletion_config as config
|
|
||||||
config.ACCOUNT_DELETION_THRESHOLD_HOURS = new_threshold
|
|
||||||
|
|
||||||
# Validate and log warning if needed
|
|
||||||
validate_threshold()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'message': 'Deletion threshold updated successfully',
|
|
||||||
'threshold_hours': new_threshold
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
|
||||||
|
|
||||||
@admin_api.route('/admin/deletion-queue/trigger', methods=['POST'])
|
|
||||||
@admin_required
|
|
||||||
def trigger_deletion_queue():
|
|
||||||
"""
|
|
||||||
Manually trigger the deletion scheduler to process the queue immediately.
|
|
||||||
Returns stats about the run.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Trigger the deletion process
|
|
||||||
result = trigger_deletion_manually()
|
|
||||||
|
|
||||||
# Get updated queue stats
|
|
||||||
Query_ = Query()
|
|
||||||
marked_users = users_db.search(Query_.marked_for_deletion == True)
|
|
||||||
|
|
||||||
# Count users that were just processed (this is simplified)
|
|
||||||
processed = result.get('queued_users', 0)
|
|
||||||
|
|
||||||
# In a real implementation, you'd return actual stats from the deletion run
|
|
||||||
# For now, we'll return simplified stats
|
|
||||||
return jsonify({
|
|
||||||
'message': 'Deletion scheduler triggered',
|
|
||||||
'processed': processed,
|
|
||||||
'deleted': 0, # TODO: Track this in the deletion function
|
|
||||||
'failed': 0 # TODO: Track this in the deletion function
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
|
||||||
@@ -1,970 +0,0 @@
|
|||||||
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 db.tracking import insert_tracking_event
|
|
||||||
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
|
|
||||||
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
|
|
||||||
|
|
||||||
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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
ChildQuery = Query()
|
|
||||||
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_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():
|
|
||||||
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')
|
|
||||||
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, user_id=user_id)
|
|
||||||
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):
|
|
||||||
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', 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) & (ChildQuery.user_id == user_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) & (PendingQuery.user_id == user_id))
|
|
||||||
|
|
||||||
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)))
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
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():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
ChildQuery = Query()
|
|
||||||
children = child_db.search(ChildQuery.user_id == user_id)
|
|
||||||
return jsonify({'children': children}), 200
|
|
||||||
|
|
||||||
# Child DELETE
|
|
||||||
@child_api.route('/child/<id>', methods=['DELETE'])
|
|
||||||
def delete_child(id):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
ChildQuery = Query()
|
|
||||||
|
|
||||||
# Cascade delete overrides for this child
|
|
||||||
deleted_count = delete_overrides_for_child(id)
|
|
||||||
if deleted_count > 0:
|
|
||||||
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
|
|
||||||
|
|
||||||
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_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):
|
|
||||||
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 = 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.get('name')}."}), 200
|
|
||||||
|
|
||||||
# python
|
|
||||||
@child_api.route('/child/<id>/set-tasks', methods=['PUT'])
|
|
||||||
def set_child_tasks(id):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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) & (ChildQuery.user_id == user_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)
|
|
||||||
|
|
||||||
# Identify unassigned tasks and delete their overrides
|
|
||||||
old_task_ids = set(child.tasks)
|
|
||||||
unassigned_task_ids = old_task_ids - new_task_ids
|
|
||||||
for task_id in unassigned_task_ids:
|
|
||||||
# Only delete overrides for task entities
|
|
||||||
override = get_override(id, task_id)
|
|
||||||
if override and override.entity_type == 'task':
|
|
||||||
delete_override(id, task_id)
|
|
||||||
logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}")
|
|
||||||
|
|
||||||
# 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):
|
|
||||||
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 = 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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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 = result[0]
|
|
||||||
task_ids = child.get('tasks', [])
|
|
||||||
|
|
||||||
TaskQuery = Query()
|
|
||||||
child_tasks = []
|
|
||||||
for tid in task_ids:
|
|
||||||
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
|
||||||
if not task:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 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_dict = ct.to_dict()
|
|
||||||
if custom_value is not None:
|
|
||||||
ct_dict['custom_value'] = custom_value
|
|
||||||
child_tasks.append(ct_dict)
|
|
||||||
|
|
||||||
return jsonify({'tasks': child_tasks}), 200
|
|
||||||
|
|
||||||
@child_api.route('/child/<id>/list-assignable-tasks', methods=['GET'])
|
|
||||||
def list_assignable_tasks(id):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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 = result[0]
|
|
||||||
assigned_ids = set(child.get('tasks', []))
|
|
||||||
|
|
||||||
# Get all assignable tasks (not already assigned)
|
|
||||||
all_tasks = [t for t in task_db.all() if t and t.get('id') and t.get('id') not in assigned_ids]
|
|
||||||
|
|
||||||
# Group by name
|
|
||||||
from collections import defaultdict
|
|
||||||
name_to_tasks = defaultdict(list)
|
|
||||||
for t in all_tasks:
|
|
||||||
name_to_tasks[t.get('name')].append(t)
|
|
||||||
|
|
||||||
filtered_tasks = []
|
|
||||||
for name, tasks in name_to_tasks.items():
|
|
||||||
user_tasks = [t for t in tasks if t.get('user_id') is not None]
|
|
||||||
if len(user_tasks) == 0:
|
|
||||||
# Only system task exists
|
|
||||||
filtered_tasks.append(tasks[0])
|
|
||||||
elif len(user_tasks) == 1:
|
|
||||||
# Only one user task: show it, not system
|
|
||||||
filtered_tasks.append(user_tasks[0])
|
|
||||||
else:
|
|
||||||
# Multiple user tasks: show all user tasks, not system
|
|
||||||
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]
|
|
||||||
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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
ChildQuery = Query()
|
|
||||||
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_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 (not filtering out assigned, since this is 'all')
|
|
||||||
ChildTaskQuery = Query()
|
|
||||||
all_tasks = task_db.search((ChildTaskQuery.user_id == user_id) | (ChildTaskQuery.user_id == None))
|
|
||||||
|
|
||||||
name_to_tasks = defaultdict(list)
|
|
||||||
for t in all_tasks:
|
|
||||||
name_to_tasks[t.get('name')].append(t)
|
|
||||||
|
|
||||||
filtered_tasks = []
|
|
||||||
for name, tasks in name_to_tasks.items():
|
|
||||||
user_tasks = [t for t in tasks if t.get('user_id') is not None]
|
|
||||||
if len(user_tasks) == 0:
|
|
||||||
filtered_tasks.append(tasks[0])
|
|
||||||
elif len(user_tasks) == 1:
|
|
||||||
filtered_tasks.append(user_tasks[0])
|
|
||||||
else:
|
|
||||||
filtered_tasks.extend(user_tasks)
|
|
||||||
|
|
||||||
result_tasks = []
|
|
||||||
for t in filtered_tasks:
|
|
||||||
if has_type and t.get('is_good') != good:
|
|
||||||
continue
|
|
||||||
ct = ChildTask(
|
|
||||||
t.get('name'),
|
|
||||||
t.get('is_good'),
|
|
||||||
t.get('points'),
|
|
||||||
t.get('image_id'),
|
|
||||||
t.get('id')
|
|
||||||
)
|
|
||||||
task_dict = ct.to_dict()
|
|
||||||
task_dict.update({'assigned': t.get('id') in assigned_ids})
|
|
||||||
result_tasks.append(task_dict)
|
|
||||||
|
|
||||||
result_tasks.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
|
||||||
return jsonify({ 'tasks': result_tasks, 'count': len(result_tasks), 'list_type': 'task' }), 200
|
|
||||||
|
|
||||||
|
|
||||||
@child_api.route('/child/<id>/trigger-task', methods=['POST'])
|
|
||||||
def trigger_child_task(id):
|
|
||||||
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 = 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) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
|
||||||
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
|
|
||||||
|
|
||||||
# Check for override
|
|
||||||
override = get_override(id, task_id)
|
|
||||||
points_value = override.custom_value if override else task.points
|
|
||||||
|
|
||||||
# update the child's points based on task type
|
|
||||||
if task.is_good:
|
|
||||||
child.points += points_value
|
|
||||||
else:
|
|
||||||
child.points -= points_value
|
|
||||||
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_metadata = {
|
|
||||||
'task_name': task.name,
|
|
||||||
'is_good': task.is_good,
|
|
||||||
'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=child.id,
|
|
||||||
entity_type=entity_type,
|
|
||||||
entity_id=task.id,
|
|
||||||
action='activated',
|
|
||||||
points_before=points_before,
|
|
||||||
points_after=child.points,
|
|
||||||
metadata=tracking_metadata
|
|
||||||
)
|
|
||||||
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
|
|
||||||
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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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) & (ChildQuery.user_id == user_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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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])
|
|
||||||
assigned_ids = set(child.rewards)
|
|
||||||
|
|
||||||
# Get all rewards from database
|
|
||||||
ChildRewardQuery = Query()
|
|
||||||
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
|
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
name_to_rewards = defaultdict(list)
|
|
||||||
for r in all_rewards:
|
|
||||||
name_to_rewards[r.get('name')].append(r)
|
|
||||||
|
|
||||||
filtered_rewards = []
|
|
||||||
for name, rewards in name_to_rewards.items():
|
|
||||||
user_rewards = [r for r in rewards if r.get('user_id') is not None]
|
|
||||||
if len(user_rewards) == 0:
|
|
||||||
filtered_rewards.append(rewards[0])
|
|
||||||
elif len(user_rewards) == 1:
|
|
||||||
filtered_rewards.append(user_rewards[0])
|
|
||||||
else:
|
|
||||||
filtered_rewards.extend(user_rewards)
|
|
||||||
|
|
||||||
result_rewards = []
|
|
||||||
for r in filtered_rewards:
|
|
||||||
cr = ChildReward(
|
|
||||||
r.get('name'),
|
|
||||||
r.get('cost'),
|
|
||||||
r.get('image_id'),
|
|
||||||
r.get('id')
|
|
||||||
)
|
|
||||||
reward_dict = cr.to_dict()
|
|
||||||
reward_dict.update({'assigned': r.get('id') in assigned_ids})
|
|
||||||
result_rewards.append(reward_dict)
|
|
||||||
result_rewards.sort(key=lambda t: (not t['assigned'], t['name'].lower()))
|
|
||||||
return jsonify({
|
|
||||||
'rewards': result_rewards,
|
|
||||||
'rewards_count': len(result_rewards),
|
|
||||||
'list_type': 'reward'
|
|
||||||
}), 200
|
|
||||||
|
|
||||||
|
|
||||||
@child_api.route('/child/<id>/set-rewards', methods=['PUT'])
|
|
||||||
def set_child_rewards(id):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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) & (ChildQuery.user_id == user_id))
|
|
||||||
if not result:
|
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
|
||||||
|
|
||||||
child = Child.from_dict(result[0])
|
|
||||||
old_reward_ids = set(child.rewards)
|
|
||||||
|
|
||||||
# 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) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
|
|
||||||
valid_reward_ids.append(rid)
|
|
||||||
|
|
||||||
# Identify unassigned rewards and delete their overrides
|
|
||||||
new_reward_ids_set = set(valid_reward_ids)
|
|
||||||
unassigned_reward_ids = old_reward_ids - new_reward_ids_set
|
|
||||||
for reward_id in unassigned_reward_ids:
|
|
||||||
override = get_override(id, reward_id)
|
|
||||||
if override and override.entity_type == 'reward':
|
|
||||||
delete_override(id, reward_id)
|
|
||||||
logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}")
|
|
||||||
|
|
||||||
# Replace rewards with validated IDs
|
|
||||||
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
|
||||||
send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, valid_reward_ids)))
|
|
||||||
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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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) & (ChildQuery.user_id == user_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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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 = result[0]
|
|
||||||
reward_ids = child.get('rewards', [])
|
|
||||||
|
|
||||||
RewardQuery = Query()
|
|
||||||
child_rewards = []
|
|
||||||
for rid in reward_ids:
|
|
||||||
reward = reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
|
||||||
if not reward:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check for override
|
|
||||||
override = get_override(id, rid)
|
|
||||||
custom_value = override.custom_value if override else None
|
|
||||||
|
|
||||||
cr = ChildReward(reward.get('name'), reward.get('cost'), reward.get('image_id'), reward.get('id'))
|
|
||||||
cr_dict = cr.to_dict()
|
|
||||||
if custom_value is not None:
|
|
||||||
cr_dict['custom_value'] = custom_value
|
|
||||||
child_rewards.append(cr_dict)
|
|
||||||
|
|
||||||
return jsonify({'rewards': child_rewards}), 200
|
|
||||||
|
|
||||||
@child_api.route('/child/<id>/list-assignable-rewards', methods=['GET'])
|
|
||||||
def list_assignable_rewards(id):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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 = result[0]
|
|
||||||
assigned_ids = set(child.get('rewards', []))
|
|
||||||
|
|
||||||
# Get all assignable rewards (not already assigned)
|
|
||||||
all_rewards = [r for r in reward_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
|
|
||||||
|
|
||||||
# Group by name
|
|
||||||
from collections import defaultdict
|
|
||||||
name_to_rewards = defaultdict(list)
|
|
||||||
for r in all_rewards:
|
|
||||||
name_to_rewards[r.get('name')].append(r)
|
|
||||||
|
|
||||||
filtered_rewards = []
|
|
||||||
for name, rewards in name_to_rewards.items():
|
|
||||||
user_rewards = [r for r in rewards if r.get('user_id') is not None]
|
|
||||||
if len(user_rewards) == 0:
|
|
||||||
filtered_rewards.append(rewards[0])
|
|
||||||
elif len(user_rewards) == 1:
|
|
||||||
filtered_rewards.append(user_rewards[0])
|
|
||||||
else:
|
|
||||||
filtered_rewards.extend(user_rewards)
|
|
||||||
|
|
||||||
assignable_rewards = [ChildReward(r.get('name'), r.get('cost'), r.get('image_id'), r.get('id')).to_dict() for r in filtered_rewards]
|
|
||||||
return jsonify({'rewards': assignable_rewards, 'count': len(assignable_rewards)}), 200
|
|
||||||
|
|
||||||
@child_api.route('/child/<id>/trigger-reward', methods=['POST'])
|
|
||||||
def trigger_child_reward(id):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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) & (ChildQuery.user_id == user_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) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
|
||||||
if not reward_result:
|
|
||||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
|
||||||
reward: Reward = Reward.from_dict(reward_result[0])
|
|
||||||
|
|
||||||
# Check for override
|
|
||||||
override = get_override(id, reward_id)
|
|
||||||
cost_value = override.custom_value if override else reward.cost
|
|
||||||
|
|
||||||
# Check if child has enough points
|
|
||||||
logger.info(f'Child {child.name} has {child.points} points, reward {reward.name} costs {cost_value} points')
|
|
||||||
if child.points < cost_value:
|
|
||||||
points_needed = cost_value - child.points
|
|
||||||
return jsonify({
|
|
||||||
'error': 'Insufficient points',
|
|
||||||
'points_needed': points_needed,
|
|
||||||
'current_points': child.points,
|
|
||||||
'reward_cost': cost_value
|
|
||||||
}), 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)))
|
|
||||||
|
|
||||||
# Capture points before modification
|
|
||||||
points_before = child.points
|
|
||||||
|
|
||||||
# update the child's points based on reward cost
|
|
||||||
child.points -= cost_value
|
|
||||||
# update the child in the database
|
|
||||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
|
||||||
|
|
||||||
# Create tracking event
|
|
||||||
tracking_metadata = {
|
|
||||||
'reward_name': reward.name,
|
|
||||||
'reward_cost': reward.cost,
|
|
||||||
'default_cost': reward.cost
|
|
||||||
}
|
|
||||||
if override:
|
|
||||||
tracking_metadata['custom_cost'] = override.custom_value
|
|
||||||
tracking_metadata['has_override'] = True
|
|
||||||
|
|
||||||
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=tracking_metadata
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
|
||||||
def list_affordable_rewards(id):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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])
|
|
||||||
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) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))) 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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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])
|
|
||||||
points = child.points
|
|
||||||
reward_ids = child.rewards
|
|
||||||
|
|
||||||
RewardQuery = Query()
|
|
||||||
statuses = []
|
|
||||||
for reward_id in reward_ids:
|
|
||||||
reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
|
||||||
if not reward_dict:
|
|
||||||
continue
|
|
||||||
reward: Reward = Reward.from_dict(reward_dict)
|
|
||||||
|
|
||||||
# Check for override
|
|
||||||
override = get_override(id, reward_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
|
|
||||||
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))
|
|
||||||
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id)
|
|
||||||
status_dict = status.to_dict()
|
|
||||||
if override:
|
|
||||||
status_dict['custom_value'] = override.custom_value
|
|
||||||
statuses.append(status_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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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) & (ChildQuery.user_id == user_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, 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}.',
|
|
||||||
'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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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) & (ChildQuery.user_id == user_id))
|
|
||||||
if not result:
|
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
|
||||||
|
|
||||||
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(
|
|
||||||
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id) & (PendingQuery.user_id == user_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:
|
|
||||||
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():
|
|
||||||
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 = []
|
|
||||||
|
|
||||||
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) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
|
||||||
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
|
|
||||||
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
from flask import Blueprint, request, jsonify
|
|
||||||
from tinydb import Query
|
|
||||||
from api.utils import get_validated_user_id, send_event_for_current_user
|
|
||||||
from api.error_codes import ErrorCodes
|
|
||||||
from db.db import child_db, task_db, reward_db
|
|
||||||
from db.child_overrides import (
|
|
||||||
insert_override,
|
|
||||||
get_override,
|
|
||||||
get_overrides_for_child,
|
|
||||||
delete_override
|
|
||||||
)
|
|
||||||
from models.child_override import ChildOverride
|
|
||||||
from events.types.event import Event
|
|
||||||
from events.types.event_types import EventType
|
|
||||||
from events.types.child_override_set import ChildOverrideSetPayload
|
|
||||||
from events.types.child_override_deleted import ChildOverrideDeletedPayload
|
|
||||||
import logging
|
|
||||||
|
|
||||||
child_override_api = Blueprint('child_override_api', __name__)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@child_override_api.route('/child/<child_id>/override', methods=['PUT'])
|
|
||||||
def set_child_override(child_id):
|
|
||||||
"""
|
|
||||||
Set or update a custom value for a task/reward for a specific child.
|
|
||||||
"""
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
|
||||||
|
|
||||||
# Validate child exists and belongs to user
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
|
||||||
if not child_result:
|
|
||||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
|
||||||
|
|
||||||
child_dict = child_result[0]
|
|
||||||
|
|
||||||
# Parse request data
|
|
||||||
data = request.get_json() or {}
|
|
||||||
entity_id = data.get('entity_id')
|
|
||||||
entity_type = data.get('entity_type')
|
|
||||||
custom_value = data.get('custom_value')
|
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
if not entity_id:
|
|
||||||
return jsonify({'error': 'entity_id is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_id'}), 400
|
|
||||||
if not entity_type:
|
|
||||||
return jsonify({'error': 'entity_type is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'entity_type'}), 400
|
|
||||||
if custom_value is None:
|
|
||||||
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
|
|
||||||
|
|
||||||
# Validate entity_type
|
|
||||||
if entity_type not in ['task', 'reward']:
|
|
||||||
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
|
|
||||||
|
|
||||||
# Validate custom_value range
|
|
||||||
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
|
|
||||||
return jsonify({'error': 'custom_value must be an integer between 0 and 10000', 'code': ErrorCodes.INVALID_VALUE, 'field': 'custom_value'}), 400
|
|
||||||
|
|
||||||
# Validate entity exists and is assigned to child
|
|
||||||
if entity_type == 'task':
|
|
||||||
EntityQuery = Query()
|
|
||||||
entity_result = task_db.search(
|
|
||||||
(EntityQuery.id == entity_id) &
|
|
||||||
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
|
|
||||||
)
|
|
||||||
if not entity_result:
|
|
||||||
return jsonify({'error': 'Task not found', 'code': ErrorCodes.TASK_NOT_FOUND}), 404
|
|
||||||
|
|
||||||
# Check if task is assigned to child
|
|
||||||
assigned_tasks = child_dict.get('tasks', [])
|
|
||||||
if entity_id not in assigned_tasks:
|
|
||||||
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
|
||||||
|
|
||||||
else: # reward
|
|
||||||
EntityQuery = Query()
|
|
||||||
entity_result = reward_db.search(
|
|
||||||
(EntityQuery.id == entity_id) &
|
|
||||||
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
|
|
||||||
)
|
|
||||||
if not entity_result:
|
|
||||||
return jsonify({'error': 'Reward not found', 'code': ErrorCodes.REWARD_NOT_FOUND}), 404
|
|
||||||
|
|
||||||
# Check if reward is assigned to child
|
|
||||||
assigned_rewards = child_dict.get('rewards', [])
|
|
||||||
if entity_id not in assigned_rewards:
|
|
||||||
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
|
||||||
|
|
||||||
# Create and insert override
|
|
||||||
try:
|
|
||||||
override = ChildOverride.create_override(
|
|
||||||
child_id=child_id,
|
|
||||||
entity_id=entity_id,
|
|
||||||
entity_type=entity_type,
|
|
||||||
custom_value=custom_value
|
|
||||||
)
|
|
||||||
insert_override(override)
|
|
||||||
|
|
||||||
# Send SSE event
|
|
||||||
resp = send_event_for_current_user(
|
|
||||||
Event(EventType.CHILD_OVERRIDE_SET.value, ChildOverrideSetPayload(override))
|
|
||||||
)
|
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
|
|
||||||
return jsonify({'override': override.to_dict()}), 200
|
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
return jsonify({'error': str(e), 'code': ErrorCodes.VALIDATION_ERROR}), 400
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error setting override: {e}")
|
|
||||||
return jsonify({'error': 'Internal server error', 'code': ErrorCodes.INTERNAL_ERROR}), 500
|
|
||||||
|
|
||||||
|
|
||||||
@child_override_api.route('/child/<child_id>/overrides', methods=['GET'])
|
|
||||||
def get_child_overrides(child_id):
|
|
||||||
"""
|
|
||||||
Get all overrides for a specific child.
|
|
||||||
"""
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
|
||||||
|
|
||||||
# Validate child exists and belongs to user
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
|
||||||
if not child_result:
|
|
||||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
|
||||||
|
|
||||||
# Get all overrides for child
|
|
||||||
overrides = get_overrides_for_child(child_id)
|
|
||||||
|
|
||||||
return jsonify({'overrides': [o.to_dict() for o in overrides]}), 200
|
|
||||||
|
|
||||||
|
|
||||||
@child_override_api.route('/child/<child_id>/override/<entity_id>', methods=['DELETE'])
|
|
||||||
def delete_child_override(child_id, entity_id):
|
|
||||||
"""
|
|
||||||
Delete an override (reset to default).
|
|
||||||
"""
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
|
||||||
|
|
||||||
# Validate child exists and belongs to user
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
|
||||||
if not child_result:
|
|
||||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
|
||||||
|
|
||||||
# Get override to determine entity_type for event
|
|
||||||
override = get_override(child_id, entity_id)
|
|
||||||
if not override:
|
|
||||||
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
|
|
||||||
|
|
||||||
entity_type = override.entity_type
|
|
||||||
|
|
||||||
# Delete override
|
|
||||||
deleted = delete_override(child_id, entity_id)
|
|
||||||
if not deleted:
|
|
||||||
return jsonify({'error': 'Override not found', 'code': ErrorCodes.OVERRIDE_NOT_FOUND}), 404
|
|
||||||
|
|
||||||
# Send SSE event
|
|
||||||
resp = send_event_for_current_user(
|
|
||||||
Event(EventType.CHILD_OVERRIDE_DELETED.value,
|
|
||||||
ChildOverrideDeletedPayload(child_id, entity_id, entity_type))
|
|
||||||
)
|
|
||||||
if resp:
|
|
||||||
return resp
|
|
||||||
|
|
||||||
return jsonify({'message': 'Override deleted'}), 200
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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"
|
|
||||||
ACCOUNT_MARKED_FOR_DELETION = "ACCOUNT_MARKED_FOR_DELETION"
|
|
||||||
ALREADY_MARKED = "ALREADY_MARKED"
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorCodes:
|
|
||||||
"""Centralized error codes for API responses."""
|
|
||||||
UNAUTHORIZED = "UNAUTHORIZED"
|
|
||||||
CHILD_NOT_FOUND = "CHILD_NOT_FOUND"
|
|
||||||
TASK_NOT_FOUND = "TASK_NOT_FOUND"
|
|
||||||
REWARD_NOT_FOUND = "REWARD_NOT_FOUND"
|
|
||||||
ENTITY_NOT_ASSIGNED = "ENTITY_NOT_ASSIGNED"
|
|
||||||
OVERRIDE_NOT_FOUND = "OVERRIDE_NOT_FOUND"
|
|
||||||
MISSING_FIELD = "MISSING_FIELD"
|
|
||||||
INVALID_VALUE = "INVALID_VALUE"
|
|
||||||
VALIDATION_ERROR = "VALIDATION_ERROR"
|
|
||||||
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
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_rewards_set import ChildRewardsSet
|
|
||||||
from db.db import reward_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.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():
|
|
||||||
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')
|
|
||||||
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, user_id=user_id)
|
|
||||||
reward_db.insert(reward.to_dict())
|
|
||||||
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(reward.id, RewardModified.OPERATION_ADD)))
|
|
||||||
return jsonify({'message': f'Reward {name} added.'}), 201
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@reward_api.route('/reward/<id>', methods=['GET'])
|
|
||||||
def get_reward(id):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
RewardQuery = Query()
|
|
||||||
result = reward_db.search((RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
|
||||||
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():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
ids_param = request.args.get('ids')
|
|
||||||
RewardQuery = Query()
|
|
||||||
rewards = reward_db.search((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))
|
|
||||||
if ids_param is not None:
|
|
||||||
if ids_param.strip() == '':
|
|
||||||
rewards = []
|
|
||||||
else:
|
|
||||||
ids = set(ids_param.split(','))
|
|
||||||
rewards = [reward for reward in rewards if reward.get('id') in ids]
|
|
||||||
|
|
||||||
# Filter out default rewards if user-specific version exists (case/whitespace-insensitive)
|
|
||||||
user_rewards = {r['name'].strip().lower(): r for r in rewards if r.get('user_id') == user_id}
|
|
||||||
filtered_rewards = []
|
|
||||||
for r in rewards:
|
|
||||||
if r.get('user_id') is None and r['name'].strip().lower() in user_rewards:
|
|
||||||
continue # Skip default if user version exists
|
|
||||||
filtered_rewards.append(r)
|
|
||||||
|
|
||||||
# Sort: user-created items first (by name), then default items (by name)
|
|
||||||
user_created = sorted([r for r in filtered_rewards if r.get('user_id') == user_id], key=lambda x: x['name'].lower())
|
|
||||||
default_items = sorted([r for r in filtered_rewards if r.get('user_id') is None], key=lambda x: x['name'].lower())
|
|
||||||
sorted_rewards = user_created + default_items
|
|
||||||
|
|
||||||
return jsonify({'rewards': sorted_rewards}), 200
|
|
||||||
|
|
||||||
@reward_api.route('/reward/<id>', methods=['DELETE'])
|
|
||||||
def delete_reward(id):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
RewardQuery = Query()
|
|
||||||
reward = reward_db.get(RewardQuery.id == id)
|
|
||||||
if not reward:
|
|
||||||
return jsonify({'error': 'Reward not found'}), 404
|
|
||||||
if reward.get('user_id') is None:
|
|
||||||
import logging
|
|
||||||
logging.warning(f"Forbidden delete attempt on system reward: id={id}, by user_id={user_id}")
|
|
||||||
return jsonify({'error': 'System rewards cannot be deleted.'}), 403
|
|
||||||
removed = reward_db.remove((RewardQuery.id == id) & (RewardQuery.user_id == user_id))
|
|
||||||
if removed:
|
|
||||||
# Cascade delete overrides for this reward
|
|
||||||
deleted_count = delete_overrides_for_entity(id)
|
|
||||||
if deleted_count > 0:
|
|
||||||
import logging
|
|
||||||
logging.info(f"Cascade deleted {deleted_count} overrides for reward {id}")
|
|
||||||
|
|
||||||
# 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'))
|
|
||||||
send_event_for_current_user(Event(EventType.CHILD_REWARDS_SET.value, ChildRewardsSet(id, rewards)))
|
|
||||||
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value, RewardModified(id, RewardModified.OPERATION_DELETE)))
|
|
||||||
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):
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
RewardQuery = Query()
|
|
||||||
existing = reward_db.get((RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
|
||||||
if not existing:
|
|
||||||
return jsonify({'error': 'Reward not found'}), 404
|
|
||||||
|
|
||||||
reward = Reward.from_dict(existing)
|
|
||||||
is_dirty = False
|
|
||||||
|
|
||||||
data = request.get_json(force=True) or {}
|
|
||||||
if 'name' in data:
|
|
||||||
name = (data.get('name') or '').strip()
|
|
||||||
if not name:
|
|
||||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
|
||||||
reward.name = name
|
|
||||||
is_dirty = True
|
|
||||||
|
|
||||||
if 'description' in data:
|
|
||||||
desc = (data.get('description') or '').strip()
|
|
||||||
if not desc:
|
|
||||||
return jsonify({'error': 'Description cannot be empty'}), 400
|
|
||||||
reward.description = desc
|
|
||||||
is_dirty = True
|
|
||||||
|
|
||||||
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
|
|
||||||
reward.cost = cost
|
|
||||||
is_dirty = True
|
|
||||||
|
|
||||||
if 'image_id' in data:
|
|
||||||
reward.image_id = data.get('image_id', '')
|
|
||||||
is_dirty = True
|
|
||||||
|
|
||||||
if not is_dirty:
|
|
||||||
return jsonify({'error': 'No valid fields to update'}), 400
|
|
||||||
|
|
||||||
if reward.user_id is None: # public reward
|
|
||||||
new_reward = Reward(name=reward.name, description=reward.description, cost=reward.cost, image_id=reward.image_id, user_id=user_id)
|
|
||||||
reward_db.insert(new_reward.to_dict())
|
|
||||||
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
|
||||||
RewardModified(new_reward.id, RewardModified.OPERATION_ADD)))
|
|
||||||
return jsonify(new_reward.to_dict()), 200
|
|
||||||
|
|
||||||
reward_db.update(reward.to_dict(), (RewardQuery.id == id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
|
||||||
send_event_for_current_user(Event(EventType.REWARD_MODIFIED.value,
|
|
||||||
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
|
||||||
return jsonify(reward.to_dict()), 200
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
task_api = Blueprint('task_api', __name__)
|
|
||||||
|
|
||||||
# Task endpoints
|
|
||||||
@task_api.route('/task/add', methods=['PUT'])
|
|
||||||
def add_task():
|
|
||||||
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')
|
|
||||||
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, 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'Task {name} added.'}), 201
|
|
||||||
|
|
||||||
@task_api.route('/task/<id>', methods=['GET'])
|
|
||||||
def get_task(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)))
|
|
||||||
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():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
ids_param = request.args.get('ids')
|
|
||||||
TaskQuery = Query()
|
|
||||||
tasks = task_db.search((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None))
|
|
||||||
if ids_param is not None:
|
|
||||||
if ids_param.strip() == '':
|
|
||||||
tasks = []
|
|
||||||
else:
|
|
||||||
ids = set(ids_param.split(','))
|
|
||||||
tasks = [task for task in tasks if task.get('id') in ids]
|
|
||||||
|
|
||||||
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 # Skip default if user version exists
|
|
||||||
filtered_tasks.append(t)
|
|
||||||
|
|
||||||
# Sort order:
|
|
||||||
# 1) good tasks first, then not-good tasks
|
|
||||||
# 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]
|
|
||||||
|
|
||||||
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(good_tasks) + sort_user_then_default(not_good_tasks)
|
|
||||||
|
|
||||||
return jsonify({'tasks': sorted_tasks}), 200
|
|
||||||
|
|
||||||
@task_api.route('/task/<id>', methods=['DELETE'])
|
|
||||||
def delete_task(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)
|
|
||||||
if not task:
|
|
||||||
return jsonify({'error': 'Task not found'}), 404
|
|
||||||
if task.get('user_id') is None:
|
|
||||||
import logging
|
|
||||||
logging.warning(f"Forbidden delete attempt on system task: id={id}, by user_id={user_id}")
|
|
||||||
return jsonify({'error': 'System tasks cannot be deleted.'}), 403
|
|
||||||
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
|
|
||||||
if removed:
|
|
||||||
# Cascade delete overrides for this task
|
|
||||||
deleted_count = delete_overrides_for_entity(id)
|
|
||||||
if deleted_count > 0:
|
|
||||||
import logging
|
|
||||||
logging.info(f"Cascade deleted {deleted_count} overrides for task {id}")
|
|
||||||
|
|
||||||
# remove the task id from any child's task list
|
|
||||||
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'Task {id} deleted.'}), 200
|
|
||||||
return jsonify({'error': 'Task not found'}), 404
|
|
||||||
|
|
||||||
@task_api.route('/task/<id>/edit', methods=['PUT'])
|
|
||||||
def edit_task(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)))
|
|
||||||
if not existing:
|
|
||||||
return jsonify({'error': 'Task not found'}), 404
|
|
||||||
|
|
||||||
task = Task.from_dict(existing)
|
|
||||||
is_dirty = False
|
|
||||||
|
|
||||||
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
|
|
||||||
task.name = name
|
|
||||||
is_dirty = True
|
|
||||||
|
|
||||||
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
|
|
||||||
task.points = points
|
|
||||||
is_dirty = True
|
|
||||||
|
|
||||||
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
|
|
||||||
task.is_good = is_good
|
|
||||||
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: # 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)
|
|
||||||
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
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
from flask import Blueprint, request, jsonify, current_app
|
|
||||||
from events.types.user_modified import UserModified
|
|
||||||
from models.user import User
|
|
||||||
from tinydb import Query
|
|
||||||
from db.db import users_db
|
|
||||||
import jwt
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
import utils.email_sender as email_sender
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from api.utils import get_validated_user_id, normalize_email, send_event_for_current_user
|
|
||||||
from api.error_codes import ACCOUNT_MARKED_FOR_DELETION, ALREADY_MARKED
|
|
||||||
from events.types.event_types import EventType
|
|
||||||
from events.types.event import Event
|
|
||||||
from events.types.profile_updated import ProfileUpdated
|
|
||||||
from utils.tracking_logger import log_tracking_event
|
|
||||||
from models.tracking_event import TrackingEvent
|
|
||||||
from db.tracking import insert_tracking_event
|
|
||||||
|
|
||||||
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'])
|
|
||||||
user_id = payload.get('user_id')
|
|
||||||
user_dict = users_db.get(UserQuery.id == user_id)
|
|
||||||
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_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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/profile', methods=['PUT'])
|
|
||||||
def update_profile():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
user = get_current_user()
|
|
||||||
if not user:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
|
||||||
data = request.get_json()
|
|
||||||
# Only allow first_name, last_name, image_id to be updated
|
|
||||||
first_name = data.get('first_name')
|
|
||||||
last_name = data.get('last_name')
|
|
||||||
image_id = data.get('image_id')
|
|
||||||
if first_name is not None:
|
|
||||||
user.first_name = first_name
|
|
||||||
if last_name is not None:
|
|
||||||
user.last_name = last_name
|
|
||||||
if image_id is not None:
|
|
||||||
user.image_id = image_id
|
|
||||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
|
||||||
|
|
||||||
# Create tracking event
|
|
||||||
metadata = {}
|
|
||||||
if first_name is not None:
|
|
||||||
metadata['first_name_updated'] = True
|
|
||||||
if last_name is not None:
|
|
||||||
metadata['last_name_updated'] = True
|
|
||||||
if image_id is not None:
|
|
||||||
metadata['image_updated'] = True
|
|
||||||
|
|
||||||
tracking_event = TrackingEvent.create_event(
|
|
||||||
user_id=user_id,
|
|
||||||
child_id=None, # No child for user profile
|
|
||||||
entity_type='user',
|
|
||||||
entity_id=user.id,
|
|
||||||
action='updated',
|
|
||||||
points_before=0, # Not relevant
|
|
||||||
points_after=0,
|
|
||||||
metadata=metadata
|
|
||||||
)
|
|
||||||
insert_tracking_event(tracking_event)
|
|
||||||
log_tracking_event(tracking_event)
|
|
||||||
|
|
||||||
# Send SSE event
|
|
||||||
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
|
|
||||||
|
|
||||||
return jsonify({'message': 'Profile updated'}), 200
|
|
||||||
|
|
||||||
@user_api.route('/user/image', methods=['PUT'])
|
|
||||||
def update_image():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
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
|
|
||||||
|
|
||||||
@user_api.route('/user/check-pin', methods=['POST'])
|
|
||||||
def check_pin():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
user = get_current_user()
|
|
||||||
if not user:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
|
||||||
data = request.get_json()
|
|
||||||
pin = data.get('pin')
|
|
||||||
if not pin:
|
|
||||||
return jsonify({'error': 'Missing pin'}), 400
|
|
||||||
if user.pin and pin == user.pin:
|
|
||||||
return jsonify({'valid': True}), 200
|
|
||||||
return jsonify({'valid': False}), 200
|
|
||||||
|
|
||||||
@user_api.route('/user/has-pin', methods=['GET'])
|
|
||||||
def has_pin():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
user = get_current_user()
|
|
||||||
if not user:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
|
||||||
return jsonify({'has_pin': bool(user.pin)}), 200
|
|
||||||
|
|
||||||
@user_api.route('/user/request-pin-setup', methods=['POST'])
|
|
||||||
def request_pin_setup():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
user = get_current_user()
|
|
||||||
if not user or not user.verified:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
|
||||||
# Generate 6-digit/character code
|
|
||||||
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
|
|
||||||
user.pin_setup_code = code
|
|
||||||
user.pin_setup_code_created = datetime.utcnow().isoformat()
|
|
||||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
|
||||||
# Send email
|
|
||||||
send_pin_setup_email(user.email, code)
|
|
||||||
return jsonify({'message': 'Verification code sent to your email.'}), 200
|
|
||||||
|
|
||||||
def send_pin_setup_email(email, code):
|
|
||||||
# Use the reusable email sender
|
|
||||||
email_sender.send_pin_setup_email(email, code)
|
|
||||||
|
|
||||||
@user_api.route('/user/verify-pin-setup', methods=['POST'])
|
|
||||||
def verify_pin_setup():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
user = get_current_user()
|
|
||||||
if not user or not user.verified:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
|
||||||
data = request.get_json()
|
|
||||||
code = data.get('code')
|
|
||||||
if not code:
|
|
||||||
return jsonify({'error': 'Missing code'}), 400
|
|
||||||
if not user.pin_setup_code or not user.pin_setup_code_created:
|
|
||||||
return jsonify({'error': 'No code requested'}), 400
|
|
||||||
# Check expiry (10 min)
|
|
||||||
created = datetime.fromisoformat(user.pin_setup_code_created)
|
|
||||||
if datetime.utcnow() > created + timedelta(minutes=10):
|
|
||||||
return jsonify({'error': 'Code expired'}), 400
|
|
||||||
if code.strip().upper() != user.pin_setup_code.upper():
|
|
||||||
return jsonify({'error': 'Invalid code'}), 400
|
|
||||||
return jsonify({'message': 'Code verified'}), 200
|
|
||||||
|
|
||||||
@user_api.route('/user/set-pin', methods=['POST'])
|
|
||||||
def set_pin():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
user = get_current_user()
|
|
||||||
if not user or not user.verified:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
|
||||||
data = request.get_json()
|
|
||||||
pin = data.get('pin')
|
|
||||||
if not pin or not pin.isdigit() or not (4 <= len(pin) <= 6):
|
|
||||||
return jsonify({'error': 'PIN must be 4-6 digits'}), 400
|
|
||||||
# Only allow if code was recently verified
|
|
||||||
if not user.pin_setup_code or not user.pin_setup_code_created:
|
|
||||||
return jsonify({'error': 'No code verified'}), 400
|
|
||||||
created = datetime.fromisoformat(user.pin_setup_code_created)
|
|
||||||
if datetime.utcnow() > created + timedelta(minutes=10):
|
|
||||||
return jsonify({'error': 'Code expired'}), 400
|
|
||||||
# Set pin, clear code
|
|
||||||
user.pin = pin
|
|
||||||
user.pin_setup_code = ''
|
|
||||||
user.pin_setup_code_created = None
|
|
||||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
|
||||||
return jsonify({'message': 'Parent PIN set'}), 200
|
|
||||||
|
|
||||||
@user_api.route('/user/mark-for-deletion', methods=['POST'])
|
|
||||||
def mark_for_deletion():
|
|
||||||
user_id = get_validated_user_id()
|
|
||||||
if not user_id:
|
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
|
||||||
user = get_current_user()
|
|
||||||
if not user:
|
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
|
||||||
|
|
||||||
# Validate email from request body
|
|
||||||
data = request.get_json()
|
|
||||||
email = data.get('email', '').strip()
|
|
||||||
if not email:
|
|
||||||
return jsonify({'error': 'Email is required', 'code': 'EMAIL_REQUIRED'}), 400
|
|
||||||
|
|
||||||
# Verify email matches the logged-in user - make sure to normalize the email address first
|
|
||||||
if normalize_email(email) != normalize_email(user.email):
|
|
||||||
return jsonify({'error': 'Email does not match your account', 'code': 'EMAIL_MISMATCH'}), 400
|
|
||||||
|
|
||||||
# Check if already marked
|
|
||||||
if user.marked_for_deletion:
|
|
||||||
return jsonify({'error': 'Account already marked for deletion', 'code': ALREADY_MARKED}), 400
|
|
||||||
|
|
||||||
# Mark for deletion
|
|
||||||
user.marked_for_deletion = True
|
|
||||||
user.marked_for_deletion_at = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
# Invalidate any outstanding verification/reset tokens so they cannot be used after marking
|
|
||||||
user.verify_token = None
|
|
||||||
user.verify_token_created = None
|
|
||||||
user.reset_token = None
|
|
||||||
user.reset_token_created = None
|
|
||||||
|
|
||||||
users_db.update(user.to_dict(), UserQuery.id == user.id)
|
|
||||||
|
|
||||||
# Trigger SSE event
|
|
||||||
send_event_for_current_user(Event(EventType.USER_MARKED_FOR_DELETION.value, UserModified(user.id, UserModified.OPERATION_DELETE)))
|
|
||||||
|
|
||||||
return jsonify({'success': True}), 200
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import jwt
|
|
||||||
import re
|
|
||||||
from db.db import users_db
|
|
||||||
from tinydb import Query
|
|
||||||
from flask import request, current_app, jsonify
|
|
||||||
|
|
||||||
from events.sse import send_event_to_user
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_email(email: str) -> str:
|
|
||||||
"""Normalize email for uniqueness checks (Gmail: remove dots and +aliases)."""
|
|
||||||
email = email.strip().lower()
|
|
||||||
if '@' not in email:
|
|
||||||
return email
|
|
||||||
local, domain = email.split('@', 1)
|
|
||||||
if domain in ('gmail.com', 'googlemail.com'):
|
|
||||||
local = local.split('+', 1)[0].replace('.', '')
|
|
||||||
return f"{local}@{domain}"
|
|
||||||
|
|
||||||
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'])
|
|
||||||
user_id = payload.get('user_id')
|
|
||||||
if not user_id:
|
|
||||||
return None
|
|
||||||
token_version = payload.get('token_version', 0)
|
|
||||||
user = users_db.get(Query().id == user_id)
|
|
||||||
if not user:
|
|
||||||
return None
|
|
||||||
if token_version != user.get('token_version', 0):
|
|
||||||
return None
|
|
||||||
return user_id
|
|
||||||
except jwt.InvalidTokenError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_validated_user_id():
|
|
||||||
user_id = get_current_user_id()
|
|
||||||
if not user_id or not users_db.get(Query().id == user_id):
|
|
||||||
return None
|
|
||||||
return user_id
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import os
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Account deletion threshold in hours
|
|
||||||
# Default: 720 hours (30 days)
|
|
||||||
# Minimum: 24 hours (1 day)
|
|
||||||
# Maximum: 720 hours (30 days)
|
|
||||||
|
|
||||||
try:
|
|
||||||
ACCOUNT_DELETION_THRESHOLD_HOURS = int(os.getenv('ACCOUNT_DELETION_THRESHOLD_HOURS', '720'))
|
|
||||||
except ValueError as e:
|
|
||||||
raise ValueError(
|
|
||||||
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be a valid integer. "
|
|
||||||
f"Invalid value: {os.getenv('ACCOUNT_DELETION_THRESHOLD_HOURS')}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
MIN_THRESHOLD_HOURS = 24
|
|
||||||
MAX_THRESHOLD_HOURS = 720
|
|
||||||
|
|
||||||
def validate_threshold(threshold_hours=None):
|
|
||||||
"""
|
|
||||||
Validate the account deletion threshold.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
threshold_hours: Optional threshold value to validate. If None, validates the module's global value.
|
|
||||||
|
|
||||||
Returns True if valid, raises ValueError if invalid.
|
|
||||||
"""
|
|
||||||
value = threshold_hours if threshold_hours is not None else ACCOUNT_DELETION_THRESHOLD_HOURS
|
|
||||||
|
|
||||||
if value < MIN_THRESHOLD_HOURS:
|
|
||||||
raise ValueError(
|
|
||||||
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be at least {MIN_THRESHOLD_HOURS} hours. "
|
|
||||||
f"Current value: {value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if value > MAX_THRESHOLD_HOURS:
|
|
||||||
raise ValueError(
|
|
||||||
f"ACCOUNT_DELETION_THRESHOLD_HOURS must be at most {MAX_THRESHOLD_HOURS} hours. "
|
|
||||||
f"Current value: {value}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Warn if threshold is less than 7 days (168 hours)
|
|
||||||
if value < 168:
|
|
||||||
logger.warning(
|
|
||||||
f"Account deletion threshold is set to {value} hours, "
|
|
||||||
"which is below the recommended minimum of 7 days (168 hours). "
|
|
||||||
"Users will have limited time to recover their accounts."
|
|
||||||
)
|
|
||||||
|
|
||||||
if threshold_hours is None:
|
|
||||||
# Only log this when validating the module's global value
|
|
||||||
logger.info(f"Account deletion threshold: {ACCOUNT_DELETION_THRESHOLD_HOURS} hours")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Validate on module import
|
|
||||||
validate_threshold()
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"""Helper functions for child override database operations."""
|
|
||||||
import logging
|
|
||||||
from typing import Optional, List
|
|
||||||
from tinydb import Query
|
|
||||||
from db.db import child_overrides_db
|
|
||||||
from models.child_override import ChildOverride
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def insert_override(override: ChildOverride) -> str:
|
|
||||||
"""
|
|
||||||
Insert or update an override. Only one override per (child_id, entity_id).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
override: ChildOverride instance to insert or update
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The override ID
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
OverrideQuery = Query()
|
|
||||||
existing = child_overrides_db.get(
|
|
||||||
(OverrideQuery.child_id == override.child_id) &
|
|
||||||
(OverrideQuery.entity_id == override.entity_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing override
|
|
||||||
override.touch() # Update timestamp
|
|
||||||
child_overrides_db.update(override.to_dict(), doc_ids=[existing.doc_id])
|
|
||||||
logger.info(f"Override updated: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
|
|
||||||
else:
|
|
||||||
# Insert new override
|
|
||||||
child_overrides_db.insert(override.to_dict())
|
|
||||||
logger.info(f"Override created: child={override.child_id}, entity={override.entity_id}, value={override.custom_value}")
|
|
||||||
|
|
||||||
return override.id
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to insert override: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def get_override(child_id: str, entity_id: str) -> Optional[ChildOverride]:
|
|
||||||
"""
|
|
||||||
Get override for a specific child and entity.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
child_id: Child ID
|
|
||||||
entity_id: Entity ID (task or reward)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ChildOverride instance or None if not found
|
|
||||||
"""
|
|
||||||
OverrideQuery = Query()
|
|
||||||
result = child_overrides_db.get(
|
|
||||||
(OverrideQuery.child_id == child_id) &
|
|
||||||
(OverrideQuery.entity_id == entity_id)
|
|
||||||
)
|
|
||||||
return ChildOverride.from_dict(result) if result else None
|
|
||||||
|
|
||||||
|
|
||||||
def get_overrides_for_child(child_id: str) -> List[ChildOverride]:
|
|
||||||
"""
|
|
||||||
Get all overrides for a specific child.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
child_id: Child ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of ChildOverride instances
|
|
||||||
"""
|
|
||||||
OverrideQuery = Query()
|
|
||||||
results = child_overrides_db.search(OverrideQuery.child_id == child_id)
|
|
||||||
return [ChildOverride.from_dict(r) for r in results]
|
|
||||||
|
|
||||||
|
|
||||||
def delete_override(child_id: str, entity_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Delete a specific override.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
child_id: Child ID
|
|
||||||
entity_id: Entity ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if deleted, False if not found
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
OverrideQuery = Query()
|
|
||||||
deleted = child_overrides_db.remove(
|
|
||||||
(OverrideQuery.child_id == child_id) &
|
|
||||||
(OverrideQuery.entity_id == entity_id)
|
|
||||||
)
|
|
||||||
if deleted:
|
|
||||||
logger.info(f"Override deleted: child={child_id}, entity={entity_id}")
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete override: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def delete_overrides_for_child(child_id: str) -> int:
|
|
||||||
"""
|
|
||||||
Delete all overrides for a child.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
child_id: Child ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Count of deleted overrides
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
OverrideQuery = Query()
|
|
||||||
deleted = child_overrides_db.remove(OverrideQuery.child_id == child_id)
|
|
||||||
count = len(deleted)
|
|
||||||
if count > 0:
|
|
||||||
logger.info(f"Overrides cascade deleted for child: child_id={child_id}, count={count}")
|
|
||||||
return count
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete overrides for child: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def delete_overrides_for_entity(entity_id: str) -> int:
|
|
||||||
"""
|
|
||||||
Delete all overrides for an entity.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
entity_id: Entity ID (task or reward)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Count of deleted overrides
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
OverrideQuery = Query()
|
|
||||||
deleted = child_overrides_db.remove(OverrideQuery.entity_id == entity_id)
|
|
||||||
count = len(deleted)
|
|
||||||
if count > 0:
|
|
||||||
logger.info(f"Overrides cascade deleted for entity: entity_id={entity_id}, count={count}")
|
|
||||||
return count
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete overrides for entity: {e}")
|
|
||||||
raise
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
"""Helper functions for tracking events database operations."""
|
|
||||||
import logging
|
|
||||||
from typing import Optional, List
|
|
||||||
from tinydb import Query
|
|
||||||
from db.db import tracking_events_db
|
|
||||||
from models.tracking_event import TrackingEvent, EntityType, ActionType
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def insert_tracking_event(event: TrackingEvent) -> str:
|
|
||||||
"""
|
|
||||||
Insert a tracking event into the database.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: TrackingEvent instance to insert
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The event ID
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tracking_events_db.insert(event.to_dict())
|
|
||||||
logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}")
|
|
||||||
return event.id
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to insert tracking event: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracking_events_by_child(
|
|
||||||
child_id: str,
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
entity_type: Optional[EntityType] = None,
|
|
||||||
action: Optional[ActionType] = None
|
|
||||||
) -> tuple[List[TrackingEvent], int]:
|
|
||||||
"""
|
|
||||||
Query tracking events for a specific child with optional filters.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
child_id: Child ID to filter by
|
|
||||||
limit: Maximum number of results (default 50, max 500)
|
|
||||||
offset: Number of results to skip
|
|
||||||
entity_type: Optional filter by entity type
|
|
||||||
action: Optional filter by action type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (list of TrackingEvent instances, total count)
|
|
||||||
"""
|
|
||||||
limit = min(limit, 500)
|
|
||||||
|
|
||||||
TrackingQuery = Query()
|
|
||||||
query_condition = TrackingQuery.child_id == child_id
|
|
||||||
|
|
||||||
if entity_type:
|
|
||||||
query_condition &= TrackingQuery.entity_type == entity_type
|
|
||||||
if action:
|
|
||||||
query_condition &= TrackingQuery.action == action
|
|
||||||
|
|
||||||
all_results = tracking_events_db.search(query_condition)
|
|
||||||
total = len(all_results)
|
|
||||||
|
|
||||||
# Sort by occurred_at desc, then created_at desc
|
|
||||||
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
|
|
||||||
|
|
||||||
paginated = all_results[offset:offset + limit]
|
|
||||||
events = [TrackingEvent.from_dict(r) for r in paginated]
|
|
||||||
|
|
||||||
return events, total
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracking_events_by_user(
|
|
||||||
user_id: str,
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
entity_type: Optional[EntityType] = None
|
|
||||||
) -> tuple[List[TrackingEvent], int]:
|
|
||||||
"""
|
|
||||||
Query tracking events for a specific user.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User ID to filter by
|
|
||||||
limit: Maximum number of results
|
|
||||||
offset: Number of results to skip
|
|
||||||
entity_type: Optional filter by entity type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (list of TrackingEvent instances, total count)
|
|
||||||
"""
|
|
||||||
limit = min(limit, 500)
|
|
||||||
|
|
||||||
TrackingQuery = Query()
|
|
||||||
query_condition = TrackingQuery.user_id == user_id
|
|
||||||
|
|
||||||
if entity_type:
|
|
||||||
query_condition &= TrackingQuery.entity_type == entity_type
|
|
||||||
|
|
||||||
all_results = tracking_events_db.search(query_condition)
|
|
||||||
total = len(all_results)
|
|
||||||
|
|
||||||
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
|
|
||||||
|
|
||||||
paginated = all_results[offset:offset + limit]
|
|
||||||
events = [TrackingEvent.from_dict(r) for r in paginated]
|
|
||||||
|
|
||||||
return events, total
|
|
||||||
|
|
||||||
|
|
||||||
def anonymize_tracking_events_for_user(user_id: str) -> int:
|
|
||||||
"""
|
|
||||||
Anonymize tracking events by setting user_id to None.
|
|
||||||
Called when a user is deleted.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User ID to anonymize
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of records anonymized
|
|
||||||
"""
|
|
||||||
TrackingQuery = Query()
|
|
||||||
result = tracking_events_db.update({'user_id': None}, TrackingQuery.user_id == user_id)
|
|
||||||
count = len(result) if result else 0
|
|
||||||
logger.info(f"Anonymized {count} tracking events for user {user_id}")
|
|
||||||
return count
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
from events.types.payload import Payload
|
|
||||||
|
|
||||||
|
|
||||||
class ChildOverrideDeletedPayload(Payload):
|
|
||||||
def __init__(self, child_id: str, entity_id: str, entity_type: str):
|
|
||||||
super().__init__({
|
|
||||||
'child_id': child_id,
|
|
||||||
'entity_id': entity_id,
|
|
||||||
'entity_type': entity_type
|
|
||||||
})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def child_id(self) -> str:
|
|
||||||
return self.get("child_id")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def entity_id(self) -> str:
|
|
||||||
return self.get("entity_id")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def entity_type(self) -> str:
|
|
||||||
return self.get("entity_type")
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from events.types.payload import Payload
|
|
||||||
from models.child_override import ChildOverride
|
|
||||||
|
|
||||||
|
|
||||||
class ChildOverrideSetPayload(Payload):
|
|
||||||
def __init__(self, override: ChildOverride):
|
|
||||||
super().__init__({
|
|
||||||
'override': override.to_dict()
|
|
||||||
})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def override(self) -> dict:
|
|
||||||
return self.get("override")
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from events.types.payload import Payload
|
|
||||||
|
|
||||||
|
|
||||||
class ProfileUpdated(Payload):
|
|
||||||
def __init__(self, user_id: str):
|
|
||||||
super().__init__({
|
|
||||||
'user_id': user_id,
|
|
||||||
})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_id(self) -> str:
|
|
||||||
return self.get("user_id")
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
from events.types.payload import Payload
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingEventCreated(Payload):
|
|
||||||
def __init__(self, tracking_event_id: str, child_id: str, entity_type: str, action: str):
|
|
||||||
super().__init__({
|
|
||||||
'tracking_event_id': tracking_event_id,
|
|
||||||
'child_id': child_id,
|
|
||||||
'entity_type': entity_type,
|
|
||||||
'action': action
|
|
||||||
})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tracking_event_id(self) -> str:
|
|
||||||
return self.get("tracking_event_id")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def child_id(self) -> str:
|
|
||||||
return self.get("child_id")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def entity_type(self) -> str:
|
|
||||||
return self.get("entity_type")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def action(self) -> str:
|
|
||||||
return self.get("action")
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from events.types.payload import Payload
|
|
||||||
|
|
||||||
|
|
||||||
class UserDeleted(Payload):
|
|
||||||
"""
|
|
||||||
Event payload for when a user account is deleted.
|
|
||||||
This event is broadcast only to admin users.
|
|
||||||
"""
|
|
||||||
def __init__(self, user_id: str, email: str, deleted_at: str):
|
|
||||||
super().__init__({
|
|
||||||
'user_id': user_id,
|
|
||||||
'email': email,
|
|
||||||
'deleted_at': deleted_at,
|
|
||||||
})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_id(self) -> str:
|
|
||||||
return self.get("user_id")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def email(self) -> str:
|
|
||||||
return self.get("email")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def deleted_at(self) -> str:
|
|
||||||
return self.get("deleted_at")
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
from events.types.payload import Payload
|
|
||||||
|
|
||||||
|
|
||||||
class UserModified(Payload):
|
|
||||||
OPERATION_ADD = "ADD"
|
|
||||||
OPERATION_EDIT = "EDIT"
|
|
||||||
OPERATION_DELETE = "DELETE"
|
|
||||||
def __init__(self, user_id: str, operation: str):
|
|
||||||
super().__init__({
|
|
||||||
'user_id': user_id,
|
|
||||||
'operation': operation,
|
|
||||||
})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_id(self) -> str:
|
|
||||||
return self.get("user_id")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def operation(self) -> str:
|
|
||||||
return self.get("operation")
|
|
||||||
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from typing import Literal
|
|
||||||
from models.base import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ChildOverride(BaseModel):
|
|
||||||
"""
|
|
||||||
Stores per-child customized points/cost for tasks, penalties, and rewards.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
child_id: ID of the child this override applies to
|
|
||||||
entity_id: ID of the task/penalty/reward being customized
|
|
||||||
entity_type: Type of entity ('task' or 'reward')
|
|
||||||
custom_value: Custom points (for tasks/penalties) or cost (for rewards)
|
|
||||||
"""
|
|
||||||
child_id: str
|
|
||||||
entity_id: str
|
|
||||||
entity_type: Literal['task', 'reward']
|
|
||||||
custom_value: int
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""Validate custom_value range and entity_type."""
|
|
||||||
if self.custom_value < 0 or self.custom_value > 10000:
|
|
||||||
raise ValueError("custom_value must be between 0 and 10000")
|
|
||||||
if self.entity_type not in ['task', 'reward']:
|
|
||||||
raise ValueError("entity_type must be 'task' or 'reward'")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, d: dict):
|
|
||||||
return cls(
|
|
||||||
child_id=d.get('child_id'),
|
|
||||||
entity_id=d.get('entity_id'),
|
|
||||||
entity_type=d.get('entity_type'),
|
|
||||||
custom_value=d.get('custom_value'),
|
|
||||||
id=d.get('id'),
|
|
||||||
created_at=d.get('created_at'),
|
|
||||||
updated_at=d.get('updated_at')
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
base = super().to_dict()
|
|
||||||
base.update({
|
|
||||||
'child_id': self.child_id,
|
|
||||||
'entity_id': self.entity_id,
|
|
||||||
'entity_type': self.entity_type,
|
|
||||||
'custom_value': self.custom_value
|
|
||||||
})
|
|
||||||
return base
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_override(
|
|
||||||
child_id: str,
|
|
||||||
entity_id: str,
|
|
||||||
entity_type: Literal['task', 'reward'],
|
|
||||||
custom_value: int
|
|
||||||
) -> 'ChildOverride':
|
|
||||||
"""Factory method to create a new override."""
|
|
||||||
return ChildOverride(
|
|
||||||
child_id=child_id,
|
|
||||||
entity_id=entity_id,
|
|
||||||
entity_type=entity_type,
|
|
||||||
custom_value=custom_value
|
|
||||||
)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Literal, Optional
|
|
||||||
from models.base import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
EntityType = Literal['task', 'reward', 'penalty']
|
|
||||||
ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled']
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TrackingEvent(BaseModel):
|
|
||||||
user_id: Optional[str]
|
|
||||||
child_id: str
|
|
||||||
entity_type: EntityType
|
|
||||||
entity_id: str
|
|
||||||
action: ActionType
|
|
||||||
points_before: int
|
|
||||||
points_after: int
|
|
||||||
delta: int
|
|
||||||
occurred_at: str # UTC ISO 8601 timestamp
|
|
||||||
metadata: Optional[dict] = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""Validate invariants after initialization."""
|
|
||||||
if self.delta != self.points_after - self.points_before:
|
|
||||||
raise ValueError(
|
|
||||||
f"Delta invariant violated: {self.delta} != {self.points_after} - {self.points_before}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, d: dict):
|
|
||||||
return cls(
|
|
||||||
user_id=d.get('user_id'),
|
|
||||||
child_id=d.get('child_id'),
|
|
||||||
entity_type=d.get('entity_type'),
|
|
||||||
entity_id=d.get('entity_id'),
|
|
||||||
action=d.get('action'),
|
|
||||||
points_before=d.get('points_before'),
|
|
||||||
points_after=d.get('points_after'),
|
|
||||||
delta=d.get('delta'),
|
|
||||||
occurred_at=d.get('occurred_at'),
|
|
||||||
metadata=d.get('metadata'),
|
|
||||||
id=d.get('id'),
|
|
||||||
created_at=d.get('created_at'),
|
|
||||||
updated_at=d.get('updated_at')
|
|
||||||
)
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
base = super().to_dict()
|
|
||||||
base.update({
|
|
||||||
'user_id': self.user_id,
|
|
||||||
'child_id': self.child_id,
|
|
||||||
'entity_type': self.entity_type,
|
|
||||||
'entity_id': self.entity_id,
|
|
||||||
'action': self.action,
|
|
||||||
'points_before': self.points_before,
|
|
||||||
'points_after': self.points_after,
|
|
||||||
'delta': self.delta,
|
|
||||||
'occurred_at': self.occurred_at,
|
|
||||||
'metadata': self.metadata
|
|
||||||
})
|
|
||||||
return base
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def create_event(
|
|
||||||
user_id: Optional[str],
|
|
||||||
child_id: str,
|
|
||||||
entity_type: EntityType,
|
|
||||||
entity_id: str,
|
|
||||||
action: ActionType,
|
|
||||||
points_before: int,
|
|
||||||
points_after: int,
|
|
||||||
metadata: Optional[dict] = None
|
|
||||||
) -> 'TrackingEvent':
|
|
||||||
"""Factory method to create a tracking event with server timestamp."""
|
|
||||||
delta = points_after - points_before
|
|
||||||
occurred_at = datetime.now(timezone.utc).isoformat()
|
|
||||||
|
|
||||||
return TrackingEvent(
|
|
||||||
user_id=user_id,
|
|
||||||
child_id=child_id,
|
|
||||||
entity_type=entity_type,
|
|
||||||
entity_id=entity_id,
|
|
||||||
action=action,
|
|
||||||
points_before=points_before,
|
|
||||||
points_after=points_after,
|
|
||||||
delta=delta,
|
|
||||||
occurred_at=occurred_at,
|
|
||||||
metadata=metadata
|
|
||||||
)
|
|
||||||
6
backend/package-lock.json
generated
6
backend/package-lock.json
generated
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "backend",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# Backend Scripts
|
|
||||||
|
|
||||||
Utility scripts for backend management tasks.
|
|
||||||
|
|
||||||
## create_admin.py
|
|
||||||
|
|
||||||
Creates an admin user account with elevated privileges.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python scripts/create_admin.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The script will prompt you for:
|
|
||||||
|
|
||||||
- Email address
|
|
||||||
- Password (minimum 8 characters)
|
|
||||||
- First name
|
|
||||||
- Last name
|
|
||||||
|
|
||||||
### Security Notes
|
|
||||||
|
|
||||||
- Admin users can only be created through this script or direct database manipulation
|
|
||||||
- The admin role cannot be assigned through the signup API
|
|
||||||
- Existing email addresses will be rejected
|
|
||||||
- Passwords are hashed using werkzeug's secure hash algorithm
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ python scripts/create_admin.py
|
|
||||||
=== Create Admin User ===
|
|
||||||
|
|
||||||
Email: admin@example.com
|
|
||||||
Password: ********
|
|
||||||
First name: Admin
|
|
||||||
Last name: User
|
|
||||||
|
|
||||||
Create admin user 'admin@example.com'? (yes/no): yes
|
|
||||||
✓ Admin user created successfully!
|
|
||||||
Email: admin@example.com
|
|
||||||
Name: Admin User
|
|
||||||
Role: admin
|
|
||||||
```
|
|
||||||
|
|
||||||
## hash_passwords.py
|
|
||||||
|
|
||||||
Migrates existing plain text passwords in the database to secure hashed passwords.
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python scripts/hash_passwords.py
|
|
||||||
```
|
|
||||||
|
|
||||||
### Description
|
|
||||||
|
|
||||||
This script should be run once after deploying password hashing to convert any existing plain text passwords to secure hashes. It:
|
|
||||||
|
|
||||||
- Reads all users from the database
|
|
||||||
- Identifies plain text passwords (those not starting with hash prefixes)
|
|
||||||
- Hashes plain text passwords using werkzeug's secure algorithm
|
|
||||||
- Updates user records in the database
|
|
||||||
- Skips already-hashed passwords
|
|
||||||
- Reports the number of users updated
|
|
||||||
|
|
||||||
### Security Notes
|
|
||||||
|
|
||||||
- Run this script only once after password hashing deployment
|
|
||||||
- Execute in a secure environment (admin access only)
|
|
||||||
- Verify a few users can log in after migration
|
|
||||||
- Delete or secure the script after use to prevent accidental re-execution
|
|
||||||
- The script is idempotent - running it multiple times is safe but unnecessary
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ python scripts/hash_passwords.py
|
|
||||||
Password already hashed for user admin@example.com
|
|
||||||
Hashed password for user user1@example.com
|
|
||||||
Hashed password for user user2@example.com
|
|
||||||
Migration complete. Updated 2 users.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
These scripts require the backend virtual environment to be activated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
.venv\Scripts\activate
|
|
||||||
|
|
||||||
# Linux/Mac
|
|
||||||
source .venv/bin/activate
|
|
||||||
```
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""
|
|
||||||
Script to create an admin user account.
|
|
||||||
Usage: python backend/scripts/create_admin.py
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
||||||
|
|
||||||
from db.db import users_db
|
|
||||||
from models.user import User
|
|
||||||
from werkzeug.security import generate_password_hash
|
|
||||||
from tinydb import Query
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
def create_admin_user(email: str, password: str, first_name: str, last_name: str):
|
|
||||||
"""Create an admin user account."""
|
|
||||||
|
|
||||||
# Check if user already exists
|
|
||||||
Query_ = Query()
|
|
||||||
existing_user = users_db.get(Query_.email == email)
|
|
||||||
|
|
||||||
if existing_user:
|
|
||||||
print(f"Error: User with email {email} already exists")
|
|
||||||
return False
|
|
||||||
|
|
||||||
admin = User(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
email=email,
|
|
||||||
first_name=first_name,
|
|
||||||
last_name=last_name,
|
|
||||||
password=generate_password_hash(password),
|
|
||||||
verified=True,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
|
|
||||||
users_db.insert(admin.to_dict())
|
|
||||||
print(f"✓ Admin user created successfully!")
|
|
||||||
print(f" Email: {email}")
|
|
||||||
print(f" Name: {first_name} {last_name}")
|
|
||||||
print(f" Role: admin")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
print("=== Create Admin User ===\n")
|
|
||||||
|
|
||||||
email = input("Email: ").strip()
|
|
||||||
password = input("Password: ").strip()
|
|
||||||
first_name = input("First name: ").strip()
|
|
||||||
last_name = input("Last name: ").strip()
|
|
||||||
|
|
||||||
if not all([email, password, first_name, last_name]):
|
|
||||||
print("Error: All fields are required")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if len(password) < 8:
|
|
||||||
print("Error: Password must be at least 8 characters")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
confirm = input(f"\nCreate admin user '{email}'? (yes/no): ").strip().lower()
|
|
||||||
|
|
||||||
if confirm == 'yes':
|
|
||||||
create_admin_user(email, password, first_name, last_name)
|
|
||||||
else:
|
|
||||||
print("Cancelled")
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Script to hash existing plain text passwords in the database.
|
|
||||||
Run this once after deploying password hashing to migrate existing users.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
||||||
|
|
||||||
from werkzeug.security import generate_password_hash
|
|
||||||
from tinydb import Query
|
|
||||||
from db.db import users_db
|
|
||||||
from models.user import User
|
|
||||||
|
|
||||||
def main():
|
|
||||||
users = users_db.all()
|
|
||||||
updated_count = 0
|
|
||||||
|
|
||||||
for user_dict in users:
|
|
||||||
user = User.from_dict(user_dict)
|
|
||||||
# Check if password is already hashed (starts with scrypt: or $pbkdf2-sha256$)
|
|
||||||
if not (user.password.startswith('scrypt:') or user.password.startswith('$pbkdf2-sha256$')):
|
|
||||||
# Hash the plain text password
|
|
||||||
user.password = generate_password_hash(user.password)
|
|
||||||
# Update in database
|
|
||||||
users_db.update(user.to_dict(), Query().id == user.id)
|
|
||||||
updated_count += 1
|
|
||||||
print(f"Hashed password for user {user.email}")
|
|
||||||
else:
|
|
||||||
print(f"Password already hashed for user {user.email}")
|
|
||||||
|
|
||||||
print(f"Migration complete. Updated {updated_count} users.")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
from tinydb import Query
|
|
||||||
main()
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import os
|
|
||||||
os.environ['DB_ENV'] = 'test'
|
|
||||||
import sys
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
# Ensure backend root is in sys.path for imports like 'config.paths'
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def set_test_db_env():
|
|
||||||
os.environ['DB_ENV'] = 'test'
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
import pytest
|
|
||||||
import jwt
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
# Set up path and environment before imports
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
||||||
os.environ['DB_ENV'] = 'test'
|
|
||||||
|
|
||||||
from main import app
|
|
||||||
from models.user import User
|
|
||||||
from db.db import users_db
|
|
||||||
from config.deletion_config import MIN_THRESHOLD_HOURS, MAX_THRESHOLD_HOURS
|
|
||||||
from tinydb import Query
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
"""Create test client."""
|
|
||||||
app.config['TESTING'] = True
|
|
||||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
|
||||||
with app.test_client() as client:
|
|
||||||
yield client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def admin_user():
|
|
||||||
"""Create admin user and return auth token."""
|
|
||||||
users_db.truncate()
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
id='admin_user',
|
|
||||||
email='admin@example.com',
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
marked_for_deletion=False,
|
|
||||||
marked_for_deletion_at=None,
|
|
||||||
deletion_in_progress=False,
|
|
||||||
deletion_attempted_at=None,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
users_db.insert(user.to_dict())
|
|
||||||
|
|
||||||
# Create JWT token
|
|
||||||
token = jwt.encode({'user_id': 'admin_user'}, 'supersecretkey', algorithm='HS256')
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def setup_deletion_queue():
|
|
||||||
"""Set up test users in deletion queue."""
|
|
||||||
users_db.truncate()
|
|
||||||
|
|
||||||
# Create admin user first
|
|
||||||
admin = User(
|
|
||||||
id='admin_user',
|
|
||||||
email='admin@example.com',
|
|
||||||
first_name='Admin',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
marked_for_deletion=False,
|
|
||||||
marked_for_deletion_at=None,
|
|
||||||
deletion_in_progress=False,
|
|
||||||
deletion_attempted_at=None,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
users_db.insert(admin.to_dict())
|
|
||||||
|
|
||||||
# User due for deletion
|
|
||||||
user1 = User(
|
|
||||||
id='user1',
|
|
||||||
email='user1@example.com',
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
marked_for_deletion=True,
|
|
||||||
marked_for_deletion_at=(datetime.now() - timedelta(hours=800)).isoformat(),
|
|
||||||
deletion_in_progress=False,
|
|
||||||
deletion_attempted_at=None
|
|
||||||
)
|
|
||||||
users_db.insert(user1.to_dict())
|
|
||||||
|
|
||||||
# User not yet due
|
|
||||||
user2 = User(
|
|
||||||
id='user2',
|
|
||||||
email='user2@example.com',
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
marked_for_deletion=True,
|
|
||||||
marked_for_deletion_at=(datetime.now() - timedelta(hours=100)).isoformat(),
|
|
||||||
deletion_in_progress=False,
|
|
||||||
deletion_attempted_at=None
|
|
||||||
)
|
|
||||||
users_db.insert(user2.to_dict())
|
|
||||||
|
|
||||||
# User with deletion in progress
|
|
||||||
user3 = User(
|
|
||||||
id='user3',
|
|
||||||
email='user3@example.com',
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
marked_for_deletion=True,
|
|
||||||
marked_for_deletion_at=(datetime.now() - timedelta(hours=850)).isoformat(),
|
|
||||||
deletion_in_progress=True,
|
|
||||||
deletion_attempted_at=datetime.now().isoformat()
|
|
||||||
)
|
|
||||||
users_db.insert(user3.to_dict())
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetDeletionQueue:
|
|
||||||
"""Tests for GET /admin/deletion-queue endpoint."""
|
|
||||||
|
|
||||||
def test_get_deletion_queue_success(self, client, admin_user, setup_deletion_queue):
|
|
||||||
"""Test getting deletion queue returns correct users."""
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.get('/admin/deletion-queue')
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
|
|
||||||
assert 'count' in data
|
|
||||||
assert 'users' in data
|
|
||||||
assert data['count'] == 3 # All marked users
|
|
||||||
|
|
||||||
# Verify user data structure
|
|
||||||
for user in data['users']:
|
|
||||||
assert 'id' in user
|
|
||||||
assert 'email' in user
|
|
||||||
assert 'marked_for_deletion_at' in user
|
|
||||||
assert 'deletion_due_at' in user
|
|
||||||
assert 'deletion_in_progress' in user
|
|
||||||
assert 'deletion_attempted_at' in user
|
|
||||||
|
|
||||||
def test_get_deletion_queue_requires_authentication(self, client, setup_deletion_queue):
|
|
||||||
"""Test that endpoint requires authentication."""
|
|
||||||
response = client.get('/admin/deletion-queue')
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
assert data['code'] == 'AUTH_REQUIRED'
|
|
||||||
|
|
||||||
def test_get_deletion_queue_invalid_token(self, client, setup_deletion_queue):
|
|
||||||
"""Test that invalid token is rejected."""
|
|
||||||
client.set_cookie('token', 'invalid_token')
|
|
||||||
response = client.get('/admin/deletion-queue')
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
# Note: Flask test client doesn't actually parse JWT, so it returns AUTH_REQUIRED
|
|
||||||
# In production, invalid tokens would be caught by JWT decode
|
|
||||||
|
|
||||||
def test_get_deletion_queue_expired_token(self, client, setup_deletion_queue):
|
|
||||||
"""Test that expired token is rejected."""
|
|
||||||
# Create expired token
|
|
||||||
expired_token = jwt.encode(
|
|
||||||
{'user_id': 'admin_user', 'exp': datetime.now() - timedelta(hours=1)},
|
|
||||||
'supersecretkey',
|
|
||||||
algorithm='HS256'
|
|
||||||
)
|
|
||||||
|
|
||||||
client.set_cookie('token', expired_token)
|
|
||||||
response = client.get('/admin/deletion-queue')
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
assert data['code'] == 'TOKEN_EXPIRED'
|
|
||||||
|
|
||||||
def test_get_deletion_queue_empty(self, client, admin_user):
|
|
||||||
"""Test getting deletion queue when empty."""
|
|
||||||
users_db.truncate()
|
|
||||||
|
|
||||||
# Re-create admin user
|
|
||||||
admin = User(
|
|
||||||
id='admin_user',
|
|
||||||
email='admin@example.com',
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
marked_for_deletion=False,
|
|
||||||
marked_for_deletion_at=None,
|
|
||||||
deletion_in_progress=False,
|
|
||||||
deletion_attempted_at=None,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
users_db.insert(admin.to_dict())
|
|
||||||
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.get('/admin/deletion-queue')
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert data['count'] == 0
|
|
||||||
assert len(data['users']) == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetDeletionThreshold:
|
|
||||||
"""Tests for GET /admin/deletion-threshold endpoint."""
|
|
||||||
|
|
||||||
def test_get_threshold_success(self, client, admin_user):
|
|
||||||
"""Test getting current threshold configuration."""
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.get('/admin/deletion-threshold')
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
|
|
||||||
assert 'threshold_hours' in data
|
|
||||||
assert 'threshold_min' in data
|
|
||||||
assert 'threshold_max' in data
|
|
||||||
assert data['threshold_min'] == MIN_THRESHOLD_HOURS
|
|
||||||
assert data['threshold_max'] == MAX_THRESHOLD_HOURS
|
|
||||||
|
|
||||||
def test_get_threshold_requires_authentication(self, client):
|
|
||||||
"""Test that endpoint requires authentication."""
|
|
||||||
response = client.get('/admin/deletion-threshold')
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
data = response.get_json()
|
|
||||||
assert data['code'] == 'AUTH_REQUIRED'
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateDeletionThreshold:
|
|
||||||
"""Tests for PUT /admin/deletion-threshold endpoint."""
|
|
||||||
|
|
||||||
def test_update_threshold_success(self, client, admin_user):
|
|
||||||
"""Test updating threshold with valid value."""
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.put(
|
|
||||||
'/admin/deletion-threshold',
|
|
||||||
json={'threshold_hours': 168}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'message' in data
|
|
||||||
assert data['threshold_hours'] == 168
|
|
||||||
|
|
||||||
def test_update_threshold_validates_minimum(self, client, admin_user):
|
|
||||||
"""Test that threshold below minimum is rejected."""
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.put(
|
|
||||||
'/admin/deletion-threshold',
|
|
||||||
json={'threshold_hours': 23}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
assert data['code'] == 'THRESHOLD_TOO_LOW'
|
|
||||||
|
|
||||||
def test_update_threshold_validates_maximum(self, client, admin_user):
|
|
||||||
"""Test that threshold above maximum is rejected."""
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.put(
|
|
||||||
'/admin/deletion-threshold',
|
|
||||||
json={'threshold_hours': 721}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
assert data['code'] == 'THRESHOLD_TOO_HIGH'
|
|
||||||
|
|
||||||
def test_update_threshold_missing_value(self, client, admin_user):
|
|
||||||
"""Test that missing threshold value is rejected."""
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.put(
|
|
||||||
'/admin/deletion-threshold',
|
|
||||||
json={}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
assert data['code'] == 'MISSING_THRESHOLD'
|
|
||||||
|
|
||||||
def test_update_threshold_invalid_type(self, client, admin_user):
|
|
||||||
"""Test that non-integer threshold is rejected."""
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.put(
|
|
||||||
'/admin/deletion-threshold',
|
|
||||||
json={'threshold_hours': 'invalid'}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
assert data['code'] == 'INVALID_TYPE'
|
|
||||||
|
|
||||||
def test_update_threshold_requires_authentication(self, client):
|
|
||||||
"""Test that endpoint requires authentication."""
|
|
||||||
response = client.put(
|
|
||||||
'/admin/deletion-threshold',
|
|
||||||
json={'threshold_hours': 168}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
class TestTriggerDeletionQueue:
|
|
||||||
"""Tests for POST /admin/deletion-queue/trigger endpoint."""
|
|
||||||
|
|
||||||
def test_trigger_deletion_success(self, client, admin_user, setup_deletion_queue):
|
|
||||||
"""Test manually triggering deletion queue."""
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.post('/admin/deletion-queue/trigger')
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
|
|
||||||
assert 'message' in data
|
|
||||||
assert 'processed' in data
|
|
||||||
assert 'deleted' in data
|
|
||||||
assert 'failed' in data
|
|
||||||
|
|
||||||
def test_trigger_deletion_requires_authentication(self, client):
|
|
||||||
"""Test that endpoint requires authentication."""
|
|
||||||
response = client.post('/admin/deletion-queue/trigger')
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
data = response.get_json()
|
|
||||||
assert data['code'] == 'AUTH_REQUIRED'
|
|
||||||
|
|
||||||
def test_trigger_deletion_with_empty_queue(self, client, admin_user):
|
|
||||||
"""Test triggering deletion with empty queue."""
|
|
||||||
users_db.truncate()
|
|
||||||
|
|
||||||
# Re-create admin user
|
|
||||||
admin = User(
|
|
||||||
id='admin_user',
|
|
||||||
email='admin@example.com',
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
marked_for_deletion=False,
|
|
||||||
marked_for_deletion_at=None,
|
|
||||||
deletion_in_progress=False,
|
|
||||||
deletion_attempted_at=None,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
users_db.insert(admin.to_dict())
|
|
||||||
|
|
||||||
client.set_cookie('token', admin_user)
|
|
||||||
response = client.post('/admin/deletion-queue/trigger')
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert data['processed'] == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestAdminRoleValidation:
|
|
||||||
"""Tests for admin role validation."""
|
|
||||||
|
|
||||||
def test_non_admin_user_access(self, client):
|
|
||||||
"""
|
|
||||||
Test that non-admin users cannot access admin endpoints.
|
|
||||||
"""
|
|
||||||
users_db.truncate()
|
|
||||||
|
|
||||||
# Create non-admin user (role='user')
|
|
||||||
user = User(
|
|
||||||
id='regular_user',
|
|
||||||
email='user@example.com',
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
marked_for_deletion=False,
|
|
||||||
marked_for_deletion_at=None,
|
|
||||||
deletion_in_progress=False,
|
|
||||||
deletion_attempted_at=None,
|
|
||||||
role='user'
|
|
||||||
)
|
|
||||||
users_db.insert(user.to_dict())
|
|
||||||
|
|
||||||
# Create token for non-admin
|
|
||||||
token = jwt.encode({'user_id': 'regular_user'}, 'supersecretkey', algorithm='HS256')
|
|
||||||
|
|
||||||
client.set_cookie('token', token)
|
|
||||||
response = client.get('/admin/deletion-queue')
|
|
||||||
|
|
||||||
# Should return 403 Forbidden
|
|
||||||
assert response.status_code == 403
|
|
||||||
data = response.get_json()
|
|
||||||
assert data['code'] == 'ADMIN_REQUIRED'
|
|
||||||
assert 'Admin access required' in data['error']
|
|
||||||
|
|
||||||
def test_admin_user_access(self, client):
|
|
||||||
"""
|
|
||||||
Test that admin users can access admin endpoints.
|
|
||||||
"""
|
|
||||||
users_db.truncate()
|
|
||||||
|
|
||||||
# Create admin user (role='admin')
|
|
||||||
admin = User(
|
|
||||||
id='admin_user',
|
|
||||||
email='admin@example.com',
|
|
||||||
first_name='Admin',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
marked_for_deletion=False,
|
|
||||||
marked_for_deletion_at=None,
|
|
||||||
deletion_in_progress=False,
|
|
||||||
deletion_attempted_at=None,
|
|
||||||
role='admin'
|
|
||||||
)
|
|
||||||
users_db.insert(admin.to_dict())
|
|
||||||
|
|
||||||
# Create token for admin
|
|
||||||
token = jwt.encode({'user_id': 'admin_user'}, 'supersecretkey', algorithm='HS256')
|
|
||||||
|
|
||||||
client.set_cookie('token', token)
|
|
||||||
response = client.get('/admin/deletion-queue')
|
|
||||||
|
|
||||||
# Should succeed
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
def test_update_threshold_requires_admin(self, client):
|
|
||||||
"""
|
|
||||||
Test that updating deletion threshold requires admin role.
|
|
||||||
"""
|
|
||||||
users_db.truncate()
|
|
||||||
|
|
||||||
# Create non-admin user
|
|
||||||
user = User(
|
|
||||||
id='regular_user',
|
|
||||||
email='user@example.com',
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
password='hash',
|
|
||||||
role='user'
|
|
||||||
)
|
|
||||||
users_db.insert(user.to_dict())
|
|
||||||
|
|
||||||
token = jwt.encode({'user_id': 'regular_user'}, 'supersecretkey', algorithm='HS256')
|
|
||||||
|
|
||||||
client.set_cookie('token', token)
|
|
||||||
response = client.put('/admin/deletion-threshold', json={'threshold_hours': 168})
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
data = response.get_json()
|
|
||||||
assert data['code'] == 'ADMIN_REQUIRED'
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
|
||||||
from flask import Flask
|
|
||||||
from api.auth_api import auth_api
|
|
||||||
from db.db import users_db
|
|
||||||
from tinydb import Query
|
|
||||||
from models.user import User
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
"""Setup Flask test client with auth blueprint."""
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
|
||||||
app.config['TESTING'] = True
|
|
||||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
|
||||||
app.config['FRONTEND_URL'] = 'http://localhost:5173'
|
|
||||||
with app.test_client() as client:
|
|
||||||
yield client
|
|
||||||
|
|
||||||
def test_signup_hashes_password(client):
|
|
||||||
"""Test that signup hashes the password."""
|
|
||||||
# Clean up any existing user
|
|
||||||
users_db.remove(Query().email == 'test@example.com')
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'first_name': 'Test',
|
|
||||||
'last_name': 'User',
|
|
||||||
'email': 'test@example.com',
|
|
||||||
'password': 'password123'
|
|
||||||
}
|
|
||||||
response = client.post('/auth/signup', json=data)
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
# Check that password is hashed in DB
|
|
||||||
user_dict = users_db.get(Query().email == 'test@example.com')
|
|
||||||
assert user_dict is not None
|
|
||||||
assert user_dict['password'].startswith('scrypt:')
|
|
||||||
|
|
||||||
def test_login_with_correct_password(client):
|
|
||||||
"""Test login succeeds with correct password."""
|
|
||||||
# Clean up and create a user with hashed password
|
|
||||||
users_db.remove(Query().email == 'test@example.com')
|
|
||||||
hashed_pw = generate_password_hash('password123')
|
|
||||||
user = User(
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
email='test@example.com',
|
|
||||||
password=hashed_pw,
|
|
||||||
verified=True
|
|
||||||
)
|
|
||||||
users_db.insert(user.to_dict())
|
|
||||||
|
|
||||||
data = {'email': 'test@example.com', 'password': 'password123'}
|
|
||||||
response = client.post('/auth/login', json=data)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert 'token' in response.headers.get('Set-Cookie', '')
|
|
||||||
|
|
||||||
def test_login_with_incorrect_password(client):
|
|
||||||
"""Test login fails with incorrect password."""
|
|
||||||
# Clean up and create a user with hashed password
|
|
||||||
users_db.remove(Query().email == 'test@example.com')
|
|
||||||
hashed_pw = generate_password_hash('password123')
|
|
||||||
user = User(
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
email='test@example.com',
|
|
||||||
password=hashed_pw,
|
|
||||||
verified=True
|
|
||||||
)
|
|
||||||
users_db.insert(user.to_dict())
|
|
||||||
|
|
||||||
data = {'email': 'test@example.com', 'password': 'wrongpassword'}
|
|
||||||
response = client.post('/auth/login', json=data)
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json['code'] == 'INVALID_CREDENTIALS'
|
|
||||||
|
|
||||||
def test_reset_password_hashes_new_password(client):
|
|
||||||
"""Test that reset-password hashes the new password."""
|
|
||||||
# Clean up and create a user with reset token
|
|
||||||
users_db.remove(Query().email == 'test@example.com')
|
|
||||||
user = User(
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
email='test@example.com',
|
|
||||||
password=generate_password_hash('oldpassword'),
|
|
||||||
verified=True,
|
|
||||||
reset_token='validtoken',
|
|
||||||
reset_token_created=datetime.utcnow().isoformat()
|
|
||||||
)
|
|
||||||
users_db.insert(user.to_dict())
|
|
||||||
|
|
||||||
data = {'token': 'validtoken', 'password': 'newpassword123'}
|
|
||||||
response = client.post('/auth/reset-password', json=data)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Check that password is hashed in DB
|
|
||||||
user_dict = users_db.get(Query().email == 'test@example.com')
|
|
||||||
assert user_dict is not None
|
|
||||||
assert user_dict['password'].startswith('scrypt:')
|
|
||||||
assert check_password_hash(user_dict['password'], 'newpassword123')
|
|
||||||
|
|
||||||
|
|
||||||
def test_reset_password_invalidates_existing_jwt(client):
|
|
||||||
users_db.remove(Query().email == 'test@example.com')
|
|
||||||
user = User(
|
|
||||||
first_name='Test',
|
|
||||||
last_name='User',
|
|
||||||
email='test@example.com',
|
|
||||||
password=generate_password_hash('oldpassword123'),
|
|
||||||
verified=True,
|
|
||||||
reset_token='validtoken2',
|
|
||||||
reset_token_created=datetime.utcnow().isoformat(),
|
|
||||||
)
|
|
||||||
users_db.insert(user.to_dict())
|
|
||||||
|
|
||||||
login_response = client.post('/auth/login', json={'email': 'test@example.com', 'password': 'oldpassword123'})
|
|
||||||
assert login_response.status_code == 200
|
|
||||||
login_cookie = login_response.headers.get('Set-Cookie', '')
|
|
||||||
assert 'token=' in login_cookie
|
|
||||||
old_token = login_cookie.split('token=', 1)[1].split(';', 1)[0]
|
|
||||||
assert old_token
|
|
||||||
|
|
||||||
reset_response = client.post('/auth/reset-password', json={'token': 'validtoken2', 'password': 'newpassword123'})
|
|
||||||
assert reset_response.status_code == 200
|
|
||||||
reset_cookie = reset_response.headers.get('Set-Cookie', '')
|
|
||||||
assert 'token=' in reset_cookie
|
|
||||||
|
|
||||||
# Set the old token as a cookie and test that it's now invalid
|
|
||||||
client.set_cookie('token', old_token)
|
|
||||||
me_response = client.get('/auth/me')
|
|
||||||
assert me_response.status_code == 401
|
|
||||||
assert me_response.json['code'] == 'INVALID_TOKEN'
|
|
||||||
|
|
||||||
def test_migration_script_hashes_plain_text_passwords():
|
|
||||||
"""Test the migration script hashes plain text passwords."""
|
|
||||||
# Clean up
|
|
||||||
users_db.remove(Query().email == 'test1@example.com')
|
|
||||||
users_db.remove(Query().email == 'test2@example.com')
|
|
||||||
|
|
||||||
# Create users with plain text passwords
|
|
||||||
user1 = User(
|
|
||||||
first_name='Test1',
|
|
||||||
last_name='User',
|
|
||||||
email='test1@example.com',
|
|
||||||
password='plaintext1',
|
|
||||||
verified=True
|
|
||||||
)
|
|
||||||
already_hashed = generate_password_hash('alreadyhashed')
|
|
||||||
user2 = User(
|
|
||||||
first_name='Test2',
|
|
||||||
last_name='User',
|
|
||||||
email='test2@example.com',
|
|
||||||
password=already_hashed, # Already hashed
|
|
||||||
verified=True
|
|
||||||
)
|
|
||||||
users_db.insert(user1.to_dict())
|
|
||||||
users_db.insert(user2.to_dict())
|
|
||||||
|
|
||||||
# Run migration script
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
||||||
from scripts.hash_passwords import main
|
|
||||||
main()
|
|
||||||
|
|
||||||
# Check user1 password is now hashed
|
|
||||||
user1_dict = users_db.get(Query().email == 'test1@example.com')
|
|
||||||
assert user1_dict['password'].startswith('scrypt:')
|
|
||||||
assert check_password_hash(user1_dict['password'], 'plaintext1')
|
|
||||||
|
|
||||||
# Check user2 password unchanged
|
|
||||||
user2_dict = users_db.get(Query().email == 'test2@example.com')
|
|
||||||
assert user2_dict['password'] == already_hashed
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from flask import Flask
|
|
||||||
from api.auth_api import auth_api
|
|
||||||
from db.db import users_db
|
|
||||||
from tinydb import Query
|
|
||||||
from models.user import User
|
|
||||||
from werkzeug.security import generate_password_hash
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import jwt
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
|
||||||
app.config['TESTING'] = True
|
|
||||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
|
||||||
with app.test_client() as client:
|
|
||||||
yield client
|
|
||||||
|
|
||||||
def setup_marked_user(email, verified=False, verify_token=None, reset_token=None):
|
|
||||||
users_db.remove(Query().email == email)
|
|
||||||
user = User(
|
|
||||||
first_name='Marked',
|
|
||||||
last_name='User',
|
|
||||||
email=email,
|
|
||||||
password=generate_password_hash('password123'),
|
|
||||||
verified=verified,
|
|
||||||
marked_for_deletion=True,
|
|
||||||
verify_token=verify_token,
|
|
||||||
verify_token_created=datetime.utcnow().isoformat() if verify_token else None,
|
|
||||||
reset_token=reset_token,
|
|
||||||
reset_token_created=datetime.utcnow().isoformat() if reset_token else None
|
|
||||||
)
|
|
||||||
users_db.insert(user.to_dict())
|
|
||||||
|
|
||||||
|
|
||||||
def test_signup_marked_for_deletion(client):
|
|
||||||
setup_marked_user('marked@example.com')
|
|
||||||
data = {
|
|
||||||
'first_name': 'Marked',
|
|
||||||
'last_name': 'User',
|
|
||||||
'email': 'marked@example.com',
|
|
||||||
'password': 'password123'
|
|
||||||
}
|
|
||||||
response = client.post('/auth/signup', json=data)
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
|
||||||
|
|
||||||
def test_verify_marked_for_deletion(client):
|
|
||||||
setup_marked_user('marked2@example.com', verify_token='verifytoken123')
|
|
||||||
response = client.get('/auth/verify', query_string={'token': 'verifytoken123'})
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
|
||||||
|
|
||||||
def test_request_password_reset_marked_for_deletion(client):
|
|
||||||
setup_marked_user('marked3@example.com')
|
|
||||||
response = client.post('/auth/request-password-reset', json={'email': 'marked3@example.com'})
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
|
||||||
|
|
||||||
def test_me_marked_for_deletion(client):
|
|
||||||
email = 'marked4@example.com'
|
|
||||||
setup_marked_user(email, verified=True)
|
|
||||||
|
|
||||||
# Get the user to access the ID
|
|
||||||
user_dict = users_db.get(Query().email == email)
|
|
||||||
user = User.from_dict(user_dict)
|
|
||||||
|
|
||||||
# Create a valid JWT token for the marked user
|
|
||||||
payload = {
|
|
||||||
'email': email,
|
|
||||||
'user_id': user.id,
|
|
||||||
'exp': datetime.utcnow() + timedelta(hours=24)
|
|
||||||
}
|
|
||||||
token = jwt.encode(payload, 'supersecretkey', algorithm='HS256')
|
|
||||||
|
|
||||||
# Make request with token cookie
|
|
||||||
client.set_cookie('token', token)
|
|
||||||
response = client.get('/auth/me')
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
assert response.json['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
|
||||||
@@ -1,944 +0,0 @@
|
|||||||
"""Tests for child override API endpoints and integration."""
|
|
||||||
import pytest
|
|
||||||
import os
|
|
||||||
from flask import Flask
|
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from tinydb import Query
|
|
||||||
from werkzeug.security import generate_password_hash
|
|
||||||
|
|
||||||
from models.child_override import ChildOverride
|
|
||||||
from models.child import Child
|
|
||||||
from models.task import Task
|
|
||||||
from models.reward import Reward
|
|
||||||
from db.child_overrides import (
|
|
||||||
insert_override,
|
|
||||||
get_override,
|
|
||||||
delete_override,
|
|
||||||
get_overrides_for_child,
|
|
||||||
delete_overrides_for_child,
|
|
||||||
delete_overrides_for_entity
|
|
||||||
)
|
|
||||||
from db.db import child_overrides_db, child_db, task_db, reward_db, users_db
|
|
||||||
from api.child_override_api import child_override_api
|
|
||||||
from api.child_api import child_api
|
|
||||||
from api.auth_api import auth_api
|
|
||||||
from events.types.event_types import EventType
|
|
||||||
|
|
||||||
# Test user credentials
|
|
||||||
TEST_USER_ID = "testuserid"
|
|
||||||
TEST_EMAIL = "testuser@example.com"
|
|
||||||
TEST_PASSWORD = "testpass"
|
|
||||||
|
|
||||||
|
|
||||||
def add_test_user():
|
|
||||||
"""Create test user in database."""
|
|
||||||
users_db.remove(Query().email == TEST_EMAIL)
|
|
||||||
users_db.insert({
|
|
||||||
"id": TEST_USER_ID,
|
|
||||||
"first_name": "Test",
|
|
||||||
"last_name": "User",
|
|
||||||
"email": TEST_EMAIL,
|
|
||||||
"password": generate_password_hash(TEST_PASSWORD),
|
|
||||||
"verified": True,
|
|
||||||
"image_id": "boy01"
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def login_and_set_cookie(client):
|
|
||||||
"""Login and set authentication cookie."""
|
|
||||||
resp = client.post('/auth/login', json={
|
|
||||||
"email": TEST_EMAIL,
|
|
||||||
"password": TEST_PASSWORD
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
"""Create Flask test client with authentication."""
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.register_blueprint(child_override_api)
|
|
||||||
app.register_blueprint(child_api)
|
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
|
||||||
app.config['TESTING'] = True
|
|
||||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
|
||||||
|
|
||||||
with app.test_client() as client:
|
|
||||||
add_test_user()
|
|
||||||
login_and_set_cookie(client)
|
|
||||||
yield client
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def task():
|
|
||||||
"""Create a test task."""
|
|
||||||
task = Task(name="Clean Room", points=10, is_good=True, image_id="task-icon.png")
|
|
||||||
task_db.insert({**task.to_dict(), 'user_id': TEST_USER_ID})
|
|
||||||
return task
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def reward():
|
|
||||||
"""Create a test reward."""
|
|
||||||
reward = Reward(name="Ice Cream", description="Delicious treat", cost=50, image_id="reward-icon.png")
|
|
||||||
reward_db.insert({**reward.to_dict(), 'user_id': TEST_USER_ID})
|
|
||||||
return reward
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def child_with_task(client, task):
|
|
||||||
"""Create child and assign task."""
|
|
||||||
# Create child via API
|
|
||||||
resp = client.put('/child/add', json={'name': 'Alice', 'age': 8})
|
|
||||||
assert resp.status_code == 201
|
|
||||||
|
|
||||||
# Get child ID
|
|
||||||
children = client.get('/child/list').get_json()['children']
|
|
||||||
child = next(c for c in children if c['name'] == 'Alice')
|
|
||||||
child_id = child['id']
|
|
||||||
|
|
||||||
# Assign task directly in database (bypass API validation)
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_doc = child_db.search(ChildQuery.id == child_id)[0]
|
|
||||||
child_doc['tasks'] = child_doc.get('tasks', []) + [task.id]
|
|
||||||
child_db.update(child_doc, ChildQuery.id == child_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'child_id': child_id,
|
|
||||||
'task_id': task.id,
|
|
||||||
'task': task,
|
|
||||||
'default_points': 10
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def child_with_reward(client, reward):
|
|
||||||
"""Create child and assign reward."""
|
|
||||||
# Create child via API
|
|
||||||
resp = client.put('/child/add', json={'name': 'Bob', 'age': 9})
|
|
||||||
assert resp.status_code == 201
|
|
||||||
|
|
||||||
# Get child ID
|
|
||||||
children = client.get('/child/list').get_json()['children']
|
|
||||||
child = next(c for c in children if c['name'] == 'Bob')
|
|
||||||
child_id = child['id']
|
|
||||||
|
|
||||||
# Assign reward directly in database (bypass API validation)
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_doc = child_db.search(ChildQuery.id == child_id)[0]
|
|
||||||
child_doc['rewards'] = child_doc.get('rewards', []) + [reward.id]
|
|
||||||
child_db.update(child_doc, ChildQuery.id == child_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'child_id': child_id,
|
|
||||||
'reward_id': reward.id,
|
|
||||||
'reward': reward,
|
|
||||||
'default_cost': 50
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def child_with_task_override(client, child_with_task):
|
|
||||||
"""Create child with task and override."""
|
|
||||||
child_id = child_with_task['child_id']
|
|
||||||
task_id = child_with_task['task_id']
|
|
||||||
|
|
||||||
# Set override
|
|
||||||
resp = client.put(f'/child/{child_id}/override', json={
|
|
||||||
'entity_id': task_id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 15
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
return {**child_with_task, 'override_value': 15}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def child_with_reward_override(client, child_with_reward):
|
|
||||||
"""Create child with reward and override."""
|
|
||||||
child_id = child_with_reward['child_id']
|
|
||||||
reward_id = child_with_reward['reward_id']
|
|
||||||
|
|
||||||
# Set override
|
|
||||||
resp = client.put(f'/child/{child_id}/override', json={
|
|
||||||
'entity_id': reward_id,
|
|
||||||
'entity_type': 'reward',
|
|
||||||
'custom_value': 75
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
return {**child_with_reward, 'override_value': 75}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_sse():
|
|
||||||
"""Mock SSE event broadcaster."""
|
|
||||||
with patch('api.child_override_api.send_event_for_current_user') as mock:
|
|
||||||
yield mock
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def cleanup_db():
|
|
||||||
"""Cleanup database after all tests."""
|
|
||||||
yield
|
|
||||||
child_overrides_db.close()
|
|
||||||
child_db.close()
|
|
||||||
task_db.close()
|
|
||||||
reward_db.close()
|
|
||||||
users_db.close()
|
|
||||||
|
|
||||||
# Clean up test database files
|
|
||||||
for filename in ['child_overrides.json', 'children.json', 'tasks.json', 'rewards.json', 'users.json']:
|
|
||||||
if os.path.exists(filename):
|
|
||||||
try:
|
|
||||||
os.remove(filename)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class TestChildOverrideModel:
|
|
||||||
"""Test ChildOverride model validation."""
|
|
||||||
|
|
||||||
def test_create_valid_override(self):
|
|
||||||
"""Test creating override with valid data."""
|
|
||||||
override = ChildOverride.create_override(
|
|
||||||
child_id='child123',
|
|
||||||
entity_id='task456',
|
|
||||||
entity_type='task',
|
|
||||||
custom_value=15
|
|
||||||
)
|
|
||||||
assert override.child_id == 'child123'
|
|
||||||
assert override.entity_id == 'task456'
|
|
||||||
assert override.entity_type == 'task'
|
|
||||||
assert override.custom_value == 15
|
|
||||||
|
|
||||||
def test_custom_value_negative_raises_error(self):
|
|
||||||
"""Test custom_value < 0 raises ValueError."""
|
|
||||||
with pytest.raises(ValueError, match="custom_value must be between 0 and 10000"):
|
|
||||||
ChildOverride(
|
|
||||||
child_id='child123',
|
|
||||||
entity_id='task456',
|
|
||||||
entity_type='task',
|
|
||||||
custom_value=-1
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_custom_value_too_large_raises_error(self):
|
|
||||||
"""Test custom_value > 10000 raises ValueError."""
|
|
||||||
with pytest.raises(ValueError, match="custom_value must be between 0 and 10000"):
|
|
||||||
ChildOverride(
|
|
||||||
child_id='child123',
|
|
||||||
entity_id='task456',
|
|
||||||
entity_type='task',
|
|
||||||
custom_value=10001
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_custom_value_zero_allowed(self):
|
|
||||||
"""Test custom_value = 0 is valid."""
|
|
||||||
override = ChildOverride(
|
|
||||||
child_id='child123',
|
|
||||||
entity_id='task456',
|
|
||||||
entity_type='task',
|
|
||||||
custom_value=0
|
|
||||||
)
|
|
||||||
assert override.custom_value == 0
|
|
||||||
|
|
||||||
def test_custom_value_max_allowed(self):
|
|
||||||
"""Test custom_value = 10000 is valid."""
|
|
||||||
override = ChildOverride(
|
|
||||||
child_id='child123',
|
|
||||||
entity_id='task456',
|
|
||||||
entity_type='task',
|
|
||||||
custom_value=10000
|
|
||||||
)
|
|
||||||
assert override.custom_value == 10000
|
|
||||||
|
|
||||||
def test_invalid_entity_type_raises_error(self):
|
|
||||||
"""Test entity_type not in ['task', 'reward'] raises ValueError."""
|
|
||||||
with pytest.raises(ValueError, match="entity_type must be 'task' or 'reward'"):
|
|
||||||
ChildOverride(
|
|
||||||
child_id='child123',
|
|
||||||
entity_id='task456',
|
|
||||||
entity_type='invalid',
|
|
||||||
custom_value=15
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestChildOverrideDB:
|
|
||||||
"""Test database operations for child overrides."""
|
|
||||||
|
|
||||||
def test_insert_new_override(self):
|
|
||||||
"""Test inserting new override."""
|
|
||||||
override = ChildOverride.create_override(
|
|
||||||
child_id='child1',
|
|
||||||
entity_id='task1',
|
|
||||||
entity_type='task',
|
|
||||||
custom_value=20
|
|
||||||
)
|
|
||||||
override_id = insert_override(override)
|
|
||||||
assert override_id == override.id
|
|
||||||
|
|
||||||
# Verify it was inserted
|
|
||||||
retrieved = get_override('child1', 'task1')
|
|
||||||
assert retrieved is not None
|
|
||||||
assert retrieved.custom_value == 20
|
|
||||||
|
|
||||||
def test_insert_updates_existing(self):
|
|
||||||
"""Test inserting override for same (child_id, entity_id) updates."""
|
|
||||||
override1 = ChildOverride.create_override(
|
|
||||||
child_id='child2',
|
|
||||||
entity_id='task2',
|
|
||||||
entity_type='task',
|
|
||||||
custom_value=10
|
|
||||||
)
|
|
||||||
insert_override(override1)
|
|
||||||
|
|
||||||
override2 = ChildOverride.create_override(
|
|
||||||
child_id='child2',
|
|
||||||
entity_id='task2',
|
|
||||||
entity_type='task',
|
|
||||||
custom_value=25
|
|
||||||
)
|
|
||||||
insert_override(override2)
|
|
||||||
|
|
||||||
# Should only have one override with updated value
|
|
||||||
retrieved = get_override('child2', 'task2')
|
|
||||||
assert retrieved.custom_value == 25
|
|
||||||
|
|
||||||
all_overrides = get_overrides_for_child('child2')
|
|
||||||
assert len(all_overrides) == 1
|
|
||||||
|
|
||||||
def test_get_nonexistent_override_returns_none(self):
|
|
||||||
"""Test getting override that doesn't exist returns None."""
|
|
||||||
result = get_override('nonexistent_child', 'nonexistent_task')
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_get_overrides_for_child(self):
|
|
||||||
"""Test getting all overrides for a child."""
|
|
||||||
child_id = 'child3'
|
|
||||||
|
|
||||||
override1 = ChildOverride.create_override(child_id, 'task1', 'task', 10)
|
|
||||||
override2 = ChildOverride.create_override(child_id, 'task2', 'task', 15)
|
|
||||||
override3 = ChildOverride.create_override(child_id, 'reward1', 'reward', 100)
|
|
||||||
|
|
||||||
insert_override(override1)
|
|
||||||
insert_override(override2)
|
|
||||||
insert_override(override3)
|
|
||||||
|
|
||||||
overrides = get_overrides_for_child(child_id)
|
|
||||||
assert len(overrides) == 3
|
|
||||||
|
|
||||||
values = [o.custom_value for o in overrides]
|
|
||||||
assert 10 in values
|
|
||||||
assert 15 in values
|
|
||||||
assert 100 in values
|
|
||||||
|
|
||||||
def test_delete_override(self):
|
|
||||||
"""Test deleting specific override."""
|
|
||||||
override = ChildOverride.create_override('child4', 'task4', 'task', 30)
|
|
||||||
insert_override(override)
|
|
||||||
|
|
||||||
deleted = delete_override('child4', 'task4')
|
|
||||||
assert deleted is True
|
|
||||||
|
|
||||||
# Verify it was deleted
|
|
||||||
result = get_override('child4', 'task4')
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_delete_overrides_for_child(self):
|
|
||||||
"""Test deleting all overrides for a child."""
|
|
||||||
child_id = 'child5'
|
|
||||||
|
|
||||||
insert_override(ChildOverride.create_override(child_id, 'task1', 'task', 10))
|
|
||||||
insert_override(ChildOverride.create_override(child_id, 'task2', 'task', 20))
|
|
||||||
insert_override(ChildOverride.create_override(child_id, 'reward1', 'reward', 50))
|
|
||||||
|
|
||||||
count = delete_overrides_for_child(child_id)
|
|
||||||
assert count == 3
|
|
||||||
|
|
||||||
# Verify all deleted
|
|
||||||
overrides = get_overrides_for_child(child_id)
|
|
||||||
assert len(overrides) == 0
|
|
||||||
|
|
||||||
def test_delete_overrides_for_entity(self):
|
|
||||||
"""Test deleting all overrides for an entity."""
|
|
||||||
entity_id = 'task99'
|
|
||||||
|
|
||||||
insert_override(ChildOverride.create_override('child1', entity_id, 'task', 10))
|
|
||||||
insert_override(ChildOverride.create_override('child2', entity_id, 'task', 20))
|
|
||||||
insert_override(ChildOverride.create_override('child3', entity_id, 'task', 30))
|
|
||||||
|
|
||||||
count = delete_overrides_for_entity(entity_id)
|
|
||||||
assert count == 3
|
|
||||||
|
|
||||||
# Verify all deleted
|
|
||||||
assert get_override('child1', entity_id) is None
|
|
||||||
assert get_override('child2', entity_id) is None
|
|
||||||
assert get_override('child3', entity_id) is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestChildOverrideAPIAuth:
|
|
||||||
"""Test authentication and authorization."""
|
|
||||||
|
|
||||||
def test_put_returns_404_for_nonexistent_child(self, client, task):
|
|
||||||
"""Test PUT returns 404 for non-existent child."""
|
|
||||||
resp = client.put('/child/nonexistent-id/override', json={
|
|
||||||
'entity_id': task.id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 20
|
|
||||||
})
|
|
||||||
assert resp.status_code == 404
|
|
||||||
assert b'Child not found' in resp.data
|
|
||||||
|
|
||||||
def test_put_returns_404_for_unassigned_entity(self, client):
|
|
||||||
"""Test PUT returns 404 when entity is not assigned to child."""
|
|
||||||
# Create child
|
|
||||||
resp = client.put('/child/add', json={'name': 'Charlie', 'age': 7})
|
|
||||||
assert resp.status_code == 201
|
|
||||||
|
|
||||||
children = client.get('/child/list').get_json()['children']
|
|
||||||
child = next(c for c in children if c['name'] == 'Charlie')
|
|
||||||
|
|
||||||
# Try to set override for task not assigned to child
|
|
||||||
resp = client.put(f'/child/{child["id"]}/override', json={
|
|
||||||
'entity_id': 'unassigned-task-id',
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 20
|
|
||||||
})
|
|
||||||
assert resp.status_code == 404
|
|
||||||
assert b'not assigned' in resp.data or b'not found' in resp.data
|
|
||||||
|
|
||||||
def test_get_returns_404_for_nonexistent_child(self, client):
|
|
||||||
"""Test GET returns 404 for non-existent child."""
|
|
||||||
resp = client.get('/child/nonexistent-id/overrides')
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
def test_get_returns_empty_array_when_no_overrides(self, client, child_with_task):
|
|
||||||
"""Test GET returns empty array when child has no overrides."""
|
|
||||||
resp = client.get(f"/child/{child_with_task['child_id']}/overrides")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
assert data['overrides'] == []
|
|
||||||
|
|
||||||
def test_delete_returns_404_when_override_not_found(self, client, child_with_task):
|
|
||||||
"""Test DELETE returns 404 when override doesn't exist."""
|
|
||||||
resp = client.delete(
|
|
||||||
f"/child/{child_with_task['child_id']}/override/{child_with_task['task_id']}"
|
|
||||||
)
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
def test_delete_returns_404_for_nonexistent_child(self, client):
|
|
||||||
"""Test DELETE returns 404 for non-existent child."""
|
|
||||||
resp = client.delete('/child/nonexistent-id/override/some-task-id')
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
class TestChildOverrideAPIValidation:
|
|
||||||
"""Test API endpoint validation."""
|
|
||||||
|
|
||||||
def test_put_returns_400_for_negative_value(self, client, child_with_task):
|
|
||||||
"""Test PUT returns 400 for custom_value < 0."""
|
|
||||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
|
||||||
'entity_id': child_with_task['task_id'],
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': -5
|
|
||||||
})
|
|
||||||
assert resp.status_code == 400
|
|
||||||
# Check for either format of the error message
|
|
||||||
assert b'custom_value' in resp.data and (b'0 and 10000' in resp.data or b'between 0' in resp.data)
|
|
||||||
|
|
||||||
def test_put_returns_400_for_value_too_large(self, client, child_with_task):
|
|
||||||
"""Test PUT returns 400 for custom_value > 10000."""
|
|
||||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
|
||||||
'entity_id': child_with_task['task_id'],
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 10001
|
|
||||||
})
|
|
||||||
assert resp.status_code == 400
|
|
||||||
# Check for either format of the error message
|
|
||||||
assert b'custom_value' in resp.data and (b'0 and 10000' in resp.data or b'between 0' in resp.data)
|
|
||||||
|
|
||||||
def test_put_returns_400_for_invalid_entity_type(self, client, child_with_task):
|
|
||||||
"""Test PUT returns 400 for invalid entity_type."""
|
|
||||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
|
||||||
'entity_id': child_with_task['task_id'],
|
|
||||||
'entity_type': 'invalid',
|
|
||||||
'custom_value': 20
|
|
||||||
})
|
|
||||||
assert resp.status_code == 400
|
|
||||||
assert b'entity_type must be' in resp.data or b'invalid' in resp.data.lower()
|
|
||||||
|
|
||||||
def test_put_accepts_zero_value(self, client, child_with_task):
|
|
||||||
"""Test PUT accepts custom_value = 0."""
|
|
||||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
|
||||||
'entity_id': child_with_task['task_id'],
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 0
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
def test_put_accepts_max_value(self, client, child_with_task):
|
|
||||||
"""Test PUT accepts custom_value = 10000."""
|
|
||||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
|
||||||
'entity_id': child_with_task['task_id'],
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 10000
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
class TestChildOverrideAPIBasic:
|
|
||||||
"""Test basic API functionality."""
|
|
||||||
|
|
||||||
def test_put_creates_new_override(self, client, child_with_task):
|
|
||||||
"""Test PUT creates new override with valid data."""
|
|
||||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
|
||||||
'entity_id': child_with_task['task_id'],
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 25
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
data = resp.get_json()
|
|
||||||
assert 'override' in data
|
|
||||||
assert data['override']['custom_value'] == 25
|
|
||||||
assert data['override']['child_id'] == child_with_task['child_id']
|
|
||||||
assert data['override']['entity_id'] == child_with_task['task_id']
|
|
||||||
|
|
||||||
def test_put_updates_existing_override(self, client, child_with_task_override):
|
|
||||||
"""Test PUT updates existing override."""
|
|
||||||
child_id = child_with_task_override['child_id']
|
|
||||||
task_id = child_with_task_override['task_id']
|
|
||||||
|
|
||||||
resp = client.put(f"/child/{child_id}/override", json={
|
|
||||||
'entity_id': task_id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 30
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
data = resp.get_json()
|
|
||||||
assert data['override']['custom_value'] == 30
|
|
||||||
|
|
||||||
# Verify only one override exists for this child-task combination
|
|
||||||
override = get_override(child_id, task_id)
|
|
||||||
assert override is not None
|
|
||||||
assert override.custom_value == 30
|
|
||||||
|
|
||||||
def test_get_returns_all_overrides(self, client, child_with_task):
|
|
||||||
"""Test GET returns all overrides for child."""
|
|
||||||
child_id = child_with_task['child_id']
|
|
||||||
task_id = child_with_task['task_id']
|
|
||||||
|
|
||||||
# Create a second task and assign to same child
|
|
||||||
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
|
|
||||||
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
|
|
||||||
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_doc = child_db.search(ChildQuery.id == child_id)[0]
|
|
||||||
child_doc['tasks'] = child_doc.get('tasks', []) + [task2.id]
|
|
||||||
child_db.update(child_doc, ChildQuery.id == child_id)
|
|
||||||
|
|
||||||
# Set two overrides
|
|
||||||
client.put(f'/child/{child_id}/override', json={
|
|
||||||
'entity_id': task_id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 15
|
|
||||||
})
|
|
||||||
client.put(f'/child/{child_id}/override', json={
|
|
||||||
'entity_id': task2.id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 100
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get all overrides
|
|
||||||
resp = client.get(f'/child/{child_id}/overrides')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
data = resp.get_json()
|
|
||||||
assert len(data['overrides']) >= 2
|
|
||||||
values = [o['custom_value'] for o in data['overrides']]
|
|
||||||
assert 15 in values
|
|
||||||
assert 100 in values
|
|
||||||
|
|
||||||
def test_delete_removes_override(self, client, child_with_task_override):
|
|
||||||
"""Test DELETE removes override successfully."""
|
|
||||||
resp = client.delete(
|
|
||||||
f"/child/{child_with_task_override['child_id']}/override/{child_with_task_override['task_id']}"
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert b'Override deleted' in resp.data
|
|
||||||
|
|
||||||
# Verify it was deleted
|
|
||||||
override = get_override(
|
|
||||||
child_with_task_override['child_id'],
|
|
||||||
child_with_task_override['task_id']
|
|
||||||
)
|
|
||||||
assert override is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestChildOverrideSSE:
|
|
||||||
"""Test SSE event emission."""
|
|
||||||
|
|
||||||
def test_put_emits_child_override_set_event(self, client, child_with_task, mock_sse):
|
|
||||||
"""Test PUT emits child_override_set event."""
|
|
||||||
resp = client.put(f"/child/{child_with_task['child_id']}/override", json={
|
|
||||||
'entity_id': child_with_task['task_id'],
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 25
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
# Verify SSE event was emitted (just check it was called)
|
|
||||||
assert mock_sse.called, "SSE event should have been emitted"
|
|
||||||
|
|
||||||
def test_delete_emits_child_override_deleted_event(self, client, child_with_task_override, mock_sse):
|
|
||||||
"""Test DELETE emits child_override_deleted event."""
|
|
||||||
resp = client.delete(
|
|
||||||
f"/child/{child_with_task_override['child_id']}/override/{child_with_task_override['task_id']}"
|
|
||||||
)
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
# Verify SSE event was emitted (just check it was called)
|
|
||||||
assert mock_sse.called, "SSE event should have been emitted"
|
|
||||||
|
|
||||||
|
|
||||||
class TestIntegration:
|
|
||||||
"""Test override integration with existing endpoints."""
|
|
||||||
|
|
||||||
def test_list_tasks_includes_custom_value_for_overridden(self, client, child_with_task_override):
|
|
||||||
"""Test list-tasks includes custom_value when override exists."""
|
|
||||||
resp = client.get(f"/child/{child_with_task_override['child_id']}/list-tasks")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
tasks = resp.get_json()['tasks']
|
|
||||||
task = next(t for t in tasks if t['id'] == child_with_task_override['task_id'])
|
|
||||||
assert task['custom_value'] == 15
|
|
||||||
|
|
||||||
def test_list_tasks_shows_no_custom_value_for_non_overridden(self, client, child_with_task):
|
|
||||||
"""Test list-tasks doesn't include custom_value when no override."""
|
|
||||||
resp = client.get(f"/child/{child_with_task['child_id']}/list-tasks")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
tasks = resp.get_json()['tasks']
|
|
||||||
task = next(t for t in tasks if t['id'] == child_with_task['task_id'])
|
|
||||||
assert 'custom_value' not in task or task.get('custom_value') is None
|
|
||||||
|
|
||||||
def test_list_rewards_includes_custom_value_for_overridden(self, client, child_with_reward_override):
|
|
||||||
"""Test list-rewards includes custom_value when override exists."""
|
|
||||||
resp = client.get(f"/child/{child_with_reward_override['child_id']}/list-rewards")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
rewards = resp.get_json()['rewards']
|
|
||||||
reward = next(r for r in rewards if r['id'] == child_with_reward_override['reward_id'])
|
|
||||||
assert reward['custom_value'] == 75
|
|
||||||
|
|
||||||
def test_trigger_task_uses_custom_value(self, client, child_with_task_override):
|
|
||||||
"""Test trigger-task uses override value when calculating points."""
|
|
||||||
child_id = child_with_task_override['child_id']
|
|
||||||
task_id = child_with_task_override['task_id']
|
|
||||||
|
|
||||||
# Get initial points
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
initial_points = resp.get_json()['points']
|
|
||||||
|
|
||||||
# Trigger task
|
|
||||||
resp = client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
# Verify points increased by override value (15, not default 10)
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
final_points = resp.get_json()['points']
|
|
||||||
assert final_points == initial_points + 15
|
|
||||||
|
|
||||||
def test_trigger_task_uses_default_when_no_override(self, client, child_with_task):
|
|
||||||
"""Test trigger-task uses default points when no override."""
|
|
||||||
child_id = child_with_task['child_id']
|
|
||||||
task_id = child_with_task['task_id']
|
|
||||||
|
|
||||||
# Get initial points
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
initial_points = resp.get_json()['points']
|
|
||||||
|
|
||||||
# Trigger task
|
|
||||||
resp = client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
# Verify points increased by default (10)
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
final_points = resp.get_json()['points']
|
|
||||||
assert final_points == initial_points + 10
|
|
||||||
|
|
||||||
def test_trigger_reward_uses_custom_value(self, client, child_with_reward_override):
|
|
||||||
"""Test trigger-reward uses override value when deducting points."""
|
|
||||||
child_id = child_with_reward_override['child_id']
|
|
||||||
reward_id = child_with_reward_override['reward_id']
|
|
||||||
|
|
||||||
# Give child enough points
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_db.update({'points': 100}, ChildQuery.id == child_id)
|
|
||||||
|
|
||||||
# Trigger reward
|
|
||||||
resp = client.post(f'/child/{child_id}/trigger-reward', json={'reward_id': reward_id})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
# Verify points deducted by override value (75, not default 50)
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
final_points = resp.get_json()['points']
|
|
||||||
assert final_points == 100 - 75
|
|
||||||
|
|
||||||
def test_set_tasks_deletes_overrides_for_unassigned(self, client, child_with_task_override):
|
|
||||||
"""Test set-tasks deletes overrides when task is unassigned."""
|
|
||||||
child_id = child_with_task_override['child_id']
|
|
||||||
task_id = child_with_task_override['task_id']
|
|
||||||
|
|
||||||
# Verify override exists
|
|
||||||
override = get_override(child_id, task_id)
|
|
||||||
assert override is not None
|
|
||||||
|
|
||||||
# Unassign task directly in database (simulating what set-tasks does)
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_db.update({'tasks': []}, ChildQuery.id == child_id)
|
|
||||||
|
|
||||||
# Manually call delete function (simulating API behavior)
|
|
||||||
delete_override(child_id, task_id)
|
|
||||||
|
|
||||||
# Verify override was deleted
|
|
||||||
override = get_override(child_id, task_id)
|
|
||||||
assert override is None
|
|
||||||
|
|
||||||
def test_set_tasks_preserves_overrides_for_still_assigned(self, client, child_with_task_override, task):
|
|
||||||
"""Test set-tasks preserves overrides for still-assigned tasks."""
|
|
||||||
child_id = child_with_task_override['child_id']
|
|
||||||
task_id = child_with_task_override['task_id']
|
|
||||||
|
|
||||||
# Create another task
|
|
||||||
task2 = Task(name="Do Homework", points=20, is_good=True, image_id="homework.png")
|
|
||||||
task_db.insert({**task2.to_dict(), 'user_id': TEST_USER_ID})
|
|
||||||
|
|
||||||
# Assign both tasks directly in database
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_db.update({'tasks': [task_id, task2.id]}, ChildQuery.id == child_id)
|
|
||||||
|
|
||||||
# Override should still exist (we didn't delete it)
|
|
||||||
override = get_override(child_id, task_id)
|
|
||||||
assert override is not None
|
|
||||||
assert override.custom_value == 15
|
|
||||||
|
|
||||||
def test_set_rewards_deletes_overrides_for_unassigned(self, client, child_with_reward_override):
|
|
||||||
"""Test set-rewards deletes overrides when reward is unassigned."""
|
|
||||||
child_id = child_with_reward_override['child_id']
|
|
||||||
reward_id = child_with_reward_override['reward_id']
|
|
||||||
|
|
||||||
# Verify override exists
|
|
||||||
override = get_override(child_id, reward_id)
|
|
||||||
assert override is not None
|
|
||||||
|
|
||||||
# Unassign reward
|
|
||||||
resp = client.put(f'/child/{child_id}/set-rewards', json={'reward_ids': []})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
# Verify override was deleted
|
|
||||||
override = get_override(child_id, reward_id)
|
|
||||||
assert override is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestCascadeDelete:
|
|
||||||
"""Test cascade deletion behavior."""
|
|
||||||
|
|
||||||
def test_deleting_child_removes_all_overrides(self, client, child_with_task_override):
|
|
||||||
"""Test deleting child removes all its overrides."""
|
|
||||||
child_id = child_with_task_override['child_id']
|
|
||||||
|
|
||||||
# Verify override exists
|
|
||||||
overrides = get_overrides_for_child(child_id)
|
|
||||||
assert len(overrides) > 0
|
|
||||||
|
|
||||||
# Delete child
|
|
||||||
resp = client.delete(f'/child/{child_id}')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
# Verify overrides were deleted
|
|
||||||
overrides = get_overrides_for_child(child_id)
|
|
||||||
assert len(overrides) == 0
|
|
||||||
|
|
||||||
def test_deleting_task_removes_all_overrides_for_task(self, client, child_with_task_override, task):
|
|
||||||
"""Test deleting task removes all overrides for that task."""
|
|
||||||
task_id = child_with_task_override['task_id']
|
|
||||||
|
|
||||||
# Create another child with same task
|
|
||||||
resp = client.put('/child/add', json={'name': 'Eve', 'age': 10})
|
|
||||||
children = client.get('/child/list').get_json()['children']
|
|
||||||
eve = next(c for c in children if c['name'] == 'Eve')
|
|
||||||
|
|
||||||
# Assign task to Eve directly in database
|
|
||||||
ChildQuery = Query()
|
|
||||||
child_doc = child_db.search(ChildQuery.id == eve['id'])[0]
|
|
||||||
child_doc['tasks'] = child_doc.get('tasks', []) + [task_id]
|
|
||||||
child_db.update(child_doc, ChildQuery.id == eve['id'])
|
|
||||||
|
|
||||||
# Set override for Eve
|
|
||||||
client.put(f'/child/{eve["id"]}/override', json={
|
|
||||||
'entity_id': task_id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 99
|
|
||||||
})
|
|
||||||
|
|
||||||
# Verify both overrides exist
|
|
||||||
override1 = get_override(child_with_task_override['child_id'], task_id)
|
|
||||||
override2 = get_override(eve['id'], task_id)
|
|
||||||
assert override1 is not None
|
|
||||||
assert override2 is not None
|
|
||||||
|
|
||||||
# Delete task (simulate what API does)
|
|
||||||
delete_overrides_for_entity(task_id)
|
|
||||||
task_db.remove(Query().id == task_id)
|
|
||||||
|
|
||||||
# Verify both overrides were deleted
|
|
||||||
override1 = get_override(child_with_task_override['child_id'], task_id)
|
|
||||||
override2 = get_override(eve['id'], task_id)
|
|
||||||
assert override1 is None
|
|
||||||
assert override2 is None
|
|
||||||
|
|
||||||
def test_deleting_reward_removes_all_overrides_for_reward(self, client, child_with_reward_override, reward):
|
|
||||||
"""Test deleting reward removes all overrides for that reward."""
|
|
||||||
reward_id = child_with_reward_override['reward_id']
|
|
||||||
|
|
||||||
# Verify override exists
|
|
||||||
override = get_override(child_with_reward_override['child_id'], reward_id)
|
|
||||||
assert override is not None
|
|
||||||
|
|
||||||
# Delete reward using task_api endpoint pattern (delete by ID from db directly for testing)
|
|
||||||
from db.db import reward_db
|
|
||||||
from db.child_overrides import delete_overrides_for_entity
|
|
||||||
|
|
||||||
# Simulate what the API does: delete overrides then delete reward
|
|
||||||
delete_overrides_for_entity(reward_id)
|
|
||||||
reward_db.remove(Query().id == reward_id)
|
|
||||||
|
|
||||||
# Verify override was deleted
|
|
||||||
override = get_override(child_with_reward_override['child_id'], reward_id)
|
|
||||||
assert override is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestEdgeCases:
|
|
||||||
"""Test edge cases and boundary conditions."""
|
|
||||||
|
|
||||||
def test_multiple_children_different_overrides_same_entity(self, client, task):
|
|
||||||
"""Test multiple children can have different overrides for same entity."""
|
|
||||||
# Create two children
|
|
||||||
client.put('/child/add', json={'name': 'Frank', 'age': 8})
|
|
||||||
client.put('/child/add', json={'name': 'Grace', 'age': 9})
|
|
||||||
|
|
||||||
children = client.get('/child/list').get_json()['children']
|
|
||||||
frank = next(c for c in children if c['name'] == 'Frank')
|
|
||||||
grace = next(c for c in children if c['name'] == 'Grace')
|
|
||||||
|
|
||||||
# Assign same task to both directly in database
|
|
||||||
ChildQuery = Query()
|
|
||||||
for child_id in [frank['id'], grace['id']]:
|
|
||||||
child_doc = child_db.search(ChildQuery.id == child_id)[0]
|
|
||||||
child_doc['tasks'] = child_doc.get('tasks', []) + [task.id]
|
|
||||||
child_db.update(child_doc, ChildQuery.id == child_id)
|
|
||||||
|
|
||||||
# Set different overrides
|
|
||||||
client.put(f'/child/{frank["id"]}/override', json={
|
|
||||||
'entity_id': task.id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 5
|
|
||||||
})
|
|
||||||
client.put(f'/child/{grace["id"]}/override', json={
|
|
||||||
'entity_id': task.id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 20
|
|
||||||
})
|
|
||||||
|
|
||||||
# Verify both overrides exist with different values
|
|
||||||
frank_override = get_override(frank['id'], task.id)
|
|
||||||
grace_override = get_override(grace['id'], task.id)
|
|
||||||
|
|
||||||
assert frank_override is not None
|
|
||||||
assert grace_override is not None
|
|
||||||
assert frank_override.custom_value == 5
|
|
||||||
assert grace_override.custom_value == 20
|
|
||||||
|
|
||||||
def test_zero_points_displays_correctly(self, client, child_with_task):
|
|
||||||
"""Test custom_value = 0 displays and works correctly."""
|
|
||||||
child_id = child_with_task['child_id']
|
|
||||||
task_id = child_with_task['task_id']
|
|
||||||
|
|
||||||
# Set override to 0
|
|
||||||
resp = client.put(f'/child/{child_id}/override', json={
|
|
||||||
'entity_id': task_id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 0
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
# Get initial points
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
initial_points = resp.get_json()['points']
|
|
||||||
|
|
||||||
# Trigger task
|
|
||||||
client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
|
|
||||||
|
|
||||||
# Verify points didn't change (0 added)
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
final_points = resp.get_json()['points']
|
|
||||||
assert final_points == initial_points
|
|
||||||
|
|
||||||
def test_max_value_10000_works_correctly(self, client, child_with_task):
|
|
||||||
"""Test custom_value = 10000 works correctly."""
|
|
||||||
child_id = child_with_task['child_id']
|
|
||||||
task_id = child_with_task['task_id']
|
|
||||||
|
|
||||||
# Set override to max
|
|
||||||
resp = client.put(f'/child/{child_id}/override', json={
|
|
||||||
'entity_id': task_id,
|
|
||||||
'entity_type': 'task',
|
|
||||||
'custom_value': 10000
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
# Get initial points
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
initial_points = resp.get_json()['points']
|
|
||||||
|
|
||||||
# Trigger task
|
|
||||||
client.post(f'/child/{child_id}/trigger-task', json={'task_id': task_id})
|
|
||||||
|
|
||||||
# Verify points increased by 10000
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
final_points = resp.get_json()['points']
|
|
||||||
assert final_points == initial_points + 10000
|
|
||||||
|
|
||||||
def test_reward_status_uses_override_for_points_needed(self, client, child_with_reward_override):
|
|
||||||
"""Test reward-status uses override value when calculating points_needed."""
|
|
||||||
child_id = child_with_reward_override['child_id']
|
|
||||||
reward_id = child_with_reward_override['reward_id']
|
|
||||||
|
|
||||||
# Get child's current points
|
|
||||||
resp = client.get(f'/child/{child_id}')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
assert data is not None, "Child data response is None"
|
|
||||||
child_points = data['points']
|
|
||||||
|
|
||||||
# Get reward status
|
|
||||||
resp = client.get(f'/child/{child_id}/reward-status')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.get_json()
|
|
||||||
assert data is not None, f"Reward status response is None for child {child_id}"
|
|
||||||
|
|
||||||
rewards = data['reward_status']
|
|
||||||
reward_status = next((r for r in rewards if r['id'] == reward_id), None)
|
|
||||||
assert reward_status is not None, f"Reward {reward_id} not found in reward_status"
|
|
||||||
|
|
||||||
# Override value is 75, default cost is 50 (from fixture)
|
|
||||||
# points_needed should be max(0, 75 - child_points)
|
|
||||||
expected_points_needed = max(0, 75 - child_points)
|
|
||||||
assert reward_status['points_needed'] == expected_points_needed
|
|
||||||
|
|
||||||
# Verify custom_value is included in response
|
|
||||||
assert reward_status.get('custom_value') == 75
|
|
||||||
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import os
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import patch
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Set up path and environment before imports
|
|
||||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
||||||
os.environ['DB_ENV'] = 'test'
|
|
||||||
|
|
||||||
# Now import the module to test
|
|
||||||
from config import deletion_config
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeletionConfig:
|
|
||||||
"""Tests for deletion configuration module."""
|
|
||||||
|
|
||||||
def test_default_threshold_value(self):
|
|
||||||
"""Test that default threshold is 720 hours (30 days)."""
|
|
||||||
# Reset to default by reloading module
|
|
||||||
import importlib
|
|
||||||
with patch.dict(os.environ, {}, clear=True):
|
|
||||||
os.environ['DB_ENV'] = 'test'
|
|
||||||
importlib.reload(deletion_config)
|
|
||||||
assert deletion_config.ACCOUNT_DELETION_THRESHOLD_HOURS == 720
|
|
||||||
|
|
||||||
def test_environment_variable_override(self):
|
|
||||||
"""Test that environment variable overrides default value."""
|
|
||||||
import importlib
|
|
||||||
with patch.dict(os.environ, {'ACCOUNT_DELETION_THRESHOLD_HOURS': '168', 'DB_ENV': 'test'}):
|
|
||||||
importlib.reload(deletion_config)
|
|
||||||
assert deletion_config.ACCOUNT_DELETION_THRESHOLD_HOURS == 168
|
|
||||||
|
|
||||||
def test_minimum_threshold_enforcement(self):
|
|
||||||
"""Test that threshold below 24 hours is invalid."""
|
|
||||||
with pytest.raises(ValueError, match="ACCOUNT_DELETION_THRESHOLD_HOURS must be at least 24"):
|
|
||||||
deletion_config.validate_threshold(23)
|
|
||||||
|
|
||||||
def test_maximum_threshold_enforcement(self):
|
|
||||||
"""Test that threshold above 720 hours is invalid."""
|
|
||||||
with pytest.raises(ValueError, match="ACCOUNT_DELETION_THRESHOLD_HOURS must be at most 720"):
|
|
||||||
deletion_config.validate_threshold(721)
|
|
||||||
|
|
||||||
def test_invalid_threshold_negative(self):
|
|
||||||
"""Test that negative threshold values are invalid."""
|
|
||||||
with pytest.raises(ValueError, match="ACCOUNT_DELETION_THRESHOLD_HOURS must be at least 24"):
|
|
||||||
deletion_config.validate_threshold(-1)
|
|
||||||
|
|
||||||
def test_invalid_threshold_zero(self):
|
|
||||||
"""Test that zero threshold is invalid."""
|
|
||||||
with pytest.raises(ValueError, match="ACCOUNT_DELETION_THRESHOLD_HOURS must be at least 24"):
|
|
||||||
deletion_config.validate_threshold(0)
|
|
||||||
|
|
||||||
def test_valid_threshold_24_hours(self):
|
|
||||||
"""Test that 24 hours (minimum) is valid."""
|
|
||||||
# Should not raise
|
|
||||||
deletion_config.validate_threshold(24)
|
|
||||||
|
|
||||||
def test_valid_threshold_720_hours(self):
|
|
||||||
"""Test that 720 hours (maximum) is valid."""
|
|
||||||
# Should not raise
|
|
||||||
deletion_config.validate_threshold(720)
|
|
||||||
|
|
||||||
def test_valid_threshold_168_hours(self):
|
|
||||||
"""Test that 168 hours (7 days) is valid."""
|
|
||||||
# Should not raise
|
|
||||||
deletion_config.validate_threshold(168)
|
|
||||||
|
|
||||||
def test_warning_for_threshold_below_168_hours(self, caplog):
|
|
||||||
"""Test that setting threshold below 168 hours logs a warning."""
|
|
||||||
import logging
|
|
||||||
caplog.set_level(logging.WARNING)
|
|
||||||
deletion_config.validate_threshold(100)
|
|
||||||
assert any("below the recommended minimum" in record.message for record in caplog.records)
|
|
||||||
|
|
||||||
def test_no_warning_for_threshold_above_168_hours(self, caplog):
|
|
||||||
"""Test that threshold above 168 hours doesn't log warning."""
|
|
||||||
import logging
|
|
||||||
caplog.set_level(logging.WARNING)
|
|
||||||
deletion_config.validate_threshold(200)
|
|
||||||
# Should not have the specific warning
|
|
||||||
assert not any("below the recommended minimum" in record.message for record in caplog.records)
|
|
||||||
|
|
||||||
def test_threshold_constants_defined(self):
|
|
||||||
"""Test that MIN and MAX threshold constants are defined."""
|
|
||||||
assert deletion_config.MIN_THRESHOLD_HOURS == 24
|
|
||||||
assert deletion_config.MAX_THRESHOLD_HOURS == 720
|
|
||||||
|
|
||||||
def test_invalid_environment_variable_non_numeric(self):
|
|
||||||
"""Test that non-numeric environment variable raises error."""
|
|
||||||
import importlib
|
|
||||||
with patch.dict(os.environ, {'ACCOUNT_DELETION_THRESHOLD_HOURS': 'invalid', 'DB_ENV': 'test'}):
|
|
||||||
with pytest.raises(ValueError, match="ACCOUNT_DELETION_THRESHOLD_HOURS must be a valid integer"):
|
|
||||||
importlib.reload(deletion_config)
|
|
||||||
|
|
||||||
def test_environment_variable_with_decimal(self):
|
|
||||||
"""Test that decimal environment variable raises error."""
|
|
||||||
import importlib
|
|
||||||
with patch.dict(os.environ, {'ACCOUNT_DELETION_THRESHOLD_HOURS': '24.5', 'DB_ENV': 'test'}):
|
|
||||||
with pytest.raises(ValueError, match="ACCOUNT_DELETION_THRESHOLD_HOURS must be a valid integer"):
|
|
||||||
importlib.reload(deletion_config)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,146 +0,0 @@
|
|||||||
import pytest
|
|
||||||
import os
|
|
||||||
from werkzeug.security import generate_password_hash
|
|
||||||
|
|
||||||
from flask import Flask
|
|
||||||
from api.task_api import task_api
|
|
||||||
from api.auth_api import auth_api
|
|
||||||
from db.db import task_db, child_db, users_db
|
|
||||||
from tinydb import Query
|
|
||||||
import jwt
|
|
||||||
|
|
||||||
|
|
||||||
# Test user credentials
|
|
||||||
TEST_EMAIL = "testuser@example.com"
|
|
||||||
TEST_PASSWORD = "testpass"
|
|
||||||
|
|
||||||
def add_test_user():
|
|
||||||
users_db.remove(Query().email == TEST_EMAIL)
|
|
||||||
users_db.insert({
|
|
||||||
"id": "testuserid",
|
|
||||||
"first_name": "Test",
|
|
||||||
"last_name": "User",
|
|
||||||
"email": TEST_EMAIL,
|
|
||||||
"password": generate_password_hash(TEST_PASSWORD),
|
|
||||||
"verified": True,
|
|
||||||
"image_id": "boy01"
|
|
||||||
})
|
|
||||||
|
|
||||||
def login_and_set_cookie(client):
|
|
||||||
resp = client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
token = resp.headers.get("Set-Cookie")
|
|
||||||
assert token and "token=" in token
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.register_blueprint(task_api)
|
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
|
||||||
app.config['TESTING'] = True
|
|
||||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
|
||||||
with app.test_client() as client:
|
|
||||||
add_test_user()
|
|
||||||
login_and_set_cookie(client)
|
|
||||||
yield client
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
|
||||||
def cleanup_db():
|
|
||||||
yield
|
|
||||||
task_db.close()
|
|
||||||
if os.path.exists('tasks.json'):
|
|
||||||
os.remove('tasks.json')
|
|
||||||
|
|
||||||
def test_add_task(client):
|
|
||||||
response = client.put('/task/add', json={'name': 'Clean Room', 'points': 10, 'is_good': True})
|
|
||||||
assert response.status_code == 201
|
|
||||||
assert b'Task Clean Room added.' in response.data
|
|
||||||
# verify in database
|
|
||||||
tasks = task_db.all()
|
|
||||||
assert any(task.get('name') == 'Clean Room' and task.get('points') == 10 and task.get('is_good') is True and task.get('image_id') == '' for task in tasks)
|
|
||||||
|
|
||||||
response = client.put('/task/add', json={'name': 'Eat Dinner', 'points': 5, 'is_good': False, 'image_id': 'meal'})
|
|
||||||
assert response.status_code == 201
|
|
||||||
assert b'Task Eat Dinner added.' in response.data
|
|
||||||
# verify in database
|
|
||||||
tasks = task_db.all()
|
|
||||||
assert any(task.get('name') == 'Eat Dinner' and task.get('points') == 5 and task.get('is_good') is False and task.get('image_id') == 'meal' for task in tasks)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_tasks(client):
|
|
||||||
task_db.truncate()
|
|
||||||
# Insert user-owned tasks
|
|
||||||
task_db.insert({'id': 't1', 'name': 'Task1', 'points': 5, 'is_good': True, 'user_id': 'testuserid'})
|
|
||||||
task_db.insert({'id': 't2', 'name': 'Task2', 'points': 15, 'is_good': False, 'image_id': 'meal', 'user_id': 'testuserid'})
|
|
||||||
response = client.get('/task/list')
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert b'tasks' in response.data
|
|
||||||
data = response.json
|
|
||||||
assert len(data['tasks']) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_tasks_sorted_by_is_good_then_user_then_default_then_name(client):
|
|
||||||
task_db.truncate()
|
|
||||||
|
|
||||||
task_db.insert({'id': 'u_good_z', 'name': 'Zoo', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
|
|
||||||
task_db.insert({'id': 'u_good_a', 'name': 'Apple', 'points': 1, 'is_good': True, 'user_id': 'testuserid'})
|
|
||||||
task_db.insert({'id': 'd_good_m', 'name': 'Mop', 'points': 1, 'is_good': True, 'user_id': None})
|
|
||||||
task_db.insert({'id': 'd_good_b', 'name': 'Brush', 'points': 1, 'is_good': True, 'user_id': None})
|
|
||||||
|
|
||||||
task_db.insert({'id': 'u_bad_c', 'name': 'Chore', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
|
|
||||||
task_db.insert({'id': 'u_bad_a', 'name': 'Alarm', 'points': 1, 'is_good': False, 'user_id': 'testuserid'})
|
|
||||||
task_db.insert({'id': 'd_bad_y', 'name': 'Yell', 'points': 1, 'is_good': False, 'user_id': None})
|
|
||||||
task_db.insert({'id': 'd_bad_b', 'name': 'Bicker', 'points': 1, 'is_good': False, 'user_id': None})
|
|
||||||
|
|
||||||
response = client.get('/task/list')
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
tasks = response.json['tasks']
|
|
||||||
ordered_ids = [t['id'] for t in tasks]
|
|
||||||
assert ordered_ids == [
|
|
||||||
'u_good_a',
|
|
||||||
'u_good_z',
|
|
||||||
'd_good_b',
|
|
||||||
'd_good_m',
|
|
||||||
'u_bad_a',
|
|
||||||
'u_bad_c',
|
|
||||||
'd_bad_b',
|
|
||||||
'd_bad_y',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_task_not_found(client):
|
|
||||||
response = client.get('/task/nonexistent-id')
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert b'Task not found' in response.data
|
|
||||||
|
|
||||||
def test_delete_task_not_found(client):
|
|
||||||
response = client.delete('/task/nonexistent-id')
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert b'Task not found' in response.data
|
|
||||||
|
|
||||||
def test_delete_assigned_task_removes_from_child(client):
|
|
||||||
# create user-owned task and child with the task already assigned
|
|
||||||
task_db.insert({'id': 't_delete_assigned', 'name': 'Temp Task', 'points': 5, 'is_good': True, 'user_id': 'testuserid'})
|
|
||||||
child_db.insert({
|
|
||||||
'id': 'child_for_task_delete',
|
|
||||||
'name': 'Frank',
|
|
||||||
'age': 7,
|
|
||||||
'points': 0,
|
|
||||||
'tasks': ['t_delete_assigned'],
|
|
||||||
'rewards': []
|
|
||||||
})
|
|
||||||
ChildQuery = Query()
|
|
||||||
# Ensure child has the user-owned task
|
|
||||||
child2 = child_db.search(ChildQuery.id == 'child_for_task_delete')[0]
|
|
||||||
if 't_delete_assigned' not in child2.get('tasks', []):
|
|
||||||
child2['tasks'] = ['t_delete_assigned']
|
|
||||||
child_db.update({'tasks': ['t_delete_assigned']}, ChildQuery.id == 'child_for_task_delete')
|
|
||||||
assert 't_delete_assigned' in child_db.search(ChildQuery.id == 'child_for_task_delete')[0].get('tasks', [])
|
|
||||||
# call the delete endpoint
|
|
||||||
resp = client.delete('/task/t_delete_assigned')
|
|
||||||
assert resp.status_code == 200
|
|
||||||
# verify the task id is no longer in the child's tasks
|
|
||||||
child = child_db.search(ChildQuery.id == 'child_for_task_delete')[0]
|
|
||||||
assert 't_delete_assigned' not in child.get('tasks', [])
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import os
|
|
||||||
os.environ['DB_ENV'] = 'test'
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from models.tracking_event import TrackingEvent
|
|
||||||
from db.tracking import (
|
|
||||||
insert_tracking_event,
|
|
||||||
get_tracking_events_by_child,
|
|
||||||
get_tracking_events_by_user,
|
|
||||||
anonymize_tracking_events_for_user
|
|
||||||
)
|
|
||||||
from db.db import tracking_events_db
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracking_event_creation():
|
|
||||||
"""Test creating a tracking event with factory method."""
|
|
||||||
event = TrackingEvent.create_event(
|
|
||||||
user_id='user123',
|
|
||||||
child_id='child456',
|
|
||||||
entity_type='task',
|
|
||||||
entity_id='task789',
|
|
||||||
action='activated',
|
|
||||||
points_before=10,
|
|
||||||
points_after=20,
|
|
||||||
metadata={'task_name': 'Homework'}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert event.user_id == 'user123'
|
|
||||||
assert event.child_id == 'child456'
|
|
||||||
assert event.entity_type == 'task'
|
|
||||||
assert event.action == 'activated'
|
|
||||||
assert event.points_before == 10
|
|
||||||
assert event.points_after == 20
|
|
||||||
assert event.delta == 10
|
|
||||||
assert event.metadata == {'task_name': 'Homework'}
|
|
||||||
assert event.occurred_at # Should have ISO timestamp
|
|
||||||
|
|
||||||
|
|
||||||
def test_tracking_event_delta_invariant():
|
|
||||||
"""Test that delta invariant is enforced."""
|
|
||||||
with pytest.raises(ValueError, match="Delta invariant violated"):
|
|
||||||
TrackingEvent(
|
|
||||||
user_id='user123',
|
|
||||||
child_id='child456',
|
|
||||||
entity_type='task',
|
|
||||||
entity_id='task789',
|
|
||||||
action='activated',
|
|
||||||
points_before=10,
|
|
||||||
points_after=20,
|
|
||||||
delta=5, # Wrong! Should be 10
|
|
||||||
occurred_at='2026-02-09T12:00:00Z'
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_insert_and_query_tracking_event():
|
|
||||||
"""Test inserting and querying tracking events."""
|
|
||||||
tracking_events_db.truncate()
|
|
||||||
|
|
||||||
event1 = TrackingEvent.create_event(
|
|
||||||
user_id='user1',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='task',
|
|
||||||
entity_id='task1',
|
|
||||||
action='activated',
|
|
||||||
points_before=0,
|
|
||||||
points_after=10
|
|
||||||
)
|
|
||||||
|
|
||||||
event2 = TrackingEvent.create_event(
|
|
||||||
user_id='user1',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='reward',
|
|
||||||
entity_id='reward1',
|
|
||||||
action='requested',
|
|
||||||
points_before=10,
|
|
||||||
points_after=10
|
|
||||||
)
|
|
||||||
|
|
||||||
insert_tracking_event(event1)
|
|
||||||
insert_tracking_event(event2)
|
|
||||||
|
|
||||||
# Query by child
|
|
||||||
events, total = get_tracking_events_by_child('child1', limit=10, offset=0)
|
|
||||||
assert total == 2
|
|
||||||
assert len(events) == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_query_with_filters():
|
|
||||||
"""Test querying with entity_type and action filters."""
|
|
||||||
tracking_events_db.truncate()
|
|
||||||
|
|
||||||
# Insert task activation
|
|
||||||
task_event = TrackingEvent.create_event(
|
|
||||||
user_id='user1',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='task',
|
|
||||||
entity_id='task1',
|
|
||||||
action='activated',
|
|
||||||
points_before=0,
|
|
||||||
points_after=10
|
|
||||||
)
|
|
||||||
insert_tracking_event(task_event)
|
|
||||||
|
|
||||||
# Insert reward request
|
|
||||||
reward_event = TrackingEvent.create_event(
|
|
||||||
user_id='user1',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='reward',
|
|
||||||
entity_id='reward1',
|
|
||||||
action='requested',
|
|
||||||
points_before=10,
|
|
||||||
points_after=10
|
|
||||||
)
|
|
||||||
insert_tracking_event(reward_event)
|
|
||||||
|
|
||||||
# Filter by entity_type
|
|
||||||
events, total = get_tracking_events_by_child('child1', entity_type='task')
|
|
||||||
assert total == 1
|
|
||||||
assert events[0].entity_type == 'task'
|
|
||||||
|
|
||||||
# Filter by action
|
|
||||||
events, total = get_tracking_events_by_child('child1', action='requested')
|
|
||||||
assert total == 1
|
|
||||||
assert events[0].action == 'requested'
|
|
||||||
|
|
||||||
|
|
||||||
def test_pagination():
|
|
||||||
"""Test offset-based pagination."""
|
|
||||||
tracking_events_db.truncate()
|
|
||||||
|
|
||||||
# Insert 5 events
|
|
||||||
for i in range(5):
|
|
||||||
event = TrackingEvent.create_event(
|
|
||||||
user_id='user1',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='task',
|
|
||||||
entity_id=f'task{i}',
|
|
||||||
action='activated',
|
|
||||||
points_before=i * 10,
|
|
||||||
points_after=(i + 1) * 10
|
|
||||||
)
|
|
||||||
insert_tracking_event(event)
|
|
||||||
|
|
||||||
# First page
|
|
||||||
events, total = get_tracking_events_by_child('child1', limit=2, offset=0)
|
|
||||||
assert total == 5
|
|
||||||
assert len(events) == 2
|
|
||||||
|
|
||||||
# Second page
|
|
||||||
events, total = get_tracking_events_by_child('child1', limit=2, offset=2)
|
|
||||||
assert total == 5
|
|
||||||
assert len(events) == 2
|
|
||||||
|
|
||||||
# Last page
|
|
||||||
events, total = get_tracking_events_by_child('child1', limit=2, offset=4)
|
|
||||||
assert total == 5
|
|
||||||
assert len(events) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_anonymize_tracking_events():
|
|
||||||
"""Test anonymizing tracking events on user deletion."""
|
|
||||||
tracking_events_db.truncate()
|
|
||||||
|
|
||||||
event = TrackingEvent.create_event(
|
|
||||||
user_id='user_to_delete',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='task',
|
|
||||||
entity_id='task1',
|
|
||||||
action='activated',
|
|
||||||
points_before=0,
|
|
||||||
points_after=10
|
|
||||||
)
|
|
||||||
insert_tracking_event(event)
|
|
||||||
|
|
||||||
# Anonymize
|
|
||||||
count = anonymize_tracking_events_for_user('user_to_delete')
|
|
||||||
assert count == 1
|
|
||||||
|
|
||||||
# Verify user_id is None
|
|
||||||
events, total = get_tracking_events_by_child('child1')
|
|
||||||
assert total == 1
|
|
||||||
assert events[0].user_id is None
|
|
||||||
assert events[0].child_id == 'child1' # Child data preserved
|
|
||||||
|
|
||||||
|
|
||||||
def test_points_change_correctness():
|
|
||||||
"""Test that points before/after/delta are tracked correctly."""
|
|
||||||
tracking_events_db.truncate()
|
|
||||||
|
|
||||||
# Task activation (points increase)
|
|
||||||
task_event = TrackingEvent.create_event(
|
|
||||||
user_id='user1',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='task',
|
|
||||||
entity_id='task1',
|
|
||||||
action='activated',
|
|
||||||
points_before=50,
|
|
||||||
points_after=60
|
|
||||||
)
|
|
||||||
assert task_event.delta == 10
|
|
||||||
insert_tracking_event(task_event)
|
|
||||||
|
|
||||||
# Reward redeem (points decrease)
|
|
||||||
reward_event = TrackingEvent.create_event(
|
|
||||||
user_id='user1',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='reward',
|
|
||||||
entity_id='reward1',
|
|
||||||
action='redeemed',
|
|
||||||
points_before=60,
|
|
||||||
points_after=40
|
|
||||||
)
|
|
||||||
assert reward_event.delta == -20
|
|
||||||
insert_tracking_event(reward_event)
|
|
||||||
|
|
||||||
# Query and verify
|
|
||||||
events, _ = get_tracking_events_by_child('child1')
|
|
||||||
assert len(events) == 2
|
|
||||||
assert events[0].delta == -20 # Most recent (sorted desc)
|
|
||||||
assert events[1].delta == 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_points_change_for_request_and_cancel():
|
|
||||||
"""Test that reward request and cancel have delta=0."""
|
|
||||||
tracking_events_db.truncate()
|
|
||||||
|
|
||||||
# Request
|
|
||||||
request_event = TrackingEvent.create_event(
|
|
||||||
user_id='user1',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='reward',
|
|
||||||
entity_id='reward1',
|
|
||||||
action='requested',
|
|
||||||
points_before=100,
|
|
||||||
points_after=100
|
|
||||||
)
|
|
||||||
assert request_event.delta == 0
|
|
||||||
insert_tracking_event(request_event)
|
|
||||||
|
|
||||||
# Cancel
|
|
||||||
cancel_event = TrackingEvent.create_event(
|
|
||||||
user_id='user1',
|
|
||||||
child_id='child1',
|
|
||||||
entity_type='reward',
|
|
||||||
entity_id='reward1',
|
|
||||||
action='cancelled',
|
|
||||||
points_before=100,
|
|
||||||
points_after=100
|
|
||||||
)
|
|
||||||
assert cancel_event.delta == 0
|
|
||||||
insert_tracking_event(cancel_event)
|
|
||||||
|
|
||||||
events, _ = get_tracking_events_by_child('child1')
|
|
||||||
assert all(e.delta == 0 for e in events)
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from flask import Flask
|
|
||||||
from api.user_api import user_api
|
|
||||||
from api.auth_api import auth_api
|
|
||||||
from db.db import users_db
|
|
||||||
from tinydb import Query
|
|
||||||
import jwt
|
|
||||||
from werkzeug.security import generate_password_hash
|
|
||||||
|
|
||||||
# Test user credentials
|
|
||||||
TEST_EMAIL = "usertest@example.com"
|
|
||||||
TEST_PASSWORD = "testpass123"
|
|
||||||
MARKED_EMAIL = "marked@example.com"
|
|
||||||
MARKED_PASSWORD = "markedpass"
|
|
||||||
|
|
||||||
def add_test_users():
|
|
||||||
"""Add test users to the database."""
|
|
||||||
# Remove if exists
|
|
||||||
users_db.remove(Query().email == TEST_EMAIL)
|
|
||||||
users_db.remove(Query().email == MARKED_EMAIL)
|
|
||||||
|
|
||||||
# Add regular test user
|
|
||||||
users_db.insert({
|
|
||||||
"id": "test_user_id",
|
|
||||||
"first_name": "Test",
|
|
||||||
"last_name": "User",
|
|
||||||
"email": TEST_EMAIL,
|
|
||||||
"password": generate_password_hash(TEST_PASSWORD),
|
|
||||||
"verified": True,
|
|
||||||
"image_id": "boy01",
|
|
||||||
"marked_for_deletion": False,
|
|
||||||
"marked_for_deletion_at": None
|
|
||||||
})
|
|
||||||
|
|
||||||
# Add user already marked for deletion
|
|
||||||
users_db.insert({
|
|
||||||
"id": "marked_user_id",
|
|
||||||
"first_name": "Marked",
|
|
||||||
"last_name": "User",
|
|
||||||
"email": MARKED_EMAIL,
|
|
||||||
"password": generate_password_hash(MARKED_PASSWORD),
|
|
||||||
"verified": True,
|
|
||||||
"image_id": "girl01",
|
|
||||||
"marked_for_deletion": True,
|
|
||||||
"marked_for_deletion_at": "2024-01-15T10:30:00+00:00"
|
|
||||||
})
|
|
||||||
|
|
||||||
def login_and_get_token(client, email, password):
|
|
||||||
"""Login and extract JWT token from response."""
|
|
||||||
resp = client.post('/auth/login', json={"email": email, "password": password})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
# Extract token from Set-Cookie header
|
|
||||||
set_cookie = resp.headers.get("Set-Cookie")
|
|
||||||
assert set_cookie and "token=" in set_cookie
|
|
||||||
# Flask test client automatically handles cookies
|
|
||||||
return resp
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client():
|
|
||||||
"""Setup Flask test client with registered blueprints."""
|
|
||||||
app = Flask(__name__)
|
|
||||||
app.register_blueprint(user_api)
|
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
|
||||||
app.config['TESTING'] = True
|
|
||||||
app.config['SECRET_KEY'] = 'supersecretkey'
|
|
||||||
app.config['FRONTEND_URL'] = 'http://localhost:5173' # Needed for email_sender
|
|
||||||
with app.test_client() as client:
|
|
||||||
add_test_users()
|
|
||||||
yield client
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def authenticated_client(client):
|
|
||||||
"""Setup client with authenticated user session."""
|
|
||||||
login_and_get_token(client, TEST_EMAIL, TEST_PASSWORD)
|
|
||||||
return client
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def marked_client(client):
|
|
||||||
"""Setup client with marked-for-deletion user session."""
|
|
||||||
login_and_get_token(client, MARKED_EMAIL, MARKED_PASSWORD)
|
|
||||||
return client
|
|
||||||
|
|
||||||
def test_mark_user_for_deletion_success(authenticated_client):
|
|
||||||
"""Test successfully marking a user account for deletion."""
|
|
||||||
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert data['success'] is True
|
|
||||||
|
|
||||||
# Verify database was updated
|
|
||||||
UserQuery = Query()
|
|
||||||
user = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
|
||||||
assert user['marked_for_deletion'] is True
|
|
||||||
assert user['marked_for_deletion_at'] is not None
|
|
||||||
|
|
||||||
# Verify timestamp is valid ISO format
|
|
||||||
marked_at = datetime.fromisoformat(user['marked_for_deletion_at'])
|
|
||||||
assert marked_at.tzinfo is not None
|
|
||||||
|
|
||||||
def test_login_for_marked_user_returns_403(client):
|
|
||||||
"""Test that login for a marked-for-deletion user returns 403 Forbidden."""
|
|
||||||
response = client.post('/auth/login', json={
|
|
||||||
"email": MARKED_EMAIL,
|
|
||||||
"password": MARKED_PASSWORD
|
|
||||||
})
|
|
||||||
assert response.status_code == 403
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
|
||||||
|
|
||||||
def test_mark_for_deletion_requires_auth(client):
|
|
||||||
"""Test that marking for deletion requires authentication."""
|
|
||||||
response = client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
|
||||||
assert response.status_code == 401
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
|
|
||||||
def test_login_blocked_for_marked_user(client):
|
|
||||||
"""Test that login is blocked for users marked for deletion."""
|
|
||||||
response = client.post('/auth/login', json={
|
|
||||||
"email": MARKED_EMAIL,
|
|
||||||
"password": MARKED_PASSWORD
|
|
||||||
})
|
|
||||||
assert response.status_code == 403
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
|
||||||
|
|
||||||
def test_login_succeeds_for_unmarked_user(client):
|
|
||||||
"""Test that login works normally for users not marked for deletion."""
|
|
||||||
response = client.post('/auth/login', json={
|
|
||||||
"email": TEST_EMAIL,
|
|
||||||
"password": TEST_PASSWORD
|
|
||||||
})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'message' in data
|
|
||||||
|
|
||||||
def test_password_reset_ignored_for_marked_user(client):
|
|
||||||
"""Test that password reset requests return 403 for marked users."""
|
|
||||||
response = client.post('/auth/request-password-reset', json={"email": MARKED_EMAIL})
|
|
||||||
assert response.status_code == 403
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
assert data['code'] == 'ACCOUNT_MARKED_FOR_DELETION'
|
|
||||||
|
|
||||||
def test_password_reset_works_for_unmarked_user(client):
|
|
||||||
"""Test that password reset works normally for unmarked users."""
|
|
||||||
response = client.post('/auth/request-password-reset', json={"email": TEST_EMAIL})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'message' in data
|
|
||||||
|
|
||||||
def test_mark_for_deletion_updates_timestamp(authenticated_client):
|
|
||||||
"""Test that marking for deletion sets a proper timestamp."""
|
|
||||||
before_time = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
after_time = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# Verify timestamp is between before and after
|
|
||||||
UserQuery = Query()
|
|
||||||
user = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
|
||||||
marked_at = datetime.fromisoformat(user['marked_for_deletion_at'])
|
|
||||||
|
|
||||||
assert before_time <= marked_at <= after_time
|
|
||||||
|
|
||||||
|
|
||||||
def test_mark_for_deletion_clears_tokens(authenticated_client):
|
|
||||||
"""When an account is marked for deletion, verify/reset tokens must be cleared."""
|
|
||||||
# Seed verify/reset tokens for the user
|
|
||||||
UserQuery = Query()
|
|
||||||
now_iso = datetime.utcnow().isoformat()
|
|
||||||
users_db.update({
|
|
||||||
'verify_token': 'verify-abc',
|
|
||||||
'verify_token_created': now_iso,
|
|
||||||
'reset_token': 'reset-xyz',
|
|
||||||
'reset_token_created': now_iso
|
|
||||||
}, UserQuery.email == TEST_EMAIL)
|
|
||||||
|
|
||||||
# Ensure tokens are present before marking
|
|
||||||
user_before = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
|
||||||
assert user_before['verify_token'] is not None
|
|
||||||
assert user_before['reset_token'] is not None
|
|
||||||
|
|
||||||
# Mark account for deletion
|
|
||||||
response = authenticated_client.post('/user/mark-for-deletion', json={"email": TEST_EMAIL})
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Verify tokens were cleared in the DB
|
|
||||||
user_after = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
|
||||||
assert user_after.get('verify_token') is None
|
|
||||||
assert user_after.get('verify_token_created') is None
|
|
||||||
assert user_after.get('reset_token') is None
|
|
||||||
assert user_after.get('reset_token_created') is None
|
|
||||||
|
|
||||||
def test_mark_for_deletion_with_invalid_jwt(client):
|
|
||||||
"""Test marking for deletion with invalid JWT token."""
|
|
||||||
# Set invalid cookie manually
|
|
||||||
client.set_cookie('token', 'invalid.jwt.token')
|
|
||||||
|
|
||||||
response = client.post('/user/mark-for-deletion', json={})
|
|
||||||
assert response.status_code == 401
|
|
||||||
data = response.get_json()
|
|
||||||
assert 'error' in data
|
|
||||||
|
|
||||||
def test_update_profile_success(authenticated_client):
|
|
||||||
"""Test successfully updating user profile."""
|
|
||||||
response = authenticated_client.put('/user/profile', json={
|
|
||||||
'first_name': 'Updated',
|
|
||||||
'last_name': 'Name',
|
|
||||||
'image_id': 'new_image'
|
|
||||||
})
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.get_json()
|
|
||||||
assert data['message'] == 'Profile updated'
|
|
||||||
|
|
||||||
# Verify database was updated
|
|
||||||
UserQuery = Query()
|
|
||||||
user = users_db.search(UserQuery.email == TEST_EMAIL)[0]
|
|
||||||
assert user['first_name'] == 'Updated'
|
|
||||||
assert user['last_name'] == 'Name'
|
|
||||||
assert user['image_id'] == 'new_image'
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
|
||||||
from tinydb import Query
|
|
||||||
|
|
||||||
from config.deletion_config import ACCOUNT_DELETION_THRESHOLD_HOURS
|
|
||||||
from config.paths import get_user_image_dir
|
|
||||||
from db.db import users_db, child_db, task_db, reward_db, image_db, pending_reward_db
|
|
||||||
from models.user import User
|
|
||||||
from events.types.event import Event
|
|
||||||
from events.types.event_types import EventType
|
|
||||||
from events.types.user_deleted import UserDeleted
|
|
||||||
from events.sse import send_to_user
|
|
||||||
|
|
||||||
# Setup dedicated logger for account deletion
|
|
||||||
logger = logging.getLogger('account_deletion_scheduler')
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
|
|
||||||
# Create logs directory if it doesn't exist
|
|
||||||
os.makedirs('logs', exist_ok=True)
|
|
||||||
|
|
||||||
# Add rotating file handler
|
|
||||||
file_handler = RotatingFileHandler(
|
|
||||||
'logs/account_deletion.log',
|
|
||||||
maxBytes=10*1024*1024, # 10MB
|
|
||||||
backupCount=5
|
|
||||||
)
|
|
||||||
file_handler.setFormatter(
|
|
||||||
logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
)
|
|
||||||
logger.addHandler(file_handler)
|
|
||||||
|
|
||||||
# Also log to stdout
|
|
||||||
console_handler = logging.StreamHandler()
|
|
||||||
console_handler.setFormatter(
|
|
||||||
logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
)
|
|
||||||
logger.addHandler(console_handler)
|
|
||||||
|
|
||||||
MAX_DELETION_ATTEMPTS = 3
|
|
||||||
|
|
||||||
def send_user_deleted_event_to_admins(user_id: str, email: str, deleted_at: str):
|
|
||||||
"""
|
|
||||||
Send USER_DELETED event to all admin users.
|
|
||||||
|
|
||||||
TODO: Currently sends to all authenticated users with active SSE connections.
|
|
||||||
In production, this should filter to only users with admin role.
|
|
||||||
"""
|
|
||||||
event = Event(
|
|
||||||
EventType.USER_DELETED.value,
|
|
||||||
UserDeleted(user_id, email, deleted_at)
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: Get list of admin users and send only to them
|
|
||||||
# For now, we'll skip broadcasting since we don't have a way to get all active admin connections
|
|
||||||
# This will need to be implemented when admin role system is in place
|
|
||||||
logger.info(f"USER_DELETED event created for {user_id} ({email}) at {deleted_at}")
|
|
||||||
# Future implementation:
|
|
||||||
# admin_users = get_admin_users()
|
|
||||||
# for admin in admin_users:
|
|
||||||
# send_to_user(admin.id, event.to_dict())
|
|
||||||
|
|
||||||
def is_user_due_for_deletion(user: User) -> bool:
|
|
||||||
"""
|
|
||||||
Check if a user is due for deletion based on marked_for_deletion_at timestamp
|
|
||||||
and the configured threshold.
|
|
||||||
"""
|
|
||||||
if not user.marked_for_deletion or not user.marked_for_deletion_at:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
marked_at = datetime.fromisoformat(user.marked_for_deletion_at)
|
|
||||||
threshold_delta = timedelta(hours=ACCOUNT_DELETION_THRESHOLD_HOURS)
|
|
||||||
due_at = marked_at + threshold_delta
|
|
||||||
|
|
||||||
# Get current time - make it timezone-aware if marked_at is timezone-aware
|
|
||||||
now = datetime.now()
|
|
||||||
if marked_at.tzinfo is not None:
|
|
||||||
# Convert marked_at to naive UTC for comparison
|
|
||||||
marked_at = marked_at.replace(tzinfo=None)
|
|
||||||
due_at = marked_at + threshold_delta
|
|
||||||
|
|
||||||
return now >= due_at
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
logger.error(f"Error parsing marked_for_deletion_at for user {user.id}: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_deletion_attempt_count(user: User) -> int:
|
|
||||||
"""
|
|
||||||
Calculate the number of deletion attempts based on deletion_attempted_at.
|
|
||||||
This is a simplified version - in practice, you might track attempts differently.
|
|
||||||
"""
|
|
||||||
# For now, we'll consider any user with deletion_attempted_at as having 1 attempt
|
|
||||||
# In a more robust system, you'd track this in a separate field or table
|
|
||||||
if user.deletion_attempted_at:
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def delete_user_data(user: User) -> bool:
|
|
||||||
"""
|
|
||||||
Delete all data associated with a user in the correct order.
|
|
||||||
Returns True if successful, False otherwise.
|
|
||||||
"""
|
|
||||||
user_id = user.id
|
|
||||||
success = True
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Step 1: Set deletion_in_progress flag
|
|
||||||
logger.info(f"Starting deletion for user {user_id} ({user.email})")
|
|
||||||
Query_ = Query()
|
|
||||||
users_db.update({'deletion_in_progress': True}, Query_.id == user_id)
|
|
||||||
|
|
||||||
# Step 2: Remove pending rewards for user's children
|
|
||||||
try:
|
|
||||||
children = child_db.search(Query_.user_id == user_id)
|
|
||||||
child_ids = [child['id'] for child in children]
|
|
||||||
|
|
||||||
if child_ids:
|
|
||||||
for child_id in child_ids:
|
|
||||||
removed = pending_reward_db.remove(Query_.child_id == child_id)
|
|
||||||
if removed:
|
|
||||||
logger.info(f"Deleted {len(removed)} pending rewards for child {child_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete pending rewards for user {user_id}: {e}")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Step 3: Remove children
|
|
||||||
try:
|
|
||||||
removed = child_db.remove(Query_.user_id == user_id)
|
|
||||||
if removed:
|
|
||||||
logger.info(f"Deleted {len(removed)} children for user {user_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete children for user {user_id}: {e}")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Step 4: Remove user-created tasks
|
|
||||||
try:
|
|
||||||
removed = task_db.remove(Query_.user_id == user_id)
|
|
||||||
if removed:
|
|
||||||
logger.info(f"Deleted {len(removed)} tasks for user {user_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete tasks for user {user_id}: {e}")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Step 5: Remove user-created rewards
|
|
||||||
try:
|
|
||||||
removed = reward_db.remove(Query_.user_id == user_id)
|
|
||||||
if removed:
|
|
||||||
logger.info(f"Deleted {len(removed)} rewards for user {user_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete rewards for user {user_id}: {e}")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Step 6: Remove user's images from database
|
|
||||||
try:
|
|
||||||
removed = image_db.remove(Query_.user_id == user_id)
|
|
||||||
if removed:
|
|
||||||
logger.info(f"Deleted {len(removed)} images from database for user {user_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete images from database for user {user_id}: {e}")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Step 7: Delete user's image directory from filesystem
|
|
||||||
try:
|
|
||||||
user_image_dir = get_user_image_dir(user_id)
|
|
||||||
if os.path.exists(user_image_dir):
|
|
||||||
shutil.rmtree(user_image_dir)
|
|
||||||
logger.info(f"Deleted image directory for user {user_id}")
|
|
||||||
else:
|
|
||||||
logger.info(f"Image directory for user {user_id} does not exist (already deleted or never created)")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete image directory for user {user_id}: {e}")
|
|
||||||
success = False
|
|
||||||
|
|
||||||
# Step 8: Remove user record
|
|
||||||
if success:
|
|
||||||
try:
|
|
||||||
users_db.remove(Query_.id == user_id)
|
|
||||||
deleted_at = datetime.now().isoformat()
|
|
||||||
logger.info(f"Successfully deleted user {user_id} ({user.email})")
|
|
||||||
|
|
||||||
# Send USER_DELETED event to admin users
|
|
||||||
send_user_deleted_event_to_admins(user_id, user.email, deleted_at)
|
|
||||||
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to delete user record for {user_id}: {e}")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
# Deletion failed, update flags
|
|
||||||
logger.error(f"Deletion failed for user {user_id}, marking for retry")
|
|
||||||
users_db.update({
|
|
||||||
'deletion_in_progress': False,
|
|
||||||
'deletion_attempted_at': datetime.now().isoformat()
|
|
||||||
}, Query_.id == user_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Unexpected error during deletion for user {user_id}: {e}")
|
|
||||||
# Try to clear the in_progress flag
|
|
||||||
try:
|
|
||||||
users_db.update({
|
|
||||||
'deletion_in_progress': False,
|
|
||||||
'deletion_attempted_at': datetime.now().isoformat()
|
|
||||||
}, Query_.id == user_id)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
def process_deletion_queue(force=False):
|
|
||||||
"""
|
|
||||||
Process the deletion queue: find users due for deletion and delete them.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
force (bool): If True, delete all marked users immediately without checking threshold.
|
|
||||||
If False, only delete users past the threshold time.
|
|
||||||
"""
|
|
||||||
if force:
|
|
||||||
logger.info("Starting FORCED deletion scheduler run (bypassing time threshold)")
|
|
||||||
else:
|
|
||||||
logger.info("Starting deletion scheduler run")
|
|
||||||
|
|
||||||
processed = 0
|
|
||||||
deleted = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get all marked users
|
|
||||||
Query_ = Query()
|
|
||||||
marked_users = users_db.search(Query_.marked_for_deletion == True)
|
|
||||||
|
|
||||||
if not marked_users:
|
|
||||||
logger.info("No users marked for deletion")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info(f"Found {len(marked_users)} users marked for deletion")
|
|
||||||
|
|
||||||
for user_dict in marked_users:
|
|
||||||
user = User.from_dict(user_dict)
|
|
||||||
processed += 1
|
|
||||||
|
|
||||||
# Check if user is due for deletion (skip check if force=True)
|
|
||||||
if not force and not is_user_due_for_deletion(user):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check retry limit
|
|
||||||
attempt_count = get_deletion_attempt_count(user)
|
|
||||||
if attempt_count >= MAX_DELETION_ATTEMPTS:
|
|
||||||
logger.critical(
|
|
||||||
f"User {user.id} ({user.email}) has failed deletion {attempt_count} times. "
|
|
||||||
"Manual intervention required."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip if deletion is already in progress (from a previous run)
|
|
||||||
if user.deletion_in_progress:
|
|
||||||
logger.warning(
|
|
||||||
f"User {user.id} ({user.email}) has deletion_in_progress=True. "
|
|
||||||
"This may indicate a previous run was interrupted. Retrying..."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Attempt deletion
|
|
||||||
if delete_user_data(user):
|
|
||||||
deleted += 1
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Deletion scheduler run complete: "
|
|
||||||
f"{processed} users processed, {deleted} deleted, {failed} failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in deletion scheduler: {e}")
|
|
||||||
|
|
||||||
def check_interrupted_deletions():
|
|
||||||
"""
|
|
||||||
On startup, check for users with deletion_in_progress=True
|
|
||||||
and retry their deletion.
|
|
||||||
"""
|
|
||||||
logger.info("Checking for interrupted deletions from previous runs")
|
|
||||||
|
|
||||||
try:
|
|
||||||
Query_ = Query()
|
|
||||||
interrupted_users = users_db.search(
|
|
||||||
(Query_.marked_for_deletion == True) &
|
|
||||||
(Query_.deletion_in_progress == True)
|
|
||||||
)
|
|
||||||
|
|
||||||
if interrupted_users:
|
|
||||||
logger.warning(
|
|
||||||
f"Found {len(interrupted_users)} users with interrupted deletions. "
|
|
||||||
"Will retry on next scheduler run."
|
|
||||||
)
|
|
||||||
# Reset the flag so they can be retried
|
|
||||||
for user_dict in interrupted_users:
|
|
||||||
users_db.update(
|
|
||||||
{'deletion_in_progress': False},
|
|
||||||
Query_.id == user_dict['id']
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking for interrupted deletions: {e}")
|
|
||||||
|
|
||||||
# Global scheduler instance
|
|
||||||
_scheduler = None
|
|
||||||
|
|
||||||
def start_deletion_scheduler():
|
|
||||||
"""
|
|
||||||
Start the background deletion scheduler.
|
|
||||||
Should be called once during application startup.
|
|
||||||
"""
|
|
||||||
global _scheduler
|
|
||||||
|
|
||||||
if _scheduler is not None:
|
|
||||||
logger.warning("Deletion scheduler is already running")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.info("Starting account deletion scheduler")
|
|
||||||
|
|
||||||
# Check for interrupted deletions from previous runs
|
|
||||||
check_interrupted_deletions()
|
|
||||||
|
|
||||||
# Create and start scheduler
|
|
||||||
_scheduler = BackgroundScheduler()
|
|
||||||
|
|
||||||
# Run every hour
|
|
||||||
_scheduler.add_job(
|
|
||||||
process_deletion_queue,
|
|
||||||
'interval',
|
|
||||||
hours=1,
|
|
||||||
id='account_deletion',
|
|
||||||
name='Account Deletion Scheduler',
|
|
||||||
replace_existing=True
|
|
||||||
)
|
|
||||||
|
|
||||||
_scheduler.start()
|
|
||||||
logger.info("Account deletion scheduler started (runs every 1 hour)")
|
|
||||||
|
|
||||||
def stop_deletion_scheduler():
|
|
||||||
"""
|
|
||||||
Stop the deletion scheduler (for testing or shutdown).
|
|
||||||
"""
|
|
||||||
global _scheduler
|
|
||||||
|
|
||||||
if _scheduler is not None:
|
|
||||||
_scheduler.shutdown()
|
|
||||||
_scheduler = None
|
|
||||||
logger.info("Account deletion scheduler stopped")
|
|
||||||
|
|
||||||
def trigger_deletion_manually():
|
|
||||||
"""
|
|
||||||
Manually trigger the deletion process (for admin use).
|
|
||||||
Deletes all marked users immediately without waiting for threshold.
|
|
||||||
Returns stats about the run.
|
|
||||||
"""
|
|
||||||
logger.info("Manual deletion trigger requested - forcing immediate deletion")
|
|
||||||
process_deletion_queue(force=True)
|
|
||||||
|
|
||||||
# Return stats (simplified version)
|
|
||||||
Query_ = Query()
|
|
||||||
marked_users = users_db.search(Query_.marked_for_deletion == True)
|
|
||||||
return {
|
|
||||||
'triggered': True,
|
|
||||||
'queued_users': len(marked_users)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
from flask import current_app
|
|
||||||
from flask_mail import Mail, Message
|
|
||||||
|
|
||||||
def send_verification_email(to_email: str, token: str) -> None:
|
|
||||||
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.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
Mail(current_app).send(msg)
|
|
||||||
print(f"[EMAIL to {to_email}] Verification: {verify_url}")
|
|
||||||
except Exception:
|
|
||||||
print(f"Failed to send email to {to_email}. Verification link: {verify_url}")
|
|
||||||
|
|
||||||
def send_reset_password_email(to_email: str, token: str) -> None:
|
|
||||||
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.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
Mail(current_app).send(msg)
|
|
||||||
print(f"[EMAIL to {to_email}] Reset password: {reset_url}")
|
|
||||||
except Exception:
|
|
||||||
print(f"Failed to send email to {to_email}. Reset link: {reset_url}")
|
|
||||||
|
|
||||||
def send_pin_setup_email(to_email: str, code: str) -> None:
|
|
||||||
html_body = f"""
|
|
||||||
<div style='font-family:sans-serif;'>
|
|
||||||
<h2>Set up your Parent PIN</h2>
|
|
||||||
<p>To set your Parent PIN, enter the following code in the app:</p>
|
|
||||||
<div style='font-size:2rem; font-weight:bold; letter-spacing:0.2em; margin:1.5rem 0;'>{code}</div>
|
|
||||||
<p>This code is valid for 10 minutes.</p>
|
|
||||||
<p>If you did not request this, you can ignore this email.</p>
|
|
||||||
<hr>
|
|
||||||
<div style='color:#888;font-size:0.95rem;'>Reward App</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
msg = Message(
|
|
||||||
subject="Set up your Parent PIN",
|
|
||||||
recipients=[to_email],
|
|
||||||
html=html_body,
|
|
||||||
sender=current_app.config.get('MAIL_DEFAULT_SENDER', 'no-reply@reward-app.local')
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
Mail(current_app).send(msg)
|
|
||||||
print(f"[EMAIL to {to_email}] Parent PIN setup code: {code}")
|
|
||||||
except Exception:
|
|
||||||
print(f"Failed to send email to {to_email}. Parent PIN setup code: {code}")
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""Per-user rotating audit logger for tracking events."""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from logging.handlers import RotatingFileHandler
|
|
||||||
from config.paths import get_logs_dir
|
|
||||||
from models.tracking_event import TrackingEvent
|
|
||||||
|
|
||||||
|
|
||||||
# Store handlers per user_id to avoid recreating
|
|
||||||
_user_loggers = {}
|
|
||||||
|
|
||||||
|
|
||||||
def get_tracking_logger(user_id: str) -> logging.Logger:
|
|
||||||
"""
|
|
||||||
Get or create a per-user rotating file logger for tracking events.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user_id: User ID for the log file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Logger instance configured for the user
|
|
||||||
"""
|
|
||||||
if user_id in _user_loggers:
|
|
||||||
return _user_loggers[user_id]
|
|
||||||
|
|
||||||
logs_dir = get_logs_dir()
|
|
||||||
os.makedirs(logs_dir, exist_ok=True)
|
|
||||||
|
|
||||||
log_file = os.path.join(logs_dir, f'tracking_user_{user_id}.log')
|
|
||||||
|
|
||||||
logger = logging.getLogger(f'tracking.user.{user_id}')
|
|
||||||
logger.setLevel(logging.INFO)
|
|
||||||
logger.propagate = False # Don't propagate to root logger
|
|
||||||
|
|
||||||
# Rotating file handler: 10MB max, keep 5 backups
|
|
||||||
handler = RotatingFileHandler(
|
|
||||||
log_file,
|
|
||||||
maxBytes=10 * 1024 * 1024, # 10MB
|
|
||||||
backupCount=5,
|
|
||||||
encoding='utf-8'
|
|
||||||
)
|
|
||||||
|
|
||||||
formatter = logging.Formatter(
|
|
||||||
'%(asctime)s | %(message)s',
|
|
||||||
datefmt='%Y-%m-%d %H:%M:%S'
|
|
||||||
)
|
|
||||||
handler.setFormatter(formatter)
|
|
||||||
|
|
||||||
logger.addHandler(handler)
|
|
||||||
_user_loggers[user_id] = logger
|
|
||||||
|
|
||||||
return logger
|
|
||||||
|
|
||||||
|
|
||||||
def log_tracking_event(event: TrackingEvent) -> None:
|
|
||||||
"""
|
|
||||||
Log a tracking event to the user's audit log file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: TrackingEvent to log
|
|
||||||
"""
|
|
||||||
if not event.user_id:
|
|
||||||
# If user was deleted (anonymized), skip logging
|
|
||||||
return
|
|
||||||
|
|
||||||
logger = get_tracking_logger(event.user_id)
|
|
||||||
|
|
||||||
log_msg = (
|
|
||||||
f"user_id={event.user_id} | "
|
|
||||||
f"child_id={event.child_id} | "
|
|
||||||
f"entity_type={event.entity_type} | "
|
|
||||||
f"entity_id={event.entity_id} | "
|
|
||||||
f"action={event.action} | "
|
|
||||||
f"points_before={event.points_before} | "
|
|
||||||
f"points_after={event.points_after} | "
|
|
||||||
f"delta={event.delta:+d} | "
|
|
||||||
f"occurred_at={event.occurred_at}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if event.metadata:
|
|
||||||
metadata_str = ' | '.join(f"{k}={v}" for k, v in event.metadata.items())
|
|
||||||
log_msg += f" | {metadata_str}"
|
|
||||||
|
|
||||||
logger.info(log_msg)
|
|
||||||
BIN
chore.bundle
Normal file
BIN
chore.bundle
Normal file
Binary file not shown.
@@ -9,33 +9,19 @@ TEST_DATA_DIR_NAME = 'test_data'
|
|||||||
# Project root (two levels up from this file)
|
# Project root (two levels up from this file)
|
||||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
def get_base_data_dir(data_env: str | None = None) -> str:
|
|
||||||
"""
|
|
||||||
Return the absolute base data directory path for the given env.
|
|
||||||
data_env: 'prod' uses `data`, anything else uses `test_data`.
|
|
||||||
"""
|
|
||||||
env = (data_env or os.environ.get('DATA_ENV', 'prod')).lower()
|
|
||||||
base_name = DATA_DIR_NAME if env == 'prod' else TEST_DATA_DIR_NAME
|
|
||||||
return os.path.join(PROJECT_ROOT, base_name)
|
|
||||||
|
|
||||||
def get_database_dir(db_env: str | None = None) -> str:
|
def get_database_dir(db_env: str | None = None) -> str:
|
||||||
"""
|
"""
|
||||||
Return the absolute base directory path for the given DB env.
|
Return the absolute base directory path for the given DB env.
|
||||||
db_env: 'prod' uses `data/db`, anything else uses `test_data/db`.
|
db_env: 'prod' uses `data/db`, anything else uses `test_data/db`.
|
||||||
"""
|
"""
|
||||||
env = (db_env or os.environ.get('DB_ENV', 'prod')).lower()
|
env = (db_env or os.environ.get('DB_ENV', 'prod')).lower()
|
||||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(env), 'db')
|
base_name = DATA_DIR_NAME if env == 'prod' else TEST_DATA_DIR_NAME
|
||||||
|
return os.path.join(PROJECT_ROOT, base_name, 'db')
|
||||||
|
|
||||||
def get_user_image_dir(username: str | None) -> str:
|
def get_user_image_dir(username: str | None) -> str:
|
||||||
"""
|
"""
|
||||||
Return the absolute directory path for storing images for a specific user.
|
Return the absolute directory path for storing images for a specific user.
|
||||||
"""
|
"""
|
||||||
if username:
|
if username:
|
||||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images', username)
|
return os.path.join(PROJECT_ROOT, DATA_DIR_NAME, 'images', username)
|
||||||
return os.path.join(PROJECT_ROOT, 'resources', 'images')
|
return os.path.join(PROJECT_ROOT, 'resources', 'images')
|
||||||
|
|
||||||
def get_logs_dir() -> str:
|
|
||||||
"""
|
|
||||||
Return the absolute directory path for application logs.
|
|
||||||
"""
|
|
||||||
return os.path.join(PROJECT_ROOT, 'logs')
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# file: config/version.py
|
# file: config/version.py
|
||||||
import os
|
import os
|
||||||
|
|
||||||
BASE_VERSION = "1.0.5" # update manually when releasing features
|
BASE_VERSION = "1.0.3" # update manually when releasing features
|
||||||
|
|
||||||
def get_full_version() -> str:
|
def get_full_version() -> str:
|
||||||
"""
|
"""
|
||||||
BIN
data/images/user123/26b69abf-2428-4a44-904d-fd124ff3e913.jpg
Normal file
BIN
data/images/user123/26b69abf-2428-4a44-904d-fd124ff3e913.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
data/images/user123/d4f830a2-fa42-4f8c-8d78-9af380fedeb0.png
Normal file
BIN
data/images/user123/d4f830a2-fa42-4f8c-8d78-9af380fedeb0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
@@ -73,8 +73,6 @@ reward_path = os.path.join(base_dir, 'rewards.json')
|
|||||||
image_path = os.path.join(base_dir, 'images.json')
|
image_path = os.path.join(base_dir, 'images.json')
|
||||||
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
||||||
users_path = os.path.join(base_dir, 'users.json')
|
users_path = os.path.join(base_dir, 'users.json')
|
||||||
tracking_events_path = os.path.join(base_dir, 'tracking_events.json')
|
|
||||||
child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
|
|
||||||
|
|
||||||
# Use separate TinyDB instances/files for each collection
|
# Use separate TinyDB instances/files for each collection
|
||||||
_child_db = TinyDB(child_path, indent=2)
|
_child_db = TinyDB(child_path, indent=2)
|
||||||
@@ -83,8 +81,6 @@ _reward_db = TinyDB(reward_path, indent=2)
|
|||||||
_image_db = TinyDB(image_path, indent=2)
|
_image_db = TinyDB(image_path, indent=2)
|
||||||
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
||||||
_users_db = TinyDB(users_path, indent=2)
|
_users_db = TinyDB(users_path, indent=2)
|
||||||
_tracking_events_db = TinyDB(tracking_events_path, indent=2)
|
|
||||||
_child_overrides_db = TinyDB(child_overrides_path, indent=2)
|
|
||||||
|
|
||||||
# Expose table objects wrapped with locking
|
# Expose table objects wrapped with locking
|
||||||
child_db = LockedTable(_child_db)
|
child_db = LockedTable(_child_db)
|
||||||
@@ -93,8 +89,6 @@ reward_db = LockedTable(_reward_db)
|
|||||||
image_db = LockedTable(_image_db)
|
image_db = LockedTable(_image_db)
|
||||||
pending_reward_db = LockedTable(_pending_rewards_db)
|
pending_reward_db = LockedTable(_pending_rewards_db)
|
||||||
users_db = LockedTable(_users_db)
|
users_db = LockedTable(_users_db)
|
||||||
tracking_events_db = LockedTable(_tracking_events_db)
|
|
||||||
child_overrides_db = LockedTable(_child_overrides_db)
|
|
||||||
|
|
||||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||||
child_db.truncate()
|
child_db.truncate()
|
||||||
@@ -103,6 +97,4 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
|||||||
image_db.truncate()
|
image_db.truncate()
|
||||||
pending_reward_db.truncate()
|
pending_reward_db.truncate()
|
||||||
users_db.truncate()
|
users_db.truncate()
|
||||||
tracking_events_db.truncate()
|
|
||||||
child_overrides_db.truncate()
|
|
||||||
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
# File: db/debug.py
|
# File: db/debug.py
|
||||||
|
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
from api.image_api import IMAGE_TYPE_ICON, IMAGE_TYPE_PROFILE
|
from api.image_api import IMAGE_TYPE_ICON, IMAGE_TYPE_PROFILE
|
||||||
from db.db import task_db, reward_db, image_db
|
from db.db import task_db, reward_db, image_db
|
||||||
@@ -121,22 +119,7 @@ def createDefaultRewards():
|
|||||||
reward_db.insert(reward.to_dict())
|
reward_db.insert(reward.to_dict())
|
||||||
|
|
||||||
def initializeImages():
|
def initializeImages():
|
||||||
|
"""Initialize the image database with default images if empty."""
|
||||||
"""Initialize the image database with default images if empty, and copy images to data/images/default."""
|
|
||||||
# Step 1: Create data/images/default directory if it doesn't exist
|
|
||||||
default_img_dir = os.path.join(os.path.dirname(__file__), '../data/images/default')
|
|
||||||
os.makedirs(default_img_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# Step 2: Copy all image files from resources/images/ to data/images/default
|
|
||||||
src_img_dir = os.path.join(os.path.dirname(__file__), '../resources/images')
|
|
||||||
if os.path.exists(src_img_dir):
|
|
||||||
for fname in os.listdir(src_img_dir):
|
|
||||||
src_path = os.path.join(src_img_dir, fname)
|
|
||||||
dst_path = os.path.join(default_img_dir, fname)
|
|
||||||
if os.path.isfile(src_path):
|
|
||||||
shutil.copy2(src_path, dst_path)
|
|
||||||
|
|
||||||
# Original DB initialization logic
|
|
||||||
if len(image_db.all()) == 0:
|
if len(image_db.all()) == 0:
|
||||||
image_defs = [
|
image_defs = [
|
||||||
('boy01', IMAGE_TYPE_PROFILE, '.png', True),
|
('boy01', IMAGE_TYPE_PROFILE, '.png', True),
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# yaml
|
|
||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
|
||||||
chores-test-app-backend: # Test backend service name
|
|
||||||
image: git.ryankegel.com:3000/kegel/chores/backend:next # Use latest next tag
|
|
||||||
ports:
|
|
||||||
- "5004:5000" # Host 5004 -> Container 5000
|
|
||||||
environment:
|
|
||||||
- FLASK_ENV=development
|
|
||||||
- FRONTEND_URL=https://devserver.lan:446 # Add this for test env
|
|
||||||
# Add volumes, networks, etc., as needed
|
|
||||||
|
|
||||||
chores-test-app-frontend: # Test frontend service name
|
|
||||||
image: git.ryankegel.com:3000/kegel/chores/frontend:next # Use latest next tag
|
|
||||||
ports:
|
|
||||||
- "446:443" # Host 446 -> Container 443 (HTTPS)
|
|
||||||
environment:
|
|
||||||
- BACKEND_HOST=chores-test-app-backend # Points to internal backend service
|
|
||||||
depends_on:
|
|
||||||
- chores-test-app-backend
|
|
||||||
# Add volumes, networks, etc., as needed
|
|
||||||
|
|
||||||
networks:
|
|
||||||
chores-test-app-net:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
chores-test-app-backend-data: {}
|
|
||||||
@@ -1,37 +1,30 @@
|
|||||||
# yaml
|
# yaml
|
||||||
version: "3.8"
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
chores-app-backend: # Production backend service name
|
chore-app-backend:
|
||||||
image: git.ryankegel.com:3000/kegel/chores/backend:latest # Or specific version tag
|
image: devserver.lan:5900/chore-app-backend:production
|
||||||
container_name: chores-app-backend-prod # Added for easy identification
|
container_name: chore-app-backend
|
||||||
ports:
|
restart: unless-stopped
|
||||||
- "5001:5000" # Host 5001 -> Container 5000
|
expose:
|
||||||
environment:
|
- "5000"
|
||||||
- FLASK_ENV=production
|
networks:
|
||||||
- FRONTEND_URL=${FRONTEND_URL}
|
- chore-app-net
|
||||||
volumes:
|
volumes:
|
||||||
- chores-app-backend-data:/app/data # Assuming backend data storage; adjust path as needed
|
- chore-app-backend-data:/app/data # persists backend data
|
||||||
networks:
|
|
||||||
- chores-app-net
|
|
||||||
# Add other volumes, networks, etc., as needed
|
|
||||||
|
|
||||||
chores-app-frontend: # Production frontend service name
|
chore-app-frontend:
|
||||||
image: git.ryankegel.com:3000/kegel/chores/frontend:latest # Or specific version tag
|
image: devserver.lan:5900/chore-app-frontend:production
|
||||||
container_name: chores-app-frontend-prod # Added for easy identification
|
container_name: chore-app-frontend
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "443:443" # Host 443 -> Container 443 (HTTPS)
|
- "4600:443"
|
||||||
environment:
|
|
||||||
- BACKEND_HOST=chores-app-backend # Points to internal backend service
|
|
||||||
depends_on:
|
|
||||||
- chores-app-backend
|
|
||||||
networks:
|
networks:
|
||||||
- chores-app-net
|
- chore-app-net
|
||||||
# Add volumes, networks, etc., as needed
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
chores-app-net:
|
chore-app-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
chores-app-backend-data: {}
|
chore-app-backend-data: {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user