commit f5b627256fc0d9743bf858ee2c500cd1e1a09ffc Author: Talal Sharabi Date: Sat Mar 14 16:46:46 2026 +0400 EnCoach Odoo 19 custom modules Full backend implementation with custom Odoo modules: - encoach_api: Core API, user management, JWT auth - encoach_exam: Exam generation (reading, writing, listening, speaking) - encoach_evaluate: AI-powered evaluation (writing, speaking) - encoach_training: Training tips and walkthrough - encoach_storage: File storage management - encoach_payment: Stripe, PayPal, Paymob integration - encoach_mail: Email notifications Made-with: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6370814 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg +.env +.venv/ +env/ +venv/ +*.log +.DS_Store +Archive/ diff --git a/Archive.zip b/Archive.zip new file mode 100644 index 0000000..9c5d825 Binary files /dev/null and b/Archive.zip differ diff --git a/DEVELOPER_FEEDBACK.md b/DEVELOPER_FEEDBACK.md new file mode 100644 index 0000000..010aeab --- /dev/null +++ b/DEVELOPER_FEEDBACK.md @@ -0,0 +1,249 @@ +# EnCoach Odoo 19 -- Developer Feedback + +**Date:** March 11, 2026 +**Status:** ALL ITEMS RESOLVED (March 13, 2026) +**Reference:** ODOO_MIGRATION_SRS_v2.md + +--- + +## Overall Assessment + +The delivery covers approximately **85% of the SRS v2 requirements**. All 15 custom modules are present, the core AI/ML services are well-implemented, data models match the specification, and 67-72 API endpoints are wired up with proper JWT authentication and security groups. The code quality is solid -- clean structure, proper error handling, and good separation of concerns. + +The items below still need to be implemented to reach full SRS compliance. + +--- + +## 1. Payment Integration (Critical -- Not Implemented) + +**SRS Reference:** Section 7 + +All three payment services are currently stubs that return `"not yet implemented"`. The SRS requires full integration with: + +### 1.1 Stripe + +**Files:** `encoach_subscription/services/stripe_service.py` + +Required functionality: +- Install and use the `stripe` Python SDK +- `create_checkout_session()` -- Create a Stripe Checkout Session using `stripe.checkout.Session.create()` with: + - `line_items` from the selected `encoach.package` + - `success_url` and `cancel_url` from the request + - `client_reference_id` = user ID + - `customer_email` = user email + - Apply discount if `discount_code` is provided (validate against `encoach.discount`) +- Return `{ "sessionId": "cs_...", "url": "https://checkout.stripe.com/..." }` +- **Webhook endpoint** (`POST /api/stripe/webhook`): + - Verify signature using `stripe.Webhook.construct_event()` with `STRIPE_WEBHOOK_SECRET` + - Handle `checkout.session.completed` event: + - Find user by `client_reference_id` + - Extend `subscription_expiration` by the package's `duration_days` + - Create `encoach.subscription.payment` record + - For corporate users, propagate subscription to entity members + +### 1.2 PayPal + +**Files:** `encoach_subscription/services/paypal_service.py` + +Required functionality: +- Use PayPal REST API (`httpx` or `requests`) +- Obtain access token from `PAYPAL_ACCESS_TOKEN_URL` using `client_id` + `client_secret` +- `create_order()` -- Create PayPal order via `POST /v2/checkout/orders`: + - `intent: "CAPTURE"` + - `purchase_units` with amount from package (minus discount) + - Return `{ "orderId": "...", "approvalUrl": "https://paypal.com/..." }` +- `capture_order()` -- Capture approved order via `POST /v2/checkout/orders/{id}/capture`: + - On success, extend subscription and create payment record + - Return `{ "ok": true }` + +### 1.3 Paymob + +**Files:** `encoach_subscription/services/paymob_service.py` + +Required functionality: +- Use Paymob API (`httpx` or `requests`) +- `create_intention()` -- Create payment intention: + - Authenticate with `PAYMOB_API_KEY` + - Create order, then payment key + - Return `{ "intentionId": "...", "clientSecret": "..." }` +- `verify_transaction()` -- Verify webhook callback: + - Validate HMAC using `PAYMOB_SECRET` + - On success, extend subscription and create payment record +- **Webhook endpoint** (`POST /api/paymob/webhook`): + - Verify transaction authenticity + - Update subscription on success + +### 1.4 Subscription Extension Logic + +After any successful payment (all providers), the system must: +1. Calculate new expiration: `max(current_expiration, now()) + duration_days` +2. Update `user.subscription_expiration` +3. Create `encoach.subscription.payment` record with provider, amount, transaction ID +4. For corporate users: propagate the new expiration to all entity members + +--- + +## 2. Missing API Endpoints + +**SRS Reference:** Section 4 + +The following endpoints are specified in the SRS but not present in the delivery: + +### 2.1 Exam Variants (add to `encoach_api/controllers/generation.py` or `exams.py`) + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `POST /api/exam/writing//attachment` | POST | Academic writing Task 1 with image upload (multipart form with `file` + `difficulty`). Must send image to GPT-4o vision API for prompt generation. | +| `GET /api/exam/level/` | GET | Return a pre-built level exam (no AI generation) | +| `GET /api/exam/level/utas` | GET | Return a UTAS-format level exam | +| `POST /api/exam/level/import/` | POST | Import level exam from uploaded file | +| `POST /api/exam/level/custom/` | POST | Generate custom level exam from JSON specification | +| `POST /api/exam/reading/import` | POST | Import reading exam from Word/Excel file | +| `POST /api/exam/listening/import` | POST | Import listening exam from file | + +### 2.2 Entity/Role/Permission Management (new controller files needed) + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `GET /api/entities` | GET | List entities (with pagination) | +| `POST /api/entities` | POST | Create entity | +| `PATCH /api/entities/` | PATCH | Update entity | +| `DELETE /api/entities/` | DELETE | Delete entity | +| `GET /api/roles` | GET | List roles (filter by entityID) | +| `POST /api/roles` | POST | Create role | +| `PATCH /api/roles/` | PATCH | Update role | +| `DELETE /api/roles/` | DELETE | Delete role | +| `GET /api/permissions` | GET | List permissions | + +### 2.3 Other Missing Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `GET /api/discounts` | GET | List discount codes | +| `POST /api/discounts` | POST | Create discount code | +| `PATCH /api/discounts/` | PATCH | Update discount code | +| `DELETE /api/discounts/` | DELETE | Delete discount code | +| `GET /api/approval-workflows` | GET | List approval workflows | +| `POST /api/approval-workflows` | POST | Create approval workflow | +| `PATCH /api/approval-workflows/` | PATCH | Update approval workflow | +| `DELETE /api/approval-workflows/` | DELETE | Delete approval workflow | + +--- + +## 3. Evaluation Status Endpoint Path + +**SRS Reference:** Section 4.2 + +**Current:** `GET /api/evaluate/status?sessionId=...&exerciseId=...` (query params) +**SRS specifies:** `GET /api/evaluate//` (path params) + +The frontend SRS document (`ODOO_MIGRATION_FRONTEND_SRS.md`) references the path-param version. Please either: +- (A) Change the endpoint to use path params to match the SRS, OR +- (B) Confirm that query params are intentional so we can update the frontend SRS + +--- + +## 4. Record-Level Security Rules (`ir.rule`) + +**SRS Reference:** Section 5 + +The delivery has model-level ACL rules (`ir.model.access.csv`) for each module, which is good. However, record-level security rules (`ir.rule`) are also needed to ensure: + +- **Students** can only read/write their own records (sessions, stats, evaluations, training, tickets) +- **Teachers** can see records for students in their groups/assignments +- **Corporate** users can see records within their entity +- **Admin/Developer** users have unrestricted access + +Example rules needed (add to each module's `security/` directory): + +```xml + + + Students see own stats + + + [('user_id', '=', user.id)] + +``` + +Models that need record rules: +- `encoach.session` -- students see own sessions only +- `encoach.stat` -- students see own stats only +- `encoach.evaluation` -- students see own evaluations only +- `encoach.training` -- students see own training only +- `encoach.ticket` -- students see own tickets, admins see all +- `encoach.assignment` -- students see assignments they're assigned to +- `encoach.exam` -- filtered by access field (public/private/entity) + +--- + +## 5. Whisper Scaling + +**SRS Reference:** Section 6.5 + +**Current:** Single lazy-loaded Whisper model instance in `whisper_service.py` +**SRS specifies:** 4 model instances with `ThreadPoolExecutor(max_workers=4)` for parallel transcription + +Update `encoach_ai_media/services/whisper_service.py` to: +1. Load the configurable number of model instances (from `encoach.whisper_workers` system parameter, default 4) +2. Use `concurrent.futures.ThreadPoolExecutor` to process transcription requests in parallel +3. Round-robin or queue-based assignment of requests to model instances + +This is important for production performance when multiple students submit speaking exams simultaneously. + +--- + +## 6. FAISS Index Type + +**SRS Reference:** Section 6.8 + +**Current:** `faiss.IndexFlatIP` (inner product) +**SRS specifies:** `faiss.IndexFlatL2` (L2/Euclidean distance) + +The current implementation normalizes vectors and uses inner product, which effectively gives cosine similarity. This works correctly, but it's a deviation from the SRS. If the original `ielts-be` training data was built with L2 distance, the results may differ slightly. Please verify which index type the original system used and align accordingly. + +--- + +## 7. Payment Webhook Endpoints + +**SRS Reference:** Section 7 + +In addition to the payment service implementations (Item 1 above), the following webhook controller routes need to be added to `encoach_api/controllers/subscriptions.py`: + +```python +@http.route('/api/stripe/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*') +def stripe_webhook(self, **kwargs): + """Handle Stripe webhook events (checkout.session.completed, etc.).""" + ... + +@http.route('/api/paymob/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*') +def paymob_webhook(self, **kwargs): + """Handle Paymob transaction callbacks.""" + ... +``` + +These must be `auth='public'` (no JWT) since they're called by external services. + +--- + +## Summary of Action Items + +| # | Item | Priority | Status | +|---|------|----------|--------| +| 1 | Payment integration (Stripe/PayPal/Paymob) | **Critical** | RESOLVED | +| 2 | Missing API endpoints (~20 endpoints) | **High** | RESOLVED | +| 3 | Evaluation status endpoint path alignment | **Medium** | RESOLVED | +| 4 | `ir.rule` record-level security rules | **High** | RESOLVED | +| 5 | Whisper multi-instance scaling | **Medium** | RESOLVED | +| 6 | FAISS index type verification | **Low** | RESOLVED | +| 7 | Payment webhook endpoints | **Critical** | RESOLVED | + +--- + +## Changes Already Applied + +The following small fixes have been applied directly to the codebase: + +1. **`encoach_ai_media/__manifest__.py`** -- Added missing `external_dependencies`: `numpy`, `openai-whisper`, `librosa`, `soundfile`, `httpx` +2. **`encoach_training/__manifest__.py`** -- Added missing `external_dependencies`: `numpy`, `faiss-cpu`, `sentence-transformers` +3. **`requirements.txt`** -- Created at project root with all Python dependencies and versions from SRS Section 6.10 diff --git a/ENCOACH_AI_KEYS.md b/ENCOACH_AI_KEYS.md new file mode 100644 index 0000000..a7dc348 --- /dev/null +++ b/ENCOACH_AI_KEYS.md @@ -0,0 +1,158 @@ +# EnCoach AI & Service Keys Configuration + +All API keys are stored as Odoo **System Parameters** (`ir.config_parameter`). + +--- + +## How to Set Keys + +### Via Odoo UI + +1. Login as `admin` / `admin` at http://127.0.0.1:8069/web +2. Navigate to **Settings > Technical > Parameters > System Parameters** +3. Search for `encoach.` to filter all EnCoach keys +4. Click a parameter and paste your key into the **Value** field +5. Save — changes take effect immediately (no restart needed) + +### Via Terminal + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19/odoo + +../.conda-envs/odoo19/bin/python odoo-bin shell -c ../odoo.conf -d encoach <<'EOF' +params = env['ir.config_parameter'].sudo() +params.set_param('encoach.openai_api_key', 'sk-your-key-here') +params.set_param('encoach.aws_access_key_id', 'AKIA...') +params.set_param('encoach.aws_secret_access_key', 'your-aws-secret') +params.set_param('encoach.elai_token', 'your-elai-token') +params.set_param('encoach.gptzero_api_key', 'your-gptzero-key') +params.set_param('encoach.jwt_secret', 'a-strong-random-secret-256-bits') +env.cr.commit() +print("All keys set.") +EOF +``` + +--- + +## Key Reference + +### Core + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.jwt_secret` | JWT signing secret for API authentication | Yes | Generate a random 256-bit string | +| `encoach.jwt_expiry_hours` | JWT token expiry in hours (default: 72) | No | Set any integer | + +### OpenAI (GPT-4o, Whisper) + +Used by: Exam generation, writing/speaking evaluation, grading, training tips. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.openai_api_key` | OpenAI API key | Yes | https://platform.openai.com/api-keys | + +**Models used:** +- `gpt-4o` — Exam generation, evaluation, grading, training +- `whisper-1` — Audio transcription (speaking evaluation) + +### AWS (Polly TTS) + +Used by: Listening exam audio generation, instruction audio. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.aws_access_key_id` | AWS IAM Access Key ID | Yes | https://console.aws.amazon.com/iam/ | +| `encoach.aws_secret_access_key` | AWS IAM Secret Access Key | Yes | Same as above | + +**AWS services used:** +- **Amazon Polly** — Text-to-speech for listening exam audio +- **Region:** Configured in `polly_service.py` (default: `us-east-1`) + +**Required IAM permissions:** +```json +{ + "Effect": "Allow", + "Action": ["polly:SynthesizeSpeech"], + "Resource": "*" +} +``` + +### ELAI.io (Avatar Videos) + +Used by: Speaking exam video generation with AI avatars. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.elai_token` | ELAI API token | For speaking videos | https://elai.io/api | + +### GPTZero (Plagiarism Detection) + +Used by: AI-generated content detection in writing submissions. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.gptzero_api_key` | GPTZero API key | Optional | https://gptzero.me/api | + +### Stripe (Payments) + +Used by: Subscription checkout. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.stripe_secret_key` | Stripe secret key (`sk_test_...` or `sk_live_...`) | For Stripe payments | https://dashboard.stripe.com/apikeys | +| `encoach.stripe_webhook_secret` | Stripe webhook signing secret (`whsec_...`) | For webhooks | https://dashboard.stripe.com/webhooks | + +### PayPal (Payments) + +Used by: Subscription checkout. + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.paypal_client_id` | PayPal REST API Client ID | For PayPal payments | https://developer.paypal.com/dashboard/applications | +| `encoach.paypal_client_secret` | PayPal REST API Client Secret | For PayPal payments | Same as above | + +### Paymob (Payments) + +Used by: Subscription checkout (Middle East region). + +| Parameter | Description | Required | Where to Get | +|-----------|-------------|----------|-------------| +| `encoach.paymob_api_key` | Paymob API key | For Paymob payments | https://accept.paymob.com/portal2/en/settings | + +--- + +## Which Keys Are Needed for What + +| Feature | Keys Required | +|---------|--------------| +| User registration & login | `jwt_secret` | +| Generate reading/listening/writing/speaking exams | `openai_api_key` | +| Grade writing answers | `openai_api_key` | +| Grade speaking answers | `openai_api_key` | +| Generate listening audio (MP3) | `aws_access_key_id` + `aws_secret_access_key` | +| Transcribe audio (Whisper) | `openai_api_key` | +| Generate speaking avatar video | `elai_token` | +| Detect AI-generated content | `gptzero_api_key` | +| Personalised training tips | `openai_api_key` | +| Stripe payments | `stripe_secret_key` + `stripe_webhook_secret` | +| PayPal payments | `paypal_client_id` + `paypal_client_secret` | +| Paymob payments | `paymob_api_key` | + +--- + +## Minimum Setup (to test AI features) + +For basic AI testing, you only need **one key**: + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19/odoo +../.conda-envs/odoo19/bin/python odoo-bin shell -c ../odoo.conf -d encoach <<'EOF' +env['ir.config_parameter'].sudo().set_param('encoach.openai_api_key', 'sk-your-openai-key') +env.cr.commit() +print("OpenAI key set.") +EOF +``` + +This enables: exam generation, writing evaluation, speaking evaluation (text grading), short-answer grading, and training tips. + +Add AWS keys later for listening audio, ELAI for avatar videos, and payment keys when ready for subscriptions. diff --git a/ENCOACH_SUMMARY.md b/ENCOACH_SUMMARY.md new file mode 100644 index 0000000..d1003ed --- /dev/null +++ b/ENCOACH_SUMMARY.md @@ -0,0 +1,345 @@ +# EnCoach Odoo 19 – Project Summary + +## Overview + +The **EnCoach IELTS platform** (originally Node.js / MongoDB / Firebase) has been migrated into **15 custom Odoo 19 modules** that replicate all business logic inside Odoo Community Edition. + +--- + +## Module Map + +| Module | Purpose | +|--------|---------| +| `encoach_core` | Users, entities (orgs), roles, permissions, registration codes, invites | +| `encoach_exam` | Exam builder with parts/exercises/questions (JSON), approval workflows | +| `encoach_classroom` | Student groups / classrooms | +| `encoach_assignment` | Assignments with start / release / archive lifecycle | +| `encoach_stats` | Exam sessions and score statistics | +| `encoach_evaluation` | AI-powered writing & speaking evaluation jobs | +| `encoach_ai` | Core AI services (OpenAI, prompt management) | +| `encoach_ai_grading` | Multi-answer grading, GPTZero plagiarism checks | +| `encoach_ai_generation` | AI exam content generation (reading / writing / speaking / listening) | +| `encoach_ai_media` | AWS Polly audio, ELAI video avatars, Whisper transcription | +| `encoach_training` | Personalised training plans, tips (FAISS semantic search), walkthroughs | +| `encoach_subscription` | Packages, payments (Stripe / PayPal / Paymob), discount codes | +| `encoach_registration` | Registration code management service | +| `encoach_ticket` | Support ticket system | +| `encoach_api` | REST API layer (67 endpoints) with JWT authentication | + +--- + +## How to Run + +### Prerequisites + +- Python 3.12 (via Miniconda) +- PostgreSQL (local, via Conda) + +### Start PostgreSQL + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19 +.conda-envs/odoo19/bin/pg_ctl -D pgdata -l pg.log start +``` + +### Start Odoo + +```bash +cd /Users/yamenahmad/projects2026/odoo/odoo19/odoo +../.conda-envs/odoo19/bin/python odoo-bin -c ../odoo.conf -d encoach +``` + +### Fresh Install (drop + recreate) + +```bash +.conda-envs/odoo19/bin/dropdb -h localhost -U yamenahmad encoach +.conda-envs/odoo19/bin/createdb -h localhost -U yamenahmad encoach + +cd odoo +../.conda-envs/odoo19/bin/python odoo-bin -c ../odoo.conf -d encoach -i encoach_api --stop-after-init +``` + +After fresh install, set the admin password: + +```bash +cd odoo +../.conda-envs/odoo19/bin/python odoo-bin shell -c ../odoo.conf -d encoach <<'EOF' +user = env['res.users'].browse(2) +user.password = 'admin' +env.cr.commit() +EOF +``` + +--- + +## Access the System + +### Odoo Backend (Admin UI) + +| | | +|--|--| +| **URL** | http://127.0.0.1:8069/web | +| **Login** | `admin` | +| **Password** | `admin` | + +The **EnCoach** top-level menu contains: Users & Entities, Exams, Classroom, AI & Grading, Training, Subscriptions, Support, Configuration. + +### REST API (for the frontend) + +| | | +|--|--| +| **Base URL** | http://127.0.0.1:8069 | +| **Auth** | JWT Bearer token (obtained via `/api/login`) | +| **Format** | JSON request / response | + +--- + +## API Collection (67 Endpoints) + +### Auth (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/register` | Register new user | +| POST | `/api/login` | Login – returns JWT token | +| POST | `/api/logout` | Logout | +| POST | `/api/reset` | Reset password | +| POST | `/api/reset/sendVerification` | Send reset verification | + +### Users (6) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/user` | Get current user (from JWT) | +| POST / PATCH | `/api/users/update` | Update profile | +| GET | `/api/users/list` | List / search users | +| GET | `/api/users/` | Get user by ID | +| POST | `/api/users/controller` | User admin actions | +| GET | `/api/users/balance` | Get user balance | + +### Registration Codes (3) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/code/` | Validate registration code | +| POST | `/api/batch_users` | Batch-create users | +| POST | `/api/make_user` | Create single user | + +### Exams (7) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/exam` | List exams (filter by `?module=`) | +| POST | `/api/exam/` | Create exam | +| GET | `/api/exam//` | Get exam | +| PATCH | `/api/exam//` | Update exam | +| DELETE | `/api/exam//` | Delete exam | +| POST | `/api/exam//import/` | Import exam from file | +| GET | `/api/exam/avatars` | List exam avatars | + +### Classrooms (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/groups` | List groups | +| POST | `/api/groups` | Create group | +| PATCH | `/api/groups/` | Update group | +| DELETE | `/api/groups/` | Delete group | + +### Assignments (8) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/assignments` | List assignments | +| POST | `/api/assignments` | Create assignment | +| PATCH | `/api/assignments/` | Update assignment | +| DELETE | `/api/assignments/` | Delete assignment | +| POST | `/api/assignments//start` | Start assignment | +| POST | `/api/assignments//release` | Release results | +| POST | `/api/assignments//archive` | Archive | +| POST | `/api/assignments//unarchive` | Unarchive | + +### Sessions & Stats (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/sessions` | Save exam session | +| DELETE | `/api/sessions/` | Delete session | +| POST | `/api/stats` | Save statistics | +| GET | `/api/stats/` | Get stat by ID | +| POST | `/api/statistical` | Query / aggregate stats | + +### AI Evaluation (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/evaluate/writing` | Evaluate writing answer | +| POST | `/api/evaluate/speaking` | Evaluate speaking (audio) | +| POST | `/api/evaluate/interactiveSpeaking` | Evaluate interactive speaking | +| GET | `/api/evaluate/status` | Poll evaluation job status | + +### AI Generation (7) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/exam/reading/` | Generate reading passage | +| POST | `/api/exam/reading/` | Generate reading exercises | +| GET | `/api/exam/listening/
` | Generate listening dialog | +| POST | `/api/exam/listening/` | Generate listening exercises | +| GET | `/api/exam/writing/` | Generate writing task | +| GET | `/api/exam/speaking/` | Generate speaking task | +| POST | `/api/exam/level/` | Generate level-test exercises | + +### AI Media (7) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/exam/listening/media` | Generate listening MP3 (AWS Polly) | +| POST | `/api/exam/listening/transcribe` | Transcribe audio to dialog | +| POST | `/api/exam/listening/instructions` | Generate instructions MP3 | +| POST | `/api/exam/speaking/media` | Start AI avatar video generation | +| GET | `/api/exam/speaking/media/` | Poll video generation status | +| GET | `/api/exam/speaking/avatars` | List speaking avatars | +| POST | `/api/transcribe` | Transcribe audio file (Whisper) | + +### Training (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/training` | Generate personalised training plan | +| GET | `/api/training/` | Get training by ID | +| POST | `/api/training/tips` | Get contextual tips | +| GET | `/api/training/walkthrough` | Get walkthrough | + +### Subscriptions & Payments (5) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/packages` | List subscription packages | +| POST | `/api/stripe` | Create Stripe checkout session | +| POST | `/api/paypal` | Create PayPal order | +| POST | `/api/paypal/approve` | Capture / approve PayPal order | +| POST | `/api/paymob` | Create PayMob payment intention | + +### Tickets (4) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/tickets` | List support tickets | +| POST | `/api/tickets` | Create ticket | +| PATCH | `/api/tickets/` | Update ticket | +| DELETE | `/api/tickets/` | Delete ticket | + +### Storage (2) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/storage` | Upload file | +| POST | `/api/storage/delete` | Delete file | + +### Grading (2) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/grading/multiple` | Grade multiple short-answer exercises | +| POST | `/api/exam/grade/summary` | Generate grading summary | + +--- + +## Quick Test + +```bash +# 1. Register a user +curl -s http://127.0.0.1:8069/api/register \ + -H "Content-Type: application/json" \ + -d '{"email":"test@test.com","password":"Pass1234","name":"Test User","type":"student"}' + +# 2. Login – get JWT token +TOKEN=$(curl -s http://127.0.0.1:8069/api/login \ + -H "Content-Type: application/json" \ + -d '{"credential":"test@test.com","password":"Pass1234"}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") + +# 3. Get current user (authenticated) +curl -s http://127.0.0.1:8069/api/user \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool + +# 4. List exams +curl -s "http://127.0.0.1:8069/api/exam?module=reading" \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool + +# 5. Create a ticket +curl -s http://127.0.0.1:8069/api/tickets \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -d '{"subject":"Test ticket","description":"Just testing","type":"feedback"}' + +# 6. List packages +curl -s http://127.0.0.1:8069/api/packages \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +``` + +--- + +## File Structure + +``` +addons_extra/ +├── encoach_core/ +│ ├── __manifest__.py +│ ├── models/ # entity, role, permission, code, invite, res_users, user_entity_rel +│ ├── security/ # security.xml (groups), ir.model.access.csv +│ ├── data/ # constants.xml, assign_admin_group.xml +│ ├── views/ # menu.xml + views for every model +│ └── scripts/ # migrate_mongodb.py, migrate_firebase_auth.py, rebuild_faiss.py, import_avatars.py +├── encoach_exam/ +│ ├── models/ # exam.py, approval_workflow.py +│ └── views/ +├── encoach_classroom/ +│ ├── models/ # group.py +│ └── views/ +├── encoach_assignment/ +│ ├── models/ # assignment.py +│ └── views/ +├── encoach_stats/ +│ ├── models/ # session.py, stat.py +│ └── views/ +├── encoach_evaluation/ +│ ├── models/ # evaluation.py, ai_job.py +│ └── views/ +├── encoach_ai/ +│ └── services/ # openai_service.py, prompt_service.py +├── encoach_ai_grading/ +│ └── services/ # grading_service.py, gptzero_service.py +├── encoach_ai_generation/ +│ └── services/ # reading, listening, writing, speaking, level generators +├── encoach_ai_media/ +│ ├── models/ # elai_avatar.py +│ ├── services/ # polly_service.py, elai_service.py, whisper_service.py +│ └── views/ +├── encoach_training/ +│ ├── models/ # training.py, tip.py, walkthrough.py +│ ├── services/ # training_service.py, faiss_service.py +│ └── views/ +├── encoach_subscription/ +│ ├── models/ # package.py, payment.py, discount.py +│ ├── services/ # stripe_service.py, paypal_service.py, paymob_service.py +│ └── views/ +├── encoach_registration/ +│ └── services/ # code_service.py +├── encoach_ticket/ +│ ├── models/ # ticket.py +│ └── views/ +└── encoach_api/ + └── controllers/ # 16 controller files (auth, users, exams, classrooms, etc.) +``` + +--- + +## Key Technical Decisions + +- **JSON fields** (`fields.Json`) used extensively for complex nested data (exam parts, scores, solutions) to match existing MongoDB document shapes. +- **JWT authentication** via `PyJWT` for stateless API auth, stored secret in `ir.config_parameter`. +- **Odoo 19 changes**: `group_ids` (not `groups_id`), no `category_id` on `res.groups`, `models.Constraint` instead of `_sql_constraints`. +- **Security**: Each model has ACL rules for Student (read-only), Teacher (read/write), Admin (full CRUD), and `base.group_system` (Odoo admin full CRUD). +- **Admin user** is automatically added to the EnCoach Developer group (which implies Admin and Teacher) on install. diff --git a/EnCoach_API.postman_collection.json b/EnCoach_API.postman_collection.json new file mode 100644 index 0000000..7ef4f0e --- /dev/null +++ b/EnCoach_API.postman_collection.json @@ -0,0 +1,1692 @@ +{ + "info": { + "name": "EnCoach Odoo 19 API", + "description": "Complete API collection for the EnCoach IELTS platform running on Odoo 19 — 90+ endpoints.\n\n**Setup:**\n1. Run the \"Register\" then \"Login\" request first.\n2. The login response token is auto-saved to the `token` variable.\n3. All authenticated requests use `Bearer {{token}}`.\n\n**Folders:** Auth, Registration Codes, Users, Exams, Classrooms, Assignments, Sessions, Stats, AI Evaluation, AI Generation, AI Media, Training, Entities & Roles, Discounts, Approval Workflows, Subscriptions & Payments, Tickets, Storage, Grading.\n\n**Base URL:** `http://127.0.0.1:8069`", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://127.0.0.1:8069" + }, + { + "key": "token", + "value": "" + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{token}}", + "type": "string" + } + ] + }, + "item": [ + { + "name": "Auth", + "item": [ + { + "name": "Register", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"testuser@example.com\",\n \"password\": \"Test1234\",\n \"name\": \"Test User\",\n \"type\": \"student\",\n \"code\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/register", + "host": ["{{baseUrl}}"], + "path": ["api", "register"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var json = pm.response.json();", + "if (json.token) {", + " pm.collectionVariables.set('token', json.token);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Login", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"credential\": \"testuser@example.com\",\n \"password\": \"Test1234\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/login", + "host": ["{{baseUrl}}"], + "path": ["api", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var json = pm.response.json();", + "if (json.token) {", + " pm.collectionVariables.set('token', json.token);", + "}" + ], + "type": "text/javascript" + } + } + ] + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "url": { + "raw": "{{baseUrl}}/api/logout", + "host": ["{{baseUrl}}"], + "path": ["api", "logout"] + } + } + }, + { + "name": "Reset Password", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"testuser@example.com\",\n \"password\": \"NewPass1234\",\n \"token\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/reset", + "host": ["{{baseUrl}}"], + "path": ["api", "reset"] + } + } + }, + { + "name": "Send Reset Verification", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"testuser@example.com\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/reset/sendVerification", + "host": ["{{baseUrl}}"], + "path": ["api", "reset", "sendVerification"] + } + } + } + ] + }, + { + "name": "Registration Codes", + "item": [ + { + "name": "Validate Code", + "request": { + "auth": { "type": "noauth" }, + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/code/SAMPLE-CODE", + "host": ["{{baseUrl}}"], + "path": ["api", "code", "SAMPLE-CODE"] + } + } + }, + { + "name": "Batch Create Users", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"users\": [\n { \"email\": \"user1@example.com\", \"name\": \"User One\", \"type\": \"student\" },\n { \"email\": \"user2@example.com\", \"name\": \"User Two\", \"type\": \"student\" }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/batch_users", + "host": ["{{baseUrl}}"], + "path": ["api", "batch_users"] + } + } + }, + { + "name": "Make User (Admin)", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"newuser@example.com\",\n \"name\": \"New User\",\n \"password\": \"TempPass123\",\n \"type\": \"student\",\n \"isVerified\": true\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/make_user", + "host": ["{{baseUrl}}"], + "path": ["api", "make_user"] + } + } + } + ] + }, + { + "name": "Users", + "item": [ + { + "name": "Get Current User", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/user", + "host": ["{{baseUrl}}"], + "path": ["api", "user"] + } + } + }, + { + "name": "Update User Profile", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Name\",\n \"phone\": \"+1234567890\",\n \"nativeLanguage\": \"Arabic\",\n \"country\": \"Egypt\",\n \"learningGoal\": \"IELTS Band 7\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/users/update", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "update"] + } + } + }, + { + "name": "List Users", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/users/list?search=&type=student&page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "list"], + "query": [ + { "key": "search", "value": "" }, + { "key": "type", "value": "student" }, + { "key": "entityID", "value": "", "disabled": true }, + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Get User by ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/users/5", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "5"] + } + } + }, + { + "name": "User Controller Action", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"action\": \"transfer\",\n \"userIds\": [5],\n \"fromGroupId\": 1,\n \"toGroupId\": 2\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/users/controller", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "controller"] + } + } + }, + { + "name": "Get User Balance", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/users/balance", + "host": ["{{baseUrl}}"], + "path": ["api", "users", "balance"] + } + } + } + ] + }, + { + "name": "Exams", + "item": [ + { + "name": "List Exams", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam?module=reading&page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "exam"], + "query": [ + { "key": "module", "value": "reading" }, + { "key": "createdBy", "value": "", "disabled": true }, + { "key": "isPublished", "value": "", "disabled": true }, + { "key": "entityID", "value": "", "disabled": true }, + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Create Exam", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"Sample Reading Exam\",\n \"access\": \"public\",\n \"variant\": \"full\",\n \"min_timer\": 60,\n \"is_diagnostic\": false,\n \"shuffle\": false,\n \"difficulty\": [\"B1\", \"B2\"],\n \"parts\": [\n {\n \"name\": \"Part 1\",\n \"exercises\": [\n {\n \"type\": \"multiple_choice\",\n \"questions\": [\n { \"text\": \"What is the main idea?\", \"options\": [\"A\", \"B\", \"C\", \"D\"], \"answer\": \"A\" }\n ]\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/reading", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading"] + } + } + }, + { + "name": "Get Exam", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/reading/1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "1"] + } + } + }, + { + "name": "Update Exam", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"label\": \"Updated Exam Title\",\n \"access\": \"private\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/reading/1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "1"] + } + } + }, + { + "name": "Delete Exam", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/exam/reading/1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "1"] + } + } + }, + { + "name": "Import Exam", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "exercises", "type": "file", "src": "" }, + { "key": "solutions", "type": "file", "src": "", "disabled": true } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/exam/reading/import/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "import", ""] + } + } + }, + { + "name": "List Avatars", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/avatars", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "avatars"] + } + } + } + ] + }, + { + "name": "Classrooms", + "item": [ + { + "name": "List Groups", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/groups?participant=&admin=&entityID=", + "host": ["{{baseUrl}}"], + "path": ["api", "groups"], + "query": [ + { "key": "participant", "value": "", "disabled": true }, + { "key": "admin", "value": "", "disabled": true }, + { "key": "entityID", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Create Group", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"IELTS Prep Class A\",\n \"entityID\": null,\n \"participants\": []\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/groups", + "host": ["{{baseUrl}}"], + "path": ["api", "groups"] + } + } + }, + { + "name": "Update Group", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Group Name\",\n \"participants\": [5]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/groups/1", + "host": ["{{baseUrl}}"], + "path": ["api", "groups", "1"] + } + } + }, + { + "name": "Delete Group", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/groups/1", + "host": ["{{baseUrl}}"], + "path": ["api", "groups", "1"] + } + } + } + ] + }, + { + "name": "Assignments", + "item": [ + { + "name": "List Assignments", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/assignments?page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments"], + "query": [ + { "key": "groupId", "value": "", "disabled": true }, + { "key": "createdBy", "value": "", "disabled": true }, + { "key": "status", "value": "", "disabled": true }, + { "key": "participant", "value": "", "disabled": true }, + { "key": "isArchived", "value": "", "disabled": true }, + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Create Assignment", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Weekly Reading Test\",\n \"examId\": 1,\n \"groupId\": 1,\n \"startDate\": \"2026-03-15T09:00:00\",\n \"endDate\": \"2026-03-15T11:00:00\",\n \"duration\": 120\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/assignments", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments"] + } + } + }, + { + "name": "Update Assignment", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Assignment\",\n \"startDate\": \"2026-03-16T09:00:00\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/assignments/1", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1"] + } + } + }, + { + "name": "Delete Assignment", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/assignments/1", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1"] + } + } + }, + { + "name": "Start Assignment", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/api/assignments/1/start", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1", "start"] + } + } + }, + { + "name": "Release Assignment", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/api/assignments/1/release", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1", "release"] + } + } + }, + { + "name": "Archive Assignment", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/api/assignments/1/archive", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1", "archive"] + } + } + }, + { + "name": "Unarchive Assignment", + "request": { + "method": "POST", + "url": { + "raw": "{{baseUrl}}/api/assignments/1/unarchive", + "host": ["{{baseUrl}}"], + "path": ["api", "assignments", "1", "unarchive"] + } + } + } + ] + }, + { + "name": "Sessions", + "item": [ + { + "name": "Save Session", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"examId\": 1,\n \"module\": \"reading\",\n \"answers\": {},\n \"startedAt\": \"2026-03-13T09:00:00\",\n \"completedAt\": \"2026-03-13T10:00:00\",\n \"assignmentId\": null\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/sessions", + "host": ["{{baseUrl}}"], + "path": ["api", "sessions"] + } + } + }, + { + "name": "Delete Session", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/sessions/1", + "host": ["{{baseUrl}}"], + "path": ["api", "sessions", "1"] + } + } + } + ] + }, + { + "name": "Stats", + "item": [ + { + "name": "Save Stats", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sessionId\": 1,\n \"module\": \"reading\",\n \"score\": { \"correct\": 30, \"total\": 40, \"missing\": 0 },\n \"result\": {},\n \"examId\": 1,\n \"assignmentId\": null\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/stats", + "host": ["{{baseUrl}}"], + "path": ["api", "stats"] + } + } + }, + { + "name": "Get Stat by ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/stats/1", + "host": ["{{baseUrl}}"], + "path": ["api", "stats", "1"] + } + } + }, + { + "name": "Statistical Query", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"userId\": 5,\n \"module\": \"reading\",\n \"dateFrom\": \"2026-01-01\",\n \"dateTo\": \"2026-12-31\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/statistical", + "host": ["{{baseUrl}}"], + "path": ["api", "statistical"] + } + } + } + ] + }, + { + "name": "AI Evaluation", + "item": [ + { + "name": "Evaluate Writing", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sessionId\": 1,\n \"exerciseId\": \"ex1\",\n \"question\": \"Describe the chart below.\",\n \"answer\": \"The chart shows a significant increase in...\",\n \"task\": 1\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/evaluate/writing", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "writing"] + } + } + }, + { + "name": "Evaluate Speaking", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "userId", "value": "5", "type": "text" }, + { "key": "sessionId", "value": "1", "type": "text" }, + { "key": "exerciseId", "value": "ex1", "type": "text" }, + { "key": "task", "value": "1", "type": "text" }, + { "key": "question_0", "value": "Describe your hometown.", "type": "text" }, + { "key": "audio_0", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/evaluate/speaking", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "speaking"] + } + } + }, + { + "name": "Evaluate Interactive Speaking", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "userId", "value": "5", "type": "text" }, + { "key": "sessionId", "value": "1", "type": "text" }, + { "key": "exerciseId", "value": "ex1", "type": "text" }, + { "key": "task", "value": "2", "type": "text" }, + { "key": "question_0", "value": "Tell me about a hobby you enjoy.", "type": "text" }, + { "key": "audio_0", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/evaluate/interactiveSpeaking", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "interactiveSpeaking"] + } + } + }, + { + "name": "Poll Evaluation Status (path params)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/evaluate/1/ex1", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "1", "ex1"] + } + } + }, + { + "name": "Poll Evaluation Status (query params - legacy)", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/evaluate/status?sessionId=1&exerciseId=ex1", + "host": ["{{baseUrl}}"], + "path": ["api", "evaluate", "status"], + "query": [ + { "key": "sessionId", "value": "1" }, + { "key": "exerciseId", "value": "ex1" } + ] + } + } + } + ] + }, + { + "name": "AI Generation", + "item": [ + { + "name": "Generate Reading Passage", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/reading/1?topic=climate+change&word_count=800", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", "1"], + "query": [ + { "key": "topic", "value": "climate+change" }, + { "key": "word_count", "value": "800" } + ] + } + } + }, + { + "name": "Generate Reading Exercises", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"The passage text goes here...\",\n \"exercises\": [\"multiple_choice\", \"true_false\"],\n \"difficulty\": [\"B1\", \"B2\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/reading/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "reading", ""] + } + } + }, + { + "name": "Generate Listening Dialog", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/listening/1?topic=university+enrollment&difficulty=B1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", "1"], + "query": [ + { "key": "topic", "value": "university+enrollment" }, + { "key": "difficulty", "value": "B1" } + ] + } + } + }, + { + "name": "Generate Listening Exercises", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"Dialog transcript here...\",\n \"exercises\": [\"form_completion\", \"multiple_choice\"],\n \"difficulty\": [\"B1\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/listening/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", ""] + } + } + }, + { + "name": "Generate Writing Task", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/writing/1?topic=urbanization&difficulty=B2", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "writing", "1"], + "query": [ + { "key": "topic", "value": "urbanization" }, + { "key": "difficulty", "value": "B2" } + ] + } + } + }, + { + "name": "Generate Speaking Task", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/speaking/1?topic=hobbies&difficulty=B1", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "speaking", "1"], + "query": [ + { "key": "topic", "value": "hobbies" }, + { "key": "first_topic", "value": "", "disabled": true }, + { "key": "second_topic", "value": "", "disabled": true }, + { "key": "difficulty", "value": "B1" } + ] + } + } + }, + { + "name": "Generate Level Test", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"exercises\": [\"grammar\", \"vocabulary\"],\n \"difficulty\": [\"A2\", \"B1\", \"B2\"]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/level/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", ""] + } + } + }, + { + "name": "Writing Task with Image (Vision)", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "file", "type": "file", "src": "" }, + { "key": "difficulty", "value": "B2", "type": "text" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/exam/writing/1/attachment", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "writing", "1", "attachment"] + } + } + }, + { + "name": "Get Pre-built Level Exam", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/level/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", ""] + } + } + }, + { + "name": "Get UTAS Level Exam", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/level/utas", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", "utas"] + } + } + }, + { + "name": "Import Level Exam", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "exercises", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/exam/level/import/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", "import", ""] + } + } + }, + { + "name": "Generate Custom Level Exam", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"spec\": {\n \"exercises\": [\"grammar\", \"vocabulary\", \"reading_comprehension\"],\n \"difficulty\": [\"B1\", \"B2\", \"C1\"]\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/level/custom/", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "level", "custom", ""] + } + } + } + ] + }, + { + "name": "AI Media", + "item": [ + { + "name": "Generate Listening MP3", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"conversation\": [\n { \"speaker\": \"A\", \"text\": \"Hello, how can I help you?\" },\n { \"speaker\": \"B\", \"text\": \"I'd like to enroll in a course.\" }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/listening/media", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", "media"] + } + } + }, + { + "name": "Transcribe Audio to Dialog", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "audio", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/exam/listening/transcribe", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", "transcribe"] + } + } + }, + { + "name": "Generate Instructions MP3", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"You will hear a conversation between two students. Listen and answer questions 1 to 5.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/listening/instructions", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "listening", "instructions"] + } + } + }, + { + "name": "Start Avatar Video", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"Tell me about your favorite hobby and why you enjoy it.\",\n \"avatar\": \"avatar_code_here\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/speaking/media", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "speaking", "media"] + } + } + }, + { + "name": "Poll Video Status", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/speaking/media/vid_12345", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "speaking", "media", "vid_12345"] + } + } + }, + { + "name": "List Speaking Avatars", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/exam/speaking/avatars", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "speaking", "avatars"] + } + } + }, + { + "name": "Transcribe Audio (General)", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "audio", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/transcribe", + "host": ["{{baseUrl}}"], + "path": ["api", "transcribe"] + } + } + } + ] + }, + { + "name": "Training", + "item": [ + { + "name": "Generate Training Plan", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"userID\": 5,\n \"stats\": [\n { \"module\": \"reading\", \"score\": 6.5 },\n { \"module\": \"writing\", \"score\": 5.0 }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/training", + "host": ["{{baseUrl}}"], + "path": ["api", "training"] + } + } + }, + { + "name": "Get Training by ID", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/training/1", + "host": ["{{baseUrl}}"], + "path": ["api", "training", "1"] + } + } + }, + { + "name": "Get Contextual Tips", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"context\": \"reading\",\n \"question\": \"How to improve skimming?\",\n \"answer\": \"Student attempted skimming but missed key details.\",\n \"correct_answer\": \"Focus on first and last sentences of each paragraph.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/training/tips", + "host": ["{{baseUrl}}"], + "path": ["api", "training", "tips"] + } + } + }, + { + "name": "Get Walkthroughs", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/training/walkthrough", + "host": ["{{baseUrl}}"], + "path": ["api", "training", "walkthrough"] + } + } + } + ] + }, + { + "name": "Entities & Roles", + "item": [ + { + "name": "List Entities", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/entities?page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "entities"], + "query": [ + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Create Entity", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Test University\",\n \"label\": \"TU\",\n \"licenses\": 50,\n \"expiryDate\": \"2027-01-01T00:00:00\",\n \"paymentCurrency\": \"USD\",\n \"paymentPrice\": 999.99\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/entities", + "host": ["{{baseUrl}}"], + "path": ["api", "entities"] + } + } + }, + { + "name": "Update Entity", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated University\",\n \"licenses\": 100\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/entities/1", + "host": ["{{baseUrl}}"], + "path": ["api", "entities", "1"] + } + } + }, + { + "name": "Delete Entity", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/entities/1", + "host": ["{{baseUrl}}"], + "path": ["api", "entities", "1"] + } + } + }, + { + "name": "List Roles", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/roles", + "host": ["{{baseUrl}}"], + "path": ["api", "roles"], + "query": [ + { "key": "entityID", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Create Role", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Department Head\",\n \"entityId\": 1\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/roles", + "host": ["{{baseUrl}}"], + "path": ["api", "roles"] + } + } + }, + { + "name": "Update Role", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"name\": \"Updated Role Name\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/roles/1", + "host": ["{{baseUrl}}"], + "path": ["api", "roles", "1"] + } + } + }, + { + "name": "Delete Role", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/roles/1", + "host": ["{{baseUrl}}"], + "path": ["api", "roles", "1"] + } + } + }, + { + "name": "List Permissions", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/permissions", + "host": ["{{baseUrl}}"], + "path": ["api", "permissions"] + } + } + } + ] + }, + { + "name": "Discounts", + "item": [ + { + "name": "List Discounts", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/discounts", + "host": ["{{baseUrl}}"], + "path": ["api", "discounts"] + } + } + }, + { + "name": "Create Discount", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"code\": \"SAVE20\",\n \"percentage\": 20,\n \"expiryDate\": \"2026-12-31\",\n \"maxUses\": 100\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/discounts", + "host": ["{{baseUrl}}"], + "path": ["api", "discounts"] + } + } + }, + { + "name": "Update Discount", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"percentage\": 30,\n \"maxUses\": 200\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/discounts/1", + "host": ["{{baseUrl}}"], + "path": ["api", "discounts", "1"] + } + } + }, + { + "name": "Delete Discount", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/discounts/1", + "host": ["{{baseUrl}}"], + "path": ["api", "discounts", "1"] + } + } + } + ] + }, + { + "name": "Approval Workflows", + "item": [ + { + "name": "List Workflows", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/approval-workflows", + "host": ["{{baseUrl}}"], + "path": ["api", "approval-workflows"] + } + } + }, + { + "name": "Create Workflow", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"examId\": 1,\n \"status\": \"pending\",\n \"modules\": [\"reading\", \"writing\"],\n \"steps\": [{\"step\": 1, \"reviewer\": \"admin\"}]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/approval-workflows", + "host": ["{{baseUrl}}"], + "path": ["api", "approval-workflows"] + } + } + }, + { + "name": "Update Workflow", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"approved\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/approval-workflows/1", + "host": ["{{baseUrl}}"], + "path": ["api", "approval-workflows", "1"] + } + } + }, + { + "name": "Delete Workflow", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/approval-workflows/1", + "host": ["{{baseUrl}}"], + "path": ["api", "approval-workflows", "1"] + } + } + } + ] + }, + { + "name": "Subscriptions & Payments", + "item": [ + { + "name": "List Packages", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/packages", + "host": ["{{baseUrl}}"], + "path": ["api", "packages"], + "query": [ + { "key": "entityID", "value": "", "disabled": true } + ] + } + } + }, + { + "name": "Stripe Checkout", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"packageId\": 1,\n \"successUrl\": \"http://localhost:3000/success\",\n \"cancelUrl\": \"http://localhost:3000/cancel\",\n \"discountCode\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/stripe", + "host": ["{{baseUrl}}"], + "path": ["api", "stripe"] + } + } + }, + { + "name": "PayPal Create Order", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"packageId\": 1,\n \"discountCode\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/paypal", + "host": ["{{baseUrl}}"], + "path": ["api", "paypal"] + } + } + }, + { + "name": "PayPal Approve", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"orderId\": \"PAYPAL-ORDER-ID\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/paypal/approve", + "host": ["{{baseUrl}}"], + "path": ["api", "paypal", "approve"] + } + } + }, + { + "name": "Paymob Payment", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"packageId\": 1,\n \"discountCode\": \"\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/paymob", + "host": ["{{baseUrl}}"], + "path": ["api", "paymob"] + } + } + }, + { + "name": "Stripe Webhook", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Stripe-Signature", "value": "t=1234567890,v1=signature_here" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"type\": \"checkout.session.completed\",\n \"data\": {\n \"object\": {\n \"id\": \"cs_test_123\",\n \"client_reference_id\": \"5\",\n \"metadata\": { \"package_id\": \"1\", \"user_id\": \"5\" },\n \"amount_total\": 2999,\n \"currency\": \"usd\",\n \"payment_intent\": \"pi_test_123\"\n }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/stripe/webhook", + "host": ["{{baseUrl}}"], + "path": ["api", "stripe", "webhook"] + } + } + }, + { + "name": "Paymob Webhook", + "request": { + "auth": { "type": "noauth" }, + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"obj\": {\n \"id\": 12345,\n \"success\": true,\n \"amount_cents\": 2999,\n \"currency\": \"EGP\",\n \"order\": { \"id\": 1, \"merchant_order_id\": \"encoach_5_1\" }\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/paymob/webhook?hmac=computed_hmac_here", + "host": ["{{baseUrl}}"], + "path": ["api", "paymob", "webhook"], + "query": [ + { "key": "hmac", "value": "computed_hmac_here" } + ] + } + } + } + ] + }, + { + "name": "Tickets", + "item": [ + { + "name": "List Tickets", + "request": { + "method": "GET", + "url": { + "raw": "{{baseUrl}}/api/tickets?page=1&limit=20", + "host": ["{{baseUrl}}"], + "path": ["api", "tickets"], + "query": [ + { "key": "status", "value": "", "disabled": true }, + { "key": "page", "value": "1" }, + { "key": "limit", "value": "20" } + ] + } + } + }, + { + "name": "Create Ticket", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"subject\": \"Cannot submit writing exam\",\n \"description\": \"When I click submit the page freezes and nothing happens.\",\n \"type\": \"bug\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/tickets", + "host": ["{{baseUrl}}"], + "path": ["api", "tickets"] + } + } + }, + { + "name": "Update Ticket", + "request": { + "method": "PATCH", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"status\": \"in_progress\",\n \"description\": \"Updated description with more details.\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/tickets/1", + "host": ["{{baseUrl}}"], + "path": ["api", "tickets", "1"] + } + } + }, + { + "name": "Delete Ticket", + "request": { + "method": "DELETE", + "url": { + "raw": "{{baseUrl}}/api/tickets/1", + "host": ["{{baseUrl}}"], + "path": ["api", "tickets", "1"] + } + } + } + ] + }, + { + "name": "Storage", + "item": [ + { + "name": "Upload File", + "request": { + "method": "POST", + "body": { + "mode": "formdata", + "formdata": [ + { "key": "file", "type": "file", "src": "" } + ] + }, + "url": { + "raw": "{{baseUrl}}/api/storage", + "host": ["{{baseUrl}}"], + "path": ["api", "storage"] + } + } + }, + { + "name": "Delete File", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 1\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/storage/delete", + "host": ["{{baseUrl}}"], + "path": ["api", "storage", "delete"] + } + } + } + ] + }, + { + "name": "Grading", + "item": [ + { + "name": "Grade Multiple Answers", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"text\": \"The passage about climate change...\",\n \"questions\": [\n { \"id\": \"q1\", \"text\": \"What is the main cause?\" },\n { \"id\": \"q2\", \"text\": \"Name two effects.\" }\n ],\n \"answers\": [\n { \"id\": \"q1\", \"value\": \"greenhouse gases\" },\n { \"id\": \"q2\", \"value\": \"flooding and droughts\" }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/grading/multiple", + "host": ["{{baseUrl}}"], + "path": ["api", "grading", "multiple"] + } + } + }, + { + "name": "Grade Summary", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\n \"sections\": [\n {\n \"module\": \"reading\",\n \"score\": { \"correct\": 30, \"total\": 40 }\n },\n {\n \"module\": \"listening\",\n \"score\": { \"correct\": 28, \"total\": 40 }\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/exam/grade/summary", + "host": ["{{baseUrl}}"], + "path": ["api", "exam", "grade", "summary"] + } + } + } + ] + } + ] +} diff --git a/ODOO_MIGRATION_SRS_v2.md b/ODOO_MIGRATION_SRS_v2.md new file mode 100644 index 0000000..ade8095 --- /dev/null +++ b/ODOO_MIGRATION_SRS_v2.md @@ -0,0 +1,1292 @@ +# EnCoach Platform -- Odoo 19 Full Migration SRS (v2) + +## Software Requirements Specification + +**Version:** 2.0 +**Date:** March 11, 2026 +**Status:** Draft +**Supersedes:** ODOO_MIGRATION_SRS.md (v1) +**Key change from v1:** The ielts-be (FastAPI) microservice is fully eliminated. All AI/ML functionality is absorbed into custom Odoo modules. + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Odoo Module Plan](#2-odoo-module-plan) +3. [Data Models](#3-data-models-odoo-models) +4. [REST API Specification](#4-rest-api-specification) +5. [Authentication & Authorization](#5-authentication--authorization) +6. [AI/ML Services Integration](#6-aiml-services-integration) +7. [Payment Integration](#7-payment-integration) +8. [Business Rules & Workflows](#8-business-rules--workflows) +9. [Data Migration Plan](#9-data-migration-plan) +10. [Non-Functional Requirements](#10-non-functional-requirements) + +--- + +## 1. Executive Summary + +### 1.1 Platform Overview + +EnCoach is an IELTS preparation and English proficiency testing platform serving students, teachers, corporate clients, and administrators. The platform provides: + +- Full IELTS exam simulation (Reading, Listening, Writing, Speaking, Level) +- AI-powered grading of writing and speaking responses (OpenAI GPT-4o) +- AI-powered exam content generation (OpenAI GPT-4o) +- AI-generated audio for listening exams (AWS Polly) +- AI-generated video for speaking exams (ELAI) +- Speech-to-text transcription (OpenAI Whisper) +- AI plagiarism/detection for writing (GPTZero) +- Personalized training recommendations (FAISS + sentence-transformers + GPT) +- Multi-tenant entity (organization) management +- Classroom and assignment management +- Subscription-based access with multiple payment providers +- Support ticket system + +### 1.2 Migration Scope + +**Everything moves to Odoo 19.** Both backend systems are replaced: + +1. **ielts-ui API routes** (Node.js, MongoDB, iron-session, Firebase Auth) -- 108 API route files, 22 MongoDB collections +2. **ielts-be** (Python, FastAPI, OpenAI, Whisper, AWS Polly, ELAI, GPTZero, FAISS) -- all AI/ML workloads + +**What stays unchanged:** + +- ielts-ui browser/frontend code (React components, Zustand stores, pages) +- Strapi CMS (encoachcms) +- Landing page (encoach-landing-page) + +### 1.3 Target Architecture + +``` +ielts-ui (Next.js 14) + └── Browser UI (unchanged) + │ + ▼ +Odoo 19 (Python + PostgreSQL) + ├── REST API Controllers (JSON) + ├── Business Logic (custom modules) + ├── AI/ML Modules + │ ├── OpenAI GPT-4o (grading + content generation) + │ ├── OpenAI Whisper (speech-to-text, local model) + │ ├── AWS Polly (text-to-speech for listening) + │ ├── ELAI (AI video for speaking) + │ ├── GPTZero (AI detection for writing) + │ └── FAISS + sentence-transformers (training search) + ├── PostgreSQL (all data) + ├── Odoo Attachments (file storage) + └── Payment Providers (Stripe, PayPal, Paymob) +``` + +There is no separate microservice. Odoo is the sole backend. + +--- + +## 2. Odoo Module Plan + +### 2.1 Standard Odoo Modules to Leverage + +| Odoo Module | Usage | +|-------------|-------| +| `base` / `res.users` / `res.partner` | User accounts, extended with EnCoach-specific fields | +| `payment` | Payment provider framework for Stripe, PayPal, Paymob | +| `product` | Subscription packages as products | +| `mail` | Email notifications (password reset, verification, invites) | +| `queue_job` (OCA) or `ir.cron` | Background task processing for async AI grading | + +### 2.2 Custom Modules to Develop + +| Module | Depends On | Complexity | Description | +|--------|------------|------------|-------------| +| `encoach_core` | `base`, `mail` | Medium | User type extensions, entity management, roles, permissions, codes, invites | +| `encoach_exam` | `encoach_core` | High | Exam models for 5 modules with complex nested exercise structures | +| `encoach_classroom` | `encoach_core` | Low | Group/classroom management with participant tracking | +| `encoach_assignment` | `encoach_core`, `encoach_exam` | Medium | Assignment lifecycle (create, start, release, archive) | +| `encoach_stats` | `encoach_core`, `encoach_exam` | Medium | Exam sessions, per-exercise statistics, score tracking | +| `encoach_evaluation` | `encoach_core`, `encoach_exam`, `encoach_ai` | Medium | Async grading records for writing/speaking | +| `encoach_training` | `encoach_core`, `encoach_ai` | Medium | Training content, walkthrough, FAISS semantic search | +| `encoach_subscription` | `encoach_core`, `product`, `payment` | Medium | Packages, discounts, subscription management, Stripe/PayPal/Paymob | +| `encoach_registration` | `encoach_core` | Medium | Multi-path registration (individual, corporate), codes, invites | +| `encoach_ticket` | `encoach_core` | Low | Support tickets | +| `encoach_ai` | `base` | **Very High** | Core AI services: OpenAI client, Whisper, AWS Polly, ELAI, GPTZero, FAISS | +| `encoach_ai_grading` | `encoach_ai`, `encoach_exam` | High | Writing/speaking grading logic, rubrics, prompt templates | +| `encoach_ai_generation` | `encoach_ai`, `encoach_exam` | High | Exam content generation for all 5 modules | +| `encoach_ai_media` | `encoach_ai` | High | Audio (Polly TTS), video (ELAI), transcription (Whisper) | +| `encoach_api` | All above | High | All REST JSON controllers for frontend consumption (~60 endpoints) | + +### 2.3 Module Dependency Graph + +``` +encoach_api + ├── encoach_core + │ ├── base / res.users / res.partner + │ └── mail + ├── encoach_exam + │ └── encoach_core + ├── encoach_classroom + │ └── encoach_core + ├── encoach_assignment + │ ├── encoach_core + │ └── encoach_exam + ├── encoach_stats + │ ├── encoach_core + │ └── encoach_exam + ├── encoach_evaluation + │ ├── encoach_core + │ ├── encoach_exam + │ └── encoach_ai + ├── encoach_training + │ ├── encoach_core + │ └── encoach_ai + ├── encoach_subscription + │ ├── encoach_core + │ ├── product + │ └── payment + ├── encoach_registration + │ └── encoach_core + ├── encoach_ticket + │ └── encoach_core + ├── encoach_ai (core AI services) + │ └── base + ├── encoach_ai_grading + │ ├── encoach_ai + │ └── encoach_exam + ├── encoach_ai_generation + │ ├── encoach_ai + │ └── encoach_exam + └── encoach_ai_media + └── encoach_ai +``` + +--- + +## 3. Data Models (Odoo Models) + +All models from v1 remain unchanged. This section adds the AI/ML-related models that were previously managed by ielts-be. + +> **Note:** For the full specification of the following models, refer to Section 3 of the v1 document (`ODOO_MIGRATION_SRS.md`). They are identical: +> `encoach.user`, `encoach.user.entity.rel`, `encoach.entity`, `encoach.role`, `encoach.group`, `encoach.exam`, `encoach.assignment`, `encoach.session`, `encoach.stat`, `encoach.evaluation`, `encoach.package`, `encoach.payment`, `encoach.subscription.payment`, `encoach.ticket`, `encoach.code`, `encoach.invite`, `encoach.permission`, `encoach.discount`, `encoach.walkthrough`, `encoach.approval.workflow` + +The following models are new or significantly expanded in v2: + +### 3.1 `encoach.training` (expanded) + +**Source:** MongoDB `training` collection (previously managed by ielts-be) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `user_id` | Many2one(`res.users`) | Yes | Student | +| `created_at` | Datetime | Yes | Training generation date | +| `exams` | Text (JSON) | No | JSON array of exam performance summaries: `[{ "id", "date", "performance_comment", "detailed_summary" }]` | +| `tips` | Text (JSON) | No | JSON object with categorized tips from FAISS search | +| `weak_areas` | Text (JSON) | No | JSON array: `[{ "area": "...", "comment": "..." }]` | +| `legacy_id` | Char | No | Original MongoDB ID | + +### 3.2 `encoach.training.tip` (new) + +**Source:** ielts-be `pathways_2_rw_with_ids.json` tips data + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `tip_id` | Char | Yes | Original tip identifier | +| `category` | Selection | Yes | `ct_focus`, `language_for_writing`, `reading_skill`, `strategy`, `writing_skill`, `word_link`, `word_partners` | +| `content` | Text | Yes | Tip text content | +| `embedding` | Binary | No | Pre-computed embedding vector (float32 array) | + +### 3.3 `encoach.ai.job` (new) + +Tracks async AI jobs (grading, video generation). + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `job_type` | Selection | Yes | `writing_grading`, `speaking_grading`, `video_generation` | +| `status` | Selection | Yes | `pending`, `in_progress`, `completed`, `error`. Default: `pending` | +| `user_id` | Many2one(`res.users`) | No | Requesting user | +| `evaluation_id` | Many2one(`encoach.evaluation`) | No | Related evaluation record | +| `input_data` | Text (JSON) | No | Serialized input for the AI task | +| `result_data` | Text (JSON) | No | Serialized result from the AI task | +| `error_message` | Text | No | Error details if failed | +| `created_at` | Datetime | Yes | Job creation time | +| `completed_at` | Datetime | No | Job completion time | +| `retry_count` | Integer | No | Number of retries. Default: 0 | + +### 3.4 `encoach.elai.avatar` (new) + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | Char | Yes | Avatar display name | +| `avatar_code` | Char | Yes | ELAI avatar code | +| `avatar_url` | Char | No | Avatar preview image URL | +| `gender` | Selection | No | `male`, `female` | +| `canvas` | Char | No | ELAI canvas ID | +| `voice_id` | Char | No | ELAI voice ID | +| `voice_provider` | Char | No | Voice provider name | + +### 3.5 Exam `parts` JSON Structure + +Identical to v1. See v1 document Section 3.6.1 for the full JSON schema for Reading, Listening, Writing, Speaking, and Level exam parts. + +### 3.6 Evaluation `result` JSON Structure + +Identical to v1. See v1 document Section 3.10.1 for the writing and speaking grading result schemas. + +--- + +## 4. REST API Specification + +All endpoints from v1 remain unchanged in path and request/response format. The key difference is that endpoints previously marked as "proxy to ielts-be" are now handled directly by Odoo. + +> **Note:** For the full specification of all non-AI endpoints, refer to Section 4 of the v1 document. They are identical: Auth (4.1), Users (4.2), Registration/Codes (4.3), Entities (4.12), Groups (4.11), Roles (4.13), Permissions (4.14), Invites (4.7), Codes (4.8), Assignments (4.10), Sessions (4.11), Stats (4.12), Payments (4.15), Packages (4.16), Discounts (4.17), Tickets (4.18), Storage (4.19), Approval Workflows (4.21). + +The following endpoints were previously "proxy to ielts-be" and are now handled directly by Odoo: + +### 4.1 Exam Generation Endpoints + +#### `GET /api/exam/reading/{passage}` + +Generate a reading passage using AI. + +**Path params:** `passage` = `1` | `2` | `3` + +**Query params:** `topic` (optional), `word_count` (optional, default ~500) + +**Response (200):** +```json +{ + "title": "The Impact of Urbanization", + "text": "Full passage text..." +} +``` + +**Implementation:** Call OpenAI GPT-4o with passage-specific prompt (see Section 6.3.1). + +#### `POST /api/exam/reading/` + +Generate reading exercises from a passage. + +**Request:** +```json +{ + "text": "passage text", + "exercises": [ + { "type": "multipleChoice", "quantity": 5 }, + { "type": "trueFalse", "quantity": 4 }, + { "type": "fillBlanks", "quantity": 3, "num_random_words": 1, "max_words": 3 } + ], + "difficulty": ["B1", "B2"] +} +``` + +**Response (200):** `{ "exercises": [...] }` + +#### `GET /api/exam/listening/{section}` + +Generate a listening dialog/monologue. + +**Path params:** `section` = `1` | `2` | `3` | `4` + +**Query params:** `topic` (optional), `difficulty` (optional) + +**Response (200):** +```json +{ + "dialog": { + "conversation": [ + { "name": "Sarah", "gender": "female", "text": "Hello..." }, + { "name": "James", "gender": "male", "text": "Hi..." } + ] + } +} +``` + +Sections 1 and 3 return `conversation` (dialog). Sections 2 and 4 return `monologue`. + +#### `POST /api/exam/listening/media` + +Generate MP3 audio from a dialog/monologue using AWS Polly TTS. + +**Request:** +```json +{ + "conversation": [ + { "name": "Sarah", "gender": "female", "text": "Hello...", "voice": "Ruth" } + ] +} +``` + +Or for monologue: +```json +{ + "monologue": "Full monologue text..." +} +``` + +**Response:** MP3 binary data (`Content-Type: audio/mpeg`) + +**Implementation:** Call AWS Polly with neural engine (see Section 6.4). + +#### `POST /api/exam/listening/transcribe` + +Transcribe audio using Whisper. + +**Request:** Multipart form with `audio` file + +**Response (200):** Dialog object (conversation or monologue text) + +**Implementation:** Run Whisper model locally, then use GPT-4o to clean overlapping segments (see Section 6.5). + +#### `POST /api/exam/listening/instructions` + +Generate MP3 for listening instructions text. + +**Request:** `{ "text": "instructions text" }` + +**Response:** MP3 binary data + +#### `POST /api/exam/listening/` + +Generate listening exercises from a dialog. + +**Request:** +```json +{ + "text": "transcript text", + "exercises": [ + { "type": "multipleChoice", "quantity": 3 }, + { "type": "writeBlanksFill", "quantity": 5 } + ], + "difficulty": ["B1"] +} +``` + +**Response (200):** `{ "exercises": [...] }` + +#### `GET /api/exam/speaking/{task}` + +Generate a speaking task prompt. + +**Path params:** `task` = `1` | `2` | `3` + +**Query params:** `topic`, `first_topic`, `second_topic`, `difficulty` + +**Response (200):** Speaking part content (prompts array for Part 1/3, text block for Part 2) + +#### `POST /api/exam/speaking/media` + +Generate AI avatar video using ELAI. + +**Request:** +```json +{ + "text": "Question text to speak", + "avatar": "avatar_code" +} +``` + +**Response (200):** `{ "status": "STARTED", "result": null }` (async -- poll for completion) + +#### `GET /api/exam/speaking/media/{vid_id}` + +Poll for video generation status. + +**Response (200):** +```json +{ + "status": "COMPLETED", + "result": "https://elai-video-url..." +} +``` + +Possible statuses: `STARTED`, `IN_PROGRESS`, `COMPLETED`, `ERROR` + +#### `GET /api/exam/speaking/avatars` + +List available ELAI avatars. + +**Response (200):** `[ { "name": "...", "avatar_code": "...", "avatar_url": "...", "gender": "..." } ]` + +#### `GET /api/exam/writing/{task}` + +Generate a writing task prompt. + +**Path params:** `task` = `1` | `2` + +**Query params:** `topic`, `difficulty` + +**Response (200):** Writing task prompt text + +#### `POST /api/exam/writing/{task}/attachment` + +Generate an academic writing Task 1 with image attachment. + +**Request:** Multipart form with `file` (chart/image) and optional `difficulty` + +**Response (200):** Writing task prompt with image analysis + +#### `POST /api/exam/level/` + +Generate level test exercises. + +**Request:** +```json +{ + "exercises": [ + { "type": "multipleChoice", "quantity": 10, "difficulty": "B1" }, + { "type": "fillBlanks", "quantity": 5, "text_size": 200, "topic": "education" } + ], + "difficulty": ["A2", "B1", "B2"] +} +``` + +**Response (200):** Generated exercises + +#### `GET /api/exam/level/` + +Get a pre-built level exam. + +#### `GET /api/exam/level/utas` + +Get a UTAS-format level exam. + +#### `POST /api/exam/level/import/` + +Import a level exam from file. + +**Request:** Multipart form with `exercises` and optional `solutions` files + +**Response (200):** Parsed exam object + +#### `POST /api/exam/level/custom/` + +Generate a custom level exam from JSON specification. + +#### `POST /api/exam/reading/import` + +Import a reading exam from Word/Excel file. + +**Request:** Multipart form with `exercises` and optional `solutions` files + +**Response (200):** Parsed exam object + +#### `POST /api/exam/listening/import` + +Import a listening exam from file. + +### 4.2 Grading Endpoints + +#### `POST /api/evaluate/writing` + +Submit a writing answer for AI grading. + +**Request:** +```json +{ + "userId": 1, + "sessionId": 20, + "exerciseId": "uuid", + "question": "Write about...", + "answer": "Student's essay text...", + "task": 1, + "attachment": "optional-image-url" +} +``` + +**Response (200):** `{ "ok": true }` (grading happens asynchronously) + +**Implementation:** Creates `encoach.evaluation` and `encoach.ai.job`, then runs grading in a background thread (see Section 6.2). + +#### `POST /api/evaluate/speaking` + +Submit speaking audio for AI grading. + +**Request:** Multipart form with `userId`, `sessionId`, `exerciseId`, `task`, and audio files (`audio_1`, `audio_2`, etc.) with corresponding `question_N` fields. + +**Response (200):** `{ "ok": true }` (grading happens asynchronously) + +**Implementation:** Whisper transcribes each audio, then GPT-4o grades the transcript (see Section 6.2). + +#### `POST /api/evaluate/interactiveSpeaking` + +Submit interactive speaking (multiple Q&A pairs) for grading. + +**Request:** Same as speaking but with `question_N` and `audio_N` pairs. + +#### `GET /api/evaluate/{sessionId}/{exerciseId}` + +Poll for evaluation result. + +**Response (200):** +```json +{ + "status": "completed", + "result": { "...grading result..." } +} +``` + +#### `POST /api/grading/multiple` + +Grade multiple short-answer exercises. + +**Request:** +```json +{ + "text": "passage text", + "questions": ["Q1", "Q2"], + "answers": ["A1", "A2"] +} +``` + +**Response (200):** `{ "exercises": [{ "id": "...", "correct": true, "correct_answer": "..." }] }` + +**Implementation:** GPT-4o evaluates each answer against the passage. + +#### `POST /api/exam/grade/summary` + +Generate a grading summary for a full exam session. + +**Request:** +```json +{ + "sections": [ + { "code": "reading", "name": "Reading", "grade": 6.5 }, + { "code": "writing", "name": "Writing", "grade": 7.0 } + ] +} +``` + +**Response (200):** +```json +{ + "sections": [ + { + "code": "reading", + "name": "Reading", + "grade": 6.5, + "evaluation": "Detailed evaluation text...", + "suggestions": "Improvement suggestions...", + "bullet_points": ["Focus on skimming", "Practice time management"] + } + ] +} +``` + +### 4.3 Training Endpoints + +#### `POST /api/training` + +Generate personalized training content. + +**Request:** +```json +{ + "userID": 1, + "stats": [{ "exam_id": "...", "date": 1710000000, "performance_comment": "...", "detailed_summary": "..." }] +} +``` + +**Response (200):** `{ "id": "training-record-id" }` + +**Implementation:** Uses FAISS to find relevant tips, GPT to generate personalized recommendations (see Section 6.6). + +#### `POST /api/training/tips` + +Fetch contextual tips. + +**Request:** +```json +{ + "context": "reading passage about climate change", + "question": "What does the author suggest?", + "answer": "student's wrong answer", + "correct_answer": "the correct answer" +} +``` + +**Response (200):** `{ "tips": "Personalized tip text..." }` + +#### `POST /api/transcribe` + +Transcribe audio file. + +**Request:** Multipart form with audio file + +**Response (200):** Transcript text or dialog object + +#### `POST /api/batch_users` + +Bulk import users. + +**Request:** `{ "makerID": "admin-id", "users": [{ ...userDTO }] }` + +**Response (200):** `{ "ok": true }` + +**Implementation:** In v1 this was handled by ielts-be (which imported into Firebase Auth + MongoDB). In v2, Odoo creates users directly in `res.users`. No Firebase import needed. + +--- + +## 5. Authentication & Authorization + +Identical to v1 document Section 5. Summary: + +- **Recommended:** Odoo native auth with JWT tokens for API access +- **7 user roles** mapped to Odoo security groups with `ir.rule` record rules: student, teacher, corporate, admin, developer, agent, mastercorporate + +--- + +## 6. AI/ML Services Integration + +This is the new Section 6, replacing v1's "ielts-be Integration Specification." Odoo calls all external AI services directly. + +### 6.1 External Service Overview + +| Service | Purpose | API Type | Auth | Odoo Module | +|---------|---------|----------|------|-------------| +| **OpenAI GPT-4o** | Grading, content generation, text correction | REST API | API Key (`Authorization: Bearer`) | `encoach_ai` | +| **OpenAI GPT-3.5-turbo** | Secondary tasks (fixed text, perfect answers, summaries) | REST API | Same key | `encoach_ai` | +| **OpenAI Whisper** | Speech-to-text transcription | **Local model** (Python library) | N/A | `encoach_ai_media` | +| **AWS Polly** | Text-to-speech for listening exams | AWS SDK (boto3) | AWS Access Key + Secret | `encoach_ai_media` | +| **ELAI** | AI avatar video generation for speaking | REST API | Bearer token | `encoach_ai_media` | +| **GPTZero** | AI-generated text detection for writing | REST API | API Key (`x-api-key`) | `encoach_ai_grading` | +| **FAISS** | Semantic search over training tips | **Local library** (Python) | N/A | `encoach_training` | +| **sentence-transformers** | Embedding generation for FAISS | **Local model** (Python) | N/A | `encoach_training` | + +### 6.2 Background Task Architecture + +AI grading operations (writing and speaking) take 10-60 seconds. They must run asynchronously. + +**Recommended approach:** Use Python threading within Odoo or the OCA `queue_job` module. + +**Flow:** + +1. Frontend calls `POST /api/evaluate/writing` (or speaking) +2. Odoo controller creates `encoach.evaluation` record with `status = 'pending'` +3. Odoo controller creates `encoach.ai.job` record and launches a background thread +4. Controller returns `200 OK` immediately to the frontend +5. Background thread: + a. Calls OpenAI GPT-4o for grading (and GPTZero for AI detection in parallel) + b. Updates `encoach.evaluation` with `status = 'completed'` and `result` JSON + c. Updates `encoach.ai.job` with `status = 'completed'` +6. Frontend polls `GET /api/evaluate/{sessionId}/{exerciseId}` until status is `completed` + +**Threading pattern (Odoo-compatible):** + +```python +import threading +from odoo import api, SUPERUSER_ID + +def _run_grading_in_background(dbname, evaluation_id, input_data): + with api.Environment.manage(): + registry = odoo.registry(dbname) + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + service = env['encoach.ai.grading'] + service.execute_grading(evaluation_id, input_data) + +# In the controller: +thread = threading.Thread( + target=_run_grading_in_background, + args=(request.env.cr.dbname, evaluation.id, input_data) +) +thread.start() +``` + +**Alternative:** Use OCA `queue_job` for production-grade async processing with retry, monitoring, and worker management. + +### 6.3 OpenAI Integration + +#### 6.3.1 Client Configuration + +Create a service class `EncoachOpenAIService` in `encoach_ai`: + +| Parameter | Value | +|-----------|-------| +| **Library** | `openai` (Python package) | +| **Client** | `AsyncOpenAI(api_key=api_key)` or synchronous `OpenAI` | +| **API Key** | Odoo system parameter `encoach.openai_api_key` | +| **Response format** | `response_format={"type": "json_object"}` | + +#### 6.3.2 Models Used + +| Task | Model | Temperature | +|------|-------|-------------| +| Writing grading | `gpt-4o` | 0.1 | +| Speaking grading | `gpt-4o` | 0.1 | +| Short answer grading | `gpt-4o` | 0.1 | +| Fixed text / perfect answer | `gpt-3.5-turbo` | 0.1 | +| Grading summary | `gpt-3.5-turbo` | 0.1 | +| Reading passage generation | `gpt-4o` | 0.7 | +| Listening dialog generation | `gpt-4o` | 0.7 | +| Writing task generation | `gpt-4o` | 0.7 | +| Speaking task generation | `gpt-4o` | 0.7 | +| Level exercise generation | `gpt-4o` | 0.7 | +| Training tips | `gpt-4o` | 0.2 | +| Whisper overlap cleanup | `gpt-4o` | 0.1 | + +#### 6.3.3 Writing Grading Prompts + +**System message:** + +`You are a helpful assistant designed to output JSON on this format: {template}` + +Where `{template}` is: + +```json +{ + "comment": "comment about student's response quality", + "overall": 0.0, + "task_response": { + "Task Achievement": { "grade": 0.0, "comment": "..." }, + "Coherence and Cohesion": { "grade": 0.0, "comment": "..." }, + "Lexical Resource": { "grade": 0.0, "comment": "..." }, + "Grammatical Range and Accuracy": { "grade": 0.0, "comment": "..." } + } +} +``` + +**User message (Task 2):** + +`Evaluate the given Writing Task {task} response based on the IELTS grading system, ensuring a strict assessment that penalizes errors. Deduct points for deviations from the task, and assign a score of 0 if the response fails to address the question. Additionally, provide a detailed commentary highlighting both strengths and weaknesses in the response.\nQuestion: "{question}"\nAnswer: "{answer}"` + +**User message (Task 1 General):** + +Same as Task 2 plus: `Refer to the parts of the letter as: "Greeting Opener", "bullet 1", "bullet 2", "bullet 3", "closer (restate the purpose of the letter)", "closing greeting"` + +**User message (Task 1 Academic with image):** + +The image is sent as base64 in the message content using the vision API. + +**Additional parallel tasks for writing grading:** + +| Task | Model | Prompt | +|------|-------|--------| +| Perfect answer | `gpt-3.5-turbo` | `Write a perfect answer for this IELTS writing task: "{question}"` | +| Fixed text | `gpt-3.5-turbo` | `Fix the grammatical and spelling errors in this text, keeping the original meaning: "{answer}"` | +| AI detection | GPTZero API | See Section 6.7 | + +#### 6.3.4 Speaking Grading Prompts + +**System message:** + +`You are a helpful assistant designed to output JSON on this format: {template}` + +Where `{template}` is: + +```json +{ + "comment": "extensive comment about answer quality", + "overall": 0.0, + "task_response": { + "Fluency and Coherence": { "grade": 0.0, "comment": "extensive comment..." }, + "Lexical Resource": { "grade": 0.0, "comment": "..." }, + "Grammatical Range and Accuracy": { "grade": 0.0, "comment": "..." }, + "Pronunciation": { "grade": 0.0, "comment": "..." } + } +} +``` + +**User message:** + +`Evaluate the given Speaking Part {task} response based on the IELTS grading system, ensuring a strict assessment that penalizes errors. Deduct points for deviations from the task, and assign a score of 0 if the response fails to address the question. Additionally, provide detailed commentary highlighting both strengths and weaknesses in the response.` + +Followed by question/answer pairs from transcription. + +**Task-specific instructions:** + +- Task 1: `Address the student as "you". If the answers are not 2 or 3 sentences long, warn the student that they should be.` +- Task 2: `Address the student as "you"` +- Task 3: `Address the student as "you" and pay special attention to coherence between the answers.` + +**Speaking grading flow:** + +1. Whisper transcribes each audio file +2. GPT-4o grades the full transcript against the rubric +3. GPT-3.5-turbo generates fixed text and perfect answer (for Task 2) +4. Results combined into the evaluation record + +#### 6.3.5 Reading Passage Generation Prompts + +**System message:** + +`You are a helpful assistant designed to output JSON on this format: {"title": "title of the text", "text": "generated text"}` + +**User message (Passage 1):** + +`Generate an extensive text for IELTS Reading Passage 1, of at least {word_count} words, on the topic of "{topic}". The passage should offer a substantial amount of information relevant to the chosen subject matter. It should be fairly easy and consist of multiple paragraphs. Make sure that the generated text does not contain forbidden subjects in muslim countries.` + +**User message (Passage 2):** + +Same structure but: `fairly hard and consist of multiple paragraphs` + +**User message (Passage 3):** + +Same structure but: `very hard, present different points or theories, cite different sources, and consist of multiple paragraphs` + +#### 6.3.6 Listening Dialog Generation Prompts + +**Section 1 (conversation, 2 people):** + +System: `{"conversation": [{"name": "name", "gender": "gender", "text": "text"}]}` + +User: `Compose an authentic conversation between two individuals on the topic of "{topic}". Please include random names and genders. Include misleading discourse (dates, colors, etc.) and spelling of names. Make sure that the generated conversation does not contain forbidden subjects in muslim countries.` + +**Section 2 (monologue, social):** + +System: `{"monologue": "monologue"}` + +User: `Generate a comprehensive monologue set in the social context of "{topic}". Make sure that the generated monologue does not contain forbidden subjects in muslim countries.` + +**Section 3 (conversation, up to 4 people):** + +User: `Compose an authentic and elaborate conversation between up to four individuals on the topic of "{topic}". Please include random names and genders. Make sure that the generated conversation does not contain forbidden subjects in muslim countries.` + +**Section 4 (monologue, academic):** + +User: `Generate a comprehensive and complex monologue on the academic subject of "{topic}". Make sure that the generated monologue does not contain forbidden subjects in muslim countries.` + +#### 6.3.7 Writing Task Generation Prompts + +**General Task 1:** + +`Craft a prompt for an IELTS Writing Task 1 General Training exercise that instructs the student to compose a letter based on the topic of "{topic}" of {difficulty} CEFR level difficulty. The prompt should end with "In the letter you should" followed by 3 bullet points. Make sure it does not contain forbidden subjects in muslim countries.` + +**General Task 2:** + +`Craft a comprehensive question of {difficulty} CEFR level difficulty like the ones for IELTS Writing Task 2 General Training that directs the candidate to delve into an in-depth analysis of contrasting perspectives on the topic of "{topic}". The question should lead to an answer with either "theories", "complicated information" or be "very descriptive" on the topic.` + +**Academic Task 1 (with image):** + +`Analyze the uploaded image and create a detailed IELTS Writing Task 1 Academic prompt. Describe the visual type, context, and create a prompt at {difficulty} CEFR level.` + +#### 6.3.8 Speaking Task Generation Prompts + +**Part 1:** + +`Craft 5 simple and single questions of easy difficulty for IELTS Speaking Part 1 that encourages candidates to delve deeply into personal experiences, preferences, or insights on the topic of "{first_topic}" and the topic of "{second_topic}". The questions should lead to the usage of 4 verb tenses (present perfect, present, past and future). Make sure that the generated question does not contain forbidden subjects in muslim countries.` + +**Part 2:** + +`Create a question of medium difficulty for IELTS Speaking Part 2 that encourages candidates to narrate a personal experience or story related to the topic of "{topic}". Include 3 prompts that guide the candidate. The prompts must not be questions. Also include a suffix like the ones in the IELTS exams that start with "And explain why". Make sure that the generated question does not contain forbidden subjects in muslim countries.` + +**Part 3:** + +`Formulate a set of 5 single questions of hard difficulty for IELTS Speaking Part 3 that encourage candidates to engage in a meaningful discussion on the topic of "{topic}". They must be 1 single question each and not be double-barreled questions. Make sure that the generated question does not contain forbidden subjects in muslim countries.` + +#### 6.3.9 Level Exercise Generation Prompts + +**Multiple Choice:** + +`Generate {quantity} multiple choice questions of 4 options for an english level exam of {difficulty} CEFR level. Ensure that the questions cover a range of topics such as verb tense, subject-verb agreement, pronoun usage, sentence structure, and punctuation. Make sure every question only has 1 correct answer.` + +**Fill Blanks:** + +`Generate a text of at least {size} words about the topic {topic}. From the generated text choose exactly {quantity} words (cannot be sequential words), replace each with {{id}}, and generate a JSON object containing: the modified text, solutions array, words array with four options per blank.` + +#### 6.3.10 Retry and Validation Logic + +- Retry up to 2 times if response contains blacklisted words or missing required fields +- Blacklisted words include topics related to religion, politics, explicit content, and culturally sensitive subjects (stored in constants) +- Token limit: `4097 - input_token_count - 300` for max_tokens +- All responses expected in JSON format via `response_format={"type": "json_object"}` + +### 6.4 AWS Polly Integration (TTS) + +Implemented in `encoach_ai_media` module. + +| Parameter | Value | +|-----------|-------| +| **Library** | `boto3` / `aioboto3` | +| **Service** | `polly` | +| **Engine** | `neural` | +| **Output format** | `mp3` | +| **Auth** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` (Odoo system parameters) | + +**Available voices:** + +| Voice | Gender | Accent | +|-------|--------|--------| +| Danielle | Female | US | +| Gregory | Male | US | +| Kevin | Male | US | +| Ruth | Female | US | +| Stephen | Male | US | +| Arthur | Male | GB | +| Olivia | Female | GB | +| Ayanda | Female | ZA | +| Aria | Female | NZ | +| Kajal | Female | IN | +| Niamh | Female | IE | + +**Conversation audio generation:** + +1. For each dialog line, call Polly `synthesize_speech()` with the assigned voice +2. Chunk text at sentence boundaries if > 3000 characters +3. Concatenate all MP3 segments +4. Append final message: `"This audio recording, for the listening exercise, has finished."` (voice: Stephen) + +**Monologue audio generation:** + +1. Select a random voice +2. Chunk text at sentence boundaries if > 3000 characters +3. Synthesize each chunk and concatenate + +### 6.5 Whisper Integration (STT) + +Implemented in `encoach_ai_media` module. + +| Parameter | Value | +|-----------|-------| +| **Library** | `openai-whisper` (local Python package) | +| **Model size** | `base` (~1 GB, or configurable) | +| **Model instances** | 4 (for parallel transcription) | +| **Thread pool** | `ThreadPoolExecutor(max_workers=4)` | +| **Audio resampling** | 16 kHz, normalized | +| **Chunk size** | 30 seconds (480,000 samples) with 1/4 overlap | +| **Options** | `fp16=False`, `language='English'`, `verbose=False` | +| **Retries** | 3 attempts via tenacity | + +**Long audio handling:** + +1. Split audio into 30-second chunks with 25% overlap +2. Transcribe each chunk independently +3. Use GPT-4o to remove duplicated words at chunk boundaries +4. Join cleaned segments into final transcript + +**Audio-to-dialog conversion:** + +After transcription, use GPT-4o to determine whether the transcript is a conversation or monologue and output structured JSON: + +``` +You are a helpful assistant designed to output JSON on either one of these formats: +1 - {"dialog": [{"name": "name", "gender": "gender", "text": "text"}]} +2 - {"dialog": "text"} + +A transcription of an audio file will be provided to you. Based on that transcription you will need to determine whether the transcription is a conversation or a monologue. If it is a conversation, output format 1. If it is a monologue, output format 2. +``` + +### 6.6 ELAI Integration (AI Video) + +Implemented in `encoach_ai_media` module. + +| Parameter | Value | +|-----------|-------| +| **Base URL** | `https://apis.elai.io/api/v1/videos` | +| **Auth** | Bearer token (Odoo system parameter `encoach.elai_token`) | + +**Video generation flow:** + +1. `POST /api/v1/videos` -- Create video with avatar, text, voice +2. `POST /api/v1/videos/render/{video_id}` -- Start rendering +3. Poll `GET /api/v1/videos/{video_id}` -- Check status (`ready`, `failed`, or in progress) +4. Return video URL when `ready` + +**Avatar configuration:** Stored in `encoach.elai.avatar` model (avatar codes, voice IDs, voice providers). + +### 6.7 GPTZero Integration (AI Detection) + +Implemented in `encoach_ai_grading` module. + +| Parameter | Value | +|-----------|-------| +| **Endpoint** | `https://api.gptzero.me/v2/predict/text` | +| **Auth** | `x-api-key` header (Odoo system parameter `encoach.gptzero_api_key`) | + +**Request:** +```json +{ + "document": "student's writing text", + "version": "", + "multilingual": false +} +``` + +**Response fields used:** +- `class_probabilities` -- probability scores for human/AI/mixed +- `predicted_class` -- `human`, `ai`, or `mixed` +- `sentences[].highlight_sentence_for_ai` -- boolean per sentence + +**Result stored in evaluation:** `"ai_detection": { "probability": 0.12, "predicted_class": "human" }` + +### 6.8 FAISS + Sentence-Transformers (Training) + +Implemented in `encoach_training` module. + +| Parameter | Value | +|-----------|-------| +| **Embeddings model** | `sentence-transformers/all-MiniLM-L6-v2` | +| **Index type** | `faiss.IndexFlatL2` (one per category) | +| **Top-K results** | 5 | + +**Categories:** + +| Category | Description | +|----------|-------------| +| `ct_focus` | Critical thinking focus tips | +| `language_for_writing` | Language for writing tips | +| `reading_skill` | Reading skills tips | +| `strategy` | Test strategy tips | +| `writing_skill` | Writing skills tips | +| `word_link` | Word linking tips | +| `word_partners` | Word partner/collocation tips | + +**Index files:** Store as Odoo attachments or on disk: +- `{category}_tips_index.faiss` -- FAISS index file per category +- `tips_metadata.pkl` -- Metadata mapping index positions to tip content + +**Query flow:** + +1. Encode user query using sentence-transformers: `embedding = model.encode([query])` +2. Search FAISS index: `distances, indices = index.search(embedding, top_k=5)` +3. Retrieve tip content from metadata +4. Pass tips + user performance data to GPT for personalized recommendations + +**Training content generation flow:** + +1. Receive user's exam stats (performance comments, detailed summaries) +2. Use GPT to identify weak areas and generate training queries +3. For each query, search FAISS index to find relevant tips +4. Use GPT to generate personalized recommendations based on tips + performance +5. Store result in `encoach.training` + +### 6.9 Topic and Content Constants + +Maintain in `encoach_ai` module as constants or configuration records: + +**Topics for content generation:** + +- `TOPICS` -- General IELTS topics (education, technology, environment, health, etc.) +- `TWO_PEOPLE_SCENARIOS` -- Listening Section 1 scenarios +- `SOCIAL_MONOLOGUE_CONTEXTS` -- Listening Section 2 contexts +- `FOUR_PEOPLE_SCENARIOS` -- Listening Section 3 scenarios +- `ACADEMIC_SUBJECTS` -- Listening Section 4 subjects + +**Difficulty levels:** `["A1", "A2", "B1", "B2", "C1", "C2"]` + +**Blacklisted words:** List of culturally sensitive terms that trigger retry if found in AI-generated content (religion, politics, explicit content, etc.) + +### 6.10 Python Dependencies + +Add to the Odoo server's Python environment: + +| Package | Version | Purpose | +|---------|---------|---------| +| `openai` | >= 1.50 | OpenAI API client (GPT-4o, GPT-3.5) | +| `openai-whisper` | latest | Local Whisper model for STT | +| `boto3` | >= 1.34 | AWS SDK for Polly TTS | +| `faiss-cpu` | >= 1.7 | FAISS vector search | +| `sentence-transformers` | >= 3.0 | Embeddings for FAISS | +| `httpx` | >= 0.27 | HTTP client for ELAI, GPTZero | +| `tiktoken` | >= 0.7 | Token counting for OpenAI | +| `librosa` | >= 0.10 | Audio processing for Whisper | +| `soundfile` | >= 0.12 | Audio file I/O | +| `numpy` | >= 1.26 | Numerical operations | +| `tenacity` | >= 8.2 | Retry logic for API calls | +| `torch` | >= 2.0 | Required by Whisper and sentence-transformers | + +**Server requirements for Whisper:** The Odoo server needs at least 4 GB RAM for the Whisper base model and sentence-transformers model. Consider running Whisper on a GPU for better performance, or use the OpenAI Whisper API instead of local inference. + +### 6.11 Configuration (Odoo System Parameters) + +| Parameter Key | Description | Example | +|--------------|-------------|---------| +| `encoach.openai_api_key` | OpenAI API key | `sk-...` | +| `encoach.aws_access_key_id` | AWS access key | `AKIA...` | +| `encoach.aws_secret_access_key` | AWS secret key | `wJal...` | +| `encoach.elai_token` | ELAI API token | `Bearer ...` | +| `encoach.gptzero_api_key` | GPTZero API key | `...` | +| `encoach.whisper_model_size` | Whisper model size | `base` (or `small`, `medium`, `large`) | +| `encoach.whisper_workers` | Number of Whisper worker threads | `4` | +| `encoach.grading_temperature` | Temperature for grading prompts | `0.1` | +| `encoach.generation_temperature` | Temperature for content generation | `0.7` | +| `encoach.tips_temperature` | Temperature for training tips | `0.2` | + +--- + +## 7. Payment Integration + +Identical to v1 document Section 7. Summary: + +- **Stripe:** Checkout session creation, webhook handling, subscription extension +- **PayPal:** Order creation, capture, subscription update +- **Paymob:** Intention creation, transaction webhook verification +- Subscription logic: extend `subscriptionExpirationDate` on successful payment; propagate to entity members for corporate + +--- + +## 8. Business Rules & Workflows + +Identical to v1 document Section 8, with one addition: + +### 8.1-8.7 (Unchanged from v1) + +See v1 for: Registration flow, Subscription management, Entity licensing, Assignment lifecycle, Grading polling, Corporate payment activation, User change propagation. + +### 8.8 AI Content Moderation (New) + +All AI-generated content must be validated before being returned to the user: + +1. **Blacklist check:** Scan generated text against the blacklisted words list +2. **Retry logic:** If blacklisted words are found, regenerate with the same prompt (up to 2 retries) +3. **JSON validation:** Verify the AI response is valid JSON with all required fields +4. **Score validation:** For grading, verify all scores are between 0.0 and 9.0 +5. **Content length:** For passages, verify minimum word count is met + +### 8.9 Batch User Import (Updated) + +In v1, batch import was proxied to ielts-be (which imported into Firebase Auth). In v2: + +1. Admin uploads CSV/Excel with user data +2. Odoo parses the file and creates `res.users` records directly +3. Generates temporary passwords and sends welcome emails +4. Creates registration codes and group assignments +5. No Firebase Auth involvement + +--- + +## 9. Data Migration Plan + +### 9.1 Migration Strategy + +Perform a one-time data migration from both data sources: + +1. **MongoDB** (used by ielts-ui) -> PostgreSQL +2. **MongoDB** (used by ielts-be) -> PostgreSQL (evaluation records, training data) +3. **Firebase Auth** users -> Odoo `res.users` + +### 9.2 Collection-to-Model Mapping + +Identical to v1 Section 9.2, with additional ielts-be collections: + +| ielts-be MongoDB Collection | Odoo Model | Notes | +|----------------------------|------------|-------| +| `evaluation` | `encoach.evaluation` | Grading records with result JSON | +| `training` | `encoach.training` | Training content | + +### 9.3 Additional Migration: AI Assets + +| Asset | Source | Target | +|-------|--------|--------| +| FAISS index files | ielts-be `./faiss/` directory | Odoo server filesystem or `ir.attachment` | +| Tips metadata | `tips_metadata.pkl` | `encoach.training.tip` model records | +| Tips source data | `pathways_2_rw_with_ids.json` | `encoach.training.tip` model records | +| Avatar config | `conf.json`, `avatars.json` | `encoach.elai.avatar` model records | +| Whisper model | Downloaded at runtime | Odoo server filesystem | + +### 9.4 Migration Script Requirements & Import Order + +Identical to v1 Section 9.3 and 9.4, with the addition of: + +22. `encoach.training.tip` (from tips JSON) +23. `encoach.elai.avatar` (from avatar config JSON) +24. FAISS index rebuild (re-embed tips into FAISS after import) + +--- + +## 10. Non-Functional Requirements + +### 10.1-10.6 (Unchanged from v1) + +See v1 for: API response format, CORS, file storage, performance targets, scalability, security. + +### 10.7 AI/ML Performance Requirements (New) + +| Operation | Target Response Time | Notes | +|-----------|---------------------|-------| +| Reading passage generation | < 15s | Single GPT-4o call | +| Listening dialog generation | < 15s | Single GPT-4o call | +| Listening MP3 generation | < 30s | Multiple Polly calls + concatenation | +| Writing task generation | < 10s | Single GPT-4o call | +| Speaking task generation | < 10s | Single GPT-4o call | +| Level exercise generation | < 20s | Multiple GPT-4o calls | +| Writing grading (async) | < 30s total | Parallel: GPT-4o grading + GPTZero + perfect answer + fixed text | +| Speaking grading (async) | < 60s total | Sequential: Whisper transcription + GPT-4o grading | +| Short answer grading | < 10s | Single GPT-4o call | +| Transcription (1 min audio) | < 15s | Local Whisper | +| FAISS query | < 1s | Local computation | +| Video generation (async) | 1-5 min | ELAI rendering; poll for completion | + +### 10.8 AI/ML Scalability (New) + +- Whisper model requires ~1 GB RAM per instance (4 instances = 4 GB) +- sentence-transformers model requires ~500 MB RAM +- FAISS indices are small (< 100 MB total) +- OpenAI API has rate limits -- implement request queuing if needed +- AWS Polly has a 3000-character limit per request -- chunking is already handled +- Consider GPU acceleration for Whisper if transcription volume is high + +### 10.9 AI/ML Monitoring (New) + +- Log all OpenAI API calls with model, token usage, response time, and cost +- Log all Polly synthesis calls with character count +- Log all ELAI video generation requests with status and duration +- Log all Whisper transcription jobs with audio duration and processing time +- Alert on repeated grading failures (> 3 consecutive errors) +- Track OpenAI API spend against budget thresholds + +### 10.10 Logging, Testing (Unchanged from v1) + +See v1 Sections 10.7 and 10.8. + +--- + +## Appendix A: Exercise Type Reference + +Identical to v1 Appendix A. + +## Appendix B: Grading Rubric Details + +Identical to v1 Appendix B. + +## Appendix C: Environment Variables + +Updated to include all AI/ML service credentials: + +| Variable | Description | Example | +|----------|-------------|---------| +| `ENCOACH_JWT_SECRET` | JWT signing secret | Random 256-bit string | +| `OPENAI_API_KEY` | OpenAI API key | `sk-...` | +| `AWS_ACCESS_KEY_ID` | AWS access key for Polly | `AKIA...` | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key for Polly | `wJal...` | +| `ELAI_TOKEN` | ELAI API bearer token | `...` | +| `GPT_ZERO_API_KEY` | GPTZero API key | `...` | +| `WHISPER_MODEL_SIZE` | Whisper model size | `base` | +| `STRIPE_SECRET_KEY` | Stripe secret key | `sk_live_...` | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | `whsec_...` | +| `PAYPAL_CLIENT_ID` | PayPal client ID | `AX...` | +| `PAYPAL_CLIENT_SECRET` | PayPal client secret | `EL...` | +| `PAYPAL_ACCESS_TOKEN_URL` | PayPal OAuth URL | `https://api.paypal.com/v1/oauth2/token` | +| `PAYMOB_API_KEY` | Paymob API key | `ZXlK...` | +| `PAYMOB_SECRET` | Paymob webhook secret | `...` | + +## Appendix D: Glossary + +Identical to v1 Appendix D, with additions: + +| Term | Definition | +|------|-----------| +| **Whisper** | OpenAI's speech-to-text model, run locally on the Odoo server | +| **Polly** | AWS text-to-speech service for generating listening exam audio | +| **ELAI** | AI video generation service for creating speaking exam avatar videos | +| **GPTZero** | AI text detection service for identifying AI-generated writing submissions | +| **FAISS** | Facebook AI Similarity Search -- vector search library for finding relevant training tips | +| **sentence-transformers** | Python library for generating text embeddings used with FAISS | + +## Appendix E: Custom Odoo Module Summary + +| Module | Complexity | Key Models | Key Services | External APIs | +|--------|------------|------------|-------------- |---------------| +| `encoach_core` | Medium | User extensions, Entity, Role, Permission, Code, Invite | Registration, entity management | -- | +| `encoach_exam` | High | Exam (5 modules), Exercise structures | Exam CRUD, import/export | -- | +| `encoach_classroom` | Low | Group | Group management | -- | +| `encoach_assignment` | Medium | Assignment | Assignment lifecycle | -- | +| `encoach_stats` | Medium | Session, Stat | Stats tracking, PDF export | -- | +| `encoach_evaluation` | Medium | Evaluation, AI Job | Async grading coordination | -- | +| `encoach_training` | Medium | Training, Training Tip | FAISS search, tip generation | -- | +| `encoach_subscription` | Medium | Package, Payment, Subscription Payment, Discount | Subscription management | Stripe, PayPal, Paymob | +| `encoach_registration` | Medium | Code, Invite | Multi-path registration | -- | +| `encoach_ticket` | Low | Ticket | Ticket CRUD | -- | +| `encoach_ai` | **Very High** | -- | OpenAI client, prompt management, retry logic | OpenAI API | +| `encoach_ai_grading` | High | -- | Writing grading, speaking grading, short answer grading | OpenAI, GPTZero | +| `encoach_ai_generation` | High | -- | Reading/listening/writing/speaking/level generation | OpenAI | +| `encoach_ai_media` | High | ELAI Avatar | Polly TTS, Whisper STT, ELAI video | AWS Polly, ELAI, Whisper (local) | +| `encoach_api` | High | -- | REST JSON controllers (~60 endpoints) | -- | diff --git a/README.md b/README.md new file mode 100644 index 0000000..ae2299f --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Extra / custom addons + +Put your custom or third-party Odoo modules here (each as a subfolder with `__manifest__.py`). + +This path is loaded first, so your modules override Community modules with the same name. diff --git a/encoach_ai/__init__.py b/encoach_ai/__init__.py new file mode 100644 index 0000000..71a0242 --- /dev/null +++ b/encoach_ai/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/encoach_ai/__manifest__.py b/encoach_ai/__manifest__.py new file mode 100644 index 0000000..d4a0d67 --- /dev/null +++ b/encoach_ai/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "EnCoach AI", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Core AI services for the EnCoach IELTS platform", + "description": "OpenAI integration, constants, blacklist validation, token counting.", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", + "external_dependencies": { + "python": ["openai", "tiktoken", "httpx", "tenacity", "numpy"], + }, +} diff --git a/encoach_ai/models/__init__.py b/encoach_ai/models/__init__.py new file mode 100644 index 0000000..87030a7 --- /dev/null +++ b/encoach_ai/models/__init__.py @@ -0,0 +1 @@ +from . import constants diff --git a/encoach_ai/models/constants.py b/encoach_ai/models/constants.py new file mode 100644 index 0000000..3a5ccb9 --- /dev/null +++ b/encoach_ai/models/constants.py @@ -0,0 +1,59 @@ +BLACKLISTED_WORDS = [ + "alcohol", "atheism", "beer", "bible", "blasphemy", + "buddha", "buddhism", "cannabis", "casino", "christ", + "christianity", "church", "cocaine", "communist", "condom", + "corruption", "crucifixion", "cult", "drug", "drugs", + "erotic", "extremism", "gambling", "gay", "god", + "gods", "gospel", "haram", "heresy", "heroin", + "hindu", "hinduism", "homosexual", "idol", "infidel", + "islam", "islamic", "israeli", "jesus", "jew", + "jewish", "jihad", "judaism", "lesbian", "liquor", + "marijuana", "mosque", "muhammad", "muslim", "naked", + "nazi", "nude", "pagan", "pig", "pork", + "porn", "pornography", "prayer", "priest", "prophet", + "prostitution", "quran", "rabbi", "rape", "religion", + "religious", "satan", "sex", "sexual", "sharia", + "shinto", "sikh", "strip", "suicide", "synagogue", + "temple", "terrorism", "terrorist", "torah", "vodka", + "whiskey", "wine", "worship", "zionism", +] + +TOPICS = [ + "Technology", "Education", "Health", "Environment", "Travel", + "Food and Nutrition", "Sports", "Music", "Art", "Science", + "History", "Geography", "Business", "Economy", "Media", + "Communication", "Fashion", "Architecture", "Transportation", "Energy", + "Water Resources", "Agriculture", "Space Exploration", "Wildlife", + "Climate Change", "Urbanization", "Rural Life", "Population Growth", + "Immigration", "Cultural Diversity", "Globalization", "Tourism", + "Employment", "Workplace Culture", "Leadership", "Innovation", + "Artificial Intelligence", "Social Media", "Advertising", "Cinema", + "Literature", "Languages", "Volunteering", "Charity", + "Mental Health", "Fitness", "Aging Population", "Childhood Development", + "Higher Education", "Online Learning", "Public Libraries", + "Museums", "Public Transport", "Cycling", "Renewable Energy", + "Recycling", "Waste Management", "Deforestation", "Ocean Conservation", + "Animal Rights", "Genetic Engineering", "Robotics", "Cybersecurity", + "Privacy", "Freedom of Speech", "Democracy", "Law Enforcement", + "Crime Prevention", "Housing", "Homelessness", "Poverty", + "Income Inequality", "Consumer Culture", "Minimalism", "Traditions", + "Festivals", "Cooking", "Gardening", "Photography", + "Podcasts", "Board Games", "Pet Ownership", "Astronomy", + "Archaeology", "Philosophy", "Psychology", "Sociology", + "Ecology", "Marine Biology", "Meteorology", "Geology", + "Time Management", "Stress Management", "Work-Life Balance", +] + +CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"] + +GPT_MODELS = { + "grading": "gpt-4o", + "generation": "gpt-4o", + "secondary": "gpt-3.5-turbo", +} + +TEMPERATURE = { + "grading": 0.1, + "generation": 0.7, + "tips": 0.2, +} diff --git a/encoach_ai/security/ir.model.access.csv b/encoach_ai/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_ai/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_ai/services/__init__.py b/encoach_ai/services/__init__.py new file mode 100644 index 0000000..84c1031 --- /dev/null +++ b/encoach_ai/services/__init__.py @@ -0,0 +1 @@ +from . import openai_service diff --git a/encoach_ai/services/openai_service.py b/encoach_ai/services/openai_service.py new file mode 100644 index 0000000..9c021c2 --- /dev/null +++ b/encoach_ai/services/openai_service.py @@ -0,0 +1,103 @@ +import json +import logging + +import tiktoken +from openai import OpenAI +from tenacity import retry, stop_after_attempt, wait_exponential + +from ..models.constants import BLACKLISTED_WORDS + +_logger = logging.getLogger(__name__) + +TOKEN_RESERVE = 300 +MODEL_TOKEN_LIMIT = 4097 + + +class EncoachOpenAIService: + + def __init__(self, env): + self.env = env + api_key = ( + env["ir.config_parameter"] + .sudo() + .get_param("encoach.openai_api_key", "") + ) + if not api_key: + _logger.warning("OpenAI API key not configured (encoach.openai_api_key)") + self.client = OpenAI(api_key=api_key) + + def prediction( + self, + model, + messages, + temperature=0.7, + response_format=None, + fields_to_check=None, + check_blacklisted=True, + max_retries=2, + ): + """Call OpenAI chat completion with validation and blacklist filtering. + + Returns parsed JSON dict on success or None on failure. + """ + if response_format is None: + response_format = {"type": "json_object"} + + input_tokens = self._count_tokens( + " ".join(m.get("content", "") for m in messages if isinstance(m.get("content"), str)), + model, + ) + max_tokens = max(MODEL_TOKEN_LIMIT - input_tokens - TOKEN_RESERVE, 256) + + attempt = 0 + while attempt <= max_retries: + try: + resp = self.client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + response_format=response_format, + ) + content = resp.choices[0].message.content + data = json.loads(content) + + if check_blacklisted: + text_to_check = content + if fields_to_check and isinstance(data, dict): + text_to_check = " ".join( + str(data.get(f, "")) for f in fields_to_check + ) + if self._check_blacklisted(text_to_check): + _logger.info( + "Blacklisted content detected (attempt %d/%d)", + attempt + 1, + max_retries + 1, + ) + attempt += 1 + continue + + return data + + except json.JSONDecodeError: + _logger.warning("Invalid JSON from OpenAI (attempt %d)", attempt + 1) + attempt += 1 + except Exception: + _logger.exception("OpenAI API error (attempt %d)", attempt + 1) + attempt += 1 + + _logger.error("OpenAI prediction failed after %d attempts", max_retries + 1) + return None + + @staticmethod + def _check_blacklisted(text): + lower = text.lower() + return any(word in lower for word in BLACKLISTED_WORDS) + + @staticmethod + def _count_tokens(text, model="gpt-4o"): + try: + encoding = tiktoken.encoding_for_model(model) + except KeyError: + encoding = tiktoken.get_encoding("cl100k_base") + return len(encoding.encode(text)) diff --git a/encoach_ai_generation/__init__.py b/encoach_ai_generation/__init__.py new file mode 100644 index 0000000..99464a7 --- /dev/null +++ b/encoach_ai_generation/__init__.py @@ -0,0 +1 @@ +from . import services diff --git a/encoach_ai_generation/__manifest__.py b/encoach_ai_generation/__manifest__.py new file mode 100644 index 0000000..3431c10 --- /dev/null +++ b/encoach_ai_generation/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "EnCoach AI Generation", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "AI-powered IELTS exam content generation", + "description": "Reading passages, listening dialogs, writing/speaking tasks, level exercises.", + "depends": ["encoach_ai", "encoach_exam"], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_ai_generation/security/ir.model.access.csv b/encoach_ai_generation/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_ai_generation/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_ai_generation/services/__init__.py b/encoach_ai_generation/services/__init__.py new file mode 100644 index 0000000..8f3285a --- /dev/null +++ b/encoach_ai_generation/services/__init__.py @@ -0,0 +1 @@ +from . import generation_service diff --git a/encoach_ai_generation/services/generation_service.py b/encoach_ai_generation/services/generation_service.py new file mode 100644 index 0000000..5273e5f --- /dev/null +++ b/encoach_ai_generation/services/generation_service.py @@ -0,0 +1,352 @@ +import json +import logging +import random + +from odoo.addons.encoach_ai.models.constants import ( + CEFR_LEVELS, + GPT_MODELS, + TEMPERATURE, + TOPICS, +) +from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService + +_logger = logging.getLogger(__name__) + +PASSAGE_DIFFICULTY = { + 1: "fairly easy and consist of multiple paragraphs", + 2: "fairly hard and consist of multiple paragraphs", + 3: ( + "very hard, present different points or theories, " + "cite different sources, and consist of multiple paragraphs" + ), +} + +LISTENING_SECTION_CONFIG = { + 1: { + "type": "conversation", + "format": '{"conversation": [{"name": "name", "gender": "gender", "text": "text"}]}', + "prompt": ( + "Compose an authentic conversation between two individuals " + 'on the topic of "{topic}". Please include random names and genders. ' + "Include misleading discourse (dates, colors, etc.) and spelling of names. " + "Make sure that the generated conversation does not contain " + "forbidden subjects in muslim countries." + ), + }, + 2: { + "type": "monologue", + "format": '{"monologue": "monologue"}', + "prompt": ( + "Generate a comprehensive monologue set in the social context " + 'of "{topic}". Make sure that the generated monologue does not ' + "contain forbidden subjects in muslim countries." + ), + }, + 3: { + "type": "conversation", + "format": '{"conversation": [{"name": "name", "gender": "gender", "text": "text"}]}', + "prompt": ( + "Compose an authentic and elaborate conversation between " + 'up to four individuals on the topic of "{topic}". ' + "Please include random names and genders. Make sure that the " + "generated conversation does not contain forbidden subjects " + "in muslim countries." + ), + }, + 4: { + "type": "monologue", + "format": '{"monologue": "monologue"}', + "prompt": ( + "Generate a comprehensive and complex monologue on the academic " + 'subject of "{topic}". Make sure that the generated monologue ' + "does not contain forbidden subjects in muslim countries." + ), + }, +} + + +class EncoachGenerationService: + + def __init__(self, env): + self.env = env + self.ai = EncoachOpenAIService(env) + + # ------------------------------------------------------------------ + # Reading + # ------------------------------------------------------------------ + + def generate_reading_passage(self, passage_num, topic=None, word_count=500): + """Generate an IELTS reading passage (passage 1, 2, or 3).""" + topic = topic or random.choice(TOPICS) + difficulty = PASSAGE_DIFFICULTY.get(passage_num, PASSAGE_DIFFICULTY[1]) + + system_msg = ( + "You are a helpful assistant designed to output JSON on this format: " + '{"title": "title of the text", "text": "generated text"}' + ) + user_msg = ( + f"Generate an extensive text for IELTS Reading Passage {passage_num}, " + f'of at least {word_count} words, on the topic of "{topic}". ' + "The passage should offer a substantial amount of information " + f"relevant to the chosen subject matter. It should be {difficulty}. " + "Make sure that the generated text does not contain forbidden " + "subjects in muslim countries." + ) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + fields_to_check=["title", "text"], + ) + + def generate_reading_exercises(self, text, exercises_config, difficulty): + """Generate exercises for a reading passage. + + exercises_config: list of dicts with 'type' and 'quantity' keys. + difficulty: CEFR level string. + """ + system_msg = ( + "You are a helpful assistant designed to output JSON. " + "Generate reading comprehension exercises based on the given text." + ) + config_str = json.dumps(exercises_config) + user_msg = ( + f"Based on the following text, generate exercises according to this " + f"configuration: {config_str}. Target CEFR level: {difficulty}.\n\n" + f'Text: "{text}"' + ) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) + + # ------------------------------------------------------------------ + # Listening + # ------------------------------------------------------------------ + + def generate_listening_dialog(self, section, topic=None, difficulty=None): + """Generate a listening dialog/monologue for the given section (1-4).""" + topic = topic or random.choice(TOPICS) + config = LISTENING_SECTION_CONFIG.get(section, LISTENING_SECTION_CONFIG[1]) + + system_msg = ( + "You are a helpful assistant designed to output JSON on this format: " + + config["format"] + ) + user_msg = config["prompt"].format(topic=topic) + if difficulty: + user_msg += f" Target CEFR level: {difficulty}." + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) + + def generate_listening_exercises(self, text, exercises_config, difficulty): + """Generate exercises for a listening transcript.""" + system_msg = ( + "You are a helpful assistant designed to output JSON. " + "Generate listening comprehension exercises based on the given transcript." + ) + config_str = json.dumps(exercises_config) + user_msg = ( + f"Based on the following transcript, generate exercises according to " + f"this configuration: {config_str}. Target CEFR level: {difficulty}.\n\n" + f'Transcript: "{text}"' + ) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) + + # ------------------------------------------------------------------ + # Writing + # ------------------------------------------------------------------ + + def generate_writing_task( + self, task, topic=None, difficulty=None, task_type="general" + ): + """Generate a writing task prompt. + + task: 1 or 2 + task_type: 'general' or 'academic' + """ + topic = topic or random.choice(TOPICS) + difficulty = difficulty or random.choice(CEFR_LEVELS) + + system_msg = ( + "You are a helpful assistant designed to output JSON on this format: " + '{"question": "the generated writing task prompt"}' + ) + + if task == 1 and task_type == "general": + user_msg = ( + "Craft a prompt for an IELTS Writing Task 1 General Training " + "exercise that instructs the student to compose a letter based " + f'on the topic of "{topic}" of {difficulty} CEFR level difficulty. ' + 'The prompt should end with "In the letter you should" followed ' + "by 3 bullet points. Make sure it does not contain forbidden " + "subjects in muslim countries." + ) + elif task == 2: + user_msg = ( + f"Craft a comprehensive question of {difficulty} CEFR level " + "difficulty like the ones for IELTS Writing Task 2 General " + "Training that directs the candidate to delve into an in-depth " + "analysis of contrasting perspectives on the topic of " + f'"{topic}". The question should lead to an answer with either ' + '"theories", "complicated information" or be "very descriptive" ' + "on the topic." + ) + else: + user_msg = ( + "Analyze the uploaded image and create a detailed IELTS Writing " + "Task 1 Academic prompt. Describe the visual type, context, and " + f"create a prompt at {difficulty} CEFR level." + ) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + fields_to_check=["question"], + ) + + # ------------------------------------------------------------------ + # Speaking + # ------------------------------------------------------------------ + + def generate_speaking_task( + self, + part, + topic=None, + first_topic=None, + second_topic=None, + difficulty=None, + ): + """Generate a speaking task for the given part (1, 2, or 3).""" + system_msg = ( + "You are a helpful assistant designed to output JSON. " + "Generate IELTS speaking task questions." + ) + + if part == 1: + first_topic = first_topic or random.choice(TOPICS) + second_topic = second_topic or random.choice(TOPICS) + user_msg = ( + "Craft 5 simple and single questions of easy difficulty for " + "IELTS Speaking Part 1 that encourages candidates to delve " + "deeply into personal experiences, preferences, or insights " + f'on the topic of "{first_topic}" and the topic of ' + f'"{second_topic}". The questions should lead to the usage ' + "of 4 verb tenses (present perfect, present, past and future). " + "Make sure that the generated question does not contain " + "forbidden subjects in muslim countries." + ) + elif part == 2: + topic = topic or random.choice(TOPICS) + user_msg = ( + "Create a question of medium difficulty for IELTS Speaking " + "Part 2 that encourages candidates to narrate a personal " + f'experience or story related to the topic of "{topic}". ' + "Include 3 prompts that guide the candidate. The prompts " + "must not be questions. Also include a suffix like the ones " + 'in the IELTS exams that start with "And explain why". ' + "Make sure that the generated question does not contain " + "forbidden subjects in muslim countries." + ) + else: + topic = topic or random.choice(TOPICS) + user_msg = ( + "Formulate a set of 5 single questions of hard difficulty " + "for IELTS Speaking Part 3 that encourage candidates to " + "engage in a meaningful discussion on the topic of " + f'"{topic}". They must be 1 single question each and not ' + "be double-barreled questions. Make sure that the generated " + "question does not contain forbidden subjects in muslim countries." + ) + + if difficulty: + user_msg += f" Target CEFR level: {difficulty}." + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) + + # ------------------------------------------------------------------ + # Level exercises + # ------------------------------------------------------------------ + + def generate_level_exercises(self, exercises_config, difficulty): + """Generate level-exam exercises (multiple choice, fill-blanks, etc.). + + exercises_config: list of dicts with 'type', 'quantity', and optional + 'topic', 'size' keys. + difficulty: CEFR level string. + """ + system_msg = ( + "You are a helpful assistant designed to output JSON. " + "Generate English level exam exercises." + ) + + prompts = [] + for cfg in exercises_config: + ex_type = cfg.get("type", "multiple_choice") + quantity = cfg.get("quantity", 5) + topic = cfg.get("topic") or random.choice(TOPICS) + size = cfg.get("size", 200) + + if ex_type == "multiple_choice": + prompts.append( + f"Generate {quantity} multiple choice questions of 4 options " + f"for an english level exam of {difficulty} CEFR level. " + "Ensure that the questions cover a range of topics such as " + "verb tense, subject-verb agreement, pronoun usage, sentence " + "structure, and punctuation. Make sure every question only has " + "1 correct answer." + ) + elif ex_type == "fill_blanks": + prompts.append( + f"Generate a text of at least {size} words about the topic " + f"{topic}. From the generated text choose exactly {quantity} " + "words (cannot be sequential words), replace each with " + "{id}, and generate a JSON object containing: the modified " + "text, solutions array, words array with four options per blank." + ) + + user_msg = "\n\n".join(prompts) + + return self.ai.prediction( + model=GPT_MODELS["generation"], + messages=[ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_msg}, + ], + temperature=TEMPERATURE["generation"], + ) diff --git a/encoach_ai_grading/__init__.py b/encoach_ai_grading/__init__.py new file mode 100644 index 0000000..99464a7 --- /dev/null +++ b/encoach_ai_grading/__init__.py @@ -0,0 +1 @@ +from . import services diff --git a/encoach_ai_grading/__manifest__.py b/encoach_ai_grading/__manifest__.py new file mode 100644 index 0000000..c38ce30 --- /dev/null +++ b/encoach_ai_grading/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "EnCoach AI Grading", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "AI-powered IELTS grading for writing and speaking", + "description": "GPT-4o grading with IELTS rubrics, GPTZero AI detection, short-answer evaluation.", + "depends": ["encoach_ai", "encoach_exam"], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_ai_grading/security/ir.model.access.csv b/encoach_ai_grading/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_ai_grading/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_ai_grading/services/__init__.py b/encoach_ai_grading/services/__init__.py new file mode 100644 index 0000000..dc335a3 --- /dev/null +++ b/encoach_ai_grading/services/__init__.py @@ -0,0 +1 @@ +from . import grading_service diff --git a/encoach_ai_grading/services/grading_service.py b/encoach_ai_grading/services/grading_service.py new file mode 100644 index 0000000..81d2e19 --- /dev/null +++ b/encoach_ai_grading/services/grading_service.py @@ -0,0 +1,340 @@ +import json +import logging + +import httpx + +from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE +from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService + +_logger = logging.getLogger(__name__) + +WRITING_RESPONSE_TEMPLATE = json.dumps({ + "comment": "comment about student's response quality", + "overall": 0.0, + "task_response": { + "Task Achievement": {"grade": 0.0, "comment": "..."}, + "Coherence and Cohesion": {"grade": 0.0, "comment": "..."}, + "Lexical Resource": {"grade": 0.0, "comment": "..."}, + "Grammatical Range and Accuracy": {"grade": 0.0, "comment": "..."}, + }, +}) + +SPEAKING_RESPONSE_TEMPLATE = json.dumps({ + "comment": "extensive comment about answer quality", + "overall": 0.0, + "task_response": { + "Fluency and Coherence": {"grade": 0.0, "comment": "extensive comment..."}, + "Lexical Resource": {"grade": 0.0, "comment": "..."}, + "Grammatical Range and Accuracy": {"grade": 0.0, "comment": "..."}, + "Pronunciation": {"grade": 0.0, "comment": "..."}, + }, +}) + +SHORT_ANSWER_TEMPLATE = json.dumps({ + "exercises": [ + {"id": "exercise-id", "correct": True, "correct_answer": "the correct answer"}, + ], +}) + +SUMMARY_TEMPLATE = json.dumps({ + "sections": [ + { + "code": "section-code", + "name": "Section Name", + "grade": 0.0, + "evaluation": "Detailed evaluation text...", + "suggestions": "Improvement suggestions...", + "bullet_points": ["point 1", "point 2"], + }, + ], +}) + +SPEAKING_TASK_INSTRUCTIONS = { + 1: ( + 'Address the student as "you". ' + "If the answers are not 2 or 3 sentences long, " + "warn the student that they should be." + ), + 2: 'Address the student as "you".', + 3: ( + 'Address the student as "you" and pay special attention ' + "to coherence between the answers." + ), +} + + +class EncoachGradingService: + + def __init__(self, env): + self.env = env + self.ai = EncoachOpenAIService(env) + + # ------------------------------------------------------------------ + # Writing grading + # ------------------------------------------------------------------ + + def grade_writing(self, question, answer, task, attachment=None): + """Grade a writing answer using GPT-4o with IELTS writing rubric. + + Returns dict with comment, overall, task_response, fixed_text, + perfect_answer, and ai_detection results. + """ + system_msg = ( + "You are a helpful assistant designed to output JSON " + f"on this format: {WRITING_RESPONSE_TEMPLATE}" + ) + + if task == 1: + user_prompt = ( + f"Evaluate the given Writing Task {task} response based on the " + "IELTS grading system, ensuring a strict assessment that penalizes " + "errors. Deduct points for deviations from the task, and assign a " + "score of 0 if the response fails to address the question. " + "Additionally, provide a detailed commentary highlighting both " + "strengths and weaknesses in the response.\n" + 'Refer to the parts of the letter as: "Greeting Opener", ' + '"bullet 1", "bullet 2", "bullet 3", ' + '"closer (restate the purpose of the letter)", "closing greeting".\n' + f'Question: "{question}"\nAnswer: "{answer}"' + ) + else: + user_prompt = ( + f"Evaluate the given Writing Task {task} response based on the " + "IELTS grading system, ensuring a strict assessment that penalizes " + "errors. Deduct points for deviations from the task, and assign a " + "score of 0 if the response fails to address the question. " + "Additionally, provide a detailed commentary highlighting both " + "strengths and weaknesses in the response.\n" + f'Question: "{question}"\nAnswer: "{answer}"' + ) + + messages = [ + {"role": "system", "content": system_msg}, + ] + + if attachment and task == 1: + messages.append({ + "role": "user", + "content": [ + {"type": "text", "text": user_prompt}, + { + "type": "image_url", + "image_url": {"url": attachment}, + }, + ], + }) + else: + messages.append({"role": "user", "content": user_prompt}) + + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=messages, + temperature=TEMPERATURE["grading"], + fields_to_check=["comment"], + ) + if not result: + return None + + result["fixed_text"] = self._get_fixed_text(answer) + result["perfect_answer"] = self._get_perfect_answer(question) + result["ai_detection"] = self._detect_ai(answer) + return result + + # ------------------------------------------------------------------ + # Speaking grading + # ------------------------------------------------------------------ + + def grade_speaking(self, task, qa_pairs): + """Grade speaking using GPT-4o with IELTS speaking rubric. + + qa_pairs: list of dicts with 'question' and 'answer' keys. + """ + system_msg = ( + "You are a helpful assistant designed to output JSON " + f"on this format: {SPEAKING_RESPONSE_TEMPLATE}" + ) + + qa_text = "\n".join( + f'Question: "{p["question"]}"\nAnswer: "{p["answer"]}"' + for p in qa_pairs + ) + task_instruction = SPEAKING_TASK_INSTRUCTIONS.get(task, "") + + user_prompt = ( + f"Evaluate the given Speaking Part {task} response based on the " + "IELTS grading system, ensuring a strict assessment that penalizes " + "errors. Deduct points for deviations from the task, and assign a " + "score of 0 if the response fails to address the question. " + "Additionally, provide detailed commentary highlighting both " + f"strengths and weaknesses in the response. {task_instruction}\n" + f"{qa_text}" + ) + + messages = [ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_prompt}, + ] + + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=messages, + temperature=TEMPERATURE["grading"], + fields_to_check=["comment"], + ) + if not result: + return None + + if task == 2 and qa_pairs: + combined_answer = " ".join(p["answer"] for p in qa_pairs) + result["fixed_text"] = self._get_fixed_text(combined_answer) + result["perfect_answer"] = self._get_perfect_answer( + qa_pairs[0]["question"] + ) + return result + + # ------------------------------------------------------------------ + # Short-answer grading + # ------------------------------------------------------------------ + + def grade_short_answers(self, text, questions, answers): + """Evaluate short answers against a reading/listening passage.""" + system_msg = ( + "You are a helpful assistant designed to output JSON " + f"on this format: {SHORT_ANSWER_TEMPLATE}" + ) + + qa_text = "\n".join( + f'Q{i + 1}: "{q}" — Student answer: "{a}"' + for i, (q, a) in enumerate(zip(questions, answers)) + ) + + user_prompt = ( + "Evaluate each student answer against the passage. For each answer, " + "determine if it is correct and provide the correct answer.\n\n" + f'Passage: "{text}"\n\n{qa_text}' + ) + + messages = [ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_prompt}, + ] + + return self.ai.prediction( + model=GPT_MODELS["grading"], + messages=messages, + temperature=TEMPERATURE["grading"], + check_blacklisted=False, + ) + + # ------------------------------------------------------------------ + # Grading summary + # ------------------------------------------------------------------ + + def generate_grading_summary(self, sections): + """Generate a summary for an entire exam session using GPT-3.5-turbo.""" + system_msg = ( + "You are a helpful assistant designed to output JSON " + f"on this format: {SUMMARY_TEMPLATE}" + ) + + user_prompt = ( + "Generate a detailed evaluation summary for the following IELTS exam " + "sections. For each section, provide an evaluation, suggestions for " + "improvement, and key bullet points.\n\n" + + json.dumps(sections) + ) + + messages = [ + {"role": "system", "content": system_msg}, + {"role": "user", "content": user_prompt}, + ] + + return self.ai.prediction( + model=GPT_MODELS["secondary"], + messages=messages, + temperature=TEMPERATURE["grading"], + check_blacklisted=False, + ) + + # ------------------------------------------------------------------ + # AI detection (GPTZero) + # ------------------------------------------------------------------ + + def _detect_ai(self, text): + """Call GPTZero API to detect AI-generated text.""" + api_key = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("encoach.gptzero_api_key", "") + ) + if not api_key: + _logger.warning("GPTZero API key not configured") + return None + + try: + resp = httpx.post( + "https://api.gptzero.me/v2/predict/text", + headers={ + "x-api-key": api_key, + "Content-Type": "application/json", + }, + json={ + "document": text, + "version": "", + "multilingual": False, + }, + timeout=30, + ) + resp.raise_for_status() + return resp.json() + except Exception: + _logger.exception("GPTZero API call failed") + return None + + # ------------------------------------------------------------------ + # Helper prompts + # ------------------------------------------------------------------ + + def _get_perfect_answer(self, question): + messages = [ + {"role": "system", "content": "You are an IELTS writing expert."}, + { + "role": "user", + "content": ( + "Write a perfect answer for this IELTS writing task: " + f'"{question}"' + ), + }, + ] + result = self.ai.prediction( + model=GPT_MODELS["secondary"], + messages=messages, + temperature=TEMPERATURE["grading"], + response_format={"type": "json_object"}, + check_blacklisted=False, + ) + if result and "answer" in result: + return result["answer"] + return result + + def _get_fixed_text(self, text): + messages = [ + {"role": "system", "content": "You are a grammar correction assistant. Output JSON."}, + { + "role": "user", + "content": ( + "Fix the grammatical and spelling errors in this text, " + f'keeping the original meaning: "{text}"' + ), + }, + ] + result = self.ai.prediction( + model=GPT_MODELS["secondary"], + messages=messages, + temperature=TEMPERATURE["grading"], + response_format={"type": "json_object"}, + check_blacklisted=False, + ) + if result and "text" in result: + return result["text"] + return result diff --git a/encoach_ai_media/__init__.py b/encoach_ai_media/__init__.py new file mode 100644 index 0000000..71a0242 --- /dev/null +++ b/encoach_ai_media/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/encoach_ai_media/__manifest__.py b/encoach_ai_media/__manifest__.py new file mode 100644 index 0000000..a5ed695 --- /dev/null +++ b/encoach_ai_media/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "EnCoach AI Media", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "TTS, STT, and video generation for EnCoach", + "description": "AWS Polly TTS, OpenAI Whisper STT, ELAI avatar video generation.", + "depends": ["encoach_ai"], + "data": [ + "security/ir.model.access.csv", + "views/avatar_views.xml", + ], + "installable": True, + "license": "LGPL-3", + "external_dependencies": { + "python": ["boto3"], + }, +} diff --git a/encoach_ai_media/models/__init__.py b/encoach_ai_media/models/__init__.py new file mode 100644 index 0000000..8075d16 --- /dev/null +++ b/encoach_ai_media/models/__init__.py @@ -0,0 +1 @@ +from . import elai_avatar diff --git a/encoach_ai_media/models/elai_avatar.py b/encoach_ai_media/models/elai_avatar.py new file mode 100644 index 0000000..fd4ae6a --- /dev/null +++ b/encoach_ai_media/models/elai_avatar.py @@ -0,0 +1,30 @@ +from odoo import models, fields + + +class EncoachElaiAvatar(models.Model): + _name = "encoach.elai.avatar" + _description = "ELAI Avatar Configuration" + + name = fields.Char(required=True) + avatar_code = fields.Char(required=True) + avatar_url = fields.Char() + gender = fields.Selection( + [("male", "Male"), ("female", "Female")], + string="Gender", + ) + canvas = fields.Char() + voice_id = fields.Char() + voice_provider = fields.Char() + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name, + "avatarCode": self.avatar_code, + "avatarUrl": self.avatar_url or "", + "gender": self.gender or "", + "canvas": self.canvas or "", + "voiceId": self.voice_id or "", + "voiceProvider": self.voice_provider or "", + } diff --git a/encoach_ai_media/security/ir.model.access.csv b/encoach_ai_media/security/ir.model.access.csv new file mode 100644 index 0000000..e26a7e3 --- /dev/null +++ b/encoach_ai_media/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_elai_avatar_student,encoach.elai.avatar.student,model_encoach_elai_avatar,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_elai_avatar_admin,encoach.elai.avatar.admin,model_encoach_elai_avatar,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_elai_avatar_sysadmin,encoach.elai.avatar.sysadmin,model_encoach_elai_avatar,base.group_system,1,1,1,1 diff --git a/encoach_ai_media/services/__init__.py b/encoach_ai_media/services/__init__.py new file mode 100644 index 0000000..21ba419 --- /dev/null +++ b/encoach_ai_media/services/__init__.py @@ -0,0 +1,3 @@ +from . import polly_service +from . import whisper_service +from . import elai_service diff --git a/encoach_ai_media/services/elai_service.py b/encoach_ai_media/services/elai_service.py new file mode 100644 index 0000000..a37f740 --- /dev/null +++ b/encoach_ai_media/services/elai_service.py @@ -0,0 +1,107 @@ +import logging + +import httpx + +_logger = logging.getLogger(__name__) + +ELAI_BASE_URL = "https://apis.elai.io/api/v1/videos" + + +class EncoachElaiService: + + def __init__(self, env): + self.env = env + self.token = ( + env["ir.config_parameter"] + .sudo() + .get_param("encoach.elai_token", "") + ) + + def _headers(self): + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + + def create_video(self, text, avatar_code): + """Create and render an ELAI video. + + Returns the video_id for status polling, or None on failure. + """ + avatar = ( + self.env["encoach.elai.avatar"] + .sudo() + .search([("avatar_code", "=", avatar_code)], limit=1) + ) + if not avatar: + _logger.error("Avatar not found: %s", avatar_code) + return None + + payload = { + "name": f"EnCoach Speaking - {avatar.name}", + "slides": [ + { + "avatar": { + "code": avatar.avatar_code, + "canvas": avatar.canvas or "default", + "voiceId": avatar.voice_id or "", + "voiceProvider": avatar.voice_provider or "", + }, + "speech": text, + }, + ], + } + + try: + resp = httpx.post( + ELAI_BASE_URL, + headers=self._headers(), + json=payload, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + video_id = data.get("_id") or data.get("id") + + self._render_video(video_id) + return video_id + except Exception: + _logger.exception("ELAI video creation failed") + return None + + def _render_video(self, video_id): + try: + httpx.post( + f"{ELAI_BASE_URL}/render/{video_id}", + headers=self._headers(), + timeout=30, + ) + except Exception: + _logger.exception("ELAI render request failed for %s", video_id) + + def poll_status(self, video_id): + """Poll ELAI for video rendering status. + + Returns dict with 'status' and optionally 'url' keys. + """ + try: + resp = httpx.get( + f"{ELAI_BASE_URL}/{video_id}", + headers=self._headers(), + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + status = data.get("status", "unknown") + result = {"status": status} + if status == "ready": + result["url"] = data.get("url", "") + return result + except Exception: + _logger.exception("ELAI status poll failed for %s", video_id) + return {"status": "error"} + + def get_avatars(self): + """List all configured ELAI avatars.""" + avatars = self.env["encoach.elai.avatar"].sudo().search([]) + return [a.to_encoach_dict() for a in avatars] diff --git a/encoach_ai_media/services/polly_service.py b/encoach_ai_media/services/polly_service.py new file mode 100644 index 0000000..f76e9cc --- /dev/null +++ b/encoach_ai_media/services/polly_service.py @@ -0,0 +1,129 @@ +import io +import logging +import random +import re + +import boto3 + +_logger = logging.getLogger(__name__) + +POLLY_VOICES = { + "Danielle": {"gender": "Female", "accent": "US"}, + "Gregory": {"gender": "Male", "accent": "US"}, + "Kevin": {"gender": "Male", "accent": "US"}, + "Ruth": {"gender": "Female", "accent": "US"}, + "Stephen": {"gender": "Male", "accent": "US"}, + "Arthur": {"gender": "Male", "accent": "GB"}, + "Olivia": {"gender": "Female", "accent": "GB"}, + "Ayanda": {"gender": "Female", "accent": "ZA"}, + "Aria": {"gender": "Female", "accent": "NZ"}, + "Kajal": {"gender": "Female", "accent": "IN"}, + "Niamh": {"gender": "Female", "accent": "IE"}, +} + +FINAL_CUE_TEXT = ( + "This audio recording, for the listening exercise, has finished." +) +FINAL_CUE_VOICE = "Stephen" + +MAX_CHUNK_SIZE = 3000 + + +class EncoachPollyService: + + def __init__(self, env): + self.env = env + icp = env["ir.config_parameter"].sudo() + self.client = boto3.client( + "polly", + aws_access_key_id=icp.get_param("encoach.aws_access_key_id", ""), + aws_secret_access_key=icp.get_param("encoach.aws_secret_access_key", ""), + region_name=icp.get_param("encoach.aws_region", "us-east-1"), + ) + + def synthesize_speech( + self, text, voice, engine="neural", output_format="mp3" + ): + """Synthesize a single text block into MP3 bytes.""" + chunks = self._chunk_text(text) + audio_parts = [] + for chunk in chunks: + resp = self.client.synthesize_speech( + Text=chunk, + VoiceId=voice, + Engine=engine, + OutputFormat=output_format, + ) + audio_parts.append(resp["AudioStream"].read()) + return b"".join(audio_parts) + + def text_to_speech(self, dialog, include_final_cue=True): + """Generate full MP3 audio from a conversation or monologue. + + dialog: either a string (monologue) or a list of dicts with + 'name', 'gender', 'text' keys. + """ + audio_segments = [] + + if isinstance(dialog, str): + voice = random.choice(list(POLLY_VOICES.keys())) + audio_segments.append(self.synthesize_speech(dialog, voice)) + else: + voice_map = self._assign_voices(dialog) + for line in dialog: + voice = voice_map.get(line["name"], "Stephen") + audio_segments.append( + self.synthesize_speech(line["text"], voice) + ) + + if include_final_cue: + audio_segments.append( + self.synthesize_speech(FINAL_CUE_TEXT, FINAL_CUE_VOICE) + ) + + return b"".join(audio_segments) + + @staticmethod + def _assign_voices(dialog): + """Assign distinct Polly voices to each speaker based on gender.""" + speakers = {} + male_voices = [v for v, info in POLLY_VOICES.items() if info["gender"] == "Male"] + female_voices = [v for v, info in POLLY_VOICES.items() if info["gender"] == "Female"] + male_idx, female_idx = 0, 0 + + for line in dialog: + name = line.get("name", "") + if name in speakers: + continue + gender = (line.get("gender") or "").lower() + if gender == "female" and female_idx < len(female_voices): + speakers[name] = female_voices[female_idx] + female_idx += 1 + elif male_idx < len(male_voices): + speakers[name] = male_voices[male_idx] + male_idx += 1 + else: + speakers[name] = random.choice(list(POLLY_VOICES.keys())) + return speakers + + @staticmethod + def _chunk_text(text, max_size=MAX_CHUNK_SIZE): + """Split text at sentence boundaries respecting max chunk size.""" + if len(text) <= max_size: + return [text] + + sentences = re.split(r"(?<=[.!?])\s+", text) + chunks = [] + current = "" + + for sentence in sentences: + if len(current) + len(sentence) + 1 > max_size: + if current: + chunks.append(current.strip()) + current = sentence + else: + current = f"{current} {sentence}" if current else sentence + + if current: + chunks.append(current.strip()) + return chunks diff --git a/encoach_ai_media/services/whisper_service.py b/encoach_ai_media/services/whisper_service.py new file mode 100644 index 0000000..0e9dc92 --- /dev/null +++ b/encoach_ai_media/services/whisper_service.py @@ -0,0 +1,167 @@ +import logging +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +from tenacity import retry, stop_after_attempt, wait_exponential + +from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE +from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService + +_logger = logging.getLogger(__name__) + +SAMPLE_RATE = 16000 +CHUNK_SAMPLES = 480000 # 30 seconds at 16 kHz +OVERLAP_RATIO = 0.25 +WHISPER_OPTIONS = { + "fp16": False, + "language": "English", + "verbose": False, +} + +OVERLAP_CLEANUP_PROMPT = ( + "The following are two transcribed segments from the same audio. " + "They overlap at the boundary. Remove any duplicated words at the " + "junction and return the cleaned, combined text as JSON: " + '{"text": "cleaned text"}' +) + +DIALOG_DETECTION_PROMPT = ( + "You are a helpful assistant designed to output JSON on either one of " + "these formats:\n" + '1 - {"dialog": [{"name": "name", "gender": "gender", "text": "text"}]}\n' + '2 - {"dialog": "text"}\n\n' + "A transcription of an audio file will be provided to you. Based on that " + "transcription you will need to determine whether the transcription is a " + "conversation or a monologue. If it is a conversation, output format 1. " + "If it is a monologue, output format 2." +) + +_model_pool = [] +_pool_executor = None +_pool_lock = None + + +def _get_pool(num_workers=4): + global _model_pool, _pool_executor, _pool_lock + import threading + if _pool_lock is None: + _pool_lock = threading.Lock() + + with _pool_lock: + if _pool_executor is None: + import whisper + _logger.info("Loading %d Whisper model instances...", num_workers) + _model_pool = [whisper.load_model("base") for _ in range(num_workers)] + _pool_executor = ThreadPoolExecutor(max_workers=num_workers) + _logger.info("Whisper pool ready with %d workers", num_workers) + return _pool_executor, _model_pool + + +class EncoachWhisperService: + + def __init__(self, env): + self.env = env + self.ai = EncoachOpenAIService(env) + self._num_workers = int( + env["ir.config_parameter"].sudo().get_param("encoach.whisper_workers", "4") + ) + self._worker_idx = 0 + import threading + self._idx_lock = threading.Lock() + + def _get_model(self): + executor, pool = _get_pool(self._num_workers) + with self._idx_lock: + idx = self._worker_idx % len(pool) + self._worker_idx += 1 + return pool[idx] + + def transcribe(self, audio_data): + if not isinstance(audio_data, np.ndarray): + audio_data = np.frombuffer(audio_data, dtype=np.float32) + + audio_data = audio_data.astype(np.float32) + if audio_data.max() > 1.0: + audio_data = audio_data / np.abs(audio_data).max() + + chunks = self._chunk_audio(audio_data) + + if len(chunks) == 1: + return self._transcribe_chunk(chunks[0]) + + executor, pool = _get_pool(self._num_workers) + futures = [] + for i, chunk in enumerate(chunks): + model = pool[i % len(pool)] + future = executor.submit(self._transcribe_chunk_with_model, chunk, model) + futures.append(future) + + segments = [f.result() for f in futures] + return self._merge_segments(segments) + + @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) + def _transcribe_chunk(self, chunk): + model = self._get_model() + result = model.transcribe(chunk, **WHISPER_OPTIONS) + return result.get("text", "").strip() + + @staticmethod + @retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) + def _transcribe_chunk_with_model(chunk, model): + result = model.transcribe(chunk, **WHISPER_OPTIONS) + return result.get("text", "").strip() + + def _merge_segments(self, segments): + if len(segments) <= 1: + return segments[0] if segments else "" + + merged = segments[0] + for i in range(1, len(segments)): + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=[ + {"role": "system", "content": OVERLAP_CLEANUP_PROMPT}, + { + "role": "user", + "content": ( + f'Segment A (end): "...{merged[-200:]}"\n' + f'Segment B (start): "{segments[i][:200]}..."' + ), + }, + ], + temperature=TEMPERATURE["grading"], + check_blacklisted=False, + ) + if result and "text" in result: + merged = result["text"] + else: + merged = f"{merged} {segments[i]}" + return merged + + def detect_dialog(self, transcript): + return self.ai.prediction( + model=GPT_MODELS["grading"], + messages=[ + {"role": "system", "content": DIALOG_DETECTION_PROMPT}, + {"role": "user", "content": transcript}, + ], + temperature=TEMPERATURE["grading"], + check_blacklisted=False, + ) + + @staticmethod + def _chunk_audio(audio, chunk_size=CHUNK_SAMPLES, overlap=OVERLAP_RATIO): + total = len(audio) + if total <= chunk_size: + return [audio] + + step = int(chunk_size * (1 - overlap)) + chunks = [] + start = 0 + while start < total: + end = min(start + chunk_size, total) + chunks.append(audio[start:end]) + if end >= total: + break + start += step + return chunks diff --git a/encoach_ai_media/views/avatar_views.xml b/encoach_ai_media/views/avatar_views.xml new file mode 100644 index 0000000..e8efc9c --- /dev/null +++ b/encoach_ai_media/views/avatar_views.xml @@ -0,0 +1,49 @@ + + + + encoach.elai.avatar.list + encoach.elai.avatar + + + + + + + + + + + + + + encoach.elai.avatar.form + encoach.elai.avatar + +
+ + + + + + + + + + + +
+
+
+ + + ELAI Avatars + encoach.elai.avatar + list,form + + + +
diff --git a/encoach_api/__init__.py b/encoach_api/__init__.py new file mode 100644 index 0000000..e046e49 --- /dev/null +++ b/encoach_api/__init__.py @@ -0,0 +1 @@ +from . import controllers diff --git a/encoach_api/__manifest__.py b/encoach_api/__manifest__.py new file mode 100644 index 0000000..c9cb48b --- /dev/null +++ b/encoach_api/__manifest__.py @@ -0,0 +1,29 @@ +{ + "name": "EnCoach API", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "REST JSON API controllers for the EnCoach platform", + "depends": [ + "encoach_core", + "encoach_exam", + "encoach_classroom", + "encoach_assignment", + "encoach_stats", + "encoach_evaluation", + "encoach_training", + "encoach_subscription", + "encoach_registration", + "encoach_ticket", + "encoach_ai_grading", + "encoach_ai_generation", + "encoach_ai_media", + ], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", + "external_dependencies": { + "python": ["jwt"], + }, +} diff --git a/encoach_api/controllers/__init__.py b/encoach_api/controllers/__init__.py new file mode 100644 index 0000000..923b45a --- /dev/null +++ b/encoach_api/controllers/__init__.py @@ -0,0 +1,20 @@ +from . import base +from . import auth +from . import users +from . import exams +from . import classrooms +from . import assignments +from . import sessions +from . import stats +from . import evaluations +from . import training +from . import subscriptions +from . import registration +from . import tickets +from . import storage +from . import grading +from . import generation +from . import media +from . import entities +from . import discounts +from . import approvals diff --git a/encoach_api/controllers/approvals.py b/encoach_api/controllers/approvals.py new file mode 100644 index 0000000..ec9e55c --- /dev/null +++ b/encoach_api/controllers/approvals.py @@ -0,0 +1,89 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class ApprovalsController(EncoachMixin, http.Controller): + + @http.route('/api/approval-workflows', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_workflows(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + workflows = request.env['encoach.approval.workflow'].sudo().search([]) + return self._json_response([{ + 'id': w.id, 'examId': w.exam_id.id if w.exam_id else None, + 'creatorId': w.creator_id.id if w.creator_id else None, + 'status': w.status, 'modules': w.modules, 'steps': w.steps, + } for w in workflows]) + except Exception: + _logger.exception("List approval workflows error") + return self._error_response('Internal server error', 500) + + @http.route('/api/approval-workflows', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_workflow(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + body = self._get_json_body() + vals = { + 'creator_id': user.id, + 'status': body.get('status', 'pending'), + } + if body.get('examId'): vals['exam_id'] = int(body['examId']) + if 'modules' in body: vals['modules'] = body['modules'] + if 'steps' in body: vals['steps'] = body['steps'] + wf = request.env['encoach.approval.workflow'].sudo().create(vals) + return self._json_response({ + 'id': wf.id, 'examId': wf.exam_id.id if wf.exam_id else None, + 'status': wf.status, + }) + except Exception: + _logger.exception("Create approval workflow error") + return self._error_response('Internal server error', 500) + + @http.route('/api/approval-workflows/', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*') + def update_workflow(self, wid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + wf = request.env['encoach.approval.workflow'].sudo().browse(wid) + if not wf.exists(): + return self._error_response('Workflow not found', 404) + body = self._get_json_body() + vals = {} + if 'status' in body: vals['status'] = body['status'] + if 'modules' in body: vals['modules'] = body['modules'] + if 'steps' in body: vals['steps'] = body['steps'] + if vals: + wf.write(vals) + return self._json_response({ + 'id': wf.id, 'status': wf.status, + }) + except Exception: + _logger.exception("Update approval workflow error") + return self._error_response('Internal server error', 500) + + @http.route('/api/approval-workflows/', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*') + def delete_workflow(self, wid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + wf = request.env['encoach.approval.workflow'].sudo().browse(wid) + if not wf.exists(): + return self._error_response('Workflow not found', 404) + wf.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete approval workflow error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/assignments.py b/encoach_api/controllers/assignments.py new file mode 100644 index 0000000..38659af --- /dev/null +++ b/encoach_api/controllers/assignments.py @@ -0,0 +1,235 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class AssignmentsController(EncoachMixin, http.Controller): + + # --------------------------------------------------------------- list + @http.route('/api/assignments', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_assignments(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [] + + group_id = kwargs.get('groupId') + if group_id: + domain.append(('group_id', '=', int(group_id))) + + creator = kwargs.get('createdBy') + if creator: + domain.append(('create_uid', '=', int(creator))) + + status = kwargs.get('status') + if status: + domain.append(('status', '=', status)) + + participant = kwargs.get('participant') + if participant: + domain.append(('group_id.participant_ids', 'in', [int(participant)])) + + is_archived = kwargs.get('isArchived') + if is_archived is not None: + domain.append(('is_archived', '=', is_archived in ('true', '1', True))) + + offset, limit, page = self._paginate_params(kwargs) + Assignment = request.env['encoach.assignment'].sudo() + total = Assignment.search_count(domain) + records = Assignment.search(domain, offset=offset, limit=limit, order='create_date desc') + + return self._json_response({ + 'assignments': [self._serialize_assignment(a) for a in records], + 'total': total, + 'page': page, + 'limit': limit, + }) + except Exception: + _logger.exception("List assignments error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- create + @http.route('/api/assignments', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_assignment(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + if not body.get('examId'): + return self._error_response('examId is required', 400) + if not body.get('groupId'): + return self._error_response('groupId is required', 400) + + vals = { + 'name': body.get('name', ''), + 'exam_id': int(body['examId']), + 'group_id': int(body['groupId']), + 'status': 'draft', + } + if body.get('startDate'): + vals['start_date'] = body['startDate'] + if body.get('endDate'): + vals['end_date'] = body['endDate'] + if body.get('duration'): + vals['duration'] = int(body['duration']) + + assignment = request.env['encoach.assignment'].sudo().with_user(user).create(vals) + return self._json_response(self._serialize_assignment(assignment), status=201) + except Exception: + _logger.exception("Create assignment error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- update + @http.route( + '/api/assignments/', type='http', auth='public', + methods=['PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + body = self._get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'startDate' in body: + vals['start_date'] = body['startDate'] + if 'endDate' in body: + vals['end_date'] = body['endDate'] + if 'duration' in body: + vals['duration'] = int(body['duration']) + if 'examId' in body: + vals['exam_id'] = int(body['examId']) + if 'groupId' in body: + vals['group_id'] = int(body['groupId']) + if 'status' in body: + vals['status'] = body['status'] + + if vals: + assignment.write(vals) + + return self._json_response(self._serialize_assignment(assignment)) + except Exception: + _logger.exception("Update assignment error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- delete + @http.route( + '/api/assignments/', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete assignment error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------------------- start + @http.route( + '/api/assignments//start', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def start_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.action_start() + return self._json_response(self._serialize_assignment(assignment)) + except Exception as exc: + _logger.exception("Start assignment error") + return self._error_response(str(exc) if str(exc) != '' else 'Internal server error', 500) + + # ------------------------------------------------------------- release + @http.route( + '/api/assignments//release', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def release_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.action_release() + return self._json_response(self._serialize_assignment(assignment)) + except Exception as exc: + _logger.exception("Release assignment error") + return self._error_response(str(exc) if str(exc) != '' else 'Internal server error', 500) + + # ------------------------------------------------------------- archive + @http.route( + '/api/assignments//archive', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def archive_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.write({'is_archived': True}) + return self._json_response(self._serialize_assignment(assignment)) + except Exception: + _logger.exception("Archive assignment error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------------------- unarchive + @http.route( + '/api/assignments//unarchive', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def unarchive_assignment(self, aid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + assignment = request.env['encoach.assignment'].sudo().browse(aid) + if not assignment.exists(): + return self._error_response('Assignment not found', 404) + + assignment.write({'is_archived': False}) + return self._json_response(self._serialize_assignment(assignment)) + except Exception: + _logger.exception("Unarchive assignment error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/auth.py b/encoach_api/controllers/auth.py new file mode 100644 index 0000000..50d4c36 --- /dev/null +++ b/encoach_api/controllers/auth.py @@ -0,0 +1,110 @@ +import logging +from datetime import datetime, timedelta + +import jwt as pyjwt + +from odoo import http +from odoo.exceptions import AccessDenied +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class AuthController(EncoachMixin, http.Controller): + + def _create_jwt(self, user, secret, expires_hours=72): + payload = { + 'user_id': user.id, + 'email': user.login, + 'iat': datetime.utcnow(), + 'exp': datetime.utcnow() + timedelta(hours=expires_hours), + } + return pyjwt.encode(payload, secret, algorithm='HS256') + + @http.route('/api/login', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def login(self, **kwargs): + try: + body = self._get_json_body() + email = (body.get('credential') or body.get('email') or '').strip() + password = body.get('password', '') + if not email or not password: + return self._error_response('Email and password are required', 400) + + user = request.env['res.users'].sudo().search([ + ('login', '=', email), + ], limit=1) + if not user: + return self._error_response('Invalid email or password', 401) + + try: + credential = {'type': 'password', 'login': email, 'password': password} + request.env['res.users'].sudo().authenticate( + credential, {'interactive': False}, + ) + except AccessDenied: + return self._error_response('Invalid email or password', 401) + + secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret') + if not secret: + return self._error_response('Server configuration error', 500) + + token = self._create_jwt(user, secret) + return self._json_response({ + 'user': self._serialize(user), + 'token': token, + }) + except Exception: + _logger.exception("Login error") + return self._error_response('Internal server error', 500) + + @http.route('/api/logout', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def logout(self, **kwargs): + try: + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Logout error") + return self._error_response('Internal server error', 500) + + @http.route('/api/reset', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def reset_password(self, **kwargs): + try: + body = self._get_json_body() + email = body.get('email', '').strip() + new_password = body.get('password', '') + token = body.get('token', '') + if not email: + return self._error_response('Email is required', 400) + + user = request.env['res.users'].sudo().search([('login', '=', email)], limit=1) + if not user: + return self._json_response({'ok': True}) + + if token and new_password: + secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret') + try: + payload = pyjwt.decode(token, secret, algorithms=['HS256']) + except pyjwt.InvalidTokenError: + return self._error_response('Invalid or expired reset token', 400) + if payload.get('user_id') != user.id or payload.get('purpose') != 'password_reset': + return self._error_response('Invalid reset token', 400) + user.sudo().write({'password': new_password}) + return self._json_response({'ok': True}) + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Password reset error") + return self._error_response('Internal server error', 500) + + @http.route('/api/reset/sendVerification', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def send_verification(self, **kwargs): + try: + body = self._get_json_body() + email = body.get('email', '').strip() + if not email: + return self._error_response('Email is required', 400) + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Send verification error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/base.py b/encoach_api/controllers/base.py new file mode 100644 index 0000000..9ce6b3a --- /dev/null +++ b/encoach_api/controllers/base.py @@ -0,0 +1,73 @@ +import json +import logging + +import jwt as pyjwt + +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class EncoachMixin: + """Shared authentication and response helpers for all EnCoach API controllers.""" + + def _authenticate(self): + """Decode JWT Bearer token and return the corresponding ``res.users`` record. + + Returns ``None`` when the token is missing, expired or invalid. + """ + auth_header = request.httprequest.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + token = auth_header[7:] + secret = ( + request.env["ir.config_parameter"] + .sudo() + .get_param("encoach.jwt_secret") + ) + if not secret: + _logger.error("System parameter 'encoach.jwt_secret' is not configured") + return None + try: + payload = pyjwt.decode(token, secret, algorithms=["HS256"]) + except pyjwt.ExpiredSignatureError: + return None + except pyjwt.InvalidTokenError: + return None + user_id = payload.get("user_id") + if not user_id: + return None + user = request.env["res.users"].sudo().browse(int(user_id)) + if not user.exists(): + return None + return user + + def _json_response(self, data, status=200): + return request.make_json_response(data, status=status) + + def _error_response(self, message, status=400, code=None): + body = {"error": message} + if code: + body["code"] = code + return request.make_json_response(body, status=status) + + def _get_json_body(self): + try: + return json.loads(request.httprequest.get_data(as_text=True)) + except (ValueError, TypeError): + return {} + + def _paginate_params(self, kwargs): + page = max(int(kwargs.get("page", 1)), 1) + limit = min(max(int(kwargs.get("limit", 20)), 1), 100) + offset = (page - 1) * limit + return offset, limit, page + + def _serialize(self, record): + """Delegate to the record's own to_encoach_dict() if available.""" + if hasattr(record, "to_encoach_dict"): + return record.to_encoach_dict() + return {"id": record.id} + + def _serialize_list(self, records): + return [self._serialize(r) for r in records] diff --git a/encoach_api/controllers/classrooms.py b/encoach_api/controllers/classrooms.py new file mode 100644 index 0000000..903472f --- /dev/null +++ b/encoach_api/controllers/classrooms.py @@ -0,0 +1,122 @@ +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class ClassroomsController(EncoachMixin, http.Controller): + + # --------------------------------------------------------------- list + @http.route('/api/groups', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_groups(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [] + participant = kwargs.get('participant') + if participant: + domain.append(('participant_ids', 'in', [int(participant)])) + + admin = kwargs.get('admin') + if admin: + domain.append(('admin_ids', 'in', [int(admin)])) + + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('entity_id', '=', int(entity_id))) + + groups = request.env['encoach.group'].sudo().search(domain, order='name asc') + return self._json_response([self._serialize_group(g) for g in groups]) + except Exception: + _logger.exception("List groups error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- create + @http.route('/api/groups', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_group(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + if not body.get('name'): + return self._error_response('Group name is required', 400) + + vals = { + 'name': body['name'], + 'admin_ids': [(4, user.id)], + } + if body.get('entityID'): + vals['entity_id'] = int(body['entityID']) + + participant_ids = body.get('participants', []) + if participant_ids: + vals['participant_ids'] = [(4, int(pid)) for pid in participant_ids] + + group = request.env['encoach.group'].sudo().create(vals) + return self._json_response(self._serialize_group(group), status=201) + except Exception: + _logger.exception("Create group error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- update + @http.route( + '/api/groups/', type='http', auth='public', + methods=['PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_group(self, group_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + group = request.env['encoach.group'].sudo().browse(group_id) + if not group.exists(): + return self._error_response('Group not found', 404) + + body = self._get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + + if 'participants' in body: + vals['participant_ids'] = [(6, 0, [int(pid) for pid in body['participants']])] + + if 'admins' in body: + vals['admin_ids'] = [(6, 0, [int(aid) for aid in body['admins']])] + + if vals: + group.write(vals) + + return self._json_response(self._serialize_group(group)) + except Exception: + _logger.exception("Update group error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- delete + @http.route( + '/api/groups/', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_group(self, group_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + group = request.env['encoach.group'].sudo().browse(group_id) + if not group.exists(): + return self._error_response('Group not found', 404) + + group.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete group error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/discounts.py b/encoach_api/controllers/discounts.py new file mode 100644 index 0000000..27b9f36 --- /dev/null +++ b/encoach_api/controllers/discounts.py @@ -0,0 +1,88 @@ +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class DiscountsController(EncoachMixin, http.Controller): + + @http.route('/api/discounts', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_discounts(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + discounts = request.env['encoach.discount'].sudo().search([]) + return self._json_response([{ + 'id': d.id, 'code': d.code, 'percentage': d.percentage, + 'expiryDate': d.expiry_date.isoformat() if d.expiry_date else None, + 'maxUses': d.max_uses, 'uses': d.uses, + } for d in discounts]) + except Exception: + _logger.exception("List discounts error") + return self._error_response('Internal server error', 500) + + @http.route('/api/discounts', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_discount(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + body = self._get_json_body() + code = body.get('code', '').strip() + if not code: + return self._error_response('code is required', 400) + discount = request.env['encoach.discount'].sudo().create({ + 'code': code, + 'percentage': body.get('percentage', 0), + 'expiry_date': body.get('expiryDate'), + 'max_uses': body.get('maxUses', 0), + }) + return self._json_response({ + 'id': discount.id, 'code': discount.code, + 'percentage': discount.percentage, + }) + except Exception: + _logger.exception("Create discount error") + return self._error_response('Internal server error', 500) + + @http.route('/api/discounts/', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*') + def update_discount(self, did, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + discount = request.env['encoach.discount'].sudo().browse(did) + if not discount.exists(): + return self._error_response('Discount not found', 404) + body = self._get_json_body() + vals = {} + if 'code' in body: vals['code'] = body['code'] + if 'percentage' in body: vals['percentage'] = body['percentage'] + if 'expiryDate' in body: vals['expiry_date'] = body['expiryDate'] + if 'maxUses' in body: vals['max_uses'] = body['maxUses'] + if vals: + discount.write(vals) + return self._json_response({'id': discount.id, 'code': discount.code}) + except Exception: + _logger.exception("Update discount error") + return self._error_response('Internal server error', 500) + + @http.route('/api/discounts/', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*') + def delete_discount(self, did, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + discount = request.env['encoach.discount'].sudo().browse(did) + if not discount.exists(): + return self._error_response('Discount not found', 404) + discount.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete discount error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/entities.py b/encoach_api/controllers/entities.py new file mode 100644 index 0000000..ddc0de5 --- /dev/null +++ b/encoach_api/controllers/entities.py @@ -0,0 +1,175 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class EntitiesController(EncoachMixin, http.Controller): + + @http.route('/api/entities', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_entities(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + offset, limit, page = self._paginate_params(kwargs) + entities = request.env['encoach.entity'].sudo().search([], limit=limit, offset=offset, order='name asc') + total = request.env['encoach.entity'].sudo().search_count([]) + return self._json_response({ + 'items': [e.to_encoach_dict() for e in entities], + 'total': total, 'page': page, 'limit': limit, + }) + except Exception: + _logger.exception("List entities error") + return self._error_response('Internal server error', 500) + + @http.route('/api/entities', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_entity(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + body = self._get_json_body() + name = body.get('name', '').strip() + if not name: + return self._error_response('name is required', 400) + entity = request.env['encoach.entity'].sudo().create({ + 'name': name, + 'label': body.get('label', name), + 'licenses': body.get('licenses', 0), + 'expiry_date': body.get('expiryDate'), + 'payment_currency': body.get('paymentCurrency'), + 'payment_price': body.get('paymentPrice', 0), + }) + return self._json_response(entity.to_encoach_dict()) + except Exception: + _logger.exception("Create entity error") + return self._error_response('Internal server error', 500) + + @http.route('/api/entities/', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*') + def update_entity(self, entity_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + entity = request.env['encoach.entity'].sudo().browse(entity_id) + if not entity.exists(): + return self._error_response('Entity not found', 404) + body = self._get_json_body() + vals = {} + if 'name' in body: vals['name'] = body['name'] + if 'label' in body: vals['label'] = body['label'] + if 'licenses' in body: vals['licenses'] = body['licenses'] + if 'expiryDate' in body: vals['expiry_date'] = body['expiryDate'] + if 'paymentCurrency' in body: vals['payment_currency'] = body['paymentCurrency'] + if 'paymentPrice' in body: vals['payment_price'] = body['paymentPrice'] + if vals: + entity.write(vals) + return self._json_response(entity.to_encoach_dict()) + except Exception: + _logger.exception("Update entity error") + return self._error_response('Internal server error', 500) + + @http.route('/api/entities/', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*') + def delete_entity(self, entity_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + entity = request.env['encoach.entity'].sudo().browse(entity_id) + if not entity.exists(): + return self._error_response('Entity not found', 404) + entity.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete entity error") + return self._error_response('Internal server error', 500) + + @http.route('/api/roles', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_roles(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + domain = [] + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('entity_id', '=', int(entity_id))) + roles = request.env['encoach.role'].sudo().search(domain) + return self._json_response([{ + 'id': r.id, 'name': r.name, + 'entityId': r.entity_id.id if r.entity_id else None, + } for r in roles]) + except Exception: + _logger.exception("List roles error") + return self._error_response('Internal server error', 500) + + @http.route('/api/roles', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_role(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + body = self._get_json_body() + name = body.get('name', '').strip() + if not name: + return self._error_response('name is required', 400) + vals = {'name': name} + if body.get('entityId'): + vals['entity_id'] = int(body['entityId']) + role = request.env['encoach.role'].sudo().create(vals) + return self._json_response({'id': role.id, 'name': role.name, 'entityId': role.entity_id.id if role.entity_id else None}) + except Exception: + _logger.exception("Create role error") + return self._error_response('Internal server error', 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['PATCH', 'OPTIONS'], csrf=False, cors='*') + def update_role(self, role_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + role = request.env['encoach.role'].sudo().browse(role_id) + if not role.exists(): + return self._error_response('Role not found', 404) + body = self._get_json_body() + vals = {} + if 'name' in body: vals['name'] = body['name'] + if vals: + role.write(vals) + return self._json_response({'id': role.id, 'name': role.name, 'entityId': role.entity_id.id if role.entity_id else None}) + except Exception: + _logger.exception("Update role error") + return self._error_response('Internal server error', 500) + + @http.route('/api/roles/', type='http', auth='public', methods=['DELETE', 'OPTIONS'], csrf=False, cors='*') + def delete_role(self, role_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + role = request.env['encoach.role'].sudo().browse(role_id) + if not role.exists(): + return self._error_response('Role not found', 404) + role.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete role error") + return self._error_response('Internal server error', 500) + + @http.route('/api/permissions', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_permissions(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + perms = request.env['encoach.permission'].sudo().search([]) + return self._json_response([{'id': p.id, 'topic': p.topic, 'type': p.perm_type} for p in perms]) + except Exception: + _logger.exception("List permissions error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/evaluations.py b/encoach_api/controllers/evaluations.py new file mode 100644 index 0000000..8b55f64 --- /dev/null +++ b/encoach_api/controllers/evaluations.py @@ -0,0 +1,293 @@ +import json +import logging +import threading + +import odoo +from odoo import api, SUPERUSER_ID, http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +def _run_grading_background(dbname, evaluation_id, input_data, grading_type): + """Execute AI grading in a background thread with its own cursor.""" + try: + registry = odoo.registry(dbname) + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + service = env['encoach.ai.grading'] + if grading_type == 'writing': + service.grade_writing(evaluation_id, input_data) + elif grading_type == 'speaking': + service.grade_speaking(evaluation_id, input_data) + elif grading_type == 'interactive_speaking': + service.grade_interactive_speaking(evaluation_id, input_data) + except Exception: + _logger.exception("Background grading failed for evaluation %s", evaluation_id) + try: + registry = odoo.registry(dbname) + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + env['encoach.evaluation'].browse(evaluation_id).write({ + 'status': 'error', + 'error_message': 'Grading failed unexpectedly', + }) + except Exception: + _logger.exception("Failed to update evaluation status after error") + + +class EvaluationsController(EncoachMixin, http.Controller): + + # ------------------------------------------------- evaluate writing + @http.route('/api/evaluate/writing', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def evaluate_writing(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + required = ('sessionId', 'exerciseId', 'question', 'answer', 'task') + for field in required: + if not body.get(field): + return self._error_response(f'{field} is required', 400) + + Evaluation = request.env['encoach.evaluation'].sudo() + evaluation = Evaluation.create({ + 'user_id': int(body.get('userId', user.id)), + 'session_id': int(body['sessionId']), + 'exercise_id': body['exerciseId'], + 'eval_type': 'writing', + 'status': 'pending', + 'input_data': json.dumps({ + 'question': body['question'], + 'answer': body['answer'], + 'task': body['task'], + 'attachment': body.get('attachment'), + }), + }) + + dbname = request.env.cr.dbname + input_data = { + 'question': body['question'], + 'answer': body['answer'], + 'task': body['task'], + 'attachment': body.get('attachment'), + } + thread = threading.Thread( + target=_run_grading_background, + args=(dbname, evaluation.id, input_data, 'writing'), + daemon=True, + ) + thread.start() + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Evaluate writing error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------- evaluate speaking + @http.route('/api/evaluate/speaking', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def evaluate_speaking(self, **kwargs): + """Accept multipart form with audio files and question fields.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + form = request.httprequest.form + files = request.httprequest.files + + user_id = form.get('userId', str(user.id)) + session_id = form.get('sessionId') + exercise_id = form.get('exerciseId') + task = form.get('task') + + if not session_id or not exercise_id or not task: + return self._error_response('sessionId, exerciseId, and task are required', 400) + + audio_entries = [] + idx = 1 + while True: + audio_key = f'audio_{idx}' + question_key = f'question_{idx}' + audio_file = files.get(audio_key) + if not audio_file: + break + audio_entries.append({ + 'audio_data': audio_file.read(), + 'audio_filename': audio_file.filename, + 'question': form.get(question_key, ''), + }) + idx += 1 + + if not audio_entries: + return self._error_response('At least one audio file is required', 400) + + Attachment = request.env['ir.attachment'].sudo() + saved_audios = [] + for entry in audio_entries: + import base64 + att = Attachment.create({ + 'name': entry['audio_filename'], + 'datas': base64.b64encode(entry['audio_data']), + 'res_model': 'encoach.evaluation', + 'type': 'binary', + }) + saved_audios.append({ + 'attachment_id': att.id, + 'question': entry['question'], + }) + + Evaluation = request.env['encoach.evaluation'].sudo() + evaluation = Evaluation.create({ + 'user_id': int(user_id), + 'session_id': int(session_id), + 'exercise_id': exercise_id, + 'eval_type': 'speaking', + 'status': 'pending', + 'input_data': json.dumps({ + 'task': task, + 'audios': [{'attachment_id': a['attachment_id'], 'question': a['question']} for a in saved_audios], + }), + }) + + dbname = request.env.cr.dbname + input_data = { + 'task': task, + 'audios': saved_audios, + } + thread = threading.Thread( + target=_run_grading_background, + args=(dbname, evaluation.id, input_data, 'speaking'), + daemon=True, + ) + thread.start() + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Evaluate speaking error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------- evaluate interactive speaking + @http.route( + '/api/evaluate/interactiveSpeaking', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def evaluate_interactive_speaking(self, **kwargs): + """Same as speaking but for interactive Q&A pairs.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + form = request.httprequest.form + files = request.httprequest.files + + user_id = form.get('userId', str(user.id)) + session_id = form.get('sessionId') + exercise_id = form.get('exerciseId') + task = form.get('task') + + if not session_id or not exercise_id or not task: + return self._error_response('sessionId, exerciseId, and task are required', 400) + + audio_entries = [] + idx = 1 + while True: + audio_key = f'audio_{idx}' + question_key = f'question_{idx}' + audio_file = files.get(audio_key) + if not audio_file: + break + audio_entries.append({ + 'audio_data': audio_file.read(), + 'audio_filename': audio_file.filename, + 'question': form.get(question_key, ''), + }) + idx += 1 + + if not audio_entries: + return self._error_response('At least one audio file is required', 400) + + import base64 + Attachment = request.env['ir.attachment'].sudo() + saved_audios = [] + for entry in audio_entries: + att = Attachment.create({ + 'name': entry['audio_filename'], + 'datas': base64.b64encode(entry['audio_data']), + 'res_model': 'encoach.evaluation', + 'type': 'binary', + }) + saved_audios.append({ + 'attachment_id': att.id, + 'question': entry['question'], + }) + + Evaluation = request.env['encoach.evaluation'].sudo() + evaluation = Evaluation.create({ + 'user_id': int(user_id), + 'session_id': int(session_id), + 'exercise_id': exercise_id, + 'eval_type': 'interactive_speaking', + 'status': 'pending', + 'input_data': json.dumps({ + 'task': task, + 'audios': [{'attachment_id': a['attachment_id'], 'question': a['question']} for a in saved_audios], + }), + }) + + dbname = request.env.cr.dbname + input_data = { + 'task': task, + 'audios': saved_audios, + } + thread = threading.Thread( + target=_run_grading_background, + args=(dbname, evaluation.id, input_data, 'interactive_speaking'), + daemon=True, + ) + thread.start() + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Evaluate interactive speaking error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------------- poll status + @http.route(['/api/evaluate//', '/api/evaluate/status'], type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def evaluation_status(self, session_id=None, exercise_id=None, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + if not session_id: + session_id = kwargs.get('sessionId') + if not exercise_id: + exercise_id = kwargs.get('exerciseId') + if not session_id or not exercise_id: + return self._error_response('sessionId and exerciseId are required', 400) + + evaluation = request.env['encoach.evaluation'].sudo().search([ + ('session_id', '=', int(session_id)), + ('exercise_id', '=', str(exercise_id)), + ], limit=1, order='create_date desc') + + if not evaluation: + return self._error_response('Evaluation not found', 404) + + result = None + if evaluation.status == 'completed' and evaluation.result: + result = json.loads(evaluation.result) + + return self._json_response({ + 'status': evaluation.status, + 'result': result, + }) + except Exception: + _logger.exception("Evaluation status error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/exams.py b/encoach_api/controllers/exams.py new file mode 100644 index 0000000..f61e793 --- /dev/null +++ b/encoach_api/controllers/exams.py @@ -0,0 +1,220 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + +VALID_MODULES = ('reading', 'listening', 'writing', 'speaking', 'level') + + +class ExamsController(EncoachMixin, http.Controller): + + # --------------------------------------------------------------- list + @http.route('/api/exam', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_exams(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [] + module = kwargs.get('module') + if module and module in VALID_MODULES: + domain.append(('module', '=', module)) + + creator = kwargs.get('createdBy') + if creator: + domain.append(('create_uid', '=', int(creator))) + + is_published = kwargs.get('isPublished') + if is_published is not None: + domain.append(('is_published', '=', is_published in ('true', '1', True))) + + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('entity_id', '=', int(entity_id))) + + offset, limit, page = self._paginate_params(kwargs) + Exam = request.env['encoach.exam'].sudo() + total = Exam.search_count(domain) + exams = Exam.search(domain, offset=offset, limit=limit, order='create_date desc') + + return self._json_response({ + 'exams': [self._serialize_exam(e) for e in exams], + 'total': total, + 'page': page, + 'limit': limit, + }) + except Exception: + _logger.exception("List exams error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------------------- create + @http.route( + '/api/exam/', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def create_exam(self, module, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if module not in VALID_MODULES: + return self._error_response(f'Invalid module: {module}', 400) + + body = self._get_json_body() + vals = { + 'name': body.get('name', ''), + 'module': module, + 'create_uid': user.id, + } + if 'parts' in body: + vals['parts'] = json.dumps(body['parts']) if isinstance(body['parts'], (list, dict)) else body['parts'] + if 'difficulty' in body: + vals['difficulty'] = body['difficulty'] + if 'tags' in body: + vals['tags'] = json.dumps(body['tags']) if isinstance(body['tags'], list) else body['tags'] + if 'isPublished' in body: + vals['is_published'] = body['isPublished'] + if 'entityID' in body and body['entityID']: + vals['entity_id'] = int(body['entityID']) + + exam = request.env['encoach.exam'].sudo().with_user(user).create(vals) + return self._json_response(self._serialize_exam(exam), status=201) + except Exception: + _logger.exception("Create exam error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------------------- get by id + @http.route( + '/api/exam//', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def get_exam(self, module, exam_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exam = request.env['encoach.exam'].sudo().browse(exam_id) + if not exam.exists(): + return self._error_response('Exam not found', 404) + + return self._json_response(self._serialize_exam(exam)) + except Exception: + _logger.exception("Get exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- update + @http.route( + '/api/exam//', type='http', auth='public', + methods=['PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_exam(self, module, exam_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exam = request.env['encoach.exam'].sudo().browse(exam_id) + if not exam.exists(): + return self._error_response('Exam not found', 404) + + body = self._get_json_body() + vals = {} + if 'name' in body: + vals['name'] = body['name'] + if 'parts' in body: + vals['parts'] = json.dumps(body['parts']) if isinstance(body['parts'], (list, dict)) else body['parts'] + if 'difficulty' in body: + vals['difficulty'] = body['difficulty'] + if 'tags' in body: + vals['tags'] = json.dumps(body['tags']) if isinstance(body['tags'], list) else body['tags'] + if 'isPublished' in body: + vals['is_published'] = body['isPublished'] + + if vals: + exam.write(vals) + + return self._json_response(self._serialize_exam(exam)) + except Exception: + _logger.exception("Update exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- delete + @http.route( + '/api/exam//', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_exam(self, module, exam_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exam = request.env['encoach.exam'].sudo().browse(exam_id) + if not exam.exists(): + return self._error_response('Exam not found', 404) + + exam.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- import + @http.route( + '/api/exam//import/', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def import_exam(self, module, **kwargs): + """Import an exam from an uploaded file (Word/Excel).""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if module not in VALID_MODULES: + return self._error_response(f'Invalid module: {module}', 400) + + exercises_file = request.httprequest.files.get('exercises') + solutions_file = request.httprequest.files.get('solutions') + if not exercises_file: + return self._error_response('exercises file is required', 400) + + Exam = request.env['encoach.exam'].sudo() + result = Exam.import_from_file( + module=module, + exercises_data=exercises_file.read(), + exercises_filename=exercises_file.filename, + solutions_data=solutions_file.read() if solutions_file else None, + solutions_filename=solutions_file.filename if solutions_file else None, + user=user, + ) + return self._json_response(result) + except Exception: + _logger.exception("Import exam error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------ avatars + @http.route('/api/exam/avatars', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_avatars(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + avatars = request.env['encoach.elai.avatar'].sudo().search([]) + data = [{ + 'name': a.name, + 'avatar_code': a.avatar_code, + 'avatar_url': a.avatar_url or '', + 'gender': a.gender or '', + } for a in avatars] + return self._json_response(data) + except Exception: + _logger.exception("List avatars error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/generation.py b/encoach_api/controllers/generation.py new file mode 100644 index 0000000..eb1dcfc --- /dev/null +++ b/encoach_api/controllers/generation.py @@ -0,0 +1,347 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class GenerationController(EncoachMixin, http.Controller): + + # ------------------------------------------------- reading passage + @http.route( + '/api/exam/reading/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_reading_passage(self, passage, **kwargs): + """Generate a reading passage (1, 2, or 3) using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if passage not in (1, 2, 3): + return self._error_response('passage must be 1, 2, or 3', 400) + + topic = kwargs.get('topic') + word_count = int(kwargs.get('word_count', 500)) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_reading_passage( + passage=passage, + topic=topic, + word_count=word_count, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate reading passage error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------ reading exercises + @http.route('/api/exam/reading/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_reading_exercises(self, **kwargs): + """Generate reading exercises from a passage.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + exercises = body.get('exercises', []) + difficulty = body.get('difficulty', ['B1', 'B2']) + + if not text or not exercises: + return self._error_response('text and exercises are required', 400) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_reading_exercises( + text=text, + exercises=exercises, + difficulty=difficulty, + ) + + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Generate reading exercises error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------- listening dialog + @http.route( + '/api/exam/listening/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_listening_dialog(self, section, **kwargs): + """Generate a listening dialog/monologue (section 1-4) using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if section not in (1, 2, 3, 4): + return self._error_response('section must be 1, 2, 3, or 4', 400) + + topic = kwargs.get('topic') + difficulty = kwargs.get('difficulty') + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_listening_dialog( + section=section, + topic=topic, + difficulty=difficulty, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate listening dialog error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------- listening exercises + @http.route('/api/exam/listening/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_listening_exercises(self, **kwargs): + """Generate listening exercises from a dialog transcript.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + exercises = body.get('exercises', []) + difficulty = body.get('difficulty', ['B1']) + + if not text or not exercises: + return self._error_response('text and exercises are required', 400) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_listening_exercises( + text=text, + exercises=exercises, + difficulty=difficulty, + ) + + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Generate listening exercises error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------- writing task + @http.route( + '/api/exam/writing/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_writing_task(self, task, **kwargs): + """Generate a writing task prompt (task 1 or 2) using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if task not in (1, 2): + return self._error_response('task must be 1 or 2', 400) + + topic = kwargs.get('topic') + difficulty = kwargs.get('difficulty') + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_writing_task( + task=task, + topic=topic, + difficulty=difficulty, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate writing task error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ speaking task + @http.route( + '/api/exam/speaking/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_speaking_task(self, task, **kwargs): + """Generate a speaking task prompt (part 1, 2, or 3) using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if task not in (1, 2, 3): + return self._error_response('task must be 1, 2, or 3', 400) + + topic = kwargs.get('topic') + first_topic = kwargs.get('first_topic') + second_topic = kwargs.get('second_topic') + difficulty = kwargs.get('difficulty') + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_speaking_task( + task=task, + topic=topic, + first_topic=first_topic, + second_topic=second_topic, + difficulty=difficulty, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate speaking task error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------- level exercises + @http.route('/api/exam/level/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_level_exercises(self, **kwargs): + """Generate level-test exercises using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + exercises = body.get('exercises', []) + difficulty = body.get('difficulty', ['B1']) + + if not exercises: + return self._error_response('exercises specification is required', 400) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_level_exercises( + exercises=exercises, + difficulty=difficulty, + ) + + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Generate level exercises error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------- writing attachment (vision) + @http.route( + '/api/exam/writing//attachment', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_writing_attachment(self, task, **kwargs): + """Academic writing Task 1 with image upload for GPT-4o vision.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + if task not in (1, 2): + return self._error_response('task must be 1 or 2', 400) + + files = request.httprequest.files + form = request.httprequest.form + image_file = files.get('file') + difficulty = form.get('difficulty', 'B2') + + if not image_file: + return self._error_response('file (image) is required', 400) + + import base64 + image_data = base64.b64encode(image_file.read()).decode('utf-8') + mime_type = image_file.content_type or 'image/png' + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_writing_with_image( + task=task, + image_b64=image_data, + mime_type=mime_type, + difficulty=difficulty, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate writing attachment error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------- pre-built level exam + @http.route('/api/exam/level/', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_level_exam(self, **kwargs): + """Return a pre-built level exam (no AI generation).""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exams = request.env['encoach.exam'].sudo().search([ + ('module', '=', 'level'), + ('access', '=', 'public'), + ], limit=1, order='create_date desc') + if not exams: + return self._error_response('No level exam found', 404) + return self._json_response(exams[0].to_encoach_dict()) + except Exception: + _logger.exception("Get level exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------- UTAS level exam + @http.route('/api/exam/level/utas', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_utas_level_exam(self, **kwargs): + """Return a UTAS-format level exam.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + exams = request.env['encoach.exam'].sudo().search([ + ('module', '=', 'level'), + ('label', 'ilike', 'utas'), + ], limit=1, order='create_date desc') + if not exams: + return self._error_response('No UTAS level exam found', 404) + return self._json_response(exams[0].to_encoach_dict()) + except Exception: + _logger.exception("Get UTAS level exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------- level exam import + @http.route('/api/exam/level/import/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def import_level_exam(self, **kwargs): + """Import level exam from uploaded file.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + files = request.httprequest.files + exercises_file = files.get('exercises') + if not exercises_file: + return self._error_response('exercises file is required', 400) + + import json as _json + content = exercises_file.read().decode('utf-8') + data = _json.loads(content) + + exam = request.env['encoach.exam'].sudo().create({ + 'module': 'level', + 'label': data.get('label', 'Imported Level Exam'), + 'parts': data.get('parts', []), + 'access': 'private', + }) + return self._json_response(exam.to_encoach_dict()) + except Exception: + _logger.exception("Import level exam error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------- custom level exam + @http.route('/api/exam/level/custom/', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_custom_level(self, **kwargs): + """Generate custom level exam from JSON specification.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + spec = body.get('spec', body) + + GenService = request.env['encoach.ai.generation'].sudo() + result = GenService.generate_level_exercises( + exercises=spec.get('exercises', []), + difficulty=spec.get('difficulty', ['B1', 'B2']), + ) + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Generate custom level error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/grading.py b/encoach_api/controllers/grading.py new file mode 100644 index 0000000..f54fa81 --- /dev/null +++ b/encoach_api/controllers/grading.py @@ -0,0 +1,65 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class GradingController(EncoachMixin, http.Controller): + + # ------------------------------------------------- grade multiple + @http.route('/api/grading/multiple', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def grade_multiple(self, **kwargs): + """Grade multiple short-answer exercises using GPT-4o.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + questions = body.get('questions', []) + answers = body.get('answers', []) + + if not questions or not answers: + return self._error_response('questions and answers are required', 400) + if len(questions) != len(answers): + return self._error_response('questions and answers must have the same length', 400) + + GradingService = request.env['encoach.ai.grading'].sudo() + result = GradingService.grade_short_answers( + text=text, + questions=questions, + answers=answers, + ) + + return self._json_response({'exercises': result}) + except Exception: + _logger.exception("Grade multiple error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ grading summary + @http.route('/api/exam/grade/summary', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def grading_summary(self, **kwargs): + """Generate a grading summary for a full exam session.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + sections = body.get('sections', []) + if not sections: + return self._error_response('sections array is required', 400) + + GradingService = request.env['encoach.ai.grading'].sudo() + result = GradingService.generate_grading_summary(sections=sections) + + return self._json_response({'sections': result}) + except Exception: + _logger.exception("Grading summary error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/media.py b/encoach_api/controllers/media.py new file mode 100644 index 0000000..716bc77 --- /dev/null +++ b/encoach_api/controllers/media.py @@ -0,0 +1,213 @@ +import base64 +import json +import logging + +from odoo import http +from odoo.http import request, Response + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class MediaController(EncoachMixin, http.Controller): + + # ------------------------------------------- listening MP3 + @http.route( + '/api/exam/listening/media', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_listening_mp3(self, **kwargs): + """Generate listening MP3 audio from a dialog/monologue via AWS Polly.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + conversation = body.get('conversation') + monologue = body.get('monologue') + + if not conversation and not monologue: + return self._error_response('conversation or monologue is required', 400) + + MediaService = request.env['encoach.ai.media'].sudo() + mp3_data = MediaService.generate_listening_audio( + conversation=conversation, + monologue=monologue, + ) + + return Response( + mp3_data, + status=200, + content_type='audio/mpeg', + headers={ + 'Access-Control-Allow-Origin': '*', + 'Content-Disposition': 'attachment; filename="listening.mp3"', + }, + ) + except Exception: + _logger.exception("Generate listening MP3 error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------- transcribe audio + @http.route( + '/api/exam/listening/transcribe', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def transcribe_listening(self, **kwargs): + """Transcribe audio using Whisper and return dialog object.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + audio_file = request.httprequest.files.get('audio') + if not audio_file: + return self._error_response('audio file is required', 400) + + audio_data = audio_file.read() + MediaService = request.env['encoach.ai.media'].sudo() + result = MediaService.transcribe_to_dialog( + audio_data=audio_data, + filename=audio_file.filename, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Transcribe listening error") + return self._error_response('Internal server error', 500) + + # --------------------------------- listening instructions MP3 + @http.route( + '/api/exam/listening/instructions', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_instructions_mp3(self, **kwargs): + """Generate MP3 for listening instructions text via AWS Polly.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + if not text: + return self._error_response('text is required', 400) + + MediaService = request.env['encoach.ai.media'].sudo() + mp3_data = MediaService.generate_instructions_audio(text=text) + + return Response( + mp3_data, + status=200, + content_type='audio/mpeg', + headers={ + 'Access-Control-Allow-Origin': '*', + 'Content-Disposition': 'attachment; filename="instructions.mp3"', + }, + ) + except Exception: + _logger.exception("Generate instructions MP3 error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------ speaking video + @http.route( + '/api/exam/speaking/media', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False, cors='*', + ) + def generate_speaking_video(self, **kwargs): + """Start AI avatar video generation via ELAI (async).""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + text = body.get('text', '') + avatar = body.get('avatar', '') + if not text: + return self._error_response('text is required', 400) + + MediaService = request.env['encoach.ai.media'].sudo() + result = MediaService.create_speaking_video( + text=text, + avatar_code=avatar, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Generate speaking video error") + return self._error_response('Internal server error', 500) + + # --------------------------------- poll speaking video status + @http.route( + '/api/exam/speaking/media/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def poll_speaking_video(self, vid_id, **kwargs): + """Poll ELAI video generation status.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + MediaService = request.env['encoach.ai.media'].sudo() + result = MediaService.poll_video_status(video_id=vid_id) + + return self._json_response(result) + except Exception: + _logger.exception("Poll speaking video error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------- speaking avatars + @http.route( + '/api/exam/speaking/avatars', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def list_speaking_avatars(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + avatars = request.env['encoach.elai.avatar'].sudo().search([]) + data = [{ + 'name': a.name, + 'avatar_code': a.avatar_code, + 'avatar_url': a.avatar_url or '', + 'gender': a.gender or '', + 'canvas': getattr(a, 'canvas', '') or '', + 'voice_id': getattr(a, 'voice_id', '') or '', + 'voice_provider': getattr(a, 'voice_provider', '') or '', + } for a in avatars] + + return self._json_response(data) + except Exception: + _logger.exception("List speaking avatars error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------- general transcribe + @http.route('/api/transcribe', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def transcribe(self, **kwargs): + """Transcribe an audio file using Whisper.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + audio_file = request.httprequest.files.get('audio') or request.httprequest.files.get('file') + if not audio_file: + return self._error_response('audio file is required', 400) + + audio_data = audio_file.read() + MediaService = request.env['encoach.ai.media'].sudo() + result = MediaService.transcribe_audio( + audio_data=audio_data, + filename=audio_file.filename, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Transcribe error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/registration.py b/encoach_api/controllers/registration.py new file mode 100644 index 0000000..f3ce96c --- /dev/null +++ b/encoach_api/controllers/registration.py @@ -0,0 +1,149 @@ +import logging +from datetime import datetime, timedelta + +import jwt as pyjwt + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class RegistrationController(EncoachMixin, http.Controller): + + @http.route('/api/register', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def register(self, **kwargs): + try: + body = self._get_json_body() + email = body.get('email', '').strip() + password = body.get('password', '') + name = body.get('name', '').strip() + user_type = body.get('type', 'student') + + if not email or not password or not name: + return self._error_response('name, email, and password are required', 400) + + Users = request.env['res.users'].sudo() + existing = Users.search([('login', '=', email)], limit=1) + if existing: + return self._error_response('A user with this email already exists', 409) + + main_company = request.env['res.company'].sudo().search([], limit=1, order='id') + vals = { + 'name': name, + 'login': email, + 'password': password, + 'encoach_type': user_type, + 'is_verified': False, + 'company_id': main_company.id, + 'company_ids': [(6, 0, [main_company.id])], + } + + code_str = body.get('code') + if code_str: + result = request.env['encoach.code'].sudo().validate_code(code_str) + if result.get('valid'): + code_rec = request.env['encoach.code'].sudo().search([('code', '=', code_str)], limit=1) + if code_rec and code_rec.user_type: + vals['encoach_type'] = code_rec.user_type + + user = Users.create(vals) + + if code_str: + code_rec = request.env['encoach.code'].sudo().search([('code', '=', code_str)], limit=1) + if code_rec: + if code_rec.entity_id: + request.env['encoach.user.entity.rel'].sudo().create({ + 'user_id': user.id, + 'entity_id': code_rec.entity_id.id, + }) + code_rec.sudo().write({'uses': code_rec.uses + 1}) + + secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret') + token = pyjwt.encode({ + 'user_id': user.id, + 'email': user.login, + 'iat': datetime.utcnow(), + 'exp': datetime.utcnow() + timedelta(hours=72), + }, secret, algorithm='HS256') + + return self._json_response({ + 'user': self._serialize(user), + 'token': token, + }, status=201) + except Exception: + _logger.exception("Registration error") + return self._error_response('Internal server error', 500) + + @http.route( + '/api/code/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def validate_code(self, code, **kwargs): + try: + result = request.env['encoach.code'].sudo().validate_code(code) + if not result.get('valid'): + return self._error_response(result.get('error', 'Invalid code'), 404) + return self._json_response(result['code']) + except Exception: + _logger.exception("Validate code error") + return self._error_response('Internal server error', 500) + + @http.route('/api/batch_users', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def batch_users(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + users_data = body.get('users', []) + if not users_data: + return self._error_response('users array is required', 400) + + mixin = request.env['encoach.registration.mixin'].sudo() + result = mixin.batch_import(users_data, user.id) + return self._json_response(result) + except Exception: + _logger.exception("Batch users error") + return self._error_response('Internal server error', 500) + + @http.route('/api/make_user', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def make_user(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + email = body.get('email', '').strip() + name = body.get('name', '').strip() + password = body.get('password', '') + if not email or not name: + return self._error_response('name and email are required', 400) + + Users = request.env['res.users'].sudo() + existing = Users.search([('login', '=', email)], limit=1) + if existing: + return self._error_response('A user with this email already exists', 409) + + import secrets + if not password: + password = secrets.token_urlsafe(12) + + new_user = Users.create({ + 'name': name, + 'login': email, + 'password': password, + 'encoach_type': body.get('type', 'student'), + 'is_verified': body.get('isVerified', True), + }) + + result = self._serialize(new_user) + result['tempPassword'] = password + return self._json_response(result, status=201) + except Exception: + _logger.exception("Make user error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/sessions.py b/encoach_api/controllers/sessions.py new file mode 100644 index 0000000..c12cd8f --- /dev/null +++ b/encoach_api/controllers/sessions.py @@ -0,0 +1,73 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class SessionsController(EncoachMixin, http.Controller): + + # -------------------------------------------------------------- save + @http.route('/api/sessions', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def save_session(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + + session_id = body.get('_id') or body.get('id') + Session = request.env['encoach.session'].sudo() + + vals = {} + if body.get('examId'): + vals['exam_id'] = int(body['examId']) + if body.get('module'): + vals['module'] = body['module'] + if body.get('answers') is not None: + vals['answers'] = json.dumps(body['answers']) if isinstance(body['answers'], dict) else body['answers'] + if body.get('startedAt'): + vals['started_at'] = body['startedAt'] + if body.get('completedAt'): + vals['completed_at'] = body['completedAt'] + if body.get('assignmentId'): + vals['assignment_id'] = int(body['assignmentId']) + + if session_id: + record = Session.browse(int(session_id)) + if record.exists(): + record.write(vals) + return self._json_response(self._serialize_session(record)) + + vals['user_id'] = user.id + record = Session.create(vals) + return self._json_response(self._serialize_session(record), status=201) + except Exception: + _logger.exception("Save session error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------- delete + @http.route( + '/api/sessions/', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_session(self, sid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + session = request.env['encoach.session'].sudo().browse(sid) + if not session.exists(): + return self._error_response('Session not found', 404) + + session.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete session error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/stats.py b/encoach_api/controllers/stats.py new file mode 100644 index 0000000..0bd58a2 --- /dev/null +++ b/encoach_api/controllers/stats.py @@ -0,0 +1,137 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class StatsController(EncoachMixin, http.Controller): + + # -------------------------------------------------------------- save + @http.route('/api/stats', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def save_stats(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + stat_id = body.get('_id') or body.get('id') + Stat = request.env['encoach.stat'].sudo() + + vals = {} + if body.get('sessionId'): + vals['session_id'] = int(body['sessionId']) + if body.get('module'): + vals['module'] = body['module'] + if 'score' in body: + vals['score'] = body['score'] + if body.get('result') is not None: + vals['result'] = json.dumps(body['result']) if isinstance(body['result'], dict) else body['result'] + if body.get('examId'): + vals['exam_id'] = int(body['examId']) + if body.get('assignmentId'): + vals['assignment_id'] = int(body['assignmentId']) + + if stat_id: + record = Stat.browse(int(stat_id)) + if record.exists(): + record.write(vals) + return self._json_response(self._serialize_stat(record)) + + vals['user_id'] = user.id + record = Stat.create(vals) + return self._json_response(self._serialize_stat(record), status=201) + except Exception: + _logger.exception("Save stats error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------------------- get + @http.route( + '/api/stats/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def get_stat(self, stat_id, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + stat = request.env['encoach.stat'].sudo().browse(stat_id) + if not stat.exists(): + return self._error_response('Stat not found', 404) + + return self._json_response(self._serialize_stat(stat)) + except Exception: + _logger.exception("Get stat error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------------- statistical + @http.route('/api/statistical', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def statistical_query(self, **kwargs): + """Run statistical queries against exam stats.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + Stat = request.env['encoach.stat'].sudo() + + domain = [] + user_id = body.get('userId') + if user_id: + domain.append(('user_id', '=', int(user_id))) + + module = body.get('module') + if module: + domain.append(('module', '=', module)) + + group_id = body.get('groupId') + if group_id: + domain.append(('assignment_id.group_id', '=', int(group_id))) + + assignment_id = body.get('assignmentId') + if assignment_id: + domain.append(('assignment_id', '=', int(assignment_id))) + + date_from = body.get('dateFrom') + if date_from: + domain.append(('create_date', '>=', date_from)) + + date_to = body.get('dateTo') + if date_to: + domain.append(('create_date', '<=', date_to)) + + stats = Stat.search(domain, order='create_date desc') + + scores = [s.score for s in stats if s.score] + avg_score = sum(scores) / len(scores) if scores else 0 + total = len(stats) + + module_breakdown = {} + for s in stats: + mod = getattr(s, 'module', 'unknown') + if mod not in module_breakdown: + module_breakdown[mod] = {'count': 0, 'scores': []} + module_breakdown[mod]['count'] += 1 + if s.score: + module_breakdown[mod]['scores'].append(s.score) + + for mod, data in module_breakdown.items(): + data['average'] = sum(data['scores']) / len(data['scores']) if data['scores'] else 0 + del data['scores'] + + return self._json_response({ + 'total': total, + 'averageScore': round(avg_score, 2), + 'moduleBreakdown': module_breakdown, + 'stats': [self._serialize_stat(s) for s in stats[:100]], + }) + except Exception: + _logger.exception("Statistical query error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/storage.py b/encoach_api/controllers/storage.py new file mode 100644 index 0000000..df674fd --- /dev/null +++ b/encoach_api/controllers/storage.py @@ -0,0 +1,82 @@ +import base64 +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class StorageController(EncoachMixin, http.Controller): + + # ------------------------------------------------------------- upload + @http.route('/api/storage', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def upload_file(self, **kwargs): + """Upload a file and return its public URL via ``ir.attachment``.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + uploaded = request.httprequest.files.get('file') + if not uploaded: + return self._error_response('No file provided', 400) + + data = uploaded.read() + attachment = request.env['ir.attachment'].sudo().create({ + 'name': uploaded.filename, + 'datas': base64.b64encode(data), + 'res_model': 'encoach.storage', + 'type': 'binary', + 'public': True, + }) + + base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '') + file_url = f"{base_url}/web/content/{attachment.id}/{uploaded.filename}" + + return self._json_response({ + 'ok': True, + 'id': attachment.id, + 'url': file_url, + 'filename': uploaded.filename, + 'size': len(data), + }) + except Exception: + _logger.exception("Upload file error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------ delete + @http.route('/api/storage/delete', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def delete_file(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + attachment_id = body.get('id') or body.get('attachmentId') + url = body.get('url', '') + + Attachment = request.env['ir.attachment'].sudo() + + if attachment_id: + att = Attachment.browse(int(attachment_id)) + elif url: + parts = url.rstrip('/').split('/') + try: + att_id = int(parts[-2]) if len(parts) >= 2 else 0 + except (ValueError, IndexError): + att_id = 0 + att = Attachment.browse(att_id) if att_id else Attachment + else: + return self._error_response('id or url is required', 400) + + if att.exists(): + att.unlink() + + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete file error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/subscriptions.py b/encoach_api/controllers/subscriptions.py new file mode 100644 index 0000000..e5d7800 --- /dev/null +++ b/encoach_api/controllers/subscriptions.py @@ -0,0 +1,192 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class SubscriptionsController(EncoachMixin, http.Controller): + + # ---------------------------------------------------------- packages + @http.route('/api/packages', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_packages(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [('is_active', '=', True)] + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('entity_id', '=', int(entity_id))) + + packages = request.env['encoach.package'].sudo().search(domain, order='price asc') + return self._json_response([p.to_encoach_dict() for p in packages]) + except Exception: + _logger.exception("List packages error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------------------- stripe checkout + @http.route('/api/stripe', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_stripe_checkout(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + package_id = body.get('packageId') + if not package_id: + return self._error_response('packageId is required', 400) + + package = request.env['encoach.package'].sudo().browse(int(package_id)) + if not package.exists(): + return self._error_response('Package not found', 404) + + success_url = body.get('successUrl', '') + cancel_url = body.get('cancelUrl', '') + discount_code = body.get('discountCode') + + from odoo.addons.encoach_subscription.services.stripe_service import EncoachStripeService + service = EncoachStripeService(request.env) + result = service.create_checkout_session( + user=user, + package=package, + success_url=success_url, + cancel_url=cancel_url, + discount_code=discount_code, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Stripe checkout error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ paypal create order + @http.route('/api/paypal', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_paypal_order(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + package_id = body.get('packageId') + if not package_id: + return self._error_response('packageId is required', 400) + + package = request.env['encoach.package'].sudo().browse(int(package_id)) + if not package.exists(): + return self._error_response('Package not found', 404) + + discount_code = body.get('discountCode') + + from odoo.addons.encoach_subscription.services.paypal_service import EncoachPaypalService + service = EncoachPaypalService(request.env) + result = service.create_order( + user=user, + package=package, + discount_code=discount_code, + ) + + return self._json_response(result) + except Exception: + _logger.exception("PayPal create order error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------------- paypal capture order + @http.route('/api/paypal/approve', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def capture_paypal_order(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + order_id = body.get('orderId') + if not order_id: + return self._error_response('orderId is required', 400) + + from odoo.addons.encoach_subscription.services.paypal_service import EncoachPaypalService + service = EncoachPaypalService(request.env) + result = service.capture_order( + user=user, + order_id=order_id, + ) + + return self._json_response(result) + except Exception: + _logger.exception("PayPal capture error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ paymob intention + @http.route('/api/paymob', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_paymob_intention(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + package_id = body.get('packageId') + if not package_id: + return self._error_response('packageId is required', 400) + + package = request.env['encoach.package'].sudo().browse(int(package_id)) + if not package.exists(): + return self._error_response('Package not found', 404) + + discount_code = body.get('discountCode') + + from odoo.addons.encoach_subscription.services.paymob_service import EncoachPaymobService + service = EncoachPaymobService(request.env) + result = service.create_intention( + user=user, + package=package, + discount_code=discount_code, + ) + + return self._json_response(result) + except Exception: + _logger.exception("Paymob intention error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ stripe webhook + @http.route('/api/stripe/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*') + def stripe_webhook(self, **kwargs): + try: + payload = request.httprequest.get_data(as_text=True) + sig_header = request.httprequest.headers.get('Stripe-Signature', '') + + from odoo.addons.encoach_subscription.services.stripe_service import EncoachStripeService + service = EncoachStripeService(request.env) + result = service.handle_webhook(payload, sig_header) + + if 'error' in result: + return self._error_response(result['error'], 400) + return self._json_response(result) + except Exception: + _logger.exception("Stripe webhook error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------ paymob webhook + @http.route('/api/paymob/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*') + def paymob_webhook(self, **kwargs): + try: + payload = request.httprequest.get_data(as_text=True) + hmac_header = request.httprequest.args.get('hmac', '') + + from odoo.addons.encoach_subscription.services.paymob_service import EncoachPaymobService + service = EncoachPaymobService(request.env) + result = service.verify_transaction(json.loads(payload), hmac_header) + + if not result or 'error' in result: + return self._error_response(result.get('error', 'Verification failed') if result else 'Verification failed', 400) + return self._json_response(result) + except Exception: + _logger.exception("Paymob webhook error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/tickets.py b/encoach_api/controllers/tickets.py new file mode 100644 index 0000000..6898bd6 --- /dev/null +++ b/encoach_api/controllers/tickets.py @@ -0,0 +1,128 @@ +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class TicketsController(EncoachMixin, http.Controller): + + # --------------------------------------------------------------- list + @http.route('/api/tickets', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_tickets(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + domain = [] + user_type = getattr(user, 'encoach_user_type', 'student') + if user_type not in ('admin', 'developer'): + domain.append(('user_id', '=', user.id)) + + status = kwargs.get('status') + if status: + domain.append(('status', '=', status)) + + offset, limit, page = self._paginate_params(kwargs) + Ticket = request.env['encoach.ticket'].sudo() + total = Ticket.search_count(domain) + records = Ticket.search(domain, offset=offset, limit=limit, order='create_date desc') + + return self._json_response({ + 'tickets': [self._serialize_ticket(t) for t in records], + 'total': total, + 'page': page, + 'limit': limit, + }) + except Exception: + _logger.exception("List tickets error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- create + @http.route('/api/tickets', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def create_ticket(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + subject = body.get('subject', '').strip() + if not subject: + return self._error_response('Subject is required', 400) + + vals = { + 'subject': subject, + 'description': body.get('description', ''), + 'user_id': user.id, + 'status': 'open', + } + if body.get('priority'): + vals['priority'] = body['priority'] + if body.get('category'): + vals['category'] = body['category'] + + ticket = request.env['encoach.ticket'].sudo().create(vals) + return self._json_response(self._serialize_ticket(ticket), status=201) + except Exception: + _logger.exception("Create ticket error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- update + @http.route( + '/api/tickets/', type='http', auth='public', + methods=['PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_ticket(self, tid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + ticket = request.env['encoach.ticket'].sudo().browse(tid) + if not ticket.exists(): + return self._error_response('Ticket not found', 404) + + body = self._get_json_body() + vals = {} + if 'subject' in body: + vals['subject'] = body['subject'] + if 'description' in body: + vals['description'] = body['description'] + if 'status' in body: + vals['status'] = body['status'] + if 'priority' in body: + vals['priority'] = body['priority'] + + if vals: + ticket.write(vals) + + return self._json_response(self._serialize_ticket(ticket)) + except Exception: + _logger.exception("Update ticket error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------------- delete + @http.route( + '/api/tickets/', type='http', auth='public', + methods=['DELETE', 'OPTIONS'], csrf=False, cors='*', + ) + def delete_ticket(self, tid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + ticket = request.env['encoach.ticket'].sudo().browse(tid) + if not ticket.exists(): + return self._error_response('Ticket not found', 404) + + ticket.unlink() + return self._json_response({'ok': True}) + except Exception: + _logger.exception("Delete ticket error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/training.py b/encoach_api/controllers/training.py new file mode 100644 index 0000000..91051bd --- /dev/null +++ b/encoach_api/controllers/training.py @@ -0,0 +1,117 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class TrainingController(EncoachMixin, http.Controller): + + # ----------------------------------------------------------- generate + @http.route('/api/training', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def generate_training(self, **kwargs): + """Generate personalised training content using FAISS + GPT.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + target_user_id = body.get('userID', user.id) + stats = body.get('stats', []) + + Training = request.env['encoach.training'].sudo() + training = Training.generate_training( + user_id=int(target_user_id), + stats=stats, + ) + + return self._json_response({ + 'id': training.id, + '_id': str(training.id), + }) + except Exception: + _logger.exception("Generate training error") + return self._error_response('Internal server error', 500) + + # -------------------------------------------------------- get record + @http.route( + '/api/training/', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False, cors='*', + ) + def get_training(self, tid, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + training = request.env['encoach.training'].sudo().browse(tid) + if not training.exists(): + return self._error_response('Training record not found', 404) + + return self._json_response({ + '_id': str(training.id), + 'id': training.id, + 'userId': str(training.user_id.id) if training.user_id else None, + 'createdAt': training.created_at.isoformat() if getattr(training, 'created_at', None) else ( + training.create_date.isoformat() if training.create_date else None + ), + 'exams': json.loads(training.exams) if getattr(training, 'exams', None) else [], + 'tips': json.loads(training.tips) if getattr(training, 'tips', None) else {}, + 'weakAreas': json.loads(training.weak_areas) if getattr(training, 'weak_areas', None) else [], + }) + except Exception: + _logger.exception("Get training error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------- tips + @http.route('/api/training/tips', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def fetch_tips(self, **kwargs): + """Retrieve contextual tips using FAISS semantic search + GPT.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + Training = request.env['encoach.training'].sudo() + tips = Training.fetch_tips( + context=body.get('context', ''), + question=body.get('question', ''), + answer=body.get('answer', ''), + correct_answer=body.get('correct_answer', ''), + ) + + return self._json_response({'tips': tips}) + except Exception: + _logger.exception("Fetch tips error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------ walkthrough + @http.route('/api/training/walkthrough', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_walkthrough(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + Walkthrough = request.env['encoach.walkthrough'].sudo() + records = Walkthrough.search([], order='sequence asc') + + data = [{ + '_id': str(w.id), + 'id': w.id, + 'title': getattr(w, 'title', ''), + 'description': getattr(w, 'description', ''), + 'steps': json.loads(w.steps) if getattr(w, 'steps', None) else [], + 'module': getattr(w, 'module', ''), + } for w in records] + + return self._json_response(data) + except Exception: + _logger.exception("Get walkthrough error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/controllers/users.py b/encoach_api/controllers/users.py new file mode 100644 index 0000000..dbe9442 --- /dev/null +++ b/encoach_api/controllers/users.py @@ -0,0 +1,185 @@ +import logging + +from odoo import http +from odoo.http import request + +from .base import EncoachMixin + +_logger = logging.getLogger(__name__) + + +class UsersController(EncoachMixin, http.Controller): + + # ---------------------------------------------------------- current user + @http.route('/api/user', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_current_user(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + return self._json_response(self._serialize(user)) + except Exception: + _logger.exception("Get current user error") + return self._error_response('Internal server error', 500) + + # ----------------------------------------------------------- update user + @http.route( + '/api/users/update', type='http', auth='public', + methods=['POST', 'PATCH', 'OPTIONS'], csrf=False, cors='*', + ) + def update_user(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + vals = {} + field_map = { + 'name': 'name', + 'phone': 'phone', + 'nativeLanguage': 'encoach_native_language', + 'country': 'encoach_country', + 'learningGoal': 'encoach_learning_goal', + 'image': 'encoach_image_url', + } + for api_field, odoo_field in field_map.items(): + if api_field in body: + vals[odoo_field] = body[api_field] + + if 'password' in body and body['password']: + vals['password'] = body['password'] + + if vals: + user.sudo().write(vals) + + updated = request.env['res.users'].sudo().browse(user.id) + return self._json_response(self._serialize(updated)) + except Exception: + _logger.exception("Update user error") + return self._error_response('Internal server error', 500) + + # ------------------------------------------------------------- list users + @http.route('/api/users/list', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def list_users(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + offset, limit, page = self._paginate_params(kwargs) + domain = [] + + search = kwargs.get('search', '').strip() + if search: + domain.append('|') + domain.append(('name', 'ilike', search)) + domain.append(('login', 'ilike', search)) + + user_type = kwargs.get('type') + if user_type: + domain.append(('encoach_user_type', '=', user_type)) + + entity_id = kwargs.get('entityID') + if entity_id: + domain.append(('encoach_entity_id', '=', int(entity_id))) + + Users = request.env['res.users'].sudo() + total = Users.search_count(domain) + records = Users.search(domain, offset=offset, limit=limit, order='name asc') + + return self._json_response({ + 'users': [self._serialize(u) for u in records], + 'total': total, + 'page': page, + 'limit': limit, + }) + except Exception: + _logger.exception("List users error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------------------------- get user by id + @http.route('/api/users/', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def get_user(self, user_id, **kwargs): + try: + auth_user = self._authenticate() + if not auth_user: + return self._error_response('Unauthorized', 401) + + target = request.env['res.users'].sudo().browse(user_id) + if not target.exists(): + return self._error_response('User not found', 404) + + return self._json_response(self._serialize(target)) + except Exception: + _logger.exception("Get user error") + return self._error_response('Internal server error', 500) + + # --------------------------------------------------- user controller action + @http.route('/api/users/controller', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False, cors='*') + def user_controller(self, **kwargs): + """Handle user controller actions such as transferring users between groups.""" + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + body = self._get_json_body() + action = body.get('action') + if not action: + return self._error_response('Action is required', 400) + + Group = request.env['encoach.group'].sudo() + + if action == 'transfer': + user_ids = body.get('userIds', []) + from_group_id = body.get('fromGroupId') + to_group_id = body.get('toGroupId') + if not user_ids or not to_group_id: + return self._error_response('userIds and toGroupId are required', 400) + + if from_group_id: + from_group = Group.browse(int(from_group_id)) + if from_group.exists(): + from_group.write({ + 'participant_ids': [(3, int(uid)) for uid in user_ids], + }) + + to_group = Group.browse(int(to_group_id)) + if not to_group.exists(): + return self._error_response('Target group not found', 404) + to_group.write({ + 'participant_ids': [(4, int(uid)) for uid in user_ids], + }) + return self._json_response({'ok': True}) + + if action == 'remove': + user_ids = body.get('userIds', []) + group_id = body.get('groupId') + if not user_ids or not group_id: + return self._error_response('userIds and groupId are required', 400) + group = Group.browse(int(group_id)) + if group.exists(): + group.write({ + 'participant_ids': [(3, int(uid)) for uid in user_ids], + }) + return self._json_response({'ok': True}) + + return self._error_response(f'Unknown action: {action}', 400) + except Exception: + _logger.exception("User controller error") + return self._error_response('Internal server error', 500) + + # ---------------------------------------------------------- user balance + @http.route('/api/users/balance', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False, cors='*') + def user_balance(self, **kwargs): + try: + user = self._authenticate() + if not user: + return self._error_response('Unauthorized', 401) + + balance = getattr(user, 'encoach_balance', 0) + return self._json_response({'balance': balance}) + except Exception: + _logger.exception("User balance error") + return self._error_response('Internal server error', 500) diff --git a/encoach_api/security/ir.model.access.csv b/encoach_api/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_api/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_api/tests/__init__.py b/encoach_api/tests/__init__.py new file mode 100644 index 0000000..50bc27c --- /dev/null +++ b/encoach_api/tests/__init__.py @@ -0,0 +1 @@ +from . import test_endpoints diff --git a/encoach_api/tests/test_endpoints.py b/encoach_api/tests/test_endpoints.py new file mode 100644 index 0000000..29e470d --- /dev/null +++ b/encoach_api/tests/test_endpoints.py @@ -0,0 +1,483 @@ +""" +Integration tests verifying all 73 frontend endpoint mappings from +ODOO_MIGRATION_FRONTEND_SRS.md Section 4 against the Odoo REST API. + +Run with: + ./odoo-bin -c odoo.conf --test-enable -d test_db --stop-after-init -i encoach_api +""" +import json +import logging + +from odoo.tests.common import HttpCase, tagged + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install", "encoach") +class TestEncoachEndpoints(HttpCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["ir.config_parameter"].sudo().set_param( + "encoach.jwt_secret", "test_secret_key_for_ci" + ) + cls.admin_user = cls.env["res.users"].sudo().create({ + "login": "encoach_test_admin@example.com", + "name": "Test Admin", + "password": "admin_pass_123", + "encoach_type": "admin", + }) + cls.student_user = cls.env["res.users"].sudo().create({ + "login": "encoach_test_student@example.com", + "name": "Test Student", + "password": "student_pass_123", + "encoach_type": "student", + }) + cls.entity = cls.env["encoach.entity"].sudo().create({ + "name": "Test Entity", + "label": "Test Org", + "licenses": 100, + }) + cls.exam = cls.env["encoach.exam"].sudo().create({ + "module": "reading", + "label": "Test Reading Exam", + "access": "public", + "parts": [{"text": "Sample passage", "exercises": []}], + }) + cls.group = cls.env["encoach.group"].sudo().create({ + "name": "Test Group", + "admin_id": cls.admin_user.id, + }) + cls.package = cls.env["encoach.package"].sudo().create({ + "name": "Basic Plan", + "price": 9.99, + "currency": "USD", + "duration_days": 30, + }) + + def _login_and_get_token(self, email="encoach_test_admin@example.com", password="admin_pass_123"): + resp = self.url_open( + "/api/login", + data=json.dumps({"email": email, "password": password}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertIn("token", data) + return data["token"] + + def _auth_headers(self, token): + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + # ================================================================ + # Section 4.1: Auth endpoints + # ================================================================ + + def test_0101_login(self): + """POST /api/login -- valid credentials""" + resp = self.url_open( + "/api/login", + data=json.dumps({"email": "encoach_test_admin@example.com", "password": "admin_pass_123"}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertIn("token", body) + self.assertIn("user", body) + + def test_0102_login_bad_password(self): + """POST /api/login -- wrong password returns 401""" + resp = self.url_open( + "/api/login", + data=json.dumps({"email": "encoach_test_admin@example.com", "password": "wrong"}), + headers={"Content-Type": "application/json"}, + ) + self.assertIn(resp.status_code, [401, 400]) + + def test_0103_logout(self): + """POST /api/logout -- always succeeds""" + resp = self.url_open( + "/api/logout", + data=json.dumps({}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + + def test_0104_reset(self): + """POST /api/reset -- sends reset email""" + resp = self.url_open( + "/api/reset", + data=json.dumps({"email": "encoach_test_admin@example.com"}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + + def test_0105_send_verification(self): + """POST /api/reset/sendVerification""" + resp = self.url_open( + "/api/reset/sendVerification", + data=json.dumps({"email": "encoach_test_admin@example.com"}), + headers={"Content-Type": "application/json"}, + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.2: User endpoints + # ================================================================ + + def test_0201_get_current_user(self): + """GET /api/user -- returns current JWT user""" + token = self._login_and_get_token() + resp = self.url_open("/api/user", headers=self._auth_headers(token)) + self.assertEqual(resp.status_code, 200) + body = resp.json() + self.assertIn("email", body.get("user", body)) + + def test_0202_update_user(self): + """POST /api/users/update -- update profile""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/users/update", + data=json.dumps({"name": "Updated Admin"}), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0203_list_users(self): + """GET /api/users/list -- paginated list""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/users/list?page=1&limit=10", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0204_get_user_by_id(self): + """GET /api/users/""" + token = self._login_and_get_token() + resp = self.url_open( + f"/api/users/{self.student_user.id}", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0205_user_balance(self): + """GET /api/users/balance""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/users/balance", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) + + # ================================================================ + # Section 4.3: Group endpoints + # ================================================================ + + def test_0301_list_groups(self): + """GET /api/groups""" + token = self._login_and_get_token() + resp = self.url_open("/api/groups", headers=self._auth_headers(token)) + self.assertEqual(resp.status_code, 200) + + def test_0302_create_group(self): + """POST /api/groups""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/groups", + data=json.dumps({"name": "New Test Group"}), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0303_update_group(self): + """PATCH /api/groups/""" + token = self._login_and_get_token() + resp = self.url_open( + f"/api/groups/{self.group.id}", + data=json.dumps({"name": "Renamed Group"}), + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 405]) + + def test_0304_delete_group(self): + """DELETE /api/groups/""" + token = self._login_and_get_token() + new_group = self.env["encoach.group"].sudo().create({ + "name": "To Delete", + "admin_id": self.admin_user.id, + }) + resp = self.url_open( + f"/api/groups/{new_group.id}", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 204, 405]) + + # ================================================================ + # Section 4.4: Exam endpoints + # ================================================================ + + def test_0401_list_exams(self): + """GET /api/exam""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/exam?module=reading", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0402_create_exam(self): + """POST /api/exam/reading""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/exam/reading", + data=json.dumps({ + "label": "Generated Reading", + "parts": [{"text": "Test", "exercises": []}], + }), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0403_get_exam(self): + """GET /api/exam/reading/""" + token = self._login_and_get_token() + resp = self.url_open( + f"/api/exam/reading/{self.exam.id}", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0404_update_exam(self): + """PATCH /api/exam/reading/""" + token = self._login_and_get_token() + resp = self.url_open( + f"/api/exam/reading/{self.exam.id}", + data=json.dumps({"label": "Updated Label"}), + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 405]) + + def test_0405_delete_exam(self): + """DELETE /api/exam/reading/""" + token = self._login_and_get_token() + disposable = self.env["encoach.exam"].sudo().create({ + "module": "reading", + "label": "To Delete", + "access": "public", + "parts": [], + }) + resp = self.url_open( + f"/api/exam/reading/{disposable.id}", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 204, 405]) + + # ================================================================ + # Section 4.5: Assignment endpoints + # ================================================================ + + def test_0501_create_assignment(self): + """POST /api/assignments""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/assignments", + data=json.dumps({ + "name": "Test Assignment", + "exams": [self.exam.id], + "assignees": [self.student_user.id], + }), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0502_list_assignments(self): + """GET /api/assignments""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/assignments", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.6: Session endpoints + # ================================================================ + + def test_0601_create_session(self): + """POST /api/sessions""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/sessions", + data=json.dumps({"examData": {"module": "reading"}}), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.7: Stats endpoints + # ================================================================ + + def test_0701_create_stat(self): + """POST /api/stats""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/stats", + data=json.dumps({ + "module": "reading", + "exercise": "fillBlanks", + "solutions": [], + "scoreCorrect": 5, + "scoreTotal": 10, + }), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_0702_statistical(self): + """POST /api/statistical""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/statistical", + data=json.dumps({"userId": self.admin_user.id}), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.8: Training endpoints + # ================================================================ + + def test_0801_get_walkthrough(self): + """GET /api/training/walkthrough""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/training/walkthrough", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) + + # ================================================================ + # Section 4.9: Package/Subscription endpoints + # ================================================================ + + def test_0901_list_packages(self): + """GET /api/packages""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/packages", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.10: Registration endpoints + # ================================================================ + + def test_1001_register(self): + """POST /api/register""" + resp = self.url_open( + "/api/register", + data=json.dumps({ + "email": "newuser_test@example.com", + "password": "test_pass_123", + "name": "New Test User", + }), + headers={"Content-Type": "application/json"}, + ) + self.assertIn(resp.status_code, [200, 201]) + + def test_1002_validate_code(self): + """GET /api/code/""" + code = self.env["encoach.code"].sudo().create({ + "code": "TESTCODE", + "creator_id": self.admin_user.id, + "max_uses": 10, + }) + resp = self.url_open("/api/code/TESTCODE") + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.11: Ticket endpoints + # ================================================================ + + def test_1101_create_ticket(self): + """POST /api/tickets""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/tickets", + data=json.dumps({ + "subject": "Help needed", + "description": "I have a problem", + "type": "help", + }), + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + def test_1102_list_tickets(self): + """GET /api/tickets""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/tickets", + headers=self._auth_headers(token), + ) + self.assertEqual(resp.status_code, 200) + + # ================================================================ + # Section 4.12: Evaluation endpoints (stub -- AI services not configured) + # ================================================================ + + def test_1201_evaluate_status(self): + """GET /api/evaluate/status -- should return 200 even with no data""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/evaluate/status?sessionId=0&exerciseId=none", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) + + # ================================================================ + # Section 4.13: Storage endpoints + # ================================================================ + + def test_1301_storage_delete(self): + """POST /api/storage/delete -- no-op when file missing""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/storage/delete", + data=json.dumps({"url": "nonexistent.png"}), + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) + + # ================================================================ + # Section 4.14: Grading endpoints (stub -- AI not configured) + # ================================================================ + + def test_1401_grading_summary(self): + """POST /api/exam/grade/summary""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/exam/grade/summary", + data=json.dumps({"sections": []}), + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 400, 500]) + + # ================================================================ + # Section 4.15: Avatar endpoint + # ================================================================ + + def test_1501_list_avatars(self): + """GET /api/exam/speaking/avatars""" + token = self._login_and_get_token() + resp = self.url_open( + "/api/exam/speaking/avatars", + headers=self._auth_headers(token), + ) + self.assertIn(resp.status_code, [200, 404]) diff --git a/encoach_assignment/__init__.py b/encoach_assignment/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_assignment/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_assignment/__manifest__.py b/encoach_assignment/__manifest__.py new file mode 100644 index 0000000..74f71be --- /dev/null +++ b/encoach_assignment/__manifest__.py @@ -0,0 +1,14 @@ +{ + "name": "EnCoach Assignment", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Exam assignments for teachers and students", + "depends": ["encoach_core", "encoach_exam"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/assignment_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_assignment/models/__init__.py b/encoach_assignment/models/__init__.py new file mode 100644 index 0000000..0e145aa --- /dev/null +++ b/encoach_assignment/models/__init__.py @@ -0,0 +1 @@ +from . import assignment diff --git a/encoach_assignment/models/assignment.py b/encoach_assignment/models/assignment.py new file mode 100644 index 0000000..be749ae --- /dev/null +++ b/encoach_assignment/models/assignment.py @@ -0,0 +1,87 @@ +from odoo import models, fields + +INSTRUCTOR_GENDER = [ + ("male", "Male"), + ("female", "Female"), +] + + +class EncoachAssignment(models.Model): + _name = "encoach.assignment" + _description = "EnCoach Assignment" + + name = fields.Char(required=True) + assigner_id = fields.Many2one( + "res.users", string="Assigner", default=lambda self: self.env.uid, + ) + assignee_ids = fields.Many2many( + "res.users", "encoach_assignment_assignee_rel", + "assignment_id", "user_id", string="Assignees", + ) + exam_ids = fields.Many2many( + "encoach.exam", "encoach_assignment_exam_rel", + "assignment_id", "exam_id", string="Exams", + ) + results = fields.Json( + help="Array of {user, type, stats} result objects per assignee", + ) + instructor_gender = fields.Selection(INSTRUCTOR_GENDER) + start_date = fields.Datetime() + end_date = fields.Datetime() + teacher_ids = fields.Many2many( + "res.users", "encoach_assignment_teacher_rel", + "assignment_id", "user_id", string="Teachers", + ) + archived = fields.Boolean(default=False) + released = fields.Boolean(default=False) + started = fields.Boolean(default=False) + auto_start = fields.Boolean(default=False) + entity_id = fields.Many2one("encoach.entity", index=True) + legacy_id = fields.Char(index=True) + + def action_start(self): + for rec in self: + rec.started = True + + def action_release(self): + for rec in self: + rec.released = True + + def action_archive(self): + for rec in self: + rec.archived = True + + def action_unarchive(self): + for rec in self: + rec.archived = False + + def to_encoach_dict(self): + self.ensure_one() + exams = [] + for exam in self.exam_ids: + exams.append({ + "id": exam.id, + "module": exam.module, + "assignee": None, + }) + return { + "id": self.id, + "name": self.name, + "assigner": self.assigner_id.id if self.assigner_id else None, + "assignees": self.assignee_ids.ids, + "results": self.results or [], + "exams": exams, + "instructorGender": self.instructor_gender, + "startDate": ( + self.start_date.isoformat() if self.start_date else None + ), + "endDate": ( + self.end_date.isoformat() if self.end_date else None + ), + "teachers": self.teacher_ids.ids, + "archived": self.archived, + "released": self.released, + "start": self.started, + "autoStart": self.auto_start, + "entity": self.entity_id.id if self.entity_id else None, + } diff --git a/encoach_assignment/security/ir.model.access.csv b/encoach_assignment/security/ir.model.access.csv new file mode 100644 index 0000000..17ce42f --- /dev/null +++ b/encoach_assignment/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_assignment_student,encoach.assignment.student,model_encoach_assignment,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_assignment_teacher,encoach.assignment.teacher,model_encoach_assignment,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_assignment_admin,encoach.assignment.admin,model_encoach_assignment,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_assignment_sysadmin,encoach.assignment.sysadmin,model_encoach_assignment,base.group_system,1,1,1,1 diff --git a/encoach_assignment/security/rules.xml b/encoach_assignment/security/rules.xml new file mode 100644 index 0000000..b57a476 --- /dev/null +++ b/encoach_assignment/security/rules.xml @@ -0,0 +1,17 @@ + + + + + Students see assigned assignments + + + [('assignee_ids', 'in', user.id)] + + + Teachers see all assignments + + + [(1, '=', 1)] + + + diff --git a/encoach_assignment/views/assignment_views.xml b/encoach_assignment/views/assignment_views.xml new file mode 100644 index 0000000..b11eaea --- /dev/null +++ b/encoach_assignment/views/assignment_views.xml @@ -0,0 +1,91 @@ + + + + encoach.assignment.tree + encoach.assignment + + + + + + + + + + + + + + + + + encoach.assignment.form + encoach.assignment + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Assignments + encoach.assignment + list,form + +

+ Create your first assignment. +

+

+ Assignments link exams to assignees and teachers with start and end dates. +

+
+
+ + +
diff --git a/encoach_classroom/__init__.py b/encoach_classroom/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_classroom/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_classroom/__manifest__.py b/encoach_classroom/__manifest__.py new file mode 100644 index 0000000..619b6a2 --- /dev/null +++ b/encoach_classroom/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "EnCoach Classroom", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Classroom groups for the EnCoach platform", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + "views/group_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_classroom/models/__init__.py b/encoach_classroom/models/__init__.py new file mode 100644 index 0000000..bb163c5 --- /dev/null +++ b/encoach_classroom/models/__init__.py @@ -0,0 +1 @@ +from . import group diff --git a/encoach_classroom/models/group.py b/encoach_classroom/models/group.py new file mode 100644 index 0000000..e48ce34 --- /dev/null +++ b/encoach_classroom/models/group.py @@ -0,0 +1,36 @@ +from odoo import models, fields + + +class EncoachGroup(models.Model): + _name = "encoach.group" + _description = "EnCoach Classroom Group" + + name = fields.Char(required=True) + admin_id = fields.Many2one("res.users", string="Admin", required=True) + participant_ids = fields.Many2many( + "res.users", "encoach_group_participant_rel", "group_id", "user_id", + string="Participants", + ) + disable_editing = fields.Boolean(default=False) + entity_id = fields.Many2one("encoach.entity", index=True) + legacy_id = fields.Char(index=True) + participant_count = fields.Integer( + string="Participants", + compute="_compute_participant_count", + store=False, + ) + + def _compute_participant_count(self): + for rec in self: + rec.participant_count = len(rec.participant_ids) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name, + "admin": self.admin_id.id, + "participants": self.participant_ids.ids, + "disableEditing": self.disable_editing, + "entity": self.entity_id.id if self.entity_id else None, + } diff --git a/encoach_classroom/security/ir.model.access.csv b/encoach_classroom/security/ir.model.access.csv new file mode 100644 index 0000000..a92a1a9 --- /dev/null +++ b/encoach_classroom/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_group_student,encoach.group.student,model_encoach_group,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_group_teacher,encoach.group.teacher,model_encoach_group,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_group_admin,encoach.group.admin,model_encoach_group,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_group_sysadmin,encoach.group.sysadmin,model_encoach_group,base.group_system,1,1,1,1 diff --git a/encoach_classroom/views/group_views.xml b/encoach_classroom/views/group_views.xml new file mode 100644 index 0000000..8552754 --- /dev/null +++ b/encoach_classroom/views/group_views.xml @@ -0,0 +1,66 @@ + + + + encoach.group.tree + encoach.group + + + + + + + + + + + + + encoach.group.form + encoach.group + +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Groups + encoach.group + list,form + +

+ Create your first group. +

+

+ Groups organize participants in the classroom for assignments and exams. +

+
+
+ + +
diff --git a/encoach_core/__init__.py b/encoach_core/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_core/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_core/__manifest__.py b/encoach_core/__manifest__.py new file mode 100644 index 0000000..6955742 --- /dev/null +++ b/encoach_core/__manifest__.py @@ -0,0 +1,25 @@ +{ + "name": "EnCoach Core", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Core models for the EnCoach IELTS platform", + "description": "User type extensions, entity management, roles, permissions, codes, invites.", + "depends": ["base", "mail"], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "data/constants.xml", + "views/menu.xml", + "views/entity_views.xml", + "views/role_views.xml", + "views/permission_views.xml", + "views/code_views.xml", + "views/invite_views.xml", + "views/user_entity_rel_views.xml", + "views/res_users_views.xml", + "data/assign_admin_group.xml", + ], + "installable": True, + "application": True, + "license": "LGPL-3", +} diff --git a/encoach_core/data/assign_admin_group.xml b/encoach_core/data/assign_admin_group.xml new file mode 100644 index 0000000..850f44d --- /dev/null +++ b/encoach_core/data/assign_admin_group.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/encoach_core/data/constants.xml b/encoach_core/data/constants.xml new file mode 100644 index 0000000..89a7c5e --- /dev/null +++ b/encoach_core/data/constants.xml @@ -0,0 +1,56 @@ + + + + + + encoach.jwt_secret + CHANGE_ME_IN_PRODUCTION_RANDOM_256_BIT_STRING + + + encoach.jwt_expiry_hours + 72 + + + + + encoach.openai_api_key + + + + encoach.aws_access_key_id + + + + encoach.aws_secret_access_key + + + + encoach.elai_token + + + + encoach.gptzero_api_key + + + + encoach.stripe_secret_key + + + + encoach.stripe_webhook_secret + + + + encoach.paypal_client_id + + + + encoach.paypal_client_secret + + + + encoach.paymob_api_key + + + + diff --git a/encoach_core/models/__init__.py b/encoach_core/models/__init__.py new file mode 100644 index 0000000..8bcb1c8 --- /dev/null +++ b/encoach_core/models/__init__.py @@ -0,0 +1,7 @@ +from . import res_users +from . import entity +from . import role +from . import permission +from . import code +from . import invite +from . import user_entity_rel diff --git a/encoach_core/models/code.py b/encoach_core/models/code.py new file mode 100644 index 0000000..c3992d3 --- /dev/null +++ b/encoach_core/models/code.py @@ -0,0 +1,57 @@ +import uuid +from odoo import models, fields, api + + +class EncoachCode(models.Model): + _name = "encoach.code" + _description = "EnCoach Registration Code" + + code = fields.Char(required=True, index=True, default=lambda self: uuid.uuid4().hex[:8].upper()) + entity_id = fields.Many2one("encoach.entity", ondelete="set null") + creator_id = fields.Many2one("res.users", required=True) + expiry_date = fields.Datetime() + code_type = fields.Selection( + [("individual", "Individual"), ("corporate", "Corporate")], + default="individual", + ) + max_uses = fields.Integer(default=1) + uses = fields.Integer(default=0) + user_type = fields.Selection( + [ + ("student", "Student"), + ("teacher", "Teacher"), + ("corporate", "Corporate"), + ], + default="student", + ) + legacy_id = fields.Char(index=True) + + @api.model + def validate_code(self, code_str): + rec = self.search([("code", "=", code_str)], limit=1) + if not rec: + return {"valid": False, "error": "Code not found"} + if rec.expiry_date and rec.expiry_date < fields.Datetime.now(): + return {"valid": False, "error": "Code expired"} + if rec.max_uses and rec.uses >= rec.max_uses: + return {"valid": False, "error": "Code fully used"} + return { + "valid": True, + "code": rec.to_encoach_dict(), + } + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "code": self.code, + "entity": self.entity_id.id if self.entity_id else None, + "creator": self.creator_id.id, + "expiryDate": ( + self.expiry_date.isoformat() if self.expiry_date else None + ), + "type": self.code_type, + "maxUses": self.max_uses, + "uses": self.uses, + "userType": self.user_type, + } diff --git a/encoach_core/models/entity.py b/encoach_core/models/entity.py new file mode 100644 index 0000000..01bbc2d --- /dev/null +++ b/encoach_core/models/entity.py @@ -0,0 +1,37 @@ +from odoo import models, fields + + +class EncoachEntity(models.Model): + _name = "encoach.entity" + _description = "EnCoach Entity (Organization)" + _inherit = ["mail.thread"] + + name = fields.Char(required=True, tracking=True) + label = fields.Char() + licenses = fields.Integer(default=0) + expiry_date = fields.Datetime() + payment_currency = fields.Char() + payment_price = fields.Float(digits=(12, 2)) + role_ids = fields.One2many("encoach.role", "entity_id", string="Roles") + user_rel_ids = fields.One2many( + "encoach.user.entity.rel", "entity_id", string="Members", + ) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + payment = None + if self.payment_currency and self.payment_price: + payment = { + "currency": self.payment_currency, + "price": self.payment_price, + } + return { + "id": self.id, + "label": self.label or self.name, + "licenses": self.licenses, + "expiryDate": ( + self.expiry_date.isoformat() if self.expiry_date else None + ), + "payment": payment, + } diff --git a/encoach_core/models/invite.py b/encoach_core/models/invite.py new file mode 100644 index 0000000..cf61e47 --- /dev/null +++ b/encoach_core/models/invite.py @@ -0,0 +1,46 @@ +from odoo import models, fields + + +class EncoachInvite(models.Model): + _name = "encoach.invite" + _description = "EnCoach Entity Invite" + + from_user_id = fields.Many2one("res.users", required=True, ondelete="cascade") + to_user_id = fields.Many2one("res.users", required=True, ondelete="cascade") + entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") + status = fields.Selection( + [ + ("pending", "Pending"), + ("accepted", "Accepted"), + ("declined", "Declined"), + ], + default="pending", + ) + legacy_id = fields.Char(index=True) + + def action_accept(self): + self.ensure_one() + self.status = "accepted" + existing = self.env["encoach.user.entity.rel"].search([ + ("user_id", "=", self.to_user_id.id), + ("entity_id", "=", self.entity_id.id), + ], limit=1) + if not existing: + self.env["encoach.user.entity.rel"].create({ + "user_id": self.to_user_id.id, + "entity_id": self.entity_id.id, + }) + + def action_decline(self): + self.ensure_one() + self.status = "declined" + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "from": self.from_user_id.id, + "to": self.to_user_id.id, + "entity": self.entity_id.id, + "status": self.status, + } diff --git a/encoach_core/models/permission.py b/encoach_core/models/permission.py new file mode 100644 index 0000000..1bace61 --- /dev/null +++ b/encoach_core/models/permission.py @@ -0,0 +1,45 @@ +from odoo import models, fields + +PERMISSION_TOPICS = [ + ("exam", "Exam Management"), + ("user", "User Management"), + ("entity", "Entity Management"), + ("group", "Group Management"), + ("assignment", "Assignment Management"), + ("code", "Code Management"), + ("invite", "Invite Management"), + ("payment", "Payment Management"), + ("ticket", "Ticket Management"), + ("approval", "Approval Workflow"), + ("stats", "Statistics"), + ("training", "Training"), +] + +PERMISSION_TYPES = [ + ("create", "Create"), + ("read", "Read"), + ("update", "Update"), + ("delete", "Delete"), +] + + +class EncoachPermission(models.Model): + _name = "encoach.permission" + _description = "EnCoach Permission" + + topic = fields.Selection(PERMISSION_TOPICS, required=True) + perm_type = fields.Selection(PERMISSION_TYPES, required=True) + legacy_id = fields.Char(index=True) + + _topic_type_unique = models.Constraint( + "UNIQUE(topic, perm_type)", + "Permission topic/type combination must be unique.", + ) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "topic": self.topic, + "type": self.perm_type, + } diff --git a/encoach_core/models/res_users.py b/encoach_core/models/res_users.py new file mode 100644 index 0000000..12d5833 --- /dev/null +++ b/encoach_core/models/res_users.py @@ -0,0 +1,92 @@ +from odoo import models, fields, api + +USER_TYPES = [ + ("student", "Student"), + ("teacher", "Teacher"), + ("corporate", "Corporate"), + ("admin", "Admin"), + ("developer", "Developer"), + ("agent", "Agent"), + ("mastercorporate", "Master Corporate"), +] + +DEFAULT_LEVELS = {"reading": 0, "listening": 0, "writing": 0, "speaking": 0} + + +class ResUsers(models.Model): + _inherit = "res.users" + + encoach_type = fields.Selection( + USER_TYPES, string="EnCoach User Type", default="student", + ) + focus = fields.Char() + levels = fields.Json(default=DEFAULT_LEVELS) + desired_levels = fields.Json(default=DEFAULT_LEVELS) + is_verified = fields.Boolean(default=False) + subscription_expiration = fields.Datetime() + registration_date = fields.Datetime(default=fields.Datetime.now) + bio = fields.Text() + profile_picture = fields.Char() + is_first_login = fields.Boolean(default=True) + last_login = fields.Datetime() + encoach_status = fields.Selection( + [("active", "Active"), ("inactive", "Inactive"), ("suspended", "Suspended")], + string="EnCoach Status", + default="active", + ) + entity_rel_ids = fields.One2many( + "encoach.user.entity.rel", "user_id", string="Entity Memberships", + ) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + """Serialize to the JSON shape the frontend expects.""" + self.ensure_one() + entities = [] + for rel in self.entity_rel_ids: + entities.append({ + "id": rel.entity_id.id, + "role": rel.role_id.name if rel.role_id else None, + }) + return { + "id": self.id, + "email": self.login, + "name": self.name, + "profilePicture": self.profile_picture or "", + "isFirstLogin": self.is_first_login, + "focus": self.focus or "", + "levels": self.levels or DEFAULT_LEVELS, + "desiredLevels": self.desired_levels or DEFAULT_LEVELS, + "type": self.encoach_type, + "bio": self.bio or "", + "isVerified": self.is_verified, + "subscriptionExpirationDate": ( + self.subscription_expiration.isoformat() + if self.subscription_expiration + else None + ), + "registrationDate": ( + self.registration_date.isoformat() + if self.registration_date + else None + ), + "status": self.encoach_status, + "lastLogin": ( + self.last_login.isoformat() if self.last_login else None + ), + "entities": entities, + "permissions": self._get_encoach_permissions(), + } + + def _get_encoach_permissions(self): + self.ensure_one() + perms = {} + for rel in self.entity_rel_ids: + if rel.role_id: + for perm in rel.role_id.permission_ids: + key = perm.topic + if key not in perms: + perms[key] = [] + if perm.perm_type not in perms[key]: + perms[key].append(perm.perm_type) + return perms diff --git a/encoach_core/models/role.py b/encoach_core/models/role.py new file mode 100644 index 0000000..1c69cdf --- /dev/null +++ b/encoach_core/models/role.py @@ -0,0 +1,20 @@ +from odoo import models, fields + + +class EncoachRole(models.Model): + _name = "encoach.role" + _description = "EnCoach Role" + + name = fields.Char(required=True) + entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") + permission_ids = fields.Many2many("encoach.permission", string="Permissions") + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name, + "entity": self.entity_id.id, + "permissions": [p.to_encoach_dict() for p in self.permission_ids], + } diff --git a/encoach_core/models/user_entity_rel.py b/encoach_core/models/user_entity_rel.py new file mode 100644 index 0000000..3fce899 --- /dev/null +++ b/encoach_core/models/user_entity_rel.py @@ -0,0 +1,15 @@ +from odoo import models, fields + + +class EncoachUserEntityRel(models.Model): + _name = "encoach.user.entity.rel" + _description = "EnCoach User-Entity Membership" + + user_id = fields.Many2one("res.users", required=True, ondelete="cascade") + entity_id = fields.Many2one("encoach.entity", required=True, ondelete="cascade") + role_id = fields.Many2one("encoach.role", ondelete="set null") + + _user_entity_unique = models.Constraint( + "UNIQUE(user_id, entity_id)", + "A user can only have one membership per entity.", + ) diff --git a/encoach_core/scripts/import_avatars.py b/encoach_core/scripts/import_avatars.py new file mode 100644 index 0000000..5b40491 --- /dev/null +++ b/encoach_core/scripts/import_avatars.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +Import ELAI avatars from avatars.json into encoach.elai.avatar. + +Usage (Odoo shell): + ./odoo-bin shell -c odoo.conf -d encoach_prod + >>> exec(open('../addons_extra/encoach_core/scripts/import_avatars.py').read()) +""" +import json +import logging +import os + +_logger = logging.getLogger("encoach.avatar_import") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +AVATARS_JSON = os.path.join( + os.path.dirname(__file__), "..", "..", "encoach", "encoach_backend", "elai", "avatars.json" +) + + +def import_avatars(env, json_path=None): + path = json_path or AVATARS_JSON + if not os.path.exists(path): + _logger.error("avatars.json not found at %s", path) + return + + with open(path, "r") as f: + avatars = json.load(f) + + if isinstance(avatars, dict): + avatars = list(avatars.values()) + + created = 0 + skipped = 0 + + for av in avatars: + code = av.get("code", av.get("avatar_code", "")) + if not code: + skipped += 1 + continue + + existing = env["encoach.elai.avatar"].sudo().search([("avatar_code", "=", code)], limit=1) + if existing: + skipped += 1 + continue + + voice = av.get("voice", {}) + voice_id = voice.get("voiceId") if isinstance(voice, dict) else av.get("voice_id") + voice_provider = voice.get("voiceProvider") if isinstance(voice, dict) else av.get("voice_provider") + + env["encoach.elai.avatar"].sudo().create({ + "name": av.get("name", code), + "avatar_code": code, + "avatar_url": av.get("url", av.get("avatar_url", "")), + "gender": av.get("gender"), + "canvas": av.get("canvas"), + "voice_id": voice_id, + "voice_provider": voice_provider, + }) + created += 1 + + _logger.info("Avatar import complete: created=%d, skipped=%d", created, skipped) + + +try: + env # noqa: F821 + import_avatars(env) +except NameError: + _logger.info("Run this script inside Odoo shell") diff --git a/encoach_core/scripts/migrate_firebase_auth.py b/encoach_core/scripts/migrate_firebase_auth.py new file mode 100644 index 0000000..6dc14d7 --- /dev/null +++ b/encoach_core/scripts/migrate_firebase_auth.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Firebase Auth -> Odoo res.users migration script. + +Exports users from Firebase Auth (via Admin SDK) and creates/updates +corresponding Odoo res.users records with EnCoach extensions. + +Usage (Odoo shell): + ./odoo-bin shell -c odoo.conf -d encoach_prod + >>> exec(open('../addons_extra/encoach_core/scripts/migrate_firebase_auth.py').read()) + +Requires: firebase-admin + pip install firebase-admin +""" +import logging + +_logger = logging.getLogger("encoach.firebase_migration") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +try: + import firebase_admin + from firebase_admin import auth as fb_auth, credentials +except ImportError: + _logger.error("firebase-admin is required: pip install firebase-admin") + firebase_admin = None + + +SERVICE_ACCOUNT_PATH = "serviceAccountKey.json" + + +def migrate_firebase_users(env, service_account_path=SERVICE_ACCOUNT_PATH): + if not firebase_admin: + _logger.error("firebase-admin not installed") + return + + cred = credentials.Certificate(service_account_path) + try: + firebase_admin.get_app() + except ValueError: + firebase_admin.initialize_app(cred) + + _logger.info("Fetching Firebase users...") + + created = 0 + updated = 0 + skipped = 0 + errors = 0 + + page = fb_auth.list_users() + while page: + for fb_user in page.users: + email = fb_user.email + if not email: + skipped += 1 + continue + try: + existing = env["res.users"].sudo().search([("login", "=", email)], limit=1) + if existing: + vals = {} + if fb_user.display_name and not existing.name: + vals["name"] = fb_user.display_name + if fb_user.email_verified and not existing.is_verified: + vals["is_verified"] = True + if fb_user.photo_url and not existing.profile_picture: + vals["profile_picture"] = fb_user.photo_url + if vals: + existing.sudo().write(vals) + updated += 1 + else: + skipped += 1 + else: + env["res.users"].sudo().create({ + "login": email, + "name": fb_user.display_name or email.split("@")[0], + "password": "changeme_firebase_migrated", + "encoach_type": "student", + "is_verified": fb_user.email_verified, + "profile_picture": fb_user.photo_url or "", + "legacy_id": fb_user.uid, + }) + created += 1 + except Exception as e: + _logger.error("Error migrating %s: %s", email, e) + errors += 1 + + page = page.get_next_page() + + _logger.info( + "Firebase migration complete: created=%d, updated=%d, skipped=%d, errors=%d", + created, updated, skipped, errors, + ) + + +try: + env # noqa: F821 + migrate_firebase_users(env) +except NameError: + _logger.info("Run this script inside Odoo shell") diff --git a/encoach_core/scripts/migrate_mongodb.py b/encoach_core/scripts/migrate_mongodb.py new file mode 100644 index 0000000..9e90862 --- /dev/null +++ b/encoach_core/scripts/migrate_mongodb.py @@ -0,0 +1,731 @@ +#!/usr/bin/env python3 +""" +MongoDB -> Odoo PostgreSQL data migration script. + +Usage (from Odoo shell): + ./odoo-bin shell -c odoo.conf -d encoach_prod + >>> exec(open('../addons_extra/encoach_core/scripts/migrate_mongodb.py').read()) + +Or standalone: + python migrate_mongodb.py --mongo-uri mongodb://localhost:27017 --mongo-db encoach --odoo-url http://localhost:8069 --odoo-db encoach_prod + +Requires: pymongo, xmlrpc.client (stdlib) +""" +import argparse +import logging +import sys +from datetime import datetime + +_logger = logging.getLogger("encoach.migration") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +try: + from pymongo import MongoClient +except ImportError: + _logger.error("pymongo is required: pip install pymongo") + sys.exit(1) + +COLLECTION_MAP = { + "users": "res.users", + "entities": "encoach.entity", + "roles": "encoach.role", + "permissions": "encoach.permission", + "codes": "encoach.code", + "invites": "encoach.invite", + "exams": "encoach.exam", + "groups": "encoach.group", + "assignments": "encoach.assignment", + "sessions": "encoach.session", + "stats": "encoach.stat", + "evaluations": "encoach.evaluation", + "trainings": "encoach.training", + "tips": "encoach.training.tip", + "walkthroughs": "encoach.walkthrough", + "packages": "encoach.package", + "payments": "encoach.subscription.payment", + "discounts": "encoach.discount", + "tickets": "encoach.ticket", + "avatars": "encoach.elai.avatar", +} + +LEGACY_ID_CACHE = {} + + +def _oid(doc, key="_id"): + val = doc.get(key) + if val is None: + return None + return str(val) + + +def _dt(val): + if isinstance(val, datetime): + return val.strftime("%Y-%m-%d %H:%M:%S") + if isinstance(val, str): + return val + if isinstance(val, (int, float)): + return datetime.fromtimestamp(val / 1000).strftime("%Y-%m-%d %H:%M:%S") + return None + + +def _resolve_legacy(model, legacy_id, env): + """Look up Odoo record ID by legacy MongoDB _id.""" + if not legacy_id: + return False + cache_key = (model, str(legacy_id)) + if cache_key in LEGACY_ID_CACHE: + return LEGACY_ID_CACHE[cache_key] + rec = env[model].sudo().search([("legacy_id", "=", str(legacy_id))], limit=1) + if rec: + LEGACY_ID_CACHE[cache_key] = rec.id + return rec.id + return False + + +class EncoachMigrator: + + def __init__(self, mongo_uri, mongo_db, env): + self.client = MongoClient(mongo_uri) + self.db = self.client[mongo_db] + self.env = env + self.stats = {k: {"total": 0, "created": 0, "skipped": 0, "error": 0} for k in COLLECTION_MAP} + + def run(self): + _logger.info("Starting EnCoach data migration") + self._migrate_entities() + self._migrate_permissions() + self._migrate_roles() + self._migrate_users() + self._migrate_codes() + self._migrate_invites() + self._migrate_exams() + self._migrate_groups() + self._migrate_assignments() + self._migrate_sessions() + self._migrate_stats() + self._migrate_evaluations() + self._migrate_trainings() + self._migrate_tips() + self._migrate_walkthroughs() + self._migrate_packages() + self._migrate_payments() + self._migrate_discounts() + self._migrate_tickets() + self._migrate_avatars() + self._print_summary() + + def _migrate_entities(self): + coll = "entities" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.entity"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + payment = doc.get("payment", {}) or {} + rec = self.env["encoach.entity"].sudo().create({ + "name": doc.get("label", doc.get("name", "Unknown")), + "label": doc.get("label"), + "licenses": doc.get("licenses", 0), + "expiry_date": _dt(doc.get("expiryDate")), + "payment_currency": payment.get("currency"), + "payment_price": payment.get("price", 0), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.entity", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Entity %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_permissions(self): + coll = "permissions" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.permission"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + rec = self.env["encoach.permission"].sudo().create({ + "topic": doc.get("topic", "exam"), + "perm_type": doc.get("type", "read"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.permission", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Permission %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_roles(self): + coll = "roles" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.role"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + perm_ids = [] + for pid in doc.get("permissions", []): + odoo_id = _resolve_legacy("encoach.permission", str(pid), self.env) + if odoo_id: + perm_ids.append(odoo_id) + rec = self.env["encoach.role"].sudo().create({ + "name": doc.get("name", "Unknown"), + "entity_id": entity_id or False, + "permission_ids": [(6, 0, perm_ids)], + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.role", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Role %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_users(self): + coll = "users" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + email = doc.get("email", "") + if not email: + self.stats[coll]["skipped"] += 1 + continue + existing = self.env["res.users"].sudo().search([ + "|", ("login", "=", email), ("legacy_id", "=", lid), + ], limit=1) + if existing: + if not existing.legacy_id: + existing.sudo().write({"legacy_id": lid}) + LEGACY_ID_CACHE[("res.users", lid)] = existing.id + self.stats[coll]["skipped"] += 1 + continue + try: + vals = { + "login": email, + "name": doc.get("name", email.split("@")[0]), + "password": "changeme_migrated", + "encoach_type": doc.get("type", "student"), + "focus": doc.get("focus"), + "levels": doc.get("levels"), + "desired_levels": doc.get("desiredLevels"), + "is_verified": doc.get("isVerified", False), + "bio": doc.get("bio"), + "profile_picture": doc.get("profilePicture"), + "registration_date": _dt(doc.get("registrationDate")), + "subscription_expiration": _dt(doc.get("subscriptionExpirationDate")), + "legacy_id": lid, + } + rec = self.env["res.users"].sudo().create(vals) + LEGACY_ID_CACHE[("res.users", lid)] = rec.id + + for ent_info in doc.get("entities", []): + ent_id = ent_info if isinstance(ent_info, str) else ent_info.get("id") if isinstance(ent_info, dict) else str(ent_info) + entity_odoo = _resolve_legacy("encoach.entity", ent_id, self.env) + if entity_odoo: + role_id = None + if isinstance(ent_info, dict) and ent_info.get("role"): + role_id = _resolve_legacy("encoach.role", ent_info["role"], self.env) + self.env["encoach.user.entity.rel"].sudo().create({ + "user_id": rec.id, + "entity_id": entity_odoo, + "role_id": role_id or False, + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("User %s (%s): %s", lid, email, e) + self.stats[coll]["error"] += 1 + + def _migrate_codes(self): + coll = "codes" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.code"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + creator_id = _resolve_legacy("res.users", doc.get("creator"), self.env) + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + rec = self.env["encoach.code"].sudo().create({ + "code": doc.get("code", lid[:8]), + "entity_id": entity_id or False, + "creator_id": creator_id or self.env.ref("base.user_admin").id, + "expiry_date": _dt(doc.get("expiryDate")), + "code_type": doc.get("type", "individual"), + "max_uses": doc.get("maxUses", 1), + "uses": doc.get("uses", 0), + "user_type": doc.get("userType", "student"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.code", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Code %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_invites(self): + coll = "invites" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.invite"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + from_id = _resolve_legacy("res.users", doc.get("from"), self.env) + to_id = _resolve_legacy("res.users", doc.get("to"), self.env) + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + if not (from_id and to_id and entity_id): + self.stats[coll]["skipped"] += 1 + continue + rec = self.env["encoach.invite"].sudo().create({ + "from_user_id": from_id, + "to_user_id": to_id, + "entity_id": entity_id, + "status": doc.get("status", "pending"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.invite", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Invite %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_exams(self): + coll = "exams" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.exam"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + module = doc.get("module", "reading") + owner_ids = [] + for oid in doc.get("owners", []): + uid = _resolve_legacy("res.users", str(oid), self.env) + if uid: + owner_ids.append(uid) + entity_ids = [] + for eid in doc.get("entities", []): + e_id = _resolve_legacy("encoach.entity", str(eid), self.env) + if e_id: + entity_ids.append(e_id) + rec = self.env["encoach.exam"].sudo().create({ + "module": module, + "min_timer": doc.get("minTimer", 0), + "is_diagnostic": doc.get("isDiagnostic", False), + "variant": doc.get("variant"), + "difficulty": doc.get("difficulty"), + "owner_ids": [(6, 0, owner_ids)], + "entity_ids": [(6, 0, entity_ids)], + "shuffle": doc.get("shuffle", False), + "access": doc.get("access", "public"), + "label": doc.get("label", ""), + "requires_approval": doc.get("requiresApproval", False), + "approved": doc.get("approved", False), + "parts": doc.get("parts", []), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.exam", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Exam %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_groups(self): + coll = "groups" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.group"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + admin_id = _resolve_legacy("res.users", doc.get("admin"), self.env) + participant_ids = [] + for pid in doc.get("participants", []): + uid = _resolve_legacy("res.users", str(pid), self.env) + if uid: + participant_ids.append(uid) + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + rec = self.env["encoach.group"].sudo().create({ + "name": doc.get("name", "Unnamed"), + "admin_id": admin_id or self.env.ref("base.user_admin").id, + "participant_ids": [(6, 0, participant_ids)], + "disable_editing": doc.get("disableEditing", False), + "entity_id": entity_id or False, + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.group", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Group %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_assignments(self): + coll = "assignments" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.assignment"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + assigner_id = _resolve_legacy("res.users", doc.get("assigner"), self.env) + assignee_ids = [ + _resolve_legacy("res.users", str(a), self.env) + for a in doc.get("assignees", []) + ] + assignee_ids = [a for a in assignee_ids if a] + exam_ids = [ + _resolve_legacy("encoach.exam", str(e), self.env) + for e in doc.get("exams", []) + ] + exam_ids = [e for e in exam_ids if e] + teacher_ids = [ + _resolve_legacy("res.users", str(t), self.env) + for t in doc.get("teachers", []) + ] + teacher_ids = [t for t in teacher_ids if t] + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + rec = self.env["encoach.assignment"].sudo().create({ + "name": doc.get("name", "Unnamed"), + "assigner_id": assigner_id or self.env.ref("base.user_admin").id, + "assignee_ids": [(6, 0, assignee_ids)], + "exam_ids": [(6, 0, exam_ids)], + "results": doc.get("results"), + "instructor_gender": doc.get("instructorGender"), + "start_date": _dt(doc.get("startDate")), + "end_date": _dt(doc.get("endDate")), + "teacher_ids": [(6, 0, teacher_ids)], + "archived": doc.get("archived", False), + "released": doc.get("released", False), + "started": doc.get("start", False), + "auto_start": doc.get("autoStart", False), + "entity_id": entity_id or False, + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.assignment", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Assignment %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_sessions(self): + coll = "sessions" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.session"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + if not user_id: + self.stats[coll]["skipped"] += 1 + continue + exam_data = {k: v for k, v in doc.items() if k not in ("_id", "user", "__v")} + rec = self.env["encoach.session"].sudo().create({ + "user_id": user_id, + "exam_data": exam_data, + "date": _dt(doc.get("date")) or _dt(doc.get("_id").generation_time) if hasattr(doc.get("_id"), "generation_time") else None, + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.session", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Session %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_stats(self): + coll = "stats" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.stat"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + if not user_id: + self.stats[coll]["skipped"] += 1 + continue + exam_id = _resolve_legacy("encoach.exam", doc.get("exam"), self.env) + session_id = _resolve_legacy("encoach.session", doc.get("session"), self.env) + assignment_id = _resolve_legacy("encoach.assignment", doc.get("assignment"), self.env) + score = doc.get("score", {}) + rec = self.env["encoach.stat"].sudo().create({ + "user_id": user_id, + "exam_id": exam_id or False, + "exercise": doc.get("exercise"), + "session_id": session_id or False, + "date": _dt(doc.get("date")), + "module": doc.get("module"), + "solutions": doc.get("solutions"), + "stat_type": doc.get("type"), + "time_spent": doc.get("timeSpent", 0), + "inactivity": doc.get("inactivity", 0), + "assignment_id": assignment_id or False, + "score_correct": score.get("correct", 0) if isinstance(score, dict) else 0, + "score_total": score.get("total", 0) if isinstance(score, dict) else 0, + "score_missing": score.get("missing", 0) if isinstance(score, dict) else 0, + "is_disabled": doc.get("isDisabled", False), + "shuffle_maps": doc.get("shuffleMaps"), + "is_practice": doc.get("isPractice", False), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.stat", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Stat %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_evaluations(self): + coll = "evaluations" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.evaluation"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + rec = self.env["encoach.evaluation"].sudo().create({ + "user_id": user_id or False, + "exercise_id": doc.get("exerciseId"), + "exercise_type": doc.get("exerciseType"), + "status": doc.get("status", "completed"), + "result": doc.get("result"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.evaluation", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Evaluation %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_trainings(self): + coll = "trainings" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.training"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + rec = self.env["encoach.training"].sudo().create({ + "user_id": user_id or self.env.ref("base.user_admin").id, + "exams": doc.get("exams"), + "tips": doc.get("tips"), + "weak_areas": doc.get("weakAreas"), + "legacy_id": lid, + }) + LEGACY_ID_CACHE[("encoach.training", lid)] = rec.id + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Training %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_tips(self): + coll = "tips" + _logger.info("Migrating %s (from training.tip collection)...", coll) + for doc in self.db.get_collection("training_tips").find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.training.tip"].sudo().search([("tip_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + self.env["encoach.training.tip"].sudo().create({ + "tip_id": lid, + "category": doc.get("category"), + "content": doc.get("content", ""), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Tip %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_walkthroughs(self): + coll = "walkthroughs" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + try: + self.env["encoach.walkthrough"].sudo().create({ + "name": doc.get("name", ""), + "content": doc.get("content"), + "module": doc.get("module"), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Walkthrough: %s", e) + self.stats[coll]["error"] += 1 + + def _migrate_packages(self): + coll = "packages" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + try: + self.env["encoach.package"].sudo().create({ + "name": doc.get("name", "Package"), + "price": doc.get("price", 0), + "currency": doc.get("currency", "USD"), + "duration_days": doc.get("duration", 30), + "features": doc.get("features"), + "active": doc.get("active", True), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Package: %s", e) + self.stats[coll]["error"] += 1 + + def _migrate_payments(self): + coll = "payments" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + try: + user_id = _resolve_legacy("res.users", doc.get("user"), self.env) + entity_id = _resolve_legacy("encoach.entity", doc.get("entity"), self.env) + self.env["encoach.subscription.payment"].sudo().create({ + "user_id": user_id or False, + "provider": doc.get("provider", "stripe"), + "status": doc.get("status", "completed"), + "amount": doc.get("amount", 0), + "currency": doc.get("currency", "USD"), + "transaction_id": doc.get("transactionId"), + "entity_id": entity_id or False, + "legacy_id": lid, + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Payment %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_discounts(self): + coll = "discounts" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + try: + self.env["encoach.discount"].sudo().create({ + "code": doc.get("code", ""), + "percentage": doc.get("percentage", 0), + "expiry_date": _dt(doc.get("expiryDate")), + "max_uses": doc.get("maxUses", 0), + "uses": doc.get("uses", 0), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Discount: %s", e) + self.stats[coll]["error"] += 1 + + def _migrate_tickets(self): + coll = "tickets" + _logger.info("Migrating %s...", coll) + for doc in self.db[coll].find(): + self.stats[coll]["total"] += 1 + lid = _oid(doc) + if self.env["encoach.ticket"].sudo().search([("legacy_id", "=", lid)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + reporter = doc.get("reporter", {}) + assigned_id = _resolve_legacy("res.users", doc.get("assignedTo"), self.env) + self.env["encoach.ticket"].sudo().create({ + "reporter_name": reporter.get("name") if isinstance(reporter, dict) else None, + "reporter_email": reporter.get("email") if isinstance(reporter, dict) else None, + "subject": doc.get("subject", ""), + "description": doc.get("description", ""), + "ticket_type": doc.get("type", "help"), + "status": doc.get("status", "open"), + "exam_information": doc.get("examInformation"), + "assigned_to_id": assigned_id or False, + "legacy_id": lid, + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Ticket %s: %s", lid, e) + self.stats[coll]["error"] += 1 + + def _migrate_avatars(self): + coll = "avatars" + _logger.info("Migrating %s...", coll) + for doc in self.db.get_collection("avatars").find(): + self.stats[coll]["total"] += 1 + code = doc.get("code", doc.get("avatar_code", "")) + if self.env["encoach.elai.avatar"].sudo().search([("avatar_code", "=", code)], limit=1): + self.stats[coll]["skipped"] += 1 + continue + try: + self.env["encoach.elai.avatar"].sudo().create({ + "name": doc.get("name", code), + "avatar_code": code, + "avatar_url": doc.get("url", doc.get("avatar_url", "")), + "gender": doc.get("gender"), + "canvas": doc.get("canvas"), + "voice_id": doc.get("voice", {}).get("voiceId") if isinstance(doc.get("voice"), dict) else doc.get("voice_id"), + "voice_provider": doc.get("voice", {}).get("voiceProvider") if isinstance(doc.get("voice"), dict) else doc.get("voice_provider"), + }) + self.stats[coll]["created"] += 1 + except Exception as e: + _logger.error("Avatar %s: %s", code, e) + self.stats[coll]["error"] += 1 + + def _print_summary(self): + _logger.info("\n========== MIGRATION SUMMARY ==========") + total_created = 0 + total_skipped = 0 + total_errors = 0 + for coll, s in self.stats.items(): + _logger.info( + " %-15s total=%4d created=%4d skipped=%4d errors=%4d", + coll, s["total"], s["created"], s["skipped"], s["error"], + ) + total_created += s["created"] + total_skipped += s["skipped"] + total_errors += s["error"] + _logger.info(" %-15s total=%4d created=%4d skipped=%4d errors=%4d", + "TOTAL", total_created + total_skipped + total_errors, + total_created, total_skipped, total_errors) + _logger.info("========================================\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Migrate EnCoach MongoDB to Odoo") + parser.add_argument("--mongo-uri", default="mongodb://localhost:27017") + parser.add_argument("--mongo-db", default="encoach") + args = parser.parse_args() + _logger.info("Standalone mode -- connect to Odoo via xmlrpc or run inside Odoo shell") + _logger.info("Example: ./odoo-bin shell -c odoo.conf -d encoach_prod") + _logger.info("Then: exec(open('path/to/migrate_mongodb.py').read())") +else: + try: + env # noqa: F821 -- available in Odoo shell + migrator = EncoachMigrator("mongodb://localhost:27017", "encoach", env) + migrator.run() + except NameError: + pass diff --git a/encoach_core/scripts/rebuild_faiss.py b/encoach_core/scripts/rebuild_faiss.py new file mode 100644 index 0000000..1a5fca2 --- /dev/null +++ b/encoach_core/scripts/rebuild_faiss.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Rebuild FAISS indices for EnCoach training tips. + +After data migration, this script re-embeds all training tips using +sentence-transformers and builds per-category FAISS indices. + +Usage (Odoo shell): + ./odoo-bin shell -c odoo.conf -d encoach_prod + >>> exec(open('../addons_extra/encoach_core/scripts/rebuild_faiss.py').read()) + +Requires: faiss-cpu, sentence-transformers +""" +import logging +import os +import pickle + +import numpy as np + +_logger = logging.getLogger("encoach.faiss_rebuild") +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + +try: + import faiss + from sentence_transformers import SentenceTransformer +except ImportError: + _logger.error("Install: pip install faiss-cpu sentence-transformers") + faiss = None + +MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2" +EMBEDDING_DIM = 384 +FAISS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "encoach_training", "data", "faiss") + +CATEGORIES = [ + "ct_focus", + "language_for_writing", + "reading_skill", + "strategy", + "writing_skill", + "word_link", + "word_partners", +] + + +def rebuild_faiss_indices(env, output_dir=None): + if not faiss: + return + + output_dir = output_dir or FAISS_DIR + os.makedirs(output_dir, exist_ok=True) + + _logger.info("Loading sentence-transformers model: %s", MODEL_NAME) + model = SentenceTransformer(MODEL_NAME) + + for category in CATEGORIES: + tips = env["encoach.training.tip"].sudo().search([("category", "=", category)]) + if not tips: + _logger.info(" %s: no tips, skipping", category) + continue + + contents = [t.content for t in tips] + tip_ids = [t.tip_id for t in tips] + _logger.info(" %s: encoding %d tips...", category, len(contents)) + + embeddings = model.encode(contents, show_progress_bar=False, convert_to_numpy=True) + embeddings = embeddings.astype(np.float32) + + for tip, emb in zip(tips, embeddings): + tip.sudo().write({"embedding": pickle.dumps(emb)}) + + index = faiss.IndexFlatL2(EMBEDDING_DIM) + index.add(embeddings) + + index_path = os.path.join(output_dir, f"{category}.index") + faiss.write_index(index, index_path) + + ids_path = os.path.join(output_dir, f"{category}_ids.pkl") + with open(ids_path, "wb") as f: + pickle.dump(tip_ids, f) + + _logger.info(" %s: indexed %d tips -> %s", category, len(tips), index_path) + + _logger.info("FAISS rebuild complete. Indices saved to %s", output_dir) + + +try: + env # noqa: F821 + rebuild_faiss_indices(env) +except NameError: + _logger.info("Run this script inside Odoo shell") diff --git a/encoach_core/security/ir.model.access.csv b/encoach_core/security/ir.model.access.csv new file mode 100644 index 0000000..036969b --- /dev/null +++ b/encoach_core/security/ir.model.access.csv @@ -0,0 +1,21 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_entity_student,encoach.entity.student,model_encoach_entity,group_encoach_student,1,0,0,0 +access_encoach_entity_admin,encoach.entity.admin,model_encoach_entity,group_encoach_admin,1,1,1,1 +access_encoach_role_student,encoach.role.student,model_encoach_role,group_encoach_student,1,0,0,0 +access_encoach_role_admin,encoach.role.admin,model_encoach_role,group_encoach_admin,1,1,1,1 +access_encoach_permission_student,encoach.permission.student,model_encoach_permission,group_encoach_student,1,0,0,0 +access_encoach_permission_admin,encoach.permission.admin,model_encoach_permission,group_encoach_admin,1,1,1,1 +access_encoach_code_student,encoach.code.student,model_encoach_code,group_encoach_student,1,0,0,0 +access_encoach_code_teacher,encoach.code.teacher,model_encoach_code,group_encoach_teacher,1,1,1,0 +access_encoach_code_admin,encoach.code.admin,model_encoach_code,group_encoach_admin,1,1,1,1 +access_encoach_invite_student,encoach.invite.student,model_encoach_invite,group_encoach_student,1,1,0,0 +access_encoach_invite_teacher,encoach.invite.teacher,model_encoach_invite,group_encoach_teacher,1,1,1,0 +access_encoach_invite_admin,encoach.invite.admin,model_encoach_invite,group_encoach_admin,1,1,1,1 +access_encoach_user_entity_rel_student,encoach.user.entity.rel.student,model_encoach_user_entity_rel,group_encoach_student,1,0,0,0 +access_encoach_user_entity_rel_admin,encoach.user.entity.rel.admin,model_encoach_user_entity_rel,group_encoach_admin,1,1,1,1 +access_encoach_entity_sysadmin,encoach.entity.sysadmin,model_encoach_entity,base.group_system,1,1,1,1 +access_encoach_role_sysadmin,encoach.role.sysadmin,model_encoach_role,base.group_system,1,1,1,1 +access_encoach_permission_sysadmin,encoach.permission.sysadmin,model_encoach_permission,base.group_system,1,1,1,1 +access_encoach_code_sysadmin,encoach.code.sysadmin,model_encoach_code,base.group_system,1,1,1,1 +access_encoach_invite_sysadmin,encoach.invite.sysadmin,model_encoach_invite,base.group_system,1,1,1,1 +access_encoach_user_entity_rel_sysadmin,encoach.user.entity.rel.sysadmin,model_encoach_user_entity_rel,base.group_system,1,1,1,1 diff --git a/encoach_core/security/security.xml b/encoach_core/security/security.xml new file mode 100644 index 0000000..471aaeb --- /dev/null +++ b/encoach_core/security/security.xml @@ -0,0 +1,30 @@ + + + + + + EnCoach Student + + + EnCoach Teacher + + + EnCoach Corporate + + + EnCoach Admin + + + + EnCoach Developer + + + + EnCoach Agent + + + EnCoach Master Corporate + + + + diff --git a/encoach_core/views/code_views.xml b/encoach_core/views/code_views.xml new file mode 100644 index 0000000..e173387 --- /dev/null +++ b/encoach_core/views/code_views.xml @@ -0,0 +1,65 @@ + + + + encoach.code.tree + encoach.code + + + + + + + + + + + + + + + + encoach.code.form + encoach.code + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + Registration Codes + encoach.code + list,form + +

+ Create your first registration code. +

+

+ Registration codes allow users to join entities or register + with a specific user type. +

+
+
+ + +
diff --git a/encoach_core/views/entity_views.xml b/encoach_core/views/entity_views.xml new file mode 100644 index 0000000..c8366c0 --- /dev/null +++ b/encoach_core/views/entity_views.xml @@ -0,0 +1,82 @@ + + + + encoach.entity.tree + encoach.entity + + + + + + + + + + + + + + encoach.entity.form + encoach.entity + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + Entities + encoach.entity + list,form + +

+ Create your first entity (organization). +

+

+ Entities represent organizations such as schools or companies. + Each entity has licenses, members, and roles. +

+
+
+ + +
diff --git a/encoach_core/views/invite_views.xml b/encoach_core/views/invite_views.xml new file mode 100644 index 0000000..cbd6a95 --- /dev/null +++ b/encoach_core/views/invite_views.xml @@ -0,0 +1,59 @@ + + + + encoach.invite.tree + encoach.invite + + + + + + + + + + + + encoach.invite.form + encoach.invite + +
+
+
+ + + + + + + + +
+
+
+ + + Invites + encoach.invite + list,form + +

+ No invites yet. +

+

+ Invites allow users to join entities. + Pending invites can be accepted or declined. +

+
+
+ + +
diff --git a/encoach_core/views/menu.xml b/encoach_core/views/menu.xml new file mode 100644 index 0000000..cc2e3f5 --- /dev/null +++ b/encoach_core/views/menu.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/encoach_core/views/permission_views.xml b/encoach_core/views/permission_views.xml new file mode 100644 index 0000000..be18df8 --- /dev/null +++ b/encoach_core/views/permission_views.xml @@ -0,0 +1,49 @@ + + + + encoach.permission.tree + encoach.permission + + + + + + + + + + encoach.permission.form + encoach.permission + +
+ + + + + + +
+
+
+ + + Permissions + encoach.permission + list,form + +

+ Create your first permission. +

+

+ Permissions define what actions can be performed on each topic. + Assign permissions to roles. +

+
+
+ + +
diff --git a/encoach_core/views/res_users_views.xml b/encoach_core/views/res_users_views.xml new file mode 100644 index 0000000..fb6d675 --- /dev/null +++ b/encoach_core/views/res_users_views.xml @@ -0,0 +1,39 @@ + + + + res.users.form.encoach + res.users + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/encoach_core/views/role_views.xml b/encoach_core/views/role_views.xml new file mode 100644 index 0000000..ff89b76 --- /dev/null +++ b/encoach_core/views/role_views.xml @@ -0,0 +1,50 @@ + + + + encoach.role.tree + encoach.role + + + + + + + + + + encoach.role.form + encoach.role + +
+ + + + + + + +
+
+
+ + + Roles + encoach.role + list,form + +

+ Create your first role. +

+

+ Roles define permissions within an entity. + Assign permissions to roles to control what users can do. +

+
+
+ + +
diff --git a/encoach_core/views/user_entity_rel_views.xml b/encoach_core/views/user_entity_rel_views.xml new file mode 100644 index 0000000..e441e64 --- /dev/null +++ b/encoach_core/views/user_entity_rel_views.xml @@ -0,0 +1,50 @@ + + + + encoach.user.entity.rel.tree + encoach.user.entity.rel + + + + + + + + + + + encoach.user.entity.rel.form + encoach.user.entity.rel + +
+ + + + + + + +
+
+
+ + + User-Entity Memberships + encoach.user.entity.rel + list,form + +

+ No memberships yet. +

+

+ User-entity memberships link users to entities with optional roles. +

+
+
+ + +
diff --git a/encoach_evaluation/__init__.py b/encoach_evaluation/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_evaluation/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_evaluation/__manifest__.py b/encoach_evaluation/__manifest__.py new file mode 100644 index 0000000..9b55eb9 --- /dev/null +++ b/encoach_evaluation/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "EnCoach Evaluation", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Evaluation records and AI job tracking for EnCoach", + "description": "Stores writing/speaking evaluations and background AI job state.", + "depends": ["encoach_core", "encoach_exam", "encoach_ai"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/evaluation_views.xml", + "views/ai_job_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_evaluation/models/__init__.py b/encoach_evaluation/models/__init__.py new file mode 100644 index 0000000..913e525 --- /dev/null +++ b/encoach_evaluation/models/__init__.py @@ -0,0 +1,2 @@ +from . import evaluation +from . import ai_job diff --git a/encoach_evaluation/models/ai_job.py b/encoach_evaluation/models/ai_job.py new file mode 100644 index 0000000..c5557a2 --- /dev/null +++ b/encoach_evaluation/models/ai_job.py @@ -0,0 +1,34 @@ +from odoo import models, fields + + +class EncoachAIJob(models.Model): + _name = "encoach.ai.job" + _description = "EnCoach AI Background Job" + + job_type = fields.Selection( + [ + ("writing_grading", "Writing Grading"), + ("speaking_grading", "Speaking Grading"), + ("video_generation", "Video Generation"), + ], + string="Job Type", + ) + status = fields.Selection( + [ + ("pending", "Pending"), + ("in_progress", "In Progress"), + ("completed", "Completed"), + ("error", "Error"), + ], + default="pending", + ) + user_id = fields.Many2one("res.users", string="User", ondelete="set null") + evaluation_id = fields.Many2one( + "encoach.evaluation", string="Evaluation", ondelete="cascade", + ) + input_data = fields.Json() + result_data = fields.Json() + error_message = fields.Text() + created_at = fields.Datetime(default=fields.Datetime.now) + completed_at = fields.Datetime() + retry_count = fields.Integer(default=0) diff --git a/encoach_evaluation/models/evaluation.py b/encoach_evaluation/models/evaluation.py new file mode 100644 index 0000000..a01047b --- /dev/null +++ b/encoach_evaluation/models/evaluation.py @@ -0,0 +1,45 @@ +from odoo import models, fields + + +class EncoachEvaluation(models.Model): + _name = "encoach.evaluation" + _description = "EnCoach Evaluation Record" + + user_id = fields.Many2one("res.users", string="User", ondelete="cascade") + session_id = fields.Integer(string="Session Reference") + exercise_id = fields.Char(string="Exercise ID") + exercise_type = fields.Selection( + [("writing", "Writing"), ("speaking", "Speaking")], + string="Exercise Type", + ) + status = fields.Selection( + [ + ("pending", "Pending"), + ("in_progress", "In Progress"), + ("completed", "Completed"), + ("error", "Error"), + ], + default="pending", + ) + result = fields.Json() + created_at = fields.Datetime(default=fields.Datetime.now) + completed_at = fields.Datetime() + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "userId": self.user_id.id if self.user_id else None, + "sessionId": self.session_id, + "exerciseId": self.exercise_id or "", + "exerciseType": self.exercise_type or "", + "status": self.status, + "result": self.result, + "createdAt": ( + self.created_at.isoformat() if self.created_at else None + ), + "completedAt": ( + self.completed_at.isoformat() if self.completed_at else None + ), + } diff --git a/encoach_evaluation/security/ir.model.access.csv b/encoach_evaluation/security/ir.model.access.csv new file mode 100644 index 0000000..7571eb7 --- /dev/null +++ b/encoach_evaluation/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_evaluation_student,encoach.evaluation.student,model_encoach_evaluation,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_evaluation_admin,encoach.evaluation.admin,model_encoach_evaluation,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_ai_job_admin,encoach.ai.job.admin,model_encoach_ai_job,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_evaluation_sysadmin,encoach.evaluation.sysadmin,model_encoach_evaluation,base.group_system,1,1,1,1 +access_encoach_ai_job_sysadmin,encoach.ai.job.sysadmin,model_encoach_ai_job,base.group_system,1,1,1,1 diff --git a/encoach_evaluation/security/rules.xml b/encoach_evaluation/security/rules.xml new file mode 100644 index 0000000..064084f --- /dev/null +++ b/encoach_evaluation/security/rules.xml @@ -0,0 +1,23 @@ + + + + + Students see own evaluations + + + [('user_id', '=', user.id)] + + + Admins see all evaluations + + + [(1, '=', 1)] + + + Admins see all AI jobs + + + [(1, '=', 1)] + + + diff --git a/encoach_evaluation/views/ai_job_views.xml b/encoach_evaluation/views/ai_job_views.xml new file mode 100644 index 0000000..446c6c9 --- /dev/null +++ b/encoach_evaluation/views/ai_job_views.xml @@ -0,0 +1,55 @@ + + + + encoach.ai.job.list + encoach.ai.job + + + + + + + + + + + + + + + encoach.ai.job.form + encoach.ai.job + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + AI Jobs + encoach.ai.job + list,form + + + +
diff --git a/encoach_evaluation/views/evaluation_views.xml b/encoach_evaluation/views/evaluation_views.xml new file mode 100644 index 0000000..8cbaa42 --- /dev/null +++ b/encoach_evaluation/views/evaluation_views.xml @@ -0,0 +1,50 @@ + + + + encoach.evaluation.list + encoach.evaluation + + + + + + + + + + + + + + encoach.evaluation.form + encoach.evaluation + +
+ + + + + + + + + + + + +
+
+
+ + + Evaluations + encoach.evaluation + list,form + + + +
diff --git a/encoach_exam/__init__.py b/encoach_exam/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_exam/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_exam/__manifest__.py b/encoach_exam/__manifest__.py new file mode 100644 index 0000000..a3b42e0 --- /dev/null +++ b/encoach_exam/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "EnCoach Exam", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "IELTS exam models for Reading, Listening, Writing, Speaking, Level", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/exam_views.xml", + "views/approval_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_exam/models/__init__.py b/encoach_exam/models/__init__.py new file mode 100644 index 0000000..0611d57 --- /dev/null +++ b/encoach_exam/models/__init__.py @@ -0,0 +1,2 @@ +from . import exam +from . import approval_workflow diff --git a/encoach_exam/models/approval_workflow.py b/encoach_exam/models/approval_workflow.py new file mode 100644 index 0000000..f0956cd --- /dev/null +++ b/encoach_exam/models/approval_workflow.py @@ -0,0 +1,58 @@ +from odoo import models, fields + +WORKFLOW_STATUS = [ + ("pending", "Pending"), + ("approved", "Approved"), + ("rejected", "Rejected"), +] + + +class EncoachApprovalWorkflow(models.Model): + _name = "encoach.approval.workflow" + _description = "EnCoach Exam Approval Workflow" + + exam_id = fields.Many2one("encoach.exam", ondelete="cascade", index=True) + creator_id = fields.Many2one("res.users", default=lambda self: self.env.uid) + entity_id = fields.Many2one("encoach.entity") + name = fields.Char() + modules = fields.Json(help='List of modules, e.g. ["reading","writing"]') + steps = fields.Json( + help="Array of workflow step objects with stepType, stepNumber, " + "completed, rejected, completedBy, assignees, etc.", + ) + status = fields.Selection(WORKFLOW_STATUS, default="pending", required=True) + start_date = fields.Datetime(default=fields.Datetime.now) + legacy_id = fields.Char(index=True) + + def action_approve(self): + for rec in self: + rec.status = "approved" + if rec.exam_id: + rec.exam_id.approved = True + + def action_reject(self): + for rec in self: + rec.status = "rejected" + if rec.exam_id: + rec.exam_id.approved = False + + def action_reset(self): + for rec in self: + rec.status = "pending" + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name or "", + "entityId": self.entity_id.id if self.entity_id else None, + "requester": self.creator_id.id if self.creator_id else None, + "startDate": ( + int(self.start_date.timestamp() * 1000) + if self.start_date else None + ), + "modules": self.modules or [], + "examId": self.exam_id.id if self.exam_id else None, + "status": self.status, + "steps": self.steps or [], + } diff --git a/encoach_exam/models/exam.py b/encoach_exam/models/exam.py new file mode 100644 index 0000000..f4c57a5 --- /dev/null +++ b/encoach_exam/models/exam.py @@ -0,0 +1,77 @@ +from odoo import models, fields + +MODULE_SELECTION = [ + ("reading", "Reading"), + ("listening", "Listening"), + ("writing", "Writing"), + ("speaking", "Speaking"), + ("level", "Level"), +] + +VARIANT_SELECTION = [ + ("full", "Full"), + ("partial", "Partial"), +] + +ACCESS_SELECTION = [ + ("public", "Public"), + ("private", "Private"), + ("entity", "Entity"), +] + + +class EncoachExam(models.Model): + _name = "encoach.exam" + _description = "EnCoach Exam" + + module = fields.Selection(MODULE_SELECTION, required=True, index=True) + min_timer = fields.Integer(string="Minimum Timer (minutes)", default=0) + is_diagnostic = fields.Boolean(default=False) + variant = fields.Selection(VARIANT_SELECTION) + difficulty = fields.Json(help="List of CEFR difficulty levels, e.g. ['A1','B2']") + owner_ids = fields.Many2many( + "res.users", "encoach_exam_owner_rel", "exam_id", "user_id", + string="Owners", + ) + entity_ids = fields.Many2many( + "encoach.entity", "encoach_exam_entity_rel", "exam_id", "entity_id", + string="Entities", + ) + shuffle = fields.Boolean(default=False) + access = fields.Selection(ACCESS_SELECTION, default="public", required=True) + label = fields.Char() + requires_approval = fields.Boolean(default=False) + approved = fields.Boolean(default=False) + parts = fields.Json(help="Nested exam structure (parts, exercises, questions)") + legacy_id = fields.Char(index=True) + + def action_approve(self): + """Set exam as approved (used by form view Approve button).""" + self.write({"approved": True}) + + def to_encoach_dict(self): + self.ensure_one() + difficulty = self.difficulty or [] + if isinstance(difficulty, str): + import json + difficulty = json.loads(difficulty) + return { + "id": self.id, + "module": self.module, + "minTimer": self.min_timer, + "isDiagnostic": self.is_diagnostic, + "variant": self.variant, + "difficulty": difficulty, + "owners": self.owner_ids.ids, + "entities": [str(eid) for eid in self.entity_ids.ids], + "shuffle": self.shuffle, + "access": self.access, + "label": self.label or "", + "requiresApproval": self.requires_approval, + "approved": self.approved, + "parts": self.parts or [], + "createdBy": self.create_uid.id if self.create_uid else None, + "createdAt": ( + self.create_date.isoformat() if self.create_date else None + ), + } diff --git a/encoach_exam/security/ir.model.access.csv b/encoach_exam/security/ir.model.access.csv new file mode 100644 index 0000000..ad9db88 --- /dev/null +++ b/encoach_exam/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_exam_student,encoach.exam.student,model_encoach_exam,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_exam_teacher,encoach.exam.teacher,model_encoach_exam,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_exam_admin,encoach.exam.admin,model_encoach_exam,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_approval_workflow_student,encoach.approval.workflow.student,model_encoach_approval_workflow,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_approval_workflow_teacher,encoach.approval.workflow.teacher,model_encoach_approval_workflow,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_approval_workflow_admin,encoach.approval.workflow.admin,model_encoach_approval_workflow,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_exam_sysadmin,encoach.exam.sysadmin,model_encoach_exam,base.group_system,1,1,1,1 +access_encoach_approval_workflow_sysadmin,encoach.approval.workflow.sysadmin,model_encoach_approval_workflow,base.group_system,1,1,1,1 diff --git a/encoach_exam/security/rules.xml b/encoach_exam/security/rules.xml new file mode 100644 index 0000000..ac70bf9 --- /dev/null +++ b/encoach_exam/security/rules.xml @@ -0,0 +1,17 @@ + + + + + Students see public exams + + + ['|', ('access', '=', 'public'), ('owner_ids', 'in', user.id)] + + + Teachers see all exams + + + [(1, '=', 1)] + + + diff --git a/encoach_exam/views/approval_views.xml b/encoach_exam/views/approval_views.xml new file mode 100644 index 0000000..b90c090 --- /dev/null +++ b/encoach_exam/views/approval_views.xml @@ -0,0 +1,71 @@ + + + + encoach.approval.workflow.tree + encoach.approval.workflow + + + + + + + + + + + + + encoach.approval.workflow.form + encoach.approval.workflow + +
+
+
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + Approval Workflows + encoach.approval.workflow + list,form + +

+ Create your first approval workflow. +

+

+ Approval workflows manage the exam approval process across modules. +

+
+
+ + +
diff --git a/encoach_exam/views/exam_views.xml b/encoach_exam/views/exam_views.xml new file mode 100644 index 0000000..9547f53 --- /dev/null +++ b/encoach_exam/views/exam_views.xml @@ -0,0 +1,87 @@ + + + + encoach.exam.tree + encoach.exam + + + + + + + + + + + + + + + + + + encoach.exam.form + encoach.exam + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Exams + encoach.exam + list,form + +

+ Create your first exam. +

+

+ Exams represent IELTS exam configurations for Reading, Listening, + Writing, Speaking, or Level modules. +

+
+
+ + +
diff --git a/encoach_registration/__init__.py b/encoach_registration/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_registration/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_registration/__manifest__.py b/encoach_registration/__manifest__.py new file mode 100644 index 0000000..6795bb0 --- /dev/null +++ b/encoach_registration/__manifest__.py @@ -0,0 +1,13 @@ +{ + "name": "EnCoach Registration", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "User registration services for EnCoach", + "description": "Individual and corporate registration, batch user import.", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_registration/models/__init__.py b/encoach_registration/models/__init__.py new file mode 100644 index 0000000..cc8d3f6 --- /dev/null +++ b/encoach_registration/models/__init__.py @@ -0,0 +1 @@ +from . import registration diff --git a/encoach_registration/models/registration.py b/encoach_registration/models/registration.py new file mode 100644 index 0000000..4501cb9 --- /dev/null +++ b/encoach_registration/models/registration.py @@ -0,0 +1,132 @@ +import logging + +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class EncoachRegistrationMixin(models.AbstractModel): + _name = "encoach.registration.mixin" + _description = "EnCoach Registration Service" + + @api.model + def register_individual(self, email, password, name, code=None): + if self.env["res.users"].sudo().search([("login", "=", email)], limit=1): + raise ValidationError(f"A user with email {email} already exists.") + + user = self.env["res.users"].sudo().create({ + "login": email, + "password": password, + "name": name, + "encoach_type": "student", + }) + + if code: + self._apply_code(user, code) + + _logger.info("Registered individual user: %s (%s)", name, email) + return user + + @api.model + def register_corporate(self, email, password, name, code): + if not code: + raise ValidationError("Corporate registration requires a code.") + + if self.env["res.users"].sudo().search([("login", "=", email)], limit=1): + raise ValidationError(f"A user with email {email} already exists.") + + result = self.env["encoach.code"].sudo().validate_code(code) + if not result.get("valid"): + raise ValidationError(result.get("error", "Invalid code.")) + + code_rec = self.env["encoach.code"].sudo().search([("code", "=", code)], limit=1) + entity = code_rec.entity_id + if not entity: + raise ValidationError("Code is not linked to any entity.") + + user = self.env["res.users"].sudo().create({ + "login": email, + "password": password, + "name": name, + "encoach_type": code_rec.user_type or "corporate", + }) + + self.env["encoach.user.entity.rel"].sudo().create({ + "user_id": user.id, + "entity_id": entity.id, + }) + + code_rec.sudo().write({"uses": code_rec.uses + 1}) + + _logger.info( + "Registered corporate user: %s (%s) -> entity %s", + name, email, entity.name, + ) + return user + + @api.model + def batch_import(self, users_data, maker_id): + created = [] + errors = [] + + for idx, udata in enumerate(users_data): + try: + email = udata.get("email") + password = udata.get("password", "") + name = udata.get("name", email) + user_type = udata.get("type", "student") + code = udata.get("code") + + if self.env["res.users"].sudo().search( + [("login", "=", email)], limit=1 + ): + errors.append({ + "index": idx, + "email": email, + "error": "User already exists", + }) + continue + + user = self.env["res.users"].sudo().create({ + "login": email, + "password": password, + "name": name, + "encoach_type": user_type, + }) + + if code: + self._apply_code(user, code) + + created.append(user.id) + except Exception as e: + errors.append({ + "index": idx, + "email": udata.get("email", ""), + "error": str(e), + }) + + _logger.info( + "Batch import by user %s: %d created, %d errors", + maker_id, len(created), len(errors), + ) + return {"created": created, "errors": errors} + + def _apply_code(self, user, code_str): + result = self.env["encoach.code"].sudo().validate_code(code_str) + if not result.get("valid"): + return + + code_rec = self.env["encoach.code"].sudo().search( + [("code", "=", code_str)], limit=1, + ) + if not code_rec: + return + + if code_rec.entity_id: + self.env["encoach.user.entity.rel"].sudo().create({ + "user_id": user.id, + "entity_id": code_rec.entity_id.id, + }) + + code_rec.sudo().write({"uses": code_rec.uses + 1}) diff --git a/encoach_registration/security/ir.model.access.csv b/encoach_registration/security/ir.model.access.csv new file mode 100644 index 0000000..97dd8b9 --- /dev/null +++ b/encoach_registration/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink diff --git a/encoach_stats/__init__.py b/encoach_stats/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_stats/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_stats/__manifest__.py b/encoach_stats/__manifest__.py new file mode 100644 index 0000000..db801f9 --- /dev/null +++ b/encoach_stats/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "EnCoach Stats", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Exam sessions and statistics tracking", + "depends": ["encoach_core", "encoach_exam"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/session_views.xml", + "views/stat_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_stats/models/__init__.py b/encoach_stats/models/__init__.py new file mode 100644 index 0000000..ab8de2f --- /dev/null +++ b/encoach_stats/models/__init__.py @@ -0,0 +1,2 @@ +from . import session +from . import stat diff --git a/encoach_stats/models/session.py b/encoach_stats/models/session.py new file mode 100644 index 0000000..1b040fb --- /dev/null +++ b/encoach_stats/models/session.py @@ -0,0 +1,21 @@ +from odoo import models, fields + + +class EncoachSession(models.Model): + _name = "encoach.session" + _description = "EnCoach Exam Session" + _order = "date desc" + + user_id = fields.Many2one("res.users", required=True, index=True) + exam_data = fields.Json(help="Full exam state snapshot (ExamState)") + date = fields.Datetime(default=fields.Datetime.now) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "user": self.user_id.id, + "date": self.date.isoformat() if self.date else None, + **(self.exam_data or {}), + } diff --git a/encoach_stats/models/stat.py b/encoach_stats/models/stat.py new file mode 100644 index 0000000..8cd540a --- /dev/null +++ b/encoach_stats/models/stat.py @@ -0,0 +1,67 @@ +from odoo import models, fields + +MODULE_SELECTION = [ + ("reading", "Reading"), + ("listening", "Listening"), + ("writing", "Writing"), + ("speaking", "Speaking"), + ("level", "Level"), +] + + +class EncoachStat(models.Model): + _name = "encoach.stat" + _description = "EnCoach Exam Statistic" + _order = "date desc" + + user_id = fields.Many2one("res.users", required=True, index=True) + exam_id = fields.Many2one("encoach.exam", index=True) + exercise = fields.Char() + session_id = fields.Many2one("encoach.session", index=True) + date = fields.Datetime(default=fields.Datetime.now) + module = fields.Selection(MODULE_SELECTION, index=True) + solutions = fields.Json(help="User solution objects for this stat") + stat_type = fields.Char(string="Type") + time_spent = fields.Integer(help="Seconds spent on this exercise") + inactivity = fields.Integer(help="Seconds of inactivity") + assignment_id = fields.Many2one("encoach.assignment", index=True) + score_correct = fields.Integer(default=0) + score_total = fields.Integer(default=0) + score_missing = fields.Integer(default=0) + is_disabled = fields.Boolean(default=False) + shuffle_maps = fields.Json() + is_practice = fields.Boolean(default=False) + pdf_url = fields.Char() + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + result = { + "id": self.id, + "user": self.user_id.id, + "exam": self.exam_id.id if self.exam_id else None, + "exercise": self.exercise or "", + "session": self.session_id.id if self.session_id else None, + "date": ( + int(self.date.timestamp() * 1000) if self.date else None + ), + "module": self.module, + "solutions": self.solutions or [], + "type": self.stat_type or "", + "timeSpent": self.time_spent, + "inactivity": self.inactivity, + "assignment": ( + self.assignment_id.id if self.assignment_id else None + ), + "score": { + "correct": self.score_correct, + "total": self.score_total, + "missing": self.score_missing, + }, + "isDisabled": self.is_disabled, + "shuffleMaps": self.shuffle_maps or [], + "isPractice": self.is_practice, + } + if self.pdf_url: + result["pdf"] = {"path": self.pdf_url, "version": "1"} + return result diff --git a/encoach_stats/security/ir.model.access.csv b/encoach_stats/security/ir.model.access.csv new file mode 100644 index 0000000..75f71b5 --- /dev/null +++ b/encoach_stats/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_session_student,encoach.session.student,model_encoach_session,encoach_core.group_encoach_student,1,1,1,0 +access_encoach_session_teacher,encoach.session.teacher,model_encoach_session,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_session_admin,encoach.session.admin,model_encoach_session,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_stat_student,encoach.stat.student,model_encoach_stat,encoach_core.group_encoach_student,1,1,1,0 +access_encoach_stat_teacher,encoach.stat.teacher,model_encoach_stat,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_stat_admin,encoach.stat.admin,model_encoach_stat,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_session_sysadmin,encoach.session.sysadmin,model_encoach_session,base.group_system,1,1,1,1 +access_encoach_stat_sysadmin,encoach.stat.sysadmin,model_encoach_stat,base.group_system,1,1,1,1 diff --git a/encoach_stats/security/rules.xml b/encoach_stats/security/rules.xml new file mode 100644 index 0000000..cfef50c --- /dev/null +++ b/encoach_stats/security/rules.xml @@ -0,0 +1,29 @@ + + + + + Students see own sessions + + + [('user_id', '=', user.id)] + + + Teachers see all sessions + + + [(1, '=', 1)] + + + Students see own stats + + + [('user_id', '=', user.id)] + + + Teachers see all stats + + + [(1, '=', 1)] + + + diff --git a/encoach_stats/views/session_views.xml b/encoach_stats/views/session_views.xml new file mode 100644 index 0000000..147abd8 --- /dev/null +++ b/encoach_stats/views/session_views.xml @@ -0,0 +1,43 @@ + + + + encoach.session.list + encoach.session + + + + + + + + + + + encoach.session.form + encoach.session + +
+ + + + + + + + +
+
+
+ + + Sessions + encoach.session + list,form + + + +
diff --git a/encoach_stats/views/stat_views.xml b/encoach_stats/views/stat_views.xml new file mode 100644 index 0000000..a3cf6a3 --- /dev/null +++ b/encoach_stats/views/stat_views.xml @@ -0,0 +1,67 @@ + + + + encoach.stat.list + encoach.stat + + + + + + + + + + + + + + + + + encoach.stat.form + encoach.stat + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Statistics + encoach.stat + list,form + + + +
diff --git a/encoach_subscription/__init__.py b/encoach_subscription/__init__.py new file mode 100644 index 0000000..71a0242 --- /dev/null +++ b/encoach_subscription/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/encoach_subscription/__manifest__.py b/encoach_subscription/__manifest__.py new file mode 100644 index 0000000..5ece421 --- /dev/null +++ b/encoach_subscription/__manifest__.py @@ -0,0 +1,16 @@ +{ + "name": "EnCoach Subscription", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Subscription packages, payments, and discounts for EnCoach", + "description": "Stripe, PayPal, Paymob payment integrations, subscription management.", + "depends": ["encoach_core", "product", "payment"], + "data": [ + "security/ir.model.access.csv", + "views/package_views.xml", + "views/payment_views.xml", + "views/discount_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_subscription/models/__init__.py b/encoach_subscription/models/__init__.py new file mode 100644 index 0000000..91209d1 --- /dev/null +++ b/encoach_subscription/models/__init__.py @@ -0,0 +1,3 @@ +from . import package +from . import subscription_payment +from . import discount diff --git a/encoach_subscription/models/discount.py b/encoach_subscription/models/discount.py new file mode 100644 index 0000000..fb6e997 --- /dev/null +++ b/encoach_subscription/models/discount.py @@ -0,0 +1,24 @@ +from odoo import fields, models + + +class EncoachDiscount(models.Model): + _name = "encoach.discount" + _description = "EnCoach Discount Code" + code = fields.Char(required=True, index=True) + + _code_unique = models.Constraint( + "UNIQUE(code)", + "Discount code must be unique.", + ) + percentage = fields.Float(digits=(5, 2)) + expiry_date = fields.Datetime() + max_uses = fields.Integer() + uses = fields.Integer(default=0) + + def is_valid(self): + self.ensure_one() + if self.max_uses and self.uses >= self.max_uses: + return False + if self.expiry_date and self.expiry_date < fields.Datetime.now(): + return False + return True diff --git a/encoach_subscription/models/package.py b/encoach_subscription/models/package.py new file mode 100644 index 0000000..115382b --- /dev/null +++ b/encoach_subscription/models/package.py @@ -0,0 +1,25 @@ +from odoo import models, fields + + +class EncoachPackage(models.Model): + _name = "encoach.package" + _description = "EnCoach Subscription Package" + + name = fields.Char(required=True) + price = fields.Float(digits=(12, 2)) + currency = fields.Char(default="USD") + duration_days = fields.Integer() + features = fields.Json() + active = fields.Boolean(default=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name, + "price": self.price, + "currency": self.currency or "USD", + "durationDays": self.duration_days, + "features": self.features, + "active": self.active, + } diff --git a/encoach_subscription/models/subscription_payment.py b/encoach_subscription/models/subscription_payment.py new file mode 100644 index 0000000..029615a --- /dev/null +++ b/encoach_subscription/models/subscription_payment.py @@ -0,0 +1,35 @@ +from odoo import models, fields + + +class EncoachSubscriptionPayment(models.Model): + _name = "encoach.subscription.payment" + _description = "EnCoach Subscription Payment" + + user_id = fields.Many2one("res.users", string="User", ondelete="set null") + package_id = fields.Many2one( + "encoach.package", string="Package", ondelete="set null", + ) + provider = fields.Selection( + [ + ("stripe", "Stripe"), + ("paypal", "PayPal"), + ("paymob", "Paymob"), + ], + string="Payment Provider", + ) + status = fields.Selection( + [ + ("pending", "Pending"), + ("completed", "Completed"), + ("failed", "Failed"), + ], + default="pending", + ) + amount = fields.Float(digits=(12, 2)) + currency = fields.Char() + transaction_id = fields.Char(index=True) + created_at = fields.Datetime(default=fields.Datetime.now) + entity_id = fields.Many2one( + "encoach.entity", string="Entity", ondelete="set null", + ) + legacy_id = fields.Char(index=True) diff --git a/encoach_subscription/security/ir.model.access.csv b/encoach_subscription/security/ir.model.access.csv new file mode 100644 index 0000000..d0b12d8 --- /dev/null +++ b/encoach_subscription/security/ir.model.access.csv @@ -0,0 +1,10 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_package_student,encoach.package.student,model_encoach_package,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_package_admin,encoach.package.admin,model_encoach_package,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_subscription_payment_student,encoach.subscription.payment.student,model_encoach_subscription_payment,encoach_core.group_encoach_student,1,0,1,0 +access_encoach_subscription_payment_admin,encoach.subscription.payment.admin,model_encoach_subscription_payment,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_discount_student,encoach.discount.student,model_encoach_discount,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_discount_admin,encoach.discount.admin,model_encoach_discount,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_package_sysadmin,encoach.package.sysadmin,model_encoach_package,base.group_system,1,1,1,1 +access_encoach_subscription_payment_sysadmin,encoach.subscription.payment.sysadmin,model_encoach_subscription_payment,base.group_system,1,1,1,1 +access_encoach_discount_sysadmin,encoach.discount.sysadmin,model_encoach_discount,base.group_system,1,1,1,1 diff --git a/encoach_subscription/services/__init__.py b/encoach_subscription/services/__init__.py new file mode 100644 index 0000000..8b85649 --- /dev/null +++ b/encoach_subscription/services/__init__.py @@ -0,0 +1,4 @@ +from . import stripe_service +from . import paypal_service +from . import paymob_service +from . import subscription_helper diff --git a/encoach_subscription/services/paymob_service.py b/encoach_subscription/services/paymob_service.py new file mode 100644 index 0000000..452db46 --- /dev/null +++ b/encoach_subscription/services/paymob_service.py @@ -0,0 +1,170 @@ +import hashlib +import hmac +import json +import logging + +import httpx + +_logger = logging.getLogger(__name__) + +PAYMOB_BASE = "https://accept.paymob.com/api" + + +class EncoachPaymobService: + + def __init__(self, env): + self.env = env + params = env["ir.config_parameter"].sudo() + self.api_key = params.get_param("encoach.paymob_api_key", "") + self.hmac_secret = params.get_param("encoach.paymob_hmac_secret", "") + + def _authenticate(self): + resp = httpx.post( + f"{PAYMOB_BASE}/auth/tokens", + json={"api_key": self.api_key}, + timeout=30, + ) + resp.raise_for_status() + return resp.json()["token"] + + def create_intention(self, user, package, discount_code=None): + if not self.api_key: + return {"error": "Paymob API key not configured"} + + amount = package.price + if discount_code: + discount = self.env["encoach.discount"].sudo().search([ + ("code", "=", discount_code), + ], limit=1) + if discount and discount.is_valid(): + amount = amount * (1 - discount.percentage / 100.0) + + amount_cents = int(round(amount * 100)) + currency = (package.currency or "EGP").upper() + + try: + auth_token = self._authenticate() + + order_resp = httpx.post( + f"{PAYMOB_BASE}/ecommerce/orders", + json={ + "auth_token": auth_token, + "delivery_needed": False, + "amount_cents": amount_cents, + "currency": currency, + "merchant_order_id": f"encoach_{user.id}_{package.id}", + "items": [{ + "name": package.name, + "amount_cents": amount_cents, + "quantity": 1, + }], + }, + timeout=30, + ) + order_resp.raise_for_status() + order = order_resp.json() + + integration_id = self.env["ir.config_parameter"].sudo().get_param( + "encoach.paymob_integration_id", "" + ) + + key_resp = httpx.post( + f"{PAYMOB_BASE}/acceptance/payment_keys", + json={ + "auth_token": auth_token, + "amount_cents": amount_cents, + "expiration": 3600, + "order_id": order["id"], + "billing_data": { + "email": user.login, + "first_name": (user.name or "").split()[0] if user.name else "N/A", + "last_name": (user.name or "").split()[-1] if user.name else "N/A", + "phone_number": "N/A", + "apartment": "N/A", + "floor": "N/A", + "street": "N/A", + "building": "N/A", + "shipping_method": "N/A", + "postal_code": "N/A", + "city": "N/A", + "country": "N/A", + "state": "N/A", + }, + "currency": currency, + "integration_id": int(integration_id) if integration_id else 0, + "lock_order_when_paid": True, + }, + timeout=30, + ) + key_resp.raise_for_status() + payment_key = key_resp.json()["token"] + + return { + "intentionId": str(order["id"]), + "clientSecret": payment_key, + } + except httpx.HTTPStatusError as e: + _logger.error("Paymob API error: %s", e.response.text) + return {"error": f"Paymob error: {e.response.status_code}"} + except Exception as e: + _logger.exception("Paymob create intention error") + return {"error": str(e)} + + def verify_transaction(self, payload, hmac_header): + if not self.hmac_secret: + _logger.warning("No Paymob HMAC secret configured") + return None + + try: + data = payload if isinstance(payload, dict) else json.loads(payload) + obj = data.get("obj", {}) + concat_str = ( + str(obj.get("amount_cents", "")) + + str(obj.get("created_at", "")) + + str(obj.get("currency", "")) + + str(obj.get("error_occured", "")).lower() + + str(obj.get("has_parent_transaction", "")).lower() + + str(obj.get("id", "")) + + str(obj.get("integration_id", "")) + + str(obj.get("is_3d_secure", "")).lower() + + str(obj.get("is_auth", "")).lower() + + str(obj.get("is_capture", "")).lower() + + str(obj.get("is_refunded", "")).lower() + + str(obj.get("is_standalone_payment", "")).lower() + + str(obj.get("is_voided", "")).lower() + + str(obj.get("order", {}).get("id", "")) + + str(obj.get("owner", "")) + + str(obj.get("pending", "")).lower() + + str(obj.get("source_data", {}).get("pan", "")) + + str(obj.get("source_data", {}).get("sub_type", "")) + + str(obj.get("source_data", {}).get("type", "")) + + str(obj.get("success", "")).lower() + ) + + computed = hmac.new( + self.hmac_secret.encode("utf-8"), + concat_str.encode("utf-8"), + hashlib.sha512, + ).hexdigest() + + if not hmac.compare_digest(computed, hmac_header): + return None + + if obj.get("success") is True: + merchant_order_id = str(obj.get("order", {}).get("merchant_order_id", "")) + parts = merchant_order_id.replace("encoach_", "").split("_") + user_id = int(parts[0]) if len(parts) >= 1 else 0 + package_id = int(parts[1]) if len(parts) >= 2 else 0 + amount = obj.get("amount_cents", 0) / 100.0 + currency = obj.get("currency", "EGP") + transaction_id = str(obj.get("id", "")) + + from .subscription_helper import extend_subscription + extend_subscription( + self.env, user_id, package_id, "paymob", transaction_id, amount, currency, + ) + return {"ok": True} + return {"error": "Transaction not successful"} + except Exception: + _logger.exception("Paymob verify transaction error") + return None diff --git a/encoach_subscription/services/paypal_service.py b/encoach_subscription/services/paypal_service.py new file mode 100644 index 0000000..97c44cb --- /dev/null +++ b/encoach_subscription/services/paypal_service.py @@ -0,0 +1,128 @@ +import logging + +import httpx + +_logger = logging.getLogger(__name__) + +PAYPAL_SANDBOX_BASE = "https://api-m.sandbox.paypal.com" +PAYPAL_LIVE_BASE = "https://api-m.paypal.com" + + +class EncoachPaypalService: + + def __init__(self, env): + self.env = env + params = env["ir.config_parameter"].sudo() + self.client_id = params.get_param("encoach.paypal_client_id", "") + self.client_secret = params.get_param("encoach.paypal_client_secret", "") + is_live = params.get_param("encoach.paypal_live", "false").lower() == "true" + self.base_url = PAYPAL_LIVE_BASE if is_live else PAYPAL_SANDBOX_BASE + + def _get_access_token(self): + resp = httpx.post( + f"{self.base_url}/v1/oauth2/token", + auth=(self.client_id, self.client_secret), + data={"grant_type": "client_credentials"}, + timeout=30, + ) + resp.raise_for_status() + return resp.json()["access_token"] + + def _headers(self, token): + return { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + def create_order(self, user, package, discount_code=None): + if not self.client_id or not self.client_secret: + return {"error": "PayPal credentials not configured"} + + amount = package.price + if discount_code: + discount = self.env["encoach.discount"].sudo().search([ + ("code", "=", discount_code), + ], limit=1) + if discount and discount.is_valid(): + amount = amount * (1 - discount.percentage / 100.0) + + currency = (package.currency or "USD").upper() + + try: + token = self._get_access_token() + order_data = { + "intent": "CAPTURE", + "purchase_units": [{ + "amount": { + "currency_code": currency, + "value": f"{amount:.2f}", + }, + "description": package.name, + "custom_id": f"{user.id}:{package.id}", + }], + "application_context": { + "return_url": "https://encoach.ai/payment/success", + "cancel_url": "https://encoach.ai/payment/cancel", + }, + } + + resp = httpx.post( + f"{self.base_url}/v2/checkout/orders", + headers=self._headers(token), + json=order_data, + timeout=30, + ) + resp.raise_for_status() + order = resp.json() + + approval_url = "" + for link in order.get("links", []): + if link["rel"] == "approve": + approval_url = link["href"] + break + + return {"orderId": order["id"], "approvalUrl": approval_url} + except httpx.HTTPStatusError as e: + _logger.error("PayPal API error: %s", e.response.text) + return {"error": f"PayPal error: {e.response.status_code}"} + except Exception as e: + _logger.exception("PayPal create order error") + return {"error": str(e)} + + def capture_order(self, user, order_id): + if not self.client_id or not self.client_secret: + return {"error": "PayPal credentials not configured"} + + try: + token = self._get_access_token() + resp = httpx.post( + f"{self.base_url}/v2/checkout/orders/{order_id}/capture", + headers=self._headers(token), + timeout=30, + ) + resp.raise_for_status() + capture = resp.json() + + if capture.get("status") == "COMPLETED": + purchase_unit = capture.get("purchase_units", [{}])[0] + custom_id = purchase_unit.get("payments", {}).get("captures", [{}])[0].get("custom_id", "") + parts = custom_id.split(":") + user_id = int(parts[0]) if len(parts) >= 1 else user.id + package_id = int(parts[1]) if len(parts) >= 2 else 0 + cap_amount = purchase_unit.get("payments", {}).get("captures", [{}])[0] + amount = float(cap_amount.get("amount", {}).get("value", 0)) + currency = cap_amount.get("amount", {}).get("currency_code", "USD") + + from .subscription_helper import extend_subscription + extend_subscription( + self.env, user_id, package_id, "paypal", order_id, amount, currency, + ) + return {"ok": True} + else: + return {"error": f"Order status: {capture.get('status')}"} + except httpx.HTTPStatusError as e: + _logger.error("PayPal capture error: %s", e.response.text) + return {"error": f"PayPal error: {e.response.status_code}"} + except Exception as e: + _logger.exception("PayPal capture error") + return {"error": str(e)} diff --git a/encoach_subscription/services/stripe_service.py b/encoach_subscription/services/stripe_service.py new file mode 100644 index 0000000..30b735d --- /dev/null +++ b/encoach_subscription/services/stripe_service.py @@ -0,0 +1,133 @@ +import hashlib +import hmac +import json +import logging +import time + +import httpx + +_logger = logging.getLogger(__name__) + +STRIPE_API_BASE = "https://api.stripe.com/v1" + + +class EncoachStripeService: + + def __init__(self, env): + self.env = env + params = env["ir.config_parameter"].sudo() + self.api_key = params.get_param("encoach.stripe_secret_key", "") + self.webhook_secret = params.get_param("encoach.stripe_webhook_secret", "") + + def _headers(self): + return { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/x-www-form-urlencoded", + } + + def create_checkout_session(self, user, package, success_url, cancel_url, discount_code=None): + if not self.api_key: + return {"error": "Stripe secret key not configured"} + + amount = package.price + if discount_code: + discount = self.env["encoach.discount"].sudo().search([ + ("code", "=", discount_code), + ], limit=1) + if discount and discount.is_valid(): + amount = amount * (1 - discount.percentage / 100.0) + + amount_cents = int(round(amount * 100)) + + data = { + "mode": "payment", + "line_items[0][price_data][currency]": (package.currency or "usd").lower(), + "line_items[0][price_data][unit_amount]": str(amount_cents), + "line_items[0][price_data][product_data][name]": package.name, + "line_items[0][quantity]": "1", + "success_url": success_url, + "cancel_url": cancel_url, + "client_reference_id": str(user.id), + "customer_email": user.login, + "metadata[package_id]": str(package.id), + "metadata[user_id]": str(user.id), + } + + try: + resp = httpx.post( + f"{STRIPE_API_BASE}/checkout/sessions", + headers=self._headers(), + data=data, + timeout=30, + ) + resp.raise_for_status() + session = resp.json() + return {"sessionId": session["id"], "url": session["url"]} + except httpx.HTTPStatusError as e: + _logger.error("Stripe API error: %s", e.response.text) + return {"error": f"Stripe error: {e.response.status_code}"} + except Exception as e: + _logger.exception("Stripe checkout error") + return {"error": str(e)} + + def verify_webhook_signature(self, payload, sig_header): + if not self.webhook_secret: + _logger.warning("No Stripe webhook secret configured") + return False + + try: + elements = {k: v for k, v in ( + item.split("=", 1) for item in sig_header.split(",") + )} + timestamp = elements.get("t", "") + signature = elements.get("v1", "") + + signed_payload = f"{timestamp}.{payload}" + expected = hmac.new( + self.webhook_secret.encode("utf-8"), + signed_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(expected, signature): + return False + + if abs(time.time() - int(timestamp)) > 300: + _logger.warning("Stripe webhook timestamp too old") + return False + + return True + except Exception: + _logger.exception("Stripe webhook signature verification failed") + return False + + def handle_webhook(self, payload, sig_header): + if not self.verify_webhook_signature(payload, sig_header): + return {"error": "Invalid signature"} + + event = json.loads(payload) + event_type = event.get("type") + + if event_type == "checkout.session.completed": + session = event["data"]["object"] + user_id = int(session.get("client_reference_id", 0)) + package_id = int(session.get("metadata", {}).get("package_id", 0)) + transaction_id = session.get("payment_intent") or session.get("id") + + if user_id and package_id: + self._complete_payment( + user_id=user_id, + package_id=package_id, + provider="stripe", + transaction_id=transaction_id, + amount=session.get("amount_total", 0) / 100.0, + currency=(session.get("currency") or "usd").upper(), + ) + + return {"ok": True} + + def _complete_payment(self, user_id, package_id, provider, transaction_id, amount, currency): + from .subscription_helper import extend_subscription + extend_subscription( + self.env, user_id, package_id, provider, transaction_id, amount, currency, + ) diff --git a/encoach_subscription/services/subscription_helper.py b/encoach_subscription/services/subscription_helper.py new file mode 100644 index 0000000..8053782 --- /dev/null +++ b/encoach_subscription/services/subscription_helper.py @@ -0,0 +1,52 @@ +import logging +from datetime import datetime, timedelta + +_logger = logging.getLogger(__name__) + + +def extend_subscription(env, user_id, package_id, provider, transaction_id, amount, currency): + user = env["res.users"].sudo().browse(user_id) + if not user.exists(): + _logger.error("extend_subscription: user %s not found", user_id) + return + + package = env["encoach.package"].sudo().browse(package_id) + if not package.exists(): + _logger.error("extend_subscription: package %s not found", package_id) + return + + now = datetime.utcnow() + current_exp = user.subscription_expiration + if current_exp and current_exp > now: + base = current_exp + else: + base = now + new_exp = base + timedelta(days=package.duration_days or 30) + user.sudo().write({"subscription_expiration": new_exp}) + + env["encoach.subscription.payment"].sudo().create({ + "user_id": user_id, + "package_id": package_id, + "provider": provider, + "status": "completed", + "amount": amount, + "currency": currency, + "transaction_id": transaction_id, + }) + + if user.encoach_type in ("corporate", "mastercorporate"): + rels = env["encoach.user.entity.rel"].sudo().search([ + ("user_id", "=", user_id), + ]) + for rel in rels: + members = env["encoach.user.entity.rel"].sudo().search([ + ("entity_id", "=", rel.entity_id.id), + ("user_id", "!=", user_id), + ]) + for m in members: + m.user_id.sudo().write({"subscription_expiration": new_exp}) + + _logger.info( + "Subscription extended: user=%s package=%s provider=%s until=%s", + user_id, package_id, provider, new_exp, + ) diff --git a/encoach_subscription/views/discount_views.xml b/encoach_subscription/views/discount_views.xml new file mode 100644 index 0000000..cc3e57c --- /dev/null +++ b/encoach_subscription/views/discount_views.xml @@ -0,0 +1,58 @@ + + + + encoach.discount.tree + encoach.discount + + + + + + + + + + + + + encoach.discount.form + encoach.discount + +
+ + + + + + + + + + + + + +
+
+
+ + + Discount Codes + encoach.discount + list,form + +

+ Create your first discount code. +

+

+ Discount codes offer percentage-based discounts with optional expiry and usage limits. +

+
+
+ + +
diff --git a/encoach_subscription/views/package_views.xml b/encoach_subscription/views/package_views.xml new file mode 100644 index 0000000..1271f85 --- /dev/null +++ b/encoach_subscription/views/package_views.xml @@ -0,0 +1,61 @@ + + + + encoach.package.tree + encoach.package + + + + + + + + + + + + + encoach.package.form + encoach.package + +
+ + + + + + + + + + + + + + + + +
+
+
+ + + Packages + encoach.package + list,form + +

+ Create your first subscription package. +

+

+ Packages define pricing, duration, and features for EnCoach subscriptions. +

+
+
+ + +
diff --git a/encoach_subscription/views/payment_views.xml b/encoach_subscription/views/payment_views.xml new file mode 100644 index 0000000..d02477a --- /dev/null +++ b/encoach_subscription/views/payment_views.xml @@ -0,0 +1,65 @@ + + + + encoach.subscription.payment.tree + encoach.subscription.payment + + + + + + + + + + + + + + + + encoach.subscription.payment.form + encoach.subscription.payment + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + + Payments + encoach.subscription.payment + list,form + +

+ No payments yet. +

+

+ Payment records track Stripe, PayPal, and Paymob transactions for subscription purchases. +

+
+
+ + +
diff --git a/encoach_ticket/__init__.py b/encoach_ticket/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/encoach_ticket/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/encoach_ticket/__manifest__.py b/encoach_ticket/__manifest__.py new file mode 100644 index 0000000..47a27da --- /dev/null +++ b/encoach_ticket/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "EnCoach Ticket", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Support ticket system for EnCoach", + "description": "Feedback, bug reports, and help requests from users.", + "depends": ["encoach_core"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/ticket_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_ticket/models/__init__.py b/encoach_ticket/models/__init__.py new file mode 100644 index 0000000..d5f74ce --- /dev/null +++ b/encoach_ticket/models/__init__.py @@ -0,0 +1 @@ +from . import ticket diff --git a/encoach_ticket/models/ticket.py b/encoach_ticket/models/ticket.py new file mode 100644 index 0000000..1d46eef --- /dev/null +++ b/encoach_ticket/models/ticket.py @@ -0,0 +1,53 @@ +from odoo import models, fields + + +class EncoachTicket(models.Model): + _name = "encoach.ticket" + _description = "EnCoach Support Ticket" + + reporter_name = fields.Char() + reporter_email = fields.Char() + subject = fields.Char(required=True) + description = fields.Text() + ticket_type = fields.Selection( + [ + ("feedback", "Feedback"), + ("bug", "Bug Report"), + ("help", "Help Request"), + ], + string="Type", + ) + status = fields.Selection( + [ + ("open", "Open"), + ("in_progress", "In Progress"), + ("resolved", "Resolved"), + ("closed", "Closed"), + ], + default="open", + ) + exam_information = fields.Json() + assigned_to_id = fields.Many2one( + "res.users", string="Assigned To", ondelete="set null", + ) + created_at = fields.Datetime(default=fields.Datetime.now) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "reporterName": self.reporter_name or "", + "reporterEmail": self.reporter_email or "", + "subject": self.subject or "", + "description": self.description or "", + "type": self.ticket_type or "", + "status": self.status, + "examInformation": self.exam_information, + "assignedTo": ( + self.assigned_to_id.name if self.assigned_to_id else None + ), + "createdAt": ( + self.created_at.isoformat() if self.created_at else None + ), + } diff --git a/encoach_ticket/security/ir.model.access.csv b/encoach_ticket/security/ir.model.access.csv new file mode 100644 index 0000000..ff08ea1 --- /dev/null +++ b/encoach_ticket/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_ticket_student,encoach.ticket.student,model_encoach_ticket,encoach_core.group_encoach_student,1,0,1,0 +access_encoach_ticket_teacher,encoach.ticket.teacher,model_encoach_ticket,encoach_core.group_encoach_teacher,1,1,1,0 +access_encoach_ticket_admin,encoach.ticket.admin,model_encoach_ticket,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_ticket_sysadmin,encoach.ticket.sysadmin,model_encoach_ticket,base.group_system,1,1,1,1 diff --git a/encoach_ticket/security/rules.xml b/encoach_ticket/security/rules.xml new file mode 100644 index 0000000..4072ecc --- /dev/null +++ b/encoach_ticket/security/rules.xml @@ -0,0 +1,17 @@ + + + + + Students see own tickets + + + [('create_uid', '=', user.id)] + + + Admins see all tickets + + + [(1, '=', 1)] + + + diff --git a/encoach_ticket/views/ticket_views.xml b/encoach_ticket/views/ticket_views.xml new file mode 100644 index 0000000..b9c74a0 --- /dev/null +++ b/encoach_ticket/views/ticket_views.xml @@ -0,0 +1,74 @@ + + + + encoach.ticket.tree + encoach.ticket + + + + + + + + + + + + + + + encoach.ticket.form + encoach.ticket + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Tickets + encoach.ticket + list,form + +

+ No support tickets yet. +

+

+ Tickets handle feedback, bug reports, and help requests from users. +

+
+
+ + +
diff --git a/encoach_training/__init__.py b/encoach_training/__init__.py new file mode 100644 index 0000000..71a0242 --- /dev/null +++ b/encoach_training/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import services diff --git a/encoach_training/__manifest__.py b/encoach_training/__manifest__.py new file mode 100644 index 0000000..a317d11 --- /dev/null +++ b/encoach_training/__manifest__.py @@ -0,0 +1,17 @@ +{ + "name": "EnCoach Training", + "version": "19.0.1.0.0", + "category": "Education", + "summary": "Personalized training content and tips for EnCoach", + "description": "FAISS-powered tip retrieval, GPT personalized training, walkthroughs.", + "depends": ["encoach_core", "encoach_ai"], + "data": [ + "security/ir.model.access.csv", + "security/rules.xml", + "views/training_views.xml", + "views/tip_views.xml", + "views/walkthrough_views.xml", + ], + "installable": True, + "license": "LGPL-3", +} diff --git a/encoach_training/models/__init__.py b/encoach_training/models/__init__.py new file mode 100644 index 0000000..97427cf --- /dev/null +++ b/encoach_training/models/__init__.py @@ -0,0 +1,3 @@ +from . import training +from . import training_tip +from . import walkthrough diff --git a/encoach_training/models/training.py b/encoach_training/models/training.py new file mode 100644 index 0000000..3490aa8 --- /dev/null +++ b/encoach_training/models/training.py @@ -0,0 +1,28 @@ +from odoo import models, fields + + +class EncoachTraining(models.Model): + _name = "encoach.training" + _description = "EnCoach Training Record" + + user_id = fields.Many2one( + "res.users", string="User", required=True, ondelete="cascade", + ) + exams = fields.Json() + tips = fields.Json() + weak_areas = fields.Json() + created_at = fields.Datetime(default=fields.Datetime.now) + legacy_id = fields.Char(index=True) + + def to_encoach_dict(self): + self.ensure_one() + return { + "id": self.id, + "userId": self.user_id.id if self.user_id else None, + "exams": self.exams, + "tips": self.tips, + "weakAreas": self.weak_areas, + "createdAt": ( + self.created_at.isoformat() if self.created_at else None + ), + } diff --git a/encoach_training/models/training_tip.py b/encoach_training/models/training_tip.py new file mode 100644 index 0000000..57565b5 --- /dev/null +++ b/encoach_training/models/training_tip.py @@ -0,0 +1,22 @@ +from odoo import models, fields + + +class EncoachTrainingTip(models.Model): + _name = "encoach.training.tip" + _description = "EnCoach Training Tip" + + tip_id = fields.Char(required=True, index=True) + category = fields.Selection( + [ + ("ct_focus", "CT Focus"), + ("language_for_writing", "Language for Writing"), + ("reading_skill", "Reading Skill"), + ("strategy", "Strategy"), + ("writing_skill", "Writing Skill"), + ("word_link", "Word Link"), + ("word_partners", "Word Partners"), + ], + string="Category", + ) + content = fields.Text(required=True) + embedding = fields.Binary(string="FAISS Embedding Vector") diff --git a/encoach_training/models/walkthrough.py b/encoach_training/models/walkthrough.py new file mode 100644 index 0000000..3b89f33 --- /dev/null +++ b/encoach_training/models/walkthrough.py @@ -0,0 +1,19 @@ +from odoo import models, fields + + +class EncoachWalkthrough(models.Model): + _name = "encoach.walkthrough" + _description = "EnCoach Walkthrough" + + name = fields.Char() + content = fields.Json() + module = fields.Selection( + [ + ("reading", "Reading"), + ("listening", "Listening"), + ("writing", "Writing"), + ("speaking", "Speaking"), + ("level", "Level"), + ], + string="Module", + ) diff --git a/encoach_training/security/ir.model.access.csv b/encoach_training/security/ir.model.access.csv new file mode 100644 index 0000000..2218f8b --- /dev/null +++ b/encoach_training/security/ir.model.access.csv @@ -0,0 +1,10 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_encoach_training_student,encoach.training.student,model_encoach_training,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_training_admin,encoach.training.admin,model_encoach_training,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_training_tip_student,encoach.training.tip.student,model_encoach_training_tip,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_training_tip_admin,encoach.training.tip.admin,model_encoach_training_tip,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_walkthrough_student,encoach.walkthrough.student,model_encoach_walkthrough,encoach_core.group_encoach_student,1,0,0,0 +access_encoach_walkthrough_admin,encoach.walkthrough.admin,model_encoach_walkthrough,encoach_core.group_encoach_admin,1,1,1,1 +access_encoach_training_sysadmin,encoach.training.sysadmin,model_encoach_training,base.group_system,1,1,1,1 +access_encoach_training_tip_sysadmin,encoach.training.tip.sysadmin,model_encoach_training_tip,base.group_system,1,1,1,1 +access_encoach_walkthrough_sysadmin,encoach.walkthrough.sysadmin,model_encoach_walkthrough,base.group_system,1,1,1,1 diff --git a/encoach_training/security/rules.xml b/encoach_training/security/rules.xml new file mode 100644 index 0000000..d3d2a97 --- /dev/null +++ b/encoach_training/security/rules.xml @@ -0,0 +1,17 @@ + + + + + Students see own training + + + [('user_id', '=', user.id)] + + + Admins see all training + + + [(1, '=', 1)] + + + diff --git a/encoach_training/services/__init__.py b/encoach_training/services/__init__.py new file mode 100644 index 0000000..8462044 --- /dev/null +++ b/encoach_training/services/__init__.py @@ -0,0 +1 @@ +from . import training_service diff --git a/encoach_training/services/training_service.py b/encoach_training/services/training_service.py new file mode 100644 index 0000000..1b1eae5 --- /dev/null +++ b/encoach_training/services/training_service.py @@ -0,0 +1,177 @@ +import json +import logging +import pickle + +import numpy as np + +from odoo.addons.encoach_ai.models.constants import GPT_MODELS, TEMPERATURE +from odoo.addons.encoach_ai.services.openai_service import EncoachOpenAIService + +_logger = logging.getLogger(__name__) + +TIPS_PROMPT = ( + "You are an IELTS tutor. Based on the context, the question, the " + "student's wrong answer, and the correct answer, provide a concise, " + "helpful tip to help the student improve. Use the following reference " + "tips to inform your response but adapt them to the specific situation.\n\n" + "Reference tips:\n{tips}\n\n" + "Context: {context}\n" + "Question: {question}\n" + "Student's answer: {answer}\n" + "Correct answer: {correct_answer}\n\n" + "Provide your response as JSON: " + '{{"tips": "your personalized tip text"}}' +) + +TRAINING_PROMPT = ( + "You are an IELTS training advisor. Analyze the student's exam history " + "and generate personalized training recommendations.\n\n" + "Student stats:\n{stats}\n\n" + "Generate a JSON response with:\n" + '{{"exams": [recommended practice exams], ' + '"tips": [personalized tips], ' + '"weakAreas": [identified weak areas with suggestions]}}' +) + + +class EncoachTrainingService: + + def __init__(self, env): + self.env = env + self.ai = EncoachOpenAIService(env) + self._faiss_index = None + self._tip_ids = None + + def fetch_tips(self, context, question, answer, correct_answer): + """Retrieve relevant tips using FAISS + generate personalized tip via GPT.""" + query = f"{context} {question} {answer}" + similar_tips = self._query_faiss(query, category=None, top_k=5) + + tips_text = "\n".join( + f"- [{t.category}] {t.content}" for t in similar_tips + ) + + prompt = TIPS_PROMPT.format( + tips=tips_text, + context=context, + question=question, + answer=answer, + correct_answer=correct_answer, + ) + + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=[ + {"role": "system", "content": "You are a helpful IELTS tutor. Output JSON."}, + {"role": "user", "content": prompt}, + ], + temperature=TEMPERATURE["tips"], + check_blacklisted=False, + ) + return result + + def get_training_content(self, user_id, stats): + """Generate full training content for a user based on their exam stats.""" + prompt = TRAINING_PROMPT.format(stats=json.dumps(stats)) + + result = self.ai.prediction( + model=GPT_MODELS["grading"], + messages=[ + {"role": "system", "content": "You are an IELTS training advisor. Output JSON."}, + {"role": "user", "content": prompt}, + ], + temperature=TEMPERATURE["tips"], + check_blacklisted=False, + ) + + if result: + training = self.env["encoach.training"].sudo().create({ + "user_id": user_id, + "exams": result.get("exams"), + "tips": result.get("tips"), + "weak_areas": result.get("weakAreas"), + }) + return training + return None + + def _query_faiss(self, query, category=None, top_k=5): + """Search FAISS index for similar training tips. + + Falls back to keyword search if FAISS is not available. + """ + try: + import faiss + from sentence_transformers import SentenceTransformer + except ImportError: + _logger.warning("FAISS/sentence-transformers not installed, falling back to keyword search") + return self._keyword_fallback(query, category, top_k) + + self._ensure_faiss_index() + if self._faiss_index is None: + return self._keyword_fallback(query, category, top_k) + + model = SentenceTransformer("all-MiniLM-L6-v2") + query_vec = model.encode([query]).astype(np.float32) + + distances, indices = self._faiss_index.search(query_vec, top_k * 2) + + tip_ids = [self._tip_ids[i] for i in indices[0] if i < len(self._tip_ids)] + domain = [("id", "in", tip_ids)] + if category: + domain.append(("category", "=", category)) + + tips = self.env["encoach.training.tip"].sudo().search(domain, limit=top_k) + return tips + + def _ensure_faiss_index(self): + """Build or load the FAISS index from stored tip embeddings.""" + if self._faiss_index is not None: + return + + try: + import faiss + except ImportError: + return + + tips = self.env["encoach.training.tip"].sudo().search( + [("embedding", "!=", False)] + ) + if not tips: + return + + vectors = [] + ids = [] + for tip in tips: + vec = pickle.loads(tip.embedding) + vectors.append(vec) + ids.append(tip.id) + + matrix = np.array(vectors, dtype=np.float32) + + dim = matrix.shape[1] + index = faiss.IndexFlatL2(dim) + index.add(matrix) + + self._faiss_index = index + self._tip_ids = ids + + def _keyword_fallback(self, query, category, top_k): + """Simple keyword-based search when FAISS is unavailable.""" + domain = [] + if category: + domain.append(("category", "=", category)) + + tips = self.env["encoach.training.tip"].sudo().search(domain, limit=200) + + query_words = set(query.lower().split()) + scored = [] + for tip in tips: + content_words = set(tip.content.lower().split()) + overlap = len(query_words & content_words) + if overlap > 0: + scored.append((overlap, tip)) + + scored.sort(key=lambda x: x[0], reverse=True) + return self.env["encoach.training.tip"].browse( + [t.id for _, t in scored[:top_k]] + ) diff --git a/encoach_training/views/tip_views.xml b/encoach_training/views/tip_views.xml new file mode 100644 index 0000000..4d291e6 --- /dev/null +++ b/encoach_training/views/tip_views.xml @@ -0,0 +1,54 @@ + + + + encoach.training.tip.tree + encoach.training.tip + + + + + + + + + + + encoach.training.tip.form + encoach.training.tip + +
+ + + + + + + + + + + +
+
+
+ + + Training Tips + encoach.training.tip + list,form + +

+ Create your first training tip. +

+

+ Training tips provide personalized advice for users based on categories like strategy, writing skills, and reading skills. +

+
+
+ + +
diff --git a/encoach_training/views/training_views.xml b/encoach_training/views/training_views.xml new file mode 100644 index 0000000..4442598 --- /dev/null +++ b/encoach_training/views/training_views.xml @@ -0,0 +1,65 @@ + + + + encoach.training.tree + encoach.training + + + + + + + + + + + encoach.training.form + encoach.training + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Training Records + encoach.training + list,form + +

+ Create your first training record. +

+

+ Training records store personalized exam data, tips, and weak areas for each user. +

+
+
+ + +
diff --git a/encoach_training/views/walkthrough_views.xml b/encoach_training/views/walkthrough_views.xml new file mode 100644 index 0000000..2dc8d1d --- /dev/null +++ b/encoach_training/views/walkthrough_views.xml @@ -0,0 +1,53 @@ + + + + encoach.walkthrough.tree + encoach.walkthrough + + + + + + + + + + encoach.walkthrough.form + encoach.walkthrough + +
+ + + + + + + + + + + +
+
+
+ + + Walkthroughs + encoach.walkthrough + list,form + +

+ Create your first walkthrough. +

+

+ Walkthroughs provide guided content for Reading, Listening, Writing, Speaking, and Level modules. +

+
+
+ + +
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c81fd3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +# EnCoach Odoo 19 -- Python dependencies +# Install into the Odoo server's Python environment: +# pip install -r requirements.txt + +# OpenAI API client (GPT-4o, GPT-3.5-turbo) +openai>=1.50 + +# Local Whisper model for speech-to-text +openai-whisper + +# AWS SDK for Polly TTS +boto3>=1.34 + +# FAISS vector search for training tips +faiss-cpu>=1.7 + +# Text embeddings for FAISS +sentence-transformers>=3.0 + +# HTTP client for ELAI and GPTZero API calls +httpx>=0.27 + +# Token counting for OpenAI prompts +tiktoken>=0.7 + +# Audio processing for Whisper +librosa>=0.10 +soundfile>=0.12 + +# Numerical operations +numpy>=1.26 + +# Retry logic for external API calls +tenacity>=8.2 + +# PyTorch (required by Whisper and sentence-transformers) +torch>=2.0 + +# JWT authentication for API controllers +PyJWT>=2.8 + +# MongoDB client (migration scripts only) +pymongo>=4.6