Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 087aa07a74 | |||
| 8cb9199ab7 | |||
| bbdabefd62 | |||
| a7ac179e1a | |||
| 53236ab019 | |||
| 8708a1a68f | |||
| 8008f1d116 | |||
| c18d202ecc | |||
| 725bf518ea | |||
| 31ea76f013 | |||
| 5e22e5e0ee | |||
| 7e7a2ef49e | |||
| 3e1715e487 | |||
| 11e7fda997 | |||
| 09d42b14c5 | |||
| 3848be32e8 | |||
| 1aff366fd8 | |||
| 0ab40f85a4 | |||
| 22889caab4 | |||
| b538782c09 | |||
|
|
7a827b14ef | ||
| 9238d7e3a5 | |||
| c17838241a | |||
| d183e0a4b6 | |||
| b25ebaaec0 | |||
| ae5b40512c | |||
| 92635a356c | |||
| 235269bdb6 | |||
| 5d4b0ec2c9 | |||
| a21cb60aeb | |||
| e604870e26 | |||
| c3e35258a1 | |||
| d2a56e36c7 | |||
| 3bfca4e2b0 | |||
| f5d68aec4a | |||
| 38c637cc67 | |||
| f29c90897f | |||
| efb65b6da3 | |||
| 29563eeb83 | |||
| fc364621e3 | |||
| dffa4824fb | |||
| 28166842f1 | |||
| 484c7f0052 | |||
| 682e01bbf1 | |||
| 917ad25f7f | |||
| 26f90a4d1f | |||
| 73b5d831ed | |||
| 401c21ad82 | |||
| 3dee8b80a2 | |||
| 27f02224ab | |||
| 060b2953fa | |||
| 04f50c32ae | |||
| 0d651129cb | |||
| 47541afbbf | |||
| fd70eca0c9 | |||
| 99d3aeb068 | |||
| 5351932194 | |||
| e42c6c1ef2 | |||
| f14de28daa | |||
| 6f5b61de7f | |||
| 3066d7d356 | |||
| cd9070ec99 | |||
| 74d6f5819c | |||
| 63769fbe32 | |||
| a0a059472b | |||
| a47df7171c | |||
| 59b480621e | |||
| 904185e5c8 | |||
| dcac2742e9 | |||
| c7c3cce76d | |||
| 7de7047a4d | |||
| 49c175c01d | |||
| 35c4fcb9bb | |||
| 3b1e1eae6d | |||
| 6cec6bdb50 | |||
| cc436798d1 | |||
| cd34d27f76 | |||
| 92020e68ce | |||
| 7b91d2c8a4 | |||
| 696683cf30 | |||
| 96ccc1b04c | |||
| 007187020b | |||
| 39eea3ed07 | |||
| eac6f4b848 | |||
| caa28a3a2b | |||
| fd5a828084 | |||
| 76091ff06c | |||
| 4b1b3cedd1 | |||
| 40a835cfd2 | |||
| a6936ce609 | |||
| 0fd9c2618d | |||
| 3091c5ca97 | |||
| ee903f8bd6 | |||
| c4713dd9ef | |||
| a89d3d7313 | |||
| 9a6fbced15 | |||
| 5b0fe2adc2 | |||
| fd1057662f | |||
| d7fc3c0cab | |||
| 1900667328 | |||
| 03356d813f | |||
| f65d97a50a | |||
| 46af0fb959 |
141
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,141 @@
|
||||
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/ryan/backend:${{ steps.vars.outputs.tag }} ./backend
|
||||
|
||||
- name: Build Frontend Docker Image
|
||||
run: |
|
||||
docker build -t git.ryankegel.com:3000/ryan/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: ryan #${{ secrets.REGISTRY_USERNAME }} # Stored as a Gitea secret
|
||||
password: 0x013h #${{ secrets.REGISTRY_TOKEN }} # Stored as a Gitea secret (use a PAT here)
|
||||
|
||||
- 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/ryan/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/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:latest
|
||||
docker push git.ryankegel.com:3000/ryan/backend:latest
|
||||
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
|
||||
docker tag git.ryankegel.com:3000/ryan/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/backend:next
|
||||
docker push git.ryankegel.com:3000/ryan/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/ryan/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/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:latest
|
||||
docker push git.ryankegel.com:3000/ryan/frontend:latest
|
||||
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
|
||||
docker tag git.ryankegel.com:3000/ryan/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/ryan/frontend:next
|
||||
docker push git.ryankegel.com:3000/ryan/frontend:next
|
||||
fi
|
||||
|
||||
- name: Deploy Test Environment
|
||||
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
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
**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
Normal file
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
87
.github/specs/active/feat-hashed-passwords.md
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
# 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.
|
||||
19
.github/specs/archive/bug-both-system-and-user-items-shown.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
318
.github/specs/archive/feat-account-delete-scheduler.md
vendored
Normal file
@@ -0,0 +1,318 @@
|
||||
# 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)
|
||||
149
.github/specs/archive/feat-dynamic-points/IMPLEMENTATION_SUMMARY.md
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
# 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)
|
||||
BIN
.github/specs/archive/feat-dynamic-points/feat-dynamic-points-after.png
vendored
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
.github/specs/archive/feat-dynamic-points/feat-dynamic-points-before.png
vendored
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
.github/specs/archive/feat-dynamic-points/feat-dynamic-points-edit-modal.png
vendored
Normal file
|
After Width: | Height: | Size: 21 KiB |
519
.github/specs/archive/feat-dynamic-points/feat-dynamic-points.md
vendored
Normal file
@@ -0,0 +1,519 @@
|
||||
# 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.
|
||||
112
.github/specs/archive/feat-dynamic-points/feat-tracking.md
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
# 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
|
||||
26
.github/specs/archive/feat-no-delete-system-tasks-and-rewards.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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.
|
||||
251
.github/specs/archive/feat-profile-mark-remove-account.md
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
# 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.
|
||||
65
.github/specs/archive/profile-button-menu/feat-profile-icon-button-menu.md
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
# 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
Normal file
|
After Width: | Height: | Size: 58 KiB |
86
.gitignore
vendored
@@ -1,78 +1,8 @@
|
||||
# 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.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/
|
||||
backend/test_data/db/children.json
|
||||
backend/test_data/db/images.json
|
||||
backend/test_data/db/pending_rewards.json
|
||||
backend/test_data/db/rewards.json
|
||||
backend/test_data/db/tasks.json
|
||||
backend/test_data/db/users.json
|
||||
logs/account_deletion.log
|
||||
backend/test_data/db/tracking_events.json
|
||||
|
||||
5
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
15
.idea/Reward.iml
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
<?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
Normal file
@@ -0,0 +1,6 @@
|
||||
<?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
Normal file
@@ -0,0 +1,6 @@
|
||||
<?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
Normal file
@@ -0,0 +1,6 @@
|
||||
<?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
Normal file
@@ -0,0 +1,10 @@
|
||||
<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
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
4
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,4 @@
|
||||
<?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
Normal file
@@ -0,0 +1,8 @@
|
||||
<?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
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
88
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"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
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"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
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
173
README.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# 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.
|
||||
671
api/child_api.py
@@ -1,671 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
from db.db import child_db, task_db, reward_db, pending_reward_db
|
||||
from api.reward_status import RewardStatus
|
||||
from api.child_tasks import ChildTask
|
||||
from api.child_rewards import ChildReward
|
||||
from events.sse import send_event_to_user
|
||||
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 api.pending_reward import PendingReward as PendingRewardResponse
|
||||
|
||||
from models.child import Child
|
||||
from models.pending_reward import PendingReward
|
||||
from models.task import Task
|
||||
from models.reward import Reward
|
||||
|
||||
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())
|
||||
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(child.id, ChildModified.OPERATION_ADD)))
|
||||
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)
|
||||
)
|
||||
send_event_to_user(
|
||||
"user123",
|
||||
Event(
|
||||
EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(id, reward.id, ChildRewardRequest.REQUEST_CANCELLED)
|
||||
)
|
||||
)
|
||||
|
||||
child_db.update(child.to_dict(), ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_EDIT)))
|
||||
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):
|
||||
send_event_to_user("user123",
|
||||
Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
||||
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)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_TASKS_SET.value, ChildTasksSet(id, valid_task_ids)))
|
||||
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)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
|
||||
|
||||
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)
|
||||
send_event_to_user("user123", 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):
|
||||
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:
|
||||
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||
|
||||
|
||||
# update the child's points based on reward cost
|
||||
child.points -= reward.cost
|
||||
# update the child in the database
|
||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||
send_event_to_user("user123", Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
||||
|
||||
|
||||
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||
|
||||
@child_api.route('/child/<id>/affordable-rewards', methods=['GET'])
|
||||
def list_affordable_rewards(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
points = child.points
|
||||
reward_ids = child.rewards
|
||||
RewardQuery = Query()
|
||||
affordable = [
|
||||
Reward.from_dict(reward).to_dict() for reward_id in reward_ids
|
||||
if (reward := reward_db.get(RewardQuery.id == reward_id)) and points >= Reward.from_dict(reward).cost
|
||||
]
|
||||
return jsonify({'affordable_rewards': affordable}), 200
|
||||
|
||||
@child_api.route('/child/<id>/reward-status', methods=['GET'])
|
||||
def reward_status(id):
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
points = child.points
|
||||
reward_ids = child.rewards
|
||||
|
||||
RewardQuery = Query()
|
||||
statuses = []
|
||||
for reward_id in reward_ids:
|
||||
reward: Reward = Reward.from_dict(reward_db.get(RewardQuery.id == reward_id))
|
||||
if not reward:
|
||||
continue
|
||||
points_needed = max(0, reward.cost - points)
|
||||
#check to see if this reward id and child id is in the pending rewards db if so set its redeeming flag to true
|
||||
pending_query = Query()
|
||||
pending = pending_reward_db.get((pending_query.child_id == child.id) & (pending_query.reward_id == reward.id))
|
||||
status = RewardStatus(reward.id, reward.name, points_needed, reward.cost, pending is not None, reward.image_id)
|
||||
statuses.append(status.to_dict())
|
||||
|
||||
statuses.sort(key=lambda s: (not s['redeeming'], s['cost']))
|
||||
return jsonify({'reward_status': statuses}), 200
|
||||
|
||||
|
||||
@child_api.route('/child/<id>/request-reward', methods=['POST'])
|
||||
def request_reward(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
if reward_id not in child.rewards:
|
||||
return jsonify({'error': f'Reward not assigned to child {child.name}'}), 404
|
||||
|
||||
RewardQuery = Query()
|
||||
reward_result = reward_db.search(RewardQuery.id == reward_id)
|
||||
if not reward_result:
|
||||
return jsonify({'error': 'Reward not found in reward database'}), 404
|
||||
|
||||
reward = Reward.from_dict(reward_result[0])
|
||||
|
||||
# Check if child has enough points
|
||||
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())
|
||||
send_event_to_user("user123", Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||
|
||||
return jsonify({
|
||||
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
||||
'reward_id': reward.id,
|
||||
'reward_name': reward.name,
|
||||
'child_id': child.id,
|
||||
'child_name': child.name,
|
||||
'cost': reward.cost
|
||||
}), 200
|
||||
|
||||
@child_api.route('/child/<id>/cancel-request-reward', methods=['POST'])
|
||||
def cancel_request_reward(id):
|
||||
data = request.get_json()
|
||||
reward_id = data.get('reward_id')
|
||||
if not reward_id:
|
||||
return jsonify({'error': 'reward_id is required'}), 400
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
return jsonify({'error': 'Child not found'}), 404
|
||||
|
||||
child = Child.from_dict(result[0])
|
||||
|
||||
# Remove matching pending reward request
|
||||
PendingQuery = Query()
|
||||
removed = pending_reward_db.remove(
|
||||
(PendingQuery.child_id == child.id) & (PendingQuery.reward_id == reward_id)
|
||||
)
|
||||
|
||||
if not removed:
|
||||
return jsonify({'error': 'No pending request found for this reward'}), 404
|
||||
|
||||
# Notify user that the request was cancelled
|
||||
send_event_to_user(
|
||||
"user123",
|
||||
Event(
|
||||
EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from events.sse import send_event_to_user
|
||||
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
|
||||
from db.db import reward_db, child_db
|
||||
|
||||
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())
|
||||
send_event_to_user("user123", 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):
|
||||
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'))
|
||||
send_event_to_user("user123", 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):
|
||||
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)
|
||||
send_event_to_user("user123", Event(EventType.REWARD_MODIFIED.value,
|
||||
RewardModified(id, RewardModified.OPERATION_EDIT)))
|
||||
|
||||
return jsonify(updated), 200
|
||||
100
api/task_api.py
@@ -1,100 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from events.sse import send_event_to_user
|
||||
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
|
||||
from db.db import task_db, child_db
|
||||
|
||||
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())
|
||||
send_event_to_user("user123", 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):
|
||||
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'))
|
||||
send_event_to_user("user123", 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):
|
||||
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)
|
||||
send_event_to_user("user123", Event(EventType.TASK_MODIFIED.value,
|
||||
TaskModified(id, TaskModified.OPERATION_EDIT)))
|
||||
return jsonify(updated), 200
|
||||
83
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
# 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
|
||||
0
Jenkinsfile → backend/Jenkinsfile
vendored
199
backend/api/admin_api.py
Normal file
@@ -0,0 +1,199 @@
|
||||
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
|
||||
287
backend/api/auth_api.py
Normal file
@@ -0,0 +1,287 @@
|
||||
import logging
|
||||
import secrets, jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from models.user import User
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from tinydb import Query
|
||||
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 config.paths import get_user_image_dir
|
||||
|
||||
from api.error_codes import MISSING_FIELDS, EMAIL_EXISTS, MISSING_TOKEN, INVALID_TOKEN, TOKEN_TIMESTAMP_MISSING, \
|
||||
TOKEN_EXPIRED, ALREADY_VERIFIED, MISSING_EMAIL, USER_NOT_FOUND, MISSING_EMAIL_OR_PASSWORD, INVALID_CREDENTIALS, \
|
||||
NOT_VERIFIED, ACCOUNT_MARKED_FOR_DELETION
|
||||
from db.db import users_db
|
||||
from api.utils import normalize_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
auth_api = Blueprint('auth_api', __name__)
|
||||
UserQuery = Query()
|
||||
TOKEN_EXPIRY_MINUTES = 60*4
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||
|
||||
|
||||
def send_verification_email(to_email, token):
|
||||
email_sender.send_verification_email(to_email, token)
|
||||
|
||||
def send_reset_password_email(to_email, token):
|
||||
email_sender.send_reset_password_email(to_email, token)
|
||||
|
||||
@auth_api.route('/signup', methods=['POST'])
|
||||
def signup():
|
||||
data = request.get_json()
|
||||
required_fields = ['first_name', 'last_name', 'email', 'password']
|
||||
if not all(field in data for field in required_fields):
|
||||
return jsonify({'error': 'Missing required fields', 'code': MISSING_FIELDS}), 400
|
||||
email = data.get('email', '')
|
||||
norm_email = normalize_email(email)
|
||||
|
||||
existing = users_db.get(UserQuery.email == norm_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
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user = User(
|
||||
first_name=data['first_name'],
|
||||
last_name=data['last_name'],
|
||||
email=norm_email,
|
||||
password=generate_password_hash(data['password']),
|
||||
verified=False,
|
||||
verify_token=token,
|
||||
verify_token_created=now_iso,
|
||||
image_id="boy01"
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
send_verification_email(norm_email, token)
|
||||
return jsonify({'message': 'User created, verification email sent'}), 201
|
||||
|
||||
@auth_api.route('/verify', methods=['GET'])
|
||||
def verify():
|
||||
token = request.args.get('token')
|
||||
status = 'success'
|
||||
reason = ''
|
||||
code = ''
|
||||
user_dict = None
|
||||
user = None
|
||||
|
||||
if not token:
|
||||
status = 'error'
|
||||
reason = 'Missing token'
|
||||
code = MISSING_TOKEN
|
||||
else:
|
||||
user_dict = users_db.get(Query().verify_token == token)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
status = 'error'
|
||||
reason = 'Invalid token'
|
||||
code = INVALID_TOKEN
|
||||
elif user.marked_for_deletion:
|
||||
status = 'error'
|
||||
reason = 'Account marked for deletion'
|
||||
code = ACCOUNT_MARKED_FOR_DELETION
|
||||
else:
|
||||
created_str = user.verify_token_created
|
||||
if not created_str:
|
||||
status = 'error'
|
||||
reason = 'Token timestamp missing'
|
||||
code = TOKEN_TIMESTAMP_MISSING
|
||||
else:
|
||||
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=TOKEN_EXPIRY_MINUTES):
|
||||
status = 'error'
|
||||
reason = 'Token expired'
|
||||
code = TOKEN_EXPIRED
|
||||
else:
|
||||
user.verified = True
|
||||
user.verify_token = None
|
||||
user.verify_token_created = None
|
||||
users_db.update(user.to_dict(), Query().email == user.email)
|
||||
|
||||
http_status = 200 if status == 'success' else 400
|
||||
if http_status == 200 and user is not None:
|
||||
if not user.email:
|
||||
logger.error("Verified user has no email field.")
|
||||
else:
|
||||
user_image_dir = get_user_image_dir(user.id)
|
||||
os.makedirs(user_image_dir, exist_ok=True)
|
||||
|
||||
return jsonify({'status': status, 'reason': reason, 'code': code}), http_status
|
||||
|
||||
@auth_api.route('/resend-verify', methods=['POST'])
|
||||
def resend_verify():
|
||||
data = request.get_json()
|
||||
email = data.get('email', '')
|
||||
if not email:
|
||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||
norm_email = normalize_email(email)
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||
|
||||
if user.verified:
|
||||
return jsonify({'error': 'Account already verified', 'code': ALREADY_VERIFIED}), 400
|
||||
|
||||
token = secrets.token_urlsafe(32)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user.verify_token = token
|
||||
user.verify_token_created = now_iso
|
||||
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
||||
send_verification_email(norm_email, token)
|
||||
return jsonify({'message': 'Verification email resent'}), 200
|
||||
|
||||
@auth_api.route('/login', methods=['POST'])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
email = data.get('email', '')
|
||||
password = data.get('password')
|
||||
if not email or not password:
|
||||
return jsonify({'error': 'Missing email or password', 'code': MISSING_EMAIL_OR_PASSWORD}), 400
|
||||
norm_email = normalize_email(email)
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user or not check_password_hash(user.password, password):
|
||||
return jsonify({'error': 'Invalid credentials', 'code': INVALID_CREDENTIALS}), 401
|
||||
|
||||
if not user.verified:
|
||||
return jsonify({'error': 'This account has not verified', 'code': NOT_VERIFIED}), 403
|
||||
|
||||
# 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 = {
|
||||
'email': norm_email,
|
||||
'user_id': user.id,
|
||||
'token_version': user.token_version,
|
||||
'exp': datetime.utcnow() + timedelta(hours=24*7)
|
||||
}
|
||||
token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
resp = jsonify({'message': 'Login successful'})
|
||||
resp.set_cookie('token', token, httponly=True, secure=True, samesite='Strict')
|
||||
return resp, 200
|
||||
|
||||
@auth_api.route('/me', methods=['GET'])
|
||||
def me():
|
||||
token = request.cookies.get('token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 401
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
user_id = payload.get('user_id', '')
|
||||
token_version = payload.get('token_version', 0)
|
||||
user_dict = users_db.get(UserQuery.id == user_id)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'User not found', 'code': USER_NOT_FOUND}), 404
|
||||
if 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({
|
||||
'email': user.email,
|
||||
'id': user_id,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'verified': user.verified
|
||||
}), 200
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 401
|
||||
|
||||
@auth_api.route('/request-password-reset', methods=['POST'])
|
||||
def request_password_reset():
|
||||
data = request.get_json()
|
||||
email = data.get('email', '')
|
||||
norm_email = normalize_email(email)
|
||||
|
||||
success_msg = 'If this email is registered, you will receive a password reset link shortly.'
|
||||
|
||||
if not email:
|
||||
return jsonify({'error': 'Missing email', 'code': MISSING_EMAIL}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.email == norm_email)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
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)
|
||||
now_iso = datetime.utcnow().isoformat()
|
||||
user.reset_token = token
|
||||
user.reset_token_created = now_iso
|
||||
users_db.update(user.to_dict(), UserQuery.email == norm_email)
|
||||
send_reset_password_email(norm_email, token)
|
||||
|
||||
return jsonify({'message': success_msg}), 200
|
||||
|
||||
@auth_api.route('/validate-reset-token', methods=['GET'])
|
||||
def validate_reset_token():
|
||||
token = request.args.get('token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Missing token', 'code': MISSING_TOKEN}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.reset_token == token)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
|
||||
|
||||
created_str = user.reset_token_created
|
||||
if not created_str:
|
||||
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
|
||||
|
||||
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||
|
||||
return jsonify({'message': 'Token is valid'}), 200
|
||||
|
||||
@auth_api.route('/reset-password', methods=['POST'])
|
||||
def reset_password():
|
||||
data = request.get_json()
|
||||
token = data.get('token')
|
||||
new_password = data.get('password')
|
||||
|
||||
if not token or not new_password:
|
||||
return jsonify({'error': 'Missing token or password'}), 400
|
||||
|
||||
user_dict = users_db.get(UserQuery.reset_token == token)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
return jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN}), 400
|
||||
|
||||
created_str = user.reset_token_created
|
||||
if not created_str:
|
||||
return jsonify({'error': 'Token timestamp missing', 'code': TOKEN_TIMESTAMP_MISSING}), 400
|
||||
|
||||
created_dt = datetime.fromisoformat(created_str).replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) - created_dt > timedelta(minutes=RESET_PASSWORD_TOKEN_EXPIRY_MINUTES):
|
||||
return jsonify({'error': 'Token expired', 'code': TOKEN_EXPIRED}), 400
|
||||
|
||||
user.password = generate_password_hash(new_password)
|
||||
user.reset_token = None
|
||||
user.reset_token_created = None
|
||||
user.token_version += 1
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
|
||||
resp = jsonify({'message': 'Password has been reset'})
|
||||
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
|
||||
970
backend/api/child_api.py
Normal file
@@ -0,0 +1,970 @@
|
||||
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
|
||||
|
||||
173
backend/api/child_override_api.py
Normal file
@@ -0,0 +1,173 @@
|
||||
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
|
||||
28
backend/api/error_codes.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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,15 +1,18 @@
|
||||
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 flask import Blueprint, request, jsonify, send_file
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import get_current_user_id, sanitize_email, get_validated_user_id
|
||||
from config.paths import get_user_image_dir
|
||||
|
||||
from db.db import image_db
|
||||
from models.image import Image
|
||||
|
||||
image_api = Blueprint('image_api', __name__)
|
||||
UPLOAD_FOLDER = get_user_image_dir("user123")
|
||||
ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png'}
|
||||
IMAGE_TYPE_PROFILE = 1
|
||||
IMAGE_TYPE_ICON = 2
|
||||
@@ -20,6 +23,9 @@ def allowed_file(filename):
|
||||
|
||||
@image_api.route('/image/upload', methods=['POST'])
|
||||
def upload():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
if 'file' not in request.files:
|
||||
return jsonify({'error': 'No file part in the request'}), 400
|
||||
file = request.files['file']
|
||||
@@ -60,13 +66,14 @@ def upload():
|
||||
|
||||
format_extension_map = {'JPEG': '.jpg', 'PNG': '.png'}
|
||||
extension = format_extension_map.get(original_format, '.png')
|
||||
|
||||
image_record = Image(extension=extension, permanent=perm, type=image_type, user="user123")
|
||||
image_record = Image(extension=extension, permanent=perm, type=image_type, user_id=user_id)
|
||||
filename = image_record.id + extension
|
||||
filepath = os.path.abspath(os.path.join(UPLOAD_FOLDER, filename))
|
||||
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))
|
||||
|
||||
try:
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
# Save with appropriate format
|
||||
save_params = {}
|
||||
if pil_image.format == 'JPEG':
|
||||
@@ -82,25 +89,38 @@ def upload():
|
||||
|
||||
@image_api.route('/image/request/<id>', methods=['GET'])
|
||||
def request_image(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
ImageQuery = Query()
|
||||
image: Image = Image.from_dict(image_db.get(ImageQuery.id == id))
|
||||
if not image:
|
||||
image_record = image_db.get(ImageQuery.id == id)
|
||||
if not image_record:
|
||||
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}"
|
||||
filepath = os.path.abspath(os.path.join(get_user_image_dir(image.user), filename))
|
||||
if image.user_id is None:
|
||||
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):
|
||||
return jsonify({'error': 'File not found'}), 404
|
||||
return send_file(filepath)
|
||||
|
||||
@image_api.route('/image/list', methods=['GET'])
|
||||
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)
|
||||
ImageQuery = Query()
|
||||
if image_type is not None:
|
||||
if image_type not in [IMAGE_TYPE_PROFILE, IMAGE_TYPE_ICON]:
|
||||
return jsonify({'error': 'Invalid image type'}), 400
|
||||
images = image_db.search(ImageQuery.type == image_type)
|
||||
images = image_db.search((ImageQuery.type == image_type) & ((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None)))
|
||||
else:
|
||||
images = image_db.all()
|
||||
images = image_db.search((ImageQuery.user_id == user_id) | (ImageQuery.user_id == None))
|
||||
image_ids = [img['id'] for img in images]
|
||||
return jsonify({'ids': image_ids, 'count': len(image_ids)}), 200
|
||||
163
backend/api/reward_api.py
Normal file
@@ -0,0 +1,163 @@
|
||||
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
|
||||
178
backend/api/task_api.py
Normal file
@@ -0,0 +1,178 @@
|
||||
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
|
||||
122
backend/api/tracking_api.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from api.utils import get_validated_user_id
|
||||
from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
|
||||
from models.tracking_event import TrackingEvent
|
||||
from functools import wraps
|
||||
import jwt
|
||||
from tinydb import Query
|
||||
from db.db import users_db
|
||||
from models.user import User
|
||||
|
||||
|
||||
tracking_api = Blueprint('tracking_api', __name__)
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""
|
||||
Decorator to require admin role for endpoints.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
# Get JWT token from cookie
|
||||
token = request.cookies.get('token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
|
||||
|
||||
try:
|
||||
# Verify JWT token
|
||||
payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256'])
|
||||
user_id = payload.get('user_id')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||
|
||||
# Get user from database
|
||||
Query_ = Query()
|
||||
user_dict = users_db.get(Query_.id == user_id)
|
||||
|
||||
if not user_dict:
|
||||
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||
|
||||
user = User.from_dict(user_dict)
|
||||
|
||||
# Check if user has admin role
|
||||
if user.role != 'admin':
|
||||
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||
|
||||
# Store user_id in request context
|
||||
request.admin_user_id = user_id
|
||||
return f(*args, **kwargs)
|
||||
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
@tracking_api.route('/admin/tracking', methods=['GET'])
|
||||
@admin_required
|
||||
def get_tracking():
|
||||
"""
|
||||
Admin endpoint to query tracking events with filters and pagination.
|
||||
|
||||
Query params:
|
||||
- child_id: Filter by child ID (optional)
|
||||
- user_id: Filter by user ID (optional, admin only)
|
||||
- entity_type: Filter by entity type (task/reward/penalty) (optional)
|
||||
- action: Filter by action type (activated/requested/redeemed/cancelled) (optional)
|
||||
- limit: Max results (default 50, max 500)
|
||||
- offset: Pagination offset (default 0)
|
||||
"""
|
||||
child_id = request.args.get('child_id')
|
||||
filter_user_id = request.args.get('user_id')
|
||||
entity_type = request.args.get('entity_type')
|
||||
action = request.args.get('action')
|
||||
limit = int(request.args.get('limit', 50))
|
||||
offset = int(request.args.get('offset', 0))
|
||||
|
||||
# Validate limit
|
||||
limit = min(max(limit, 1), 500)
|
||||
offset = max(offset, 0)
|
||||
|
||||
# Validate filters
|
||||
if entity_type and entity_type not in ['task', 'reward', 'penalty']:
|
||||
return jsonify({'error': 'Invalid entity_type', 'code': 'INVALID_ENTITY_TYPE'}), 400
|
||||
|
||||
if action and action not in ['activated', 'requested', 'redeemed', 'cancelled']:
|
||||
return jsonify({'error': 'Invalid action', 'code': 'INVALID_ACTION'}), 400
|
||||
|
||||
# Query tracking events
|
||||
if child_id:
|
||||
events, total = get_tracking_events_by_child(
|
||||
child_id=child_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
entity_type=entity_type,
|
||||
action=action
|
||||
)
|
||||
elif filter_user_id:
|
||||
events, total = get_tracking_events_by_user(
|
||||
user_id=filter_user_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
entity_type=entity_type
|
||||
)
|
||||
else:
|
||||
return jsonify({
|
||||
'error': 'Either child_id or user_id is required',
|
||||
'code': 'MISSING_FILTER'
|
||||
}), 400
|
||||
|
||||
# Convert to dict
|
||||
events_data = [event.to_dict() for event in events]
|
||||
|
||||
return jsonify({
|
||||
'tracking_events': events_data,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'count': len(events_data)
|
||||
}), 200
|
||||
246
backend/api/user_api.py
Normal file
@@ -0,0 +1,246 @@
|
||||
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
|
||||
53
backend/api/utils.py
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
61
backend/config/deletion_config.py
Normal file
@@ -0,0 +1,61 @@
|
||||
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()
|
||||
@@ -9,19 +9,33 @@ TEST_DATA_DIR_NAME = 'test_data'
|
||||
# Project root (two levels up from this 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:
|
||||
"""
|
||||
Return the absolute base directory path for the given DB env.
|
||||
db_env: 'prod' uses `data/db`, anything else uses `test_data/db`.
|
||||
"""
|
||||
env = (db_env or os.environ.get('DB_ENV', 'prod')).lower()
|
||||
base_name = DATA_DIR_NAME if env == 'prod' else TEST_DATA_DIR_NAME
|
||||
return os.path.join(PROJECT_ROOT, base_name, 'db')
|
||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(env), 'db')
|
||||
|
||||
def get_user_image_dir(username: str | None) -> str:
|
||||
"""
|
||||
Return the absolute directory path for storing images for a specific user.
|
||||
"""
|
||||
if username:
|
||||
return os.path.join(PROJECT_ROOT, DATA_DIR_NAME, 'images', username)
|
||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images', username)
|
||||
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
|
||||
import os
|
||||
|
||||
BASE_VERSION = "1.0.3" # update manually when releasing features
|
||||
BASE_VERSION = "1.0.4" # update manually when releasing features
|
||||
|
||||
def get_full_version() -> str:
|
||||
"""
|
||||
146
backend/db/child_overrides.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""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
|
||||
@@ -72,6 +72,9 @@ task_path = os.path.join(base_dir, 'tasks.json')
|
||||
reward_path = os.path.join(base_dir, 'rewards.json')
|
||||
image_path = os.path.join(base_dir, 'images.json')
|
||||
pending_reward_path = os.path.join(base_dir, 'pending_rewards.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
|
||||
_child_db = TinyDB(child_path, indent=2)
|
||||
@@ -79,6 +82,9 @@ _task_db = TinyDB(task_path, indent=2)
|
||||
_reward_db = TinyDB(reward_path, indent=2)
|
||||
_image_db = TinyDB(image_path, indent=2)
|
||||
_pending_rewards_db = TinyDB(pending_reward_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
|
||||
child_db = LockedTable(_child_db)
|
||||
@@ -86,6 +92,9 @@ task_db = LockedTable(_task_db)
|
||||
reward_db = LockedTable(_reward_db)
|
||||
image_db = LockedTable(_image_db)
|
||||
pending_reward_db = LockedTable(_pending_rewards_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':
|
||||
child_db.truncate()
|
||||
@@ -93,4 +102,7 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||
reward_db.truncate()
|
||||
image_db.truncate()
|
||||
pending_reward_db.truncate()
|
||||
users_db.truncate()
|
||||
tracking_events_db.truncate()
|
||||
child_overrides_db.truncate()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
# File: db/debug.py
|
||||
|
||||
from tinydb import Query
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from api.image_api import IMAGE_TYPE_ICON, IMAGE_TYPE_PROFILE
|
||||
from db.db import task_db, reward_db, image_db
|
||||
@@ -119,7 +121,22 @@ def createDefaultRewards():
|
||||
reward_db.insert(reward.to_dict())
|
||||
|
||||
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:
|
||||
image_defs = [
|
||||
('boy01', IMAGE_TYPE_PROFILE, '.png', True),
|
||||
125
backend/db/tracking.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""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
|
||||
@@ -29,6 +29,7 @@ def get_queue(user_id: str, connection_id: str) -> queue.Queue:
|
||||
def send_to_user(user_id: str, data: Dict[str, Any]):
|
||||
"""Send data to all connections for a specific user."""
|
||||
logger.info(f"Sending data to {user_id} user quesues are {user_queues.keys()}")
|
||||
logger.info(f"Data: {data}")
|
||||
if user_id in user_queues:
|
||||
logger.info(f"Queued {user_id}")
|
||||
# Format as SSE message once
|
||||
@@ -37,8 +38,6 @@ def send_to_user(user_id: str, data: Dict[str, Any]):
|
||||
# Send to all connections for this user
|
||||
for connection_id, q in user_queues[user_id].items():
|
||||
try:
|
||||
logger.info(f"Sending message to {connection_id}")
|
||||
q.put(message)
|
||||
q.put(message, block=False)
|
||||
except queue.Full:
|
||||
# Skip if queue is full (connection might be dead)
|
||||
@@ -61,7 +60,6 @@ def sse_response_for_user(user_id: str):
|
||||
try:
|
||||
while True:
|
||||
# Get message from queue (blocks until available)
|
||||
logger.info(f"blocking on get for {user_id} user")
|
||||
message = user_queue.get()
|
||||
yield message
|
||||
except GeneratorExit:
|
||||
22
backend/events/types/child_override_deleted.py
Normal file
@@ -0,0 +1,22 @@
|
||||
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")
|
||||
13
backend/events/types/child_override_set.py
Normal file
@@ -0,0 +1,13 @@
|
||||
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")
|
||||
@@ -13,3 +13,13 @@ class EventType(Enum):
|
||||
CHILD_REWARD_REQUEST = "child_reward_request"
|
||||
|
||||
CHILD_MODIFIED = "child_modified"
|
||||
|
||||
USER_MARKED_FOR_DELETION = "user_marked_for_deletion"
|
||||
USER_DELETED = "user_deleted"
|
||||
|
||||
TRACKING_EVENT_CREATED = "tracking_event_created"
|
||||
|
||||
CHILD_OVERRIDE_SET = "child_override_set"
|
||||
CHILD_OVERRIDE_DELETED = "child_override_deleted"
|
||||
|
||||
PROFILE_UPDATED = "profile_updated"
|
||||
12
backend/events/types/profile_updated.py
Normal file
@@ -0,0 +1,12 @@
|
||||
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")
|
||||
27
backend/events/types/tracking_event_created.py
Normal file
@@ -0,0 +1,27 @@
|
||||
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")
|
||||
26
backend/events/types/user_deleted.py
Normal file
@@ -0,0 +1,26 @@
|
||||
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")
|
||||
21
backend/events/types/user_modified.py
Normal file
@@ -0,0 +1,21 @@
|
||||
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,16 +1,25 @@
|
||||
import sys, logging, os
|
||||
from config.paths import get_user_image_dir
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
|
||||
from api.admin_api import admin_api
|
||||
from api.auth_api import auth_api
|
||||
from api.child_api import child_api
|
||||
from api.child_override_api import child_override_api
|
||||
from api.image_api import image_api
|
||||
from api.reward_api import reward_api
|
||||
from api.task_api import task_api
|
||||
from api.tracking_api import tracking_api
|
||||
from api.user_api import user_api
|
||||
from config.version import get_full_version
|
||||
|
||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||
from events.broadcaster import Broadcaster
|
||||
from events.sse import sse_response_for_user, send_to_user
|
||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||
from utils.account_deletion_scheduler import start_deletion_scheduler
|
||||
|
||||
# Configure logging once at application startup
|
||||
logging.basicConfig(
|
||||
@@ -24,10 +33,28 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
app = Flask(__name__)
|
||||
#CORS(app, resources={r"/api/*": {"origins": ["http://localhost:3000", "http://localhost:5173"]}})
|
||||
#Todo - add prefix to all these routes instead of in each blueprint
|
||||
app.register_blueprint(admin_api)
|
||||
app.register_blueprint(child_api)
|
||||
app.register_blueprint(child_override_api)
|
||||
app.register_blueprint(reward_api)
|
||||
app.register_blueprint(task_api)
|
||||
app.register_blueprint(image_api)
|
||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||
app.register_blueprint(user_api)
|
||||
app.register_blueprint(tracking_api)
|
||||
|
||||
app.config.update(
|
||||
MAIL_SERVER='smtp.gmail.com',
|
||||
MAIL_PORT=587,
|
||||
MAIL_USE_TLS=True,
|
||||
MAIL_USERNAME='ryan.kegel@gmail.com',
|
||||
MAIL_PASSWORD='ruyj hxjf nmrz buar',
|
||||
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
|
||||
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
|
||||
SECRET_KEY='supersecretkey' # Replace with a secure key in production
|
||||
)
|
||||
|
||||
CORS(app)
|
||||
|
||||
@app.route("/version")
|
||||
@@ -61,12 +88,11 @@ def start_background_threads():
|
||||
broadcaster.start()
|
||||
|
||||
# TODO: implement users
|
||||
os.makedirs(get_user_image_dir("user123"), exist_ok=True)
|
||||
initializeImages()
|
||||
createDefaultTasks()
|
||||
createDefaultRewards()
|
||||
start_background_threads()
|
||||
|
||||
start_deletion_scheduler()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
|
||||
@@ -9,6 +9,7 @@ class Child(BaseModel):
|
||||
rewards: list[str] = field(default_factory=list)
|
||||
points: int = 0
|
||||
image_id: str | None = None
|
||||
user_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -19,10 +20,10 @@ class Child(BaseModel):
|
||||
rewards=d.get('rewards', []),
|
||||
points=d.get('points', 0),
|
||||
image_id=d.get('image_id'),
|
||||
user_id=d.get('user_id'),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
@@ -33,6 +34,7 @@ class Child(BaseModel):
|
||||
'tasks': self.tasks,
|
||||
'rewards': self.rewards,
|
||||
'points': self.points,
|
||||
'image_id': self.image_id
|
||||
'image_id': self.image_id,
|
||||
'user_id': self.user_id
|
||||
})
|
||||
return base
|
||||
64
backend/models/child_override.py
Normal file
@@ -0,0 +1,64 @@
|
||||
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
|
||||
)
|
||||
@@ -2,12 +2,14 @@
|
||||
from dataclasses import dataclass
|
||||
from models.base import BaseModel
|
||||
|
||||
|
||||
@dataclass
|
||||
class Image(BaseModel):
|
||||
type: int
|
||||
extension: str
|
||||
permanent: bool = False
|
||||
user: str | None = None
|
||||
user_id: str | None = None
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -19,7 +21,7 @@ class Image(BaseModel):
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at'),
|
||||
user=d.get('user')
|
||||
user_id=d.get('user_id') if 'user_id' in d else d.get('user')
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
@@ -28,6 +30,6 @@ class Image(BaseModel):
|
||||
'type': self.type,
|
||||
'permanent': self.permanent,
|
||||
'extension': self.extension,
|
||||
'user': self.user
|
||||
'user_id': self.user_id
|
||||
})
|
||||
return base
|
||||
@@ -5,6 +5,7 @@ from models.base import BaseModel
|
||||
class PendingReward(BaseModel):
|
||||
child_id: str
|
||||
reward_id: str
|
||||
user_id: str
|
||||
status: str = "pending" # pending, approved, rejected
|
||||
|
||||
@classmethod
|
||||
@@ -13,6 +14,7 @@ class PendingReward(BaseModel):
|
||||
child_id=d.get('child_id'),
|
||||
reward_id=d.get('reward_id'),
|
||||
status=d.get('status', 'pending'),
|
||||
user_id=d.get('user_id'),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
@@ -23,6 +25,7 @@ class PendingReward(BaseModel):
|
||||
base.update({
|
||||
'child_id': self.child_id,
|
||||
'reward_id': self.reward_id,
|
||||
'status': self.status
|
||||
'status': self.status,
|
||||
'user_id': self.user_id
|
||||
})
|
||||
return base
|
||||
@@ -8,6 +8,7 @@ class Reward(BaseModel):
|
||||
description: str
|
||||
cost: int
|
||||
image_id: str | None = None
|
||||
user_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -17,6 +18,7 @@ class Reward(BaseModel):
|
||||
description=d.get('description'),
|
||||
cost=d.get('cost', 0),
|
||||
image_id=d.get('image_id'),
|
||||
user_id=d.get('user_id'),
|
||||
id=d.get('id'),
|
||||
created_at=d.get('created_at'),
|
||||
updated_at=d.get('updated_at')
|
||||
@@ -28,6 +30,7 @@ class Reward(BaseModel):
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'cost': self.cost,
|
||||
'image_id': self.image_id
|
||||
'image_id': self.image_id,
|
||||
'user_id': self.user_id
|
||||
})
|
||||
return base
|
||||
@@ -7,6 +7,7 @@ class Task(BaseModel):
|
||||
points: int
|
||||
is_good: bool
|
||||
image_id: str | None = None
|
||||
user_id: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
@@ -15,17 +16,19 @@ class Task(BaseModel):
|
||||
points=d.get('points', 0),
|
||||
is_good=d.get('is_good', True),
|
||||
image_id=d.get('image_id'),
|
||||
user_id=d.get('user_id'),
|
||||
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({
|
||||
'name': self.name,
|
||||
'points': self.points,
|
||||
'is_good': self.is_good,
|
||||
'image_id': self.image_id
|
||||
'image_id': self.image_id,
|
||||
'user_id': self.user_id
|
||||
})
|
||||
return base
|
||||
91
backend/models/tracking_event.py
Normal file
@@ -0,0 +1,91 @@
|
||||
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
|
||||
)
|
||||
77
backend/models/user.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from dataclasses import dataclass, field
|
||||
from models.base import BaseModel
|
||||
|
||||
@dataclass
|
||||
class User(BaseModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str
|
||||
password: str # In production, this should be hashed
|
||||
verified: bool = False
|
||||
verify_token: str | None = None
|
||||
verify_token_created: str | None = None
|
||||
reset_token: str | None = None
|
||||
reset_token_created: str | None = None
|
||||
image_id: str | None = None
|
||||
pin: str = ''
|
||||
pin_setup_code: str = ''
|
||||
pin_setup_code_created: str | None = None
|
||||
marked_for_deletion: bool = False
|
||||
marked_for_deletion_at: str | None = None
|
||||
deletion_in_progress: bool = False
|
||||
deletion_attempted_at: str | None = None
|
||||
role: str = 'user'
|
||||
token_version: int = 0
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict):
|
||||
return cls(
|
||||
first_name=d.get('first_name'),
|
||||
last_name=d.get('last_name'),
|
||||
email=d.get('email'),
|
||||
password=d.get('password'),
|
||||
verified=d.get('verified', False),
|
||||
verify_token=d.get('verify_token'),
|
||||
verify_token_created=d.get('verify_token_created'),
|
||||
reset_token=d.get('reset_token'),
|
||||
reset_token_created=d.get('reset_token_created'),
|
||||
image_id=d.get('image_id'),
|
||||
pin=d.get('pin', ''),
|
||||
pin_setup_code=d.get('pin_setup_code', ''),
|
||||
pin_setup_code_created=d.get('pin_setup_code_created'),
|
||||
marked_for_deletion=d.get('marked_for_deletion', False),
|
||||
marked_for_deletion_at=d.get('marked_for_deletion_at'),
|
||||
deletion_in_progress=d.get('deletion_in_progress', False),
|
||||
deletion_attempted_at=d.get('deletion_attempted_at'),
|
||||
role=d.get('role', 'user'),
|
||||
token_version=d.get('token_version', 0),
|
||||
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({
|
||||
'first_name': self.first_name,
|
||||
'last_name': self.last_name,
|
||||
'email': self.email,
|
||||
'password': self.password,
|
||||
'verified': self.verified,
|
||||
'verify_token': self.verify_token,
|
||||
'verify_token_created': self.verify_token_created,
|
||||
'reset_token': self.reset_token,
|
||||
'reset_token_created': self.reset_token_created,
|
||||
'image_id': self.image_id,
|
||||
'pin': self.pin,
|
||||
'pin_setup_code': self.pin_setup_code,
|
||||
'pin_setup_code_created': self.pin_setup_code_created,
|
||||
'marked_for_deletion': self.marked_for_deletion,
|
||||
'marked_for_deletion_at': self.marked_for_deletion_at,
|
||||
'deletion_in_progress': self.deletion_in_progress,
|
||||
'deletion_attempted_at': self.deletion_attempted_at,
|
||||
'role': self.role,
|
||||
'token_version': self.token_version,
|
||||
})
|
||||
return base
|
||||
6
backend/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |