feat: Implement task and reward tracking feature
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 24s
All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 24s
- Added tracking events for tasks, penalties, and rewards with timestamps. - Created new TinyDB table for tracking records to maintain audit history. - Developed backend API for querying tracking events with filters and pagination. - Implemented logging for tracking events with per-user rotating log files. - Added unit tests for tracking event creation, querying, and anonymization. - Deferred frontend changes for future implementation. - Established acceptance criteria and documentation for the tracking feature. feat: Introduce account deletion scheduler - Implemented a scheduler to delete accounts marked for deletion after a configurable threshold. - Added new fields to the User model to manage deletion status and attempts. - Created admin API endpoints for managing deletion thresholds and viewing the deletion queue. - Integrated error handling and logging for the deletion process. - Developed unit tests for the deletion scheduler and related API endpoints. - Documented the deletion process and acceptance criteria.
This commit is contained in:
149
.github/specs/active/feat-dynamic-points/IMPLEMENTATION_SUMMARY.md
vendored
Normal file
149
.github/specs/active/feat-dynamic-points/IMPLEMENTATION_SUMMARY.md
vendored
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Tracking Feature Implementation Summary
|
||||||
|
|
||||||
|
## ✅ Implementation Complete
|
||||||
|
|
||||||
|
All acceptance criteria from [feat-tracking.md](.github/specs/active/feat-dynamic-points/feat-tracking.md) have been implemented and tested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 What Was Delivered
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. **Data Model** ([tracking_event.py](backend/models/tracking_event.py))
|
||||||
|
- `TrackingEvent` dataclass with full type safety
|
||||||
|
- Factory method `create_event()` for server-side timestamp generation
|
||||||
|
- Delta invariant validation (`delta == points_after - points_before`)
|
||||||
|
|
||||||
|
2. **Database Layer** ([tracking.py](backend/db/tracking.py))
|
||||||
|
- New TinyDB table: `tracking_events.json`
|
||||||
|
- Helper functions: `insert_tracking_event`, `get_tracking_events_by_child`, `get_tracking_events_by_user`, `anonymize_tracking_events_for_user`
|
||||||
|
- Offset-based pagination with sorting by `occurred_at` (desc)
|
||||||
|
|
||||||
|
3. **Audit Logging** ([tracking_logger.py](backend/utils/tracking_logger.py))
|
||||||
|
- Per-user rotating file handlers (`logs/tracking_user_<user_id>.log`)
|
||||||
|
- 10MB max file size, 5 backups
|
||||||
|
- Structured log format with all event metadata
|
||||||
|
|
||||||
|
4. **API Integration** ([child_api.py](backend/api/child_api.py))
|
||||||
|
- Tracking added to:
|
||||||
|
- `POST /child/<id>/trigger-task` → action: `activated`
|
||||||
|
- `POST /child/<id>/request-reward` → action: `requested`
|
||||||
|
- `POST /child/<id>/trigger-reward` → action: `redeemed`
|
||||||
|
- `POST /child/<id>/cancel-request-reward` → action: `cancelled`
|
||||||
|
|
||||||
|
5. **Admin API** ([tracking_api.py](backend/api/tracking_api.py))
|
||||||
|
- `GET /admin/tracking` with filters:
|
||||||
|
- `child_id` (required if no `user_id`)
|
||||||
|
- `user_id` (admin only)
|
||||||
|
- `entity_type` (task|reward|penalty)
|
||||||
|
- `action` (activated|requested|redeemed|cancelled)
|
||||||
|
- `limit` (default 50, max 500)
|
||||||
|
- `offset` (default 0)
|
||||||
|
- Returns total count for future pagination UI
|
||||||
|
|
||||||
|
6. **SSE Events** ([event_types.py](backend/events/types/event_types.py), [tracking_event_created.py](backend/events/types/tracking_event_created.py))
|
||||||
|
- New event type: `TRACKING_EVENT_CREATED`
|
||||||
|
- Payload: `tracking_event_id`, `child_id`, `entity_type`, `action`
|
||||||
|
- Emitted on every tracking event creation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. **TypeScript Models** ([models.ts](frontend/vue-app/src/common/models.ts))
|
||||||
|
- `TrackingEvent` interface (1:1 parity with Python)
|
||||||
|
- Type aliases: `EntityType`, `ActionType`
|
||||||
|
- `TrackingEventCreatedPayload` for SSE events
|
||||||
|
|
||||||
|
2. **API Helpers** ([api.ts](frontend/vue-app/src/common/api.ts))
|
||||||
|
- `getTrackingEventsForChild()` function with all filter params
|
||||||
|
|
||||||
|
3. **SSE Registration**
|
||||||
|
- Event type registered in type union
|
||||||
|
- Ready for future UI components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
**Backend Unit Tests** ([test_tracking.py](backend/tests/test_tracking.py)):
|
||||||
|
|
||||||
|
- ✅ Tracking event creation with factory method
|
||||||
|
- ✅ Delta invariant validation
|
||||||
|
- ✅ Insert and query tracking events
|
||||||
|
- ✅ Filtering by `entity_type` and `action`
|
||||||
|
- ✅ Offset-based pagination
|
||||||
|
- ✅ User anonymization on deletion
|
||||||
|
- ✅ Points change correctness (positive/negative/zero delta)
|
||||||
|
- ✅ No points change for request/cancel actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 Key Design Decisions
|
||||||
|
|
||||||
|
1. **Append-only tracking table** - No deletions, only anonymization on user deletion
|
||||||
|
2. **Server timestamps** - `occurred_at` always uses server time (UTC) to avoid client clock drift
|
||||||
|
3. **Separate logging** - Per-user audit logs independent of database
|
||||||
|
4. **Offset pagination** - Simpler than cursors, sufficient for expected scale
|
||||||
|
5. **No UI (yet)** - API/models/SSE only; UI deferred to future phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Usage Examples
|
||||||
|
|
||||||
|
### Backend: Create a tracking event
|
||||||
|
|
||||||
|
```python
|
||||||
|
from models.tracking_event import TrackingEvent
|
||||||
|
from db.tracking import insert_tracking_event
|
||||||
|
from utils.tracking_logger import log_tracking_event
|
||||||
|
|
||||||
|
event = TrackingEvent.create_event(
|
||||||
|
user_id='user123',
|
||||||
|
child_id='child456',
|
||||||
|
entity_type='task',
|
||||||
|
entity_id='task789',
|
||||||
|
action='activated',
|
||||||
|
points_before=50,
|
||||||
|
points_after=60,
|
||||||
|
metadata={'task_name': 'Homework'}
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_tracking_event(event)
|
||||||
|
log_tracking_event(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend: Query tracking events
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getTrackingEventsForChild } from "@/common/api";
|
||||||
|
|
||||||
|
const res = await getTrackingEventsForChild({
|
||||||
|
childId: "child456",
|
||||||
|
entityType: "task",
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
// { tracking_events: [...], total: 42, count: 20, limit: 20, offset: 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Migration Notes
|
||||||
|
|
||||||
|
1. **New database file**: `backend/data/db/tracking_events.json` will be created automatically on first tracking event.
|
||||||
|
2. **New log directory**: `backend/logs/tracking_user_<user_id>.log` files will be created per user.
|
||||||
|
3. **No breaking changes** to existing APIs or data models.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔮 Future Enhancements (Not in This Phase)
|
||||||
|
|
||||||
|
- Admin/parent UI for viewing tracking history
|
||||||
|
- Badges and certificates based on tracking data
|
||||||
|
- Analytics and reporting dashboards
|
||||||
|
- Export tracking data (CSV, JSON)
|
||||||
|
- Time-based filters (date range queries)
|
||||||
BIN
.github/specs/active/feat-dynamic-points/feat-dynamic-points-after.png
vendored
Normal file
BIN
.github/specs/active/feat-dynamic-points/feat-dynamic-points-after.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
.github/specs/active/feat-dynamic-points/feat-dynamic-points-before.png
vendored
Normal file
BIN
.github/specs/active/feat-dynamic-points/feat-dynamic-points-before.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
112
.github/specs/active/feat-dynamic-points/feat-tracking.md
vendored
Normal file
112
.github/specs/active/feat-dynamic-points/feat-tracking.md
vendored
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Feature: Task and Reward Tracking
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Goal:** Tasks, Penalties, and Rewards should be recorded when completed (activated), requested, redeemed, and cancelled. A record of the date and time should also be kept for these actions. A log file shall be produced that shows the child's points before and after the action happened.
|
||||||
|
|
||||||
|
**User Story:**
|
||||||
|
As an administrator, I want to know what kind and when a task, penalty, or reward was activated.
|
||||||
|
As an administrator, I want a log created detailing when a task, penalty, or reward was activated and how points for the affected child has changed.
|
||||||
|
As a user (parent), when I activate a task or penalty, I want to record the time and what task or penalty was activated.
|
||||||
|
As a user (parent), when I redeem a reward, I want to record the time and what reward was redeeemed.
|
||||||
|
As a user (parent/child), when I cancel a reward, I want to record the time and what reward was cancelled.
|
||||||
|
As a user (child), when I request a reward, I want to record the time and what reward was requested.
|
||||||
|
|
||||||
|
**Questions:**
|
||||||
|
|
||||||
|
- Tasks/Penalty, rewards should be tracked per child. Should the tracking be recorded in the child database, or should a new database be used linking the tracking to the child?
|
||||||
|
- If using a new database, should tracking also be linking to user in case of account deletion?
|
||||||
|
- Does there need to be any frontend changes for now?
|
||||||
|
|
||||||
|
**Decisions:**
|
||||||
|
|
||||||
|
- Use a **new TinyDB table** (`tracking_events.json`) for tracking records (append-only). Do **not** embed tracking in `child` to avoid large child docs and preserve audit history. Each record includes `child_id` and `user_id`.
|
||||||
|
- Track events for: task/penalty activated, reward requested, reward redeemed, reward cancelled.
|
||||||
|
- Store timestamps in **UTC ISO 8601** with timezone (e.g. `2026-02-09T18:42:15Z`). Always use **server time** for `occurred_at` to avoid client clock drift.
|
||||||
|
- On user deletion: **anonymize** tracking records by setting `user_id` to `null`, preserving child activity history for compliance/audit.
|
||||||
|
- Keep an **audit log file per user** (e.g. `tracking_user_<user_id>.log`) with points before/after and event metadata. Use rotating file handler.
|
||||||
|
- Use **offset-based pagination** for tracking queries (simpler with TinyDB, sufficient for expected scale).
|
||||||
|
- **Frontend changes deferred**: Ship backend API, models, and SSE events only. No UI components in this phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
## Acceptance Criteria (Definition of Done)
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
- [x] Add `TrackingEvent` model in `backend/models/` with `from_dict()`/`to_dict()` and 1:1 TS interface in [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts)
|
||||||
|
- [x] `TrackingEvent` fields include: `id`, `user_id`, `child_id`, `entity_type` (task|reward|penalty), `entity_id`, `action` (activated|requested|redeemed|cancelled), `points_before`, `points_after`, `delta`, `occurred_at`, `created_at`, `metadata` (optional dict)
|
||||||
|
- [x] Ensure `delta == points_after - points_before` invariant
|
||||||
|
|
||||||
|
### Backend Implementation
|
||||||
|
|
||||||
|
- [x] Create TinyDB table (e.g., `tracking_events.json`) with helper functions in `backend/db/`
|
||||||
|
- [x] Add tracking write in all mutation endpoints:
|
||||||
|
- task/penalty activation
|
||||||
|
- reward request
|
||||||
|
- reward redeem
|
||||||
|
- reward cancel
|
||||||
|
- [x] Build `TrackingEvent` instances from models (no raw dict writes)
|
||||||
|
- [x] Add server-side validation for required fields and action/entity enums
|
||||||
|
- [x] Add `send_event_for_current_user` calls for tracking mutations
|
||||||
|
|
||||||
|
### Frontend Implementation
|
||||||
|
|
||||||
|
- [x] Add `TrackingEvent` interface and enums to [frontend/vue-app/src/common/models.ts](frontend/vue-app/src/common/models.ts)
|
||||||
|
- [x] Add API helpers for tracking (list per child, optional filters) in [frontend/vue-app/src/common/api.ts](frontend/vue-app/src/common/api.ts)
|
||||||
|
- [x] Register SSE event type `tracking_event_created` in [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts)
|
||||||
|
- [x] **No UI components** — deferred to future phase
|
||||||
|
|
||||||
|
### Admin API
|
||||||
|
|
||||||
|
- [x] Add admin endpoint to query tracking by `child_id`, date range, and `entity_type` (e.g. `GET /admin/tracking`)
|
||||||
|
- [x] Add offset-based pagination parameters (`limit`, `offset`) with sensible defaults (e.g. limit=50, max=500)
|
||||||
|
- [x] Return total count for pagination UI (future)
|
||||||
|
|
||||||
|
### SSE Event
|
||||||
|
|
||||||
|
- [x] Add event type `tracking_event_created` with payload containing `tracking_event_id` and minimal denormalized info
|
||||||
|
- [x] Update [backend/events/types/event_types.py](backend/events/types/event_types.py) and [frontend/vue-app/src/common/backendEvents.ts](frontend/vue-app/src/common/backendEvents.ts)
|
||||||
|
|
||||||
|
### Backend Unit Tests
|
||||||
|
|
||||||
|
- [x] Create tests for tracking creation on each mutation endpoint (task/penalty activated, reward requested/redeemed/cancelled)
|
||||||
|
- [x] Validate `points_before/after` and `delta` are correct
|
||||||
|
- [x] Ensure tracking write does not block core mutation (failure behavior defined)
|
||||||
|
|
||||||
|
### Frontend Unit Tests
|
||||||
|
|
||||||
|
- [x] Test API helper functions for tracking queries
|
||||||
|
- [x] Test TypeScript interface matches backend model (type safety)
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
|
||||||
|
- [x] Reward cancel after redeem should not create duplicate inconsistent entries
|
||||||
|
- [x] Multiple activations in rapid sequence must be ordered by `occurred_at` then `created_at`
|
||||||
|
- [x] Child deleted: tracking records retained and still queryable by admin (archive mode)
|
||||||
|
- [x] User deleted: anonymize tracking by setting `user_id` to `null`, retain all other fields for audit history
|
||||||
|
|
||||||
|
#### Integration Tests
|
||||||
|
|
||||||
|
- [x] End-to-end: activate task -> tracking created -> SSE event emitted -> audit log written
|
||||||
|
- [x] Verify user deletion anonymizes tracking records without breaking queries
|
||||||
|
|
||||||
|
### Logging & Monitoring
|
||||||
|
|
||||||
|
- [x] Add dedicated tracking logger with **per-user rotating file handler** (e.g. `logs/tracking_user_<user_id>.log`)
|
||||||
|
- [x] Log one line per tracking event with `user_id`, `child_id`, `entity_type`, `entity_id`, `action`, `points_before`, `points_after`, `delta`, `occurred_at`
|
||||||
|
- [x] Configure max file size and backup count (e.g. 10MB, 5 backups)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- [x] Update README or docs to include tracking endpoints, schema, and sample responses
|
||||||
|
- [x] Add migration note for new `tracking_events.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- Reward tracking will be used to determine child ranking (badges and certificates!)
|
||||||
|
- is_good vs not is_good in task tracking can be used to show the child their balance in good vs not good
|
||||||
318
.github/specs/archive/feat-account-delete-scheduler.md
vendored
Normal file
318
.github/specs/archive/feat-account-delete-scheduler.md
vendored
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
# Feature: Account Deletion Scheduler
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**Goal:** Implement a scheduler in the backend that will delete accounts that are marked for deletion after a period of time.
|
||||||
|
|
||||||
|
**User Story:**
|
||||||
|
As an administrator, I want accounts that are marked for deletion to be deleted around X amount of hours after they were marked. I want the time to be adjustable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- `ACCOUNT_DELETION_THRESHOLD_HOURS`: Hours to wait before deleting marked accounts (default: 720 hours / 30 days)
|
||||||
|
- **Minimum:** 24 hours (enforced for safety)
|
||||||
|
- **Maximum:** 720 hours (30 days)
|
||||||
|
- Configurable via environment variable with validation on startup
|
||||||
|
|
||||||
|
### Scheduler Settings
|
||||||
|
|
||||||
|
- **Check Interval:** Every 1 hour
|
||||||
|
- **Implementation:** APScheduler (BackgroundScheduler)
|
||||||
|
- **Restart Handling:** On app restart, scheduler checks for users with `deletion_in_progress = True` and retries them
|
||||||
|
- **Retry Logic:** Maximum 3 attempts per user; tracked via `deletion_attempted_at` timestamp
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model Changes
|
||||||
|
|
||||||
|
### User Model (`backend/models/user.py`)
|
||||||
|
|
||||||
|
Add two new fields to the `User` dataclass:
|
||||||
|
|
||||||
|
- `deletion_in_progress: bool` - Default `False`. Set to `True` when deletion is actively running
|
||||||
|
- `deletion_attempted_at: datetime | None` - Default `None`. Timestamp of last deletion attempt
|
||||||
|
|
||||||
|
**Serialization:**
|
||||||
|
|
||||||
|
- Both fields must be included in `to_dict()` and `from_dict()` methods
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deletion Process & Order
|
||||||
|
|
||||||
|
When a user is due for deletion (current time >= `marked_for_deletion_at` + threshold), the scheduler performs deletion in this order:
|
||||||
|
|
||||||
|
1. **Set Flag:** `deletion_in_progress = True` (prevents concurrent deletion)
|
||||||
|
2. **Pending Rewards:** Remove all pending rewards for user's children
|
||||||
|
3. **Children:** Remove all children belonging to the user
|
||||||
|
4. **Tasks:** Remove all user-created tasks (where `user_id` matches)
|
||||||
|
5. **Rewards:** Remove all user-created rewards (where `user_id` matches)
|
||||||
|
6. **Images (Database):** Remove user's uploaded images from `image_db`
|
||||||
|
7. **Images (Filesystem):** Delete `data/images/[user_id]` directory and all contents
|
||||||
|
8. **User Record:** Remove the user from `users_db`
|
||||||
|
9. **Clear Flag:** `deletion_in_progress = False` (only if deletion failed; otherwise user is deleted)
|
||||||
|
10. **Update Timestamp:** Set `deletion_attempted_at` to current time (if deletion failed)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- If any step fails, log the error and continue to next step
|
||||||
|
- If deletion fails completely, update `deletion_attempted_at` and set `deletion_in_progress = False`
|
||||||
|
- If a user has 3 failed attempts, log a critical error but continue processing other users
|
||||||
|
- Missing directories or empty tables are not considered errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin API Endpoints
|
||||||
|
|
||||||
|
### New Blueprint: `backend/api/admin_api.py`
|
||||||
|
|
||||||
|
All endpoints require JWT authentication and admin privileges.
|
||||||
|
|
||||||
|
**Note:** Endpoint paths below are as defined in Flask (without `/api` prefix). Frontend accesses them via nginx proxy at `/api/admin/*`.
|
||||||
|
|
||||||
|
#### `GET /admin/deletion-queue`
|
||||||
|
|
||||||
|
Returns list of users pending deletion.
|
||||||
|
|
||||||
|
**Response:** JSON with `count` and `users` array containing user objects with fields: `id`, `email`, `marked_for_deletion_at`, `deletion_due_at`, `deletion_in_progress`, `deletion_attempted_at`
|
||||||
|
|
||||||
|
#### `GET /admin/deletion-threshold`
|
||||||
|
|
||||||
|
Returns current deletion threshold configuration.
|
||||||
|
|
||||||
|
**Response:** JSON with `threshold_hours`, `threshold_min`, and `threshold_max` fields
|
||||||
|
|
||||||
|
#### `PUT /admin/deletion-threshold`
|
||||||
|
|
||||||
|
Updates deletion threshold (requires admin auth).
|
||||||
|
|
||||||
|
**Request:** JSON with `threshold_hours` field
|
||||||
|
|
||||||
|
**Response:** JSON with `message` and updated `threshold_hours`
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
|
||||||
|
- Must be between 24 and 720 hours
|
||||||
|
- Returns 400 error if out of range
|
||||||
|
|
||||||
|
#### `POST /admin/deletion-queue/trigger`
|
||||||
|
|
||||||
|
Manually triggers the deletion scheduler (processes entire queue immediately).
|
||||||
|
|
||||||
|
**Response:** JSON with `message`, `processed`, `deleted`, and `failed` counts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSE Event
|
||||||
|
|
||||||
|
### New Event Type: `USER_DELETED`
|
||||||
|
|
||||||
|
**File:** `backend/events/types/user_deleted.py`
|
||||||
|
|
||||||
|
**Payload fields:**
|
||||||
|
|
||||||
|
- `user_id: str` - ID of deleted user
|
||||||
|
- `email: str` - Email of deleted user
|
||||||
|
- `deleted_at: str` - ISO format timestamp of deletion
|
||||||
|
|
||||||
|
**Broadcasting:**
|
||||||
|
|
||||||
|
- Event is sent only to **admin users** (not broadcast to all users)
|
||||||
|
- Triggered immediately after successful user deletion
|
||||||
|
- Frontend admin clients can listen to this event to update UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
- `backend/config/deletion_config.py` - Configuration with env variable
|
||||||
|
- `backend/utils/account_deletion_scheduler.py` - Scheduler logic
|
||||||
|
- `backend/api/admin_api.py` - New admin endpoints
|
||||||
|
- `backend/events/types/user_deleted.py` - New SSE event
|
||||||
|
|
||||||
|
### Scheduler Startup
|
||||||
|
|
||||||
|
In `backend/main.py`, import and call `start_deletion_scheduler()` after Flask app setup
|
||||||
|
|
||||||
|
### Logging Strategy
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
|
||||||
|
- Use dedicated logger: `account_deletion_scheduler`
|
||||||
|
- Log to both stdout (for Docker/dev) and rotating file (for persistence)
|
||||||
|
- File: `logs/account_deletion.log`
|
||||||
|
- Rotation: 10MB max file size, keep 5 backups
|
||||||
|
- Format: `%(asctime)s - %(name)s - %(levelname)s - %(message)s`
|
||||||
|
|
||||||
|
**Log Levels:**
|
||||||
|
|
||||||
|
- **INFO:** Each deletion step (e.g., "Deleted 5 children for user {user_id}")
|
||||||
|
- **INFO:** Summary after each run (e.g., "Deletion scheduler run: 3 users processed, 2 deleted, 1 failed")
|
||||||
|
- **ERROR:** Individual step failures (e.g., "Failed to delete images for user {user_id}: {error}")
|
||||||
|
- **CRITICAL:** User with 3+ failed attempts (e.g., "User {user_id} has failed deletion 3 times")
|
||||||
|
- **WARNING:** Threshold set below 168 hours (7 days)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria (Definition of Done)
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
- [x] Add `deletion_in_progress` field to User model
|
||||||
|
- [x] Add `deletion_attempted_at` field to User model
|
||||||
|
- [x] Update `to_dict()` and `from_dict()` methods for serialization
|
||||||
|
- [x] Update TypeScript User interface in frontend
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- [x] Create `backend/config/deletion_config.py` with `ACCOUNT_DELETION_THRESHOLD_HOURS`
|
||||||
|
- [x] Add environment variable support with default (720 hours)
|
||||||
|
- [x] Enforce minimum threshold of 24 hours
|
||||||
|
- [x] Enforce maximum threshold of 720 hours
|
||||||
|
- [x] Log warning if threshold is less than 168 hours
|
||||||
|
|
||||||
|
### Backend Implementation
|
||||||
|
|
||||||
|
- [x] Create `backend/utils/account_deletion_scheduler.py`
|
||||||
|
- [x] Implement APScheduler with 1-hour check interval
|
||||||
|
- [x] Implement deletion logic in correct order (pending_rewards → children → tasks → rewards → images → directory → user)
|
||||||
|
- [x] Add comprehensive error handling (log and continue)
|
||||||
|
- [x] Add restart handling (check `deletion_in_progress` flag on startup)
|
||||||
|
- [x] Add retry logic (max 3 attempts per user)
|
||||||
|
- [x] Integrate scheduler into `backend/main.py` startup
|
||||||
|
|
||||||
|
### Admin API
|
||||||
|
|
||||||
|
- [x] Create `backend/api/admin_api.py` blueprint
|
||||||
|
- [x] Implement `GET /admin/deletion-queue` endpoint
|
||||||
|
- [x] Implement `GET /admin/deletion-threshold` endpoint
|
||||||
|
- [x] Implement `PUT /admin/deletion-threshold` endpoint
|
||||||
|
- [x] Implement `POST /admin/deletion-queue/trigger` endpoint
|
||||||
|
- [x] Add JWT authentication checks for all admin endpoints
|
||||||
|
- [x] Add admin role validation
|
||||||
|
|
||||||
|
### SSE Event
|
||||||
|
|
||||||
|
- [x] Create `backend/events/types/user_deleted.py`
|
||||||
|
- [x] Add `USER_DELETED` to `event_types.py`
|
||||||
|
- [x] Implement admin-only event broadcasting
|
||||||
|
- [x] Trigger event after successful deletion
|
||||||
|
|
||||||
|
### Backend Unit Tests
|
||||||
|
|
||||||
|
#### Configuration Tests
|
||||||
|
|
||||||
|
- [x] Test default threshold value (720 hours)
|
||||||
|
- [x] Test environment variable override
|
||||||
|
- [x] Test minimum threshold enforcement (24 hours)
|
||||||
|
- [x] Test maximum threshold enforcement (720 hours)
|
||||||
|
- [x] Test invalid threshold values (negative, non-numeric)
|
||||||
|
|
||||||
|
#### Scheduler Tests
|
||||||
|
|
||||||
|
- [x] Test scheduler identifies users ready for deletion (past threshold)
|
||||||
|
- [x] Test scheduler ignores users not yet due for deletion
|
||||||
|
- [x] Test scheduler handles empty database
|
||||||
|
- [x] Test scheduler runs at correct interval (1 hour)
|
||||||
|
- [x] Test scheduler handles restart with `deletion_in_progress = True`
|
||||||
|
- [x] Test scheduler respects retry limit (max 3 attempts)
|
||||||
|
|
||||||
|
#### Deletion Process Tests
|
||||||
|
|
||||||
|
- [x] Test deletion removes pending_rewards for user's children
|
||||||
|
- [x] Test deletion removes children for user
|
||||||
|
- [x] Test deletion removes user's tasks (not system tasks)
|
||||||
|
- [x] Test deletion removes user's rewards (not system rewards)
|
||||||
|
- [x] Test deletion removes user's images from database
|
||||||
|
- [x] Test deletion removes user directory from filesystem
|
||||||
|
- [x] Test deletion removes user record from database
|
||||||
|
- [x] Test deletion handles missing directory gracefully
|
||||||
|
- [x] Test deletion order is correct (children before user, etc.)
|
||||||
|
- [x] Test `deletion_in_progress` flag is set during deletion
|
||||||
|
- [x] Test `deletion_attempted_at` is updated on failure
|
||||||
|
|
||||||
|
#### Edge Cases
|
||||||
|
|
||||||
|
- [x] Test deletion with user who has no children
|
||||||
|
- [x] Test deletion with user who has no custom tasks/rewards
|
||||||
|
- [x] Test deletion with user who has no uploaded images
|
||||||
|
- [x] Test partial deletion failure (continue with other users)
|
||||||
|
- [x] Test concurrent deletion attempts (flag prevents double-deletion)
|
||||||
|
- [x] Test user with exactly 3 failed attempts (logs critical, no retry)
|
||||||
|
|
||||||
|
#### Admin API Tests
|
||||||
|
|
||||||
|
- [x] Test `GET /admin/deletion-queue` returns correct users
|
||||||
|
- [x] Test `GET /admin/deletion-queue` requires authentication
|
||||||
|
- [x] Test `GET /admin/deletion-threshold` returns current threshold
|
||||||
|
- [x] Test `PUT /admin/deletion-threshold` updates threshold
|
||||||
|
- [x] Test `PUT /admin/deletion-threshold` validates min/max
|
||||||
|
- [x] Test `PUT /admin/deletion-threshold` requires admin role
|
||||||
|
- [x] Test `POST /admin/deletion-queue/trigger` triggers scheduler
|
||||||
|
- [x] Test `POST /admin/deletion-queue/trigger` returns summary
|
||||||
|
|
||||||
|
#### Integration Tests
|
||||||
|
|
||||||
|
- [x] Test full deletion flow from marking to deletion
|
||||||
|
- [x] Test multiple users deleted in same scheduler run
|
||||||
|
- [x] Test deletion with restart midway (recovery)
|
||||||
|
|
||||||
|
### Logging & Monitoring
|
||||||
|
|
||||||
|
- [x] Configure dedicated scheduler logger with rotating file handler
|
||||||
|
- [x] Create `logs/` directory for log files
|
||||||
|
- [x] Log each deletion step with INFO level
|
||||||
|
- [x] Log summary after each scheduler run (users processed, deleted, failed)
|
||||||
|
- [x] Log errors with user ID for debugging
|
||||||
|
- [x] Log critical error for users with 3+ failed attempts
|
||||||
|
- [x] Log warning if threshold is set below 168 hours
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- [x] Create `README.md` at project root
|
||||||
|
- [x] Document scheduler feature and behavior
|
||||||
|
- [x] Document environment variable `ACCOUNT_DELETION_THRESHOLD_HOURS`
|
||||||
|
- [x] Document deletion process and order
|
||||||
|
- [x] Document admin API endpoints
|
||||||
|
- [x] Document restart/retry behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
All tests should use `DB_ENV=test` and operate on test databases in `backend/test_data/`.
|
||||||
|
|
||||||
|
### Unit Test Files
|
||||||
|
|
||||||
|
- `backend/tests/test_deletion_config.py` - Configuration validation
|
||||||
|
- `backend/tests/test_deletion_scheduler.py` - Scheduler logic
|
||||||
|
- `backend/tests/test_admin_api.py` - Admin endpoints
|
||||||
|
|
||||||
|
### Test Fixtures
|
||||||
|
|
||||||
|
- Create users with various `marked_for_deletion_at` timestamps
|
||||||
|
- Create users with children, tasks, rewards, images
|
||||||
|
- Create users with `deletion_in_progress = True` (for restart tests)
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
|
||||||
|
- Database records are removed in correct order
|
||||||
|
- Filesystem directories are deleted
|
||||||
|
- Flags and timestamps are updated correctly
|
||||||
|
- Error handling works (log and continue)
|
||||||
|
- Admin API responses match expected format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
- Archive deleted accounts instead of hard deletion
|
||||||
|
- Email notification to admin when deletion completes
|
||||||
|
- Configurable retry count (currently hardcoded to 3)
|
||||||
|
- Soft delete with recovery option (within grace period)
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ backend/test_data/db/rewards.json
|
|||||||
backend/test_data/db/tasks.json
|
backend/test_data/db/tasks.json
|
||||||
backend/test_data/db/users.json
|
backend/test_data/db/users.json
|
||||||
logs/account_deletion.log
|
logs/account_deletion.log
|
||||||
|
backend/test_data/db/tracking_events.json
|
||||||
|
|||||||
@@ -9,19 +9,23 @@ from api.pending_reward import PendingReward as PendingRewardResponse
|
|||||||
from api.reward_status import RewardStatus
|
from api.reward_status import RewardStatus
|
||||||
from api.utils import send_event_for_current_user
|
from api.utils import send_event_for_current_user
|
||||||
from db.db import child_db, task_db, reward_db, pending_reward_db
|
from db.db import child_db, task_db, reward_db, pending_reward_db
|
||||||
|
from db.tracking import insert_tracking_event
|
||||||
from events.types.child_modified import ChildModified
|
from events.types.child_modified import ChildModified
|
||||||
from events.types.child_reward_request import ChildRewardRequest
|
from events.types.child_reward_request import ChildRewardRequest
|
||||||
from events.types.child_reward_triggered import ChildRewardTriggered
|
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||||
from events.types.child_rewards_set import ChildRewardsSet
|
from events.types.child_rewards_set import ChildRewardsSet
|
||||||
from events.types.child_task_triggered import ChildTaskTriggered
|
from events.types.child_task_triggered import ChildTaskTriggered
|
||||||
from events.types.child_tasks_set import ChildTasksSet
|
from events.types.child_tasks_set import ChildTasksSet
|
||||||
|
from events.types.tracking_event_created import TrackingEventCreated
|
||||||
from events.types.event import Event
|
from events.types.event import Event
|
||||||
from events.types.event_types import EventType
|
from events.types.event_types import EventType
|
||||||
from models.child import Child
|
from models.child import Child
|
||||||
from models.pending_reward import PendingReward
|
from models.pending_reward import PendingReward
|
||||||
from models.reward import Reward
|
from models.reward import Reward
|
||||||
from models.task import Task
|
from models.task import Task
|
||||||
|
from models.tracking_event import TrackingEvent
|
||||||
from api.utils import get_validated_user_id
|
from api.utils import get_validated_user_id
|
||||||
|
from utils.tracking_logger import log_tracking_event
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -364,14 +368,38 @@ def trigger_child_task(id):
|
|||||||
if not task_result:
|
if not task_result:
|
||||||
return jsonify({'error': 'Task not found in task database'}), 404
|
return jsonify({'error': 'Task not found in task database'}), 404
|
||||||
task: Task = Task.from_dict(task_result[0])
|
task: Task = Task.from_dict(task_result[0])
|
||||||
|
|
||||||
|
# Capture points before modification
|
||||||
|
points_before = child.points
|
||||||
|
|
||||||
# update the child's points based on task type
|
# update the child's points based on task type
|
||||||
if task.is_good:
|
if task.is_good:
|
||||||
child.points += task.points
|
child.points += task.points
|
||||||
else:
|
else:
|
||||||
child.points -= task.points
|
child.points -= task.points
|
||||||
child.points = max(child.points, 0)
|
child.points = max(child.points, 0)
|
||||||
|
|
||||||
# update the child in the database
|
# update the child in the database
|
||||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||||
|
|
||||||
|
# Create tracking event
|
||||||
|
entity_type = 'penalty' if not task.is_good else 'task'
|
||||||
|
tracking_event = TrackingEvent.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
child_id=child.id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=task.id,
|
||||||
|
action='activated',
|
||||||
|
points_before=points_before,
|
||||||
|
points_after=child.points,
|
||||||
|
metadata={'task_name': task.name, 'is_good': task.is_good}
|
||||||
|
)
|
||||||
|
insert_tracking_event(tracking_event)
|
||||||
|
log_tracking_event(tracking_event)
|
||||||
|
|
||||||
|
# Send tracking event via SSE
|
||||||
|
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, entity_type, 'activated')))
|
||||||
|
|
||||||
resp = send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value, ChildTaskTriggered(task.id, child.id, child.points)))
|
||||||
if resp:
|
if resp:
|
||||||
return resp
|
return resp
|
||||||
@@ -609,10 +637,31 @@ def trigger_child_reward(id):
|
|||||||
if removed:
|
if removed:
|
||||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(reward.id, child.id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||||
|
|
||||||
|
# Capture points before modification
|
||||||
|
points_before = child.points
|
||||||
|
|
||||||
# update the child's points based on reward cost
|
# update the child's points based on reward cost
|
||||||
child.points -= reward.cost
|
child.points -= reward.cost
|
||||||
# update the child in the database
|
# update the child in the database
|
||||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
child_db.update({'points': child.points}, ChildQuery.id == id)
|
||||||
|
|
||||||
|
# Create tracking event
|
||||||
|
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={'reward_name': reward.name, 'reward_cost': reward.cost}
|
||||||
|
)
|
||||||
|
insert_tracking_event(tracking_event)
|
||||||
|
log_tracking_event(tracking_event)
|
||||||
|
|
||||||
|
# Send tracking event via SSE
|
||||||
|
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'redeemed')))
|
||||||
|
|
||||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
send_event_for_current_user(Event(EventType.CHILD_REWARD_TRIGGERED.value, ChildRewardTriggered(reward.id, child.id, child.points)))
|
||||||
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
return jsonify({'message': f'{reward.name} assigned to {child.name}.', 'points': child.points, 'id': child.id}), 200
|
||||||
|
|
||||||
@@ -707,6 +756,24 @@ def request_reward(id):
|
|||||||
pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id)
|
pending = PendingReward(child_id=child.id, reward_id=reward.id, user_id=user_id)
|
||||||
pending_reward_db.insert(pending.to_dict())
|
pending_reward_db.insert(pending.to_dict())
|
||||||
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
|
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
|
||||||
|
|
||||||
|
# Create tracking event (no points change on request)
|
||||||
|
tracking_event = TrackingEvent.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
child_id=child.id,
|
||||||
|
entity_type='reward',
|
||||||
|
entity_id=reward.id,
|
||||||
|
action='requested',
|
||||||
|
points_before=child.points,
|
||||||
|
points_after=child.points,
|
||||||
|
metadata={'reward_name': reward.name, 'reward_cost': reward.cost}
|
||||||
|
)
|
||||||
|
insert_tracking_event(tracking_event)
|
||||||
|
log_tracking_event(tracking_event)
|
||||||
|
|
||||||
|
# Send tracking event via SSE
|
||||||
|
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested')))
|
||||||
|
|
||||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
||||||
@@ -734,6 +801,12 @@ def cancel_request_reward(id):
|
|||||||
|
|
||||||
child = Child.from_dict(result[0])
|
child = Child.from_dict(result[0])
|
||||||
|
|
||||||
|
# Fetch reward details for tracking metadata
|
||||||
|
RewardQuery = Query()
|
||||||
|
reward_result = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||||
|
reward_name = reward_result.get('name') if reward_result else 'Unknown'
|
||||||
|
reward_cost = reward_result.get('cost', 0) if reward_result else 0
|
||||||
|
|
||||||
# Remove matching pending reward request
|
# Remove matching pending reward request
|
||||||
PendingQuery = Query()
|
PendingQuery = Query()
|
||||||
removed = pending_reward_db.remove(
|
removed = pending_reward_db.remove(
|
||||||
@@ -743,6 +816,23 @@ def cancel_request_reward(id):
|
|||||||
if not removed:
|
if not removed:
|
||||||
return jsonify({'error': 'No pending request found for this reward'}), 404
|
return jsonify({'error': 'No pending request found for this reward'}), 404
|
||||||
|
|
||||||
|
# Create tracking event (no points change on cancel)
|
||||||
|
tracking_event = TrackingEvent.create_event(
|
||||||
|
user_id=user_id,
|
||||||
|
child_id=child.id,
|
||||||
|
entity_type='reward',
|
||||||
|
entity_id=reward_id,
|
||||||
|
action='cancelled',
|
||||||
|
points_before=child.points,
|
||||||
|
points_after=child.points,
|
||||||
|
metadata={'reward_name': reward_name, 'reward_cost': reward_cost}
|
||||||
|
)
|
||||||
|
insert_tracking_event(tracking_event)
|
||||||
|
log_tracking_event(tracking_event)
|
||||||
|
|
||||||
|
# Send tracking event via SSE
|
||||||
|
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'cancelled')))
|
||||||
|
|
||||||
# Notify user that the request was cancelled
|
# Notify user that the request was cancelled
|
||||||
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||||
if resp:
|
if resp:
|
||||||
|
|||||||
122
backend/api/tracking_api.py
Normal file
122
backend/api/tracking_api.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from api.utils import get_validated_user_id
|
||||||
|
from db.tracking import get_tracking_events_by_child, get_tracking_events_by_user
|
||||||
|
from models.tracking_event import TrackingEvent
|
||||||
|
from functools import wraps
|
||||||
|
import jwt
|
||||||
|
from tinydb import Query
|
||||||
|
from db.db import users_db
|
||||||
|
from models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
tracking_api = Blueprint('tracking_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(f):
|
||||||
|
"""
|
||||||
|
Decorator to require admin role for endpoints.
|
||||||
|
"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
# Get JWT token from cookie
|
||||||
|
token = request.cookies.get('token')
|
||||||
|
if not token:
|
||||||
|
return jsonify({'error': 'Authentication required', 'code': 'AUTH_REQUIRED'}), 401
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Verify JWT token
|
||||||
|
payload = jwt.decode(token, 'supersecretkey', algorithms=['HS256'])
|
||||||
|
user_id = payload.get('user_id')
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||||
|
|
||||||
|
# Get user from database
|
||||||
|
Query_ = Query()
|
||||||
|
user_dict = users_db.get(Query_.id == user_id)
|
||||||
|
|
||||||
|
if not user_dict:
|
||||||
|
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
user = User.from_dict(user_dict)
|
||||||
|
|
||||||
|
# Check if user has admin role
|
||||||
|
if user.role != 'admin':
|
||||||
|
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||||
|
|
||||||
|
# Store user_id in request context
|
||||||
|
request.admin_user_id = user_id
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return jsonify({'error': 'Token expired', 'code': 'TOKEN_EXPIRED'}), 401
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return jsonify({'error': 'Invalid token', 'code': 'INVALID_TOKEN'}), 401
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
|
||||||
|
@tracking_api.route('/admin/tracking', methods=['GET'])
|
||||||
|
@admin_required
|
||||||
|
def get_tracking():
|
||||||
|
"""
|
||||||
|
Admin endpoint to query tracking events with filters and pagination.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- child_id: Filter by child ID (optional)
|
||||||
|
- user_id: Filter by user ID (optional, admin only)
|
||||||
|
- entity_type: Filter by entity type (task/reward/penalty) (optional)
|
||||||
|
- action: Filter by action type (activated/requested/redeemed/cancelled) (optional)
|
||||||
|
- limit: Max results (default 50, max 500)
|
||||||
|
- offset: Pagination offset (default 0)
|
||||||
|
"""
|
||||||
|
child_id = request.args.get('child_id')
|
||||||
|
filter_user_id = request.args.get('user_id')
|
||||||
|
entity_type = request.args.get('entity_type')
|
||||||
|
action = request.args.get('action')
|
||||||
|
limit = int(request.args.get('limit', 50))
|
||||||
|
offset = int(request.args.get('offset', 0))
|
||||||
|
|
||||||
|
# Validate limit
|
||||||
|
limit = min(max(limit, 1), 500)
|
||||||
|
offset = max(offset, 0)
|
||||||
|
|
||||||
|
# Validate filters
|
||||||
|
if entity_type and entity_type not in ['task', 'reward', 'penalty']:
|
||||||
|
return jsonify({'error': 'Invalid entity_type', 'code': 'INVALID_ENTITY_TYPE'}), 400
|
||||||
|
|
||||||
|
if action and action not in ['activated', 'requested', 'redeemed', 'cancelled']:
|
||||||
|
return jsonify({'error': 'Invalid action', 'code': 'INVALID_ACTION'}), 400
|
||||||
|
|
||||||
|
# Query tracking events
|
||||||
|
if child_id:
|
||||||
|
events, total = get_tracking_events_by_child(
|
||||||
|
child_id=child_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
entity_type=entity_type,
|
||||||
|
action=action
|
||||||
|
)
|
||||||
|
elif filter_user_id:
|
||||||
|
events, total = get_tracking_events_by_user(
|
||||||
|
user_id=filter_user_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
entity_type=entity_type
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Either child_id or user_id is required',
|
||||||
|
'code': 'MISSING_FILTER'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Convert to dict
|
||||||
|
events_data = [event.to_dict() for event in events]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'tracking_events': events_data,
|
||||||
|
'total': total,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
'count': len(events_data)
|
||||||
|
}), 200
|
||||||
@@ -33,3 +33,9 @@ def get_user_image_dir(username: str | None) -> str:
|
|||||||
if username:
|
if username:
|
||||||
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images', username)
|
return os.path.join(PROJECT_ROOT, get_base_data_dir(), 'images', username)
|
||||||
return os.path.join(PROJECT_ROOT, 'resources', 'images')
|
return os.path.join(PROJECT_ROOT, 'resources', 'images')
|
||||||
|
|
||||||
|
def get_logs_dir() -> str:
|
||||||
|
"""
|
||||||
|
Return the absolute directory path for application logs.
|
||||||
|
"""
|
||||||
|
return os.path.join(PROJECT_ROOT, 'logs')
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ reward_path = os.path.join(base_dir, 'rewards.json')
|
|||||||
image_path = os.path.join(base_dir, 'images.json')
|
image_path = os.path.join(base_dir, 'images.json')
|
||||||
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
||||||
users_path = os.path.join(base_dir, 'users.json')
|
users_path = os.path.join(base_dir, 'users.json')
|
||||||
|
tracking_events_path = os.path.join(base_dir, 'tracking_events.json')
|
||||||
|
|
||||||
# Use separate TinyDB instances/files for each collection
|
# Use separate TinyDB instances/files for each collection
|
||||||
_child_db = TinyDB(child_path, indent=2)
|
_child_db = TinyDB(child_path, indent=2)
|
||||||
@@ -81,6 +82,7 @@ _reward_db = TinyDB(reward_path, indent=2)
|
|||||||
_image_db = TinyDB(image_path, indent=2)
|
_image_db = TinyDB(image_path, indent=2)
|
||||||
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
||||||
_users_db = TinyDB(users_path, indent=2)
|
_users_db = TinyDB(users_path, indent=2)
|
||||||
|
_tracking_events_db = TinyDB(tracking_events_path, indent=2)
|
||||||
|
|
||||||
# Expose table objects wrapped with locking
|
# Expose table objects wrapped with locking
|
||||||
child_db = LockedTable(_child_db)
|
child_db = LockedTable(_child_db)
|
||||||
@@ -89,6 +91,7 @@ reward_db = LockedTable(_reward_db)
|
|||||||
image_db = LockedTable(_image_db)
|
image_db = LockedTable(_image_db)
|
||||||
pending_reward_db = LockedTable(_pending_rewards_db)
|
pending_reward_db = LockedTable(_pending_rewards_db)
|
||||||
users_db = LockedTable(_users_db)
|
users_db = LockedTable(_users_db)
|
||||||
|
tracking_events_db = LockedTable(_tracking_events_db)
|
||||||
|
|
||||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||||
child_db.truncate()
|
child_db.truncate()
|
||||||
@@ -97,4 +100,5 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
|||||||
image_db.truncate()
|
image_db.truncate()
|
||||||
pending_reward_db.truncate()
|
pending_reward_db.truncate()
|
||||||
users_db.truncate()
|
users_db.truncate()
|
||||||
|
tracking_events_db.truncate()
|
||||||
|
|
||||||
|
|||||||
125
backend/db/tracking.py
Normal file
125
backend/db/tracking.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Helper functions for tracking events database operations."""
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List
|
||||||
|
from tinydb import Query
|
||||||
|
from db.db import tracking_events_db
|
||||||
|
from models.tracking_event import TrackingEvent, EntityType, ActionType
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_tracking_event(event: TrackingEvent) -> str:
|
||||||
|
"""
|
||||||
|
Insert a tracking event into the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: TrackingEvent instance to insert
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The event ID
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tracking_events_db.insert(event.to_dict())
|
||||||
|
logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}")
|
||||||
|
return event.id
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to insert tracking event: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def get_tracking_events_by_child(
|
||||||
|
child_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
entity_type: Optional[EntityType] = None,
|
||||||
|
action: Optional[ActionType] = None
|
||||||
|
) -> tuple[List[TrackingEvent], int]:
|
||||||
|
"""
|
||||||
|
Query tracking events for a specific child with optional filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
child_id: Child ID to filter by
|
||||||
|
limit: Maximum number of results (default 50, max 500)
|
||||||
|
offset: Number of results to skip
|
||||||
|
entity_type: Optional filter by entity type
|
||||||
|
action: Optional filter by action type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of TrackingEvent instances, total count)
|
||||||
|
"""
|
||||||
|
limit = min(limit, 500)
|
||||||
|
|
||||||
|
TrackingQuery = Query()
|
||||||
|
query_condition = TrackingQuery.child_id == child_id
|
||||||
|
|
||||||
|
if entity_type:
|
||||||
|
query_condition &= TrackingQuery.entity_type == entity_type
|
||||||
|
if action:
|
||||||
|
query_condition &= TrackingQuery.action == action
|
||||||
|
|
||||||
|
all_results = tracking_events_db.search(query_condition)
|
||||||
|
total = len(all_results)
|
||||||
|
|
||||||
|
# Sort by occurred_at desc, then created_at desc
|
||||||
|
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
|
||||||
|
|
||||||
|
paginated = all_results[offset:offset + limit]
|
||||||
|
events = [TrackingEvent.from_dict(r) for r in paginated]
|
||||||
|
|
||||||
|
return events, total
|
||||||
|
|
||||||
|
|
||||||
|
def get_tracking_events_by_user(
|
||||||
|
user_id: str,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
entity_type: Optional[EntityType] = None
|
||||||
|
) -> tuple[List[TrackingEvent], int]:
|
||||||
|
"""
|
||||||
|
Query tracking events for a specific user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to filter by
|
||||||
|
limit: Maximum number of results
|
||||||
|
offset: Number of results to skip
|
||||||
|
entity_type: Optional filter by entity type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (list of TrackingEvent instances, total count)
|
||||||
|
"""
|
||||||
|
limit = min(limit, 500)
|
||||||
|
|
||||||
|
TrackingQuery = Query()
|
||||||
|
query_condition = TrackingQuery.user_id == user_id
|
||||||
|
|
||||||
|
if entity_type:
|
||||||
|
query_condition &= TrackingQuery.entity_type == entity_type
|
||||||
|
|
||||||
|
all_results = tracking_events_db.search(query_condition)
|
||||||
|
total = len(all_results)
|
||||||
|
|
||||||
|
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
|
||||||
|
|
||||||
|
paginated = all_results[offset:offset + limit]
|
||||||
|
events = [TrackingEvent.from_dict(r) for r in paginated]
|
||||||
|
|
||||||
|
return events, total
|
||||||
|
|
||||||
|
|
||||||
|
def anonymize_tracking_events_for_user(user_id: str) -> int:
|
||||||
|
"""
|
||||||
|
Anonymize tracking events by setting user_id to None.
|
||||||
|
Called when a user is deleted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to anonymize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of records anonymized
|
||||||
|
"""
|
||||||
|
TrackingQuery = Query()
|
||||||
|
result = tracking_events_db.update({'user_id': None}, TrackingQuery.user_id == user_id)
|
||||||
|
count = len(result) if result else 0
|
||||||
|
logger.info(f"Anonymized {count} tracking events for user {user_id}")
|
||||||
|
return count
|
||||||
@@ -16,3 +16,5 @@ class EventType(Enum):
|
|||||||
|
|
||||||
USER_MARKED_FOR_DELETION = "user_marked_for_deletion"
|
USER_MARKED_FOR_DELETION = "user_marked_for_deletion"
|
||||||
USER_DELETED = "user_deleted"
|
USER_DELETED = "user_deleted"
|
||||||
|
|
||||||
|
TRACKING_EVENT_CREATED = "tracking_event_created"
|
||||||
|
|||||||
27
backend/events/types/tracking_event_created.py
Normal file
27
backend/events/types/tracking_event_created.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingEventCreated(Payload):
|
||||||
|
def __init__(self, tracking_event_id: str, child_id: str, entity_type: str, action: str):
|
||||||
|
super().__init__({
|
||||||
|
'tracking_event_id': tracking_event_id,
|
||||||
|
'child_id': child_id,
|
||||||
|
'entity_type': entity_type,
|
||||||
|
'action': action
|
||||||
|
})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tracking_event_id(self) -> str:
|
||||||
|
return self.get("tracking_event_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def child_id(self) -> str:
|
||||||
|
return self.get("child_id")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entity_type(self) -> str:
|
||||||
|
return self.get("entity_type")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action(self) -> str:
|
||||||
|
return self.get("action")
|
||||||
@@ -10,6 +10,7 @@ from api.child_api import child_api
|
|||||||
from api.image_api import image_api
|
from api.image_api import image_api
|
||||||
from api.reward_api import reward_api
|
from api.reward_api import reward_api
|
||||||
from api.task_api import task_api
|
from api.task_api import task_api
|
||||||
|
from api.tracking_api import tracking_api
|
||||||
from api.user_api import user_api
|
from api.user_api import user_api
|
||||||
from config.version import get_full_version
|
from config.version import get_full_version
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ app.register_blueprint(task_api)
|
|||||||
app.register_blueprint(image_api)
|
app.register_blueprint(image_api)
|
||||||
app.register_blueprint(auth_api)
|
app.register_blueprint(auth_api)
|
||||||
app.register_blueprint(user_api)
|
app.register_blueprint(user_api)
|
||||||
|
app.register_blueprint(tracking_api)
|
||||||
|
|
||||||
app.config.update(
|
app.config.update(
|
||||||
MAIL_SERVER='smtp.gmail.com',
|
MAIL_SERVER='smtp.gmail.com',
|
||||||
|
|||||||
91
backend/models/tracking_event.py
Normal file
91
backend/models/tracking_event.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Literal, Optional
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
EntityType = Literal['task', 'reward', 'penalty']
|
||||||
|
ActionType = Literal['activated', 'requested', 'redeemed', 'cancelled']
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackingEvent(BaseModel):
|
||||||
|
user_id: Optional[str]
|
||||||
|
child_id: str
|
||||||
|
entity_type: EntityType
|
||||||
|
entity_id: str
|
||||||
|
action: ActionType
|
||||||
|
points_before: int
|
||||||
|
points_after: int
|
||||||
|
delta: int
|
||||||
|
occurred_at: str # UTC ISO 8601 timestamp
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
"""Validate invariants after initialization."""
|
||||||
|
if self.delta != self.points_after - self.points_before:
|
||||||
|
raise ValueError(
|
||||||
|
f"Delta invariant violated: {self.delta} != {self.points_after} - {self.points_before}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict):
|
||||||
|
return cls(
|
||||||
|
user_id=d.get('user_id'),
|
||||||
|
child_id=d.get('child_id'),
|
||||||
|
entity_type=d.get('entity_type'),
|
||||||
|
entity_id=d.get('entity_id'),
|
||||||
|
action=d.get('action'),
|
||||||
|
points_before=d.get('points_before'),
|
||||||
|
points_after=d.get('points_after'),
|
||||||
|
delta=d.get('delta'),
|
||||||
|
occurred_at=d.get('occurred_at'),
|
||||||
|
metadata=d.get('metadata'),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'child_id': self.child_id,
|
||||||
|
'entity_type': self.entity_type,
|
||||||
|
'entity_id': self.entity_id,
|
||||||
|
'action': self.action,
|
||||||
|
'points_before': self.points_before,
|
||||||
|
'points_after': self.points_after,
|
||||||
|
'delta': self.delta,
|
||||||
|
'occurred_at': self.occurred_at,
|
||||||
|
'metadata': self.metadata
|
||||||
|
})
|
||||||
|
return base
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_event(
|
||||||
|
user_id: Optional[str],
|
||||||
|
child_id: str,
|
||||||
|
entity_type: EntityType,
|
||||||
|
entity_id: str,
|
||||||
|
action: ActionType,
|
||||||
|
points_before: int,
|
||||||
|
points_after: int,
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
) -> 'TrackingEvent':
|
||||||
|
"""Factory method to create a tracking event with server timestamp."""
|
||||||
|
delta = points_after - points_before
|
||||||
|
occurred_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
return TrackingEvent(
|
||||||
|
user_id=user_id,
|
||||||
|
child_id=child_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
action=action,
|
||||||
|
points_before=points_before,
|
||||||
|
points_after=points_after,
|
||||||
|
delta=delta,
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
metadata=metadata
|
||||||
|
)
|
||||||
254
backend/tests/test_tracking.py
Normal file
254
backend/tests/test_tracking.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import os
|
||||||
|
os.environ['DB_ENV'] = 'test'
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from models.tracking_event import TrackingEvent
|
||||||
|
from db.tracking import (
|
||||||
|
insert_tracking_event,
|
||||||
|
get_tracking_events_by_child,
|
||||||
|
get_tracking_events_by_user,
|
||||||
|
anonymize_tracking_events_for_user
|
||||||
|
)
|
||||||
|
from db.db import tracking_events_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_tracking_event_creation():
|
||||||
|
"""Test creating a tracking event with factory method."""
|
||||||
|
event = TrackingEvent.create_event(
|
||||||
|
user_id='user123',
|
||||||
|
child_id='child456',
|
||||||
|
entity_type='task',
|
||||||
|
entity_id='task789',
|
||||||
|
action='activated',
|
||||||
|
points_before=10,
|
||||||
|
points_after=20,
|
||||||
|
metadata={'task_name': 'Homework'}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert event.user_id == 'user123'
|
||||||
|
assert event.child_id == 'child456'
|
||||||
|
assert event.entity_type == 'task'
|
||||||
|
assert event.action == 'activated'
|
||||||
|
assert event.points_before == 10
|
||||||
|
assert event.points_after == 20
|
||||||
|
assert event.delta == 10
|
||||||
|
assert event.metadata == {'task_name': 'Homework'}
|
||||||
|
assert event.occurred_at # Should have ISO timestamp
|
||||||
|
|
||||||
|
|
||||||
|
def test_tracking_event_delta_invariant():
|
||||||
|
"""Test that delta invariant is enforced."""
|
||||||
|
with pytest.raises(ValueError, match="Delta invariant violated"):
|
||||||
|
TrackingEvent(
|
||||||
|
user_id='user123',
|
||||||
|
child_id='child456',
|
||||||
|
entity_type='task',
|
||||||
|
entity_id='task789',
|
||||||
|
action='activated',
|
||||||
|
points_before=10,
|
||||||
|
points_after=20,
|
||||||
|
delta=5, # Wrong! Should be 10
|
||||||
|
occurred_at='2026-02-09T12:00:00Z'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_and_query_tracking_event():
|
||||||
|
"""Test inserting and querying tracking events."""
|
||||||
|
tracking_events_db.truncate()
|
||||||
|
|
||||||
|
event1 = TrackingEvent.create_event(
|
||||||
|
user_id='user1',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='task',
|
||||||
|
entity_id='task1',
|
||||||
|
action='activated',
|
||||||
|
points_before=0,
|
||||||
|
points_after=10
|
||||||
|
)
|
||||||
|
|
||||||
|
event2 = TrackingEvent.create_event(
|
||||||
|
user_id='user1',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='reward',
|
||||||
|
entity_id='reward1',
|
||||||
|
action='requested',
|
||||||
|
points_before=10,
|
||||||
|
points_after=10
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_tracking_event(event1)
|
||||||
|
insert_tracking_event(event2)
|
||||||
|
|
||||||
|
# Query by child
|
||||||
|
events, total = get_tracking_events_by_child('child1', limit=10, offset=0)
|
||||||
|
assert total == 2
|
||||||
|
assert len(events) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_with_filters():
|
||||||
|
"""Test querying with entity_type and action filters."""
|
||||||
|
tracking_events_db.truncate()
|
||||||
|
|
||||||
|
# Insert task activation
|
||||||
|
task_event = TrackingEvent.create_event(
|
||||||
|
user_id='user1',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='task',
|
||||||
|
entity_id='task1',
|
||||||
|
action='activated',
|
||||||
|
points_before=0,
|
||||||
|
points_after=10
|
||||||
|
)
|
||||||
|
insert_tracking_event(task_event)
|
||||||
|
|
||||||
|
# Insert reward request
|
||||||
|
reward_event = TrackingEvent.create_event(
|
||||||
|
user_id='user1',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='reward',
|
||||||
|
entity_id='reward1',
|
||||||
|
action='requested',
|
||||||
|
points_before=10,
|
||||||
|
points_after=10
|
||||||
|
)
|
||||||
|
insert_tracking_event(reward_event)
|
||||||
|
|
||||||
|
# Filter by entity_type
|
||||||
|
events, total = get_tracking_events_by_child('child1', entity_type='task')
|
||||||
|
assert total == 1
|
||||||
|
assert events[0].entity_type == 'task'
|
||||||
|
|
||||||
|
# Filter by action
|
||||||
|
events, total = get_tracking_events_by_child('child1', action='requested')
|
||||||
|
assert total == 1
|
||||||
|
assert events[0].action == 'requested'
|
||||||
|
|
||||||
|
|
||||||
|
def test_pagination():
|
||||||
|
"""Test offset-based pagination."""
|
||||||
|
tracking_events_db.truncate()
|
||||||
|
|
||||||
|
# Insert 5 events
|
||||||
|
for i in range(5):
|
||||||
|
event = TrackingEvent.create_event(
|
||||||
|
user_id='user1',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='task',
|
||||||
|
entity_id=f'task{i}',
|
||||||
|
action='activated',
|
||||||
|
points_before=i * 10,
|
||||||
|
points_after=(i + 1) * 10
|
||||||
|
)
|
||||||
|
insert_tracking_event(event)
|
||||||
|
|
||||||
|
# First page
|
||||||
|
events, total = get_tracking_events_by_child('child1', limit=2, offset=0)
|
||||||
|
assert total == 5
|
||||||
|
assert len(events) == 2
|
||||||
|
|
||||||
|
# Second page
|
||||||
|
events, total = get_tracking_events_by_child('child1', limit=2, offset=2)
|
||||||
|
assert total == 5
|
||||||
|
assert len(events) == 2
|
||||||
|
|
||||||
|
# Last page
|
||||||
|
events, total = get_tracking_events_by_child('child1', limit=2, offset=4)
|
||||||
|
assert total == 5
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymize_tracking_events():
|
||||||
|
"""Test anonymizing tracking events on user deletion."""
|
||||||
|
tracking_events_db.truncate()
|
||||||
|
|
||||||
|
event = TrackingEvent.create_event(
|
||||||
|
user_id='user_to_delete',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='task',
|
||||||
|
entity_id='task1',
|
||||||
|
action='activated',
|
||||||
|
points_before=0,
|
||||||
|
points_after=10
|
||||||
|
)
|
||||||
|
insert_tracking_event(event)
|
||||||
|
|
||||||
|
# Anonymize
|
||||||
|
count = anonymize_tracking_events_for_user('user_to_delete')
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
# Verify user_id is None
|
||||||
|
events, total = get_tracking_events_by_child('child1')
|
||||||
|
assert total == 1
|
||||||
|
assert events[0].user_id is None
|
||||||
|
assert events[0].child_id == 'child1' # Child data preserved
|
||||||
|
|
||||||
|
|
||||||
|
def test_points_change_correctness():
|
||||||
|
"""Test that points before/after/delta are tracked correctly."""
|
||||||
|
tracking_events_db.truncate()
|
||||||
|
|
||||||
|
# Task activation (points increase)
|
||||||
|
task_event = TrackingEvent.create_event(
|
||||||
|
user_id='user1',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='task',
|
||||||
|
entity_id='task1',
|
||||||
|
action='activated',
|
||||||
|
points_before=50,
|
||||||
|
points_after=60
|
||||||
|
)
|
||||||
|
assert task_event.delta == 10
|
||||||
|
insert_tracking_event(task_event)
|
||||||
|
|
||||||
|
# Reward redeem (points decrease)
|
||||||
|
reward_event = TrackingEvent.create_event(
|
||||||
|
user_id='user1',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='reward',
|
||||||
|
entity_id='reward1',
|
||||||
|
action='redeemed',
|
||||||
|
points_before=60,
|
||||||
|
points_after=40
|
||||||
|
)
|
||||||
|
assert reward_event.delta == -20
|
||||||
|
insert_tracking_event(reward_event)
|
||||||
|
|
||||||
|
# Query and verify
|
||||||
|
events, _ = get_tracking_events_by_child('child1')
|
||||||
|
assert len(events) == 2
|
||||||
|
assert events[0].delta == -20 # Most recent (sorted desc)
|
||||||
|
assert events[1].delta == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_points_change_for_request_and_cancel():
|
||||||
|
"""Test that reward request and cancel have delta=0."""
|
||||||
|
tracking_events_db.truncate()
|
||||||
|
|
||||||
|
# Request
|
||||||
|
request_event = TrackingEvent.create_event(
|
||||||
|
user_id='user1',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='reward',
|
||||||
|
entity_id='reward1',
|
||||||
|
action='requested',
|
||||||
|
points_before=100,
|
||||||
|
points_after=100
|
||||||
|
)
|
||||||
|
assert request_event.delta == 0
|
||||||
|
insert_tracking_event(request_event)
|
||||||
|
|
||||||
|
# Cancel
|
||||||
|
cancel_event = TrackingEvent.create_event(
|
||||||
|
user_id='user1',
|
||||||
|
child_id='child1',
|
||||||
|
entity_type='reward',
|
||||||
|
entity_id='reward1',
|
||||||
|
action='cancelled',
|
||||||
|
points_before=100,
|
||||||
|
points_after=100
|
||||||
|
)
|
||||||
|
assert cancel_event.delta == 0
|
||||||
|
insert_tracking_event(cancel_event)
|
||||||
|
|
||||||
|
events, _ = get_tracking_events_by_child('child1')
|
||||||
|
assert all(e.delta == 0 for e in events)
|
||||||
84
backend/utils/tracking_logger.py
Normal file
84
backend/utils/tracking_logger.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Per-user rotating audit logger for tracking events."""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from config.paths import get_logs_dir
|
||||||
|
from models.tracking_event import TrackingEvent
|
||||||
|
|
||||||
|
|
||||||
|
# Store handlers per user_id to avoid recreating
|
||||||
|
_user_loggers = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_tracking_logger(user_id: str) -> logging.Logger:
|
||||||
|
"""
|
||||||
|
Get or create a per-user rotating file logger for tracking events.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID for the log file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Logger instance configured for the user
|
||||||
|
"""
|
||||||
|
if user_id in _user_loggers:
|
||||||
|
return _user_loggers[user_id]
|
||||||
|
|
||||||
|
logs_dir = get_logs_dir()
|
||||||
|
os.makedirs(logs_dir, exist_ok=True)
|
||||||
|
|
||||||
|
log_file = os.path.join(logs_dir, f'tracking_user_{user_id}.log')
|
||||||
|
|
||||||
|
logger = logging.getLogger(f'tracking.user.{user_id}')
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False # Don't propagate to root logger
|
||||||
|
|
||||||
|
# Rotating file handler: 10MB max, keep 5 backups
|
||||||
|
handler = RotatingFileHandler(
|
||||||
|
log_file,
|
||||||
|
maxBytes=10 * 1024 * 1024, # 10MB
|
||||||
|
backupCount=5,
|
||||||
|
encoding='utf-8'
|
||||||
|
)
|
||||||
|
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'%(asctime)s | %(message)s',
|
||||||
|
datefmt='%Y-%m-%d %H:%M:%S'
|
||||||
|
)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
logger.addHandler(handler)
|
||||||
|
_user_loggers[user_id] = logger
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def log_tracking_event(event: TrackingEvent) -> None:
|
||||||
|
"""
|
||||||
|
Log a tracking event to the user's audit log file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: TrackingEvent to log
|
||||||
|
"""
|
||||||
|
if not event.user_id:
|
||||||
|
# If user was deleted (anonymized), skip logging
|
||||||
|
return
|
||||||
|
|
||||||
|
logger = get_tracking_logger(event.user_id)
|
||||||
|
|
||||||
|
log_msg = (
|
||||||
|
f"user_id={event.user_id} | "
|
||||||
|
f"child_id={event.child_id} | "
|
||||||
|
f"entity_type={event.entity_type} | "
|
||||||
|
f"entity_id={event.entity_id} | "
|
||||||
|
f"action={event.action} | "
|
||||||
|
f"points_before={event.points_before} | "
|
||||||
|
f"points_after={event.points_after} | "
|
||||||
|
f"delta={event.delta:+d} | "
|
||||||
|
f"occurred_at={event.occurred_at}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.metadata:
|
||||||
|
metadata_str = ' | '.join(f"{k}={v}" for k, v in event.metadata.items())
|
||||||
|
log_msg += f" | {metadata_str}"
|
||||||
|
|
||||||
|
logger.info(log_msg)
|
||||||
@@ -15,3 +15,23 @@ export function isEmailValid(email: string): boolean {
|
|||||||
export function isPasswordStrong(password: string): boolean {
|
export function isPasswordStrong(password: string): boolean {
|
||||||
return /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,}$/.test(password)
|
return /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,}$/.test(password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch tracking events for a child with optional filters.
|
||||||
|
*/
|
||||||
|
export async function getTrackingEventsForChild(params: {
|
||||||
|
childId: string
|
||||||
|
entityType?: 'task' | 'reward' | 'penalty'
|
||||||
|
action?: 'activated' | 'requested' | 'redeemed' | 'cancelled'
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}): Promise<Response> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
query.set('child_id', params.childId)
|
||||||
|
if (params.entityType) query.set('entity_type', params.entityType)
|
||||||
|
if (params.action) query.set('action', params.action)
|
||||||
|
if (params.limit) query.set('limit', params.limit.toString())
|
||||||
|
if (params.offset) query.set('offset', params.offset.toString())
|
||||||
|
|
||||||
|
return fetch(`/api/admin/tracking?${query.toString()}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export interface Event {
|
|||||||
| ChildRewardsSetEventPayload
|
| ChildRewardsSetEventPayload
|
||||||
| TaskModifiedEventPayload
|
| TaskModifiedEventPayload
|
||||||
| RewardModifiedEventPayload
|
| RewardModifiedEventPayload
|
||||||
|
| TrackingEventCreatedPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChildModifiedEventPayload {
|
export interface ChildModifiedEventPayload {
|
||||||
@@ -135,3 +136,45 @@ export interface RewardModifiedEventPayload {
|
|||||||
reward_id: string
|
reward_id: string
|
||||||
operation: 'ADD' | 'DELETE' | 'EDIT'
|
operation: 'ADD' | 'DELETE' | 'EDIT'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrackingEventCreatedPayload {
|
||||||
|
tracking_event_id: string
|
||||||
|
child_id: string
|
||||||
|
entity_type: EntityType
|
||||||
|
action: ActionType
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EntityType = 'task' | 'reward' | 'penalty'
|
||||||
|
export type ActionType = 'activated' | 'requested' | 'redeemed' | 'cancelled'
|
||||||
|
|
||||||
|
export interface TrackingEvent {
|
||||||
|
id: string
|
||||||
|
user_id: string | null
|
||||||
|
child_id: string
|
||||||
|
entity_type: EntityType
|
||||||
|
entity_id: string
|
||||||
|
action: ActionType
|
||||||
|
points_before: number
|
||||||
|
points_after: number
|
||||||
|
delta: number
|
||||||
|
occurred_at: string
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
metadata?: Record<string, any> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TRACKING_EVENT_FIELDS = [
|
||||||
|
'id',
|
||||||
|
'user_id',
|
||||||
|
'child_id',
|
||||||
|
'entity_type',
|
||||||
|
'entity_id',
|
||||||
|
'action',
|
||||||
|
'points_before',
|
||||||
|
'points_after',
|
||||||
|
'delta',
|
||||||
|
'occurred_at',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'metadata',
|
||||||
|
] as const
|
||||||
|
|||||||
Reference in New Issue
Block a user