Compare commits
262
Commits
1.0.3
..
f3e9201afd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e9201afd | ||
|
|
da7ed41938 | ||
|
|
c9c1fa18a2 | ||
|
|
d4800be3b7 | ||
|
|
11f68c7f76 | ||
|
|
96992cf918 | ||
|
|
8e4f89f4ec | ||
|
|
cc1189cd9b | ||
|
|
541bed3a8a | ||
|
|
f7b00fc7c4 | ||
|
|
d910a6bc65 | ||
|
|
7f0326eff1 | ||
|
|
06d17e3d34 | ||
|
|
e2bb9cd6b9 | ||
|
|
d147bd6f27 | ||
|
|
ec4912aa4a | ||
|
|
0b3d1d5ed0 | ||
|
|
229b6b6f7a | ||
|
|
63e92756fe | ||
|
|
08dda6c6b0 | ||
|
|
0f7efc8961 | ||
|
|
f510dea09d | ||
|
|
a6944ad59c | ||
|
|
ad8a8bf867 | ||
|
|
5392e5af70 | ||
|
|
eb775ba7d8 | ||
|
|
082097b4f9 | ||
|
|
2e1a0ab2fa | ||
|
|
ce3d1b3d54 | ||
|
|
75d3d6dc39 | ||
|
|
3d882656e3 | ||
|
|
8308d205e8 | ||
|
|
e77254eabf | ||
|
|
a68a86a6a6 | ||
|
|
4ac83dcf17 | ||
|
|
ab0d32c6b0 | ||
|
|
28f5c43349 | ||
|
|
a2b464af7b | ||
|
|
6bf10fda2f | ||
|
|
c840825549 | ||
|
|
9936cfd544 | ||
|
|
b42c36ebdd | ||
|
|
395199f9b2 | ||
|
|
a37b259f47 | ||
|
|
0606b71890 | ||
|
|
b4fc4fc955 | ||
|
|
8774e85529 | ||
|
|
2b2f4f46c0 | ||
|
|
c5306a15d1 | ||
|
|
fa1a422747 | ||
|
|
4093e79e50 | ||
|
|
39a547ca9c | ||
|
|
127378797c | ||
|
|
db846f4e31 | ||
|
|
6d43fb23ad | ||
|
|
6a29e263aa | ||
|
|
89de4fd869 | ||
|
|
d34910faa7 | ||
|
|
1a8181b8e5 | ||
|
|
902e6cadc9 | ||
|
|
c9a4f92337 | ||
|
|
ea308b28a9 | ||
|
|
8907184fde | ||
|
|
6982fa561f | ||
|
|
bc481527c8 | ||
|
|
60dbb8a129 | ||
|
|
6b3e2cd9ba | ||
|
|
3d599243c7 | ||
|
|
cdfaf7ead1 | ||
|
|
8da61ce335 | ||
|
|
3d2577e4ec | ||
|
|
2c7e9b8b5e | ||
|
|
f48845c1d0 | ||
|
|
4ee5367742 | ||
|
|
ee16b49020 | ||
|
|
b529ddaa02 | ||
|
|
fd28c89cbf | ||
|
|
d7b1962903 | ||
|
|
9efbb455d7 | ||
|
|
d3ce54a1ff | ||
|
|
3d5f84579b | ||
|
|
5e4f7b030e | ||
|
|
91e3c45b5a | ||
|
|
0a6551368b | ||
|
|
cf2ac4b5e8 | ||
|
|
f5dfdfbb42 | ||
|
|
43d647a712 | ||
|
|
308bf0cc72 | ||
|
|
ad2bdf4c4f | ||
|
|
0d50a324a3 | ||
|
|
ea0166d198 | ||
|
|
3116295980 | ||
|
|
876d3c5531 | ||
|
|
759a1c745e | ||
|
|
bb7c4c469c | ||
|
|
861b3dc9d4 | ||
|
|
8080a59de1 | ||
|
|
a4e23aad11 | ||
|
|
89097a390e | ||
|
|
469a2ffc24 | ||
|
|
028f99b5c3 | ||
|
|
16701278ed | ||
|
|
359c170b27 | ||
|
|
f64311689b | ||
|
|
ac29ee9bd0 | ||
|
|
e9f4343426 | ||
|
|
81169da05e | ||
|
|
ef9cb01d92 | ||
|
|
db6e0a7ce8 | ||
|
|
a9131242a7 | ||
|
|
b2115ceb57 | ||
|
|
cd902da292 | ||
|
|
c2b022eb0b | ||
|
|
8da04676ca | ||
|
|
f250c42e5e | ||
|
|
accf596bd7 | ||
|
|
2c65d3ecaf | ||
|
|
a8d7427a95 | ||
|
|
b2618361e4 | ||
|
|
a10836d412 | ||
|
|
bb5330ac17 | ||
|
|
8cdc26cb88 | ||
|
|
de56eb064f | ||
|
|
031d7c0eec | ||
|
|
f07af135b7 | ||
|
|
60647bc742 | ||
|
|
384be2a79e | ||
|
|
ccfc710753 | ||
|
|
992dd8423f | ||
|
|
c922e1180d | ||
|
|
82ac820c67 | ||
|
|
76fef8c688 | ||
|
|
16d3500368 | ||
|
|
c3538cc3d4 | ||
|
|
6433236191 | ||
|
|
ebaef16daf | ||
|
|
d7316bb00a | ||
|
|
65e987ceb6 | ||
|
|
f12940dc11 | ||
|
|
1777700cc8 | ||
|
|
f5a752d873 | ||
|
|
a197f8e206 | ||
|
|
2403daa3f7 | ||
|
|
91a52c1973 | ||
|
|
a41a357f50 | ||
|
|
234adbe05f | ||
|
|
d8822b44be | ||
|
|
d68272bb57 | ||
|
|
3673119ae2 | ||
|
|
55e7dc7568 | ||
|
|
ba909100a7 | ||
|
|
8148bfac51 | ||
|
|
c43af7d43e | ||
|
|
10216f49c9 | ||
|
|
42d3567c22 | ||
|
|
be4a816a7c | ||
|
|
773840d88b | ||
|
|
075160941a | ||
|
|
d2fea646de | ||
|
|
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 |
@@ -0,0 +1 @@
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
@@ -0,0 +1,187 @@
|
||||
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/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run frontend unit tests
|
||||
run: npm run test:unit --if-present
|
||||
working-directory: frontend
|
||||
|
||||
- name: Build Backend Docker Image
|
||||
run: |
|
||||
docker build -t git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} ./backend
|
||||
|
||||
- name: Build Frontend Docker Image
|
||||
run: |
|
||||
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend
|
||||
|
||||
- name: Log in to Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.ryankegel.com:3000
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Push Backend Image to Gitea Registry
|
||||
run: |
|
||||
for i in {1..3}; do
|
||||
echo "Attempt $i to push backend image..."
|
||||
if docker push git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }}; then
|
||||
echo "Backend push succeeded on attempt $i"
|
||||
break
|
||||
else
|
||||
echo "Backend push failed on attempt $i"
|
||||
if [ $i -lt 3 ]; then
|
||||
sleep 10
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:latest
|
||||
docker push git.ryankegel.com:3000/kegel/chores/backend:latest
|
||||
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/backend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/backend:next
|
||||
docker push git.ryankegel.com:3000/kegel/chores/backend:next
|
||||
fi
|
||||
|
||||
- name: Push Frontend Image to Gitea Registry
|
||||
run: |
|
||||
for i in {1..3}; do
|
||||
echo "Attempt $i to push frontend image..."
|
||||
if docker push git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }}; then
|
||||
echo "Frontend push succeeded on attempt $i"
|
||||
break
|
||||
else
|
||||
echo "Frontend push failed on attempt $i"
|
||||
if [ $i -lt 3 ]; then
|
||||
sleep 10
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "${{ gitea.ref }}" == "refs/heads/master" ]; then
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:latest
|
||||
docker push git.ryankegel.com:3000/kegel/chores/frontend:latest
|
||||
elif [ "${{ gitea.ref }}" == "refs/heads/next" ]; then
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} git.ryankegel.com:3000/kegel/chores/frontend:next
|
||||
docker push git.ryankegel.com:3000/kegel/chores/frontend:next
|
||||
fi
|
||||
|
||||
- name: Deploy Test Environment
|
||||
if: gitea.ref == 'refs/heads/next'
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_TEST_HOST }}
|
||||
username: ${{ secrets.DEPLOY_TEST_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
cd /tmp
|
||||
if [ -d "chore" ]; then
|
||||
cd chore
|
||||
git pull origin next || true
|
||||
else
|
||||
git clone --branch next https://git.ryankegel.com/ryan/chore.git
|
||||
cd chore
|
||||
fi
|
||||
|
||||
# Write .env file — docker-compose automatically reads this
|
||||
cat > .env << EOF
|
||||
SECRET_KEY=${{ secrets.SECRET_KEY }}
|
||||
REFRESH_TOKEN_EXPIRY_DAYS=1
|
||||
DIGEST_TOKEN_SECRET=${{ secrets.DIGEST_TOKEN_SECRET }}
|
||||
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||
SEED_EMAIL=${{ secrets.SEED_EMAIL }}
|
||||
SEED_PASSWORD=${{ secrets.SEED_PASSWORD }}
|
||||
SEED_PIN=${{ secrets.SEED_PIN }}
|
||||
SEED_FIRST_NAME=${{ secrets.SEED_FIRST_NAME }}
|
||||
SEED_LAST_NAME=${{ secrets.SEED_LAST_NAME }}
|
||||
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
||||
ADMIN_FIRST_NAME=${{ secrets.ADMIN_FIRST_NAME }}
|
||||
ADMIN_LAST_NAME=${{ secrets.ADMIN_LAST_NAME }}
|
||||
EOF
|
||||
|
||||
echo "SECRET_KEY is set: $(grep -q 'SECRET_KEY=' .env && echo YES || echo NO)"
|
||||
|
||||
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
|
||||
docker-compose -f docker-compose.test.yml up -d
|
||||
|
||||
echo "Waiting for backend to be ready..."
|
||||
sleep 10
|
||||
echo "Seeding test user..."
|
||||
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/seed_test_user.py
|
||||
echo "Creating admin user..."
|
||||
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/create_admin.py
|
||||
|
||||
- name: Send mail
|
||||
if: always() # Runs on success or failure
|
||||
uses: dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 465
|
||||
username: ${{ secrets.MAIL_USER }}
|
||||
password: ${{ secrets.MAIL_PASSWORD }}
|
||||
secure: true
|
||||
to: ${{ secrets.MAIL_TO }}
|
||||
from: Gitea <git@git.ryankegel.com>
|
||||
subject: ${{ github.repository }} - Job ${{ job.status }}
|
||||
convert_markdown: true
|
||||
html_body: |
|
||||
### Job ${{ job.status }}
|
||||
|
||||
${{ github.repository }}: [${{ github.ref }}@${{ github.sha }}](${{ vars.GIT_SERVER }}/${{ github.repository }}/actions)
|
||||
@@ -0,0 +1,503 @@
|
||||
name: Promote Master to Production
|
||||
run-name: ${{ gitea.actor }} promoting ${{ github.event.inputs.target_ref || 'master' }} [${{ gitea.sha }}]
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_ref:
|
||||
description: "Git ref/branch to promote"
|
||||
required: true
|
||||
default: "master"
|
||||
run_backend_tests:
|
||||
description: "Run backend pytest gate"
|
||||
required: true
|
||||
default: "true"
|
||||
run_frontend_tests:
|
||||
description: "Run frontend unit test gate"
|
||||
required: true
|
||||
default: "true"
|
||||
run_playwright_tests:
|
||||
description: "Run Playwright E2E gate"
|
||||
required: true
|
||||
default: "true"
|
||||
skip_tests:
|
||||
description: "Skip the entire tests stage"
|
||||
required: true
|
||||
default: "false"
|
||||
deploy:
|
||||
description: "Run production deploy over SSH"
|
||||
required: true
|
||||
default: "true"
|
||||
create_tag:
|
||||
description: "Create and push git release tag after successful deploy"
|
||||
required: true
|
||||
default: "true"
|
||||
require_manual_approval:
|
||||
description: "Require approval token for deploy job"
|
||||
required: true
|
||||
default: "false"
|
||||
approval_token:
|
||||
description: "Approval token value when manual approval is required"
|
||||
required: false
|
||||
default: ""
|
||||
rollback_on_failed_healthcheck:
|
||||
description: "Auto-rollback if post-deploy health checks fail"
|
||||
required: true
|
||||
default: "false"
|
||||
|
||||
concurrency:
|
||||
group: promotion-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
target_ref: ${{ steps.resolve.outputs.target_ref }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
release_tag: ${{ steps.version.outputs.release_tag }}
|
||||
commit_sha: ${{ steps.commit.outputs.commit_sha }}
|
||||
steps:
|
||||
- name: Resolve target ref
|
||||
id: resolve
|
||||
run: |
|
||||
target_ref="${{ github.event.inputs.target_ref }}"
|
||||
if [ -z "$target_ref" ]; then
|
||||
target_ref="master"
|
||||
fi
|
||||
echo "target_ref=$target_ref" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ steps.resolve.outputs.target_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version and release tag
|
||||
id: version
|
||||
run: |
|
||||
version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=v$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve promoted commit SHA
|
||||
id: commit
|
||||
run: |
|
||||
echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Guard against duplicate release tags
|
||||
if: ${{ github.event.inputs.create_tag != 'false' }}
|
||||
run: |
|
||||
release_tag="${{ steps.version.outputs.release_tag }}"
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1; then
|
||||
echo "Release tag ${release_tag} already exists on origin; aborting promotion."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
needs: prepare
|
||||
steps:
|
||||
- name: Skip tests stage
|
||||
if: ${{ github.event.inputs.skip_tests == 'true' }}
|
||||
run: echo "Skipping tests stage because skip_tests=true"
|
||||
|
||||
- name: Check out promoted ref
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' }}
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
|
||||
- name: Set up Python for backend tests
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install backend dependencies (runner python)
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
- name: Run backend unit tests
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
||||
run: |
|
||||
cd backend
|
||||
pytest -q
|
||||
|
||||
- name: Set up Node.js
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.19.0"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run frontend unit tests
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_frontend_tests != 'false' }}
|
||||
run: npm run test:unit --if-present
|
||||
working-directory: frontend
|
||||
|
||||
- name: Create backend venv for Playwright webServer
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||
run: |
|
||||
python -m venv backend/.venv
|
||||
backend/.venv/bin/python -m pip install --upgrade pip
|
||||
backend/.venv/bin/python -m pip install -r backend/requirements.txt
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||
run: npx playwright install --with-deps
|
||||
working-directory: frontend
|
||||
|
||||
- name: Run Playwright tests
|
||||
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||
run: npx playwright test --reporter=line
|
||||
working-directory: frontend
|
||||
env:
|
||||
CI: "true"
|
||||
PLAYWRIGHT_BASE_URL: "https://localhost:5173"
|
||||
E2E_ACCESS_TOKEN_EXPIRY_MINUTES: "180"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
needs:
|
||||
- prepare
|
||||
- tests
|
||||
if: ${{ github.event.inputs.deploy != 'false' }}
|
||||
steps:
|
||||
- name: Validate manual approval token
|
||||
if: ${{ github.event.inputs.require_manual_approval != 'false' }}
|
||||
run: |
|
||||
if [ -z "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
|
||||
echo "PROD_APPROVAL_TOKEN secret is required when manual approval is enabled."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${{ github.event.inputs.approval_token }}" ]; then
|
||||
echo "approval_token input is required when manual approval is enabled."
|
||||
exit 1
|
||||
fi
|
||||
if [ "${{ github.event.inputs.approval_token }}" != "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
|
||||
echo "Approval token does not match PROD_APPROVAL_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Deploy to production homeserver
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_PROD_HOST }}
|
||||
username: ${{ secrets.DEPLOY_PROD_USER }}
|
||||
key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||
port: 22
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="${{ secrets.DEPLOY_PROD_PATH }}"
|
||||
if [ -z "$repo_dir" ]; then
|
||||
repo_dir="$HOME/chore"
|
||||
fi
|
||||
|
||||
repo_parent=$(dirname "$repo_dir")
|
||||
if [ ! -d "$repo_parent" ]; then
|
||||
mkdir -p "$repo_parent"
|
||||
fi
|
||||
if [ ! -w "$repo_parent" ]; then
|
||||
echo "Deploy user $(whoami) cannot write to ${repo_parent}."
|
||||
echo "Set DEPLOY_PROD_PATH to a writable location or grant write access before rerunning promotion."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "$repo_dir/.git" ]; then
|
||||
cd "$repo_dir"
|
||||
git fetch --all --tags
|
||||
else
|
||||
git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git "$repo_dir"
|
||||
cd "$repo_dir"
|
||||
fi
|
||||
|
||||
git checkout "${{ needs.prepare.outputs.target_ref }}"
|
||||
git reset --hard "origin/${{ needs.prepare.outputs.target_ref }}"
|
||||
|
||||
timestamp=$(date +%Y%m%d-%H%M%S)
|
||||
mkdir -p backups
|
||||
|
||||
# Capture currently running images for rollback.
|
||||
backend_prev_id=""
|
||||
frontend_prev_id=""
|
||||
if docker ps --format '{{.Names}}' | grep -q '^chores-app-backend-prod$'; then
|
||||
backend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-backend-prod)
|
||||
docker tag "$backend_prev_id" git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup || true
|
||||
fi
|
||||
if docker ps --format '{{.Names}}' | grep -q '^chores-app-frontend-prod$'; then
|
||||
frontend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-frontend-prod)
|
||||
docker tag "$frontend_prev_id" git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup || true
|
||||
fi
|
||||
|
||||
cat > .rollback-meta << EOF
|
||||
BACKEND_PREV_ID=${backend_prev_id}
|
||||
FRONTEND_PREV_ID=${frontend_prev_id}
|
||||
CREATED_AT=${timestamp}
|
||||
PROMOTED_SHA=${{ needs.prepare.outputs.commit_sha }}
|
||||
RELEASE_TAG=${{ needs.prepare.outputs.release_tag }}
|
||||
EOF
|
||||
|
||||
# Backup TinyDB volume before replacement.
|
||||
docker run --rm \
|
||||
-v chores-app-backend-data:/data \
|
||||
-v "$PWD/backups:/backup" \
|
||||
alpine sh -c "tar czf /backup/chores-backend-data-${timestamp}.tar.gz -C /data ." || true
|
||||
|
||||
# Keep long-lived cryptographic values stable; fall back to previous .env if secrets are absent.
|
||||
PREV_SECRET_KEY=""
|
||||
PREV_DIGEST_TOKEN_SECRET=""
|
||||
PREV_VAPID_PUBLIC_KEY=""
|
||||
PREV_VAPID_PRIVATE_KEY=""
|
||||
PREV_REFRESH_TOKEN_EXPIRY_DAYS=""
|
||||
if [ -f .env ]; then
|
||||
PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
PREV_REFRESH_TOKEN_EXPIRY_DAYS=$(grep '^REFRESH_TOKEN_EXPIRY_DAYS=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
fi
|
||||
|
||||
SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
|
||||
DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
|
||||
VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
|
||||
VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}"
|
||||
|
||||
if [ -z "$SECRET_KEY_VALUE" ]; then
|
||||
if [ -n "$PREV_SECRET_KEY" ]; then
|
||||
SECRET_KEY_VALUE="$PREV_SECRET_KEY"
|
||||
else
|
||||
SECRET_KEY_VALUE=$(openssl rand -hex 32)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$DIGEST_TOKEN_SECRET_VALUE" ]; then
|
||||
if [ -n "$PREV_DIGEST_TOKEN_SECRET" ]; then
|
||||
DIGEST_TOKEN_SECRET_VALUE="$PREV_DIGEST_TOKEN_SECRET"
|
||||
else
|
||||
DIGEST_TOKEN_SECRET_VALUE=$(openssl rand -hex 32)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] && [ -n "$PREV_VAPID_PUBLIC_KEY" ]; then
|
||||
VAPID_PUBLIC_KEY_VALUE="$PREV_VAPID_PUBLIC_KEY"
|
||||
fi
|
||||
if [ -z "$VAPID_PRIVATE_KEY_VALUE" ] && [ -n "$PREV_VAPID_PRIVATE_KEY" ]; then
|
||||
VAPID_PRIVATE_KEY_VALUE="$PREV_VAPID_PRIVATE_KEY"
|
||||
fi
|
||||
|
||||
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] || [ -z "$VAPID_PRIVATE_KEY_VALUE" ]; then
|
||||
echo "VAPID keys are required (set PROD_VAPID_PUBLIC_KEY/PROD_VAPID_PRIVATE_KEY or keep previous .env values)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" ]; then
|
||||
if [ -n "$PREV_REFRESH_TOKEN_EXPIRY_DAYS" ]; then
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="$PREV_REFRESH_TOKEN_EXPIRY_DAYS"
|
||||
else
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="90"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! printf '%s' "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" | grep -Eq '^[0-9]+$'; then
|
||||
echo "REFRESH_TOKEN_EXPIRY_DAYS must be a positive integer, got: $REFRESH_TOKEN_EXPIRY_DAYS_VALUE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat > .env << EOF
|
||||
FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
|
||||
BACKEND_HOST_PORT=${{ secrets.PROD_BACKEND_HOST_PORT }}
|
||||
FRONTEND_HOST_PORT=${{ secrets.PROD_FRONTEND_HOST_PORT }}
|
||||
SECRET_KEY=${SECRET_KEY_VALUE}
|
||||
REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS_VALUE}
|
||||
DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
|
||||
VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
|
||||
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
|
||||
ADMIN_EMAIL=${{ secrets.PROD_ADMIN_EMAIL }}
|
||||
ADMIN_PASSWORD=${{ secrets.PROD_ADMIN_PASSWORD }}
|
||||
ADMIN_FIRST_NAME=${{ secrets.PROD_ADMIN_FIRST_NAME }}
|
||||
ADMIN_LAST_NAME=${{ secrets.PROD_ADMIN_LAST_NAME }}
|
||||
EOF
|
||||
|
||||
chmod 600 .env .rollback-meta
|
||||
|
||||
backend_host_port=$(grep '^BACKEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
frontend_host_port=$(grep '^FRONTEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||
if [ -z "$backend_host_port" ]; then
|
||||
backend_host_port="5001"
|
||||
fi
|
||||
if [ -z "$frontend_host_port" ]; then
|
||||
frontend_host_port="4601"
|
||||
fi
|
||||
|
||||
docker-compose down
|
||||
|
||||
wait_for_container_removal() {
|
||||
container_name="$1"
|
||||
attempts=0
|
||||
while docker ps -a --format '{{.Names}}' | grep -qx "$container_name"; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 12 ]; then
|
||||
echo "Container ${container_name} still exists after waiting for teardown."
|
||||
docker ps -a --filter "name=^${container_name}$"
|
||||
return 1
|
||||
fi
|
||||
echo "Waiting for ${container_name} to be removed..."
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_port_release() {
|
||||
port="$1"
|
||||
attempts=0
|
||||
while docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ "$attempts" -ge 12 ]; then
|
||||
echo "Port ${port} is still allocated after teardown. Blocking container(s):"
|
||||
docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" || true
|
||||
return 1
|
||||
fi
|
||||
echo "Waiting for port ${port} to be released..."
|
||||
sleep 5
|
||||
done
|
||||
}
|
||||
|
||||
wait_for_container_removal chores-app-frontend-prod
|
||||
wait_for_container_removal chores-app-backend-prod
|
||||
wait_for_port_release "$frontend_host_port"
|
||||
wait_for_port_release "$backend_host_port"
|
||||
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
|
||||
sleep 20
|
||||
|
||||
backend_running=$(docker inspect -f '{{.State.Running}}' chores-app-backend-prod 2>/dev/null || echo false)
|
||||
frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)
|
||||
|
||||
backend_ok=false
|
||||
if curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null 2>&1; then
|
||||
backend_ok=true
|
||||
fi
|
||||
|
||||
frontend_ok=false
|
||||
if curl -fsS "http://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
|
||||
frontend_ok=true
|
||||
fi
|
||||
|
||||
if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
|
||||
echo "Post-deploy health check failed."
|
||||
docker-compose ps
|
||||
echo "--- Backend logs (last 200 lines) ---"
|
||||
docker logs --tail 200 chores-app-backend-prod || true
|
||||
echo "--- Frontend logs (last 120 lines) ---"
|
||||
docker logs --tail 120 chores-app-frontend-prod || true
|
||||
|
||||
if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
|
||||
echo "Attempting rollback using predeploy-backup image tags..."
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup git.ryankegel.com:3000/kegel/chores/backend:latest || true
|
||||
docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
|
||||
docker-compose up -d
|
||||
sleep 20
|
||||
curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null
|
||||
fi
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker-compose ps
|
||||
|
||||
create-release-tag:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs:
|
||||
- prepare
|
||||
- tests
|
||||
- deploy
|
||||
if: ${{ github.event.inputs.create_tag != 'false' && github.event.inputs.deploy != 'false' }}
|
||||
steps:
|
||||
- name: Check out promoted ref
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create and push annotated release tag
|
||||
run: |
|
||||
tag_name="${{ needs.prepare.outputs.release_tag }}"
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@local"
|
||||
git tag -a "$tag_name" -m "Release $tag_name"
|
||||
git push origin "$tag_name"
|
||||
|
||||
notify-on-failure:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- prepare
|
||||
- tests
|
||||
- deploy
|
||||
- create-release-tag
|
||||
if: ${{ always() }}
|
||||
steps:
|
||||
- name: Send email when promotion fails
|
||||
if: ${{ needs.prepare.result == 'failure' || needs.tests.result == 'failure' || needs.deploy.result == 'failure' || needs.create-release-tag.result == 'failure' }}
|
||||
uses: dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 465
|
||||
username: ${{ secrets.MAIL_USER }}
|
||||
password: ${{ secrets.MAIL_PASSWORD }}
|
||||
secure: true
|
||||
to: ${{ secrets.MAIL_TO }}
|
||||
from: Gitea <git@git.ryankegel.com>
|
||||
subject: Promotion failed - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
|
||||
convert_markdown: true
|
||||
html_body: |
|
||||
### Production promotion failed
|
||||
|
||||
- Repository: ${{ gitea.repository }}
|
||||
- Ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
- Commit: ${{ needs.prepare.outputs.commit_sha }}
|
||||
- Intended tag: ${{ needs.prepare.outputs.release_tag }}
|
||||
- prepare: ${{ needs.prepare.result }}
|
||||
- tests: ${{ needs.tests.result }}
|
||||
- deploy: ${{ needs.deploy.result }}
|
||||
- tag: ${{ needs.create-release-tag.result }}
|
||||
|
||||
- name: Send email when promotion succeeds
|
||||
if: ${{ needs.prepare.result == 'success' && needs.tests.result == 'success' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') && (needs.create-release-tag.result == 'success' || needs.create-release-tag.result == 'skipped') }}
|
||||
uses: dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 465
|
||||
username: ${{ secrets.MAIL_USER }}
|
||||
password: ${{ secrets.MAIL_PASSWORD }}
|
||||
secure: true
|
||||
to: ${{ secrets.MAIL_TO }}
|
||||
from: Gitea <git@git.ryankegel.com>
|
||||
subject: Promotion succeeded - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
|
||||
convert_markdown: true
|
||||
html_body: |
|
||||
### Production promotion succeeded
|
||||
|
||||
- Repository: ${{ gitea.repository }}
|
||||
- Ref: ${{ needs.prepare.outputs.target_ref }}
|
||||
- Commit: ${{ needs.prepare.outputs.commit_sha }}
|
||||
- Release tag: ${{ needs.prepare.outputs.release_tag }}
|
||||
- prepare: ${{ needs.prepare.result }}
|
||||
- tests: ${{ needs.tests.result }}
|
||||
- deploy: ${{ needs.deploy.result }}
|
||||
- tag: ${{ needs.create-release-tag.result }}
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: playwright-implementation
|
||||
|
||||
description: Converts plans into code and performs self-healing verification.
|
||||
tools: [execute, read, edit, search, "playwright-test/*"]
|
||||
---
|
||||
|
||||
# Role: Senior QA Automation Engineer
|
||||
|
||||
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
|
||||
|
||||
# Test Implementation & Healing Workflow
|
||||
|
||||
When you receive a test plan:
|
||||
|
||||
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
|
||||
2. **Verify**: Once files are written, execute the following command in the terminal:
|
||||
`npx playwright test --agent=healer`
|
||||
3. **Analyze & Repair**:
|
||||
- If the Healer Agent proposes a patch, review it.
|
||||
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
|
||||
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
|
||||
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
|
||||
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
|
||||
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: playwright-research
|
||||
description: Scans codebase and explores URLs to create Playwright test plans.
|
||||
#argument-hint: The inputs this agent expects, e.g., "a task to implement" or "a question to answer".
|
||||
tools: ["read", "search", "playwright/*", "web"]
|
||||
handoffs:
|
||||
- label: Start Implementation
|
||||
agent: playwright-implementation
|
||||
prompt: Implement the test plan
|
||||
send: true
|
||||
# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed.
|
||||
---
|
||||
|
||||
# Test Architect Persona
|
||||
|
||||
You are a Senior QA Architect. Your goal is to analyze the user's codebase and
|
||||
live application to identify high-value test cases.
|
||||
|
||||
### Your Workflow:
|
||||
|
||||
1. **Scan**: Use `read` and `search` to understand existing project structure and components.
|
||||
2. **Explore**: Use `playwright/navigate` and `playwright/screenshot` to explore the live UI.
|
||||
3. **Analyze**: Identify edge cases, happy paths, and critical user journeys.
|
||||
4. **Present**: Output a structured Markdown Test Plan.
|
||||
|
||||
### Hard Constraints:
|
||||
|
||||
- **DO NOT** write any `.spec.ts` or `.js` files.
|
||||
- **DO NOT** modify existing code.
|
||||
- **ONLY** present the plan and wait for feedback.
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: playwright-test-generator
|
||||
description: 'Use this agent when you need to create automated browser tests using Playwright Examples: <example>Context: User wants to generate a test for the test plan item. <test-suite><!-- Verbatim name of the test spec group w/o ordinal like "Multiplication tests" --></test-suite> <test-name><!-- Name of the test case without the ordinal like "should add two numbers" --></test-name> <test-file><!-- Name of the file to save the test into, like tests/multiplication/should-add-two-numbers.spec.ts --></test-file> <seed-file><!-- Seed file path from test plan --></seed-file> <body><!-- Test case content including steps and expectations --></body></example>'
|
||||
tools:
|
||||
- search
|
||||
- playwright-test/browser_click
|
||||
- playwright-test/browser_drag
|
||||
- playwright-test/browser_evaluate
|
||||
- playwright-test/browser_file_upload
|
||||
- playwright-test/browser_handle_dialog
|
||||
- playwright-test/browser_hover
|
||||
- playwright-test/browser_navigate
|
||||
- playwright-test/browser_press_key
|
||||
- playwright-test/browser_select_option
|
||||
- playwright-test/browser_snapshot
|
||||
- playwright-test/browser_type
|
||||
- playwright-test/browser_verify_element_visible
|
||||
- playwright-test/browser_verify_list_visible
|
||||
- playwright-test/browser_verify_text_visible
|
||||
- playwright-test/browser_verify_value
|
||||
- playwright-test/browser_wait_for
|
||||
- playwright-test/generator_read_log
|
||||
- playwright-test/generator_setup_page
|
||||
- playwright-test/generator_write_test
|
||||
model: Claude Sonnet 4.6
|
||||
mcp-servers:
|
||||
playwright-test:
|
||||
type: stdio
|
||||
command: npx
|
||||
args:
|
||||
- playwright
|
||||
- run-test-mcp-server
|
||||
tools:
|
||||
- "*"
|
||||
---
|
||||
|
||||
You are a Playwright Test Generator, an expert in browser automation and end-to-end testing.
|
||||
Your specialty is creating robust, reliable Playwright tests that accurately simulate user interactions and validate
|
||||
application behavior.
|
||||
|
||||
# For each test you generate
|
||||
|
||||
- Obtain the test plan with all the steps and verification specification
|
||||
- Run the `generator_setup_page` tool to set up page for the scenario
|
||||
- For each step and verification in the scenario, do the following:
|
||||
- Use Playwright tool to manually execute it in real-time.
|
||||
- Use the step description as the intent for each Playwright tool call.
|
||||
- Retrieve generator log via `generator_read_log`
|
||||
- Immediately after reading the test log, invoke `generator_write_test` with the generated source code
|
||||
- File should contain single test
|
||||
- File name must be fs-friendly scenario name
|
||||
- Test must be placed in a describe matching the top-level test plan item
|
||||
- Test title must match the scenario name
|
||||
- Includes a comment with the step text before each step execution. Do not duplicate comments if step requires
|
||||
multiple actions.
|
||||
- Always use best practices from the log when generating tests.
|
||||
|
||||
<example-generation>
|
||||
For following plan:
|
||||
|
||||
```markdown file=specs/plan.md
|
||||
### 1. Adding New Todos
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
|
||||
#### 1.1 Add Valid Todo
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Click in the "What needs to be done?" input field
|
||||
|
||||
#### 1.2 Add Multiple Todos
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
Following file is generated:
|
||||
|
||||
```ts file=add-valid-todo.spec.ts
|
||||
// spec: specs/plan.md
|
||||
// seed: tests/seed.spec.ts
|
||||
|
||||
test.describe('Adding New Todos', () => {
|
||||
test('Add Valid Todo', async { page } => {
|
||||
// 1. Click in the "What needs to be done?" input field
|
||||
await page.click(...);
|
||||
|
||||
...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
</example-generation>
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
name: playwright-test-healer
|
||||
description: Use this agent when you need to debug and fix failing Playwright tests
|
||||
tools:
|
||||
- search
|
||||
- edit
|
||||
- playwright-test/browser_console_messages
|
||||
- playwright-test/browser_evaluate
|
||||
- playwright-test/browser_generate_locator
|
||||
- playwright-test/browser_network_requests
|
||||
- playwright-test/browser_snapshot
|
||||
- playwright-test/test_debug
|
||||
- playwright-test/test_list
|
||||
- playwright-test/test_run
|
||||
model: Claude Sonnet 4
|
||||
mcp-servers:
|
||||
playwright-test:
|
||||
type: stdio
|
||||
command: npx
|
||||
args:
|
||||
- playwright
|
||||
- run-test-mcp-server
|
||||
tools:
|
||||
- "*"
|
||||
---
|
||||
|
||||
You are the Playwright Test Healer, an expert test automation engineer specializing in debugging and
|
||||
resolving Playwright test failures. Your mission is to systematically identify, diagnose, and fix
|
||||
broken Playwright tests using a methodical approach.
|
||||
|
||||
Your workflow:
|
||||
1. **Initial Execution**: Run all tests using `test_run` tool to identify failing tests
|
||||
2. **Debug failed tests**: For each failing test run `test_debug`.
|
||||
3. **Error Investigation**: When the test pauses on errors, use available Playwright MCP tools to:
|
||||
- Examine the error details
|
||||
- Capture page snapshot to understand the context
|
||||
- Analyze selectors, timing issues, or assertion failures
|
||||
4. **Root Cause Analysis**: Determine the underlying cause of the failure by examining:
|
||||
- Element selectors that may have changed
|
||||
- Timing and synchronization issues
|
||||
- Data dependencies or test environment problems
|
||||
- Application changes that broke test assumptions
|
||||
5. **Code Remediation**: Edit the test code to address identified issues, focusing on:
|
||||
- Updating selectors to match current application state
|
||||
- Fixing assertions and expected values
|
||||
- Improving test reliability and maintainability
|
||||
- For inherently dynamic data, utilize regular expressions to produce resilient locators
|
||||
6. **Verification**: Restart the test after each fix to validate the changes
|
||||
7. **Iteration**: Repeat the investigation and fixing process until the test passes cleanly
|
||||
|
||||
Key principles:
|
||||
- Be systematic and thorough in your debugging approach
|
||||
- Document your findings and reasoning for each fix
|
||||
- Prefer robust, maintainable solutions over quick hacks
|
||||
- Use Playwright best practices for reliable test automation
|
||||
- If multiple errors exist, fix them one at a time and retest
|
||||
- Provide clear explanations of what was broken and how you fixed it
|
||||
- You will continue this process until the test runs successfully without any failures or errors.
|
||||
- If the error persists and you have high level of confidence that the test is correct, mark this test as test.fixme()
|
||||
so that it is skipped during the execution. Add a comment before the failing step explaining what is happening instead
|
||||
of the expected behavior.
|
||||
- Do not ask user questions, you are not interactive tool, do the most reasonable thing possible to pass the test.
|
||||
- Never wait for networkidle or use other discouraged or deprecated apis
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: playwright-test-planner
|
||||
description: Use this agent when you need to create comprehensive test plan for a web application or website
|
||||
tools:
|
||||
- search
|
||||
- playwright-test/browser_click
|
||||
- playwright-test/browser_close
|
||||
- playwright-test/browser_console_messages
|
||||
- playwright-test/browser_drag
|
||||
- playwright-test/browser_evaluate
|
||||
- playwright-test/browser_file_upload
|
||||
- playwright-test/browser_handle_dialog
|
||||
- playwright-test/browser_hover
|
||||
- playwright-test/browser_navigate
|
||||
- playwright-test/browser_navigate_back
|
||||
- playwright-test/browser_network_requests
|
||||
- playwright-test/browser_press_key
|
||||
- playwright-test/browser_run_code
|
||||
- playwright-test/browser_select_option
|
||||
- playwright-test/browser_snapshot
|
||||
- playwright-test/browser_take_screenshot
|
||||
- playwright-test/browser_type
|
||||
- playwright-test/browser_wait_for
|
||||
- playwright-test/planner_setup_page
|
||||
- playwright-test/planner_save_plan
|
||||
model: Claude Sonnet 4.6
|
||||
mcp-servers:
|
||||
playwright-test:
|
||||
type: stdio
|
||||
command: npx
|
||||
args:
|
||||
- playwright
|
||||
- run-test-mcp-server
|
||||
tools:
|
||||
- "*"
|
||||
---
|
||||
|
||||
You are an expert web test planner with extensive experience in quality assurance, user experience testing, and test
|
||||
scenario design. Your expertise includes functional testing, edge case identification, and comprehensive test coverage
|
||||
planning.
|
||||
|
||||
You will:
|
||||
|
||||
1. **Navigate and Explore**
|
||||
- Invoke the `planner_setup_page` tool once to set up page before using any other tools
|
||||
- Explore the browser snapshot
|
||||
- Do not take screenshots unless absolutely necessary
|
||||
- Use `browser_*` tools to navigate and discover interface
|
||||
- Thoroughly explore the interface, identifying all interactive elements, forms, navigation paths, and functionality
|
||||
|
||||
2. **Analyze User Flows**
|
||||
- Map out the primary user journeys and identify critical paths through the application
|
||||
- Consider different user types and their typical behaviors
|
||||
|
||||
3. **Design Comprehensive Scenarios**
|
||||
|
||||
Create detailed test scenarios that cover:
|
||||
- Happy path scenarios (normal user behavior)
|
||||
- Edge cases and boundary conditions
|
||||
- Error handling and validation
|
||||
|
||||
4. **Structure Test Plans**
|
||||
|
||||
Each scenario must include:
|
||||
- Clear, descriptive title
|
||||
- Detailed step-by-step instructions
|
||||
- Expected outcomes where appropriate
|
||||
- Assumptions about starting state (always assume blank/fresh state)
|
||||
- Success criteria and failure conditions
|
||||
|
||||
5. **Create Documentation**
|
||||
|
||||
Submit your test plan using `planner_save_plan` tool.
|
||||
|
||||
**Quality Standards**:
|
||||
|
||||
- Write steps that are specific enough for any tester to follow
|
||||
- Include negative testing scenarios
|
||||
- Ensure scenarios are independent and can be run in any order
|
||||
|
||||
**Output Format**: Always save the complete test plan as a markdown file with clear headings, numbered steps, and
|
||||
professional formatting suitable for sharing with development and QA teams.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: playwright
|
||||
description: Expert in end-to-end testing using Playwright and TypeScript.
|
||||
tools: [runCommands, readFile, editFiles, fetchWebpage, codebase, findTestFiles]
|
||||
---
|
||||
|
||||
# Role: Senior QA Automation Engineer
|
||||
|
||||
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
|
||||
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
|
||||
3. **Execution:** After writing a test, run it using `npx playwright test` from `frontend/vue-app/`. If it fails, read the trace and fix the test immediately.
|
||||
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
|
||||
5. **Test Naming:** Test files must match the pattern `*.smoke.spec.ts` to be picked up by the `smoke` project in `playwright.config.ts`.
|
||||
|
||||
## Workflow
|
||||
|
||||
- **Step 1:** Read the relevant source code or component file.
|
||||
- **Step 2:** Generate a plan for the test steps.
|
||||
- **Step 3:** Create/Update Page Objects in `tests/pages/`.
|
||||
- **Step 4:** Write the test file in `tests/` using the `*.smoke.spec.ts` naming convention.
|
||||
- **Step 5:** Run the test and verify success.
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: playwright
|
||||
description: Expert in end-to-end testing using Playwright and TypeScript.
|
||||
---
|
||||
|
||||
# Role: Senior QA Automation Engineer
|
||||
|
||||
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
|
||||
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
|
||||
3. **Execution:** After writing a test, run it using `npx playwright test` from `frontend/vue-app/`. If it fails, read the trace and fix the test immediately.
|
||||
4. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`. Both must be running before tests execute. Use the `flask-backend` skill (sets `DB_ENV=e2e DATA_ENV=e2e`) and `vue-frontend` skill to start them.
|
||||
5. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
|
||||
6. **Test Naming:** Test files must match the pattern `*.smoke.spec.ts` to be picked up by the `smoke` project in `playwright.config.ts`.
|
||||
|
||||
## Workflow
|
||||
|
||||
- **Step 1:** Read the relevant source code or component file.
|
||||
- **Step 2:** Generate a plan for the test steps.
|
||||
- **Step 3:** Create/Update Page Objects in `tests/pages/`.
|
||||
- **Step 4:** Write the test file in `tests/` using the `*.smoke.spec.ts` naming convention.
|
||||
- **Step 5:** Run the test and verify success.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: ui-planner
|
||||
description: Expert UI/UX design review and creative alternative layouts for Vue 3 components. Use when asking for design critiques, layout ideas, visual polish, animation suggestions, or CSS improvements on existing .vue files.
|
||||
argument-hint: review this design
|
||||
tools:
|
||||
- read
|
||||
- search
|
||||
- edit
|
||||
- vscode
|
||||
- web
|
||||
- todo
|
||||
---
|
||||
|
||||
You are a Senior UI/UX Architect specializing in clean, highly reactive web applications. Your goal is to provide expert design critiques and creative layout alternatives that are technically compatible with Vue 3 and this project's conventions.
|
||||
|
||||
## 🏗 Project Context
|
||||
|
||||
This is the **Reward** app — a family chore/reward tracker. Vue 3 (Composition API / `<script setup lang="ts">`) frontend. Key files:
|
||||
|
||||
- Theme tokens: `frontend/src/assets/colors.css` — **always read this first** when reviewing a component.
|
||||
- Layout wrappers: `ParentLayout` (admin views) and `ChildLayout` (child dashboard views).
|
||||
- All `.vue` files use `<style scoped>`. Child component styling uses `:deep()` selectors.
|
||||
- File order within `.vue` files: `<template>`, then `<script>`, then `<style>`.
|
||||
|
||||
### Available Design Tokens (from `colors.css`)
|
||||
|
||||
**Brand:** `--primary` (#667eea), `--secondary` (#7257b3), `--accent` (#cbd5e1)
|
||||
**Header:** `--header-bg` (gradient primary → secondary)
|
||||
**Buttons:** `--btn-primary`, `--btn-primary-hover`, `--btn-secondary`, `--btn-secondary-hover`, `--btn-secondary-text`, `--btn-danger`, `--btn-danger-hover`, `--btn-green`, `--btn-green-hover`
|
||||
**List items:** `--list-item-bg`, `--list-item-bg-good` (#8dabfd), `--list-item-bg-bad` (#f98a8a), `--list-item-bg-reward` (#4ed271), `--list-item-border-good`, `--list-item-border-bad`, `--list-item-border-reward`
|
||||
**Forms:** `--form-bg`, `--form-shadow`, `--form-heading`, `--form-label`, `--form-input-bg`, `--form-input-border`
|
||||
**Modals/Cards:** `--modal-bg`, `--modal-shadow`, `--card-bg`, `--card-shadow`, `--card-title`
|
||||
**Feedback:** `--error`, `--error-bg`, `--loading-color`, `--list-loading-color`
|
||||
**FAB:** `--fab-bg`, `--fab-hover-bg`, `--fab-active-bg`
|
||||
|
||||
## 🛠 Technical Constraints
|
||||
|
||||
- **No utility frameworks.** No Tailwind, no Bootstrap.
|
||||
- **Layout:** Flexbox and CSS Grid only.
|
||||
- **Animations:** Vue `<Transition>` / `<TransitionGroup>` for state changes; `@keyframes` for loaders.
|
||||
- **Theming:** Only use `var(--token)` from `colors.css` for any color, shadow, or spacing token. Never hardcode hex values.
|
||||
- **Component-first:** Consider how changes affect child component slots and reusability.
|
||||
|
||||
---
|
||||
|
||||
## Workflow
|
||||
|
||||
When a component is shared, always **read the file** plus `colors.css` before responding.
|
||||
|
||||
### Step 1 — Design Audit
|
||||
|
||||
Critique the current UI concisely:
|
||||
|
||||
1. **Visual Hierarchy:** Does the most important information catch the eye first?
|
||||
2. **State Clarity:** Are loading / empty / pending / success / error states visually distinct?
|
||||
3. **Consistency:** Does spacing, typography, and color usage feel cohesive with the rest of the app?
|
||||
|
||||
### Step 2 — Creative Exploration
|
||||
|
||||
Offer a range of design directions — not a fixed number, but varied **perspectives**:
|
||||
|
||||
- **Refinement:** Polish what exists for better clarity or polish.
|
||||
- **Layout Shift:** Reorganize spatial relationships of elements.
|
||||
- **Visual Metaphor:** New ways to represent status (icons vs. text vs. color-coding vs. progress indicators).
|
||||
- **Interactivity:** Transitions, micro-animations, or hover states that make the UI feel alive.
|
||||
|
||||
### Step 3 — Implementation Spec
|
||||
|
||||
For any direction the user wants to pursue, produce a complete spec:
|
||||
|
||||
- **Template:** Semantic HTML using existing project components and slot patterns.
|
||||
- **CSS:** Complete `<style scoped>` rules using only `var(--token)` design tokens.
|
||||
- **Logic:** TypeScript / Vue reactivity changes needed (computed props, refs, transitions).
|
||||
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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/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/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/`, run `npm install` then `npm run dev`.
|
||||
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/components/__tests__/`. E2E tests: `npx playwright test` from `frontend/` — requires both servers running (use the `flask-backend` and `vue-frontend` skills).
|
||||
- **E2E Setup**: Playwright config is at `frontend/playwright.config.ts`. Tests live in `frontend/tests/`. The `globalSetup` in `playwright.config.ts` seeds the database and logs in once; all tests receive a pre-authenticated session via `storageState` — do NOT navigate to `/auth/login` in tests. Import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` rather than hardcoding credentials. The backend must be started with `DB_ENV=e2e DATA_ENV=e2e` (the `flask-backend` skill does this) so test data goes to `backend/test_data/` and never touches production data.
|
||||
- **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 3 frontend (see `src/common/`, `src/components/`, `src/layout/`) - Where tests are run from
|
||||
- `frontend/src/common/models.ts` — TypeScript interfaces (mirror Python models)
|
||||
- `frontend/src/common/api.ts` — API helpers, error parsing, validation
|
||||
- `frontend/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.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: flask-backend
|
||||
description: Starts the Flask backend using the local virtual environment.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Instructions
|
||||
|
||||
1. **Locate Environment:** Check for a virtual environment folder (usually `.venv` or `venv`) inside the `/backend` directory.
|
||||
2. **Activation Logic:**
|
||||
- If on **Windows**: Use `backend\.venv\Scripts\activate`
|
||||
- If on **macOS/Linux**: Use `source backend/.venv/bin/activate`
|
||||
3. **Set Environment Variables:**
|
||||
- `FLASK_APP`: `main.py`
|
||||
- `FLASK_DEBUG`: `1`
|
||||
- `DB_ENV`: `e2e`
|
||||
- `DATA_ENV`: `e2e`
|
||||
- `SECRET_KEY`: `dev-secret-key-change-in-production`
|
||||
- `REFRESH_TOKEN_EXPIRY_DAYS`: `90`
|
||||
4. **Command:** Execute the following via the `terminal` tool:
|
||||
`flask run --host=0.0.0.0 --port=5000 --no-debugger --no-reload`
|
||||
5. **Execution:** Run `python -m flask run --host=0.0.0.0 --port=5000`
|
||||
_Note: Using `python -m flask` ensures the version inside the venv is used._
|
||||
6. **Verification:** After running, check the terminal output for "Running on http://0.0.0.0:5000". If it fails, check if port 5000 is already in use.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: playwright-best-practices
|
||||
description: Enforces stable, maintainable, and high-performance Playwright test code.
|
||||
---
|
||||
# Playwright Best Practices
|
||||
When generating or refactoring tests, you must adhere to these standards:
|
||||
|
||||
1. **User-Visible Locators**: Prioritize `page.getByRole()`, `page.getByText()`, and `page.getByLabel()`. Never use fragile CSS selectors (e.g., `.btn-primary`) or XPath unless no other option exists.
|
||||
2. **Web-First Assertions**: Use the `expect(locator).to...` pattern (e.g., `toBeVisible()`, `toHaveText()`) to leverage Playwright's built-in auto-waiting and retry logic.
|
||||
3. **Test Isolation**: Every test must be independent. Use `test.beforeEach` for setup (like navigation or login) rather than relying on the state of a previous test.
|
||||
4. **Avoid Logic in Tests**: Keep tests declarative. Use Page Object Models (POM) if the logic for finding an element requires more than one line of code.
|
||||
5. **Network Reliability**: Mock third-party APIs using `page.route()` to prevent external flakiness.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: playwright-default
|
||||
description: Provides Playwright test generation and analysis for E2E testing.
|
||||
---
|
||||
|
||||
# Role: Senior QA Automation Engineer
|
||||
|
||||
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
|
||||
|
||||
# Test Implementation & Healing Workflow
|
||||
|
||||
When you receive a test plan:
|
||||
|
||||
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
|
||||
2. **Verify**: Once files are written, execute the following command in the terminal:
|
||||
`npx playwright test`
|
||||
3. **Analyze & Repair**:
|
||||
- If the playwright-healer skill proposes a patch, review it.
|
||||
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
|
||||
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
|
||||
|
||||
## Rules of Engagement
|
||||
|
||||
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
|
||||
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
|
||||
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
|
||||
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: playwright-healer
|
||||
description: Analyzes failing Playwright tests and suggests automated fixes based on UI changes.
|
||||
---
|
||||
# Playwright Self-Healing Instructions
|
||||
When a user asks to "fix" or "heal" a test:
|
||||
|
||||
1. **Analyze the Trace**: Use the Playwright MCP or CLI to open the latest trace file in `.playwright-cli/traces/`.
|
||||
2. **Compare Snapshots**: If a locator failed, take a fresh `snapshot` of the page. Identify if the element moved, changed its ARIA role, or had its text updated.
|
||||
3. **Propose the Patch**:
|
||||
- If the UI changed, suggest the updated locator.
|
||||
- If it's a timing issue, suggest adding an `expect(locator).toBeVisible()` wait.
|
||||
- If it's a data issue, check the mock definitions.
|
||||
4. **Verify**: Run the patched test once before presenting the final code to the user.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: playwright-smoke-gen
|
||||
description: Generates high-level smoke tests by exploring a running web application.
|
||||
---
|
||||
# Playwright Smoke Test Instructions
|
||||
When this skill is active, follow these rules:
|
||||
1. **Explore First**: Use the Playwright MCP `snapshot` tool to understand the page structure before writing code.
|
||||
2. **Web-First Assertions**: Always use `expect(locator).toBeVisible()` or `toBeEnabled()`.
|
||||
3. **Naming Convention**: Save tests in `tests/smoke/[feature].spec.ts`.
|
||||
4. **Setup/Teardown**: Use `test.beforeEach` for repeated actions like navigating to the base URL.
|
||||
5. **No Hardcoded Secrets**: If a login is required, use `process.env.TEST_USER` placeholders.
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: playwright-visual-reg
|
||||
description: Generates and manages visual regression snapshots for UI consistency.
|
||||
---
|
||||
# Playwright Visual Regression Standards
|
||||
When creating visual tests:
|
||||
|
||||
1. **Standard Assertion**: Use `await expect(page).toHaveScreenshot('name.png');`.
|
||||
2. **Masking**: Automatically mask dynamic content (dates, usernames, or random IDs) using the `mask` option:
|
||||
`await expect(page).toHaveScreenshot({ mask: [page.locator('.dynamic-id')] });`
|
||||
3. **Consistency**: Set `animations: 'disabled'` and `timezoneId: 'UTC'` in the generated test metadata to prevent false positives.
|
||||
4. **Update Strategy**: Instruct the user to run `npx playwright test --update-snapshots` if they intentionally changed the UI.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: vue-frontend
|
||||
description: Starts the Vue development server for the frontend application.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Instructions
|
||||
|
||||
Use this skill when the user wants to "start the frontend," "run vue," or "launch the dev server."
|
||||
|
||||
1. **Verify Directory:** Navigate to `./frontend`.
|
||||
- _Self-Correction:_ If the directory doesn't exist, search the workspace for `package.json` files and ask for clarification.
|
||||
|
||||
2. **Check Dependencies:** - Before running, check if `node_modules` exists in `./frontend`.
|
||||
- If missing, ask the user: "Should I run `npm install` first?"
|
||||
|
||||
3. **Execution:** - Run the command: `npm run dev`
|
||||
- This script is configured in `package.json` to start the Vite/Vue dev server.
|
||||
|
||||
4. **Success Criteria:** - Monitor the terminal output for a local URL (typically `http://localhost:5173` or similar).
|
||||
- Once the server is "Ready," notify the user.
|
||||
@@ -0,0 +1,34 @@
|
||||
name: "Copilot Setup Steps"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
# Customize this step as needed
|
||||
- name: Build application
|
||||
run: npx run build
|
||||
+12
-76
@@ -1,78 +1,14 @@
|
||||
# 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
|
||||
backend/test_data/
|
||||
logs/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
frontend/resources/
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
backend/test-results/
|
||||
.vscode/keybindings.json
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
frontend/cert.pem
|
||||
frontend/key.pem
|
||||
tmp/
|
||||
backend/.env*
|
||||
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
Generated
+15
@@ -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>
|
||||
Generated
+6
@@ -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
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Ask2AgentMigrationStateService">
|
||||
<option name="migrationStatus" value="COMPLETED" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
Generated
+4
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
name: architect
|
||||
description: "Defines system requirements, data contracts, and architectural blueprints."
|
||||
mode: subagent
|
||||
model: "deepseek/deepseek-v4-pro"
|
||||
thinking: "enabled"
|
||||
permission:
|
||||
edit: allow
|
||||
bash: deny
|
||||
---
|
||||
|
||||
You are the Lead Systems Architect. You are responsible for ensuring all subagents work from a shared technical specification. Understand the codebase deeply, identify and ask about underspecified details, design elegant architectures
|
||||
|
||||
## Core Responsibilities
|
||||
|
||||
- **Specification:** Create and maintain `specs/` markdown files for new features.
|
||||
- **Clarity:** Understand before acting — Read and comprehend existing code patterns first.
|
||||
- **Contracts:** Define API payload shapes (JSON schemas), Python type hints, and Vue prop interfaces before any code is written.
|
||||
- **Decision Log:** Maintain a `decisions.md` file to track _why_ certain architectural choices were made (e.g., why you chose a specific Vue state management pattern).
|
||||
|
||||
## Working discipline
|
||||
|
||||
These bias toward caution over speed — use judgment on trivial tasks.
|
||||
|
||||
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
|
||||
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
|
||||
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
|
||||
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
|
||||
|
||||
## Phase 1: Discovery
|
||||
|
||||
Goal: Understand what needs to be built.
|
||||
|
||||
1. Create a todo list covering all seven phases.
|
||||
2. If the feature is unclear, ask the user:
|
||||
- What problem are they solving?
|
||||
- What should the feature do?
|
||||
- Any constraints or requirements?
|
||||
3. Summarize your understanding and confirm with the user before proceeding.
|
||||
|
||||
## Phase 2: Codebase exploration
|
||||
|
||||
Goal: Understand relevant existing code at both high and low levels.
|
||||
|
||||
1. Dispatch 2–3 `code-explorer` sub-tasks in parallel. Each should:
|
||||
- Trace through the code comprehensively, focusing on abstractions, architecture, and control flow.
|
||||
- Target a different aspect (similar features, high-level architecture, UX, extension points).
|
||||
- Return a list of 5–10 key files to read.
|
||||
2. After they return, read every file they identified to build deep understanding.
|
||||
3. Present a comprehensive summary of findings and patterns to the user.
|
||||
|
||||
## Phase 3: Clarifying questions
|
||||
|
||||
Goal: Fill gaps and resolve ambiguities before designing.
|
||||
|
||||
**This is one of the most important phases. Do not skip.**
|
||||
|
||||
1. Review the codebase findings and the original feature request.
|
||||
2. Identify underspecified aspects: edge cases, error handling, integration points, scope boundaries, design preferences, backward compatibility, performance.
|
||||
3. Present all questions to the user as a clear, organized list.
|
||||
4. **Wait for answers** before moving to architecture.
|
||||
|
||||
If the user says "whatever you think is best", make your recommendation explicit and get confirmation.
|
||||
|
||||
## Phase 4: Architecture design
|
||||
|
||||
Goal: Design multiple implementation approaches with different trade-offs.
|
||||
|
||||
1. Dispatch 2–3 `code-architect` sub-tasks in parallel, each with a different focus:
|
||||
- **Minimal changes** — smallest diff, maximum reuse of existing code.
|
||||
- **Clean architecture** — maintainability, elegant abstractions.
|
||||
- **Pragmatic balance** — speed plus quality.
|
||||
2. Review all approaches and form an opinion on which fits best for this task. Consider scope (small fix vs. large feature), urgency, complexity, and team context.
|
||||
3. Present to the user: a brief summary of each approach, a trade-offs comparison, your recommendation with reasoning, and concrete differences in implementation.
|
||||
4. **Ask the user which approach they prefer.**
|
||||
|
||||
## Phase 5: Create Spec
|
||||
|
||||
Goal: Build the spec.
|
||||
**Do not start without explicit user approval.**
|
||||
|
||||
1. Wait for approval.
|
||||
2. Re-read all relevant files identified earlier.
|
||||
3. Spec following the chosen architecture. We are not writing code, just the specification.
|
||||
4. Strictly follow codebase conventions (naming, style, error-handling patterns).
|
||||
5. Update todos as you progress.
|
||||
|
||||
## Phase 6: Summary
|
||||
|
||||
Goal: Document what was accomplished.
|
||||
|
||||
1. Mark all todos complete.
|
||||
2. Save spec to specs/[feature-name].md
|
||||
3. Summarize:
|
||||
- What was built
|
||||
- Key decisions made
|
||||
- Files modified
|
||||
- Suggest running the @feature-pipeline skill to begin implementation
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Developer
|
||||
description: Implements core application features across Python backends and Vue frontends.
|
||||
mode: subagent
|
||||
model: moonshotai/kimi-k2.7-code
|
||||
temperature: 0.2
|
||||
maxSteps: 50
|
||||
permission:
|
||||
edit: allow
|
||||
bash: allow
|
||||
options:
|
||||
reasoningEffort: high
|
||||
thinking:
|
||||
type: enabled
|
||||
---
|
||||
|
||||
You are an expert full-stack developer focused on building clean, modular features.
|
||||
|
||||
### Technical Stack Focus
|
||||
|
||||
- **Backend:** Python. Prioritize clean architecture, explicit type hinting, and robust error/exception handling.
|
||||
- **Frontend:** Vue 3. Utilize the Composition API, structured reactive state management, and semantic components.
|
||||
|
||||
### Core Instructions
|
||||
|
||||
1. Maintain a strong separation of concerns between business logic and the transport layer.
|
||||
2. Match the established formatting, design tokens, and architectural conventions of the existing codebase.
|
||||
3. Avoid pulling in heavy external dependencies when clean native implementations are straightforward.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: reviewer
|
||||
description: "Performs read-only code reviews, security audits, and architectural soundness checks on Python/Vue code."
|
||||
mode: "subagent"
|
||||
model: deepseek/deepseek-v4-pro
|
||||
temperature: 0.2
|
||||
maxSteps: 50
|
||||
permission:
|
||||
edit: deny
|
||||
bash: allow
|
||||
options:
|
||||
reasoningEffort: max
|
||||
thinking:
|
||||
type: enabled
|
||||
---
|
||||
|
||||
You are a specialized code reviewer subagent.
|
||||
|
||||
### Strict Constraints
|
||||
|
||||
- **Read-Only Context:** Your role is to analyze, critique, and guide. Do not use `write`, `edit`, or patch tools to modify the workspace files directly.
|
||||
|
||||
### Audit Focus Areas
|
||||
|
||||
- Ensure asynchronous tasks in your Vue components balance resource utilization correctly.
|
||||
- Catch containerization bottlenecks or environment sync gaps in Docker configurations.
|
||||
- Verify strict typing boundaries between backend Python data models and frontend components.
|
||||
|
||||
Provide feedback by explicitly noting the file, logical block, and detected issue.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: tester
|
||||
description: "Holistic QA: Manages unit, integration, and writes and auto-repairs E2E test suites."
|
||||
mode: "subagent"
|
||||
model: "moonshotai/kimi-k2.7-code"
|
||||
permission:
|
||||
edit: allow
|
||||
bash:
|
||||
"pytest *": "allow"
|
||||
"npx playwright *": "allow"
|
||||
"playwright-cli *": "allow"
|
||||
"npm *": "ask"
|
||||
---
|
||||
|
||||
You are a comprehensive QA Engineer. You own the quality of the entire repository.
|
||||
|
||||
## Operational Directives
|
||||
|
||||
- **Unit Testing:** Audit the Developer's unit tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
|
||||
- **Front End Testing:** Audit the Developers frontend tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
|
||||
- **E2E Ownership:** Author and maintain all Playwright E2E suites using the playwright-cli skills. Prioritize user-facing locators (`getByRole`, `getByLabel`).
|
||||
- **Gatekeeping:** Before any task is considered "Done," run the full suite (unit + E2E). If a test fails, you own the investigation.
|
||||
- **Verification:** When a failure occurs, do not just notify. Trace the stack trace, identify the breaking commit or configuration change, and suggest a fix.
|
||||
|
||||
## Autonomous Repair Protocol
|
||||
|
||||
When executing tests (especially `playwright`):
|
||||
|
||||
1. **Analyze Failure:** If a test fails, do not just report. Parse the stack trace, identifying if the error is a `locator` issue, a `timeout` issue, or a `logic` error.
|
||||
2. **The "Application Bug" Check:** If the failure indicates that the _application code_ is incorrect (rather than the test locator), **STOP**. Ask the user if you should fix the application logic or if the test is wrong.
|
||||
3. **Looping:** You have permission to fix the test (e.g., update a locator) and re-run.
|
||||
4. **Safety Limit:** Do not run more than 3 repair attempts per test file. If it fails 3 times, stop, output the logs, and ask for help.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description: "Drafts and updates technical documentation, architecture guides, and API specs."
|
||||
mode: "subagent"
|
||||
model: "deepseek/deepseek/deepseek-v4-flash"
|
||||
permission:
|
||||
edit: allow
|
||||
bash: deny
|
||||
options:
|
||||
thinking:
|
||||
type: disabled
|
||||
---
|
||||
|
||||
You are a technical writer who communicates complex software architectures with pinpoint precision.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Clear, architectural READMEs, system setup guides, and internal team runbooks.
|
||||
- Clean API documentation maps outlining payload shapes, status codes, and endpoint routing.
|
||||
|
||||
### Style Guide
|
||||
|
||||
1. Keep prose technical, precise, and highly scannable.
|
||||
2. Avoid generic corporate or marketing phrases. Lead with the technical details immediately.
|
||||
3. Maximize the use of Markdown tables, bulleted structural lists, and code blocks for readability.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
description: Design a feature architecture by analyzing existing codebase patterns and conventions, then provide a comprehensive implementation blueprint with specific files to create or modify, component designs, data flows, and a build sequence. Use this skill when the user asks for an architecture design, an implementation plan for a non-trivial feature, or when dispatched as a sub-task during feature-dev architecture phase.
|
||||
---
|
||||
|
||||
# Code Architect
|
||||
|
||||
You are a senior software architect who delivers comprehensive, actionable architecture blueprints by deeply understanding codebases and making confident architectural decisions.
|
||||
|
||||
## Working discipline
|
||||
|
||||
These bias toward caution over speed — use judgment on trivial tasks.
|
||||
|
||||
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
|
||||
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
|
||||
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
|
||||
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
|
||||
|
||||
## Core process
|
||||
|
||||
### 1. Codebase pattern analysis
|
||||
|
||||
Extract existing patterns, conventions, and architectural decisions. Identify:
|
||||
|
||||
- The technology stack
|
||||
- Module boundaries and abstraction layers
|
||||
- Project guidelines (`CLAUDE.md` / `AGENTS.md`)
|
||||
- Similar features already implemented — how were they structured?
|
||||
- Key abstractions the codebase already provides
|
||||
|
||||
### 2. Architecture design
|
||||
|
||||
Based on patterns found, design the complete feature architecture:
|
||||
|
||||
- Make decisive choices. Pick one approach and commit to it.
|
||||
- Ensure seamless integration with existing code.
|
||||
- Design for testability, performance, and maintainability.
|
||||
|
||||
### 3. Complete implementation blueprint
|
||||
|
||||
Specify every file to create or modify, component responsibilities, integration points, and data flow. Break the implementation into clear phases.
|
||||
|
||||
## Output
|
||||
|
||||
Deliver a decisive, complete architecture blueprint. Include:
|
||||
|
||||
- **Patterns & conventions found** — list existing patterns with `file:line` references, similar features, and key abstractions to leverage.
|
||||
- **Architecture decision** — your chosen approach with rationale and trade-offs.
|
||||
- **Component design** — each component with its file path, responsibilities, dependencies, and interfaces.
|
||||
- **Implementation map** — specific files to create or modify, with detailed change descriptions.
|
||||
- **Data flow** — complete flow from entry points through transformations to outputs.
|
||||
- **Build sequence** — phased implementation steps as a checklist.
|
||||
- **Critical details** — error handling, state management, testing approach, performance, security.
|
||||
|
||||
Make confident architectural choices. Be specific and actionable: provide file paths, function names, and concrete steps. Avoid presenting multiple equally-weighted options unless the user specifically asked for trade-off analysis.
|
||||
|
||||
---
|
||||
|
||||
**User arguments:** $ARGUMENTS
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
description: Deeply analyze an existing codebase feature by tracing execution paths, mapping architecture layers, understanding patterns and abstractions, and documenting dependencies. Use this skill when you need to understand how a feature works before modifying or extending it, when dispatched as a sub-task during feature-dev exploration, or when the user asks "how does X work in this codebase".
|
||||
---
|
||||
|
||||
# Code Explorer
|
||||
|
||||
You are an expert code analyst specializing in tracing and understanding feature implementations across codebases.
|
||||
|
||||
## Core mission
|
||||
|
||||
Provide a complete understanding of how a specific feature works by tracing its implementation from entry points to data storage, through all abstraction layers.
|
||||
|
||||
## Analysis approach
|
||||
|
||||
### 1. Feature discovery
|
||||
|
||||
- Find entry points: APIs, UI components, CLI commands.
|
||||
- Locate core implementation files.
|
||||
- Map feature boundaries and configuration surface.
|
||||
|
||||
### 2. Code-flow tracing
|
||||
|
||||
- Follow call chains from entry to output.
|
||||
- Trace data transformations at each step.
|
||||
- Identify all dependencies and integrations.
|
||||
- Document state changes and side effects.
|
||||
|
||||
### 3. Architecture analysis
|
||||
|
||||
- Map abstraction layers: presentation → business logic → data.
|
||||
- Identify design patterns and architectural decisions.
|
||||
- Document interfaces between components.
|
||||
- Note cross-cutting concerns: auth, logging, caching, observability.
|
||||
|
||||
### 4. Implementation details
|
||||
|
||||
- Key algorithms and data structures.
|
||||
- Error handling and edge cases.
|
||||
- Performance considerations.
|
||||
- Technical debt or improvement areas.
|
||||
|
||||
## Output
|
||||
|
||||
Deliver a comprehensive analysis that helps developers understand the feature deeply enough to modify or extend it. Always include:
|
||||
|
||||
- **Entry points** with `file:line` references
|
||||
- **Step-by-step execution flow** with data transformations
|
||||
- **Key components** and their responsibilities
|
||||
- **Architecture insights** — patterns, layers, design decisions
|
||||
- **Dependencies** — internal and external
|
||||
- **Observations** about strengths, issues, or opportunities
|
||||
- **Essential files list** — the files a developer absolutely must read to understand this topic
|
||||
|
||||
Structure the response for maximum clarity and usefulness. Always cite specific file paths and line numbers.
|
||||
|
||||
---
|
||||
|
||||
**User arguments:** $ARGUMENTS
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: bugfix
|
||||
description: "Executes a bugfix pipeline on one or more gitea issues: Developer -> Tester -> Reviewer"
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea (title, body, images, comments, etc...). I will then forward information from the issues to the respective subagents.
|
||||
|
||||
Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible.
|
||||
|
||||
If an issue is not provided, ask the user for the issue number(s).
|
||||
|
||||
1. **Developer**: Provides a code fix for each issue.
|
||||
2. **Reviewer**: Audits the code and architectural soundness.
|
||||
3. **Tester**: Runs tests related to the bugfix and determines if new unit tests, integration tests, or end-to-end tests are needed. If so, implement. Verify by running the test suite.
|
||||
|
||||
## Execution Rules
|
||||
|
||||
- Stop and ask the user for clarification if a step fails or is ambiguous.
|
||||
- Use the `@` mention to trigger the respective subagents sequentially.
|
||||
- Pass the context from the previous stage to the next stage to ensure consistency.
|
||||
- Use multiple subagents to handle different aspects of the bugfix process if it will help.
|
||||
|
||||
## When to use me
|
||||
|
||||
Invoke me when you are ready to fix a Gitea issue or multiple issues.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: e2e-repair
|
||||
description: "Runs playwright tests, captures errors, and triggers auto-repair."
|
||||
---
|
||||
|
||||
## Logic
|
||||
|
||||
1. Execute: `npx playwright test [test_file]`
|
||||
2. If Success:
|
||||
- Report success.
|
||||
- Exit.
|
||||
3. If Failure:
|
||||
- Capture output.
|
||||
- Pass logs to @tester agent.
|
||||
- @tester analyzes error and edits file.
|
||||
- Repeat until success or max_retries reached.
|
||||
|
||||
## Safety Guardrails
|
||||
|
||||
- Make use of playwright-cli skills for test execution and repair.
|
||||
- Max Retries: 3 per file.
|
||||
- If the error persists after 3 retries, report: "Repair exhausted: Please review logs."
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: feature-pipeline
|
||||
description: "Executes the full dev-to-docs pipeline: Developer -> Tester -> Reviewer -> Writer."
|
||||
---
|
||||
|
||||
## What I do
|
||||
|
||||
I orchestrate a sequential feature implementation and verification pipeline:
|
||||
|
||||
1. **Developer**: Implements the feature based on the spec.
|
||||
2. **Reviewer**: Audits the code and architectural soundness.
|
||||
3. **Tester**: Runs full unit/E2E test suites; repairs failures if found.
|
||||
4. **Writer**: Updates README and API docs based on verified code.
|
||||
|
||||
## Execution Rules
|
||||
|
||||
- Stop and ask the user for clarification if a step fails or is ambiguous.
|
||||
- Use the `@` mention to trigger the respective subagents sequentially.
|
||||
- Pass the context from the previous stage to the next stage to ensure consistency.
|
||||
|
||||
## When to use me
|
||||
|
||||
Invoke me when you are ready to begin a new feature or when the Architect has finished a specification.
|
||||
@@ -0,0 +1,420 @@
|
||||
---
|
||||
name: playwright-cli
|
||||
description: Automate browser interactions, test web pages and work with Playwright tests.
|
||||
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
|
||||
---
|
||||
|
||||
# Browser Automation with playwright-cli
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# open new browser
|
||||
playwright-cli open
|
||||
# navigate to a page
|
||||
playwright-cli goto https://playwright.dev
|
||||
# interact with the page using refs from the snapshot
|
||||
playwright-cli click e15
|
||||
playwright-cli type "page.click"
|
||||
playwright-cli press Enter
|
||||
# take a screenshot (rarely used, as snapshot is more common)
|
||||
playwright-cli screenshot
|
||||
# close the browser
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Core
|
||||
|
||||
```bash
|
||||
playwright-cli open
|
||||
# open and navigate right away
|
||||
playwright-cli open https://example.com/
|
||||
playwright-cli goto https://playwright.dev
|
||||
playwright-cli type "search query"
|
||||
playwright-cli click e3
|
||||
playwright-cli dblclick e7
|
||||
# --submit presses Enter after filling the element
|
||||
playwright-cli fill e5 "user@example.com" --submit
|
||||
playwright-cli drag e2 e8
|
||||
# drop files or data onto an element (from outside the page)
|
||||
playwright-cli drop e4 --path=./image.png
|
||||
playwright-cli drop e4 --data="text/plain=hello world"
|
||||
playwright-cli hover e4
|
||||
playwright-cli select e9 "option-value"
|
||||
playwright-cli upload ./document.pdf
|
||||
playwright-cli check e12
|
||||
playwright-cli uncheck e12
|
||||
playwright-cli snapshot
|
||||
# search the snapshot for text or a regexp, returns matching nodes with surrounding context
|
||||
playwright-cli find "Sign in"
|
||||
playwright-cli find --regex "Sign (in|up)"
|
||||
# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive
|
||||
playwright-cli find --regex "/sign (in|up)/i"
|
||||
playwright-cli eval "document.title"
|
||||
playwright-cli eval "el => el.textContent" e5
|
||||
# get element id, class, or any attribute not visible in the snapshot
|
||||
playwright-cli eval "el => el.id" e5
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e5
|
||||
playwright-cli dialog-accept
|
||||
playwright-cli dialog-accept "confirmation text"
|
||||
playwright-cli dialog-dismiss
|
||||
playwright-cli resize 1920 1080
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
playwright-cli go-back
|
||||
playwright-cli go-forward
|
||||
playwright-cli reload
|
||||
```
|
||||
|
||||
### Keyboard
|
||||
|
||||
```bash
|
||||
playwright-cli press Enter
|
||||
playwright-cli press ArrowDown
|
||||
playwright-cli keydown Shift
|
||||
playwright-cli keyup Shift
|
||||
```
|
||||
|
||||
### Mouse
|
||||
|
||||
```bash
|
||||
playwright-cli mousemove 150 300
|
||||
playwright-cli mousedown
|
||||
playwright-cli mousedown right
|
||||
playwright-cli mouseup
|
||||
playwright-cli mouseup right
|
||||
playwright-cli mousewheel 0 100
|
||||
```
|
||||
|
||||
### Save as
|
||||
|
||||
```bash
|
||||
playwright-cli screenshot
|
||||
playwright-cli screenshot e5
|
||||
playwright-cli screenshot --filename=page.png
|
||||
playwright-cli screenshot --hires
|
||||
playwright-cli pdf --filename=page.pdf
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```bash
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-new
|
||||
playwright-cli tab-new https://example.com/page
|
||||
playwright-cli tab-close
|
||||
playwright-cli tab-close 2
|
||||
playwright-cli tab-select 0
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
```bash
|
||||
playwright-cli state-save
|
||||
playwright-cli state-save auth.json
|
||||
playwright-cli state-load auth.json
|
||||
|
||||
# Cookies
|
||||
playwright-cli cookie-list
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
playwright-cli cookie-get session_id
|
||||
playwright-cli cookie-set session_id abc123
|
||||
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
|
||||
playwright-cli cookie-delete session_id
|
||||
playwright-cli cookie-clear
|
||||
|
||||
# LocalStorage
|
||||
playwright-cli localstorage-list
|
||||
playwright-cli localstorage-get theme
|
||||
playwright-cli localstorage-set theme dark
|
||||
playwright-cli localstorage-delete theme
|
||||
playwright-cli localstorage-clear
|
||||
|
||||
# SessionStorage
|
||||
playwright-cli sessionstorage-list
|
||||
playwright-cli sessionstorage-get step
|
||||
playwright-cli sessionstorage-set step 3
|
||||
playwright-cli sessionstorage-delete step
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
### Network
|
||||
|
||||
```bash
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
|
||||
playwright-cli route-list
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
### DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli console
|
||||
playwright-cli console warning
|
||||
playwright-cli requests
|
||||
playwright-cli request 5
|
||||
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
|
||||
playwright-cli run-code --filename=script.js
|
||||
playwright-cli tracing-start
|
||||
playwright-cli tracing-stop
|
||||
playwright-cli video-start video.webm
|
||||
playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
|
||||
playwright-cli video-stop
|
||||
|
||||
# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target
|
||||
playwright-cli video-show-actions --duration=600 --position=top-right
|
||||
playwright-cli video-hide-actions
|
||||
|
||||
# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes
|
||||
playwright-cli show --annotate
|
||||
|
||||
# generate a Playwright locator for an element from its ref or selector
|
||||
playwright-cli generate-locator e5 --raw
|
||||
|
||||
# show a persistent highlight overlay for an element, optionally with a custom style
|
||||
playwright-cli highlight e5
|
||||
playwright-cli highlight e5 --style="outline: 3px dashed red"
|
||||
# hide a single element highlight, or all page highlights when no target is given
|
||||
playwright-cli highlight e5 --hide
|
||||
playwright-cli highlight --hide
|
||||
```
|
||||
|
||||
## Raw output
|
||||
|
||||
The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing.
|
||||
|
||||
```bash
|
||||
playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart'
|
||||
playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json
|
||||
playwright-cli --raw snapshot > before.yml
|
||||
playwright-cli click e5
|
||||
playwright-cli --raw snapshot > after.yml
|
||||
diff before.yml after.yml
|
||||
TOKEN=$(playwright-cli --raw cookie-get session_id)
|
||||
playwright-cli --raw localstorage-get theme
|
||||
```
|
||||
|
||||
For structured output wrapping every reply as JSON, pass --json
|
||||
```bash
|
||||
playwright-cli list --json
|
||||
```
|
||||
|
||||
## Open parameters
|
||||
```bash
|
||||
# Use specific browser when creating session
|
||||
playwright-cli open --browser=chrome
|
||||
playwright-cli open --browser=firefox
|
||||
playwright-cli open --browser=webkit
|
||||
playwright-cli open --browser=msedge
|
||||
|
||||
# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit).
|
||||
# Prefer this when a mobile layout is acceptable: mobile pages are usually
|
||||
# lighter, so snapshots are smaller and cheaper.
|
||||
playwright-cli open --mobile
|
||||
playwright-cli open --device="iPhone 15"
|
||||
|
||||
# Use persistent profile (by default profile is in-memory)
|
||||
playwright-cli open --persistent
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open --profile=/path/to/profile
|
||||
|
||||
# Connect to browser via Playwright Extension
|
||||
playwright-cli attach --extension=chrome
|
||||
|
||||
# Connect to a running Chrome or Edge by channel name
|
||||
playwright-cli attach --cdp=chrome
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Connect to a running browser via CDP endpoint
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
|
||||
# Start with config file
|
||||
playwright-cli open --config=my-config.json
|
||||
|
||||
# Close the browser
|
||||
playwright-cli close
|
||||
# Detach from an attached browser (leaves the external browser running)
|
||||
playwright-cli -s=msedge detach
|
||||
# Delete user data for the default session
|
||||
playwright-cli delete-data
|
||||
```
|
||||
|
||||
## URLs with `&` on Windows
|
||||
|
||||
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
|
||||
|
||||
```batch
|
||||
playwright-cli goto "https://example.com/?a=1^&b=2"
|
||||
```
|
||||
|
||||
```powershell
|
||||
playwright-cli --% goto "https://example.com/?a=1&b=2"
|
||||
```
|
||||
|
||||
## Snapshots
|
||||
|
||||
After each command, playwright-cli provides a snapshot of the current browser state.
|
||||
|
||||
```bash
|
||||
> playwright-cli goto https://example.com
|
||||
### Page
|
||||
- Page URL: https://example.com/
|
||||
- Page Title: Example Domain
|
||||
### Snapshot
|
||||
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
|
||||
```
|
||||
|
||||
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
|
||||
|
||||
```bash
|
||||
# default - save to a file with timestamp-based name
|
||||
playwright-cli snapshot
|
||||
|
||||
# save to file, use when snapshot is a part of the workflow result
|
||||
playwright-cli snapshot --filename=after-click.yaml
|
||||
|
||||
# snapshot an element instead of the whole page
|
||||
playwright-cli snapshot "#main"
|
||||
|
||||
# limit snapshot depth for efficiency, take a partial snapshot afterwards
|
||||
playwright-cli snapshot --depth=4
|
||||
playwright-cli snapshot e34
|
||||
|
||||
# include each element's bounding box as [box=x,y,width,height]
|
||||
playwright-cli snapshot --boxes
|
||||
|
||||
# search a large snapshot instead of capturing it all — returns matching nodes
|
||||
# with 3 lines of context around each match (like grep -C)
|
||||
playwright-cli find "Add to cart"
|
||||
playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}"
|
||||
```
|
||||
|
||||
## Targeting elements
|
||||
|
||||
By default, use refs from the snapshot to interact with page elements.
|
||||
|
||||
```bash
|
||||
# get snapshot with refs
|
||||
playwright-cli snapshot
|
||||
|
||||
# interact using a ref
|
||||
playwright-cli click e15
|
||||
```
|
||||
|
||||
You can also use css selectors or Playwright locators.
|
||||
|
||||
```bash
|
||||
# css selector
|
||||
playwright-cli click "#main > button.submit"
|
||||
|
||||
# role locator
|
||||
playwright-cli click "getByRole('button', { name: 'Submit' })"
|
||||
|
||||
# test id
|
||||
playwright-cli click "getByTestId('submit-button')"
|
||||
```
|
||||
|
||||
## Browser Sessions
|
||||
|
||||
```bash
|
||||
# create new browser session named "mysession" with persistent profile
|
||||
playwright-cli -s=mysession open example.com --persistent
|
||||
# same with manually specified profile directory (use when requested explicitly)
|
||||
playwright-cli -s=mysession open example.com --profile=/path/to/profile
|
||||
playwright-cli -s=mysession click e6
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
playwright-cli -s=mysession delete-data # delete user data for persistent session
|
||||
|
||||
playwright-cli list
|
||||
# Close all browsers
|
||||
playwright-cli close-all
|
||||
# Forcefully kill all browser processes
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
If global `playwright-cli` command is not available, try a local version via `npx playwright cli`:
|
||||
|
||||
```bash
|
||||
npx --no-install playwright --version
|
||||
```
|
||||
|
||||
When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command:
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
```
|
||||
|
||||
## Example: Form submission
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com/form
|
||||
playwright-cli snapshot
|
||||
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Multi-tab workflow
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tab-new https://example.com/other
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-select 0
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Debugging with DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli console
|
||||
playwright-cli requests
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tracing-start
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli tracing-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Interactive session
|
||||
|
||||
Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean":
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli show --annotate
|
||||
```
|
||||
|
||||
## Specific tasks
|
||||
|
||||
* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
|
||||
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
|
||||
* **Running Playwright code** [references/running-code.md](references/running-code.md)
|
||||
* **Browser session management** [references/session-management.md](references/session-management.md)
|
||||
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
|
||||
* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md)
|
||||
* **Tracing** [references/tracing.md](references/tracing.md)
|
||||
* **Video recording** [references/video-recording.md](references/video-recording.md)
|
||||
* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Inspecting Element Attributes
|
||||
|
||||
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot
|
||||
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
|
||||
|
||||
# get the element's id
|
||||
playwright-cli eval "el => el.id" e7
|
||||
|
||||
# get all CSS classes
|
||||
playwright-cli eval "el => el.className" e7
|
||||
|
||||
# get a specific attribute
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e7
|
||||
playwright-cli eval "el => el.getAttribute('aria-label')" e7
|
||||
|
||||
# get a computed style property
|
||||
playwright-cli eval "el => getComputedStyle(el).display" e7
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# Running Playwright Tests
|
||||
|
||||
To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
|
||||
# Run all tests through a custom npm script
|
||||
PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
|
||||
```
|
||||
|
||||
# Debugging Playwright Tests
|
||||
|
||||
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
|
||||
|
||||
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
|
||||
|
||||
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
|
||||
|
||||
```bash
|
||||
# Run the test
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
|
||||
# ...
|
||||
# ... debugging instructions for "tw-abcdef" session ...
|
||||
# ...
|
||||
|
||||
# Attach to the test
|
||||
playwright-cli attach tw-abcdef
|
||||
```
|
||||
|
||||
Keep the test running in the background while you explore and look for a fix.
|
||||
The test is paused at the start, so you should step over or pause at a particular location
|
||||
where the problem is most likely to be.
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
|
||||
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
|
||||
|
||||
After fixing the test, stop the background test run. Rerun to check that test passes.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Request Mocking
|
||||
|
||||
Intercept, mock, modify, and block network requests.
|
||||
|
||||
## CLI Route Commands
|
||||
|
||||
```bash
|
||||
# Mock with custom status
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
|
||||
# Mock with JSON body
|
||||
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
|
||||
|
||||
# Mock with custom headers
|
||||
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
|
||||
|
||||
# Remove headers from requests
|
||||
playwright-cli route "**/*" --remove-header=cookie,authorization
|
||||
|
||||
# List active routes
|
||||
playwright-cli route-list
|
||||
|
||||
# Remove a route or all routes
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
## URL Patterns
|
||||
|
||||
```
|
||||
**/api/users - Exact path match
|
||||
**/api/*/details - Wildcard in path
|
||||
**/*.{png,jpg,jpeg} - Match file extensions
|
||||
**/search?q=* - Match query parameters
|
||||
```
|
||||
|
||||
## Advanced Mocking with run-code
|
||||
|
||||
For conditional responses, request body inspection, response modification, or delays:
|
||||
|
||||
### Conditional Response Based on Request
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/login', route => {
|
||||
const body = route.request().postDataJSON();
|
||||
if (body.username === 'admin') {
|
||||
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
|
||||
} else {
|
||||
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
|
||||
}
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Modify Real Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/user', async route => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.isPremium = true;
|
||||
await route.fulfill({ response, json });
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Simulate Network Failures
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
|
||||
}"
|
||||
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
|
||||
```
|
||||
|
||||
### Delayed Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/slow', async route => {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
|
||||
});
|
||||
}"
|
||||
```
|
||||
@@ -0,0 +1,241 @@
|
||||
# Running Custom Playwright Code
|
||||
|
||||
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
|
||||
|
||||
## Syntax
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
// Your Playwright code here
|
||||
// Access page.context() for browser context operations
|
||||
}"
|
||||
```
|
||||
|
||||
You can also load the function from a file:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code --filename=./my-script.js
|
||||
```
|
||||
|
||||
|
||||
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
|
||||
import/export/require syntax is not supported.
|
||||
|
||||
## Geolocation
|
||||
|
||||
```bash
|
||||
# Grant geolocation permission and set location
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||
}"
|
||||
|
||||
# Set location to London
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
||||
}"
|
||||
|
||||
# Clear geolocation override
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().clearPermissions();
|
||||
}"
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
```bash
|
||||
# Grant multiple permissions
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions([
|
||||
'geolocation',
|
||||
'notifications',
|
||||
'camera',
|
||||
'microphone'
|
||||
]);
|
||||
}"
|
||||
|
||||
# Grant permissions for specific origin
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read'], {
|
||||
origin: 'https://example.com'
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Media Emulation
|
||||
|
||||
```bash
|
||||
# Emulate dark color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
}"
|
||||
|
||||
# Emulate light color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
}"
|
||||
|
||||
# Emulate reduced motion
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
}"
|
||||
|
||||
# Emulate print media
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
}"
|
||||
```
|
||||
|
||||
## Wait Strategies
|
||||
|
||||
```bash
|
||||
# Wait for network idle
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
}"
|
||||
|
||||
# Wait for specific element
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.loading').waitFor({ state: 'hidden' });
|
||||
}"
|
||||
|
||||
# Wait for function to return true
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForFunction(() => window.appReady === true);
|
||||
}"
|
||||
|
||||
# Wait with timeout
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.result').waitFor({ timeout: 10000 });
|
||||
}"
|
||||
```
|
||||
|
||||
## Frames and Iframes
|
||||
|
||||
```bash
|
||||
# Work with iframe
|
||||
playwright-cli run-code "async page => {
|
||||
const frame = page.locator('iframe#my-iframe').contentFrame();
|
||||
await frame.locator('button').click();
|
||||
}"
|
||||
|
||||
# Get all frames
|
||||
playwright-cli run-code "async page => {
|
||||
const frames = page.frames();
|
||||
return frames.map(f => f.url());
|
||||
}"
|
||||
```
|
||||
|
||||
## File Downloads
|
||||
|
||||
```bash
|
||||
# Handle file download
|
||||
playwright-cli run-code "async page => {
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'Download' }).click();
|
||||
const download = await downloadPromise;
|
||||
await download.saveAs('./downloaded-file.pdf');
|
||||
return download.suggestedFilename();
|
||||
}"
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
```bash
|
||||
# Read clipboard (requires permission)
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read']);
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
}"
|
||||
|
||||
# Write to clipboard
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
|
||||
}"
|
||||
```
|
||||
|
||||
## Page Information
|
||||
|
||||
```bash
|
||||
# Get page title
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.title();
|
||||
}"
|
||||
|
||||
# Get current URL
|
||||
playwright-cli run-code "async page => {
|
||||
return page.url();
|
||||
}"
|
||||
|
||||
# Get page content
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.content();
|
||||
}"
|
||||
|
||||
# Get viewport size
|
||||
playwright-cli run-code "async page => {
|
||||
return page.viewportSize();
|
||||
}"
|
||||
```
|
||||
|
||||
## JavaScript Execution
|
||||
|
||||
```bash
|
||||
# Execute JavaScript and return result
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(() => {
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
cookiesEnabled: navigator.cookieEnabled
|
||||
};
|
||||
});
|
||||
}"
|
||||
|
||||
# Pass arguments to evaluate
|
||||
playwright-cli run-code "async page => {
|
||||
const multiplier = 5;
|
||||
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
|
||||
}"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```bash
|
||||
# Try-catch in run-code
|
||||
playwright-cli run-code "async page => {
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
|
||||
return 'clicked';
|
||||
} catch (e) {
|
||||
return 'element not found';
|
||||
}
|
||||
}"
|
||||
```
|
||||
|
||||
## Complex Workflows
|
||||
|
||||
```bash
|
||||
# Login and save state
|
||||
playwright-cli run-code "async page => {
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await page.waitForURL('**/dashboard');
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
return 'Login successful';
|
||||
}"
|
||||
|
||||
# Scrape data from multiple pages
|
||||
playwright-cli run-code "async page => {
|
||||
const results = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.goto(\`https://example.com/page/\${i}\`);
|
||||
const items = await page.locator('.item').allTextContents();
|
||||
results.push(...items);
|
||||
}
|
||||
return results;
|
||||
}"
|
||||
```
|
||||
@@ -0,0 +1,225 @@
|
||||
# Browser Session Management
|
||||
|
||||
Run multiple isolated browser sessions concurrently with state persistence.
|
||||
|
||||
## Named Browser Sessions
|
||||
|
||||
Use `-s` flag to isolate browser contexts:
|
||||
|
||||
```bash
|
||||
# Browser 1: Authentication flow
|
||||
playwright-cli -s=auth open https://app.example.com/login
|
||||
|
||||
# Browser 2: Public browsing (separate cookies, storage)
|
||||
playwright-cli -s=public open https://example.com
|
||||
|
||||
# Commands are isolated by browser session
|
||||
playwright-cli -s=auth fill e1 "user@example.com"
|
||||
playwright-cli -s=public snapshot
|
||||
```
|
||||
|
||||
## Browser Session Isolation Properties
|
||||
|
||||
Each browser session has independent:
|
||||
- Cookies
|
||||
- LocalStorage / SessionStorage
|
||||
- IndexedDB
|
||||
- Cache
|
||||
- Browsing history
|
||||
- Open tabs
|
||||
|
||||
## Browser Session Commands
|
||||
|
||||
```bash
|
||||
# List all browser sessions
|
||||
playwright-cli list
|
||||
|
||||
# Stop a browser session (close the browser)
|
||||
playwright-cli close # stop the default browser
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
|
||||
# Stop all browser sessions
|
||||
playwright-cli close-all
|
||||
|
||||
# Forcefully kill all daemon processes (for stale/zombie processes)
|
||||
playwright-cli kill-all
|
||||
|
||||
# Delete browser session user data (profile directory)
|
||||
playwright-cli delete-data # delete default browser data
|
||||
playwright-cli -s=mysession delete-data # delete named browser data
|
||||
```
|
||||
|
||||
## Environment Variable
|
||||
|
||||
Set a default browser session name via environment variable:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_CLI_SESSION="mysession"
|
||||
playwright-cli open example.com # Uses "mysession" automatically
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Concurrent Scraping
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Scrape multiple sites concurrently
|
||||
|
||||
# Start all browsers
|
||||
playwright-cli -s=site1 open https://site1.com &
|
||||
playwright-cli -s=site2 open https://site2.com &
|
||||
playwright-cli -s=site3 open https://site3.com &
|
||||
wait
|
||||
|
||||
# Take snapshots from each
|
||||
playwright-cli -s=site1 snapshot
|
||||
playwright-cli -s=site2 snapshot
|
||||
playwright-cli -s=site3 snapshot
|
||||
|
||||
# Cleanup
|
||||
playwright-cli close-all
|
||||
```
|
||||
|
||||
### A/B Testing Sessions
|
||||
|
||||
```bash
|
||||
# Test different user experiences
|
||||
playwright-cli -s=variant-a open "https://app.com?variant=a"
|
||||
playwright-cli -s=variant-b open "https://app.com?variant=b"
|
||||
|
||||
# Compare
|
||||
playwright-cli -s=variant-a screenshot
|
||||
playwright-cli -s=variant-b screenshot
|
||||
```
|
||||
|
||||
### Persistent Profile
|
||||
|
||||
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
|
||||
|
||||
```bash
|
||||
# Use persistent profile (auto-generated location)
|
||||
playwright-cli open https://example.com --persistent
|
||||
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open https://example.com --profile=/path/to/profile
|
||||
```
|
||||
|
||||
## Attaching to a Running Browser
|
||||
|
||||
Use `attach` to connect to a browser that is already running, instead of launching a new one.
|
||||
|
||||
### Attach by channel name
|
||||
|
||||
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
|
||||
|
||||
```bash
|
||||
# Attach to Chrome
|
||||
playwright-cli attach --cdp=chrome
|
||||
|
||||
# Attach to Chrome Canary
|
||||
playwright-cli attach --cdp=chrome-canary
|
||||
|
||||
# Attach to Microsoft Edge
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Attach to Edge Dev
|
||||
playwright-cli attach --cdp=msedge-dev
|
||||
```
|
||||
|
||||
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
|
||||
|
||||
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
|
||||
|
||||
### Attach via CDP endpoint
|
||||
|
||||
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
```
|
||||
|
||||
### Attach via browser extension
|
||||
|
||||
Connect to a browser with the Playwright extension installed:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --extension
|
||||
```
|
||||
|
||||
### Detach
|
||||
|
||||
Tear down an attached session without affecting the external browser:
|
||||
|
||||
```bash
|
||||
# Detach the default attached session
|
||||
playwright-cli detach
|
||||
|
||||
# Detach a specific attached session
|
||||
playwright-cli -s=msedge detach
|
||||
```
|
||||
|
||||
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
|
||||
|
||||
## Default Browser Session
|
||||
|
||||
When `-s` is omitted, commands use the default browser session:
|
||||
|
||||
```bash
|
||||
# These use the same default browser session
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli close # Stops default browser
|
||||
```
|
||||
|
||||
## Browser Session Configuration
|
||||
|
||||
Configure a browser session with specific settings when opening:
|
||||
|
||||
```bash
|
||||
# Open with config file
|
||||
playwright-cli open https://example.com --config=.playwright/my-cli.json
|
||||
|
||||
# Open with specific browser
|
||||
playwright-cli open https://example.com --browser=firefox
|
||||
|
||||
# Open in headed mode
|
||||
playwright-cli open https://example.com --headed
|
||||
|
||||
# Open with persistent profile
|
||||
playwright-cli open https://example.com --persistent
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Name Browser Sessions Semantically
|
||||
|
||||
```bash
|
||||
# GOOD: Clear purpose
|
||||
playwright-cli -s=github-auth open https://github.com
|
||||
playwright-cli -s=docs-scrape open https://docs.example.com
|
||||
|
||||
# AVOID: Generic names
|
||||
playwright-cli -s=s1 open https://github.com
|
||||
```
|
||||
|
||||
### 2. Always Clean Up
|
||||
|
||||
```bash
|
||||
# Stop browsers when done
|
||||
playwright-cli -s=auth close
|
||||
playwright-cli -s=scrape close
|
||||
|
||||
# Or stop all at once
|
||||
playwright-cli close-all
|
||||
|
||||
# If browsers become unresponsive or zombie processes remain
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
### 3. Delete Stale Browser Data
|
||||
|
||||
```bash
|
||||
# Remove old browser data to free disk space
|
||||
playwright-cli -s=oldsession delete-data
|
||||
```
|
||||
@@ -0,0 +1,275 @@
|
||||
# Storage Management
|
||||
|
||||
Manage cookies, localStorage, sessionStorage, and browser storage state.
|
||||
|
||||
## Storage State
|
||||
|
||||
Save and restore complete browser state including cookies and storage.
|
||||
|
||||
### Save Storage State
|
||||
|
||||
```bash
|
||||
# Save to auto-generated filename (storage-state-{timestamp}.json)
|
||||
playwright-cli state-save
|
||||
|
||||
# Save to specific filename
|
||||
playwright-cli state-save my-auth-state.json
|
||||
```
|
||||
|
||||
### Restore Storage State
|
||||
|
||||
```bash
|
||||
# Load storage state from file
|
||||
playwright-cli state-load my-auth-state.json
|
||||
|
||||
# Reload page to apply cookies
|
||||
playwright-cli open https://example.com
|
||||
```
|
||||
|
||||
### Storage State File Format
|
||||
|
||||
The saved file contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "session_id",
|
||||
"value": "abc123",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
"expires": 1893456000,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://example.com",
|
||||
"localStorage": [
|
||||
{ "name": "theme", "value": "dark" },
|
||||
{ "name": "user_id", "value": "12345" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cookies
|
||||
|
||||
### List All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list
|
||||
```
|
||||
|
||||
### Filter Cookies by Domain
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
```
|
||||
|
||||
### Filter Cookies by Path
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --path=/api
|
||||
```
|
||||
|
||||
### Get Specific Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-get session_id
|
||||
```
|
||||
|
||||
### Set a Cookie
|
||||
|
||||
```bash
|
||||
# Basic cookie
|
||||
playwright-cli cookie-set session abc123
|
||||
|
||||
# Cookie with options
|
||||
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
|
||||
|
||||
# Cookie with expiration (Unix timestamp)
|
||||
playwright-cli cookie-set remember_me token123 --expires=1893456000
|
||||
```
|
||||
|
||||
### Delete a Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-delete session_id
|
||||
```
|
||||
|
||||
### Clear All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Cookies or Custom Options
|
||||
|
||||
For complex scenarios like adding multiple cookies at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().addCookies([
|
||||
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
|
||||
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
|
||||
]);
|
||||
}"
|
||||
```
|
||||
|
||||
## Local Storage
|
||||
|
||||
### List All localStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-get token
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set theme dark
|
||||
```
|
||||
|
||||
### Set JSON Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-delete token
|
||||
```
|
||||
|
||||
### Clear All localStorage
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Operations
|
||||
|
||||
For complex scenarios like setting multiple values at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('token', 'jwt_abc123');
|
||||
localStorage.setItem('user_id', '12345');
|
||||
localStorage.setItem('expires_at', Date.now() + 3600000);
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Session Storage
|
||||
|
||||
### List All sessionStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-get form_data
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-set step 3
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-delete step
|
||||
```
|
||||
|
||||
### Clear sessionStorage
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
## IndexedDB
|
||||
|
||||
### List Databases
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(async () => {
|
||||
const databases = await indexedDB.databases();
|
||||
return databases;
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Delete Database
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
indexedDB.deleteDatabase('myDatabase');
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication State Reuse
|
||||
|
||||
```bash
|
||||
# Step 1: Login and save state
|
||||
playwright-cli open https://app.example.com/login
|
||||
playwright-cli snapshot
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
|
||||
# Save the authenticated state
|
||||
playwright-cli state-save auth.json
|
||||
|
||||
# Step 2: Later, restore state and skip login
|
||||
playwright-cli state-load auth.json
|
||||
playwright-cli open https://app.example.com/dashboard
|
||||
# Already logged in!
|
||||
```
|
||||
|
||||
### Save and Restore Roundtrip
|
||||
|
||||
```bash
|
||||
# Set up authentication state
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
|
||||
|
||||
# Save state to file
|
||||
playwright-cli state-save my-session.json
|
||||
|
||||
# ... later, in a new session ...
|
||||
|
||||
# Restore state
|
||||
playwright-cli state-load my-session.json
|
||||
playwright-cli open https://example.com
|
||||
# Cookies and localStorage are restored!
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit storage state files containing auth tokens
|
||||
- Add `*.auth-state.json` to `.gitignore`
|
||||
- Delete state files after automation completes
|
||||
- Use environment variables for sensitive data
|
||||
- By default, sessions run in-memory mode which is safer for sensitive operations
|
||||
@@ -0,0 +1,433 @@
|
||||
# Test generation (plan → generate → heal)
|
||||
|
||||
End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently:
|
||||
|
||||
- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions.
|
||||
- **Plan** — explore the app, produce a spec file describing what to test.
|
||||
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
|
||||
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
|
||||
|
||||
Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics.
|
||||
|
||||
---
|
||||
|
||||
## 0. How generation works
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files.
|
||||
|
||||
```bash
|
||||
# Start a session
|
||||
playwright-cli open https://example.com/login
|
||||
|
||||
# Take a snapshot to see elements
|
||||
playwright-cli snapshot
|
||||
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
|
||||
|
||||
# Fill form fields - generates code automatically
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
|
||||
playwright-cli fill e2 "password123"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
|
||||
playwright-cli click e3
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
```
|
||||
|
||||
### Building a test file
|
||||
|
||||
Collect the generated code into a Playwright test:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('login flow', async ({ page }) => {
|
||||
// Generated code from playwright-cli session:
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Add assertions
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
```
|
||||
|
||||
### Use semantic locators
|
||||
|
||||
The generated code uses role-based locators when possible, which are more resilient:
|
||||
|
||||
```typescript
|
||||
// Generated (good - semantic)
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Avoid (fragile - CSS selectors)
|
||||
await page.locator('#submit-btn').click();
|
||||
```
|
||||
|
||||
### Explore before recording
|
||||
|
||||
Take snapshots to understand the page structure before recording actions:
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
# Review the element structure
|
||||
playwright-cli click e5
|
||||
```
|
||||
|
||||
### Add assertions manually
|
||||
|
||||
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
|
||||
|
||||
- `toBeVisible()` — element is rendered and visible
|
||||
- `toHaveText(text)` — element text content matches
|
||||
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
|
||||
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
|
||||
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
|
||||
|
||||
Use `playwright-cli generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
|
||||
|
||||
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
|
||||
|
||||
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
|
||||
|
||||
```bash
|
||||
# Get a stable locator for an element ref to use in the assertion
|
||||
playwright-cli --raw generate-locator e5
|
||||
# getByRole('button', { name: 'Submit' })
|
||||
|
||||
# Capture expected text content for toHaveText
|
||||
playwright-cli --raw eval "el => el.textContent" e5
|
||||
|
||||
# Capture expected input value for toHaveValue/toBeEmpty
|
||||
playwright-cli --raw eval "el => el.value" e5
|
||||
|
||||
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
|
||||
# (whole page, or use a ref to scope to a region)
|
||||
playwright-cli --raw snapshot
|
||||
playwright-cli --raw snapshot e5
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Generated action
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Manual assertions using the outputs above:
|
||||
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
|
||||
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
|
||||
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com');
|
||||
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
|
||||
|
||||
// toMatchAriaSnapshot on the whole page, finds a matching region
|
||||
await expect(page).toMatchAriaSnapshot(`
|
||||
- heading "Welcome, user"
|
||||
- link /\\d+ new messages?/
|
||||
- button "Sign out"
|
||||
`);
|
||||
|
||||
// toMatchAriaSnapshot scoped to a region
|
||||
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
|
||||
- link "Home"
|
||||
- link /\\d+ new messages?/
|
||||
- link "Profile"
|
||||
`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Planning
|
||||
|
||||
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
|
||||
|
||||
### 1.1 Prerequisite: workspace
|
||||
|
||||
Check the workspace has Playwright installed before anything else:
|
||||
|
||||
```bash
|
||||
# Either of these confirms a workspace:
|
||||
test -f playwright.config.ts || test -f playwright.config.js
|
||||
npx --no-install playwright --version
|
||||
```
|
||||
|
||||
If there is no Playwright install, bootstrap one and let the user pick the defaults:
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
### 1.2 Prerequisite: seed test
|
||||
|
||||
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
|
||||
|
||||
Minimum viable seed:
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
await page.goto('https://example.com/');
|
||||
});
|
||||
```
|
||||
|
||||
Preferred — push navigation into a fixture so scenario tests reuse it:
|
||||
|
||||
```ts
|
||||
// tests/fixtures.ts
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
export const test = baseTest.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.goto('https://example.com/');
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from './fixtures';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
// Fixture already navigates. This empty body tells agents where to start.
|
||||
});
|
||||
```
|
||||
|
||||
If no seed exists, create one that at least navigates to the app.
|
||||
|
||||
### 1.3 Explore the app
|
||||
|
||||
Launch the app via the seed in the background and attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli
|
||||
# wait for "Debugging Instructions" and the session name tw-XXXX
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
Resume so the seed runs, then probe the app:
|
||||
|
||||
```bash
|
||||
playwright-cli resume # resume so that seed test runs fully
|
||||
playwright-cli snapshot # inventory of interactive elements
|
||||
playwright-cli click e5 # follow a flow
|
||||
playwright-cli eval "location.href" # read URL / state
|
||||
playwright-cli show --annotate # ask the user to point at something
|
||||
```
|
||||
|
||||
Map out:
|
||||
|
||||
- Interactive surfaces (forms, buttons, lists, filters, modals).
|
||||
- Primary user journeys end-to-end.
|
||||
- Edge cases: empty states, validation errors, very long input, boundary values.
|
||||
- Persistence: reload, local/session storage, URL fragments.
|
||||
- Navigation: which controls change the URL, back/forward behaviour.
|
||||
|
||||
**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
**Important**: Stop the background test when done exploring.
|
||||
|
||||
### 1.4 Write the spec file
|
||||
|
||||
Save under `specs/<feature>.plan.md`. Use this structure:
|
||||
|
||||
```markdown
|
||||
# <Feature> Test Plan
|
||||
|
||||
## Application Overview
|
||||
|
||||
<One paragraph describing what the feature does and why it matters.>
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. <Group Name>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
|
||||
#### 1.1. <kebab-case-scenario-name>
|
||||
|
||||
**File:** `tests/<group>/<kebab-case-scenario-name>.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. <Concrete user step>
|
||||
- expect: <observable outcome>
|
||||
- expect: <another observable outcome>
|
||||
2. <Next step>
|
||||
- expect: <outcome>
|
||||
|
||||
#### 1.2. <next-scenario>
|
||||
...
|
||||
|
||||
### 2. <Next Group>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
...
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
|
||||
- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`).
|
||||
- Cover happy path, edge cases, validation, negative flows, persistence.
|
||||
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
|
||||
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generate
|
||||
|
||||
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
|
||||
|
||||
### 2.1 Inputs
|
||||
|
||||
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
|
||||
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
|
||||
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
|
||||
|
||||
### 2.2 Generate one scenario
|
||||
|
||||
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test <seed-file> --debug=cli # background
|
||||
playwright-cli attach tw-XXXX
|
||||
# resume
|
||||
```
|
||||
|
||||
**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
|
||||
Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
|
||||
|
||||
Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)):
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # find refs
|
||||
playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
|
||||
playwright-cli press Enter
|
||||
playwright-cli click e7
|
||||
```
|
||||
|
||||
For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details.
|
||||
|
||||
Collect the generated code and write the test file at the path given in the spec:
|
||||
|
||||
```ts
|
||||
// spec: specs/basic-operations.plan.md
|
||||
// seed: tests/seed.spec.ts
|
||||
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
|
||||
|
||||
test.describe('Signing in and out', () => {
|
||||
test('should sign in', async ({ page }) => {
|
||||
// 1. Navigate to the application
|
||||
// (handled by the seed fixture)
|
||||
|
||||
// 2. Type 'John Doe' into the username field
|
||||
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
|
||||
|
||||
// 3. Type password
|
||||
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
|
||||
|
||||
// 4. Press Enter to submit
|
||||
await page.getByRole('textbox', { name: 'password' }).press('Enter');
|
||||
|
||||
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
|
||||
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
|
||||
- Use the describe group name verbatim from the spec (no `1.` ordinal).
|
||||
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
|
||||
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
|
||||
|
||||
### 2.3 Generate multiple scenarios
|
||||
|
||||
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
|
||||
|
||||
### 2.4 Run generated tests
|
||||
|
||||
After generation, run the new tests once:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts
|
||||
```
|
||||
|
||||
Any failure goes to Section 3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Heal
|
||||
|
||||
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
|
||||
|
||||
### 3.1 Find failing tests
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
```
|
||||
|
||||
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
|
||||
|
||||
### 3.2 Debug one failure
|
||||
|
||||
Run the single failing test in debug mode in the background, then attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts:<line> --debug=cli
|
||||
# wait for "Debugging Instructions" and the tw-XXXX session name
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # did the element change / move / rename?
|
||||
playwright-cli console # app-side errors?
|
||||
playwright-cli requests # failed request? wrong payload?
|
||||
playwright-cli show --annotate # ask the user to point somewhere
|
||||
```
|
||||
|
||||
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
|
||||
|
||||
Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test.
|
||||
|
||||
### 3.3 Apply the fix
|
||||
|
||||
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
|
||||
|
||||
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
|
||||
|
||||
### 3.4 Reconcile with the spec
|
||||
|
||||
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
|
||||
|
||||
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
|
||||
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
|
||||
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
|
||||
- the scenario id (e.g. `2.3`),
|
||||
- the spec lines that no longer match,
|
||||
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
|
||||
|
||||
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
|
||||
|
||||
### 3.5 Iteration and giving up
|
||||
|
||||
- Fix failures one at a time; rerun after each.
|
||||
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
| For... | See |
|
||||
|---|---|
|
||||
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
|
||||
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
|
||||
| Managing the CLI browser session | [session-management.md](session-management.md) |
|
||||
@@ -0,0 +1,139 @@
|
||||
# Tracing
|
||||
|
||||
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Start trace recording
|
||||
playwright-cli tracing-start
|
||||
|
||||
# Perform actions
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e1
|
||||
playwright-cli fill e2 "test"
|
||||
|
||||
# Stop trace recording
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
## Trace Output Files
|
||||
|
||||
When you start tracing, Playwright creates a `traces/` directory with several files:
|
||||
|
||||
### `trace-{timestamp}.trace`
|
||||
|
||||
**Action log** - The main trace file containing:
|
||||
- Every action performed (clicks, fills, navigations)
|
||||
- DOM snapshots before and after each action
|
||||
- Screenshots at each step
|
||||
- Timing information
|
||||
- Console messages
|
||||
- Source locations
|
||||
|
||||
### `trace-{timestamp}.network`
|
||||
|
||||
**Network log** - Complete network activity:
|
||||
- All HTTP requests and responses
|
||||
- Request headers and bodies
|
||||
- Response headers and bodies
|
||||
- Timing (DNS, connect, TLS, TTFB, download)
|
||||
- Resource sizes
|
||||
- Failed requests and errors
|
||||
|
||||
### `resources/`
|
||||
|
||||
**Resources directory** - Cached resources:
|
||||
- Images, fonts, stylesheets, scripts
|
||||
- Response bodies for replay
|
||||
- Assets needed to reconstruct page state
|
||||
|
||||
## What Traces Capture
|
||||
|
||||
| Category | Details |
|
||||
|----------|---------|
|
||||
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
|
||||
| **DOM** | Full DOM snapshot before/after each action |
|
||||
| **Screenshots** | Visual state at each step |
|
||||
| **Network** | All requests, responses, headers, bodies, timing |
|
||||
| **Console** | All console.log, warn, error messages |
|
||||
| **Timing** | Precise timing for each operation |
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Debugging Failed Actions
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://app.example.com
|
||||
|
||||
# This click fails - why?
|
||||
playwright-cli click e5
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Open trace to see DOM state when click was attempted
|
||||
```
|
||||
|
||||
### Analyzing Performance
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://slow-site.com
|
||||
playwright-cli tracing-stop
|
||||
|
||||
# View network waterfall to identify slow resources
|
||||
```
|
||||
|
||||
### Capturing Evidence
|
||||
|
||||
```bash
|
||||
# Record a complete user flow for documentation
|
||||
playwright-cli tracing-start
|
||||
|
||||
playwright-cli open https://app.example.com/checkout
|
||||
playwright-cli fill e1 "4111111111111111"
|
||||
playwright-cli fill e2 "12/25"
|
||||
playwright-cli fill e3 "123"
|
||||
playwright-cli click e4
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Trace shows exact sequence of events
|
||||
```
|
||||
|
||||
## Trace vs Video vs Screenshot
|
||||
|
||||
| Feature | Trace | Video | Screenshot |
|
||||
|---------|-------|-------|------------|
|
||||
| **Format** | .trace file | .webm video | .png/.jpeg image |
|
||||
| **DOM inspection** | Yes | No | No |
|
||||
| **Network details** | Yes | No | No |
|
||||
| **Step-by-step replay** | Yes | Continuous | Single frame |
|
||||
| **File size** | Medium | Large | Small |
|
||||
| **Best for** | Debugging | Demos | Quick capture |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Start Tracing Before the Problem
|
||||
|
||||
```bash
|
||||
# Trace the entire flow, not just the failing step
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://example.com
|
||||
# ... all steps leading to the issue ...
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
### 2. Clean Up Old Traces
|
||||
|
||||
Traces can consume significant disk space:
|
||||
|
||||
```bash
|
||||
# Remove traces older than 7 days
|
||||
find .playwright-cli/traces -mtime +7 -delete
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Traces add overhead to automation
|
||||
- Large traces can consume significant disk space
|
||||
- Some dynamic content may not replay perfectly
|
||||
@@ -0,0 +1,143 @@
|
||||
# Video Recording
|
||||
|
||||
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
|
||||
|
||||
## Basic Recording
|
||||
|
||||
```bash
|
||||
# Open browser first
|
||||
playwright-cli open
|
||||
|
||||
# Start recording
|
||||
playwright-cli video-start demo.webm
|
||||
|
||||
# Add a chapter marker for section transitions
|
||||
playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
|
||||
|
||||
# Navigate and perform actions
|
||||
playwright-cli goto https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli click e1
|
||||
|
||||
# Add another chapter
|
||||
playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000
|
||||
playwright-cli fill e2 "test input"
|
||||
|
||||
# Stop and save
|
||||
playwright-cli video-stop
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Descriptive Filenames
|
||||
|
||||
```bash
|
||||
# Include context in filename
|
||||
playwright-cli video-start recordings/login-flow-2024-01-15.webm
|
||||
playwright-cli video-start recordings/checkout-test-run-42.webm
|
||||
```
|
||||
|
||||
### 2. Record entire hero scripts.
|
||||
|
||||
When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
|
||||
It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
|
||||
|
||||
1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
|
||||
2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
|
||||
3) Use playwright-cli run-code --filename your-script.js
|
||||
|
||||
**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
|
||||
|
||||
```js
|
||||
async page => {
|
||||
await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
|
||||
await page.goto('https://demo.playwright.dev/todomvc');
|
||||
|
||||
// Show a chapter card — blurs the page and shows a dialog.
|
||||
// Blocks until duration expires, then auto-removes.
|
||||
// Use this for simple use cases, but always feel free to hand-craft your own beautiful
|
||||
// overlay via await page.screencast.showOverlay().
|
||||
await page.screencast.showChapter('Adding Todo Items', {
|
||||
description: 'We will add several items to the todo list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Perform action
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Show next chapter
|
||||
await page.screencast.showChapter('Verifying Results', {
|
||||
description: 'Checking the item appeared in the list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Add a sticky annotation that stays while you perform actions.
|
||||
// Overlays are pointer-events: none, so they won't block clicks.
|
||||
const annotation = await page.screencast.showOverlay(`
|
||||
<div style="position: absolute; top: 8px; right: 8px;
|
||||
padding: 6px 12px; background: rgba(0,0,0,0.7);
|
||||
border-radius: 8px; font-size: 13px; color: white;">
|
||||
✓ Item added successfully
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Perform more actions while the annotation is visible
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Remove the annotation when done
|
||||
await annotation.dispose();
|
||||
|
||||
// You can also highlight relevant locators and provide contextual annotations.
|
||||
const bounds = await page.getByText('Walk the dog').boundingBox();
|
||||
await page.screencast.showOverlay(`
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y}px;
|
||||
left: ${bounds.x}px;
|
||||
width: ${bounds.width}px;
|
||||
height: ${bounds.height}px;
|
||||
border: 1px solid red;">
|
||||
</div>
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y + bounds.height + 5}px;
|
||||
left: ${bounds.x + bounds.width / 2}px;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px;
|
||||
background: #808080;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: white;">Check it out, it is right above this text
|
||||
</div>
|
||||
`, { duration: 2000 });
|
||||
|
||||
await page.screencast.stop();
|
||||
}
|
||||
```
|
||||
|
||||
Embrace creativity, overlays are powerful.
|
||||
|
||||
### Overlay API Summary
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
|
||||
| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
|
||||
| `disposable.dispose()` | Remove a sticky overlay added without duration |
|
||||
| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
|
||||
|
||||
## Tracing vs Video
|
||||
|
||||
| Feature | Video | Tracing |
|
||||
|---------|-------|---------|
|
||||
| Output | WebM file | Trace file (viewable in Trace Viewer) |
|
||||
| Shows | Visual recording | DOM snapshots, network, console, actions |
|
||||
| Use case | Demos, documentation | Debugging, analysis |
|
||||
| Size | Larger | Smaller |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Recording adds slight overhead to automation
|
||||
- Large recordings can consume significant disk space
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Python: Flask",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"module": "flask",
|
||||
"python": "${command:python.interpreterPath}",
|
||||
"envFile": "${workspaceFolder}/backend/.env",
|
||||
"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",
|
||||
"console": "integratedTerminal",
|
||||
"serverReadyAction": {
|
||||
"pattern": "Local:.*https://localhost:([0-9]+)",
|
||||
"uriFormat": "https://localhost:%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Chrome: Launch (Vue App)",
|
||||
"type": "pwa-chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:5173",
|
||||
"webRoot": "${workspaceFolder}/frontend"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"console": "integratedTerminal",
|
||||
"osx": {
|
||||
"env": {
|
||||
"PATH": "/opt/homebrew/bin:${env:PATH}"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Full Stack (Backend + Frontend)",
|
||||
"stopAll": true,
|
||||
"configurations": [
|
||||
"Python: Flask",
|
||||
"Vue: Dev Server"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+77
@@ -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",
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Full Stack (Backend + Frontend)",
|
||||
"configurations": [
|
||||
"Python: Flask",
|
||||
"Vue: Dev Server",
|
||||
"Chrome: Attach to Vue App"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"servers": {
|
||||
"playwright-test": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"playwright",
|
||||
"run-test-mcp-server",
|
||||
"--config=playwright.config.ts"
|
||||
],
|
||||
"cwd": "frontend"
|
||||
},
|
||||
"gitea": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITEA_ACCESS_TOKEN=${env:GITEA_ACCESS_TOKEN}",
|
||||
"-e",
|
||||
"GITEA_HOST=https://git.ryankegel.com",
|
||||
"docker.gitea.com/gitea-mcp-server"
|
||||
],
|
||||
"type": "stdio"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"python.venvPath": "${workspaceFolder}/backend/.venv",
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.terminal.shellIntegration.enabled": true,
|
||||
"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
|
||||
},
|
||||
"terminal.integrated.automationProfile.windows": {
|
||||
"path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||||
"args": []
|
||||
},
|
||||
"python-envs.defaultEnvManager": "ms-python.python:venv",
|
||||
"python-envs.pythonProjects": [],
|
||||
"editor.gotoLocation.multipleDeclarations": "gotoAndPeek",
|
||||
"editor.gotoLocation.multipleDefinitions": "gotoAndPeek",
|
||||
"editor.gotoLocation.multipleImplementations": "gotoAndPeek",
|
||||
"editor.gotoLocation.multipleReferences": "gotoAndPeek",
|
||||
"editor.gotoLocation.multipleTypeDefinitions": "gotoAndPeek",
|
||||
//"editor.fontFamily": "Consolas, 'Courier New', monospace",
|
||||
"editor.fontFamily": "JetBrains Mono",
|
||||
"editor.fontSize": 13,
|
||||
"editor.fontLigatures": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"backend"
|
||||
],
|
||||
"python.testing.unittestEnabled": false,
|
||||
"python.testing.pytestEnabled": true,
|
||||
}
|
||||
Vendored
+146
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "PW: Task Modification Tests",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Task Modification Tests (PS)",
|
||||
"type": "shell",
|
||||
"command": "powershell -Command \"cd '$env:APPDATA/../../../d/Python Utilities/Reward/frontend'; npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification\"",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Task Modification Tests (cmd)",
|
||||
"type": "shell",
|
||||
"command": "cmd /c \"cd /d \\\"D:\\Python Utilities\\Reward\\frontend\\\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification 2>&1\"",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Tests",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Tests 2",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Tests Final",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Full Test Suite",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Full Suite",
|
||||
"type": "shell",
|
||||
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
|
||||
"isBackground": false,
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "PW: Full Suite (Process)",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test --reporter=line 2>&1"
|
||||
],
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "PW: Penalty Default Test (Process)",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/tasks/penalty-default.spec.ts --project=chromium-default-tasks --reporter=line 2>&1"
|
||||
],
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "PW: User Profile Editing Test (Process)",
|
||||
"type": "process",
|
||||
"command": "powershell.exe",
|
||||
"args": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/user-profile/profile-editing.spec.ts --project=chromium-user-profile --reporter=line 2>&1"
|
||||
],
|
||||
"group": "test",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "shared"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# AGENTS.md
|
||||
|
||||
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates over SSE.
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend (run from `backend/`)
|
||||
- Activate venv: `source .venv/bin/activate`
|
||||
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `main.py`)
|
||||
- Required env vars: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing
|
||||
- Optional persistence switch: `USE_MONGODB` (`true` | `false`). Defaults to `true`; set `MONGO_URI` (and optionally `MONGO_DB_NAME`). Set to `false` to use TinyDB instead.
|
||||
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` dir (see `config/paths.py`). For MongoDB these also select the default database name (`chore_db`, `chore_db_test`, `chore_db_e2e`) unless `MONGO_DB_NAME` is set.
|
||||
- Tests: `pytest tests/` — `conftest.py` forces `DB_ENV=test`, `USE_MONGODB=true`, `MONGO_URI=mongomock`, and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`
|
||||
- Python imports assume `backend/` is on `sys.path` (set by `conftest.py` / `flask run` cwd). Run pytest from `backend/`.
|
||||
- Create admin user: `python scripts/create_admin.py` (admin role cannot be set via signup)
|
||||
|
||||
### Frontend (run from `frontend/`)
|
||||
- Dev: `npm run dev` (Vite, https://localhost:5173)
|
||||
- Lint: `npm run lint`
|
||||
- Type-check: `npm run type-check`
|
||||
- Unit tests: `npm run test:unit` (Vitest). Single: `npx vitest run path/to/file.spec.ts`
|
||||
- E2E: `npx playwright test` — config auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e USE_MONGODB=true MONGO_URI=mongomock`. Tests live in `e2e/`. `frontend/.env.test` contains the example MongoDB config.
|
||||
- E2E buckets are Playwright projects (see `playwright.config.ts`) targeting directories under `e2e/mode_parent/`
|
||||
|
||||
## Architecture
|
||||
|
||||
### API routing — the `/api` prefix
|
||||
- Frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`.** Backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
||||
- `auth_api` is the only blueprint registered with a prefix: `url_prefix='/auth'` in `main.py:67`.
|
||||
- API errors return `{ error, code }` (codes in `backend/api/error_codes.py`). Frontend extracts them via `parseErrorResponse(res)` in `src/common/api.ts`.
|
||||
|
||||
### Models — strict 1:1 parity
|
||||
- Python `@dataclass`es in `backend/models/`. TypeScript interfaces in `frontend/src/common/models.ts`. Any model change requires updating both.
|
||||
- Persistence is MongoDB by default (`USE_MONGODB=true`), or TinyDB when `USE_MONGODB=false`. Both are accessed through the `LockedTable` / `MongoLockedTable` wrappers in `backend/db/db.py`. Operate on model instances with `from_dict()` / `to_dict()` — never raw dicts.
|
||||
- MongoDB client initialization is lazy (`backend/db/mongo_client.py`). `backend/gunicorn.conf.py` provides the `post_fork` hook required for multi-worker Gunicorn deployments; `backend/Dockerfile` loads it with `-c gunicorn.conf.py`.
|
||||
- Migration script: `cd backend && python -m scripts/migrate_to_mongodb [--dry-run]`. It reads TinyDB JSON files and writes them to MongoDB idempotently, backing up the originals to `<db_dir>/backups/<timestamp>/`.
|
||||
|
||||
### SSE event bus — mandatory for every mutation
|
||||
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` from `api/utils.py`. Event types in `backend/events/types/` are mirrored in `frontend/src/common/backendEvents.ts`.
|
||||
- Frontend: register listeners in `onMounted`, clean up in `onUnmounted`. SSE endpoint is `/events`.
|
||||
|
||||
### Background schedulers (started in `main.py` at boot)
|
||||
- `start_deletion_scheduler` — runs hourly, deletes accounts marked for deletion after threshold
|
||||
- `start_digest_scheduler` — email digests
|
||||
- `start_state_expiry_scheduler` — expires stale state
|
||||
- `start_chore_expiry_notification_scheduler` — chore expiry notifications
|
||||
|
||||
## Frontend conventions
|
||||
- SFC file order: `<template>` → `<script>` → `<style scoped>`. TypeScript only in `<script>`. All styles must be `scoped`.
|
||||
- Colors/spacing: use only `:root` CSS variables from `colors.css`. No hardcoded hex/px for themed properties.
|
||||
- Layout shells: `ParentLayout` for admin/management, `ChildLayout` for child dashboard/focus.
|
||||
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
|
||||
|
||||
## Testing gotchas
|
||||
- E2E tests use pre-authenticated sessions via `storageState` in `playwright.config.ts` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts`.
|
||||
- E2E buckets that mutate shared state (default tasks, delete-account, create-child) use isolated users. Preserve this pattern when adding new buckets.
|
||||
- Backend tests: `conftest.py` sets `DB_ENV=test` + dummy secrets. Test DB lands in `test_data/db/`, never touches production `data/`.
|
||||
|
||||
## Feature specs
|
||||
Specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is complete.
|
||||
@@ -0,0 +1,67 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates flow over Server-Sent Events.
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend (run from `backend/`)
|
||||
- Activate venv first: `source .venv/bin/activate` (mac/linux) — Python runs from `backend/.venv/`.
|
||||
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `backend/main.py`).
|
||||
- Required env vars at startup: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing.
|
||||
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` directory (see `backend/config/paths.py`).
|
||||
- Tests: `pytest tests/` — `tests/conftest.py` forces `DB_ENV=test` and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`.
|
||||
- Create an admin user (cannot be done via signup): `python scripts/create_admin.py`.
|
||||
|
||||
### Frontend (run from `frontend/`)
|
||||
- Dev: `npm run dev` (Vite, https://localhost:5173).
|
||||
- Build: `npm run build`. Type-check: `npm run type-check`. Lint: `npm run lint`.
|
||||
- Unit/component tests: `npm run test:unit` (Vitest). Single test: `npx vitest run path/to/file.spec.ts`.
|
||||
- E2E: `npx playwright test` from `frontend/`. Config at `playwright.config.ts` auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e`, so test data lands in `backend/test_data/` and never touches production. The `globalSetup` seeds the DB and logs in; tests receive a pre-authenticated session via `storageState` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts` rather than hardcoding.
|
||||
- E2E suite is split into Playwright "projects" in `playwright.config.ts` (`chromium-routines`, `chromium-task-assignment`, …) — each bucket targets a directory under `e2e/mode_parent/` and some use isolated users to avoid cross-bucket interference. Run a single bucket: `npx playwright test --project=chromium-routines`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### API surface and nginx proxy
|
||||
- Each entity has its own Flask blueprint in `backend/api/` (`child_api.py`, `chore_api.py`, `routine_api.py`, …). Registered in `backend/main.py`.
|
||||
- The frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`** — backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
||||
- The `auth_api` blueprint is the only one mounted under a prefix (`/auth`).
|
||||
- API errors return `{ error, code }`; codes live in `backend/api/error_codes.py`. Frontend extracts them via `parseErrorResponse(res)` in `frontend/src/common/api.ts`.
|
||||
|
||||
### Models — keep 1:1 parity
|
||||
- Python `@dataclass`es live in `backend/models/`. TypeScript interfaces live in `frontend/src/common/models.ts`. Any model change requires updating **both**.
|
||||
- Persistence is TinyDB (JSON files under `data/db/` or `test_data/db/`). All DB access goes through the thread-safe `LockedTable` wrapper in `backend/db/db.py`. Always operate on model instances using `from_dict()` / `to_dict()` — never raw dicts.
|
||||
|
||||
### SSE event bus (mandatory for every mutation)
|
||||
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` (from `api/utils.py`). Event types live in `backend/events/types/` and `frontend/src/common/backendEvents.ts` (mirrored).
|
||||
- Frontend state is event-driven: register listeners in `onMounted`, clean up in `onUnmounted`. See `components/BackendEventsListener.vue` and `src/common/backendEvents.ts`.
|
||||
- The SSE endpoint is `/events`; per-user queues live in `backend/events/sse.py`.
|
||||
|
||||
### Background schedulers
|
||||
Started in `backend/main.py` at boot:
|
||||
- `start_deletion_scheduler` — runs hourly, deletes accounts that were marked-for-deletion at least `ACCOUNT_DELETION_THRESHOLD_HOURS` ago (default 720, min 24, max 720). Cleans pending rewards, children, tasks, rewards, images, then the user. Logs to `logs/account_deletion.log`.
|
||||
- `start_digest_scheduler` — email digests.
|
||||
- `start_state_expiry_scheduler` — expires stale state.
|
||||
- `start_chore_expiry_notification_scheduler` — chore expiry notifications.
|
||||
|
||||
### Auth & security
|
||||
- JWT in HttpOnly + Secure + SameSite=Strict cookies. Verification tokens expire in 4 hours; password-reset tokens in 10 minutes.
|
||||
- Admin role is **never** assignable via signup — use `backend/scripts/create_admin.py`. Admin endpoints under `/admin/*` enforce role check.
|
||||
|
||||
### Frontend conventions
|
||||
- Vue SFC file order: `<template>` → `<script>` → `<style scoped>`. TypeScript only inside `<script>`. **All styles must be `scoped`.**
|
||||
- Use **only** `:root` CSS variables from `colors.css` for colors/spacing/tokens (e.g. `--btn-primary`, `--list-item-bg-good`). No hardcoded hex/px values for themed properties.
|
||||
- Layout shells: `ParentLayout` for admin/management views, `ChildLayout` for child dashboard/focus views.
|
||||
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
|
||||
|
||||
### Specs
|
||||
Feature specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is considered complete.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Backend Python imports assume `backend/` is on `sys.path` (added by `conftest.py` for tests, by `flask run` cwd in dev). Run pytest from `backend/`.
|
||||
- Don't replace code with comments; mirror changes across backend + frontend so model/event parity holds.
|
||||
- E2E tests share a single seeded user by default — buckets that mutate shared state (default tasks, delete-account, create-child) deliberately use isolated users; preserve that pattern when adding new buckets.
|
||||
@@ -0,0 +1,203 @@
|
||||
# Reward - Chore & Reward Management System
|
||||
|
||||
A family-friendly application for managing chores, tasks, and rewards for children.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
- **Backend**: Flask (Python) with TinyDB or MongoDB 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
|
||||
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` |
|
||||
| `USE_MONGODB` | Use MongoDB (`true`/`false`) | `true` |
|
||||
| `MONGO_URI` | MongoDB connection URI (required when `USE_MONGODB=true`) | — |
|
||||
| `MONGO_DB_NAME` | MongoDB database name (optional) | Parsed from `MONGO_URI`, or `chore_db`/`chore_db_test`/`chore_db_e2e` based on `DB_ENV` |
|
||||
|
||||
### Database Backend
|
||||
|
||||
The application supports two persistence backends:
|
||||
|
||||
- **MongoDB** (default): Set `MONGO_URI` (and optionally `MONGO_DB_NAME`). This is the recommended backend for production and managed hosting (e.g., MongoDB Atlas).
|
||||
- **TinyDB**: JSON-file storage in `backend/data/db/` (or `backend/test_data/db/` for `test`/`e2e`). Opt in by setting `USE_MONGODB=false`.
|
||||
|
||||
#### Migrating from TinyDB to MongoDB
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
# Dry run to preview what will be migrated
|
||||
python -m scripts.migrate_to_mongodb --dry-run
|
||||
|
||||
# Run the migration (backs up TinyDB files first)
|
||||
python -m scripts.migrate_to_mongodb
|
||||
```
|
||||
|
||||
The migration script reads the existing TinyDB JSON files and inserts each record into the matching MongoDB collection, skipping records that already exist. Original TinyDB files are backed up to `backend/data/db/backups/<timestamp>/`.
|
||||
|
||||
#### Rolling Back to TinyDB
|
||||
|
||||
Set `USE_MONGODB=false`. The original JSON files remain in place.
|
||||
|
||||
#### Gunicorn / Docker
|
||||
|
||||
When running multiple Gunicorn workers, each worker must create its own MongoDB client after forking. This is handled automatically by `backend/gunicorn.conf.py`, which is loaded by `backend/Dockerfile` via `-c gunicorn.conf.py`.
|
||||
|
||||
### 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
|
||||
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 / MongoDB persistence layer
|
||||
│ ├── events/ # SSE event system
|
||||
│ ├── models/ # Data models
|
||||
│ ├── tests/ # Backend tests
|
||||
│ └── utils/ # Utilities (scheduler, etc)
|
||||
├── frontend/
|
||||
│ └── 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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -15,4 +15,4 @@ ENV PYTHONIOENCODING=utf-8
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
# Use Gunicorn instead of python main.py
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "main:app"]
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "-c", "gunicorn.conf.py", "main:app"]
|
||||
@@ -0,0 +1,287 @@
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timedelta
|
||||
from tinydb import Query
|
||||
|
||||
from db.db import users_db
|
||||
from models.user import User
|
||||
from api.utils import admin_required, get_validated_user_id
|
||||
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__)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test-only endpoint — active ONLY when DB_ENV=e2e
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@admin_api.route('/admin/test/digest-token', methods=['POST'])
|
||||
def create_test_digest_token():
|
||||
"""Create a valid DigestActionToken for E2E tests.
|
||||
|
||||
Only active when DB_ENV=e2e. Requires authentication.
|
||||
"""
|
||||
if os.environ.get('DB_ENV') != 'e2e':
|
||||
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
child_id = data.get('child_id')
|
||||
entity_id = data.get('entity_id')
|
||||
entity_type = data.get('entity_type')
|
||||
action = data.get('action')
|
||||
expires_in_hours = data.get('expires_in_hours', 24)
|
||||
|
||||
if not all([child_id, entity_id, entity_type, action]):
|
||||
return jsonify({'error': 'child_id, entity_id, entity_type, and action are required',
|
||||
'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
if entity_type not in ('chore', 'reward'):
|
||||
return jsonify({'error': 'entity_type must be "chore" or "reward"',
|
||||
'code': 'INVALID_ENTITY_TYPE'}), 400
|
||||
|
||||
if action not in ('approve', 'deny'):
|
||||
return jsonify({'error': 'action must be "approve" or "deny"',
|
||||
'code': 'INVALID_ACTION'}), 400
|
||||
|
||||
try:
|
||||
from utils.digest_token import create_action_token
|
||||
token = create_action_token(
|
||||
user_id=user_id,
|
||||
child_id=child_id,
|
||||
entity_id=entity_id,
|
||||
entity_type=entity_type,
|
||||
action=action,
|
||||
expiry_hours=int(expires_in_hours),
|
||||
)
|
||||
return jsonify({'token': token.id}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
|
||||
|
||||
@admin_api.route('/admin/test/send-digest', methods=['POST'])
|
||||
def send_test_digest():
|
||||
"""Trigger a digest email for a specific user by email address.
|
||||
|
||||
Only active when DB_ENV is not 'production'. Requires admin authentication.
|
||||
Note: actual email delivery is skipped in e2e mode by email_sender.
|
||||
"""
|
||||
if os.environ.get('DB_ENV') == 'production':
|
||||
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
# Verify caller is admin
|
||||
caller_dict = users_db.get(Query().id == user_id)
|
||||
if not caller_dict or caller_dict.get('role') != 'admin':
|
||||
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = data.get('email', '').strip().lower()
|
||||
if not email:
|
||||
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
target = users_db.get(Query().email == email)
|
||||
if not target:
|
||||
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||
|
||||
target_id = target.get('id')
|
||||
target_email = target.get('email')
|
||||
|
||||
try:
|
||||
from flask import current_app
|
||||
from utils.digest_scheduler import send_digest_for_user
|
||||
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
||||
items_sent = send_digest_for_user(target_id, target_email, frontend_url)
|
||||
return jsonify({'items_sent': items_sent}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
|
||||
|
||||
@admin_api.route('/admin/test/trigger-chore-expiry', methods=['POST'])
|
||||
def trigger_test_chore_expiry():
|
||||
"""Trigger the chore expiry notification check for a specific user by email address.
|
||||
|
||||
Only active when DB_ENV is not 'production'. Requires admin authentication.
|
||||
Note: actual push delivery requires VAPID keys to be configured.
|
||||
"""
|
||||
if os.environ.get('DB_ENV') == 'production':
|
||||
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
caller_dict = users_db.get(Query().id == user_id)
|
||||
if not caller_dict or caller_dict.get('role') != 'admin':
|
||||
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||
|
||||
data = request.get_json() or {}
|
||||
email = data.get('email', '').strip().lower()
|
||||
if not email:
|
||||
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
target = users_db.get(Query().email == email)
|
||||
if not target:
|
||||
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||
|
||||
target_id = target.get('id')
|
||||
tz_str = target.get('timezone')
|
||||
|
||||
try:
|
||||
from utils.chore_expiry_notification_scheduler import send_chore_expiry_notifications_for_user
|
||||
chores_notified = send_chore_expiry_notifications_for_user(target_id, tz_str)
|
||||
return jsonify({'chores_notified': chores_notified}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||
@@ -0,0 +1,653 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
import jwt
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from models.user import User
|
||||
from models.refresh_token import RefreshToken
|
||||
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 events.sse import send_event_to_user
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.payload import Payload
|
||||
|
||||
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,
|
||||
REFRESH_TOKEN_REUSE, REFRESH_TOKEN_EXPIRED, MISSING_REFRESH_TOKEN,
|
||||
)
|
||||
from db.db import (
|
||||
users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db,
|
||||
pending_reward_db, pending_confirmations_db, tracking_events_db,
|
||||
child_overrides_db, chore_schedules_db, task_extensions_db,
|
||||
routine_db, routine_items_db, routine_schedules_db, routine_extensions_db,
|
||||
push_subscriptions_db, digest_action_tokens_db,
|
||||
)
|
||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||
from api.utils import normalize_email
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
auth_api = Blueprint('auth_api', __name__)
|
||||
UserQuery = Query()
|
||||
TokenQuery = Query()
|
||||
TOKEN_EXPIRY_MINUTES = 60 * 4
|
||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||
try:
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
|
||||
except ValueError:
|
||||
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
||||
try:
|
||||
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = int(
|
||||
os.environ.get('REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', '30')
|
||||
)
|
||||
except ValueError:
|
||||
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = 30
|
||||
E2E_TEST_EMAIL = 'e2e@test.com'
|
||||
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
||||
E2E_TEST_PIN = '1234'
|
||||
E2E_DELETE_EMAIL = 'e2e-delete@test.com'
|
||||
E2E_DELETE_PASSWORD = 'E2eDeletePass1!'
|
||||
E2E_DELETE_PIN = '5678'
|
||||
E2E_CC_EMAIL = 'e2e-cc@test.com'
|
||||
E2E_CC_PASSWORD = 'E2eCCPass1!'
|
||||
E2E_CC_PIN = '3456'
|
||||
E2E_TUTORIAL_EMAIL = 'e2e-tutorial@test.com'
|
||||
E2E_TUTORIAL_PASSWORD = 'E2eTutorialPass1!'
|
||||
E2E_TUTORIAL_PIN = '7890'
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _hash_token(raw_token: str) -> str:
|
||||
"""SHA-256 hash a raw refresh token for secure storage."""
|
||||
return hashlib.sha256(raw_token.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def _create_access_token(user: User) -> str:
|
||||
"""Create a short-lived JWT access token."""
|
||||
payload = {
|
||||
'email': user.email,
|
||||
'user_id': user.id,
|
||||
'token_version': user.token_version,
|
||||
'exp': datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRY_MINUTES),
|
||||
}
|
||||
return jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm='HS256')
|
||||
|
||||
|
||||
def _create_refresh_token(user_id: str, token_family: str | None = None) -> tuple[str, RefreshToken]:
|
||||
"""
|
||||
Create a refresh token: returns (raw_token, RefreshToken record).
|
||||
If token_family is None, a new family is created (login).
|
||||
Otherwise, the existing family is reused (rotation).
|
||||
"""
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(days=expiry_days)).isoformat()
|
||||
family = token_family or str(uuid.uuid4())
|
||||
|
||||
record = RefreshToken(
|
||||
user_id=user_id,
|
||||
token_hash=_hash_token(raw_token),
|
||||
token_family=family,
|
||||
expires_at=expires_at,
|
||||
is_used=False,
|
||||
)
|
||||
refresh_tokens_db.insert(record.to_dict())
|
||||
return raw_token, record
|
||||
|
||||
|
||||
def _set_auth_cookies(resp, access_token: str, raw_refresh_token: str):
|
||||
"""Set both access and refresh token cookies on a response."""
|
||||
expiry_days = current_app.config['REFRESH_TOKEN_EXPIRY_DAYS']
|
||||
resp.set_cookie(
|
||||
'access_token', access_token,
|
||||
httponly=True, secure=True, samesite='Lax',
|
||||
max_age=ACCESS_TOKEN_EXPIRY_MINUTES * 60,
|
||||
)
|
||||
resp.set_cookie(
|
||||
'refresh_token', raw_refresh_token,
|
||||
httponly=True, secure=True, samesite='Strict',
|
||||
max_age=expiry_days * 24 * 3600,
|
||||
path='/api/auth',
|
||||
)
|
||||
|
||||
|
||||
def _clear_auth_cookies(resp):
|
||||
"""Clear both access and refresh token cookies."""
|
||||
resp.set_cookie('access_token', '', expires=0, httponly=True, secure=True, samesite='Strict')
|
||||
resp.set_cookie('refresh_token', '', expires=0, httponly=True, secure=True, samesite='Strict', path='/api/auth')
|
||||
|
||||
|
||||
def _purge_expired_tokens(user_id: str):
|
||||
"""Remove expired refresh tokens for a user to prevent unbounded DB growth."""
|
||||
now = datetime.now(timezone.utc)
|
||||
all_tokens = refresh_tokens_db.search(TokenQuery.user_id == user_id)
|
||||
for t in all_tokens:
|
||||
try:
|
||||
exp = datetime.fromisoformat(t['expires_at'])
|
||||
if exp.tzinfo is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
if now > exp:
|
||||
refresh_tokens_db.remove(TokenQuery.id == t['id'])
|
||||
except (ValueError, KeyError):
|
||||
refresh_tokens_db.remove(TokenQuery.id == t['id'])
|
||||
|
||||
@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
|
||||
|
||||
# Purge expired refresh tokens for this user
|
||||
_purge_expired_tokens(user.id)
|
||||
|
||||
# Create access token (short-lived JWT)
|
||||
access_token = _create_access_token(user)
|
||||
|
||||
# Create refresh token (long-lived, new family for fresh login)
|
||||
raw_refresh, _ = _create_refresh_token(user.id)
|
||||
|
||||
resp = jsonify({'message': 'Login successful'})
|
||||
_set_auth_cookies(resp, access_token, raw_refresh)
|
||||
return resp, 200
|
||||
|
||||
@auth_api.route('/me', methods=['GET'])
|
||||
def me():
|
||||
token = request.cookies.get('access_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)
|
||||
|
||||
# Invalidate ALL refresh tokens for this user
|
||||
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
||||
|
||||
# Notify all active sessions (other tabs/devices) to sign out immediately
|
||||
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'password_reset'})))
|
||||
|
||||
resp = jsonify({'message': 'Password has been reset'})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 200
|
||||
|
||||
|
||||
@auth_api.route('/refresh', methods=['POST'])
|
||||
def refresh():
|
||||
raw_token = request.cookies.get('refresh_token')
|
||||
if not raw_token:
|
||||
return jsonify({'error': 'Missing refresh token', 'code': MISSING_REFRESH_TOKEN}), 401
|
||||
|
||||
token_hash = _hash_token(raw_token)
|
||||
token_dict = refresh_tokens_db.get(TokenQuery.token_hash == token_hash)
|
||||
|
||||
if not token_dict:
|
||||
# Token not found — could be invalid or already purged
|
||||
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
token_record = RefreshToken.from_dict(token_dict)
|
||||
|
||||
# Look up the user early (needed for both legitimate rotation and grace-period handling)
|
||||
user_dict = users_db.get(UserQuery.id == token_record.user_id)
|
||||
user = User.from_dict(user_dict) if user_dict else None
|
||||
if not user:
|
||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
if user.marked_for_deletion:
|
||||
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
||||
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 403
|
||||
|
||||
# Check expiry
|
||||
try:
|
||||
exp = datetime.fromisoformat(token_record.expires_at)
|
||||
if exp.tzinfo is None:
|
||||
exp = exp.replace(tzinfo=timezone.utc)
|
||||
if datetime.now(timezone.utc) > exp:
|
||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||
resp = jsonify({'error': 'Refresh token expired', 'code': REFRESH_TOKEN_EXPIRED})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
except ValueError:
|
||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||
resp = jsonify({'error': 'Invalid token', 'code': INVALID_TOKEN})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
# THEFT DETECTION: token was already used (rotated out) but replayed
|
||||
if token_record.is_used:
|
||||
# Grace period: tolerate a very recent rotation to avoid false positives
|
||||
# from legitimate concurrent refresh requests (race conditions).
|
||||
grace_period = current_app.config.get(
|
||||
'REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS
|
||||
)
|
||||
rotated_at = token_record.rotated_at
|
||||
is_race_condition = False
|
||||
if rotated_at:
|
||||
try:
|
||||
rotated_dt = datetime.fromisoformat(rotated_at)
|
||||
if rotated_dt.tzinfo is None:
|
||||
rotated_dt = rotated_dt.replace(tzinfo=timezone.utc)
|
||||
if (datetime.now(timezone.utc) - rotated_dt).total_seconds() <= grace_period:
|
||||
is_race_condition = True
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if is_race_condition:
|
||||
logger.info(
|
||||
'Refresh token replay within grace period treated as race condition. user_id=%s, family=%s, ip=%s',
|
||||
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||
)
|
||||
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
||||
access_token = _create_access_token(user)
|
||||
resp = jsonify({
|
||||
'email': user.email,
|
||||
'id': user.id,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'verified': user.verified,
|
||||
})
|
||||
_set_auth_cookies(resp, access_token, raw_new_refresh)
|
||||
return resp, 200
|
||||
|
||||
logger.warning(
|
||||
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing family sessions',
|
||||
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||
)
|
||||
# Invalidate only the affected family, not every session for the user.
|
||||
refresh_tokens_db.remove(
|
||||
(TokenQuery.user_id == token_record.user_id) & (TokenQuery.token_family == token_record.token_family)
|
||||
)
|
||||
resp = jsonify({'error': 'Token reuse detected, family sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 401
|
||||
|
||||
# ROTATION: mark old token as used, create new one in same family
|
||||
refresh_tokens_db.update(
|
||||
{'is_used': True, 'rotated_at': datetime.now(timezone.utc).isoformat()},
|
||||
TokenQuery.id == token_record.id,
|
||||
)
|
||||
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
||||
|
||||
# Issue new access token
|
||||
access_token = _create_access_token(user)
|
||||
|
||||
resp = jsonify({
|
||||
'email': user.email,
|
||||
'id': user.id,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'verified': user.verified,
|
||||
})
|
||||
_set_auth_cookies(resp, access_token, raw_new_refresh)
|
||||
return resp, 200
|
||||
|
||||
|
||||
@auth_api.route('/logout', methods=['POST'])
|
||||
def logout():
|
||||
# Delete the refresh token from DB if present
|
||||
raw_token = request.cookies.get('refresh_token')
|
||||
if raw_token:
|
||||
token_hash = _hash_token(raw_token)
|
||||
refresh_tokens_db.remove(TokenQuery.token_hash == token_hash)
|
||||
|
||||
resp = jsonify({'message': 'Logged out'})
|
||||
_clear_auth_cookies(resp)
|
||||
return resp, 200
|
||||
|
||||
|
||||
@auth_api.route('/e2e-create-delete-user', methods=['POST'])
|
||||
def e2e_create_delete_user():
|
||||
"""Create a secondary e2e test user for deletion testing. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
norm_email = normalize_email(E2E_DELETE_EMAIL)
|
||||
users_db.remove(UserQuery.email == norm_email)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='Delete',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_DELETE_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_DELETE_PIN,
|
||||
tutorial_enabled=False,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
|
||||
|
||||
@auth_api.route('/e2e-create-cc-user', methods=['POST'])
|
||||
def e2e_create_cc_user():
|
||||
"""Create an isolated e2e test user for create-child tests. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
norm_email = normalize_email(E2E_CC_EMAIL)
|
||||
# Remove old user and all their children so deleteAllChildren() starts clean.
|
||||
existing = users_db.get(UserQuery.email == norm_email)
|
||||
if existing:
|
||||
child_db.remove(Query().user_id == existing.get('id'))
|
||||
users_db.remove(UserQuery.email == norm_email)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='CreateChild',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_CC_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_CC_PIN,
|
||||
tutorial_enabled=False,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
|
||||
|
||||
@auth_api.route('/e2e-create-tutorial-user', methods=['POST'])
|
||||
def e2e_create_tutorial_user():
|
||||
"""Create an isolated e2e test user for tutorial tests. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
norm_email = normalize_email(E2E_TUTORIAL_EMAIL)
|
||||
# Clean up any children from previous tutorial runs.
|
||||
existing = users_db.get(UserQuery.email == norm_email)
|
||||
if existing:
|
||||
child_db.remove(Query().user_id == existing.get('id'))
|
||||
users_db.remove(UserQuery.email == norm_email)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='Tutorial',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_TUTORIAL_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_TUTORIAL_PIN,
|
||||
tutorial_enabled=False,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
return jsonify({'email': norm_email}), 201
|
||||
|
||||
|
||||
@auth_api.route('/e2e-seed', methods=['POST'])
|
||||
def e2e_seed():
|
||||
"""Reset the database and insert a verified test user. Only available outside production."""
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
|
||||
child_db.truncate()
|
||||
task_db.truncate()
|
||||
reward_db.truncate()
|
||||
image_db.truncate()
|
||||
pending_reward_db.truncate()
|
||||
pending_confirmations_db.truncate()
|
||||
users_db.truncate()
|
||||
tracking_events_db.truncate()
|
||||
child_overrides_db.truncate()
|
||||
chore_schedules_db.truncate()
|
||||
task_extensions_db.truncate()
|
||||
refresh_tokens_db.truncate()
|
||||
routine_db.truncate()
|
||||
routine_items_db.truncate()
|
||||
routine_schedules_db.truncate()
|
||||
routine_extensions_db.truncate()
|
||||
push_subscriptions_db.truncate()
|
||||
digest_action_tokens_db.truncate()
|
||||
|
||||
# Recreate only baseline defaults for e2e runs.
|
||||
initializeImages()
|
||||
createDefaultTasks()
|
||||
createDefaultRewards()
|
||||
|
||||
norm_email = normalize_email(E2E_TEST_EMAIL)
|
||||
user = User(
|
||||
first_name='E2E',
|
||||
last_name='Tester',
|
||||
email=norm_email,
|
||||
password=generate_password_hash(E2E_TEST_PASSWORD),
|
||||
verified=True,
|
||||
role='user',
|
||||
pin=E2E_TEST_PIN,
|
||||
tutorial_enabled=False,
|
||||
)
|
||||
users_db.insert(user.to_dict())
|
||||
|
||||
return jsonify({'email': norm_email}), 201
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Shared business logic for chore confirmation and reward actions.
|
||||
|
||||
Called from both child_api.py (JWT-authenticated endpoints) and
|
||||
digest_action_api.py (token-authenticated endpoints). All functions take
|
||||
user_id explicitly rather than reading it from the Flask request context.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from tinydb import Query
|
||||
|
||||
from db.db import child_db, task_db, reward_db, pending_confirmations_db, routine_db
|
||||
from db.child_overrides import get_override
|
||||
from db.chore_schedules import get_schedule
|
||||
from db.routine_schedules import get_schedule as get_routine_schedule
|
||||
from db.tracking import insert_tracking_event
|
||||
from events.sse import send_event_to_user
|
||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||
from events.types.child_routine_confirmation import ChildRoutineConfirmation
|
||||
from events.types.child_reward_request import ChildRewardRequest
|
||||
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||
from events.types.child_task_triggered import ChildTaskTriggered
|
||||
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.reward import Reward
|
||||
from models.routine import Routine
|
||||
from models.task import Task
|
||||
from models.tracking_event import TrackingEvent
|
||||
from utils.tracking_logger import log_tracking_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None:
|
||||
"""Award points for a completed chore and mark the pending confirmation approved.
|
||||
|
||||
Returns a result dict on success, or None if the confirmation was already resolved.
|
||||
Raises ValueError if the child or task cannot be found.
|
||||
"""
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if not child_result:
|
||||
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
if task_id not in child.tasks:
|
||||
logger.info(f'Task {task_id} no longer assigned to child {child_id}; skipping approve')
|
||||
return None
|
||||
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
||||
return None
|
||||
|
||||
TaskQ = Query()
|
||||
task_result = task_db.get(
|
||||
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
|
||||
)
|
||||
if not task_result:
|
||||
raise ValueError(f'Task {task_id} not found')
|
||||
task = Task.from_dict(task_result)
|
||||
|
||||
override = get_override(child_id, task_id)
|
||||
points_value = override.custom_value if override else task.points
|
||||
points_before = child.points
|
||||
child.points += points_value
|
||||
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||
|
||||
schedule = get_schedule(child_id, task_id)
|
||||
if schedule:
|
||||
now_str = datetime.now(timezone.utc).isoformat()
|
||||
pending_confirmations_db.update(
|
||||
{'status': 'approved', 'approved_at': now_str},
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
else:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
tracking_metadata = {
|
||||
'task_name': task.name,
|
||||
'task_type': task.type,
|
||||
'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='chore', entity_id=task_id,
|
||||
action='approved', points_before=points_before, points_after=child.points,
|
||||
metadata=tracking_metadata,
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved')))
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value,
|
||||
ChildTaskTriggered(task_id, child_id, child.points)))
|
||||
|
||||
return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||
|
||||
|
||||
def reject_chore(user_id: str, child_id: str, task_id: str) -> None:
|
||||
"""Reject a pending chore confirmation. No-op if already resolved."""
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
||||
return
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if child_result:
|
||||
child = Child.from_dict(child_result)
|
||||
TaskQ = Query()
|
||||
task_result = task_db.get(
|
||||
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
|
||||
)
|
||||
task_name = task_result.get('name') if task_result else 'Unknown'
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
|
||||
action='rejected', points_before=child.points, points_after=child.points,
|
||||
metadata={'task_name': task_name},
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected')))
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
|
||||
|
||||
|
||||
def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict:
|
||||
"""Approve a child's pending reward request: deduct points and fire SSE events.
|
||||
|
||||
Returns a result dict on success.
|
||||
Raises ValueError if child/reward not found or the child has insufficient points.
|
||||
"""
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if not child_result:
|
||||
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
if reward_id not in child.rewards:
|
||||
logger.info(f'Reward {reward_id} no longer assigned to child {child_id}; skipping approve')
|
||||
return None
|
||||
|
||||
RewardQ = Query()
|
||||
reward_result = reward_db.get(
|
||||
(RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
|
||||
)
|
||||
if not reward_result:
|
||||
raise ValueError(f'Reward {reward_id} not found')
|
||||
reward = Reward.from_dict(reward_result)
|
||||
|
||||
override = get_override(child_id, reward_id)
|
||||
cost_value = override.custom_value if override else reward.cost
|
||||
|
||||
if child.points < cost_value:
|
||||
raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}')
|
||||
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending reward for child {child_id}, reward {reward_id} — already resolved')
|
||||
return None
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||
|
||||
points_before = child.points
|
||||
child.points -= cost_value
|
||||
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||
|
||||
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_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed')))
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value,
|
||||
ChildRewardTriggered(reward_id, child_id, child.points)))
|
||||
|
||||
return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||
|
||||
|
||||
def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
|
||||
"""Deny a child's pending reward request. No-op if already resolved.
|
||||
|
||||
Returns a result dict on success, or None if already resolved.
|
||||
"""
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved')
|
||||
return None
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
child_name = 'Unknown'
|
||||
if child_result:
|
||||
child = Child.from_dict(child_result)
|
||||
child_name = child.name
|
||||
|
||||
tracking_event = TrackingEvent.create_event(
|
||||
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
|
||||
action='denied', points_before=child.points, points_after=child.points,
|
||||
metadata={},
|
||||
)
|
||||
insert_tracking_event(tracking_event)
|
||||
log_tracking_event(tracking_event)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied')))
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||
|
||||
return {'child_name': child_name}
|
||||
|
||||
|
||||
def approve_routine(user_id: str, child_id: str, routine_id: str) -> dict | None:
|
||||
"""Award points for a completed routine and mark the pending confirmation approved.
|
||||
|
||||
Returns a result dict on success, or None if already resolved.
|
||||
Raises ValueError if the child or routine cannot be found.
|
||||
"""
|
||||
ChildQ = Query()
|
||||
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||
if not child_result:
|
||||
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||
child = Child.from_dict(child_result)
|
||||
|
||||
if routine_id not in child.routines:
|
||||
logger.info(f'Routine {routine_id} no longer assigned to child {child_id}; skipping approve')
|
||||
return None
|
||||
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
|
||||
return None
|
||||
|
||||
RoutineQ = Query()
|
||||
routine_result = routine_db.get(
|
||||
(RoutineQ.id == routine_id) & ((RoutineQ.user_id == user_id) | (RoutineQ.user_id == None))
|
||||
)
|
||||
if not routine_result:
|
||||
raise ValueError(f'Routine {routine_id} not found')
|
||||
routine = Routine.from_dict(routine_result)
|
||||
|
||||
override = get_override(child_id, routine_id)
|
||||
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
|
||||
points_before = child.points
|
||||
child.points += points_value
|
||||
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||
|
||||
schedule = get_routine_schedule(child_id, routine_id)
|
||||
now_str = datetime.now(timezone.utc).isoformat()
|
||||
if schedule:
|
||||
pending_confirmations_db.update(
|
||||
{'status': 'approved', 'approved_at': now_str},
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
else:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)))
|
||||
|
||||
return {'routine_name': routine.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||
|
||||
|
||||
def reject_routine(user_id: str, child_id: str, routine_id: str) -> None:
|
||||
"""Reject a pending routine confirmation. No-op if already resolved."""
|
||||
PendingQ = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
|
||||
(PendingQ.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
|
||||
return
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
|
||||
)
|
||||
|
||||
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_REJECTED)))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
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, routine_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', 'routine']:
|
||||
return jsonify({'error': 'entity_type must be "task", "reward", or "routine"', '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
|
||||
|
||||
elif entity_type == '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
|
||||
|
||||
else: # routine
|
||||
EntityQuery = Query()
|
||||
entity_result = routine_db.search(
|
||||
(EntityQuery.id == entity_id) &
|
||||
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
|
||||
)
|
||||
if not entity_result:
|
||||
return jsonify({'error': 'Routine not found', 'code': 'ROUTINE_NOT_FOUND'}), 404
|
||||
|
||||
assigned_routines = child_dict.get('routines', [])
|
||||
if entity_id not in assigned_routines:
|
||||
return jsonify({'error': 'Routine 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
|
||||
@@ -0,0 +1,638 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.error_codes import ErrorCodes
|
||||
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||
from db.child_overrides import delete_override, get_override
|
||||
from db.db import child_db, pending_confirmations_db, routine_db, users_db
|
||||
from db.routine_extensions import delete_extension_for_child_routine, get_extension_for_child_routine
|
||||
from db.routine_items import get_items_for_routine
|
||||
from db.routine_schedules import delete_schedule, get_schedule
|
||||
from events.types.child_routine_confirmation import ChildRoutineConfirmation
|
||||
from events.types.child_routines_set import ChildRoutinesSet
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from models.child import Child
|
||||
from models.pending_confirmation import PendingConfirmation
|
||||
from models.routine import Routine
|
||||
from utils.digest_token import create_action_token
|
||||
from utils.push_sender import send_push_to_user
|
||||
|
||||
child_routine_api = Blueprint('child_routine_api', __name__)
|
||||
|
||||
|
||||
def _get_user_timezone(user_id: str) -> str | None:
|
||||
user = users_db.get(Query().id == user_id)
|
||||
if not user:
|
||||
return None
|
||||
return user.get('timezone')
|
||||
|
||||
|
||||
def _get_user_today_local(user_id: str) -> tuple[str, str | None]:
|
||||
tz_str = _get_user_timezone(user_id)
|
||||
try:
|
||||
now_local = datetime.now(ZoneInfo(tz_str)) if tz_str else datetime.now(timezone.utc)
|
||||
except Exception:
|
||||
tz_str = None
|
||||
now_local = datetime.now(timezone.utc)
|
||||
return now_local.strftime('%Y-%m-%d'), tz_str
|
||||
|
||||
|
||||
def _is_iso_timestamp_on_local_day(timestamp: str | None, local_day: str, tz_str: str | None) -> bool:
|
||||
if not timestamp:
|
||||
return False
|
||||
try:
|
||||
normalized = timestamp.replace('Z', '+00:00')
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
|
||||
except Exception:
|
||||
tz = timezone.utc
|
||||
return parsed.astimezone(tz).strftime('%Y-%m-%d') == local_day
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _is_epoch_timestamp_on_local_day(epoch_ts, local_day: str, tz_str: str | None) -> bool:
|
||||
if epoch_ts is None:
|
||||
return False
|
||||
try:
|
||||
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
|
||||
except Exception:
|
||||
tz = timezone.utc
|
||||
try:
|
||||
return datetime.fromtimestamp(float(epoch_ts), tz).strftime('%Y-%m-%d') == local_day
|
||||
except (TypeError, ValueError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
class ChildRoutine:
|
||||
def __init__(self, name, points, image_id, _id):
|
||||
self.id = _id
|
||||
self.name = name
|
||||
self.points = points
|
||||
self.image_id = image_id
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'points': self.points,
|
||||
'image_id': self.image_id,
|
||||
}
|
||||
|
||||
|
||||
def _validate_child_for_user(child_id: str, user_id: str):
|
||||
child_q = Query()
|
||||
result = child_db.search((child_q.id == child_id) & (child_q.user_id == user_id))
|
||||
return Child.from_dict(result[0]) if result else None
|
||||
|
||||
|
||||
def _resolve_routine_for_user(routine_id: str, user_id: str):
|
||||
routine_q = Query()
|
||||
routine_result = routine_db.get(
|
||||
(routine_q.id == routine_id) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))
|
||||
)
|
||||
return Routine.from_dict(routine_result) if routine_result else None
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/assign-routine', methods=['POST'])
|
||||
def assign_routine_to_child(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
routine_id = data.get('routine_id')
|
||||
if not routine_id:
|
||||
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||
if not routine:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
routine_ids = list(child.routines)
|
||||
if routine_id not in routine_ids:
|
||||
routine_ids.append(routine_id)
|
||||
child_db.update({'routines': routine_ids}, Query().id == id)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
|
||||
)
|
||||
return jsonify({'message': f'Routine {routine_id} assigned to {child.name}.'}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/remove-routine', methods=['POST'])
|
||||
def remove_routine_from_child(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
routine_id = data.get('routine_id')
|
||||
if not routine_id:
|
||||
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
routine_ids = list(child.routines)
|
||||
if routine_id not in routine_ids:
|
||||
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
|
||||
|
||||
routine_ids.remove(routine_id)
|
||||
child_db.update({'routines': routine_ids}, Query().id == id)
|
||||
|
||||
override = get_override(id, routine_id)
|
||||
if override and override.entity_type == 'routine':
|
||||
delete_override(id, routine_id)
|
||||
|
||||
delete_schedule(id, routine_id)
|
||||
delete_extension_for_child_routine(id, routine_id)
|
||||
|
||||
pending_q = Query()
|
||||
pending_confirmations_db.remove(
|
||||
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||
)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
|
||||
)
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||
)
|
||||
)
|
||||
return jsonify({'message': f'Routine {routine_id} removed from {child.name}.'}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/set-routines', methods=['PUT'])
|
||||
def set_child_routines(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
routine_ids = data.get('routine_ids')
|
||||
if not isinstance(routine_ids, list):
|
||||
return jsonify({'error': 'routine_ids must be a list'}), 400
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
routine_q = Query()
|
||||
valid_ids = []
|
||||
for rid in dict.fromkeys(routine_ids):
|
||||
if routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))):
|
||||
valid_ids.append(rid)
|
||||
|
||||
old_ids = set(child.routines)
|
||||
new_ids = set(valid_ids)
|
||||
unassigned_ids = old_ids - new_ids
|
||||
|
||||
pending_q = Query()
|
||||
for rid in unassigned_ids:
|
||||
override = get_override(id, rid)
|
||||
if override and override.entity_type == 'routine':
|
||||
delete_override(id, rid)
|
||||
|
||||
delete_schedule(id, rid)
|
||||
delete_extension_for_child_routine(id, rid)
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||
)
|
||||
|
||||
child_db.update({'routines': valid_ids}, Query().id == id)
|
||||
send_event_for_current_user(Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, valid_ids)))
|
||||
|
||||
return jsonify({'message': f'Routines set for child {id}.', 'routine_ids': valid_ids, 'count': len(valid_ids)}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/list-routines', methods=['GET'])
|
||||
def list_child_routines(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
routine_q = Query()
|
||||
pending_q = Query()
|
||||
today_local, tz_str = _get_user_today_local(user_id)
|
||||
|
||||
child_routines = []
|
||||
for rid in child.routines:
|
||||
routine_record = routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None)))
|
||||
if not routine_record:
|
||||
continue
|
||||
|
||||
routine = Routine.from_dict(routine_record)
|
||||
override = get_override(id, rid)
|
||||
custom_value = override.custom_value if override and override.entity_type == 'routine' else None
|
||||
|
||||
cr = ChildRoutine(routine.name, routine.points, routine.image_id, routine.id)
|
||||
cr_dict = cr.to_dict()
|
||||
if custom_value is not None:
|
||||
cr_dict['custom_value'] = custom_value
|
||||
|
||||
schedule = get_schedule(id, rid)
|
||||
cr_dict['schedule'] = schedule.to_dict() if schedule else None
|
||||
|
||||
extension = get_extension_for_child_routine(id, rid)
|
||||
cr_dict['extension_date'] = extension.date if extension else None
|
||||
|
||||
items = get_items_for_routine(rid)
|
||||
cr_dict['items'] = [item.to_dict() for item in items]
|
||||
|
||||
pending = pending_confirmations_db.get(
|
||||
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||
)
|
||||
if pending:
|
||||
status = pending.get('status')
|
||||
approved_at = pending.get('approved_at')
|
||||
created_at = pending.get('created_at')
|
||||
confirmation_id = pending.get('id')
|
||||
|
||||
if status == 'approved' and _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
|
||||
cr_dict['pending_status'] = 'approved'
|
||||
cr_dict['approved_at'] = approved_at
|
||||
cr_dict['pending_confirmation_id'] = confirmation_id
|
||||
elif status == 'pending' and _is_epoch_timestamp_on_local_day(created_at, today_local, tz_str):
|
||||
cr_dict['pending_status'] = 'pending'
|
||||
cr_dict['approved_at'] = None
|
||||
cr_dict['pending_confirmation_id'] = confirmation_id
|
||||
else:
|
||||
pending_id = pending.get('id')
|
||||
if pending_id:
|
||||
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||
cr_dict['pending_status'] = None
|
||||
cr_dict['approved_at'] = None
|
||||
cr_dict['pending_confirmation_id'] = None
|
||||
else:
|
||||
cr_dict['pending_status'] = None
|
||||
cr_dict['approved_at'] = None
|
||||
cr_dict['pending_confirmation_id'] = None
|
||||
|
||||
child_routines.append(cr_dict)
|
||||
|
||||
return jsonify({'routines': child_routines}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/list-assignable-routines', methods=['GET'])
|
||||
def list_assignable_routines(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
assigned_ids = set(child.routines)
|
||||
all_routines = [r for r in routine_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
|
||||
|
||||
name_to_routines = defaultdict(list)
|
||||
for routine in all_routines:
|
||||
name_to_routines[routine.get('name')].append(routine)
|
||||
|
||||
filtered_routines = []
|
||||
for _, routines in name_to_routines.items():
|
||||
user_routines = [r for r in routines if r.get('user_id') is not None]
|
||||
if len(user_routines) == 0:
|
||||
filtered_routines.append(routines[0])
|
||||
elif len(user_routines) == 1:
|
||||
filtered_routines.append(user_routines[0])
|
||||
else:
|
||||
filtered_routines.extend(user_routines)
|
||||
|
||||
assignable = [
|
||||
ChildRoutine(r.get('name'), r.get('points'), r.get('image_id'), r.get('id')).to_dict()
|
||||
for r in filtered_routines
|
||||
]
|
||||
return jsonify({'routines': assignable, 'count': len(assignable)}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/confirm-routine', methods=['POST'])
|
||||
def confirm_routine(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
routine_id = data.get('routine_id')
|
||||
if not routine_id:
|
||||
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
if routine_id not in child.routines:
|
||||
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
|
||||
|
||||
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||
if not routine:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
pending_q = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||
)
|
||||
if existing:
|
||||
today_local, tz_str = _get_user_today_local(user_id)
|
||||
if existing.get('status') == 'pending':
|
||||
if _is_epoch_timestamp_on_local_day(existing.get('created_at'), today_local, tz_str):
|
||||
return jsonify({'error': 'Routine already pending confirmation', 'code': 'ROUTINE_ALREADY_PENDING'}), 400
|
||||
pending_id = existing.get('id')
|
||||
if pending_id:
|
||||
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||
if existing.get('status') == 'approved':
|
||||
approved_at = existing.get('approved_at', '')
|
||||
if _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
|
||||
return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400
|
||||
pending_id = existing.get('id')
|
||||
if pending_id:
|
||||
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||
|
||||
confirmation = PendingConfirmation(
|
||||
child_id=id,
|
||||
entity_id=routine_id,
|
||||
entity_type='routine',
|
||||
user_id=user_id,
|
||||
)
|
||||
pending_confirmations_db.insert(confirmation.to_dict())
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_PENDING)
|
||||
)
|
||||
)
|
||||
|
||||
push_user = users_db.get(Query().id == user_id)
|
||||
if push_user and push_user.get('push_notifications_enabled', True):
|
||||
try:
|
||||
approve_token = create_action_token(user_id, id, routine_id, 'routine', 'approve')
|
||||
deny_token = create_action_token(user_id, id, routine_id, 'routine', 'deny')
|
||||
push_payload = {
|
||||
'type': 'routine_confirmed',
|
||||
'title': 'Routine Pending',
|
||||
'body': f'{child.name} completed {routine.name}',
|
||||
'user_id': user_id,
|
||||
'child_id': id,
|
||||
'child_name': child.name,
|
||||
'entity_id': routine_id,
|
||||
'entity_type': 'routine',
|
||||
'entity_name': routine.name,
|
||||
'approve_token': approve_token.id,
|
||||
'deny_token': deny_token.id,
|
||||
}
|
||||
send_push_to_user(user_id, push_payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({'message': f'Routine {routine.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/cancel-routine-confirmation', methods=['POST'])
|
||||
def cancel_routine_confirmation(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
routine_id = data.get('routine_id')
|
||||
if not routine_id:
|
||||
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
pending_q = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
|
||||
(pending_q.user_id == user_id)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
||||
|
||||
pending_confirmations_db.remove(
|
||||
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
|
||||
(pending_q.user_id == user_id)
|
||||
)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||
)
|
||||
)
|
||||
return jsonify({'message': 'Routine confirmation cancelled.'}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/approve-routine/<confirmation_id>', methods=['POST'])
|
||||
def approve_routine(id, confirmation_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
pending_q = Query()
|
||||
confirmation = pending_confirmations_db.get(
|
||||
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||
)
|
||||
if not confirmation:
|
||||
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
|
||||
|
||||
if confirmation.get('status') != 'pending':
|
||||
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
|
||||
|
||||
routine_id = confirmation.get('entity_id')
|
||||
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||
if not routine:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
override = get_override(id, routine_id)
|
||||
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
|
||||
|
||||
new_points = max(0, child.points + points_value)
|
||||
child_db.update({'points': new_points}, Query().id == id)
|
||||
|
||||
approved_at = datetime.now(timezone.utc).isoformat()
|
||||
pending_confirmations_db.update(
|
||||
{'status': 'approved', 'approved_at': approved_at},
|
||||
pending_q.id == confirmation_id
|
||||
)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'message': f'Routine {routine.name} approved for {child.name}.',
|
||||
'points': new_points,
|
||||
'id': child.id,
|
||||
}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/reject-routine/<confirmation_id>', methods=['POST'])
|
||||
def reject_routine(id, confirmation_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
pending_q = Query()
|
||||
confirmation = pending_confirmations_db.get(
|
||||
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||
)
|
||||
if not confirmation:
|
||||
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
|
||||
|
||||
if confirmation.get('status') != 'pending':
|
||||
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
|
||||
|
||||
pending_confirmations_db.update({'status': 'rejected', 'approved_at': None}, pending_q.id == confirmation_id)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(id, confirmation.get('entity_id'), ChildRoutineConfirmation.OPERATION_REJECTED)
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({'message': 'Routine confirmation rejected.'}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/reset-routine/<confirmation_id>', methods=['POST'])
|
||||
def reset_routine(id, confirmation_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
pending_q = Query()
|
||||
confirmation = pending_confirmations_db.get(
|
||||
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||
)
|
||||
if not confirmation:
|
||||
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
|
||||
|
||||
routine_id = confirmation.get('entity_id')
|
||||
pending_confirmations_db.remove(pending_q.id == confirmation_id)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||
)
|
||||
)
|
||||
return jsonify({'message': 'Routine reset to available.'}), 200
|
||||
|
||||
|
||||
@child_routine_api.route('/child/<id>/trigger-routine', methods=['POST'])
|
||||
def trigger_child_routine(id):
|
||||
"""Parent-triggered routine confirmation — directly awards points."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
routine_id = data.get('routine_id')
|
||||
if not routine_id:
|
||||
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||
|
||||
child = _validate_child_for_user(id, user_id)
|
||||
if not child:
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
if routine_id not in child.routines:
|
||||
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
|
||||
|
||||
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||
if not routine:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
# Check for override
|
||||
override = get_override(id, routine_id)
|
||||
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
|
||||
|
||||
# Award points
|
||||
new_points = max(0, child.points + points_value)
|
||||
child_db.update({'points': new_points}, Query().id == id)
|
||||
|
||||
# Create an approved pending confirmation so it shows as completed in the routine list
|
||||
pending_q = Query()
|
||||
existing = pending_confirmations_db.get(
|
||||
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||
)
|
||||
if existing:
|
||||
today_local, tz_str = _get_user_today_local(user_id)
|
||||
# Remove old confirmation if it exists and is not from today
|
||||
if existing.get('status') == 'approved' and _is_iso_timestamp_on_local_day(existing.get('approved_at'), today_local, tz_str):
|
||||
return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400
|
||||
pending_id = existing.get('id')
|
||||
if pending_id:
|
||||
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||
|
||||
confirmation = PendingConfirmation(
|
||||
child_id=id,
|
||||
entity_id=routine_id,
|
||||
entity_type='routine',
|
||||
user_id=user_id,
|
||||
status='approved',
|
||||
approved_at=datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
pending_confirmations_db.insert(confirmation.to_dict())
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'message': f'Routine {routine.name} awarded to {child.name}.',
|
||||
'points': new_points,
|
||||
'id': child.id,
|
||||
}), 200
|
||||
@@ -1,8 +1,8 @@
|
||||
class ChildTask:
|
||||
def __init__(self, name, is_good, points, image_id, id):
|
||||
def __init__(self, name, task_type, points, image_id, id):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.is_good = is_good
|
||||
self.type = task_type
|
||||
self.points = points
|
||||
self.image_id = image_id
|
||||
|
||||
@@ -10,7 +10,7 @@ class ChildTask:
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'is_good': self.is_good,
|
||||
'type': self.type,
|
||||
'points': self.points,
|
||||
'image_id': self.image_id
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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
|
||||
|
||||
chore_api = Blueprint('chore_api', __name__)
|
||||
|
||||
TASK_TYPE = 'chore'
|
||||
|
||||
|
||||
@chore_api.route('/chore/add', methods=['PUT'])
|
||||
def add_chore():
|
||||
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')
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None:
|
||||
return jsonify({'error': 'Name and points are required'}), 400
|
||||
task = Task(name=name, points=points, type=TASK_TYPE, 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'Chore {name} added.'}), 201
|
||||
|
||||
|
||||
@chore_api.route('/chore/<id>', methods=['GET'])
|
||||
def get_chore(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)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not result:
|
||||
return jsonify({'error': 'Chore not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
|
||||
@chore_api.route('/chore/list', methods=['GET'])
|
||||
def list_chores():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
tasks = task_db.search(
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
|
||||
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
|
||||
filtered_tasks.append(t)
|
||||
|
||||
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(filtered_tasks)
|
||||
return jsonify({'tasks': sorted_tasks}), 200
|
||||
|
||||
|
||||
@chore_api.route('/chore/<id>', methods=['DELETE'])
|
||||
def delete_chore(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) & (TaskQuery.type == TASK_TYPE))
|
||||
if not task:
|
||||
return jsonify({'error': 'Chore not found'}), 404
|
||||
if task.get('user_id') is None:
|
||||
import logging
|
||||
logging.warning(f"Forbidden delete attempt on system chore: id={id}, by user_id={user_id}")
|
||||
return jsonify({'error': 'System chores cannot be deleted.'}), 403
|
||||
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
|
||||
if removed:
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for chore {id}")
|
||||
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'Chore {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Chore not found'}), 404
|
||||
|
||||
|
||||
@chore_api.route('/chore/<id>/edit', methods=['PUT'])
|
||||
def edit_chore(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)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Chore not found'}), 404
|
||||
|
||||
task = Task.from_dict(existing)
|
||||
is_dirty = False
|
||||
data = request.get_json(force=True) or {}
|
||||
|
||||
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) or points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
task.points = points
|
||||
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:
|
||||
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, 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
|
||||
@@ -0,0 +1,179 @@
|
||||
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, pending_confirmations_db
|
||||
from db.chore_schedules import get_schedule, upsert_schedule, delete_schedule
|
||||
from db.task_extensions import get_extension, add_extension, delete_extension_for_child_task
|
||||
from models.chore_schedule import ChoreSchedule
|
||||
from models.task_extension import TaskExtension
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.chore_schedule_modified import ChoreScheduleModified
|
||||
from events.types.chore_time_extended import ChoreTimeExtended
|
||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||
import logging
|
||||
|
||||
chore_schedule_api = Blueprint('chore_schedule_api', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_child(child_id: str, user_id: str):
|
||||
"""Return child dict if found and owned by user, else None."""
|
||||
ChildQuery = Query()
|
||||
result = child_db.search((ChildQuery.id == child_id) & (ChildQuery.user_id == user_id))
|
||||
return result[0] if result else None
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['GET'])
|
||||
def get_chore_schedule(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
schedule = get_schedule(child_id, task_id)
|
||||
if not schedule:
|
||||
return jsonify({'error': 'Schedule not found'}), 404
|
||||
|
||||
return jsonify(schedule.to_dict()), 200
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['PUT'])
|
||||
def set_chore_schedule(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
mode = data.get('mode')
|
||||
if mode not in ('days', 'interval'):
|
||||
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
enabled = data.get('enabled', True)
|
||||
if not isinstance(enabled, bool):
|
||||
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
if mode == 'days':
|
||||
day_configs = data.get('day_configs', [])
|
||||
if not isinstance(day_configs, list):
|
||||
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
default_hour = data.get('default_hour', 8)
|
||||
default_minute = data.get('default_minute', 0)
|
||||
default_has_deadline = data.get('default_has_deadline', True)
|
||||
schedule = ChoreSchedule(
|
||||
child_id=child_id,
|
||||
task_id=task_id,
|
||||
mode='days',
|
||||
day_configs=day_configs,
|
||||
default_hour=default_hour,
|
||||
default_minute=default_minute,
|
||||
default_has_deadline=default_has_deadline,
|
||||
enabled=enabled,
|
||||
)
|
||||
else:
|
||||
interval_days = data.get('interval_days', 2)
|
||||
anchor_date = data.get('anchor_date', '')
|
||||
interval_has_deadline = data.get('interval_has_deadline', True)
|
||||
interval_hour = data.get('interval_hour', 0)
|
||||
interval_minute = data.get('interval_minute', 0)
|
||||
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
|
||||
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
if not isinstance(anchor_date, str):
|
||||
return jsonify({'error': 'anchor_date must be a string', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
if not isinstance(interval_has_deadline, bool):
|
||||
return jsonify({'error': 'interval_has_deadline must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
schedule = ChoreSchedule(
|
||||
child_id=child_id,
|
||||
task_id=task_id,
|
||||
mode='interval',
|
||||
interval_days=interval_days,
|
||||
anchor_date=anchor_date,
|
||||
interval_has_deadline=interval_has_deadline,
|
||||
interval_hour=interval_hour,
|
||||
interval_minute=interval_minute,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
delete_extension_for_child_task(child_id, task_id)
|
||||
upsert_schedule(schedule)
|
||||
|
||||
# Reset pending chore confirmations when schedule changes (completed chores stay)
|
||||
PendingQuery = Query()
|
||||
pending_chores = pending_confirmations_db.search(
|
||||
(PendingQuery.child_id == child_id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
|
||||
)
|
||||
for pc in pending_chores:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == child_id) & (PendingQuery.entity_id == task_id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
|
||||
)
|
||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
EventType.CHORE_SCHEDULE_MODIFIED.value,
|
||||
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_SET)
|
||||
))
|
||||
|
||||
return jsonify(schedule.to_dict()), 200
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/schedule', methods=['DELETE'])
|
||||
def delete_chore_schedule(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
removed = delete_schedule(child_id, task_id)
|
||||
if not removed:
|
||||
return jsonify({'error': 'Schedule not found'}), 404
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
EventType.CHORE_SCHEDULE_MODIFIED.value,
|
||||
ChoreScheduleModified(child_id, task_id, ChoreScheduleModified.OPERATION_DELETED)
|
||||
))
|
||||
|
||||
return jsonify({'message': 'Schedule deleted'}), 200
|
||||
|
||||
|
||||
@chore_schedule_api.route('/child/<child_id>/task/<task_id>/extend', methods=['POST'])
|
||||
def extend_chore_time(child_id, task_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
date = data.get('date')
|
||||
if not date or not isinstance(date, str):
|
||||
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||
|
||||
# 409 if already extended for this exact date
|
||||
existing = get_extension(child_id, task_id, date)
|
||||
if existing:
|
||||
return jsonify({'error': 'Chore already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
|
||||
|
||||
# Clear any prior extension for this child+task before inserting the new one
|
||||
# so stale records from previous dates do not accumulate.
|
||||
delete_extension_for_child_task(child_id, task_id)
|
||||
extension = TaskExtension(child_id=child_id, task_id=task_id, date=date)
|
||||
add_extension(extension)
|
||||
|
||||
send_event_for_current_user(Event(
|
||||
EventType.CHORE_TIME_EXTENDED.value,
|
||||
ChoreTimeExtended(child_id, task_id)
|
||||
))
|
||||
|
||||
return jsonify(extension.to_dict()), 200
|
||||
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, redirect, make_response, jsonify, request
|
||||
from tinydb import Query
|
||||
|
||||
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token, peek_token
|
||||
from db.db import users_db
|
||||
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward, approve_routine, reject_routine
|
||||
from api.utils import get_validated_user_id
|
||||
|
||||
digest_action_api = Blueprint('digest_action_api', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ERROR_HTML = """<!DOCTYPE html>
|
||||
<html><head><title>Link Error</title></head>
|
||||
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
|
||||
<h2>This link is invalid or has expired.</h2>
|
||||
<p>Action links expire after 24 hours and can only be used once.</p>
|
||||
</body></html>"""
|
||||
|
||||
_UNSUB_HTML = """<!DOCTYPE html>
|
||||
<html><head><title>Unsubscribed</title></head>
|
||||
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
|
||||
<h2>You have been unsubscribed from daily digest emails.</h2>
|
||||
<p>To re-enable, visit your profile in the app.</p>
|
||||
</body></html>"""
|
||||
|
||||
_UNSUB_ERROR_HTML = """<!DOCTYPE html>
|
||||
<html><head><title>Link Error</title></head>
|
||||
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
|
||||
<h2>This unsubscribe link is invalid or has expired.</h2>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
@digest_action_api.route('/digest-action/<token_id>', methods=['GET'])
|
||||
def handle_digest_action(token_id: str):
|
||||
"""
|
||||
Validate a digest action token (without consuming it) and redirect to the
|
||||
frontend ParentView with the token embedded so the action executes only
|
||||
after the user authenticates as a parent.
|
||||
"""
|
||||
from flask import current_app
|
||||
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
||||
|
||||
token = peek_token(token_id)
|
||||
if not token:
|
||||
return make_response(_ERROR_HTML, 400)
|
||||
|
||||
deep_link = (
|
||||
f"{frontend_url}/parent/{token.child_id}"
|
||||
f"?digestToken={token_id}&scrollTo={token.entity_id}&entityType={token.entity_type}"
|
||||
)
|
||||
return redirect(deep_link, 302)
|
||||
|
||||
|
||||
@digest_action_api.route('/digest-action/<token_id>', methods=['POST'])
|
||||
def execute_digest_action(token_id: str):
|
||||
"""
|
||||
Execute a digest action. Requires the user to be authenticated (JWT cookie).
|
||||
Validates and consumes the token, then performs the approve/deny action.
|
||||
"""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
token = validate_and_consume_token(token_id)
|
||||
if not token:
|
||||
return jsonify({'error': 'This link is invalid or has expired.', 'code': 'INVALID_TOKEN'}), 400
|
||||
|
||||
if token.user_id != user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 403
|
||||
|
||||
try:
|
||||
if token.entity_type == 'chore' and token.action == 'approve':
|
||||
approve_chore(user_id, token.child_id, token.entity_id)
|
||||
elif token.entity_type == 'chore' and token.action == 'deny':
|
||||
reject_chore(user_id, token.child_id, token.entity_id)
|
||||
elif token.entity_type == 'reward' and token.action == 'approve':
|
||||
approve_reward_request(user_id, token.child_id, token.entity_id)
|
||||
elif token.entity_type == 'reward' and token.action == 'deny':
|
||||
deny_reward(user_id, token.child_id, token.entity_id)
|
||||
elif token.entity_type == 'routine' and token.action == 'approve':
|
||||
approve_routine(user_id, token.child_id, token.entity_id)
|
||||
elif token.entity_type == 'routine' and token.action == 'deny':
|
||||
reject_routine(user_id, token.child_id, token.entity_id)
|
||||
else:
|
||||
return jsonify({'error': 'Unknown action', 'code': 'INVALID_ACTION'}), 400
|
||||
except Exception as e:
|
||||
logger.error(f'Error executing digest action {token.action}/{token.entity_type}: {e}')
|
||||
return jsonify({'error': 'Failed to execute action', 'code': 'ACTION_FAILED'}), 400
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'child_id': token.child_id,
|
||||
'entity_id': token.entity_id,
|
||||
'entity_type': token.entity_type,
|
||||
'action': token.action,
|
||||
}), 200
|
||||
|
||||
|
||||
@digest_action_api.route('/digest-unsubscribe/<token>', methods=['GET'])
|
||||
def handle_digest_unsubscribe(token: str):
|
||||
user_id = validate_unsubscribe_token(token)
|
||||
if not user_id:
|
||||
return make_response(_UNSUB_ERROR_HTML, 400)
|
||||
|
||||
UserQ = Query()
|
||||
users_db.update({'email_digest_enabled': False}, UserQ.id == user_id)
|
||||
logger.info(f'User {user_id} unsubscribed from digest via email link')
|
||||
return make_response(_UNSUB_HTML, 200)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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"
|
||||
REFRESH_TOKEN_REUSE = "REFRESH_TOKEN_REUSE"
|
||||
REFRESH_TOKEN_EXPIRED = "REFRESH_TOKEN_EXPIRED"
|
||||
MISSING_REFRESH_TOKEN = "MISSING_REFRESH_TOKEN"
|
||||
|
||||
|
||||
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"
|
||||
CHORE_EXPIRED = "CHORE_EXPIRED"
|
||||
CHORE_ALREADY_PENDING = "CHORE_ALREADY_PENDING"
|
||||
CHORE_ALREADY_COMPLETED = "CHORE_ALREADY_COMPLETED"
|
||||
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
|
||||
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
|
||||
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"
|
||||
DUPLICATE_REWARD_REQUEST = "DUPLICATE_REWARD_REQUEST"
|
||||
@@ -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
|
||||
@@ -0,0 +1,165 @@
|
||||
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
|
||||
|
||||
kindness_api = Blueprint('kindness_api', __name__)
|
||||
|
||||
TASK_TYPE = 'kindness'
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/add', methods=['PUT'])
|
||||
def add_kindness():
|
||||
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')
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None:
|
||||
return jsonify({'error': 'Name and points are required'}), 400
|
||||
task = Task(name=name, points=points, type=TASK_TYPE, 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'Kindness {name} added.'}), 201
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/<id>', methods=['GET'])
|
||||
def get_kindness(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)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not result:
|
||||
return jsonify({'error': 'Kindness act not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/list', methods=['GET'])
|
||||
def list_kindness():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
tasks = task_db.search(
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
|
||||
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
|
||||
filtered_tasks.append(t)
|
||||
|
||||
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(filtered_tasks)
|
||||
return jsonify({'tasks': sorted_tasks}), 200
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/<id>', methods=['DELETE'])
|
||||
def delete_kindness(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) & (TaskQuery.type == TASK_TYPE))
|
||||
if not task:
|
||||
return jsonify({'error': 'Kindness act not found'}), 404
|
||||
if task.get('user_id') is None:
|
||||
import logging
|
||||
logging.warning(f"Forbidden delete attempt on system kindness: id={id}, by user_id={user_id}")
|
||||
return jsonify({'error': 'System kindness acts cannot be deleted.'}), 403
|
||||
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
|
||||
if removed:
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for kindness {id}")
|
||||
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'Kindness {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Kindness act not found'}), 404
|
||||
|
||||
|
||||
@kindness_api.route('/kindness/<id>/edit', methods=['PUT'])
|
||||
def edit_kindness(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)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Kindness act not found'}), 404
|
||||
|
||||
task = Task.from_dict(existing)
|
||||
is_dirty = False
|
||||
data = request.get_json(force=True) or {}
|
||||
|
||||
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) or points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
task.points = points
|
||||
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:
|
||||
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, 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
|
||||
@@ -0,0 +1,165 @@
|
||||
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
|
||||
|
||||
penalty_api = Blueprint('penalty_api', __name__)
|
||||
|
||||
TASK_TYPE = 'penalty'
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/add', methods=['PUT'])
|
||||
def add_penalty():
|
||||
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')
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None:
|
||||
return jsonify({'error': 'Name and points are required'}), 400
|
||||
task = Task(name=name, points=points, type=TASK_TYPE, 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'Penalty {name} added.'}), 201
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/<id>', methods=['GET'])
|
||||
def get_penalty(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)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not result:
|
||||
return jsonify({'error': 'Penalty not found'}), 404
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/list', methods=['GET'])
|
||||
def list_penalties():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
TaskQuery = Query()
|
||||
tasks = task_db.search(
|
||||
((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
|
||||
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
|
||||
filtered_tasks.append(t)
|
||||
|
||||
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(filtered_tasks)
|
||||
return jsonify({'tasks': sorted_tasks}), 200
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/<id>', methods=['DELETE'])
|
||||
def delete_penalty(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) & (TaskQuery.type == TASK_TYPE))
|
||||
if not task:
|
||||
return jsonify({'error': 'Penalty not found'}), 404
|
||||
if task.get('user_id') is None:
|
||||
import logging
|
||||
logging.warning(f"Forbidden delete attempt on system penalty: id={id}, by user_id={user_id}")
|
||||
return jsonify({'error': 'System penalties cannot be deleted.'}), 403
|
||||
removed = task_db.remove((TaskQuery.id == id) & (TaskQuery.user_id == user_id))
|
||||
if removed:
|
||||
deleted_count = delete_overrides_for_entity(id)
|
||||
if deleted_count > 0:
|
||||
import logging
|
||||
logging.info(f"Cascade deleted {deleted_count} overrides for penalty {id}")
|
||||
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'Penalty {id} deleted.'}), 200
|
||||
return jsonify({'error': 'Penalty not found'}), 404
|
||||
|
||||
|
||||
@penalty_api.route('/penalty/<id>/edit', methods=['PUT'])
|
||||
def edit_penalty(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)) &
|
||||
(TaskQuery.type == TASK_TYPE)
|
||||
)
|
||||
if not existing:
|
||||
return jsonify({'error': 'Penalty not found'}), 404
|
||||
|
||||
task = Task.from_dict(existing)
|
||||
is_dirty = False
|
||||
data = request.get_json(force=True) or {}
|
||||
|
||||
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) or points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
task.points = points
|
||||
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:
|
||||
new_task = Task(name=task.name, points=task.points, type=TASK_TYPE, 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
|
||||
@@ -0,0 +1,29 @@
|
||||
class PendingConfirmationResponse:
|
||||
"""Response DTO for hydrated pending confirmation data."""
|
||||
def __init__(self, _id, child_id, child_name, child_image_id,
|
||||
entity_id, entity_type, entity_name, entity_image_id,
|
||||
status='pending', approved_at=None):
|
||||
self.id = _id
|
||||
self.child_id = child_id
|
||||
self.child_name = child_name
|
||||
self.child_image_id = child_image_id
|
||||
self.entity_id = entity_id
|
||||
self.entity_type = entity_type
|
||||
self.entity_name = entity_name
|
||||
self.entity_image_id = entity_image_id
|
||||
self.status = status
|
||||
self.approved_at = approved_at
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'child_id': self.child_id,
|
||||
'child_name': self.child_name,
|
||||
'child_image_id': self.child_image_id,
|
||||
'entity_id': self.entity_id,
|
||||
'entity_type': self.entity_type,
|
||||
'entity_name': self.entity_name,
|
||||
'entity_image_id': self.entity_image_id,
|
||||
'status': self.status,
|
||||
'approved_at': self.approved_at
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import get_validated_user_id
|
||||
from db.push_subscriptions import upsert_subscription, delete_by_endpoint
|
||||
from db.db import users_db
|
||||
|
||||
push_subscription_api = Blueprint('push_subscription_api', __name__)
|
||||
|
||||
|
||||
@push_subscription_api.route('/push-vapid-key', methods=['GET'])
|
||||
def get_vapid_public_key():
|
||||
"""Return the VAPID public key for the frontend to use when subscribing."""
|
||||
public_key = current_app.config.get('VAPID_PUBLIC_KEY', '')
|
||||
return jsonify({'public_key': public_key}), 200
|
||||
|
||||
|
||||
@push_subscription_api.route('/push-subscription', methods=['POST'])
|
||||
def subscribe():
|
||||
"""Upsert a push subscription for the current user."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
endpoint = data.get('endpoint')
|
||||
keys = data.get('keys')
|
||||
timezone_str = data.get('timezone')
|
||||
|
||||
if not endpoint or not isinstance(keys, dict):
|
||||
return jsonify({'error': 'endpoint and keys are required', 'code': 'MISSING_FIELDS'}), 400
|
||||
if 'p256dh' not in keys or 'auth' not in keys:
|
||||
return jsonify({'error': 'keys must contain p256dh and auth', 'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
sub = upsert_subscription(user_id=user_id, endpoint=endpoint, keys=keys)
|
||||
|
||||
# Update user timezone if provided
|
||||
if timezone_str:
|
||||
UserQ = Query()
|
||||
users_db.update({'timezone': timezone_str}, UserQ.id == user_id)
|
||||
|
||||
return jsonify({'message': 'Subscription saved', 'id': sub.id}), 200
|
||||
|
||||
|
||||
@push_subscription_api.route('/push-subscription', methods=['DELETE'])
|
||||
def unsubscribe():
|
||||
"""Remove a push subscription for the current user by endpoint."""
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
endpoint = data.get('endpoint')
|
||||
|
||||
if not endpoint:
|
||||
return jsonify({'error': 'endpoint is required', 'code': 'MISSING_FIELDS'}), 400
|
||||
|
||||
removed = delete_by_endpoint(user_id=user_id, endpoint=endpoint)
|
||||
return jsonify({'message': 'Subscription removed', 'removed': removed}), 200
|
||||
@@ -0,0 +1,184 @@
|
||||
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, pending_confirmations_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 events.types.child_reward_request import ChildRewardRequest
|
||||
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:
|
||||
# allow empty description (same behavior as add_reward)
|
||||
# note: front-end often submits an empty string, so don't block edits
|
||||
reward.description = data.get('description') or ''
|
||||
is_dirty = True
|
||||
|
||||
if 'cost' in data:
|
||||
cost = data.get('cost')
|
||||
# allow numeric strings as well
|
||||
if isinstance(cost, str):
|
||||
try:
|
||||
cost = int(cost)
|
||||
except ValueError:
|
||||
return jsonify({'error': 'Cost must be an integer'}), 400
|
||||
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
|
||||
|
||||
# Reset pending reward requests when cost changes
|
||||
if 'cost' in data:
|
||||
PendingQuery = Query()
|
||||
pending_rewards = pending_confirmations_db.search(
|
||||
(PendingQuery.entity_id == id) & (PendingQuery.entity_type == 'reward') &
|
||||
(PendingQuery.status == 'pending')
|
||||
)
|
||||
for pr in pending_rewards:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == pr['child_id']) & (PendingQuery.entity_id == id) &
|
||||
(PendingQuery.entity_type == 'reward') & (PendingQuery.status == 'pending')
|
||||
)
|
||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||
ChildRewardRequest(pr['child_id'], id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||
|
||||
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
|
||||
@@ -0,0 +1,185 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||
from db.db import routine_db, child_db, pending_confirmations_db
|
||||
from db.child_overrides import delete_overrides_for_entity
|
||||
from db.routine_items import delete_for_routine
|
||||
from db.routine_schedules import delete_schedules_for_routine
|
||||
from db.routine_extensions import delete_extensions_for_routine
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.routine_modified import RoutineModified
|
||||
from events.types.child_routines_set import ChildRoutinesSet
|
||||
from models.routine import Routine
|
||||
|
||||
routine_api = Blueprint('routine_api', __name__)
|
||||
|
||||
|
||||
@routine_api.route('/routine/add', methods=['PUT'])
|
||||
def add_routine():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = data.get('name')
|
||||
points = data.get('points')
|
||||
image = data.get('image_id', '')
|
||||
|
||||
if not name or points is None:
|
||||
return jsonify({'error': 'Name and points are required'}), 400
|
||||
|
||||
routine = Routine(name=name, points=points, image_id=image, user_id=user_id)
|
||||
routine_db.insert(routine.to_dict())
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine.id, RoutineModified.OPERATION_ADD))
|
||||
)
|
||||
return jsonify({'message': f'Routine {name} added.', 'routine': routine.to_dict()}), 201
|
||||
|
||||
|
||||
@routine_api.route('/routine/<id>', methods=['GET'])
|
||||
def get_routine(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
q = Query()
|
||||
result = routine_db.search((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
|
||||
if not result:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
return jsonify(result[0]), 200
|
||||
|
||||
|
||||
@routine_api.route('/routine/list', methods=['GET'])
|
||||
def list_routines():
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
ids_param = request.args.get('ids')
|
||||
q = Query()
|
||||
routines = routine_db.search((q.user_id == user_id) | (q.user_id == None))
|
||||
|
||||
if ids_param is not None:
|
||||
if ids_param.strip() == '':
|
||||
routines = []
|
||||
else:
|
||||
ids = set(ids_param.split(','))
|
||||
routines = [routine for routine in routines if routine.get('id') in ids]
|
||||
|
||||
user_routines = {r['name'].strip().lower(): r for r in routines if r.get('user_id') == user_id}
|
||||
filtered_routines = []
|
||||
for routine in routines:
|
||||
if routine.get('user_id') is None and routine['name'].strip().lower() in user_routines:
|
||||
continue
|
||||
filtered_routines.append(routine)
|
||||
|
||||
user_created = sorted(
|
||||
[r for r in filtered_routines if r.get('user_id') == user_id],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
default_items = sorted(
|
||||
[r for r in filtered_routines if r.get('user_id') is None],
|
||||
key=lambda x: x['name'].lower(),
|
||||
)
|
||||
|
||||
return jsonify({'routines': user_created + default_items}), 200
|
||||
|
||||
|
||||
@routine_api.route('/routine/<id>/edit', methods=['PUT'])
|
||||
def edit_routine(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
q = Query()
|
||||
existing = routine_db.get((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
|
||||
if not existing:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
routine = Routine.from_dict(existing)
|
||||
data = request.get_json(force=True) or {}
|
||||
is_dirty = False
|
||||
|
||||
if 'name' in data:
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||
routine.name = name
|
||||
is_dirty = True
|
||||
|
||||
if 'points' in data:
|
||||
points = data.get('points')
|
||||
if not isinstance(points, int) or points <= 0:
|
||||
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||
routine.points = points
|
||||
is_dirty = True
|
||||
|
||||
if 'image_id' in data:
|
||||
routine.image_id = data.get('image_id', '')
|
||||
is_dirty = True
|
||||
|
||||
if not is_dirty:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
if routine.user_id is None:
|
||||
new_routine = Routine(name=routine.name, points=routine.points, image_id=routine.image_id, user_id=user_id)
|
||||
routine_db.insert(new_routine.to_dict())
|
||||
send_event_for_current_user(
|
||||
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(new_routine.id, RoutineModified.OPERATION_ADD))
|
||||
)
|
||||
return jsonify(new_routine.to_dict()), 200
|
||||
|
||||
routine_db.update(routine.to_dict(), q.id == id)
|
||||
send_event_for_current_user(
|
||||
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_EDIT))
|
||||
)
|
||||
return jsonify(routine.to_dict()), 200
|
||||
|
||||
|
||||
@routine_api.route('/routine/<id>', methods=['DELETE'])
|
||||
def delete_routine(id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
q = Query()
|
||||
routine = routine_db.get(q.id == id)
|
||||
if not routine:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
if routine.get('user_id') is None:
|
||||
return jsonify({'error': 'System routines cannot be deleted.'}), 403
|
||||
|
||||
removed = routine_db.remove((q.id == id) & (q.user_id == user_id))
|
||||
if not removed:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
delete_overrides_for_entity(id)
|
||||
delete_for_routine(id)
|
||||
delete_schedules_for_routine(id)
|
||||
delete_extensions_for_routine(id)
|
||||
|
||||
pending_q = Query()
|
||||
pending_confirmations_db.remove(
|
||||
(pending_q.entity_id == id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||
)
|
||||
|
||||
child_q = Query()
|
||||
children = child_db.search(child_q.user_id == user_id)
|
||||
for child in children:
|
||||
routine_ids = child.get('routines', [])
|
||||
if id in routine_ids:
|
||||
routine_ids = [rid for rid in routine_ids if rid != id]
|
||||
child_db.update({'routines': routine_ids}, child_q.id == child.get('id'))
|
||||
send_event_for_current_user(
|
||||
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(child.get('id'), routine_ids))
|
||||
)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_DELETE))
|
||||
)
|
||||
return jsonify({'message': f'Routine {id} deleted.'}), 200
|
||||
@@ -0,0 +1,126 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||
from db.db import routine_db
|
||||
from db.routine_items import add_item, delete_item, get_item, get_items_for_routine, update_item
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.routine_modified import RoutineModified
|
||||
from models.routine_item import RoutineItem
|
||||
|
||||
routine_item_api = Blueprint('routine_item_api', __name__)
|
||||
|
||||
|
||||
def _validate_routine_owned_by_user(routine_id: str, user_id: str):
|
||||
q = Query()
|
||||
return routine_db.get((q.id == routine_id) & ((q.user_id == user_id) | (q.user_id == None)))
|
||||
|
||||
|
||||
@routine_item_api.route('/routine/<routine_id>/item/add', methods=['PUT'])
|
||||
def add_routine_item(routine_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||
if not routine:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = data.get('name', '').strip()
|
||||
image_id = data.get('image_id')
|
||||
|
||||
if not name:
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
|
||||
existing_items = get_items_for_routine(routine_id)
|
||||
order = data.get('order', len(existing_items))
|
||||
|
||||
item = RoutineItem(routine_id=routine_id, name=name, image_id=image_id, order=order)
|
||||
add_item(item)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
|
||||
)
|
||||
return jsonify(item.to_dict()), 201
|
||||
|
||||
|
||||
@routine_item_api.route('/routine/<routine_id>/item/<item_id>/edit', methods=['PUT'])
|
||||
def edit_routine_item(routine_id, item_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||
if not routine:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
existing = get_item(item_id)
|
||||
if not existing or existing.routine_id != routine_id:
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
data = request.get_json(force=True) or {}
|
||||
is_dirty = False
|
||||
|
||||
if 'name' in data:
|
||||
name = data.get('name', '').strip()
|
||||
if not name:
|
||||
return jsonify({'error': 'name cannot be empty'}), 400
|
||||
existing.name = name
|
||||
is_dirty = True
|
||||
|
||||
if 'image_id' in data:
|
||||
existing.image_id = data.get('image_id')
|
||||
is_dirty = True
|
||||
|
||||
if 'order' in data:
|
||||
order = data.get('order')
|
||||
if not isinstance(order, int) or order < 0:
|
||||
return jsonify({'error': 'order must be a non-negative integer'}), 400
|
||||
existing.order = order
|
||||
is_dirty = True
|
||||
|
||||
if not is_dirty:
|
||||
return jsonify({'error': 'No valid fields to update'}), 400
|
||||
|
||||
update_item(existing)
|
||||
send_event_for_current_user(
|
||||
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
|
||||
)
|
||||
return jsonify(existing.to_dict()), 200
|
||||
|
||||
|
||||
@routine_item_api.route('/routine/<routine_id>/item/<item_id>', methods=['DELETE'])
|
||||
def delete_routine_item(routine_id, item_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||
if not routine:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
item = get_item(item_id)
|
||||
if not item or item.routine_id != routine_id:
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
delete_item(item_id)
|
||||
send_event_for_current_user(
|
||||
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
|
||||
)
|
||||
return jsonify({'message': 'Item deleted'}), 200
|
||||
|
||||
|
||||
@routine_item_api.route('/routine/<routine_id>/items', methods=['GET'])
|
||||
def list_routine_items(routine_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||
|
||||
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||
if not routine:
|
||||
return jsonify({'error': 'Routine not found'}), 404
|
||||
|
||||
items = [item.to_dict() for item in get_items_for_routine(routine_id)]
|
||||
return jsonify({'items': items, 'count': len(items)}), 200
|
||||
@@ -0,0 +1,178 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from tinydb import Query
|
||||
|
||||
from api.error_codes import ErrorCodes
|
||||
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||
from db.db import child_db, pending_confirmations_db
|
||||
from db.routine_extensions import add_extension, delete_extension_for_child_routine, get_extension
|
||||
from db.routine_schedules import delete_schedule, get_schedule, upsert_schedule
|
||||
from events.types.child_routine_confirmation import ChildRoutineConfirmation
|
||||
from events.types.event import Event
|
||||
from events.types.event_types import EventType
|
||||
from events.types.routine_schedule_modified import RoutineScheduleModified
|
||||
from events.types.routine_time_extended import RoutineTimeExtended
|
||||
from models.routine_extension import RoutineExtension
|
||||
from models.routine_schedule import RoutineSchedule
|
||||
|
||||
routine_schedule_api = Blueprint('routine_schedule_api', __name__)
|
||||
|
||||
|
||||
def _validate_child(child_id: str, user_id: str):
|
||||
q = Query()
|
||||
result = child_db.search((q.id == child_id) & (q.user_id == user_id))
|
||||
return result[0] if result else None
|
||||
|
||||
|
||||
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['GET'])
|
||||
def get_routine_schedule(child_id, routine_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
schedule = get_schedule(child_id, routine_id)
|
||||
if not schedule:
|
||||
return jsonify({'error': 'Schedule not found'}), 404
|
||||
|
||||
return jsonify(schedule.to_dict()), 200
|
||||
|
||||
|
||||
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['PUT'])
|
||||
def set_routine_schedule(child_id, routine_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
mode = data.get('mode')
|
||||
if mode not in ('days', 'interval'):
|
||||
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
enabled = data.get('enabled', True)
|
||||
if not isinstance(enabled, bool):
|
||||
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
if mode == 'days':
|
||||
day_configs = data.get('day_configs', [])
|
||||
if not isinstance(day_configs, list):
|
||||
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
schedule = RoutineSchedule(
|
||||
child_id=child_id,
|
||||
routine_id=routine_id,
|
||||
mode='days',
|
||||
day_configs=day_configs,
|
||||
default_hour=data.get('default_hour', 8),
|
||||
default_minute=data.get('default_minute', 0),
|
||||
default_has_deadline=data.get('default_has_deadline', True),
|
||||
enabled=enabled,
|
||||
)
|
||||
else:
|
||||
interval_days = data.get('interval_days', 2)
|
||||
anchor_date = data.get('anchor_date', '')
|
||||
interval_has_deadline = data.get('interval_has_deadline', True)
|
||||
interval_hour = data.get('interval_hour', 0)
|
||||
interval_minute = data.get('interval_minute', 0)
|
||||
|
||||
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
|
||||
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||
|
||||
schedule = RoutineSchedule(
|
||||
child_id=child_id,
|
||||
routine_id=routine_id,
|
||||
mode='interval',
|
||||
interval_days=interval_days,
|
||||
anchor_date=anchor_date,
|
||||
interval_has_deadline=interval_has_deadline,
|
||||
interval_hour=interval_hour,
|
||||
interval_minute=interval_minute,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
delete_extension_for_child_routine(child_id, routine_id)
|
||||
upsert_schedule(schedule)
|
||||
|
||||
pending_q = Query()
|
||||
pending_routines = pending_confirmations_db.search(
|
||||
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
|
||||
)
|
||||
for _ in pending_routines:
|
||||
pending_confirmations_db.remove(
|
||||
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
|
||||
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
|
||||
)
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||
)
|
||||
)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
|
||||
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_SET)
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify(schedule.to_dict()), 200
|
||||
|
||||
|
||||
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['DELETE'])
|
||||
def delete_routine_schedule(child_id, routine_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
removed = delete_schedule(child_id, routine_id)
|
||||
if not removed:
|
||||
return jsonify({'error': 'Schedule not found'}), 404
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
|
||||
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_DELETED)
|
||||
)
|
||||
)
|
||||
return jsonify({'message': 'Schedule deleted'}), 200
|
||||
|
||||
|
||||
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/extend', methods=['POST'])
|
||||
def extend_routine_time(child_id, routine_id):
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||
|
||||
if not _validate_child(child_id, user_id):
|
||||
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||
|
||||
data = request.get_json() or {}
|
||||
date = data.get('date')
|
||||
if not date or not isinstance(date, str):
|
||||
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||
|
||||
existing = get_extension(child_id, routine_id, date)
|
||||
if existing:
|
||||
return jsonify({'error': 'Routine already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
|
||||
|
||||
delete_extension_for_child_routine(child_id, routine_id)
|
||||
extension = RoutineExtension(child_id=child_id, routine_id=routine_id, date=date)
|
||||
add_extension(extension)
|
||||
|
||||
send_event_for_current_user(
|
||||
Event(
|
||||
EventType.ROUTINE_TIME_EXTENDED.value,
|
||||
RoutineTimeExtended(child_id, routine_id)
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify(extension.to_dict()), 200
|
||||
@@ -0,0 +1,208 @@
|
||||
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, pending_confirmations_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 events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||
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')
|
||||
task_type = data.get('type')
|
||||
# Support legacy is_good field
|
||||
if task_type is None and 'is_good' in data:
|
||||
task_type = 'chore' if data['is_good'] else 'penalty'
|
||||
image = data.get('image_id', '')
|
||||
if not name or points is None or task_type is None:
|
||||
return jsonify({'error': 'Name, points, and type are required'}), 400
|
||||
if task_type not in ['chore', 'kindness', 'penalty']:
|
||||
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
|
||||
task = Task(name=name, points=points, type=task_type, 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) chore/kindness first, then penalties
|
||||
# 2) within each group: user-created items first (by name), then default items (by name)
|
||||
good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type != 'penalty']
|
||||
not_good_tasks = [t for t in filtered_tasks if Task.from_dict(t).type == 'penalty']
|
||||
|
||||
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
|
||||
# Convert to type
|
||||
task.type = 'chore' if is_good else 'penalty'
|
||||
is_dirty = True
|
||||
|
||||
if 'type' in data:
|
||||
task_type = data.get('type')
|
||||
if task_type not in ['chore', 'kindness', 'penalty']:
|
||||
return jsonify({'error': 'type must be chore, kindness, or penalty'}), 400
|
||||
task.type = task_type
|
||||
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
|
||||
|
||||
# Reset pending chore confirmations when task is modified (points, type, etc.)
|
||||
# Completed (approved) chores stay in completed state
|
||||
if 'points' in data or 'type' in data:
|
||||
PendingQuery = Query()
|
||||
pending_chores = pending_confirmations_db.search(
|
||||
(PendingQuery.entity_id == id) & (PendingQuery.entity_type == 'chore') &
|
||||
(PendingQuery.status == 'pending')
|
||||
)
|
||||
for pc in pending_chores:
|
||||
pending_confirmations_db.remove(
|
||||
(PendingQuery.child_id == pc['child_id']) & (PendingQuery.entity_id == id) &
|
||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending')
|
||||
)
|
||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||
ChildChoreConfirmation(pc['child_id'], id, ChildChoreConfirmation.OPERATION_RESET)))
|
||||
|
||||
if task.user_id is None: # public task
|
||||
new_task = Task(name=task.name, points=task.points, type=task.type, 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
|
||||
@@ -0,0 +1,73 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from api.utils import get_validated_user_id, admin_required
|
||||
from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
|
||||
from models.tracking_event import TrackingEvent
|
||||
|
||||
|
||||
tracking_api = Blueprint('tracking_api', __name__)
|
||||
|
||||
|
||||
@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
|
||||
@@ -0,0 +1,311 @@
|
||||
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 events.sse import send_event_to_user
|
||||
from events.types.payload import Payload
|
||||
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('access_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,
|
||||
'email_digest_enabled': user.email_digest_enabled,
|
||||
'push_notifications_enabled': user.push_notifications_enabled,
|
||||
'tutorial_enabled': user.tutorial_enabled,
|
||||
'tutorial_progress': user.tutorial_progress or {},
|
||||
}), 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, email_digest_enabled, push_notifications_enabled to be updated
|
||||
first_name = data.get('first_name')
|
||||
last_name = data.get('last_name')
|
||||
image_id = data.get('image_id')
|
||||
email_digest_enabled = data.get('email_digest_enabled')
|
||||
push_notifications_enabled = data.get('push_notifications_enabled')
|
||||
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
|
||||
if email_digest_enabled is not None:
|
||||
user.email_digest_enabled = bool(email_digest_enabled)
|
||||
if push_notifications_enabled is not None:
|
||||
user.push_notifications_enabled = bool(push_notifications_enabled)
|
||||
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
|
||||
if email_digest_enabled is not None:
|
||||
metadata['email_digest_enabled_updated'] = True
|
||||
if push_notifications_enabled is not None:
|
||||
metadata['push_notifications_enabled_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/tutorial-progress', methods=['PATCH'])
|
||||
def update_tutorial_progress():
|
||||
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() or {}
|
||||
|
||||
if data.get('reset') is True:
|
||||
user.tutorial_progress = {}
|
||||
elif 'enabled' in data:
|
||||
user.tutorial_enabled = bool(data.get('enabled'))
|
||||
elif 'step_id' in data:
|
||||
step_id = str(data.get('step_id') or '').strip()
|
||||
if not step_id:
|
||||
return jsonify({'error': 'Missing step_id'}), 400
|
||||
progress = dict(user.tutorial_progress or {})
|
||||
progress[step_id] = bool(data.get('seen', True))
|
||||
user.tutorial_progress = progress
|
||||
else:
|
||||
return jsonify({'error': 'No-op'}), 400
|
||||
|
||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
|
||||
return jsonify({
|
||||
'tutorial_enabled': user.tutorial_enabled,
|
||||
'tutorial_progress': user.tutorial_progress,
|
||||
}), 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)))
|
||||
|
||||
# Notify all other active sessions to sign out and go to landing page
|
||||
send_event_to_user(user.id, Event(EventType.FORCE_LOGOUT.value, Payload({'reason': 'account_deleted'})))
|
||||
|
||||
return jsonify({'success': True}), 200
|
||||
|
||||
|
||||
@user_api.route('/user/e2e-get-pin-code', methods=['GET'])
|
||||
def e2e_get_pin_code():
|
||||
"""Return the current user's pin_setup_code for e2e testing. Never available in production."""
|
||||
import os
|
||||
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||
return jsonify({'error': 'Not available in production'}), 403
|
||||
user_id = get_validated_user_id()
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
return jsonify({'code': user.pin_setup_code or ''}), 200
|
||||
@@ -0,0 +1,97 @@
|
||||
import jwt
|
||||
import re
|
||||
from functools import wraps
|
||||
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
|
||||
from models.user import 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('access_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
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""
|
||||
Decorator to require admin role for endpoints.
|
||||
Validates JWT from access_token cookie and checks admin role.
|
||||
"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
token = request.cookies.get('access_token')
|
||||
if not token:
|
||||
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=['HS256'])
|
||||
user_id = payload.get('user_id')
|
||||
|
||||
if not user_id:
|
||||
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||
|
||||
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)
|
||||
|
||||
if user.role != 'admin':
|
||||
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||
|
||||
# Store user info in request context for the endpoint
|
||||
request.current_user = user
|
||||
request.admin_user_id = user_id
|
||||
|
||||
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
|
||||
@@ -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,39 @@ 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_images_dir() -> str:
|
||||
"""
|
||||
Return the absolute directory path for storing images.
|
||||
"""
|
||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images')
|
||||
|
||||
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.16" # update manually when releasing features
|
||||
|
||||
def get_full_version() -> str:
|
||||
"""
|
||||
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user