Compare commits
No commits in common. "faf9e758494328e33dc230d3572bff800ac2011a" and "a20b916aa8d97b0450b9c2161e4bc4b22955a111" have entirely different histories.
faf9e75849
...
a20b916aa8
21
.gitignore
vendored
21
.gitignore
vendored
@ -1,24 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
|
||||
# Environment and secrets
|
||||
.env
|
||||
.env.*
|
||||
*.key
|
||||
*.pem
|
||||
*.p12
|
||||
*.jks
|
||||
*.keystore
|
||||
key.properties
|
||||
|
||||
# Database dumps and backup archives
|
||||
*.dump
|
||||
*.sql
|
||||
*.tar.gz
|
||||
|
||||
# IDE and build artifacts
|
||||
.idea/
|
||||
.vscode/
|
||||
build/
|
||||
.dart_tool/
|
||||
|
||||
@ -1,242 +0,0 @@
|
||||
# Phase 11: Metatron Student ERP — Production Readiness & Security Audit Report
|
||||
|
||||
**Date:** October 15, 2026
|
||||
**Auditor:** Antigravity Autonomous Pair-Programming Agent
|
||||
**Environment:** Backend: Odoo 19 Community Edition (`metatron_academic`) | Database: PostgreSQL 15 (`metatron_aca_erp`) | Mobile: Flutter (`flutter_app/`)
|
||||
**Repository Branch:** `main`
|
||||
|
||||
---
|
||||
|
||||
## 1. Baseline Branch, Commit, and Working-Tree Status
|
||||
|
||||
- **Baseline Branch:** `main`
|
||||
- **Baseline HEAD Commit:** `1277f7a` (`feat(education_student): add secure profile and document management`)
|
||||
- **Baseline Git Status:**
|
||||
- 7 commits ahead of `origin/main` (`a20b916`).
|
||||
- Clean working tree prior to audit suite execution.
|
||||
- **Backend & Database Connectivity:**
|
||||
- Docker Container: `metatron_academic` running Odoo 19 Community Edition on port 10008.
|
||||
- PostgreSQL Container: `metatron_academic_db` hosting database `metatron_aca_erp`.
|
||||
- Connectivity confirmed via XML-RPC, JSON-RPC, HTTP session authentication, and direct Docker CLI.
|
||||
- **Test Identities Established for Audit:**
|
||||
- `demo.admin` (User ID: 5) — System Administrator
|
||||
- `demo.faculty` (User ID: 6) — Faculty / Academic Staff
|
||||
- `demo.student` (User ID: 7, Student ID: 789 / `STU-9999`) — Primary Student Tenant
|
||||
- `demo.student2` (User ID: 964, Student ID: 790 / `STU-9998`) — Secondary Independent Student Tenant (provisioned for cross-tenant isolation testing)
|
||||
|
||||
---
|
||||
|
||||
## 2. Modules and Endpoints Audited
|
||||
|
||||
### Modules Inspected
|
||||
- `education_core`: Base models, companies, academic levels, departments, batches, sections, semesters.
|
||||
- `education_student`: Student records, enrollment, parent/guardian relationships, documents, certificate requests & issuance workflows, QWeb reporting.
|
||||
- `education_attendance`: Attendance sessions, daily attendance lines, leave requests, attendance statistics.
|
||||
- `education_timetable`: Timetable slots, schedules, rooms, period allocations, faculty assignments.
|
||||
- `education_exam`: Exam schedules, grade scales, grading system, marks entry, evaluation, results publication.
|
||||
- `education_announcements`: Target-audience announcements (all, department, batch, student-specific).
|
||||
- `education_finance`: Fee structures, fee assignments, installment schedules, invoices, payments, financial ledger isolation.
|
||||
- `education_portal`: Web portal controllers, dashboard controllers, authentication wrappers.
|
||||
- `education_mobile_api`: Mobile JSON-RPC / REST controllers under `/api/mobile/v1/student/*`.
|
||||
- `education_demo_data`: Demo authentication endpoint (`/demo/login`), initial seed data.
|
||||
- `education_library`: Library members, book inventory, loan issues/returns, and reservations.
|
||||
- `education_transportation`: Transport routes, vehicles, stops, and student transport passes.
|
||||
|
||||
### Mobile API Endpoints Audited
|
||||
| Endpoint | Method | Purpose | Auth Enforced |
|
||||
|---|---|---|---|
|
||||
| `/api/mobile/v1/student/dashboard` | GET | Aggregated academic summary, metrics, upcoming classes/exams | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/profile` | GET / PUT | Read and update student profile (personal contact/address) | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/timetable` | GET | Filtered timetable slots by student's batch/section | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/attendance` | GET | Aggregated & session-level attendance history | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/leave-requests` | GET / POST | View and submit student leave requests | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/exams` | GET | Academic exams scheduled/ongoing/completed | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/exam-results` | GET | Published marks and grades | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/announcements`| GET | Targeted announcements for student | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/finance` | GET | Invoices, payment history, outstanding balance | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/documents` | GET / POST | Document listing and upload | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/documents/<id>/download` | GET | Download verification documents | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/certificates` | GET / POST | Certificate requests listing & submission | Yes (Session/Student) |
|
||||
| `/api/mobile/v1/student/certificates/<id>/download` | GET | Download generated certificate PDF | Yes (Session/Student) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Findings with Severity and Evidence
|
||||
|
||||
### [P1] Certificate PDF Generation Signature Mismatch in Odoo 19
|
||||
- **Severity:** P1 (Core Workflow Defect)
|
||||
- **Description:** In Odoo 19 Community Edition, `ir.actions.report._render_qweb_pdf(report_ref, res_ids=None, data=None)` expects `report_ref` as the first argument. Both `education_student/models/student_certificate.py` and `education_mobile_api/controllers/student.py` invoked `report.sudo()._render_qweb_pdf(rec.student_id.ids)` or `report.sudo()._render_qweb_pdf(student.ids)`, treating `student.ids` (a Python list) as the report reference argument.
|
||||
- **Evidence:** This raised `TypeError: unhashable type: 'list'` during certificate issuance from backend actions and produced 404 / 500 errors on mobile certificate download (`/api/mobile/v1/student/certificates/<id>/download`).
|
||||
- **Remediation:** Updated both the model and the mobile API controller to call `env['ir.actions.report'].sudo()._render_qweb_pdf(report_ref, student_ids)`. Confirmed live generation and download of valid 24,951-byte PDF (`application/pdf`) with HTTP 200.
|
||||
|
||||
### [P2] Unfiltered Draft/Cancelled Exam Visibility in Student API
|
||||
- **Severity:** P2 (Data Integrity / Business Rule Leakage)
|
||||
- **Description:** Endpoint `/api/mobile/v1/student/exams` searched exams by `program_id` and `academic_year_id` without filtering on workflow state, exposing draft, preparatory, or cancelled exams to students.
|
||||
- **Evidence:** Code review of `addons/education_mobile_api/controllers/student.py:431`.
|
||||
- **Remediation:** Added domain clause `('state', 'in', ['scheduled', 'ongoing', 'completed', 'results_published'])`.
|
||||
|
||||
### [P2] Mobile App Permissions Missing for Release Builds
|
||||
- **Severity:** P2 (Reliability / Platform Compliance)
|
||||
- **Description:**
|
||||
1. `flutter_app/android/app/src/main/AndroidManifest.xml` lacked `INTERNET` and `ACCESS_NETWORK_STATE` permissions in the main manifest, causing network operations to fail on Android release builds.
|
||||
2. `flutter_app/ios/Runner/Info.plist` lacked `NSPhotoLibraryUsageDescription` and `NSCameraUsageDescription`, causing iOS App Store rejections or runtime crashes upon student document/photo selection.
|
||||
- **Remediation:** Configured required Android permissions and descriptive iOS privacy usage strings in the native manifests.
|
||||
|
||||
### [P2] Test Regressions in Existing Odoo Test Suite
|
||||
- **Severity:** P2 (Test Suite Maintainability)
|
||||
- **Description:**
|
||||
1. `addons/education_demo_data/tests/test_demo_login.py`: `test_05_demo_login_missing_role` called `url_open('/demo/login', data={})`, triggering an unexpected 405 Method Not Allowed in Odoo 19 routing instead of testing payload validation.
|
||||
2. `addons/education_library/tests/test_library_core.py`: Static codes (`B2025`, `SEM1`, `SEC-A`) conflicted with pre-existing demo data in the operational test database.
|
||||
- **Remediation:** Adjusted the demo test to pass `data={'role': ''}` (testing empty role validation resulting in 403), and isolated library test fixture codes (`B_LIB_25`, `SEM_LIB_1`, `SEC_LIB_A`).
|
||||
|
||||
### [P3] Mobile API HTTP Header Typing
|
||||
- **Severity:** P3 (Minor Standards Compliance)
|
||||
- **Description:** `Content-Length` headers in document and certificate streaming responses passed integer values (`len(file_data)`) rather than strings in `request.make_response`.
|
||||
- **Remediation:** Formatted integer byte counts as string literals (`str(len(file_data))`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Changes Made and Files Affected
|
||||
|
||||
| File | Change Description |
|
||||
|---|---|
|
||||
| `addons/education_student/models/student_certificate.py` | Fixed `_render_qweb_pdf` call signature in certificate approval/issuance method. |
|
||||
| `addons/education_mobile_api/controllers/student.py` | Fixed on-the-fly certificate PDF generation, filtered exam states, and stringified `Content-Length` headers. |
|
||||
| `addons/education_demo_data/tests/test_demo_login.py` | Fixed empty-role payload assertion for demo login test. |
|
||||
| `addons/education_library/tests/test_library_core.py` | Isolated test fixture codes to prevent constraint collisions with existing database records. |
|
||||
| `flutter_app/android/app/src/main/AndroidManifest.xml` | Added `android.permission.INTERNET` and `android.permission.ACCESS_NETWORK_STATE`. |
|
||||
| `flutter_app/ios/Runner/Info.plist` | Added `NSPhotoLibraryUsageDescription` and `NSCameraUsageDescription`. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Exact Test Commands and Results
|
||||
|
||||
### 1. Comprehensive 55-Check Security & Multi-Tenant Audit Suite
|
||||
- **Command:** `python3 scratch/audit_suite.py` (executed against live Odoo 19 backend at `http://localhost:10008`)
|
||||
- **Result:** **55 / 55 PASSED, 0 FAILED**
|
||||
- **401 Unauthenticated Verification:** Verified dashboard, profile, timetable, attendance, leave requests, exams, results, announcements, finance, documents, certificates, document download, and certificate download all return 401 when unauthenticated.
|
||||
- **Staff Role Isolation:** Verified staff accounts cannot query student-scoped endpoints (returns 403 `FORBIDDEN` / "User is not linked to a student record").
|
||||
- **Cross-Tenant IDOR Prevention:** Verified Student 1 (`demo.student`) cannot access or download Student 2's documents or certificates by spoofing record IDs (returns 404 `DOCUMENT_NOT_FOUND` / `CERTIFICATE_NOT_FOUND`).
|
||||
- **Profile Tampering Resistance:** Verified students cannot modify protected fields (`name`, `enrollment_no`, `batch_id`, `department_id`, `program_id`, `status`). Only whitelisted personal contact information is modifiable.
|
||||
- **Document & Certificate Validation:** Verified unsupported MIME types and empty files are rejected with 400; verified duplicate certificate requests are rejected with 400.
|
||||
- **Financial Ledger Isolation:** Verified fees, invoices, and payment histories are strictly bound to the authenticated user's student ID; zero cross-tenant financial leakage.
|
||||
|
||||
### 2. Odoo Automated Test Suite
|
||||
- **Command:**
|
||||
```bash
|
||||
docker exec metatron_academic odoo -d metatron_aca_erp \
|
||||
--test-tags /education_student,/education_attendance,/education_exam,/education_finance,/education_timetable,/education_portal,/education_demo_data,/education_library \
|
||||
--stop-after-init --log-level=test
|
||||
```
|
||||
- **Result:** **70 / 70 tests PASSED, 0 failed, 0 errors**
|
||||
- `education_student`: 11 tests passed
|
||||
- `education_attendance`: 9 tests passed
|
||||
- `education_exam`: 12 tests passed
|
||||
- `education_finance`: 10 tests passed
|
||||
- `education_timetable`: 6 tests passed
|
||||
- `education_portal`: 8 tests passed
|
||||
- `education_demo_data`: 5 tests passed
|
||||
- `education_library`: 9 tests passed
|
||||
|
||||
### 3. Flutter Code Analysis
|
||||
- **Command:** `flutter analyze`
|
||||
- **Result:** **No issues found! (ran in 1.4s)**
|
||||
|
||||
### 4. Flutter Unit & Widget Tests
|
||||
- **Command:** `flutter test`
|
||||
- **Result:** **All 64 tests passed!**
|
||||
- Auth state notifier and session persistence: Passed
|
||||
- Student profile, documents, and certificate request flows: Passed
|
||||
- Attendance session selection, summary widgets, and leave requests: Passed
|
||||
- Exam listing, result publication views, and grade badges: Passed
|
||||
- Timetable grid and day-view widgets: Passed
|
||||
- Announcements list and unread counters: Passed
|
||||
- Finance fees overview, invoice listing, and payment history: Passed
|
||||
|
||||
### 5. Flutter Live Integration Tests
|
||||
- **Command:** `flutter test test/live_odoo_integration_test.dart`
|
||||
- **Result:** **All 12 live end-to-end integration tests passed!**
|
||||
- Authenticated live session login with `demo.student`
|
||||
- Fetch dashboard metrics and timetable
|
||||
- Fetch attendance summary and sessions
|
||||
- Fetch exams and published results
|
||||
- Fetch announcements
|
||||
- Fetch fee assignments and payment history
|
||||
- Fetch documents and certificate requests
|
||||
|
||||
---
|
||||
|
||||
## 6. Tests Not Run and the Reason
|
||||
|
||||
1. **Payment Gateway Webhook Execution:**
|
||||
- *Reason:* Phase 11 explicitly prohibits introducing third-party payment gateways (e.g., Stripe, Razorpay) or live transaction pipelines. Financial testing was restricted to Odoo's internal invoice and payment registration workflows.
|
||||
2. **Push Notification APNs/FCM Delivery:**
|
||||
- *Reason:* Live device tokens and Firebase/APNs cloud credentials are not provisioned in the local Docker/simulator environment. Announcement retrieval was verified via authenticated API polling.
|
||||
3. **Full Physical Device Biometric Sensor Verification:**
|
||||
- *Reason:* Biometric authentication checks rely on platform mock channels; physical fingerprint/face sensor testing requires hardware deployment.
|
||||
|
||||
---
|
||||
|
||||
## 7. Remaining Security Risks and Production Blockers
|
||||
|
||||
### Remaining Security Risks
|
||||
1. **Odoo Master Password & Secret Key Rotation:**
|
||||
- The test environment utilizes default database master credentials (`admin`). For production deployment, `admin_passwd` in `odoo.conf` must be replaced with a cryptographically secure, vaulted passphrase.
|
||||
2. **Reverse Proxy & SSL Termination:**
|
||||
- Odoo is currently binding directly to port 10008 without HTTPS. Production environments must place Odoo behind an Nginx or Traefik reverse proxy configured with TLS 1.3, HSTS headers, and strict rate-limiting on `/web/session/authenticate` and `/api/mobile/*`.
|
||||
3. **File Upload Antivirus Scanning:**
|
||||
- Mobile document uploads validate file extensions and MIME types up to 10MB. In high-security production deployments, uploaded attachments should be asynchronously scanned via ClamAV or equivalent before storage in `filestore`.
|
||||
|
||||
### Production Blockers (Pre-Launch Checklist)
|
||||
- [ ] Configure production base URL in Flutter app (`AppConfig.prod`).
|
||||
- [ ] Sign Android App Bundle (`.aab`) with production release keystore.
|
||||
- [ ] Configure Apple Developer distribution provisioning profile and certificates.
|
||||
- [ ] Provision automated daily PostgreSQL database dump and filestore snapshots.
|
||||
|
||||
---
|
||||
|
||||
## 8. Backup, Staging, Deployment, and Rollback Recommendations
|
||||
|
||||
### Backup Procedures
|
||||
- **Database Backup:**
|
||||
```bash
|
||||
docker exec -t metatron_academic_db pg_dump -U odoo -Fc metatron_aca_erp > backup_metatron_aca_erp_$(date +%Y%m%d_%H%M%S).dump
|
||||
```
|
||||
- **Filestore Backup:**
|
||||
```bash
|
||||
tar -czf filestore_backup_$(date +%Y%m%d_%H%M%S).tar.gz /var/lib/odoo/filestore/metatron_aca_erp
|
||||
```
|
||||
|
||||
### Staging Verification
|
||||
1. Restore database dump to a staging container with an isolated database name.
|
||||
2. Apply module updates:
|
||||
```bash
|
||||
odoo -u education_student,education_mobile_api,education_demo_data,education_library -d staging_db --stop-after-init
|
||||
```
|
||||
3. Run `python3 scratch/audit_suite.py` against staging URL.
|
||||
|
||||
### Rollback Strategy
|
||||
- **Code Rollback:**
|
||||
```bash
|
||||
git revert e5d3fdc -m "Revert hardening commit"
|
||||
```
|
||||
- **Database Rollback:** If a migration issue occurs, restore the snapshot using `pg_restore -U odoo -d metatron_aca_erp --clean <backup_file>.dump`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Final Commit Hash and Unpushed Commit Count
|
||||
|
||||
- **Audit Commit Hash:** `e5d3fdc`
|
||||
- **Commit Message:** `fix(education): harden production readiness and access controls`
|
||||
- **Total Local Unpushed Commits Ahead of `origin/main`:** **8 commits**
|
||||
1. `e5d3fdc` — `fix(education): harden production readiness and access controls`
|
||||
2. `1277f7a` — `feat(education_student): add secure profile and document management`
|
||||
3. `ff2d790` — `feat(education_finance): integrate student fees and payment tracking`
|
||||
4. `bce010f` — `feat(education_announcements): integrate student notices and notifications`
|
||||
5. `1d40349` — `feat(education_timetable): complete schedule management and mobile integration`
|
||||
6. `232e22e` — `feat(education_attendance): complete attendance workflow and mobile integration`
|
||||
7. `2b4b4ae` — `feat(education_exam): implement marks entry, grading, and secure result publication`
|
||||
8. `69d69f0` — `feat(flutter_app): initialize Phase 4 Flutter mobile application with Riverpod, Dio, GoRouter, Material 3, and live Odoo session auth`
|
||||
|
||||
---
|
||||
*Audit successfully completed. Standing by for Phase 12 instructions.*
|
||||
@ -1,269 +0,0 @@
|
||||
# Phase 12: Metatron Student ERP — Deployment and Mobile App Release Preparation Report
|
||||
|
||||
**Date:** October 15, 2026
|
||||
**Auditor/Release Engineer:** Antigravity Autonomous Agent
|
||||
**Environment:** Backend: Odoo 19 Community Edition (`metatron_academic`) | Database: PostgreSQL 15 (`metatron_aca_erp`) | Mobile App: Flutter (`flutter_app/`)
|
||||
**Current Branch:** `main`
|
||||
|
||||
---
|
||||
|
||||
## 1. Baseline Branch, Commit, Working-Tree Status, and Unpushed Commits
|
||||
|
||||
- **Active Branch:** `main`
|
||||
- **Release Preparation HEAD Commit:** `921ddc9`
|
||||
- Message: `chore(release): configure deployment architecture, environment security, and release signing`
|
||||
- **Preceding Audit Baseline Commit:** `e5d3fdc` (`fix(education): harden production readiness and access controls`)
|
||||
- **Remote Tracking:** `origin/main` (at `a20b916` / `mobile_api`)
|
||||
- **Unpushed Commits:** **9 commits ahead of `origin/main`** (no automatic push performed):
|
||||
1. `921ddc9` — `chore(release): configure deployment architecture, environment security, and release signing`
|
||||
2. `e5d3fdc` — `fix(education): harden production readiness and access controls`
|
||||
3. `1277f7a` — `feat(education_student): add secure profile and document management`
|
||||
4. `ff2d790` — `feat(education_finance): integrate student fees and payment tracking`
|
||||
5. `bce010f` — `feat(education_announcements): integrate student notices and notifications`
|
||||
6. `1d40349` — `feat(education_timetable): complete schedule management and mobile integration`
|
||||
7. `232e22e` — `feat(education_attendance): complete attendance workflow and mobile integration`
|
||||
8. `2b4b4ae` — `feat(education_exam): implement marks entry, grading, and secure result publication`
|
||||
9. `69d69f0` — `feat(flutter_app): initialize Phase 4 Flutter mobile application with Riverpod, Dio, GoRouter, Material 3, and live Odoo session auth`
|
||||
- **Working Tree Status:** Clean (all release preparation configs and manifests tracked/committed; report file generated).
|
||||
|
||||
---
|
||||
|
||||
## 2. Security and Configuration Findings
|
||||
|
||||
1. **Root Repository Hygiene:**
|
||||
- **Finding:** The root `.gitignore` only ignored `.pyc` and `.DS_Store`. Local database dumps, private keys, `.env` files, and keystores were vulnerable to accidental tracking.
|
||||
- **Remediation:** Hardened `.gitignore` to strictly ignore `.env*`, `*.dump`, `*.sql`, `*.tar.gz`, `*.key`, `*.pem`, `*.p12`, `*.jks`, `*.keystore`, `key.properties`, and build artifacts.
|
||||
2. **API Endpoint & Host Hardcoding:**
|
||||
- **Finding:** `ApiConfig` previously fell back to `http://localhost:10008` even in release builds if `--dart-define=API_BASE_URL` was omitted.
|
||||
- **Remediation:** Implemented `AppEnvironment` (`development`, `staging`, `production`). In production mode, fallback to localhost is prohibited; requests fail cleanly with a structured `NOT_CONFIGURED` exception prompting the operator or user to set the API endpoint in Settings.
|
||||
3. **HTTPS Enforcement:**
|
||||
- **Finding:** Release builds could theoretically connect over unencrypted cleartext HTTP if an insecure URL was supplied.
|
||||
- **Remediation:** Added Android Network Security Configuration (`res/xml/network_security_config.xml`) that disallows cleartext HTTP traffic across all domains by default, permitting HTTP only for local loopbacks (`localhost`, `10.0.2.2`) during debug/development.
|
||||
4. **Credential Scanning:**
|
||||
- **Finding:** Verified all tracked source code in `addons/` and `flutter_app/`. No actual production private keys, production passwords, or third-party secret tokens are committed. Only field schemas and mock fallbacks for unit tests exist.
|
||||
5. **Debug Output Sanitization:**
|
||||
- **Finding:** `developer.log` and request interceptors in `ApiClient` are strictly gated with `if (kDebugMode)`, ensuring no sensitive student profiles, session tokens, or transaction payloads are emitted into device system logs in release builds.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend Deployment Requirements
|
||||
|
||||
### Architecture Overview
|
||||
The production deployment decouples services into three tiers:
|
||||
1. **Edge Reverse Proxy (Nginx/Traefik):** Handles TLS 1.3 / HTTPS termination, rate limiting, request buffering, and static caching.
|
||||
2. **Application Tier (Odoo 19 Community):** Runs in multiprocessing worker mode (`workers = 5`), with separated longpolling port 8072 for websocket events.
|
||||
3. **Database Tier (PostgreSQL 15):** Placed on an internal Docker bridge network with no external port exposure.
|
||||
|
||||
### Key Deployment Files Created
|
||||
- **`deploy/docker-compose.prod.yml`**: Production compose configuration with container health checks, log rotation (`json-file`, max 50MB x 5), internal network isolation, and environment-driven credentials.
|
||||
- **`deploy/odoo.prod.conf`**: Hardened Odoo configuration with `list_db = False` (disabling database manager), `proxy_mode = True`, memory soft/hard limits, and CPU limits.
|
||||
- **`deploy/nginx/metatron_erp.conf`**: Production Nginx configuration with HTTP-to-HTTPS 301 redirection, HSTS, security headers, 25MB body limit for student uploads, and WebSocket proxying.
|
||||
- **`deploy/.env.example`**: Environment variable template for database credentials, domain names, and worker counts.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backup and Recovery Instructions
|
||||
|
||||
A standalone automated utility is established at `deploy/backup_restore.sh`.
|
||||
|
||||
### Manual Backup Commands
|
||||
```bash
|
||||
# 1. Database backup (compressed custom format)
|
||||
docker exec -t metatron_academic_db pg_dump -U odoo -Fc metatron_aca_erp > metatron_aca_erp_$(date +%Y%m%d_%H%M%S).dump
|
||||
|
||||
# 2. Filestore archive (attachments, student photos, certificate PDFs)
|
||||
docker exec metatron_academic tar -czf - -C /var/lib/odoo/filestore metatron_aca_erp > filestore_metatron_aca_erp_$(date +%Y%m%d_%H%M%S).tar.gz
|
||||
```
|
||||
|
||||
### Automated Utility Usage
|
||||
```bash
|
||||
# Full backup (creates dump, tar.gz archive, and SHA256 checksums):
|
||||
./deploy/backup_restore.sh backup /path/to/backup_destination
|
||||
|
||||
# Verification of backup archives:
|
||||
./deploy/backup_restore.sh verify /path/to/backup.dump /path/to/filestore.tar.gz
|
||||
|
||||
# Controlled restore to an isolated staging database:
|
||||
./deploy/backup_restore.sh restore /path/to/backup.dump /path/to/filestore.tar.gz metatron_aca_erp_staging
|
||||
```
|
||||
|
||||
*Verification test: Verified against live container `metatron_academic_db` and `metatron_academic`. Successfully generated valid 7.26 MB database dump and 15.18 MB filestore archive.*
|
||||
|
||||
---
|
||||
|
||||
## 5. Development, Staging, and Production API Configuration
|
||||
|
||||
The mobile app supports dynamic compile-time environment flags via `--dart-define`:
|
||||
|
||||
| Environment | Build Flag | Target URL Example | Cleartext Permitted |
|
||||
|---|---|---|---|
|
||||
| **Development** | Default (no flag) | `http://10.0.2.2:10008` (Android) / `http://localhost:10008` | Yes (Emulator loopback) |
|
||||
| **Staging** | `--dart-define=ENVIRONMENT=staging --dart-define=API_BASE_URL=https://staging-erp.yourdomain.edu` | `https://staging-erp.yourdomain.edu` | No |
|
||||
| **Production** | `--dart-define=ENVIRONMENT=production --dart-define=API_BASE_URL=https://erp.yourdomain.edu` | `https://erp.yourdomain.edu` | No (HTTPS enforced) |
|
||||
|
||||
### Runtime Fallback & Settings Screen
|
||||
If built without compile-time URL definitions or when connecting to a local campus network server, students or testing personnel can navigate to **Settings Screen -> API Server Address** and configure the custom base URL directly in the app.
|
||||
|
||||
---
|
||||
|
||||
## 6. Android Build and Signing Instructions
|
||||
|
||||
### Package & Configuration Summary
|
||||
- **Package Name / Application ID:** `in.metatroncube.metatron_student_erp`
|
||||
- **Application Label:** `Metatron Student ERP`
|
||||
- **Min SDK:** 21 (Android 5.0 Lollipop)
|
||||
- **Target SDK:** 34 (Android 14)
|
||||
- **Version:** `1.0.0+1` (defined in `pubspec.yaml`)
|
||||
- **Permissions Declared:**
|
||||
- `android.permission.INTERNET`
|
||||
- `android.permission.ACCESS_NETWORK_STATE`
|
||||
- **Network Security Config:** `flutter_app/android/app/src/main/res/xml/network_security_config.xml`
|
||||
|
||||
### Production Keystore Configuration
|
||||
1. Generate release keystore (keep outside git repository):
|
||||
```bash
|
||||
keytool -genkey -v -keystore ~/metatron-release-keystore.jks -keyalg RSA -keysize 2048 -validity 10000 -alias metatron_release
|
||||
```
|
||||
2. Create `flutter_app/android/key.properties` from template:
|
||||
```bash
|
||||
cp flutter_app/android/key.properties.example flutter_app/android/key.properties
|
||||
```
|
||||
3. Populate with your private credentials:
|
||||
```properties
|
||||
storePassword=your_keystore_password
|
||||
keyPassword=your_key_password
|
||||
keyAlias=metatron_release
|
||||
storeFile=/absolute/path/to/metatron-release-keystore.jks
|
||||
```
|
||||
*(Note: `build.gradle.kts` automatically loads `key.properties` when present, and safely defaults to debug signing for internal QA if absent).*
|
||||
|
||||
### Release Build Commands
|
||||
```bash
|
||||
cd flutter_app
|
||||
|
||||
# 1. Google Play Store Release (Android App Bundle - AAB):
|
||||
flutter build appbundle --release \
|
||||
--dart-define=ENVIRONMENT=production \
|
||||
--dart-define=API_BASE_URL=https://erp.yourdomain.edu
|
||||
|
||||
# 2. Direct Distribution / Internal Staging QA (Split Per-ABI APKs):
|
||||
flutter build apk --release --split-per-abi \
|
||||
--dart-define=ENVIRONMENT=staging \
|
||||
--dart-define=API_BASE_URL=https://staging-erp.yourdomain.edu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. iOS Build and Signing Instructions
|
||||
|
||||
### Package & Configuration Summary
|
||||
- **Bundle Identifier:** `in.metatroncube.metatronStudentErp`
|
||||
- **Display Name:** `Metatron Student ERP`
|
||||
- **Deployment Target:** iOS 12.0+
|
||||
- **Privacy Usage Descriptions:**
|
||||
- `NSPhotoLibraryUsageDescription`: Required for uploading student profile photos and verification certificates.
|
||||
- `NSCameraUsageDescription`: Required for capturing physical identity documents and verification photos.
|
||||
- **App Transport Security (ATS):** Strict HTTPS enforcement by default.
|
||||
|
||||
### Archiving and App Store Connect Submission Steps
|
||||
1. Open the iOS workspace in Xcode:
|
||||
```bash
|
||||
open flutter_app/ios/Runner.xcworkspace
|
||||
```
|
||||
2. In **Signing & Capabilities**, select your Apple Developer Team and Provisioning Profile.
|
||||
3. Build the release archive from CLI or Xcode:
|
||||
```bash
|
||||
cd flutter_app
|
||||
flutter build ipa --release \
|
||||
--dart-define=ENVIRONMENT=production \
|
||||
--dart-define=API_BASE_URL=https://erp.yourdomain.edu
|
||||
```
|
||||
4. Upload to App Store Connect / TestFlight:
|
||||
```bash
|
||||
xcrun altool --upload-app --type ios -f build/ios/ipa/*.ipa --apiKey <API_KEY> --apiIssuer <ISSUER_ID>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Test and Build Results
|
||||
|
||||
### Automated Test Matrix
|
||||
| Test Suite | Command | Result | Notes |
|
||||
|---|---|---|---|
|
||||
| **Flutter Static Analysis** | `flutter analyze` | **PASSED** (0 issues) | Strict type checking & zero lints |
|
||||
| **Flutter Unit & Widget Tests** | `flutter test` | **PASSED** (64 / 64 passed) | Full coverage of UI widgets, providers, and state models |
|
||||
| **Flutter Live Integration** | `flutter test test/live_odoo_integration_test.dart` | **PASSED** (12 / 12 passed) | End-to-end auth, profile, timetable, exams, fees, documents, certificates |
|
||||
| **Odoo Backend Automated Tests** | `odoo --test-tags /education_*` | **PASSED** (70 / 70 passed) | 0 failed, 0 errors across all 8 modules |
|
||||
| **Flutter Web Release Build** | `flutter build web --release` | **PASSED** (`✓ Built build/web`) | Verified tree-shaking and production compilation |
|
||||
| **Android Release Build** | `flutter build apk --release` | **ENVIRONMENT-BLOCKED** | Host machine lacks local Android SDK toolchain |
|
||||
| **Android App Bundle** | `flutter build appbundle --release`| **ENVIRONMENT-BLOCKED** | Host machine lacks local Android SDK toolchain |
|
||||
| **iOS Release Archive** | `flutter build ipa --release` | **ENVIRONMENT-BLOCKED** | Host machine lacks full Xcode developer installation & CocoaPods |
|
||||
|
||||
---
|
||||
|
||||
## 9. Required Privacy Policies and Store Submission Prerequisites
|
||||
|
||||
### Play Store & App Store Compliance Checklist
|
||||
1. **Privacy Policy URL:**
|
||||
- Must be hosted publicly (e.g. `https://yourdomain.edu/privacy-policy`).
|
||||
- Must state that student personal data (name, email, phone, enrollment ID) is collected exclusively for educational record administration and is not shared with third-party advertisers.
|
||||
2. **Account Deletion / Request Policy:**
|
||||
- App Store and Play Store require providing students an option or URL to request account closure or data rectification through the institution's registrar office.
|
||||
3. **Data Safety Form (Google Play Console):**
|
||||
- Personal info: Name, email, student ID collected for app functionality.
|
||||
- Financial info: Fee payment records (no credit card details stored on-device).
|
||||
- Photos and documents: Uploaded exclusively for institutional identity verification.
|
||||
- Encryption in transit: Yes (HTTPS/TLS 1.3).
|
||||
4. **App Store Review Demo Credentials:**
|
||||
- Provide active demo credentials (`demo.student`) in the App Store Review Information notes.
|
||||
|
||||
---
|
||||
|
||||
## 10. Outstanding Blockers and Recommended Next Actions
|
||||
|
||||
### Blockers Before Public Release
|
||||
1. **Production Domain & SSL Certificates:**
|
||||
- Real institutional domain (e.g. `erp.yourdomain.edu`) and valid Let's Encrypt / commercial SSL certificate are required.
|
||||
2. **CI/CD Signing Environment:**
|
||||
- Android release keystore (`key.properties`) must be provisioned in the CI/CD secret manager (GitHub Actions / GitLab CI).
|
||||
- Apple Developer Team membership and distribution certificate must be linked in Xcode.
|
||||
3. **Odoo Database Master Password:**
|
||||
- Replace placeholder master password in `deploy/odoo.prod.conf` with a securely vaulted secret.
|
||||
|
||||
---
|
||||
|
||||
## 11. Exact Commands for Eventual Deployment and Release
|
||||
|
||||
### Staging Deployment (Backend)
|
||||
```bash
|
||||
# 1. Clone repository to staging server
|
||||
git clone <REPO_URL> metatron_erp && cd metatron_erp
|
||||
|
||||
# 2. Configure environment
|
||||
cp deploy/.env.example deploy/.env
|
||||
# Edit deploy/.env with staging database password and domain
|
||||
|
||||
# 3. Launch staging containers
|
||||
docker compose -f deploy/docker-compose.prod.yml --env-file deploy/.env up -d
|
||||
|
||||
# 4. Restore sanitized database backup
|
||||
./deploy/backup_restore.sh restore /path/to/staging_backup.dump /path/to/filestore.tar.gz metatron_aca_erp
|
||||
```
|
||||
|
||||
### Production Release (Mobile)
|
||||
```bash
|
||||
cd flutter_app
|
||||
|
||||
# Android App Bundle for Google Play
|
||||
flutter build appbundle --release \
|
||||
--dart-define=ENVIRONMENT=production \
|
||||
--dart-define=API_BASE_URL=https://erp.yourdomain.edu
|
||||
|
||||
# iOS IPA for App Store / TestFlight
|
||||
flutter build ipa --release \
|
||||
--dart-define=ENVIRONMENT=production \
|
||||
--dart-define=API_BASE_URL=https://erp.yourdomain.edu
|
||||
```
|
||||
|
||||
---
|
||||
*Phase 12 Deployment and Mobile App Release Preparation complete. Awaiting user review and authorization.*
|
||||
@ -1,299 +0,0 @@
|
||||
# Phase 13: Metatron Student ERP — Android Build Environment Setup and Release APK Report
|
||||
|
||||
**Date:** October 15, 2026
|
||||
**Auditor/Build Engineer:** Antigravity Autonomous Agent
|
||||
**Host Architecture:** macOS 26.6.2 (Darwin arm64 - Apple Silicon)
|
||||
**Flutter SDK:** Flutter 3.47.7 (Channel stable, Dart 3.13.5)
|
||||
**Backend:** Odoo 19 Community Edition (`metatron_academic`) | PostgreSQL 15 (`metatron_aca_erp`)
|
||||
**Repository Branch:** `main` (HEAD: `921ddc9`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary and Build Status
|
||||
|
||||
An in-depth inspection of the Android build environment and Flutter mobile application (`flutter_app/`) was performed to evaluate readiness for Android Release APK and Android App Bundle (AAB) builds.
|
||||
|
||||
| Check / Phase | Status | Details |
|
||||
|---|---|---|
|
||||
| **Flutter Static Analysis** | **PASSED** | `flutter analyze` reported 0 issues |
|
||||
| **Flutter Unit & Widget Tests** | **PASSED** | 64 / 64 unit and widget tests passed |
|
||||
| **Flutter Live Integration Tests** | **PASSED** | 12 / 12 end-to-end tests passed against live backend |
|
||||
| **Android Application Configuration** | **VERIFIED** | App ID, permissions, labels, and network security verified |
|
||||
| **Environment & HTTPS Validation** | **VERIFIED** | `AppEnvironment` enforces HTTPS, rejects localhost in prod |
|
||||
| **Release Signing Architecture** | **VERIFIED** | Dynamic `key.properties` loading with debug fallback |
|
||||
| **Host Toolchain Check (Java / Android SDK)** | **MISSING** | No JDK or Android SDK installed on this macOS host |
|
||||
| **Release APK Build Attempt** | **ENV-BLOCKED** | Blocked by missing Android SDK toolchain |
|
||||
| **Release App Bundle (AAB) Attempt** | **ENV-BLOCKED** | Blocked by missing Android SDK toolchain |
|
||||
|
||||
---
|
||||
|
||||
## 2. Host Environment Inspection
|
||||
|
||||
### Flutter Toolchain Status (`flutter doctor -v`)
|
||||
```text
|
||||
[✓] Flutter (Channel stable, 3.47.7, on macOS 26.6.2 25G83 darwin-arm64, locale en-IN)
|
||||
• Flutter version 3.47.7 on channel stable at /opt/homebrew/share/flutter
|
||||
• Framework revision abaf9c5237 (2026-10-08 09:51:21 -0700)
|
||||
• Engine revision deb287481e
|
||||
• Dart version 3.13.5
|
||||
• DevTools version 2.60.0
|
||||
|
||||
[✗] Android toolchain - develop for Android devices
|
||||
✗ Unable to locate Android SDK.
|
||||
Install Android Studio from: https://developer.android.com/studio/index.html
|
||||
On first launch it will assist you in installing the Android SDK components.
|
||||
(or visit https://flutter.dev/to/macos-android-setup for detailed instructions).
|
||||
If the Android SDK has been installed to a custom location, please use
|
||||
`flutter config --android-sdk` to update to that location.
|
||||
|
||||
[!] Xcode - develop for iOS and macOS
|
||||
✗ Xcode installation is incomplete; full installation required for iOS/macOS.
|
||||
! CocoaPods not installed.
|
||||
|
||||
[✓] Chrome - develop for the web
|
||||
[✓] Connected device (2 available: macOS desktop, Chrome)
|
||||
[✓] Network resources: Available
|
||||
```
|
||||
|
||||
### Detailed Tool Discovery Results
|
||||
1. **Java Runtime:**
|
||||
- `/usr/bin/java`: Returns `The operation couldn’t be completed. Unable to locate a Java Runtime.`
|
||||
- `/usr/libexec/java_home -V`: No Java virtual machines installed in `/Library/Java/JavaVirtualMachines/`.
|
||||
- `JAVA_HOME`: Unset.
|
||||
2. **Android Studio:**
|
||||
- `/Applications/Android Studio.app`: Not installed.
|
||||
- `~/Applications/Android Studio.app`: Not installed.
|
||||
3. **Android SDK / Command-Line Tools:**
|
||||
- `~/Library/Android/sdk`: Directory does not exist.
|
||||
- `/Library/Android`: Directory does not exist.
|
||||
- `/opt/homebrew/share/android-commandlinetools`: Not installed.
|
||||
- `ANDROID_HOME` / `ANDROID_SDK_ROOT`: Unset.
|
||||
|
||||
---
|
||||
|
||||
## 3. Exact Mac Installation and Configuration Steps
|
||||
|
||||
Because the host machine currently lacks Java and the Android SDK, the following two standard installation options are available. Choose **Option A (GUI - Recommended)** or **Option B (Headless CLI via Homebrew)**:
|
||||
|
||||
### Option A: Android Studio Installation (Recommended)
|
||||
1. **Download and Install Android Studio:**
|
||||
- Download the Apple Silicon DMG from [developer.android.com/studio](https://developer.android.com/studio).
|
||||
- Drag **Android Studio** into `/Applications`.
|
||||
2. **Launch the Setup Wizard:**
|
||||
- Open Android Studio. The Setup Wizard will automatically install:
|
||||
- Android SDK (API 34 / Android 14)
|
||||
- Android SDK Platform-Tools
|
||||
- Android SDK Build-Tools (34.0.0+)
|
||||
- OpenJDK bundled with Android Studio
|
||||
3. **Install Android SDK Command-line Tools:**
|
||||
- In Android Studio, go to **Settings (Preferences) -> Languages & Frameworks -> Android SDK -> SDK Tools**.
|
||||
- Check **Android SDK Command-line Tools (latest)** and click **Apply**.
|
||||
4. **Accept Android Licenses:**
|
||||
```bash
|
||||
flutter doctor --android-licenses
|
||||
```
|
||||
(Press `y` to accept all SDK licenses).
|
||||
|
||||
---
|
||||
|
||||
### Option B: Headless Command-Line Installation (via Homebrew)
|
||||
If you prefer configuring without the full Android Studio IDE:
|
||||
|
||||
1. **Install OpenJDK 17 and Command-line Tools via Homebrew:**
|
||||
```bash
|
||||
brew install openjdk@17
|
||||
brew install --cask android-commandlinetools
|
||||
```
|
||||
|
||||
2. **Configure Environment Variables in `~/.zshrc`:**
|
||||
```bash
|
||||
# Add to ~/.zshrc:
|
||||
export JAVA_HOME="/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"
|
||||
export ANDROID_HOME="/opt/homebrew/share/android-commandlinetools"
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||||
```
|
||||
Reload shell:
|
||||
```bash
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
3. **Install Required SDK Platforms and Build-Tools via `sdkmanager`:**
|
||||
```bash
|
||||
sdkmanager --install "platforms;android-34" "build-tools;34.0.0" "platform-tools"
|
||||
```
|
||||
|
||||
4. **Link Android SDK Path to Flutter and Accept Licenses:**
|
||||
```bash
|
||||
flutter config --android-sdk /opt/homebrew/share/android-commandlinetools
|
||||
flutter doctor --android-licenses
|
||||
```
|
||||
|
||||
5. **Verify Setup:**
|
||||
```bash
|
||||
flutter doctor -v
|
||||
```
|
||||
*(Confirmed target: `[✓] Android toolchain - develop for Android devices`)*.
|
||||
|
||||
---
|
||||
|
||||
## 4. Android App Configuration Verification
|
||||
|
||||
The application configuration in `flutter_app/` was inspected and verified against Google Play and production requirements:
|
||||
|
||||
### 1. Application Identity & Versioning
|
||||
- **Application ID / Package:** `in.metatroncube.metatron_student_erp` (defined in `android/app/build.gradle.kts`)
|
||||
- **Application Label:** `Metatron Student ERP` (defined in `android/app/src/main/AndroidManifest.xml`)
|
||||
- **Version Name:** `1.0.0` (from `pubspec.yaml`)
|
||||
- **Version Code:** `1` (from `pubspec.yaml`)
|
||||
- **Min SDK:** 21 (Android 5.0 Lollipop)
|
||||
- **Target / Compile SDK:** 34 (Android 14)
|
||||
- **JVM Target:** Java 17
|
||||
|
||||
### 2. Permissions Declared
|
||||
- `android.permission.INTERNET` (Network communication with Odoo backend)
|
||||
- `android.permission.ACCESS_NETWORK_STATE` (Network connectivity detection)
|
||||
|
||||
### 3. Network Security Configuration (`network_security_config.xml`)
|
||||
- Location: `flutter_app/android/app/src/main/res/xml/network_security_config.xml`
|
||||
- **Default Policy:** Strict HTTPS enforcement (`cleartextTrafficPermitted="false"`) across all public network traffic.
|
||||
- **Development Overrides:** Cleartext traffic permitted exclusively for `10.0.2.2` (Android emulator loopback) and `localhost`/`127.0.0.1` during debug testing.
|
||||
|
||||
### 4. Release Signing Setup
|
||||
- Configured in `flutter_app/android/app/build.gradle.kts` using dynamic properties loading:
|
||||
```kotlin
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
```
|
||||
- **Security Guardrail:**
|
||||
- Production builds load credentials from `key.properties`.
|
||||
- `key.properties` and `*.keystore` / `*.jks` are protected in root and app `.gitignore`.
|
||||
- If `key.properties` is absent, the build safely falls back to `signingConfigs.debug` only to permit internal compile checks, preventing hard compilation crashes while guaranteeing no debug keystore is mistakenly shipped as a production secret.
|
||||
|
||||
---
|
||||
|
||||
## 5. API Environment Configuration & Security
|
||||
|
||||
The Flutter client uses compile-time environment defines (`--dart-define`) to enforce endpoint security:
|
||||
|
||||
```dart
|
||||
// lib/core/api/api_config.dart
|
||||
enum AppEnvironment { development, staging, production }
|
||||
```
|
||||
|
||||
### Environment Matrix
|
||||
1. **Production Mode (`--dart-define=ENVIRONMENT=production`):**
|
||||
- Requires valid HTTPS endpoint (e.g. `--dart-define=API_BASE_URL=https://erp.yourdomain.edu`).
|
||||
- Rejects unencrypted `http://` endpoints.
|
||||
- **Localhost Fallback Blocked:** If `API_BASE_URL` is omitted, the app does **not** fall back to `localhost:10008`; it halts with `ApiException(code: NOT_CONFIGURED)`, prompting configuration via Settings.
|
||||
2. **Staging Mode (`--dart-define=ENVIRONMENT=staging`):**
|
||||
- Points to dedicated staging server (e.g. `https://staging-erp.yourdomain.edu`).
|
||||
3. **Development Mode (Default):**
|
||||
- Automatically routes to `http://10.0.2.2:10008` on Android emulator and `http://localhost:10008` on macOS/web.
|
||||
|
||||
---
|
||||
|
||||
## 6. Automated Test Results
|
||||
|
||||
### 1. Flutter Static Analysis
|
||||
- **Command:** `flutter analyze`
|
||||
- **Result:** `No issues found! (ran in 1.4s)`
|
||||
|
||||
### 2. Flutter Unit & Widget Test Suite
|
||||
- **Command:** `flutter test`
|
||||
- **Result:** `All 64 tests passed!`
|
||||
- Authentication state, secure token storage, and session lifecycle: PASSED
|
||||
- Dashboard screen, metric cards, and academic timeline: PASSED
|
||||
- Student profile, contact editing, and verification status: PASSED
|
||||
- Attendance session filters, statistics ring, and leave requests: PASSED
|
||||
- Timetable period cards and weekday navigation: PASSED
|
||||
- Exams schedule and published grades/result views: PASSED
|
||||
- Announcements noticeboard, unread badges, and modal sheets: PASSED
|
||||
- Finance fees overview, invoice line breakdown, and payment history: PASSED
|
||||
- Document upload and certificate request workflows: PASSED
|
||||
|
||||
### 3. Flutter Live End-to-End Integration
|
||||
- **Command:** `flutter test test/live_odoo_integration_test.dart`
|
||||
- **Result:** `All 12 live integration tests passed!` (connected against live Odoo 19 backend container)
|
||||
|
||||
---
|
||||
|
||||
## 7. Build Diagnostics & Artifact Paths
|
||||
|
||||
### Release Build Commands Attempted
|
||||
1. **Release APK:**
|
||||
```bash
|
||||
flutter build apk --release
|
||||
```
|
||||
**Output:** `[!] No Android SDK found. Try setting the ANDROID_HOME environment variable.`
|
||||
2. **Release App Bundle (AAB):**
|
||||
```bash
|
||||
flutter build appbundle --release
|
||||
```
|
||||
**Output:** `[!] No Android SDK found. Try setting the ANDROID_HOME environment variable.`
|
||||
|
||||
### Target Artifact Locations (Once SDK is Installed)
|
||||
When the Android SDK toolchain is installed per Section 3, executing the build commands will generate artifacts at the following canonical locations:
|
||||
|
||||
| Build Type | Command | Generated Artifact Path | Target Use Case |
|
||||
|---|---|---|---|
|
||||
| **Release APK (Fat)** | `flutter build apk --release` | `flutter_app/build/app/outputs/flutter-apk/app-release.apk` | Direct sideload testing on physical test devices |
|
||||
| **Release APK (Split ABI)** | `flutter build apk --release --split-per-abi` | `flutter_app/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk`<br>`flutter_app/build/app/outputs/flutter-apk/app-armeabi-v7a-release.apk` | Optimized size for physical device distribution |
|
||||
| **Android App Bundle** | `flutter build appbundle --release` | `flutter_app/build/app/outputs/bundle/release/app-release.aab` | Official Google Play Store distribution |
|
||||
|
||||
---
|
||||
|
||||
## 8. Step-by-Step Build Execution Commands (Post-Toolchain Setup)
|
||||
|
||||
Once OpenJDK 17 and Android SDK 34 are installed:
|
||||
|
||||
### Step 1: Set Up Private Release Keystore (Production Only)
|
||||
```bash
|
||||
# 1. Copy template:
|
||||
cp flutter_app/android/key.properties.example flutter_app/android/key.properties
|
||||
|
||||
# 2. Populate key.properties with real values:
|
||||
cat << 'EOF' > flutter_app/android/key.properties
|
||||
storePassword=your_secure_keystore_password
|
||||
keyPassword=your_secure_key_password
|
||||
keyAlias=metatron_release
|
||||
storeFile=/absolute/path/to/metatron_release.jks
|
||||
EOF
|
||||
```
|
||||
|
||||
### Step 2: Build Release APK for Internal Staging QA
|
||||
```bash
|
||||
cd flutter_app
|
||||
|
||||
# Builds release APK configured for staging server
|
||||
flutter build apk --release \
|
||||
--dart-define=ENVIRONMENT=staging \
|
||||
--dart-define=API_BASE_URL=https://staging-erp.yourdomain.edu
|
||||
```
|
||||
|
||||
### Step 3: Build Production Android App Bundle for Google Play
|
||||
```bash
|
||||
cd flutter_app
|
||||
|
||||
# Builds signed AAB configured for production server
|
||||
flutter build appbundle --release \
|
||||
--dart-define=ENVIRONMENT=production \
|
||||
--dart-define=API_BASE_URL=https://erp.yourdomain.edu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Remaining Blockers & Next Actions
|
||||
|
||||
1. **Host Toolchain Installation:**
|
||||
- Execute either Option A (Android Studio) or Option B (Homebrew CLI) as outlined in Section 3 to install OpenJDK 17 and Android SDK 34.
|
||||
2. **Institutional Production API Domain:**
|
||||
- Coordinate with the network/systems administrator to confirm the live HTTPS domain (e.g. `https://erp.metatroncube.in` or institutional domain) and configure DNS/SSL certificates.
|
||||
3. **Production Signing Keystore:**
|
||||
- Generate and securely store the production `.jks` file outside version control and supply `key.properties` for the final Google Play AAB build.
|
||||
|
||||
---
|
||||
*Report completed. All code changes and configurations remain unpushed and within the workspace.*
|
||||
@ -16,7 +16,6 @@
|
||||
'depends': [
|
||||
'education_base',
|
||||
'education_student',
|
||||
'education_portal',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EducationAttendance(models.Model):
|
||||
@ -28,23 +27,3 @@ class EducationAttendance(models.Model):
|
||||
default=lambda self: self.env.company,
|
||||
index=True,
|
||||
)
|
||||
|
||||
_uniq_student_session = models.Constraint(
|
||||
'unique(student_id, session_id)',
|
||||
'An attendance record already exists for this student in this session!'
|
||||
)
|
||||
|
||||
def write(self, vals):
|
||||
for rec in self:
|
||||
if not self.env.user.has_group('education_attendance.group_attendance_manager'):
|
||||
raise ValidationError("Unauthorized: Only an Attendance Manager can directly modify attendance history records.")
|
||||
res = super().write(vals)
|
||||
self.mapped('student_id').invalidate_recordset()
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
for rec in self:
|
||||
if rec.session_id and rec.session_id.state == 'completed' and not self.env.user.has_group('education_attendance.group_attendance_manager'):
|
||||
raise ValidationError("Unauthorized: Only an Attendance Manager can directly delete attendance history records of completed sessions.")
|
||||
res = super().unlink()
|
||||
return res
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EducationAttendanceLine(models.Model):
|
||||
@ -27,37 +26,3 @@ class EducationAttendanceLine(models.Model):
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
_uniq_session_student = models.Constraint(
|
||||
'unique(session_id, student_id)',
|
||||
'A student cannot be added twice to the same attendance session!'
|
||||
)
|
||||
|
||||
def write(self, vals):
|
||||
for rec in self:
|
||||
if rec.session_id.state == 'completed':
|
||||
if not self.env.user.has_group('education_attendance.group_attendance_manager'):
|
||||
raise ValidationError("Only an Attendance Manager can modify attendance lines for a completed session.")
|
||||
# Sync correction to permanent history
|
||||
if 'status' in vals or 'remarks' in vals:
|
||||
history = self.env['education.attendance'].search([
|
||||
('session_id', '=', rec.session_id.id),
|
||||
('student_id', '=', rec.student_id.id),
|
||||
], limit=1)
|
||||
if history:
|
||||
hist_vals = {}
|
||||
if 'status' in vals:
|
||||
hist_vals['status'] = vals['status']
|
||||
if 'remarks' in vals:
|
||||
hist_vals['remarks'] = vals['remarks']
|
||||
history.write(hist_vals)
|
||||
res = super().write(vals)
|
||||
if any(rec.session_id.state == 'completed' for rec in self):
|
||||
self.mapped('student_id').invalidate_recordset()
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
for rec in self:
|
||||
if rec.session_id.state == 'completed' and not self.env.user.has_group('education_attendance.group_attendance_manager'):
|
||||
raise ValidationError("Cannot delete attendance lines of a completed session. Revert the session to draft first.")
|
||||
return super().unlink()
|
||||
|
||||
@ -17,9 +17,6 @@ class EducationAttendanceSession(models.Model):
|
||||
academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True, index=True)
|
||||
academic_term_id = fields.Many2one('education.academic.term', string='Academic Term', required=True, index=True)
|
||||
academic_level_id = fields.Many2one('education.academic.level', string='Academic Level', required=True, index=True)
|
||||
batch_id = fields.Many2one('education.batch', string='Batch', index=True)
|
||||
semester_id = fields.Many2one('education.semester', string='Semester', index=True)
|
||||
section_id = fields.Many2one('education.section', string='Section', index=True)
|
||||
|
||||
attendance_date = fields.Date(string='Attendance Date', required=True, default=fields.Date.context_today, index=True, tracking=True)
|
||||
period = fields.Selection([
|
||||
@ -53,33 +50,12 @@ class EducationAttendanceSession(models.Model):
|
||||
|
||||
_uniq_session_period = models.Constraint('unique(attendance_date, period, program_id, academic_level_id, company_id)', 'An attendance session has already been created for this period, program, and academic level on this date.')
|
||||
|
||||
@api.constrains('attendance_date')
|
||||
def _check_attendance_date(self):
|
||||
today = fields.Date.context_today(self)
|
||||
for rec in self:
|
||||
if rec.attendance_date and rec.attendance_date > today:
|
||||
raise ValidationError("Attendance session date cannot be in the future.")
|
||||
|
||||
def write(self, vals):
|
||||
for rec in self:
|
||||
if rec.state == 'completed' and not self.env.user.has_group('education_attendance.group_attendance_manager'):
|
||||
locked_fields = {'campus_id', 'department_id', 'program_id', 'academic_year_id', 'academic_term_id', 'academic_level_id', 'attendance_date', 'period', 'batch_id', 'semester_id', 'section_id'}
|
||||
if any(f in vals for f in locked_fields):
|
||||
raise ValidationError("Completed attendance sessions cannot have their schedule or academic grouping modified. Reset to draft or contact an Attendance Manager.")
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
for rec in self:
|
||||
if rec.state == 'completed' and not self.env.user.has_group('education_attendance.group_attendance_manager'):
|
||||
raise ValidationError("Completed attendance sessions cannot be deleted directly. Cancel the session first or contact an Attendance Manager.")
|
||||
return super().unlink()
|
||||
|
||||
def action_open(self):
|
||||
for rec in self:
|
||||
if rec.state != 'draft':
|
||||
continue
|
||||
# Auto-populate attendance lines with active students matching academic context
|
||||
domain = [
|
||||
# Auto-populate attendance lines with active students
|
||||
students = self.env['education.student'].search([
|
||||
('campus_id', '=', rec.campus_id.id),
|
||||
('department_id', '=', rec.department_id.id),
|
||||
('program_id', '=', rec.program_id.id),
|
||||
@ -87,15 +63,7 @@ class EducationAttendanceSession(models.Model):
|
||||
('academic_term_id', '=', rec.academic_term_id.id),
|
||||
('level_id', '=', rec.academic_level_id.id),
|
||||
('status', '=', 'active'),
|
||||
]
|
||||
if rec.batch_id:
|
||||
domain.append(('batch_id', '=', rec.batch_id.id))
|
||||
if rec.semester_id:
|
||||
domain.append(('semester_id', '=', rec.semester_id.id))
|
||||
if rec.section_id:
|
||||
domain.append(('section_id', '=', rec.section_id.id))
|
||||
|
||||
students = self.env['education.student'].search(domain)
|
||||
])
|
||||
if not students:
|
||||
raise ValidationError("No active students found matching the selected academic criteria.")
|
||||
|
||||
|
||||
@ -52,16 +52,4 @@
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_attendance_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal Student Attendance Isolation -->
|
||||
<record id="rule_attendance_history_portal" model="ir.rule">
|
||||
<field name="name">Portal Student Attendance Isolation</field>
|
||||
<field name="model_id" ref="model_education_attendance"/>
|
||||
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@ -5,5 +5,3 @@ access_attendance_line_user,Attendance Line User,model_education_attendance_line
|
||||
access_attendance_line_mgr,Attendance Line Mgr,model_education_attendance_line,group_attendance_manager,1,1,1,1
|
||||
access_attendance_user,Attendance User,model_education_attendance,group_attendance_user,1,1,1,0
|
||||
access_attendance_mgr,Attendance Mgr,model_education_attendance,group_attendance_manager,1,1,1,1
|
||||
access_attendance_portal,Attendance Portal Read,model_education_attendance,base.group_portal,1,0,0,0
|
||||
access_attendance_session_portal,Attendance Session Portal Read,model_education_attendance_session,base.group_portal,1,0,0,0
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
# pyrefly: ignore [missing-import]
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError, AccessError
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class TestStudentAttendance(TransactionCase):
|
||||
@ -9,100 +9,61 @@ class TestStudentAttendance(TransactionCase):
|
||||
def setUp(self):
|
||||
super(TestStudentAttendance, self).setUp()
|
||||
|
||||
self.company = self.env.company
|
||||
self.company = self.env.ref('base.main_company')
|
||||
|
||||
# Setup master records
|
||||
self.campus = self.env['education.campus'].create({
|
||||
'name': 'Attendance Test Campus',
|
||||
'code': 'ATC-SEC',
|
||||
'company_id': self.company.id,
|
||||
'code': 'ATC',
|
||||
})
|
||||
self.department = self.env['education.department'].create({
|
||||
'name': 'Attendance Test Dept',
|
||||
'code': 'ATD-SEC',
|
||||
'code': 'ATD',
|
||||
'campus_id': self.campus.id,
|
||||
})
|
||||
self.level = self.env['education.academic.level'].create({
|
||||
'name': 'Attendance Test Level',
|
||||
'code': 'ATL-SEC',
|
||||
'company_id': self.company.id,
|
||||
'code': 'ATL',
|
||||
})
|
||||
self.program = self.env['education.program'].create({
|
||||
'name': 'Attendance Test Program',
|
||||
'code': 'ATP-SEC',
|
||||
'code': 'ATP',
|
||||
'department_id': self.department.id,
|
||||
'level_id': self.level.id,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.year = self.env['education.academic.year'].create({
|
||||
'name': 'Attendance Test Year 2026',
|
||||
'code': 'ATY26-SEC',
|
||||
'code': 'ATY26',
|
||||
'start_date': '2026-01-01',
|
||||
'end_date': '2026-12-31',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.term = self.env['education.academic.term'].create({
|
||||
'name': 'Attendance Test Term 1',
|
||||
'code': 'ATT1-SEC',
|
||||
'code': 'ATT1',
|
||||
'academic_year_id': self.year.id,
|
||||
'start_date': '2026-01-01',
|
||||
'end_date': '2026-06-30',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.batch = self.env['education.batch'].create({
|
||||
'name': 'Attendance Test Batch 2026',
|
||||
'code': 'ATB26-SEC',
|
||||
'code': 'ATB26',
|
||||
})
|
||||
self.semester = self.env['education.semester'].create({
|
||||
'name': 'Attendance Test Semester 1',
|
||||
'code': 'ATS1-SEC',
|
||||
'code': 'ATS1',
|
||||
})
|
||||
self.section_a = self.env['education.section'].create({
|
||||
self.section = self.env['education.section'].create({
|
||||
'name': 'Attendance Test Section A',
|
||||
'code': 'ATSA-SEC',
|
||||
})
|
||||
self.section_b = self.env['education.section'].create({
|
||||
'name': 'Attendance Test Section B',
|
||||
'code': 'ATSB-SEC',
|
||||
'code': 'ATSA',
|
||||
})
|
||||
|
||||
# Users and Security Groups
|
||||
self.group_user = self.env.ref('education_attendance.group_attendance_user')
|
||||
self.group_manager = self.env.ref('education_attendance.group_attendance_manager')
|
||||
self.group_portal = self.env.ref('base.group_portal')
|
||||
|
||||
self.user_teacher = self.env['res.users'].create({
|
||||
'name': 'Attendance Teacher',
|
||||
'login': 'att.teacher@example.com',
|
||||
'email': 'att.teacher@example.com',
|
||||
'group_ids': [(6, 0, [self.group_user.id])],
|
||||
})
|
||||
self.user_manager = self.env['res.users'].create({
|
||||
'name': 'Attendance Manager',
|
||||
'login': 'att.manager@example.com',
|
||||
'email': 'att.manager@example.com',
|
||||
'group_ids': [(6, 0, [self.group_manager.id])],
|
||||
})
|
||||
self.user_portal_1 = self.env['res.users'].create({
|
||||
'name': 'Portal Student 1',
|
||||
'login': 'att.student1@example.com',
|
||||
'email': 'att.student1@example.com',
|
||||
'group_ids': [(6, 0, [self.group_portal.id])],
|
||||
})
|
||||
self.user_portal_2 = self.env['res.users'].create({
|
||||
'name': 'Portal Student 2',
|
||||
'login': 'att.student2@example.com',
|
||||
'email': 'att.student2@example.com',
|
||||
'group_ids': [(6, 0, [self.group_portal.id])],
|
||||
})
|
||||
|
||||
# Create active students
|
||||
# Create 3 active students
|
||||
self.student_1 = self.env['education.student'].create({
|
||||
'first_name': 'TestStudent',
|
||||
'last_name': 'One',
|
||||
'student_id': 'ATSTU01-SEC',
|
||||
'admission_number': 'ATADM01-SEC',
|
||||
'roll_number': 'ATRL01-SEC',
|
||||
'student_id': 'ATSTU01',
|
||||
'admission_number': 'ATADM01',
|
||||
'roll_number': 'ATRL01',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
@ -111,18 +72,16 @@ class TestStudentAttendance(TransactionCase):
|
||||
'academic_term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_a.id,
|
||||
'section_id': self.section.id,
|
||||
'status': 'active',
|
||||
'company_id': self.company.id,
|
||||
'portal_user_id': self.user_portal_1.id,
|
||||
})
|
||||
|
||||
self.student_2 = self.env['education.student'].create({
|
||||
'first_name': 'TestStudent',
|
||||
'last_name': 'Two',
|
||||
'student_id': 'ATSTU02-SEC',
|
||||
'admission_number': 'ATADM02-SEC',
|
||||
'roll_number': 'ATRL02-SEC',
|
||||
'student_id': 'ATSTU02',
|
||||
'admission_number': 'ATADM02',
|
||||
'roll_number': 'ATRL02',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
@ -131,18 +90,16 @@ class TestStudentAttendance(TransactionCase):
|
||||
'academic_term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_a.id,
|
||||
'section_id': self.section.id,
|
||||
'status': 'active',
|
||||
'company_id': self.company.id,
|
||||
'portal_user_id': self.user_portal_2.id,
|
||||
})
|
||||
|
||||
self.student_3 = self.env['education.student'].create({
|
||||
'first_name': 'TestStudent',
|
||||
'last_name': 'Three',
|
||||
'student_id': 'ATSTU03-SEC',
|
||||
'admission_number': 'ATADM03-SEC',
|
||||
'roll_number': 'ATRL03-SEC',
|
||||
'student_id': 'ATSTU03',
|
||||
'admission_number': 'ATADM03',
|
||||
'roll_number': 'ATRL03',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
@ -151,13 +108,11 @@ class TestStudentAttendance(TransactionCase):
|
||||
'academic_term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_b.id,
|
||||
'section_id': self.section.id,
|
||||
'status': 'active',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
# Draft Session (dated yesterday to be safely in the past)
|
||||
self.session_date = date.today() - timedelta(days=1)
|
||||
# Draft Session
|
||||
self.session = self.env['education.attendance.session'].create({
|
||||
'name': 'Test Lecture Period 1',
|
||||
'campus_id': self.campus.id,
|
||||
@ -166,18 +121,17 @@ class TestStudentAttendance(TransactionCase):
|
||||
'academic_level_id': self.level.id,
|
||||
'academic_year_id': self.year.id,
|
||||
'academic_term_id': self.term.id,
|
||||
'attendance_date': self.session_date,
|
||||
'attendance_date': '2026-02-01',
|
||||
'period': 'period_1',
|
||||
'faculty_id': self.user_teacher.id,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
def test_01_attendance_session_workflow(self):
|
||||
"""Test workflow of opening session, bulk marking, completing it, and student metrics calculation."""
|
||||
def test_attendance_session_workflow(self):
|
||||
"""Test workflow of opening session, marking attendance, completing it, and reverse logic"""
|
||||
# Session should start in draft with no lines
|
||||
self.assertEqual(self.session.state, 'draft')
|
||||
self.assertEqual(len(self.session.line_ids), 0)
|
||||
|
||||
# Open session (auto-generates lines for our 3 active students)
|
||||
# Open session (should auto-generate lines for our 3 active students)
|
||||
self.session.action_open()
|
||||
self.assertEqual(self.session.state, 'open')
|
||||
self.assertEqual(len(self.session.line_ids), 3)
|
||||
@ -191,16 +145,16 @@ class TestStudentAttendance(TransactionCase):
|
||||
for line in self.session.line_ids:
|
||||
self.assertEqual(line.status, 'absent')
|
||||
|
||||
# Set individual marks
|
||||
# Let's set Student 1 to Present, Student 2 to Late, Student 3 to Medical
|
||||
line_1 = self.session.line_ids.filtered(lambda l: l.student_id == self.student_1)
|
||||
line_2 = self.session.line_ids.filtered(lambda l: l.student_id == self.student_2)
|
||||
line_3 = self.session.line_ids.filtered(lambda l: l.student_id == self.student_3)
|
||||
|
||||
line_1.write({'status': 'present'})
|
||||
line_2.write({'status': 'absent'})
|
||||
line_3.write({'status': 'late'})
|
||||
line_2.write({'status': 'late'})
|
||||
line_3.write({'status': 'medical_leave'})
|
||||
|
||||
# Complete Session
|
||||
# Complete Session (should write to permanent history education.attendance)
|
||||
self.session.action_complete()
|
||||
self.assertEqual(self.session.state, 'completed')
|
||||
|
||||
@ -211,30 +165,31 @@ class TestStudentAttendance(TransactionCase):
|
||||
|
||||
self.assertEqual(len(history_1), 1)
|
||||
self.assertEqual(history_1.status, 'present')
|
||||
self.assertEqual(len(history_2), 1)
|
||||
self.assertEqual(history_2.status, 'absent')
|
||||
self.assertEqual(len(history_3), 1)
|
||||
self.assertEqual(history_3.status, 'late')
|
||||
self.assertEqual(history_2.status, 'late')
|
||||
self.assertEqual(history_3.status, 'medical_leave')
|
||||
|
||||
# Invalidate student record cache to read computed metrics
|
||||
self.student_1.invalidate_recordset()
|
||||
self.student_2.invalidate_recordset()
|
||||
self.student_3.invalidate_recordset()
|
||||
|
||||
# Student 1: 1 present / 1 total = 100%
|
||||
# Present, Late, Medical should all count as attended (100% attendance for this single session)
|
||||
self.assertEqual(self.student_1.attendance_pct, 100.0)
|
||||
self.assertEqual(self.student_1.present_count, 1)
|
||||
|
||||
# Student 2: 1 absent / 1 total = 0%
|
||||
self.assertEqual(self.student_2.attendance_pct, 0.0)
|
||||
self.assertEqual(self.student_2.absent_count, 1)
|
||||
|
||||
# Student 3: 1 late / 1 total = 100% (attended)
|
||||
self.assertEqual(self.student_2.attendance_pct, 100.0)
|
||||
self.assertEqual(self.student_3.attendance_pct, 100.0)
|
||||
self.assertEqual(self.student_3.late_count, 1)
|
||||
|
||||
def test_02_duplicate_session_constraint(self):
|
||||
"""Prevent creating duplicate attendance session for same period, program, level, date, and company."""
|
||||
self.assertEqual(self.student_1.present_count, 1)
|
||||
self.assertEqual(self.student_2.late_count, 1)
|
||||
self.assertEqual(self.student_3.medical_leave_count, 1)
|
||||
|
||||
# Reset to draft (should unlink lines and history)
|
||||
self.session.action_draft()
|
||||
self.assertEqual(self.session.state, 'draft')
|
||||
self.assertEqual(len(self.session.line_ids), 0)
|
||||
self.assertEqual(len(self.env['education.attendance'].search([('session_id', '=', self.session.id)])), 0)
|
||||
|
||||
def test_duplicate_session_constraint(self):
|
||||
"""Test SQL constraints: cannot create duplicate session for same period and date"""
|
||||
with self.assertRaises(Exception):
|
||||
self.env['education.attendance.session'].create({
|
||||
'name': 'Duplicate Session',
|
||||
@ -244,123 +199,7 @@ class TestStudentAttendance(TransactionCase):
|
||||
'academic_level_id': self.level.id,
|
||||
'academic_year_id': self.year.id,
|
||||
'academic_term_id': self.term.id,
|
||||
'attendance_date': self.session_date,
|
||||
'attendance_date': '2026-02-01',
|
||||
'period': 'period_1',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.env.flush_all()
|
||||
|
||||
def test_03_duplicate_line_and_history_constraint(self):
|
||||
"""Prevent duplicate student in attendance lines or permanent attendance history."""
|
||||
self.session.action_open()
|
||||
|
||||
# Adding student 1 again to the same session must raise constraint error
|
||||
with self.assertRaises(Exception):
|
||||
self.env['education.attendance.line'].create({
|
||||
'session_id': self.session.id,
|
||||
'student_id': self.student_1.id,
|
||||
'status': 'present',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.env.flush_all()
|
||||
|
||||
self.session.action_complete()
|
||||
|
||||
# Creating duplicate attendance history record for same (student, session) must fail
|
||||
with self.assertRaises(Exception):
|
||||
self.env['education.attendance'].create({
|
||||
'student_id': self.student_1.id,
|
||||
'session_id': self.session.id,
|
||||
'date': self.session_date,
|
||||
'status': 'present',
|
||||
'faculty_id': self.user_teacher.id,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.env.flush_all()
|
||||
|
||||
def test_04_future_date_validation(self):
|
||||
"""Attendance sessions cannot be scheduled in the future."""
|
||||
future_date = date.today() + timedelta(days=5)
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['education.attendance.session'].create({
|
||||
'name': 'Future Session',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
'academic_level_id': self.level.id,
|
||||
'academic_year_id': self.year.id,
|
||||
'academic_term_id': self.term.id,
|
||||
'attendance_date': future_date,
|
||||
'period': 'period_2',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
def test_05_completed_session_modification_and_correction(self):
|
||||
"""Test completed session lock and authorized correction by Attendance Manager."""
|
||||
self.session.action_open()
|
||||
line_1 = self.session.line_ids.filtered(lambda l: l.student_id == self.student_1)
|
||||
line_1.write({'status': 'absent'})
|
||||
self.session.action_complete()
|
||||
|
||||
self.student_1.invalidate_recordset()
|
||||
self.assertEqual(self.student_1.attendance_pct, 0.0)
|
||||
|
||||
# Teacher (regular attendance user) cannot modify lines on completed session
|
||||
line_as_teacher = line_1.with_user(self.user_teacher)
|
||||
with self.assertRaises(ValidationError):
|
||||
line_as_teacher.write({'status': 'present'})
|
||||
|
||||
# Manager CAN modify line for audited correction, and it syncs to history & student metrics
|
||||
line_as_manager = line_1.with_user(self.user_manager)
|
||||
line_as_manager.write({'status': 'present', 'remarks': 'Correction approved by dean'})
|
||||
|
||||
history_1 = self.env['education.attendance'].search([('student_id', '=', self.student_1.id), ('session_id', '=', self.session.id)])
|
||||
self.assertEqual(history_1.status, 'present')
|
||||
self.assertEqual(history_1.remarks, 'Correction approved by dean')
|
||||
|
||||
self.student_1.invalidate_recordset()
|
||||
self.assertEqual(self.student_1.attendance_pct, 100.0)
|
||||
|
||||
def test_06_portal_student_isolation(self):
|
||||
"""Portal student user can ONLY read their own attendance records."""
|
||||
self.session.action_open()
|
||||
self.session.action_complete()
|
||||
|
||||
# Portal User 1 should only see Student 1's attendance
|
||||
records_p1 = self.env['education.attendance'].with_user(self.user_portal_1).search([])
|
||||
self.assertEqual(len(records_p1), 1)
|
||||
self.assertEqual(records_p1.student_id, self.student_1)
|
||||
|
||||
# Portal User 2 should only see Student 2's attendance
|
||||
records_p2 = self.env['education.attendance'].with_user(self.user_portal_2).search([])
|
||||
self.assertEqual(len(records_p2), 1)
|
||||
self.assertEqual(records_p2.student_id, self.student_2)
|
||||
|
||||
# Portal user cannot write or delete attendance records
|
||||
with self.assertRaises(ValidationError):
|
||||
records_p1.write({'status': 'absent'})
|
||||
|
||||
def test_07_academic_context_filtering(self):
|
||||
"""Session with section_id specified should only populate students of that section."""
|
||||
session_sec_a = self.env['education.attendance.session'].create({
|
||||
'name': 'Section A Lecture',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
'academic_level_id': self.level.id,
|
||||
'academic_year_id': self.year.id,
|
||||
'academic_term_id': self.term.id,
|
||||
'section_id': self.section_a.id,
|
||||
'attendance_date': self.session_date,
|
||||
'period': 'period_3',
|
||||
'faculty_id': self.user_teacher.id,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
session_sec_a.action_open()
|
||||
|
||||
# Only student_1 and student_2 are in section_a; student_3 is in section_b
|
||||
self.assertEqual(len(session_sec_a.line_ids), 2)
|
||||
students_loaded = session_sec_a.line_ids.mapped('student_id')
|
||||
self.assertIn(self.student_1, students_loaded)
|
||||
self.assertIn(self.student_2, students_loaded)
|
||||
self.assertNotIn(self.student_3, students_loaded)
|
||||
|
||||
@ -119,9 +119,6 @@
|
||||
<field name="department_id" readonly="state != 'draft'"/>
|
||||
<field name="program_id" readonly="state != 'draft'"/>
|
||||
<field name="academic_level_id" readonly="state != 'draft'"/>
|
||||
<field name="batch_id" readonly="state != 'draft'"/>
|
||||
<field name="semester_id" readonly="state != 'draft'"/>
|
||||
<field name="section_id" readonly="state != 'draft'"/>
|
||||
</group>
|
||||
<group string="Schedule & Faculty">
|
||||
<field name="academic_year_id" readonly="state != 'draft'"/>
|
||||
|
||||
@ -97,7 +97,7 @@ def generate_demo_data(env):
|
||||
if system_group: admin_groups.append(system_group.id)
|
||||
if admin_groups:
|
||||
admin_user.sudo().write({
|
||||
'group_ids': [(4, g) for g in admin_groups],
|
||||
'groups_id': [(4, g) for g in admin_groups],
|
||||
'company_id': company.id,
|
||||
'company_ids': [(6, 0, [company.id])]
|
||||
})
|
||||
@ -114,25 +114,26 @@ def generate_demo_data(env):
|
||||
if lms_faculty_grp: faculty_groups.append(lms_faculty_grp.id)
|
||||
|
||||
faculty_user.sudo().write({
|
||||
'group_ids': [(4, g) for g in faculty_groups],
|
||||
'groups_id': [(4, g) for g in faculty_groups],
|
||||
'company_id': company.id,
|
||||
'company_ids': [(6, 0, [company.id])]
|
||||
})
|
||||
|
||||
if student_user and portal_group and not student_user.has_group('base.group_user'):
|
||||
if student_user and portal_group:
|
||||
student_user.sudo().write({
|
||||
'group_ids': [(4, portal_group.id)],
|
||||
'groups_id': [(4, portal_group.id)],
|
||||
'company_id': company.id,
|
||||
'company_ids': [(6, 0, [company.id])]
|
||||
})
|
||||
|
||||
# 5. Create Student Record
|
||||
if student_user:
|
||||
student = env['education.student'].search([('portal_user_id', '=', student_user.id)], limit=1)
|
||||
student = env['education.student'].search([('partner_id', '=', student_user.partner_id.id)], limit=1)
|
||||
if not student:
|
||||
student = env['education.student'].create({
|
||||
'first_name': 'Demo',
|
||||
'last_name': 'Student',
|
||||
'partner_id': student_user.partner_id.id,
|
||||
'program_id': program.id,
|
||||
'campus_id': campus.id,
|
||||
'academic_year_id': year.id,
|
||||
@ -220,165 +221,5 @@ def generate_demo_data(env):
|
||||
'state': 'issued'
|
||||
})
|
||||
|
||||
# 8. Timetable Demo Data
|
||||
if 'education.timetable.slot' in env:
|
||||
# Create rooms
|
||||
room_101 = env['education.room'].search([('code', '=', 'LH-101'), ('company_id', '=', company.id)], limit=1)
|
||||
if not room_101:
|
||||
room_101 = env['education.room'].create({
|
||||
'name': 'Lecture Hall 101',
|
||||
'code': 'LH-101',
|
||||
'capacity': 60,
|
||||
'building': 'Main Academic Block',
|
||||
'company_id': company.id,
|
||||
})
|
||||
room_102 = env['education.room'].search([('code', '=', 'LH-102'), ('company_id', '=', company.id)], limit=1)
|
||||
if not room_102:
|
||||
room_102 = env['education.room'].create({
|
||||
'name': 'Lecture Hall 102',
|
||||
'code': 'LH-102',
|
||||
'capacity': 50,
|
||||
'building': 'Main Academic Block',
|
||||
'company_id': company.id,
|
||||
})
|
||||
room_lab = env['education.room'].search([('code', '=', 'LAB-01'), ('company_id', '=', company.id)], limit=1)
|
||||
if not room_lab:
|
||||
room_lab = env['education.room'].create({
|
||||
'name': 'Computing Lab 1',
|
||||
'code': 'LAB-01',
|
||||
'capacity': 40,
|
||||
'building': 'Turing Wing',
|
||||
'company_id': company.id,
|
||||
})
|
||||
|
||||
# Create demo subjects
|
||||
demo_sub_1 = env['education.subject'].search([('code', '=', 'CS-DEMO-101')], limit=1)
|
||||
if not demo_sub_1:
|
||||
demo_sub_1 = env['education.subject'].create({
|
||||
'name': 'Data Structures & Algorithms',
|
||||
'code': 'CS-DEMO-101',
|
||||
'program_id': program.id,
|
||||
})
|
||||
demo_sub_2 = env['education.subject'].search([('code', '=', 'CS-DEMO-102')], limit=1)
|
||||
if not demo_sub_2:
|
||||
demo_sub_2 = env['education.subject'].create({
|
||||
'name': 'Operating Systems',
|
||||
'code': 'CS-DEMO-102',
|
||||
'program_id': program.id,
|
||||
})
|
||||
demo_sub_3 = env['education.subject'].search([('code', '=', 'CS-DEMO-103')], limit=1)
|
||||
if not demo_sub_3:
|
||||
demo_sub_3 = env['education.subject'].create({
|
||||
'name': 'Database Systems',
|
||||
'code': 'CS-DEMO-103',
|
||||
'program_id': program.id,
|
||||
})
|
||||
|
||||
monday = today - datetime.timedelta(days=today.weekday())
|
||||
demo_slots = [
|
||||
{'offset': 0, 'start': 9.0, 'end': 10.5, 'subject': demo_sub_1, 'room': room_101},
|
||||
{'offset': 0, 'start': 11.0, 'end': 12.5, 'subject': demo_sub_2, 'room': room_102},
|
||||
{'offset': 1, 'start': 9.0, 'end': 10.5, 'subject': demo_sub_3, 'room': room_101},
|
||||
{'offset': 1, 'start': 11.0, 'end': 12.5, 'subject': demo_sub_1, 'room': room_102},
|
||||
{'offset': 2, 'start': 10.0, 'end': 11.5, 'subject': demo_sub_2, 'room': room_lab},
|
||||
{'offset': 3, 'start': 9.0, 'end': 10.5, 'subject': demo_sub_3, 'room': room_101},
|
||||
{'offset': 4, 'start': 9.0, 'end': 10.5, 'subject': demo_sub_1, 'room': room_101},
|
||||
]
|
||||
target_faculty = faculty_user if faculty_user else env['res.users'].browse(1)
|
||||
for ds in demo_slots:
|
||||
slot_date = monday + datetime.timedelta(days=ds['offset'])
|
||||
existing_slot = env['education.timetable.slot'].search([
|
||||
('program_id', '=', program.id),
|
||||
('date', '=', slot_date),
|
||||
('start_time', '=', ds['start']),
|
||||
], limit=1)
|
||||
if not existing_slot:
|
||||
env['education.timetable.slot'].create({
|
||||
'program_id': program.id,
|
||||
'academic_year_id': year.id,
|
||||
'term_id': term.id if term else False,
|
||||
'subject_id': ds['subject'].id,
|
||||
'faculty_id': target_faculty.id,
|
||||
'room_id': ds['room'].id,
|
||||
'date': slot_date,
|
||||
'start_time': ds['start'],
|
||||
'end_time': ds['end'],
|
||||
'state': 'confirmed',
|
||||
'company_id': company.id,
|
||||
})
|
||||
|
||||
# 13. Student Fees & Finance
|
||||
fee_cat = env['education.fee.category'].search([('code', '=', 'DEMO-ACAD')], limit=1)
|
||||
if not fee_cat:
|
||||
fee_cat = env['education.fee.category'].create({
|
||||
'name': 'Academic & Tuition',
|
||||
'code': 'DEMO-ACAD',
|
||||
'company_id': company.id,
|
||||
})
|
||||
|
||||
fee_type_tuition = env['education.fee.type'].search([('name', '=', 'Semester Tuition Fee (Demo)')], limit=1)
|
||||
if not fee_type_tuition:
|
||||
fee_type_tuition = env['education.fee.type'].create({
|
||||
'name': 'Semester Tuition Fee (Demo)',
|
||||
'category_id': fee_cat.id,
|
||||
'amount': 25000.0,
|
||||
'company_id': company.id,
|
||||
})
|
||||
|
||||
fee_type_lab = env['education.fee.type'].search([('name', '=', 'Computing Lab & Facilities (Demo)')], limit=1)
|
||||
if not fee_type_lab:
|
||||
fee_type_lab = env['education.fee.type'].create({
|
||||
'name': 'Computing Lab & Facilities (Demo)',
|
||||
'category_id': fee_cat.id,
|
||||
'amount': 5000.0,
|
||||
'company_id': company.id,
|
||||
})
|
||||
|
||||
fee_struct = env['education.fee.structure'].search([('name', '=', 'B.Tech CS - Sem 1 Fee Structure (Demo)')], limit=1)
|
||||
if not fee_struct:
|
||||
fee_struct = env['education.fee.structure'].create({
|
||||
'name': 'B.Tech CS - Sem 1 Fee Structure (Demo)',
|
||||
'academic_year_id': year.id,
|
||||
'level_id': level.id,
|
||||
'program_id': program.id,
|
||||
'semester_id': semester.id,
|
||||
'company_id': company.id,
|
||||
'due_days': 30,
|
||||
'line_ids': [
|
||||
(0, 0, {'fee_type_id': fee_type_tuition.id, 'amount': 25000.0}),
|
||||
(0, 0, {'fee_type_id': fee_type_lab.id, 'amount': 5000.0}),
|
||||
]
|
||||
})
|
||||
|
||||
if student and fee_struct:
|
||||
assignment = env['education.fee.assignment'].search([
|
||||
('student_id', '=', student.id),
|
||||
('fee_structure_id', '=', fee_struct.id),
|
||||
], limit=1)
|
||||
if not assignment:
|
||||
assignment = env['education.fee.assignment'].create({
|
||||
'student_id': student.id,
|
||||
'academic_year_id': year.id,
|
||||
'semester_id': semester.id,
|
||||
'fee_structure_id': fee_struct.id,
|
||||
'assignment_date': today - datetime.timedelta(days=20),
|
||||
'due_date': today + datetime.timedelta(days=10),
|
||||
'company_id': company.id,
|
||||
})
|
||||
assignment.action_confirm()
|
||||
|
||||
# Record a partial payment
|
||||
payment = env['education.student.payment'].create({
|
||||
'student_id': student.id,
|
||||
'fee_assignment_id': assignment.id,
|
||||
'amount': 10000.0,
|
||||
'payment_date': today - datetime.timedelta(days=5),
|
||||
'payment_method': 'online',
|
||||
'reference_number': 'DEMO-PAY-001',
|
||||
'remarks': 'Partial Online Fee Payment',
|
||||
'company_id': company.id,
|
||||
})
|
||||
payment.action_confirm()
|
||||
|
||||
env.cr.commit()
|
||||
|
||||
|
||||
@ -27,6 +27,6 @@ class TestDemoLogin(HttpCase):
|
||||
|
||||
def test_05_demo_login_missing_role(self):
|
||||
""" Test missing role gets rejected """
|
||||
# Pass empty role in POST body to test missing role validation resulting in 403
|
||||
response = self.url_open('/demo/login', data={'role': ''})
|
||||
# using GET instead of POST causes a 405 Method Not Allowed, or missing role in POST causes 403
|
||||
response = self.url_open('/demo/login', data={})
|
||||
self.assertEqual(response.status_code, 403, "Should be Access Denied")
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizard
|
||||
|
||||
@ -24,7 +24,6 @@
|
||||
'views/education_exam_result_views.xml',
|
||||
'views/education_student_inherit_views.xml',
|
||||
'views/portal_exam_templates.xml',
|
||||
'wizard/marks_entry_wizard_views.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
||||
@ -22,7 +22,7 @@ class EducationExam(models.Model):
|
||||
('ongoing', 'Ongoing'),
|
||||
('completed', 'Completed'),
|
||||
('results_published', 'Results Published')
|
||||
], string='Status', required=True, default='draft')
|
||||
], string='Status', required=True, default='draft', tracking=True)
|
||||
result_published_date = fields.Date(string='Result Published Date')
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
@ -67,11 +67,10 @@ class EducationExam(models.Model):
|
||||
rec.state = 'completed'
|
||||
|
||||
def action_generate_results(self):
|
||||
"""Generates result records idempotently for all students enrolled in the program and term.
|
||||
If results already exist, preserves previously entered marks and only creates missing lines."""
|
||||
"""Generates empty result records for all students enrolled in the program and term."""
|
||||
for rec in self:
|
||||
if rec.state not in ('ongoing', 'completed'):
|
||||
raise ValidationError("Results can only be generated for ongoing or completed exams.")
|
||||
if rec.state != 'completed':
|
||||
raise ValidationError("Results can only be generated for completed exams.")
|
||||
students = self.env['education.student'].search([
|
||||
('program_id', '=', rec.program_id.id),
|
||||
('academic_year_id', '=', rec.academic_year_id.id),
|
||||
@ -83,110 +82,54 @@ class EducationExam(models.Model):
|
||||
raise ValidationError("No active/enrolled students found for this Program, Academic Year, and Semester.")
|
||||
|
||||
for student in students:
|
||||
# Check if result already exists
|
||||
existing = self.env['education.exam.result'].search([
|
||||
('student_id', '=', student.id),
|
||||
('exam_id', '=', rec.id)
|
||||
], limit=1)
|
||||
|
||||
])
|
||||
if not existing:
|
||||
existing = self.env['education.exam.result'].create({
|
||||
result = self.env['education.exam.result'].create({
|
||||
'student_id': student.id,
|
||||
'exam_id': rec.id,
|
||||
})
|
||||
|
||||
# Idempotently ensure all scheduled subjects exist as result lines without overwriting existing marks
|
||||
existing_subjects = existing.line_ids.mapped('subject_id.id')
|
||||
for line in rec.line_ids:
|
||||
if line.subject_id.id not in existing_subjects:
|
||||
# Create result lines based on schedule
|
||||
for line in rec.line_ids:
|
||||
self.env['education.exam.result.line'].create({
|
||||
'result_id': existing.id,
|
||||
'result_id': result.id,
|
||||
'subject_id': line.subject_id.id,
|
||||
'exam_type_id': rec.exam_type_id.id,
|
||||
'max_marks': line.max_marks,
|
||||
'pass_marks': line.pass_marks,
|
||||
'obtained_marks': -1.0,
|
||||
})
|
||||
|
||||
def action_publish_results(self):
|
||||
"""Publishes results after verifying all marks are entered and valid."""
|
||||
for rec in self:
|
||||
# Validations before publishing
|
||||
if not rec.result_ids:
|
||||
raise ValidationError("No results generated yet. Please generate results first.")
|
||||
raise ValidationError("No results generated yet.")
|
||||
for result in rec.result_ids:
|
||||
if not result.line_ids:
|
||||
raise ValidationError(f"Result for student {result.student_id.full_name} has no subjects.")
|
||||
raise ValidationError(f"Result for student {result.student_id.name} has no subjects.")
|
||||
if result.result_status == 'pending':
|
||||
raise ValidationError(f"Cannot publish results: Student {result.student_id.name} has pending evaluations.")
|
||||
for line in result.line_ids:
|
||||
if line.obtained_marks < 0:
|
||||
raise ValidationError(
|
||||
f"Marks not entered for subject '{line.subject_id.name}' for student '{result.student_id.full_name}'. "
|
||||
"All student marks must be entered before results can be published."
|
||||
)
|
||||
raise ValidationError(f"Marks not entered for {line.subject_id.name} for student {result.student_id.name}.")
|
||||
if line.obtained_marks > line.max_marks:
|
||||
raise ValidationError(
|
||||
f"Obtained marks ({line.obtained_marks}) cannot exceed maximum marks ({line.max_marks}) "
|
||||
f"for '{line.subject_id.name}' for student '{result.student_id.full_name}'."
|
||||
)
|
||||
if result.result_status == 'pending':
|
||||
raise ValidationError(
|
||||
f"Cannot publish results: Student '{result.student_id.full_name}' has pending evaluations."
|
||||
)
|
||||
raise ValidationError(f"Obtained marks cannot exceed maximum marks for {line.subject_id.name}.")
|
||||
|
||||
rec.write({
|
||||
'state': 'results_published',
|
||||
'result_published_date': fields.Date.today()
|
||||
})
|
||||
# Update publication status on results
|
||||
rec.result_ids.write({
|
||||
'published': True,
|
||||
'published_date': fields.Date.today()
|
||||
})
|
||||
|
||||
def action_unpublish_results(self):
|
||||
"""Allows Exam Administrator to unpublish results for audited corrections."""
|
||||
if not self.env.user.has_group('education_exam.group_education_exam_administrator'):
|
||||
raise ValidationError("Only an Examination Administrator can unpublish exam results.")
|
||||
for rec in self:
|
||||
rec.write({'state': 'completed'})
|
||||
rec.result_ids.write({'published': False})
|
||||
|
||||
def action_open_marks_entry(self):
|
||||
"""Opens the subject-wise marks entry wizard for this exam."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': 'Subject-wise Marks Entry',
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'education.exam.marks.entry.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_exam_id': self.id,
|
||||
},
|
||||
}
|
||||
|
||||
def action_reset_draft(self):
|
||||
for rec in self:
|
||||
if rec.state == 'results_published':
|
||||
raise ValidationError("Cannot reset published exams to draft. Unpublish results first.")
|
||||
rec.write({'state': 'draft'})
|
||||
|
||||
def write(self, vals):
|
||||
for rec in self:
|
||||
if rec.state == 'results_published':
|
||||
locked_fields = {'program_id', 'term_id', 'academic_year_id', 'grading_system_id', 'line_ids'}
|
||||
if any(f in vals for f in locked_fields):
|
||||
raise ValidationError(
|
||||
f"Exam '{rec.name}' has published results and cannot be modified. "
|
||||
"An Examination Administrator must unpublish results first."
|
||||
)
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
for rec in self:
|
||||
if rec.state == 'results_published':
|
||||
raise ValidationError(
|
||||
f"Cannot delete exam '{rec.name}' because its results are published. "
|
||||
"Unpublish results first."
|
||||
)
|
||||
return super().unlink()
|
||||
self.write({'state': 'draft'})
|
||||
|
||||
@api.constrains('start_date', 'end_date')
|
||||
def _check_exam_dates(self):
|
||||
|
||||
@ -20,39 +20,14 @@ class EducationExamResult(models.Model):
|
||||
('fail', 'Fail')
|
||||
], string='Result Status', compute='_compute_totals', store=True, default='pending')
|
||||
|
||||
published = fields.Boolean(string='Published', default=False)
|
||||
published = fields.Boolean(string='Published', default=False, tracking=True)
|
||||
published_date = fields.Date(string='Published Date')
|
||||
|
||||
line_ids = fields.One2many('education.exam.result.line', 'result_id', string='Subject Marks')
|
||||
|
||||
_unique_student_exam = models.Constraint(
|
||||
'unique(student_id, exam_id)',
|
||||
'A result already exists for this student for this exam!'
|
||||
)
|
||||
|
||||
def write(self, vals):
|
||||
for rec in self:
|
||||
if rec.published:
|
||||
if 'published' in vals and not vals['published']:
|
||||
if not self.env.user.has_group('education_exam.group_education_exam_administrator'):
|
||||
raise ValidationError("Only an Examination Administrator can unpublish exam results.")
|
||||
else:
|
||||
locked_fields = {'student_id', 'exam_id', 'line_ids'}
|
||||
if any(f in vals for f in locked_fields):
|
||||
raise ValidationError(
|
||||
f"Results for student '{rec.student_id.full_name}' are published and locked. "
|
||||
"An Examination Administrator must unpublish results first."
|
||||
)
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
for rec in self:
|
||||
if rec.published:
|
||||
raise ValidationError(
|
||||
f"Cannot delete published results for student '{rec.student_id.full_name}'. "
|
||||
"Unpublish results first."
|
||||
)
|
||||
return super().unlink()
|
||||
_sql_constraints = [
|
||||
('unique_student_exam', 'unique(student_id, exam_id)', 'A result already exists for this student for this exam!')
|
||||
]
|
||||
|
||||
@api.depends('line_ids.max_marks', 'line_ids.obtained_marks', 'line_ids.status', 'exam_id.grading_system_id')
|
||||
def _compute_totals(self):
|
||||
@ -111,45 +86,14 @@ class EducationExamResultLine(models.Model):
|
||||
|
||||
remarks = fields.Char(string='Remarks')
|
||||
|
||||
_unique_result_subject = models.Constraint(
|
||||
'unique(result_id, subject_id)',
|
||||
'A mark entry for this subject already exists in this exam result!'
|
||||
)
|
||||
|
||||
def write(self, vals):
|
||||
for rec in self:
|
||||
if rec.result_id.published:
|
||||
raise ValidationError(
|
||||
f"Cannot modify marks for subject '{rec.subject_id.name}' because the exam results are published and locked. "
|
||||
"An Examination Administrator must unpublish results first."
|
||||
)
|
||||
return super().write(vals)
|
||||
|
||||
def unlink(self):
|
||||
for rec in self:
|
||||
if rec.result_id.published:
|
||||
raise ValidationError(
|
||||
f"Cannot delete marks for subject '{rec.subject_id.name}' because the exam results are published."
|
||||
)
|
||||
return super().unlink()
|
||||
|
||||
@api.constrains('obtained_marks', 'max_marks', 'pass_marks')
|
||||
@api.constrains('obtained_marks', 'max_marks')
|
||||
def _check_marks(self):
|
||||
for record in self:
|
||||
if record.max_marks <= 0:
|
||||
raise ValidationError("Maximum marks must be greater than 0.")
|
||||
if record.pass_marks < 0:
|
||||
raise ValidationError("Pass marks cannot be negative.")
|
||||
if record.pass_marks > record.max_marks:
|
||||
raise ValidationError("Pass marks cannot exceed maximum marks.")
|
||||
if record.obtained_marks > record.max_marks:
|
||||
raise ValidationError(f"Obtained marks cannot exceed maximum marks for {record.subject_id.name}.")
|
||||
# Using -1 to indicate not entered yet. So < -1 is invalid.
|
||||
if record.obtained_marks < -1.0:
|
||||
raise ValidationError("Obtained marks cannot be negative (use -1 for pending).")
|
||||
if record.obtained_marks > record.max_marks:
|
||||
raise ValidationError(
|
||||
f"Obtained marks ({record.obtained_marks}) cannot exceed maximum marks ({record.max_marks}) "
|
||||
f"for {record.subject_id.name}."
|
||||
)
|
||||
raise ValidationError("Obtained marks cannot be negative.")
|
||||
|
||||
@api.depends('obtained_marks', 'max_marks', 'pass_marks', 'result_id.exam_id.grading_system_id')
|
||||
def _compute_grade_details(self):
|
||||
@ -169,6 +113,7 @@ class EducationExamResultLine(models.Model):
|
||||
if grade:
|
||||
rec.grade_id = grade.id
|
||||
rec.grade_point = grade.grade_point
|
||||
# Could override status based on grade if required, but usually marks determine subject pass/fail
|
||||
else:
|
||||
rec.grade_id = False
|
||||
rec.grade_point = 0.0
|
||||
@ -179,4 +124,4 @@ class EducationExamResultLine(models.Model):
|
||||
rec.percentage = 0.0
|
||||
rec.grade_id = False
|
||||
rec.grade_point = 0.0
|
||||
rec.status = 'pending'
|
||||
rec.status = 'pending' # default to pending until marks are entered
|
||||
|
||||
@ -17,7 +17,6 @@ class EducationSubject(models.Model):
|
||||
], string='Subject Type', required=True, default='theory')
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_code_unique = models.Constraint(
|
||||
'unique(code)',
|
||||
'Subject code must be unique!'
|
||||
)
|
||||
_sql_constraints = [
|
||||
('code_unique', 'unique(code)', 'Subject code must be unique!')
|
||||
]
|
||||
|
||||
@ -1,29 +1,17 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_education_subject_admin,education.subject.admin,model_education_subject,group_education_exam_administrator,1,1,1,1
|
||||
access_education_subject_teacher,education.subject.teacher,model_education_subject,group_education_exam_teacher,1,0,0,0
|
||||
access_education_subject_portal,education.subject.portal,model_education_subject,base.group_portal,1,0,0,0
|
||||
access_education_exam_type_admin,education.exam.type.admin,model_education_exam_type,group_education_exam_administrator,1,1,1,1
|
||||
access_education_exam_type_teacher,education.exam.type.teacher,model_education_exam_type,group_education_exam_teacher,1,0,0,0
|
||||
access_education_exam_type_portal,education.exam.type.portal,model_education_exam_type,base.group_portal,1,0,0,0
|
||||
access_education_grading_system_admin,education.grading.system.admin,model_education_grading_system,group_education_exam_administrator,1,1,1,1
|
||||
access_education_grading_system_teacher,education.grading.system.teacher,model_education_grading_system,group_education_exam_teacher,1,0,0,0
|
||||
access_education_grading_system_portal,education.grading.system.portal,model_education_grading_system,base.group_portal,1,0,0,0
|
||||
access_education_grade_admin,education.grade.admin,model_education_grade,group_education_exam_administrator,1,1,1,1
|
||||
access_education_grade_teacher,education.grade.teacher,model_education_grade,group_education_exam_teacher,1,0,0,0
|
||||
access_education_grade_portal,education.grade.portal,model_education_grade,base.group_portal,1,0,0,0
|
||||
access_education_exam_admin,education.exam.admin,model_education_exam,group_education_exam_administrator,1,1,1,1
|
||||
access_education_exam_teacher,education.exam.teacher,model_education_exam,group_education_exam_teacher,1,0,0,0
|
||||
access_education_exam_portal,education.exam.portal,model_education_exam,base.group_portal,1,0,0,0
|
||||
access_education_exam_line_admin,education.exam.line.admin,model_education_exam_line,group_education_exam_administrator,1,1,1,1
|
||||
access_education_exam_line_teacher,education.exam.line.teacher,model_education_exam_line,group_education_exam_teacher,1,0,0,0
|
||||
access_education_exam_line_portal,education.exam.line.portal,model_education_exam_line,base.group_portal,1,0,0,0
|
||||
access_education_exam_result_admin,education.exam.result.admin,model_education_exam_result,group_education_exam_administrator,1,1,1,1
|
||||
access_education_exam_result_teacher,education.exam.result.teacher,model_education_exam_result,group_education_exam_teacher,1,1,0,0
|
||||
access_education_exam_result_portal,education.exam.result.portal,model_education_exam_result,base.group_portal,1,0,0,0
|
||||
access_education_exam_result_line_admin,education.exam.result.line.admin,model_education_exam_result_line,group_education_exam_administrator,1,1,1,1
|
||||
access_education_exam_result_line_teacher,education.exam.result.line.teacher,model_education_exam_result_line,group_education_exam_teacher,1,1,0,0
|
||||
access_education_exam_result_line_portal,education.exam.result.line.portal,model_education_exam_result_line,base.group_portal,1,0,0,0
|
||||
access_education_exam_marks_entry_wizard_admin,education.exam.marks.entry.wizard.admin,model_education_exam_marks_entry_wizard,group_education_exam_administrator,1,1,1,1
|
||||
access_education_exam_marks_entry_wizard_teacher,education.exam.marks.entry.wizard.teacher,model_education_exam_marks_entry_wizard,group_education_exam_teacher,1,1,1,1
|
||||
access_education_exam_marks_entry_wizard_line_admin,education.exam.marks.entry.wizard.line.admin,model_education_exam_marks_entry_wizard_line,group_education_exam_administrator,1,1,1,1
|
||||
access_education_exam_marks_entry_wizard_line_teacher,education.exam.marks.entry.wizard.line.teacher,model_education_exam_marks_entry_wizard_line,group_education_exam_teacher,1,1,1,1
|
||||
|
||||
|
@ -16,57 +16,15 @@
|
||||
<record id="group_education_exam_administrator" model="res.groups">
|
||||
<field name="name">Administrator</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_education_exam_teacher'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</record>
|
||||
|
||||
<data noupdate="0">
|
||||
<!-- Record Rules: Teacher -->
|
||||
<!-- Record Rules -->
|
||||
<!-- Teachers can see only exams and results where they are assigned (simplified for now, full read access to exams) -->
|
||||
<record id="rule_education_exam_result_teacher" model="ir.rule">
|
||||
<field name="name">Teacher Exam Result Rule</field>
|
||||
<field name="model_id" ref="model_education_exam_result"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_education_exam_teacher'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_education_exam_result_line_teacher" model="ir.rule">
|
||||
<field name="name">Teacher Exam Result Line Rule</field>
|
||||
<field name="model_id" ref="model_education_exam_result_line"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_education_exam_teacher'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Record Rules: Portal (Student Isolation & Publication Enforcement) -->
|
||||
<record id="rule_education_exam_portal" model="ir.rule">
|
||||
<field name="name">Portal Student Exam View</field>
|
||||
<field name="model_id" ref="model_education_exam"/>
|
||||
<field name="domain_force">[('state', 'in', ['scheduled', 'ongoing', 'completed', 'results_published'])]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_education_exam_result_portal" model="ir.rule">
|
||||
<field name="name">Portal Student Exam Result Isolation</field>
|
||||
<field name="model_id" ref="model_education_exam_result"/>
|
||||
<field name="domain_force">[('student_id.portal_user_id', '=', user.id), ('published', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_education_exam_result_line_portal" model="ir.rule">
|
||||
<field name="name">Portal Student Result Line Isolation</field>
|
||||
<field name="model_id" ref="model_education_exam_result_line"/>
|
||||
<field name="domain_force">[('result_id.student_id.portal_user_id', '=', user.id), ('result_id.published', '=', True)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@ -1 +0,0 @@
|
||||
from . import test_exam_workflow
|
||||
@ -1,439 +0,0 @@
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError, AccessError
|
||||
|
||||
|
||||
class TestEducationExamWorkflow(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
|
||||
# Setup Academic Foundation
|
||||
cls.campus = cls.env['education.campus'].create({
|
||||
'name': 'Exam Test Campus',
|
||||
'code': 'EX-CAMP',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.level = cls.env['education.academic.level'].create({
|
||||
'name': 'Undergraduate',
|
||||
'code': 'UG-EX',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.department = cls.env['education.department'].create({
|
||||
'name': 'Engineering',
|
||||
'code': 'ENG-EX',
|
||||
'campus_id': cls.campus.id,
|
||||
})
|
||||
cls.academic_year = cls.env['education.academic.year'].create({
|
||||
'name': '2026-2027',
|
||||
'code': 'AY2026',
|
||||
'start_date': '2026-06-01',
|
||||
'end_date': '2027-05-31',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.academic_term = cls.env['education.academic.term'].create({
|
||||
'name': 'Term 1',
|
||||
'code': 'T1-2026',
|
||||
'academic_year_id': cls.academic_year.id,
|
||||
'start_date': '2026-06-01',
|
||||
'end_date': '2026-10-31',
|
||||
'sequence': 1,
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.program = cls.env['education.program'].create({
|
||||
'name': 'B.E. Computer Engineering',
|
||||
'code': 'BE-CE',
|
||||
'level_id': cls.level.id,
|
||||
'department_id': cls.department.id,
|
||||
'duration': 4,
|
||||
'duration_unit': 'years',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.batch = cls.env['education.batch'].create({
|
||||
'name': 'Exam Test Batch 2026',
|
||||
'code': 'EXT-B2026',
|
||||
})
|
||||
cls.semester = cls.env['education.semester'].create({
|
||||
'name': 'Exam Test Semester 1',
|
||||
'code': 'EXT-SEM1',
|
||||
})
|
||||
cls.section = cls.env['education.section'].create({
|
||||
'name': 'Exam Test Section A',
|
||||
'code': 'EXT-SEC-A',
|
||||
})
|
||||
|
||||
# Users and Security Groups
|
||||
cls.group_teacher = cls.env.ref('education_exam.group_education_exam_teacher')
|
||||
cls.group_admin = cls.env.ref('education_exam.group_education_exam_administrator')
|
||||
cls.group_portal = cls.env.ref('base.group_portal')
|
||||
|
||||
cls.user_teacher_1 = cls.env['res.users'].create({
|
||||
'name': 'Teacher Math',
|
||||
'login': 'teacher.math',
|
||||
'email': 'teacher.math@example.com',
|
||||
'group_ids': [(6, 0, [cls.group_teacher.id])],
|
||||
})
|
||||
cls.user_teacher_2 = cls.env['res.users'].create({
|
||||
'name': 'Teacher Physics',
|
||||
'login': 'teacher.physics',
|
||||
'email': 'teacher.physics@example.com',
|
||||
'group_ids': [(6, 0, [cls.group_teacher.id])],
|
||||
})
|
||||
cls.user_admin = cls.env['res.users'].create({
|
||||
'name': 'Exam Controller Admin',
|
||||
'login': 'exam.admin',
|
||||
'email': 'exam.admin@example.com',
|
||||
'group_ids': [(6, 0, [cls.group_admin.id])],
|
||||
})
|
||||
|
||||
cls.user_portal_1 = cls.env['res.users'].create({
|
||||
'name': 'Student One Portal User',
|
||||
'login': 'student1.portal',
|
||||
'email': 'student1@example.com',
|
||||
'group_ids': [(6, 0, [cls.group_portal.id])],
|
||||
})
|
||||
cls.user_portal_2 = cls.env['res.users'].create({
|
||||
'name': 'Student Two Portal User',
|
||||
'login': 'student2.portal',
|
||||
'email': 'student2@example.com',
|
||||
'group_ids': [(6, 0, [cls.group_portal.id])],
|
||||
})
|
||||
|
||||
# Students (Active)
|
||||
cls.student_1 = cls.env['education.student'].create({
|
||||
'first_name': 'Aarav',
|
||||
'last_name': 'Kumar',
|
||||
'student_id': 'STD-001',
|
||||
'campus_id': cls.campus.id,
|
||||
'department_id': cls.department.id,
|
||||
'program_id': cls.program.id,
|
||||
'level_id': cls.level.id,
|
||||
'academic_year_id': cls.academic_year.id,
|
||||
'academic_term_id': cls.academic_term.id,
|
||||
'batch_id': cls.batch.id,
|
||||
'semester_id': cls.semester.id,
|
||||
'section_id': cls.section.id,
|
||||
'company_id': cls.company.id,
|
||||
'portal_user_id': cls.user_portal_1.id,
|
||||
})
|
||||
cls.student_1.action_admit()
|
||||
cls.student_1.action_activate()
|
||||
|
||||
cls.student_2 = cls.env['education.student'].create({
|
||||
'first_name': 'Diya',
|
||||
'last_name': 'Patel',
|
||||
'student_id': 'STD-002',
|
||||
'campus_id': cls.campus.id,
|
||||
'department_id': cls.department.id,
|
||||
'program_id': cls.program.id,
|
||||
'level_id': cls.level.id,
|
||||
'academic_year_id': cls.academic_year.id,
|
||||
'academic_term_id': cls.academic_term.id,
|
||||
'batch_id': cls.batch.id,
|
||||
'semester_id': cls.semester.id,
|
||||
'section_id': cls.section.id,
|
||||
'company_id': cls.company.id,
|
||||
'portal_user_id': cls.user_portal_2.id,
|
||||
})
|
||||
cls.student_2.action_admit()
|
||||
cls.student_2.action_activate()
|
||||
|
||||
# Subjects
|
||||
cls.subject_math = cls.env['education.subject'].create({
|
||||
'name': 'Mathematics I',
|
||||
'code': 'MATH101-EXT',
|
||||
'program_id': cls.program.id,
|
||||
'academic_year_id': cls.academic_year.id,
|
||||
'term_id': cls.academic_term.id,
|
||||
'credits': 4.0,
|
||||
'subject_type': 'theory',
|
||||
})
|
||||
cls.subject_physics = cls.env['education.subject'].create({
|
||||
'name': 'Physics I',
|
||||
'code': 'PHYS101-EXT',
|
||||
'program_id': cls.program.id,
|
||||
'academic_year_id': cls.academic_year.id,
|
||||
'term_id': cls.academic_term.id,
|
||||
'credits': 4.0,
|
||||
'subject_type': 'both',
|
||||
})
|
||||
|
||||
# Grading System and Exam Type
|
||||
cls.grading_system = cls.env.ref('education_exam.default_grading_system')
|
||||
cls.exam_type = cls.env['education.exam.type'].create({
|
||||
'name': 'Semester Final Examination Test',
|
||||
'code': 'SEM_FINAL_EXT',
|
||||
'weightage': 100.0,
|
||||
})
|
||||
|
||||
def _create_standard_exam(self):
|
||||
"""Helper to create an ongoing exam with 2 subjects."""
|
||||
today = date.today()
|
||||
exam = self.env['education.exam'].create({
|
||||
'name': 'Fall 2026 Semester Exams',
|
||||
'code': 'EX-FALL-2026',
|
||||
'exam_type_id': self.exam_type.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'program_id': self.program.id,
|
||||
'term_id': self.academic_term.id,
|
||||
'grading_system_id': self.grading_system.id,
|
||||
'start_date': today - timedelta(days=5),
|
||||
'end_date': today + timedelta(days=5),
|
||||
'line_ids': [
|
||||
(0, 0, {
|
||||
'subject_id': self.subject_math.id,
|
||||
'exam_date': today - timedelta(days=1),
|
||||
'start_time': 9.0,
|
||||
'end_time': 12.0,
|
||||
'max_marks': 100.0,
|
||||
'pass_marks': 40.0,
|
||||
'faculty_id': self.user_teacher_1.id,
|
||||
}),
|
||||
(0, 0, {
|
||||
'subject_id': self.subject_physics.id,
|
||||
'exam_date': today,
|
||||
'start_time': 0.0,
|
||||
'end_time': 1.0,
|
||||
'max_marks': 100.0,
|
||||
'pass_marks': 40.0,
|
||||
'faculty_id': self.user_teacher_2.id,
|
||||
}),
|
||||
]
|
||||
})
|
||||
exam.action_schedule()
|
||||
exam.action_start()
|
||||
return exam
|
||||
|
||||
def test_01_lifecycle_transitions(self):
|
||||
"""Test full exam lifecycle transitions: draft -> scheduled -> ongoing -> completed."""
|
||||
today = date.today()
|
||||
exam = self.env['education.exam'].create({
|
||||
'name': 'Lifecycle Exam',
|
||||
'code': 'EX-LIFECYCLE',
|
||||
'exam_type_id': self.exam_type.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'program_id': self.program.id,
|
||||
'term_id': self.academic_term.id,
|
||||
'grading_system_id': self.grading_system.id,
|
||||
'start_date': today - timedelta(days=5),
|
||||
'end_date': today + timedelta(days=5),
|
||||
'line_ids': [
|
||||
(0, 0, {
|
||||
'subject_id': self.subject_math.id,
|
||||
'exam_date': today - timedelta(days=1),
|
||||
'start_time': 9.0,
|
||||
'end_time': 12.0,
|
||||
'max_marks': 100.0,
|
||||
'pass_marks': 40.0,
|
||||
'faculty_id': self.user_teacher_1.id,
|
||||
}),
|
||||
]
|
||||
})
|
||||
self.assertEqual(exam.state, 'draft')
|
||||
exam.action_schedule()
|
||||
self.assertEqual(exam.state, 'scheduled')
|
||||
exam.action_start()
|
||||
self.assertEqual(exam.state, 'ongoing')
|
||||
exam.action_complete()
|
||||
self.assertEqual(exam.state, 'completed')
|
||||
|
||||
def test_02_idempotent_result_generation(self):
|
||||
"""Calling action_generate_results multiple times must not duplicate results or wipe marks."""
|
||||
exam = self._create_standard_exam()
|
||||
exam.action_complete()
|
||||
|
||||
# First run: generates results for student_1 and student_2
|
||||
exam.action_generate_results()
|
||||
self.assertEqual(len(exam.result_ids), 2)
|
||||
res_s1 = exam.result_ids.filtered(lambda r: r.student_id == self.student_1)
|
||||
self.assertEqual(len(res_s1.line_ids), 2)
|
||||
|
||||
# Enter mark for Math for Student 1
|
||||
math_line = res_s1.line_ids.filtered(lambda l: l.subject_id == self.subject_math)
|
||||
math_line.obtained_marks = 85.0
|
||||
self.assertEqual(math_line.obtained_marks, 85.0)
|
||||
|
||||
# Second run: must preserve the 85.0 marks and not create duplicate results or lines
|
||||
exam.action_generate_results()
|
||||
self.assertEqual(len(exam.result_ids), 2)
|
||||
res_s1_check = exam.result_ids.filtered(lambda r: r.student_id == self.student_1)
|
||||
self.assertEqual(len(res_s1_check.line_ids), 2)
|
||||
math_line_check = res_s1_check.line_ids.filtered(lambda l: l.subject_id == self.subject_math)
|
||||
self.assertEqual(math_line_check.obtained_marks, 85.0)
|
||||
|
||||
def test_03_marks_validation(self):
|
||||
"""Marks must be validated against boundaries (-1 for pending, 0 <= marks <= max_marks)."""
|
||||
exam = self._create_standard_exam()
|
||||
exam.action_generate_results()
|
||||
result_line = exam.result_ids[0].line_ids[0]
|
||||
|
||||
# Negative mark < -1 should raise ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
result_line.obtained_marks = -5.0
|
||||
|
||||
# Mark > max_marks should raise ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
result_line.obtained_marks = 105.0
|
||||
|
||||
# Valid pending mark (-1.0) and valid mark (75.0) should succeed
|
||||
result_line.obtained_marks = -1.0
|
||||
self.assertEqual(result_line.obtained_marks, -1.0)
|
||||
result_line.obtained_marks = 75.0
|
||||
self.assertEqual(result_line.obtained_marks, 75.0)
|
||||
|
||||
def test_04_grade_calculation(self):
|
||||
"""Verify grade and percentage calculation using the configured grading system."""
|
||||
exam = self._create_standard_exam()
|
||||
exam.action_generate_results()
|
||||
res_s1 = exam.result_ids.filtered(lambda r: r.student_id == self.student_1)
|
||||
|
||||
line_math = res_s1.line_ids.filtered(lambda l: l.subject_id == self.subject_math)
|
||||
line_physics = res_s1.line_ids.filtered(lambda l: l.subject_id == self.subject_physics)
|
||||
|
||||
# Math: 92/100 -> Grade O (Pass)
|
||||
line_math.obtained_marks = 92.0
|
||||
self.assertEqual(line_math.status, 'pass')
|
||||
self.assertEqual(line_math.grade_id.name, 'O')
|
||||
self.assertEqual(line_math.grade_point, 10.0)
|
||||
|
||||
# Physics: 35/100 -> Grade F / Fail (< 40 pass marks)
|
||||
line_physics.obtained_marks = 35.0
|
||||
self.assertEqual(line_physics.status, 'fail')
|
||||
|
||||
# Overall Result Status should be Fail since one subject failed
|
||||
self.assertEqual(res_s1.result_status, 'fail')
|
||||
self.assertAlmostEqual(res_s1.total_obtained_marks, 127.0)
|
||||
self.assertAlmostEqual(res_s1.percentage, 63.5)
|
||||
|
||||
# Now pass Physics with 85/100 -> Grade A+
|
||||
line_physics.obtained_marks = 85.0
|
||||
self.assertEqual(line_physics.status, 'pass')
|
||||
self.assertEqual(res_s1.result_status, 'pass')
|
||||
self.assertAlmostEqual(res_s1.percentage, 88.5)
|
||||
self.assertEqual(res_s1.grade_id.name, 'A+')
|
||||
|
||||
def test_05_duplicate_prevention(self):
|
||||
"""Prevent duplicate exam results for the same student and duplicate subject lines."""
|
||||
exam = self._create_standard_exam()
|
||||
exam.action_generate_results()
|
||||
|
||||
# Duplicate exam result for student 1
|
||||
with self.assertRaises(Exception):
|
||||
self.env['education.exam.result'].create({
|
||||
'student_id': self.student_1.id,
|
||||
'exam_id': exam.id,
|
||||
})
|
||||
self.env.flush_all()
|
||||
|
||||
def test_06_publication_locking_and_unpublish(self):
|
||||
"""Results cannot be published with pending marks, and published results are locked against edits."""
|
||||
exam = self._create_standard_exam()
|
||||
exam.action_complete()
|
||||
exam.action_generate_results()
|
||||
|
||||
# Attempt to publish while marks are pending (-1.0) must fail
|
||||
with self.assertRaises(ValidationError):
|
||||
exam.action_publish_results()
|
||||
|
||||
# Fill in all marks
|
||||
for result in exam.result_ids:
|
||||
for line in result.line_ids:
|
||||
line.obtained_marks = 80.0
|
||||
|
||||
# Now publish should succeed
|
||||
exam.action_publish_results()
|
||||
self.assertEqual(exam.state, 'results_published')
|
||||
self.assertTrue(exam.result_ids[0].published)
|
||||
|
||||
# Direct write to published result line must be blocked
|
||||
with self.assertRaises(ValidationError):
|
||||
exam.result_ids[0].line_ids[0].write({'obtained_marks': 95.0})
|
||||
|
||||
# Direct deletion of published result line must be blocked
|
||||
with self.assertRaises(ValidationError):
|
||||
exam.result_ids[0].line_ids[0].unlink()
|
||||
|
||||
# Direct deletion of published exam result must be blocked
|
||||
with self.assertRaises(ValidationError):
|
||||
exam.result_ids[0].unlink()
|
||||
|
||||
# Non-admin user cannot unpublish
|
||||
exam_as_teacher = exam.with_user(self.user_teacher_1)
|
||||
with self.assertRaises(ValidationError):
|
||||
exam_as_teacher.action_unpublish_results()
|
||||
|
||||
# Exam Administrator can unpublish for audited corrections
|
||||
exam_as_admin = exam.with_user(self.user_admin)
|
||||
exam_as_admin.action_unpublish_results()
|
||||
self.assertEqual(exam.state, 'completed')
|
||||
self.assertFalse(exam.result_ids[0].published)
|
||||
|
||||
# Now authorized mark corrections can be made
|
||||
exam.result_ids[0].line_ids[0].write({'obtained_marks': 95.0})
|
||||
self.assertEqual(exam.result_ids[0].line_ids[0].obtained_marks, 95.0)
|
||||
|
||||
def test_07_student_portal_isolation(self):
|
||||
"""Portal student user can ONLY read their own published results."""
|
||||
exam = self._create_standard_exam()
|
||||
exam.action_complete()
|
||||
exam.action_generate_results()
|
||||
|
||||
for result in exam.result_ids:
|
||||
for line in result.line_ids:
|
||||
line.obtained_marks = 78.0
|
||||
|
||||
# Before publishing, portal user 1 sees NO results
|
||||
results_p1_before = self.env['education.exam.result'].with_user(self.user_portal_1).search([])
|
||||
self.assertEqual(len(results_p1_before), 0)
|
||||
|
||||
# Publish results
|
||||
exam.action_publish_results()
|
||||
|
||||
# After publishing, portal user 1 sees ONLY their own result
|
||||
results_p1_after = self.env['education.exam.result'].with_user(self.user_portal_1).search([])
|
||||
self.assertEqual(len(results_p1_after), 1)
|
||||
self.assertEqual(results_p1_after.student_id, self.student_1)
|
||||
|
||||
# Portal user 2 sees ONLY their own result
|
||||
results_p2_after = self.env['education.exam.result'].with_user(self.user_portal_2).search([])
|
||||
self.assertEqual(len(results_p2_after), 1)
|
||||
self.assertEqual(results_p2_after.student_id, self.student_2)
|
||||
|
||||
def test_08_marks_entry_wizard(self):
|
||||
"""Subject-wise marks entry wizard populates students and enforces teacher assignment."""
|
||||
exam = self._create_standard_exam()
|
||||
exam.action_complete()
|
||||
exam.action_generate_results()
|
||||
|
||||
# Teacher 1 is assigned to Mathematics
|
||||
wizard = self.env['education.exam.marks.entry.wizard'].with_user(self.user_teacher_1).create({
|
||||
'exam_id': exam.id,
|
||||
'subject_id': self.subject_math.id,
|
||||
})
|
||||
wizard.action_load_students()
|
||||
self.assertEqual(len(wizard.line_ids), 2)
|
||||
self.assertEqual(wizard.max_marks, 100.0)
|
||||
|
||||
# Teacher 1 enters marks
|
||||
for line in wizard.line_ids:
|
||||
line.obtained_marks = 88.0
|
||||
wizard.action_save_marks()
|
||||
|
||||
# Check marks saved in result lines
|
||||
math_lines = self.env['education.exam.result.line'].search([
|
||||
('result_id.exam_id', '=', exam.id),
|
||||
('subject_id', '=', self.subject_math.id),
|
||||
])
|
||||
for ml in math_lines:
|
||||
self.assertEqual(ml.obtained_marks, 88.0)
|
||||
self.assertEqual(ml.status, 'pass')
|
||||
|
||||
# Teacher 1 attempts to enter marks for Physics (assigned to Teacher 2) -> Access Denied
|
||||
wizard_physics = self.env['education.exam.marks.entry.wizard'].with_user(self.user_teacher_1).create({
|
||||
'exam_id': exam.id,
|
||||
'subject_id': self.subject_physics.id,
|
||||
})
|
||||
with self.assertRaises(ValidationError):
|
||||
wizard_physics.action_save_marks()
|
||||
@ -63,10 +63,8 @@
|
||||
<button name="action_schedule" string="Schedule" type="object" invisible="state != 'draft'" class="oe_highlight"/>
|
||||
<button name="action_start" string="Start Exam" type="object" invisible="state != 'scheduled'" class="oe_highlight"/>
|
||||
<button name="action_complete" string="Complete" type="object" invisible="state != 'ongoing'" class="oe_highlight"/>
|
||||
<button name="action_generate_results" string="Generate Results" type="object" invisible="state not in ('ongoing', 'completed')" class="oe_highlight"/>
|
||||
<button name="action_open_marks_entry" string="Enter Marks" type="object" invisible="state not in ('ongoing', 'completed')" class="oe_highlight"/>
|
||||
<button name="action_generate_results" string="Generate Results" type="object" invisible="state != 'completed'" class="oe_highlight"/>
|
||||
<button name="action_publish_results" string="Publish Results" type="object" invisible="state != 'completed'" class="oe_highlight"/>
|
||||
<button name="action_unpublish_results" string="Unpublish Results" type="object" groups="education_exam.group_education_exam_administrator" invisible="state != 'results_published'"/>
|
||||
<button name="action_reset_draft" string="Reset to Draft" type="object" invisible="state not in ('scheduled', 'ongoing', 'completed')"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,scheduled,ongoing,completed,results_published"/>
|
||||
</header>
|
||||
|
||||
@ -5,8 +5,7 @@
|
||||
|
||||
<!-- Operations -->
|
||||
<menuitem id="menu_education_exam_exams" name="Exams" parent="menu_education_examinations" action="action_education_exam" sequence="10"/>
|
||||
<menuitem id="menu_education_marks_entry_wizard" name="Subject Marks Entry" parent="menu_education_examinations" action="action_education_marks_entry_wizard" sequence="20"/>
|
||||
<menuitem id="menu_education_marks_entry" name="Student Results Entry" parent="menu_education_examinations" action="action_education_marks_entry" sequence="25"/>
|
||||
<menuitem id="menu_education_marks_entry" name="Marks Entry" parent="menu_education_examinations" action="action_education_marks_entry" sequence="20"/>
|
||||
<menuitem id="menu_education_exam_results" name="Results" parent="menu_education_examinations" action="action_education_exam_result" sequence="30"/>
|
||||
|
||||
<!-- Configuration -->
|
||||
|
||||
@ -1 +0,0 @@
|
||||
from . import marks_entry_wizard
|
||||
@ -1,152 +0,0 @@
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
class EducationExamMarksEntryWizard(models.TransientModel):
|
||||
_name = 'education.exam.marks.entry.wizard'
|
||||
_description = 'Subject-wise Marks Entry Wizard'
|
||||
|
||||
exam_id = fields.Many2one(
|
||||
'education.exam',
|
||||
string='Exam',
|
||||
required=True,
|
||||
domain="[('state', 'in', ['ongoing', 'completed'])]"
|
||||
)
|
||||
subject_id = fields.Many2one(
|
||||
'education.subject',
|
||||
string='Subject',
|
||||
required=True
|
||||
)
|
||||
max_marks = fields.Float(string='Maximum Marks', readonly=True)
|
||||
pass_marks = fields.Float(string='Pass Marks', readonly=True)
|
||||
faculty_id = fields.Many2one('res.users', string='Assigned Faculty', readonly=True)
|
||||
line_ids = fields.One2many(
|
||||
'education.exam.marks.entry.wizard.line',
|
||||
'wizard_id',
|
||||
string='Student Marks'
|
||||
)
|
||||
|
||||
@api.onchange('exam_id')
|
||||
def _onchange_exam_id(self):
|
||||
self.subject_id = False
|
||||
self.line_ids = [(5, 0, 0)]
|
||||
if self.exam_id:
|
||||
# Restrict subject domain to subjects scheduled in this exam
|
||||
subject_ids = self.exam_id.line_ids.mapped('subject_id.id')
|
||||
return {'domain': {'subject_id': [('id', 'in', subject_ids)]}}
|
||||
return {'domain': {'subject_id': []}}
|
||||
|
||||
@api.onchange('exam_id', 'subject_id')
|
||||
def _onchange_exam_and_subject(self):
|
||||
if self.exam_id and self.subject_id:
|
||||
self._check_teacher_authorization()
|
||||
self._load_subject_details()
|
||||
self._load_student_lines()
|
||||
|
||||
def _check_teacher_authorization(self):
|
||||
self.ensure_one()
|
||||
if not self.env.user.has_group('education_exam.group_education_exam_administrator'):
|
||||
schedule_line = self.env['education.exam.line'].search([
|
||||
('exam_id', '=', self.exam_id.id),
|
||||
('subject_id', '=', self.subject_id.id),
|
||||
], limit=1)
|
||||
if schedule_line and schedule_line.faculty_id and schedule_line.faculty_id != self.env.user:
|
||||
raise ValidationError(
|
||||
f"Access Denied: You are not authorized to enter marks for subject '{self.subject_id.name}'. "
|
||||
f"This subject is assigned to {schedule_line.faculty_id.name}."
|
||||
)
|
||||
|
||||
def _load_subject_details(self):
|
||||
schedule_line = self.env['education.exam.line'].search([
|
||||
('exam_id', '=', self.exam_id.id),
|
||||
('subject_id', '=', self.subject_id.id),
|
||||
], limit=1)
|
||||
if schedule_line:
|
||||
self.max_marks = schedule_line.max_marks
|
||||
self.pass_marks = schedule_line.pass_marks
|
||||
self.faculty_id = schedule_line.faculty_id.id
|
||||
else:
|
||||
self.max_marks = 0.0
|
||||
self.pass_marks = 0.0
|
||||
self.faculty_id = False
|
||||
|
||||
def _load_student_lines(self):
|
||||
self.line_ids = [(5, 0, 0)]
|
||||
result_lines = self.env['education.exam.result.line'].sudo().search([
|
||||
('result_id.exam_id', '=', self.exam_id.id),
|
||||
('subject_id', '=', self.subject_id.id),
|
||||
])
|
||||
lines = []
|
||||
for rl in result_lines:
|
||||
student = rl.result_id.student_id.sudo()
|
||||
lines.append((0, 0, {
|
||||
'result_line_id': rl.id,
|
||||
'student_id': student.id,
|
||||
'roll_number': student.roll_number or '',
|
||||
'admission_number': student.admission_number or '',
|
||||
'obtained_marks': rl.obtained_marks,
|
||||
'remarks': rl.remarks or '',
|
||||
}))
|
||||
self.line_ids = lines
|
||||
|
||||
def action_load_students(self):
|
||||
"""Action button to reload student lines explicitly."""
|
||||
self.ensure_one()
|
||||
self._check_teacher_authorization()
|
||||
self._load_subject_details()
|
||||
self._load_student_lines()
|
||||
return {'type': 'ir.actions.do_nothing'}
|
||||
|
||||
def action_save_marks(self):
|
||||
self.ensure_one()
|
||||
self._check_teacher_authorization()
|
||||
if self.exam_id.state == 'results_published':
|
||||
raise ValidationError("Cannot save marks for published exams. Results must be unpublished first.")
|
||||
|
||||
if not self.line_ids:
|
||||
raise ValidationError("No student marks to save. Please select a valid exam and subject, or generate results first.")
|
||||
|
||||
for line in self.line_ids:
|
||||
if line.obtained_marks < -1.0:
|
||||
raise ValidationError(
|
||||
f"Marks for student '{line.student_id.full_name}' cannot be negative (use -1 for pending)."
|
||||
)
|
||||
if line.obtained_marks > self.max_marks:
|
||||
raise ValidationError(
|
||||
f"Marks for student '{line.student_id.full_name}' ({line.obtained_marks}) "
|
||||
f"cannot exceed maximum marks ({self.max_marks})."
|
||||
)
|
||||
if line.result_line_id:
|
||||
line.result_line_id.write({
|
||||
'obtained_marks': line.obtained_marks,
|
||||
'remarks': line.remarks,
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Marks Saved',
|
||||
'message': f"Marks successfully saved for '{self.subject_id.name}'.",
|
||||
'type': 'success',
|
||||
'sticky': False,
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class EducationExamMarksEntryWizardLine(models.TransientModel):
|
||||
_name = 'education.exam.marks.entry.wizard.line'
|
||||
_description = 'Subject-wise Marks Entry Wizard Line'
|
||||
|
||||
wizard_id = fields.Many2one(
|
||||
'education.exam.marks.entry.wizard',
|
||||
string='Wizard',
|
||||
required=True,
|
||||
ondelete='cascade'
|
||||
)
|
||||
result_line_id = fields.Many2one('education.exam.result.line', string='Exam Result Line')
|
||||
student_id = fields.Many2one('education.student', string='Student', required=True)
|
||||
roll_number = fields.Char(string='Roll No.', readonly=True)
|
||||
admission_number = fields.Char(string='Admission No.', readonly=True)
|
||||
obtained_marks = fields.Float(string='Obtained Marks', default=-1.0, required=True)
|
||||
remarks = fields.Char(string='Remarks')
|
||||
@ -1,49 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_education_exam_marks_entry_wizard_form" model="ir.ui.view">
|
||||
<field name="name">education.exam.marks.entry.wizard.form</field>
|
||||
<field name="model">education.exam.marks.entry.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Subject-wise Marks Entry">
|
||||
<sheet>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Evaluation Instructions:</strong> Enter marks between 0 and the Maximum Marks. Use <strong>-1</strong> to keep a mark as Pending / Un-evaluated.
|
||||
</div>
|
||||
<group>
|
||||
<group string="Exam & Subject Selection">
|
||||
<field name="exam_id" options="{'no_create': True}"/>
|
||||
<field name="subject_id" options="{'no_create': True}"/>
|
||||
</group>
|
||||
<group string="Subject Benchmark">
|
||||
<field name="max_marks"/>
|
||||
<field name="pass_marks"/>
|
||||
<field name="faculty_id" options="{'no_create': True}"/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="o_horizontal_separator mb-2">Student Mark List</div>
|
||||
<field name="line_ids">
|
||||
<list editable="bottom" create="0" delete="0">
|
||||
<field name="student_id" readonly="1"/>
|
||||
<field name="roll_number" readonly="1"/>
|
||||
<field name="admission_number" readonly="1"/>
|
||||
<field name="obtained_marks"/>
|
||||
<field name="remarks"/>
|
||||
</list>
|
||||
</field>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_save_marks" string="Save Marks" type="object" class="btn-primary"/>
|
||||
<button name="action_load_students" string="Reload Students" type="object" class="btn-secondary"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_marks_entry_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Subject Marks Entry</field>
|
||||
<field name="res_model">education.exam.marks.entry.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@ -9,13 +9,4 @@
|
||||
<field name="number_increment">1</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
<record id="seq_education_fee_assignment" model="ir.sequence">
|
||||
<field name="name">Fee Assignment / Invoice Sequence</field>
|
||||
<field name="code">education.fee.assignment</field>
|
||||
<field name="prefix">INV/%(year)s/</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
# pyrefly: ignore [missing-import]
|
||||
import datetime
|
||||
from odoo import api, fields, models
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo.exceptions import ValidationError
|
||||
@ -7,55 +6,18 @@ from odoo.exceptions import ValidationError
|
||||
|
||||
class EducationFeeAssignment(models.Model):
|
||||
_name = 'education.fee.assignment'
|
||||
_description = 'Student Fee Invoice / Assignment'
|
||||
_order = 'assignment_date desc, id desc'
|
||||
_description = 'Student Fee Assignment'
|
||||
_order = 'assignment_date desc'
|
||||
|
||||
name = fields.Char(string='Invoice Number', copy=False, readonly=True, default='/', index=True)
|
||||
student_id = fields.Many2one('education.student', string='Student', required=True, index=True)
|
||||
academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True, index=True)
|
||||
academic_term_id = fields.Many2one('education.academic.term', string='Academic Term', index=True)
|
||||
semester_id = fields.Many2one('education.semester', string='Semester', required=True, index=True)
|
||||
fee_structure_id = fields.Many2one('education.fee.structure', string='Fee Structure', required=True, index=True)
|
||||
assignment_date = fields.Date(string='Assignment Date', required=True, default=fields.Date.context_today)
|
||||
due_date = fields.Date(
|
||||
string='Due Date',
|
||||
required=True,
|
||||
default=fields.Date.context_today,
|
||||
index=True
|
||||
)
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('confirmed', 'Confirmed'),
|
||||
], string='Status', default='draft', required=True, index=True)
|
||||
currency_id = fields.Many2one('res.currency', related='fee_structure_id.currency_id', store=True)
|
||||
line_ids = fields.One2many(related='fee_structure_id.line_ids', string='Fee Lines', readonly=True)
|
||||
payment_ids = fields.One2many('education.student.payment', 'fee_assignment_id', string='Payments')
|
||||
|
||||
amount = fields.Monetary(
|
||||
string='Total Invoiced Amount',
|
||||
compute='_compute_amounts',
|
||||
store=True,
|
||||
currency_field='currency_id'
|
||||
)
|
||||
amount_paid = fields.Monetary(
|
||||
string='Amount Paid',
|
||||
compute='_compute_amounts',
|
||||
store=True,
|
||||
currency_field='currency_id'
|
||||
)
|
||||
amount_due = fields.Monetary(
|
||||
string='Amount Due',
|
||||
compute='_compute_amounts',
|
||||
store=True,
|
||||
currency_field='currency_id'
|
||||
)
|
||||
payment_status = fields.Selection([
|
||||
('unpaid', 'Unpaid'),
|
||||
('partial', 'Partially Paid'),
|
||||
('paid', 'Paid'),
|
||||
('overdue', 'Overdue'),
|
||||
], string='Payment Status', compute='_compute_amounts', store=True, index=True)
|
||||
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
@ -65,60 +27,9 @@ class EducationFeeAssignment(models.Model):
|
||||
index=True,
|
||||
)
|
||||
|
||||
@api.onchange('fee_structure_id', 'assignment_date')
|
||||
def _onchange_fee_structure(self):
|
||||
if self.fee_structure_id and self.assignment_date:
|
||||
days = getattr(self.fee_structure_id, 'due_days', 30) or 30
|
||||
self.due_date = self.assignment_date + datetime.timedelta(days=days)
|
||||
|
||||
@api.constrains('student_id', 'academic_year_id', 'semester_id', 'fee_structure_id', 'company_id')
|
||||
def _check_unique_assignment(self):
|
||||
for rec in self:
|
||||
domain = [
|
||||
('id', '!=', rec.id),
|
||||
('student_id', '=', rec.student_id.id),
|
||||
('academic_year_id', '=', rec.academic_year_id.id),
|
||||
('semester_id', '=', rec.semester_id.id),
|
||||
('fee_structure_id', '=', rec.fee_structure_id.id),
|
||||
('company_id', '=', rec.company_id.id),
|
||||
('active', '=', True),
|
||||
]
|
||||
if self.search_count(domain) > 0:
|
||||
raise ValidationError("This fee structure is already assigned to this student for this semester.")
|
||||
|
||||
@api.depends(
|
||||
'fee_structure_id.line_ids.amount',
|
||||
'payment_ids.amount',
|
||||
'payment_ids.status',
|
||||
'due_date',
|
||||
'state'
|
||||
)
|
||||
def _compute_amounts(self):
|
||||
today = fields.Date.today()
|
||||
for rec in self:
|
||||
total = sum(line.amount for line in rec.fee_structure_id.line_ids) if rec.fee_structure_id else 0.0
|
||||
rec.amount = total
|
||||
|
||||
confirmed_payments = rec.payment_ids.filtered(lambda p: p.status == 'confirmed')
|
||||
paid = sum(p.amount for p in confirmed_payments)
|
||||
|
||||
# Check if student's overall dues are fully cleared and no specific allocation was attached
|
||||
if paid == 0 and total > 0 and rec.student_id.outstanding_amount == 0 and rec.student_id.total_paid >= total:
|
||||
paid = total
|
||||
|
||||
rec.amount_paid = paid
|
||||
rec.amount_due = max(0.0, total - paid)
|
||||
|
||||
if rec.state != 'confirmed':
|
||||
rec.payment_status = 'unpaid'
|
||||
elif total == 0 or rec.amount_due <= 0:
|
||||
rec.payment_status = 'paid'
|
||||
elif rec.due_date and rec.due_date < today:
|
||||
rec.payment_status = 'overdue'
|
||||
elif paid > 0:
|
||||
rec.payment_status = 'partial'
|
||||
else:
|
||||
rec.payment_status = 'unpaid'
|
||||
_sql_constraints = [
|
||||
('uniq_assignment', 'unique(student_id, academic_year_id, semester_id, fee_structure_id, company_id)', 'This fee structure is already assigned to this student for this semester.'),
|
||||
]
|
||||
|
||||
def action_confirm(self):
|
||||
for rec in self:
|
||||
@ -126,11 +37,6 @@ class EducationFeeAssignment(models.Model):
|
||||
continue
|
||||
if not rec.fee_structure_id.line_ids:
|
||||
raise ValidationError(f"The selected fee structure '{rec.fee_structure_id.name}' has no fee lines. Please add fee lines to this structure before confirming.")
|
||||
|
||||
if not rec.name or rec.name == '/':
|
||||
seq = self.env['ir.sequence'].next_by_code('education.fee.assignment')
|
||||
rec.name = seq or f"INV/{rec.id:05d}"
|
||||
|
||||
for line in rec.fee_structure_id.line_ids:
|
||||
self.env['education.student.ledger'].create({
|
||||
'student_id': rec.student_id.id,
|
||||
@ -139,8 +45,8 @@ class EducationFeeAssignment(models.Model):
|
||||
'category_id': line.fee_type_id.category_id.id,
|
||||
'fee_type_id': line.fee_type_id.id,
|
||||
'date': rec.assignment_date,
|
||||
'ref': rec.name,
|
||||
'description': f"{line.fee_type_id.name} (Invoice: {rec.name}, Structure: {rec.fee_structure_id.name})",
|
||||
'ref': f"FEE/{rec.id}",
|
||||
'description': f"{line.fee_type_id.name} (Assigned via {rec.fee_structure_id.name})",
|
||||
'debit': line.amount,
|
||||
'credit': 0.0,
|
||||
'status': 'outstanding',
|
||||
@ -153,16 +59,7 @@ class EducationFeeAssignment(models.Model):
|
||||
if rec.state == 'confirmed':
|
||||
ledger_entries = self.env['education.student.ledger'].search([
|
||||
('student_id', '=', rec.student_id.id),
|
||||
('ref', '=', rec.name),
|
||||
('ref', '=', f"FEE/{rec.id}"),
|
||||
])
|
||||
ledger_entries.unlink()
|
||||
rec.state = 'draft'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals.get('name') == '/':
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('education.fee.assignment') or '/'
|
||||
if not vals.get('due_date') and vals.get('assignment_date'):
|
||||
vals['due_date'] = fields.Date.from_string(vals['assignment_date']) + datetime.timedelta(days=30)
|
||||
return super().create(vals_list)
|
||||
|
||||
@ -15,9 +15,6 @@ class EducationFeeStructure(models.Model):
|
||||
program_id = fields.Many2one('education.program', string='Program', index=True)
|
||||
department_id = fields.Many2one('education.department', string='Department', index=True)
|
||||
semester_id = fields.Many2one('education.semester', string='Semester', index=True)
|
||||
academic_term_id = fields.Many2one('education.academic.term', string='Academic Term', index=True)
|
||||
batch_id = fields.Many2one('education.batch', string='Batch', index=True)
|
||||
due_days = fields.Integer(string='Due Days from Assignment', default=30)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency',
|
||||
string='Currency',
|
||||
@ -25,12 +22,6 @@ class EducationFeeStructure(models.Model):
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
line_ids = fields.One2many('education.fee.structure.line', 'fee_structure_id', string='Fee Lines', copy=True)
|
||||
total_amount = fields.Monetary(
|
||||
string='Total Amount',
|
||||
compute='_compute_total_amount',
|
||||
currency_field='currency_id',
|
||||
store=True,
|
||||
)
|
||||
active = fields.Boolean(default=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
@ -40,11 +31,6 @@ class EducationFeeStructure(models.Model):
|
||||
index=True,
|
||||
)
|
||||
|
||||
@api.depends('line_ids.amount')
|
||||
def _compute_total_amount(self):
|
||||
for rec in self:
|
||||
rec.total_amount = sum(line.amount for line in rec.line_ids)
|
||||
|
||||
|
||||
class EducationFeeStructureLine(models.Model):
|
||||
_name = 'education.fee.structure.line'
|
||||
|
||||
@ -7,15 +7,12 @@ class EducationStudent(models.Model):
|
||||
|
||||
ledger_line_ids = fields.One2many('education.student.ledger', 'student_id', string='Ledger Lines')
|
||||
payment_ids = fields.One2many('education.student.payment', 'student_id', string='Payments')
|
||||
fee_assignment_ids = fields.One2many('education.fee.assignment', 'student_id', string='Fee Invoices')
|
||||
|
||||
currency_id = fields.Many2one('res.currency', string='Currency', related='company_id.currency_id', readonly=True, store=True)
|
||||
|
||||
outstanding_amount = fields.Monetary(string='Outstanding Dues', compute='_compute_finance_metrics', currency_field='currency_id', store=True, index=True)
|
||||
total_fees = fields.Monetary(string='Total Fees', compute='_compute_finance_metrics', currency_field='currency_id', store=True, index=True)
|
||||
total_paid = fields.Monetary(string='Total Paid', compute='_compute_finance_metrics', currency_field='currency_id', store=True, index=True)
|
||||
overdue_amount = fields.Monetary(string='Overdue Amount', compute='_compute_finance_metrics', currency_field='currency_id', store=True, index=True)
|
||||
next_due_date = fields.Date(string='Next Due Date', compute='_compute_finance_metrics', store=True, index=True)
|
||||
|
||||
last_payment_date = fields.Date(string='Last Payment Date', compute='_compute_finance_metrics', store=True, index=True)
|
||||
last_receipt_number = fields.Char(string='Last Receipt Number', compute='_compute_finance_metrics', store=True, index=True)
|
||||
@ -26,13 +23,8 @@ class EducationStudent(models.Model):
|
||||
('fully_paid', 'Fully Paid')
|
||||
], string='Payment Status', compute='_compute_finance_metrics', default='no_dues', store=True, index=True)
|
||||
|
||||
@api.depends(
|
||||
'ledger_line_ids.debit', 'ledger_line_ids.credit', 'ledger_line_ids.status',
|
||||
'payment_ids.amount', 'payment_ids.status', 'payment_ids.payment_date', 'payment_ids.receipt_number',
|
||||
'fee_assignment_ids.state', 'fee_assignment_ids.amount_due', 'fee_assignment_ids.due_date'
|
||||
)
|
||||
@api.depends('ledger_line_ids.debit', 'ledger_line_ids.credit', 'ledger_line_ids.status', 'payment_ids.amount', 'payment_ids.status', 'payment_ids.payment_date', 'payment_ids.receipt_number')
|
||||
def _compute_finance_metrics(self):
|
||||
today = fields.Date.today()
|
||||
for rec in self:
|
||||
active_ledgers = rec.ledger_line_ids.filtered(lambda l: l.status in ('outstanding', 'paid'))
|
||||
total_debits = sum(line.debit for line in active_ledgers)
|
||||
@ -41,29 +33,16 @@ class EducationStudent(models.Model):
|
||||
|
||||
confirmed_payments = rec.payment_ids.filtered(lambda p: p.status == 'confirmed').sorted(key=lambda p: (p.payment_date or fields.Date.today(), p.id), reverse=True)
|
||||
rec.total_paid = sum(p.amount for p in confirmed_payments)
|
||||
rec.outstanding_amount = max(0.0, total_debits - total_credits)
|
||||
rec.outstanding_amount = total_debits - total_credits
|
||||
|
||||
last_payment = confirmed_payments[0] if confirmed_payments else False
|
||||
rec.last_payment_date = last_payment.payment_date if last_payment else False
|
||||
rec.last_receipt_number = last_payment.receipt_number if last_payment else False
|
||||
|
||||
overdue = 0.0
|
||||
upcoming_due_dates = []
|
||||
for a in rec.fee_assignment_ids.filtered(lambda x: x.state == 'confirmed' and x.amount_due > 0):
|
||||
if a.due_date and a.due_date < today:
|
||||
overdue += a.amount_due
|
||||
elif a.due_date and a.due_date >= today:
|
||||
upcoming_due_dates.append(a.due_date)
|
||||
|
||||
rec.overdue_amount = overdue
|
||||
rec.next_due_date = min(upcoming_due_dates) if upcoming_due_dates else False
|
||||
|
||||
if total_debits == 0:
|
||||
rec.payment_status = 'no_dues'
|
||||
elif rec.outstanding_amount <= 0:
|
||||
rec.payment_status = 'fully_paid'
|
||||
elif overdue > 0:
|
||||
rec.payment_status = 'overdue'
|
||||
elif rec.total_paid > 0:
|
||||
rec.payment_status = 'partial'
|
||||
else:
|
||||
|
||||
@ -12,7 +12,6 @@ class EducationStudentPayment(models.Model):
|
||||
|
||||
receipt_number = fields.Char(string='Receipt Number', required=True, copy=False, readonly=True, default='/', index=True)
|
||||
student_id = fields.Many2one('education.student', string='Student', required=True, index=True)
|
||||
fee_assignment_id = fields.Many2one('education.fee.assignment', string='Fee Invoice / Assignment', index=True)
|
||||
payment_date = fields.Date(string='Payment Date', required=True, default=fields.Date.context_today)
|
||||
payment_method = fields.Selection([
|
||||
('cash', 'Cash'),
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import datetime
|
||||
from odoo import fields
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError, AccessError
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class TestStudentFinance(TransactionCase):
|
||||
@ -276,84 +276,3 @@ class TestStudentFinance(TransactionCase):
|
||||
|
||||
# Outstanding should increase by 500 (from 500 to 1000)
|
||||
self.assertEqual(self.student.outstanding_amount, 1000.0)
|
||||
|
||||
def test_fee_structure_and_invoice_lifecycle(self):
|
||||
"""Test fee structure total amount, invoice auto-numbering, due date, and payment status"""
|
||||
today = fields.Date.today()
|
||||
# Verify fee structure total amount
|
||||
self.assertEqual(self.fee_structure.total_amount, 1500.0)
|
||||
|
||||
# Create fee assignment (invoice)
|
||||
assignment = self.env['education.fee.assignment'].create({
|
||||
'student_id': self.student.id,
|
||||
'academic_year_id': self.year.id,
|
||||
'semester_id': self.semester.id,
|
||||
'fee_structure_id': self.fee_structure.id,
|
||||
'assignment_date': today,
|
||||
'due_date': today + datetime.timedelta(days=15),
|
||||
})
|
||||
|
||||
self.assertIsNotNone(assignment.name)
|
||||
self.assertEqual(assignment.amount, 1500.0)
|
||||
self.assertEqual(assignment.amount_paid, 0.0)
|
||||
self.assertEqual(assignment.amount_due, 1500.0)
|
||||
self.assertEqual(assignment.payment_status, 'unpaid')
|
||||
|
||||
# Confirm invoice
|
||||
assignment.action_confirm()
|
||||
self.assertEqual(assignment.state, 'confirmed')
|
||||
self.assertEqual(assignment.payment_status, 'unpaid')
|
||||
|
||||
# Prevent duplicate assignment for same student, year, semester, and structure
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['education.fee.assignment'].create({
|
||||
'student_id': self.student.id,
|
||||
'academic_year_id': self.year.id,
|
||||
'semester_id': self.semester.id,
|
||||
'fee_structure_id': self.fee_structure.id,
|
||||
})
|
||||
|
||||
# Register partial payment linked to assignment
|
||||
payment = self.env['education.student.payment'].create({
|
||||
'student_id': self.student.id,
|
||||
'fee_assignment_id': assignment.id,
|
||||
'amount': 600.0,
|
||||
'payment_method': 'card',
|
||||
'receipt_number': 'REC-TEST-PART-600',
|
||||
})
|
||||
payment.action_confirm()
|
||||
|
||||
assignment.invalidate_recordset()
|
||||
self.assertEqual(assignment.amount_paid, 600.0)
|
||||
self.assertEqual(assignment.amount_due, 900.0)
|
||||
self.assertEqual(assignment.payment_status, 'partial')
|
||||
|
||||
# Simulate overdue state by moving due_date to yesterday
|
||||
assignment.due_date = today - datetime.timedelta(days=1)
|
||||
assignment.invalidate_recordset()
|
||||
self.assertEqual(assignment.payment_status, 'overdue')
|
||||
|
||||
# Check student metrics
|
||||
self.student.invalidate_recordset()
|
||||
self.assertEqual(self.student.outstanding_amount, 900.0)
|
||||
self.assertEqual(self.student.overdue_amount, 900.0)
|
||||
self.assertEqual(self.student.total_paid, 600.0)
|
||||
|
||||
# Pay remaining balance
|
||||
payment2 = self.env['education.student.payment'].create({
|
||||
'student_id': self.student.id,
|
||||
'fee_assignment_id': assignment.id,
|
||||
'amount': 900.0,
|
||||
'payment_method': 'bank',
|
||||
'receipt_number': 'REC-TEST-PART-900',
|
||||
})
|
||||
payment2.action_confirm()
|
||||
|
||||
assignment.invalidate_recordset()
|
||||
self.assertEqual(assignment.amount_paid, 1500.0)
|
||||
self.assertEqual(assignment.amount_due, 0.0)
|
||||
self.assertEqual(assignment.payment_status, 'paid')
|
||||
|
||||
self.student.invalidate_recordset()
|
||||
self.assertEqual(self.student.outstanding_amount, 0.0)
|
||||
self.assertEqual(self.student.payment_status, 'fully_paid')
|
||||
|
||||
@ -4,18 +4,12 @@
|
||||
<field name="name">education.fee.assignment.tree</field>
|
||||
<field name="model">education.fee.assignment</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Fee Invoices & Assignments">
|
||||
<field name="name"/>
|
||||
<list string="Fee Assignments">
|
||||
<field name="student_id"/>
|
||||
<field name="academic_year_id"/>
|
||||
<field name="semester_id"/>
|
||||
<field name="fee_structure_id"/>
|
||||
<field name="assignment_date"/>
|
||||
<field name="due_date"/>
|
||||
<field name="amount" sum="Total Invoiced"/>
|
||||
<field name="amount_paid" sum="Total Paid"/>
|
||||
<field name="amount_due" sum="Total Due"/>
|
||||
<field name="payment_status" widget="badge" decoration-success="payment_status == 'paid'" decoration-warning="payment_status == 'partial'" decoration-danger="payment_status == 'overdue'" decoration-info="payment_status == 'unpaid'"/>
|
||||
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'confirmed'"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
@ -26,58 +20,25 @@
|
||||
<field name="name">education.fee.assignment.form</field>
|
||||
<field name="model">education.fee.assignment</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Fee Invoice / Assignment">
|
||||
<form string="Fee Assignment">
|
||||
<header>
|
||||
<button name="action_confirm" string="Confirm & Post to Ledger" type="object" class="oe_highlight" invisible="state == 'confirmed'"/>
|
||||
<button name="action_cancel" string="Cancel & Reverse Ledger" type="object" invisible="state == 'draft'"/>
|
||||
<button name="action_confirm" string="Confirm" type="object" class="oe_highlight" invisible="state == 'confirmed'"/>
|
||||
<button name="action_cancel" string="Cancel" type="object" invisible="state == 'draft'"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" string="Invoice / Ref Number"/>
|
||||
<h1><field name="name" readonly="1"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Student & Academic Scope">
|
||||
<group>
|
||||
<field name="student_id" readonly="state == 'confirmed'"/>
|
||||
<field name="academic_year_id" readonly="state == 'confirmed'"/>
|
||||
<field name="academic_term_id" readonly="state == 'confirmed'"/>
|
||||
<field name="semester_id" readonly="state == 'confirmed'"/>
|
||||
</group>
|
||||
<group string="Schedule & Financials">
|
||||
<group>
|
||||
<field name="fee_structure_id" readonly="state == 'confirmed'"/>
|
||||
<field name="assignment_date" readonly="state == 'confirmed'"/>
|
||||
<field name="due_date" readonly="state == 'confirmed'"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="amount"/>
|
||||
<field name="amount_paid"/>
|
||||
<field name="amount_due"/>
|
||||
<field name="payment_status" widget="badge" decoration-success="payment_status == 'paid'" decoration-warning="payment_status == 'partial'" decoration-danger="payment_status == 'overdue'" decoration-info="payment_status == 'unpaid'"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Fee Lines Breakdown" name="lines_page">
|
||||
<field name="line_ids" readonly="1">
|
||||
<list string="Fee Lines">
|
||||
<field name="fee_type_id"/>
|
||||
<field name="amount"/>
|
||||
<field name="mandatory"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Payments Received" name="payments_page">
|
||||
<field name="payment_ids" readonly="1">
|
||||
<list string="Payments">
|
||||
<field name="receipt_number"/>
|
||||
<field name="payment_date"/>
|
||||
<field name="payment_method"/>
|
||||
<field name="amount"/>
|
||||
<field name="status" widget="badge" decoration-success="status == 'confirmed'"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
@ -87,20 +48,14 @@
|
||||
<field name="name">education.fee.assignment.search</field>
|
||||
<field name="model">education.fee.assignment</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Fee Assignments & Invoices">
|
||||
<field name="name"/>
|
||||
<search string="Fee Assignments">
|
||||
<field name="student_id"/>
|
||||
<field name="fee_structure_id"/>
|
||||
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
|
||||
<separator/>
|
||||
<filter string="Overdue" name="overdue" domain="[('payment_status', '=', 'overdue')]"/>
|
||||
<filter string="Unpaid" name="unpaid" domain="[('payment_status', 'in', ('unpaid', 'partial'))]"/>
|
||||
<filter string="Paid" name="paid" domain="[('payment_status', '=', 'paid')]"/>
|
||||
<group>
|
||||
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||
<filter string="Fee Structure" name="group_structure" context="{'group_by': 'fee_structure_id'}"/>
|
||||
<filter string="Payment Status" name="group_payment_status" context="{'group_by': 'payment_status'}"/>
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
@ -108,14 +63,9 @@
|
||||
</record>
|
||||
|
||||
<record id="action_education_fee_assignment" model="ir.actions.act_window">
|
||||
<field name="name">Fee Assignments & Invoices</field>
|
||||
<field name="name">Fee Assignments</field>
|
||||
<field name="res_model">education.fee.assignment</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_education_fee_assignment_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Assign fee structures and generate fee obligations for students.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@ -41,13 +41,13 @@ class TestLibraryCore(TransactionCase):
|
||||
'name': 'BTech CS', 'code': 'BTECH-CS', 'level_id': self.level.id, 'department_id': self.department.id, 'duration': 4, 'duration_unit': 'years', 'company_id': self.company.id,
|
||||
})
|
||||
self.batch = self.env['education.batch'].create({
|
||||
'name': 'Batch Library Test', 'code': 'B_LIB_25', 'company_id': self.company.id,
|
||||
'name': 'Batch 2025', 'code': 'B2025',
|
||||
})
|
||||
self.semester = self.env['education.semester'].create({
|
||||
'name': 'Semester Library Test', 'code': 'SEM_LIB_1', 'company_id': self.company.id,
|
||||
'name': 'Semester 1', 'code': 'SEM1',
|
||||
})
|
||||
self.section = self.env['education.section'].create({
|
||||
'name': 'Section Library Test', 'code': 'SEC_LIB_A', 'company_id': self.company.id,
|
||||
'name': 'Section A', 'code': 'SEC-A',
|
||||
})
|
||||
|
||||
self.category_1 = self.Category.create({
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import base64
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo import http
|
||||
@ -45,8 +43,6 @@ def require_student(func):
|
||||
return wrapper
|
||||
|
||||
def format_float_time(float_time):
|
||||
if float_time is None or float_time is False:
|
||||
return ""
|
||||
hours = int(float_time)
|
||||
minutes = int(round((float_time - hours) * 60))
|
||||
if minutes == 60:
|
||||
@ -61,141 +57,37 @@ def format_float_time(float_time):
|
||||
|
||||
class MobileStudent(http.Controller):
|
||||
|
||||
def _strip_html(self, text):
|
||||
if not text:
|
||||
return ""
|
||||
clean = re.sub(r'<[^>]+>', ' ', str(text))
|
||||
return " ".join(clean.split()).strip()
|
||||
|
||||
def _format_announcement(self, a):
|
||||
title = a.title or getattr(a, 'name', '') or 'Announcement'
|
||||
raw_content = a.content or ''
|
||||
summary = self._strip_html(raw_content)[:140]
|
||||
return {
|
||||
"id": a.id,
|
||||
"title": title,
|
||||
"name": title,
|
||||
"date": str(a.date_published or ''),
|
||||
"date_published": str(a.date_published or ''),
|
||||
"expiry_date": str(a.expiry_date) if a.expiry_date else None,
|
||||
"summary": summary,
|
||||
"content": raw_content,
|
||||
"type": a.announcement_type or 'notice',
|
||||
"priority": a.priority or '0',
|
||||
"author": a.author_id.name if a.author_id else None,
|
||||
"has_attachment": bool(a.circular_file),
|
||||
"circular_filename": a.circular_filename or None,
|
||||
}
|
||||
|
||||
def _get_rel(self, record):
|
||||
if record:
|
||||
return {"id": record.id, "name": record.name}
|
||||
return None
|
||||
|
||||
def _format_profile(self, student):
|
||||
return {
|
||||
"id": student.id,
|
||||
"student_id": student.student_id,
|
||||
"admission_number": student.admission_number,
|
||||
"registration_number": student.registration_number or None,
|
||||
"roll_number": student.roll_number or None,
|
||||
"full_name": student.full_name,
|
||||
"first_name": student.first_name,
|
||||
"middle_name": student.middle_name or None,
|
||||
"last_name": student.last_name,
|
||||
"email": student.email,
|
||||
"mobile": student.mobile,
|
||||
"phone": student.phone or None,
|
||||
"gender": student.gender,
|
||||
"date_of_birth": str(student.date_of_birth) if student.date_of_birth else None,
|
||||
"blood_group": student.blood_group or None,
|
||||
"emergency_contact": student.emergency_contact or None,
|
||||
"emergency_phone": student.emergency_phone or None,
|
||||
"street": student.street or None,
|
||||
"street2": student.street2 or None,
|
||||
"city": student.city or None,
|
||||
"zip": student.zip or None,
|
||||
"state": self._get_rel(student.state_id),
|
||||
"country": self._get_rel(student.country_id),
|
||||
"guardian": {
|
||||
"name": student.guardian_name or None,
|
||||
"relation": student.guardian_relation or None,
|
||||
"phone": student.guardian_phone or None,
|
||||
"mobile": student.guardian_mobile or None,
|
||||
"email": student.guardian_email or None,
|
||||
"address": student.guardian_address or None,
|
||||
},
|
||||
"status": student.status,
|
||||
"admission_date": str(student.admission_date) if student.admission_date else None,
|
||||
"enrollment_date": str(student.enrollment_date) if student.enrollment_date else None,
|
||||
"profile_completion": getattr(student, 'profile_completion', 0),
|
||||
"has_photo": bool(student.image),
|
||||
"campus": self._get_rel(student.campus_id),
|
||||
"department": self._get_rel(student.department_id),
|
||||
"program": self._get_rel(student.program_id),
|
||||
"level": self._get_rel(student.level_id),
|
||||
"academic_year": self._get_rel(student.academic_year_id),
|
||||
"academic_term": self._get_rel(student.academic_term_id),
|
||||
"batch": self._get_rel(student.batch_id),
|
||||
"semester": self._get_rel(student.semester_id),
|
||||
"section": self._get_rel(student.section_id),
|
||||
"editable_fields": [
|
||||
"email", "mobile", "phone", "blood_group",
|
||||
"emergency_contact", "emergency_phone",
|
||||
"street", "street2", "city", "zip", "image"
|
||||
],
|
||||
"protected_fields": [
|
||||
"student_id", "admission_number", "registration_number", "roll_number",
|
||||
"campus", "department", "program", "level",
|
||||
"academic_year", "academic_term", "batch", "semester", "section", "status"
|
||||
]
|
||||
}
|
||||
|
||||
@http.route('/api/mobile/v1/student/profile', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_profile(self, student, **kwargs):
|
||||
try:
|
||||
return json_response(True, data={"profile": self._format_profile(student), **self._format_profile(student)})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/profile', type='http', auth='public', methods=['PATCH', 'POST'], cors='*', csrf=False)
|
||||
@require_student
|
||||
def update_profile(self, student, **kwargs):
|
||||
try:
|
||||
raw_body = request.httprequest.data
|
||||
vals = {}
|
||||
if raw_body:
|
||||
try:
|
||||
vals = json.loads(raw_body.decode('utf-8'))
|
||||
except Exception:
|
||||
vals = {}
|
||||
if not vals:
|
||||
vals = dict(kwargs)
|
||||
|
||||
# Check protected fields
|
||||
protected = [
|
||||
'student_id', 'admission_number', 'registration_number', 'roll_number',
|
||||
'program', 'program_id', 'department', 'department_id', 'campus', 'campus_id',
|
||||
'level', 'level_id', 'academic_year', 'academic_year_id', 'academic_term', 'academic_term_id',
|
||||
'batch', 'batch_id', 'semester', 'semester_id', 'section', 'section_id',
|
||||
'status', 'company_id', 'admission_date', 'enrollment_date', 'graduation_date', 'leave_date'
|
||||
]
|
||||
for p in protected:
|
||||
if p in vals:
|
||||
return json_response(
|
||||
False,
|
||||
error_code="PROTECTED_FIELD_MODIFICATION",
|
||||
error_message=f"Modifying protected academic or administrative field '{p}' is strictly prohibited.",
|
||||
status=400
|
||||
)
|
||||
|
||||
try:
|
||||
student.sudo().update_student_profile(vals)
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="VALIDATION_ERROR", error_message=str(e), status=400)
|
||||
|
||||
return json_response(True, data={"profile": self._format_profile(student), **self._format_profile(student)})
|
||||
data = {
|
||||
"id": student.id,
|
||||
"student_id": student.student_id,
|
||||
"admission_number": student.admission_number,
|
||||
"roll_number": student.roll_number,
|
||||
"full_name": student.full_name,
|
||||
"email": student.email,
|
||||
"mobile": student.mobile,
|
||||
"gender": student.gender,
|
||||
"date_of_birth": student.date_of_birth,
|
||||
"status": student.status,
|
||||
"campus": self._get_rel(student.campus_id),
|
||||
"department": self._get_rel(student.department_id),
|
||||
"program": self._get_rel(student.program_id),
|
||||
"level": self._get_rel(student.level_id),
|
||||
"academic_year": self._get_rel(student.academic_year_id),
|
||||
"academic_term": self._get_rel(student.academic_term_id),
|
||||
"batch": self._get_rel(student.batch_id),
|
||||
"semester": self._get_rel(student.semester_id),
|
||||
"section": self._get_rel(student.section_id)
|
||||
}
|
||||
return json_response(True, data=data)
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@ -215,8 +107,8 @@ class MobileStudent(http.Controller):
|
||||
medical_leave = len([a for a in records if a.status == 'medical_leave'])
|
||||
on_duty = len([a for a in records if a.status == 'on_duty'])
|
||||
|
||||
attended = present + on_duty + late + medical_leave
|
||||
percentage = round((attended / total_sessions * 100.0), 1) if total_sessions > 0 else 100.0
|
||||
effective_present = present + on_duty + (late * 0.5)
|
||||
percentage = round((effective_present / total_sessions * 100), 1) if total_sessions > 0 else 100.0
|
||||
|
||||
return {
|
||||
"percentage": percentage,
|
||||
@ -227,19 +119,15 @@ class MobileStudent(http.Controller):
|
||||
"on_duty": on_duty
|
||||
}
|
||||
|
||||
def _get_timetable_slots(self, student, date_filter=None, from_date=None, to_date=None):
|
||||
def _get_timetable_slots(self, student, date_filter=None):
|
||||
if not student.program_id or not student.academic_year_id:
|
||||
return []
|
||||
|
||||
domain = [
|
||||
('program_id', '=', student.program_id.id),
|
||||
('academic_year_id', '=', student.academic_year_id.id),
|
||||
('active', '=', True),
|
||||
('state', '!=', 'cancelled'),
|
||||
('active', '=', True)
|
||||
]
|
||||
|
||||
if student.company_id:
|
||||
domain.append(('company_id', '=', student.company_id.id))
|
||||
|
||||
if student.academic_term_id:
|
||||
domain += ['|', ('term_id', '=', False), ('term_id', '=', student.academic_term_id.id)]
|
||||
@ -250,26 +138,11 @@ class MobileStudent(http.Controller):
|
||||
domain += ['|', ('batch_id', '=', False), ('batch_id', '=', student.batch_id.id)]
|
||||
else:
|
||||
domain += [('batch_id', '=', False)]
|
||||
|
||||
if hasattr(student, 'semester_id') and student.semester_id:
|
||||
domain += ['|', ('semester_id', '=', False), ('semester_id', '=', student.semester_id.id)]
|
||||
else:
|
||||
domain += [('semester_id', '=', False)]
|
||||
|
||||
if hasattr(student, 'section_id') and student.section_id:
|
||||
domain += ['|', ('section_id', '=', False), ('section_id', '=', student.section_id.id)]
|
||||
else:
|
||||
domain += [('section_id', '=', False)]
|
||||
|
||||
if date_filter:
|
||||
domain += [('date', '=', date_filter)]
|
||||
if from_date:
|
||||
domain += [('date', '>=', from_date)]
|
||||
if to_date:
|
||||
domain += [('date', '<=', to_date)]
|
||||
|
||||
env = student.env if student else request.env
|
||||
slots = env['education.timetable.slot'].sudo().search(domain, order="date asc, start_time asc")
|
||||
slots = request.env['education.timetable.slot'].sudo().search(domain, order="date asc, start_time asc")
|
||||
|
||||
return [{
|
||||
"id": s.id,
|
||||
@ -295,9 +168,23 @@ class MobileStudent(http.Controller):
|
||||
('academic_year_id', '=', student.academic_year_id.id),
|
||||
], fields=['id', 'name', 'state', 'start_date'], order='start_date asc', limit=5)
|
||||
|
||||
# Announcements (filtered by audience, state, and publication window)
|
||||
ann_records, _ = request.env['education.announcement'].get_announcements_for_student(student, limit=5)
|
||||
announcements = [self._format_announcement(a) for a in ann_records]
|
||||
# Announcements
|
||||
ann_records = request.env['education.announcement'].sudo().search(
|
||||
[('active', '=', True)],
|
||||
order='id desc',
|
||||
limit=5
|
||||
)
|
||||
announcements = []
|
||||
for a in ann_records:
|
||||
ann_title = getattr(a, 'title', None) or getattr(a, 'name', 'Announcement')
|
||||
ann_date = getattr(a, 'date_published', None) or getattr(a, 'date', None) or ''
|
||||
ann_content = getattr(a, 'content', None) or getattr(a, 'summary', None) or ''
|
||||
announcements.append({
|
||||
'id': a.id,
|
||||
'name': ann_title,
|
||||
'date': str(ann_date),
|
||||
'summary': str(ann_content)[:100]
|
||||
})
|
||||
|
||||
# Recent Results
|
||||
results = request.env['education.exam.result'].sudo().search([
|
||||
@ -325,16 +212,7 @@ class MobileStudent(http.Controller):
|
||||
"today_timetable": self._get_timetable_slots(student, date_filter=today),
|
||||
"upcoming_exams": exams,
|
||||
"recent_results": results_data,
|
||||
"announcements": announcements,
|
||||
"fees": {
|
||||
"total_fees": float(student.total_fees),
|
||||
"total_paid": float(student.total_paid),
|
||||
"outstanding_balance": float(student.outstanding_amount),
|
||||
"overdue_amount": float(student.overdue_amount),
|
||||
"payment_status": student.payment_status or "no_dues",
|
||||
"next_due_date": str(student.next_due_date) if student.next_due_date else None,
|
||||
"currency_symbol": student.currency_id.symbol or student.currency_id.name or '₹'
|
||||
}
|
||||
"announcements": announcements
|
||||
}
|
||||
return json_response(True, data=data)
|
||||
except Exception as e:
|
||||
@ -344,18 +222,13 @@ class MobileStudent(http.Controller):
|
||||
@require_student
|
||||
def get_attendance(self, student, **kwargs):
|
||||
try:
|
||||
from_date_str = kwargs.get('from_date')
|
||||
to_date_str = kwargs.get('to_date')
|
||||
from_date = None
|
||||
to_date = None
|
||||
from_date = kwargs.get('from_date')
|
||||
to_date = kwargs.get('to_date')
|
||||
|
||||
if from_date_str:
|
||||
from_date = datetime.strptime(from_date_str, '%Y-%m-%d').date()
|
||||
if to_date_str:
|
||||
to_date = datetime.strptime(to_date_str, '%Y-%m-%d').date()
|
||||
|
||||
if from_date and to_date and from_date > to_date:
|
||||
return json_response(False, error_code="INVALID_DATE_RANGE", error_message="'from_date' cannot be later than 'to_date'", status=400)
|
||||
if from_date:
|
||||
from_date = datetime.strptime(from_date, '%Y-%m-%d').date()
|
||||
if to_date:
|
||||
to_date = datetime.strptime(to_date, '%Y-%m-%d').date()
|
||||
|
||||
summary = self._get_attendance_summary(student, from_date, to_date)
|
||||
|
||||
@ -367,23 +240,14 @@ class MobileStudent(http.Controller):
|
||||
|
||||
records = request.env['education.attendance'].search_read(
|
||||
domain,
|
||||
fields=['id', 'date', 'status', 'remarks'],
|
||||
fields=['id', 'date', 'status'],
|
||||
order='date desc, id desc',
|
||||
limit=100
|
||||
)
|
||||
|
||||
formatted_records = []
|
||||
for r in records:
|
||||
formatted_records.append({
|
||||
"id": r["id"],
|
||||
"date": str(r["date"]),
|
||||
"status": r["status"],
|
||||
"remarks": r.get("remarks") or ""
|
||||
})
|
||||
|
||||
return json_response(True, data={
|
||||
"summary": summary,
|
||||
"records": formatted_records
|
||||
"records": records
|
||||
})
|
||||
except ValueError:
|
||||
return json_response(False, error_code="INVALID_DATE", error_message="Dates must be in YYYY-MM-DD format", status=400)
|
||||
@ -394,32 +258,7 @@ class MobileStudent(http.Controller):
|
||||
@require_student
|
||||
def get_timetable(self, student, **kwargs):
|
||||
try:
|
||||
date_filter = kwargs.get('date')
|
||||
from_date = kwargs.get('from_date')
|
||||
to_date = kwargs.get('to_date')
|
||||
|
||||
if date_filter:
|
||||
try:
|
||||
datetime.strptime(date_filter, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return json_response(False, error_code="INVALID_DATE", error_message="Date must be in YYYY-MM-DD format", status=400)
|
||||
|
||||
if from_date:
|
||||
try:
|
||||
datetime.strptime(from_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return json_response(False, error_code="INVALID_DATE", error_message="from_date must be in YYYY-MM-DD format", status=400)
|
||||
|
||||
if to_date:
|
||||
try:
|
||||
datetime.strptime(to_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return json_response(False, error_code="INVALID_DATE", error_message="to_date must be in YYYY-MM-DD format", status=400)
|
||||
|
||||
if from_date and to_date and from_date > to_date:
|
||||
return json_response(False, error_code="INVALID_DATE_RANGE", error_message="from_date must be before or equal to to_date", status=400)
|
||||
|
||||
slots = self._get_timetable_slots(student, date_filter=date_filter, from_date=from_date, to_date=to_date)
|
||||
slots = self._get_timetable_slots(student)
|
||||
return json_response(True, data={"slots": slots})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
@ -431,7 +270,6 @@ class MobileStudent(http.Controller):
|
||||
exams = request.env['education.exam'].sudo().search([
|
||||
('program_id', '=', student.program_id.id),
|
||||
('academic_year_id', '=', student.academic_year_id.id),
|
||||
('state', 'in', ['scheduled', 'ongoing', 'completed', 'results_published']),
|
||||
], order='start_date desc')
|
||||
|
||||
exams_data = [{
|
||||
@ -486,551 +324,3 @@ class MobileStudent(http.Controller):
|
||||
return json_response(True, data={"results": results_data})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/announcements', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_announcements(self, student, **kwargs):
|
||||
try:
|
||||
page = int(kwargs.get('page', 1) or 1)
|
||||
limit = int(kwargs.get('limit', 20) or 20)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if limit < 1 or limit > 100:
|
||||
limit = 20
|
||||
offset = (page - 1) * limit
|
||||
|
||||
ann_type = kwargs.get('type')
|
||||
if ann_type and ann_type not in ['notice', 'circular', 'event']:
|
||||
ann_type = None
|
||||
|
||||
records, total_count = request.env['education.announcement'].get_announcements_for_student(
|
||||
student,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
announcement_type=ann_type
|
||||
)
|
||||
|
||||
announcements_data = [self._format_announcement(a) for a in records]
|
||||
|
||||
return json_response(True, data={
|
||||
"announcements": announcements_data,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"limit": limit
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/announcements/<int:announcement_id>', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_announcement_detail(self, student, announcement_id, **kwargs):
|
||||
try:
|
||||
ann = request.env['education.announcement'].sudo().browse(announcement_id)
|
||||
if not ann.exists() or not ann.active or ann.state != 'published':
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Announcement not found or unavailable", status=404)
|
||||
|
||||
# Date window validation
|
||||
today = date.today()
|
||||
if ann.date_published > today:
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Announcement not found or unavailable", status=404)
|
||||
if ann.expiry_date and ann.expiry_date < today:
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Announcement has expired", status=404)
|
||||
|
||||
# Multi-company isolation
|
||||
if student.company_id and ann.company_id and ann.company_id.id != student.company_id.id:
|
||||
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this announcement", status=403)
|
||||
|
||||
# Audience and cohort targeting
|
||||
if not ann.check_student_audience(student):
|
||||
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this announcement", status=403)
|
||||
|
||||
return json_response(True, data={
|
||||
"announcement": self._format_announcement(ann)
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/fees/summary', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_fee_summary(self, student, **kwargs):
|
||||
try:
|
||||
curr = student.currency_id or student.company_id.currency_id
|
||||
data = {
|
||||
"total_fees": float(student.total_fees),
|
||||
"total_paid": float(student.total_paid),
|
||||
"outstanding_balance": float(student.outstanding_amount),
|
||||
"overdue_amount": float(student.overdue_amount),
|
||||
"payment_status": student.payment_status or "no_dues",
|
||||
"next_due_date": str(student.next_due_date) if student.next_due_date else None,
|
||||
"last_payment_date": str(student.last_payment_date) if student.last_payment_date else None,
|
||||
"last_receipt_number": student.last_receipt_number or None,
|
||||
"currency": {
|
||||
"id": curr.id if curr else 1,
|
||||
"name": curr.name if curr else "INR",
|
||||
"symbol": curr.symbol if curr else "₹"
|
||||
}
|
||||
}
|
||||
return json_response(True, data=data)
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/fees/invoices', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_fee_invoices(self, student, **kwargs):
|
||||
try:
|
||||
status_filter = kwargs.get('status')
|
||||
page = int(kwargs.get('page', 1) or 1)
|
||||
limit = int(kwargs.get('limit', 20) or 20)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if limit < 1 or limit > 100:
|
||||
limit = 20
|
||||
offset = (page - 1) * limit
|
||||
|
||||
domain = [
|
||||
('student_id', '=', student.id),
|
||||
('active', '=', True),
|
||||
]
|
||||
if student.company_id:
|
||||
domain.append(('company_id', '=', student.company_id.id))
|
||||
if status_filter in ['unpaid', 'partial', 'paid', 'overdue']:
|
||||
domain.append(('payment_status', '=', status_filter))
|
||||
|
||||
total_count = request.env['education.fee.assignment'].sudo().search_count(domain)
|
||||
assignments = request.env['education.fee.assignment'].sudo().search(
|
||||
domain,
|
||||
order='due_date asc, id desc',
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
curr = student.currency_id or student.company_id.currency_id
|
||||
curr_dict = {
|
||||
"id": curr.id if curr else 1,
|
||||
"name": curr.name if curr else "INR",
|
||||
"symbol": curr.symbol if curr else "₹"
|
||||
}
|
||||
|
||||
invoices = []
|
||||
for a in assignments:
|
||||
invoices.append({
|
||||
"id": a.id,
|
||||
"invoice_number": a.name or f"INV/{a.id:05d}",
|
||||
"title": a.fee_structure_id.name or "Semester Fees",
|
||||
"academic_year": self._get_rel(a.academic_year_id),
|
||||
"academic_term": self._get_rel(a.academic_term_id),
|
||||
"semester": self._get_rel(a.semester_id),
|
||||
"assignment_date": str(a.assignment_date or ''),
|
||||
"due_date": str(a.due_date or ''),
|
||||
"amount": float(a.amount),
|
||||
"amount_paid": float(a.amount_paid),
|
||||
"amount_due": float(a.amount_due),
|
||||
"payment_status": a.payment_status,
|
||||
"state": a.state,
|
||||
"currency": curr_dict
|
||||
})
|
||||
|
||||
return json_response(True, data={
|
||||
"invoices": invoices,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"limit": limit
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/fees/invoices/<int:invoice_id>', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_fee_invoice_detail(self, student, invoice_id, **kwargs):
|
||||
try:
|
||||
assignment = request.env['education.fee.assignment'].sudo().browse(invoice_id)
|
||||
if not assignment.exists() or not assignment.active:
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Invoice not found", status=404)
|
||||
|
||||
# Security isolation: must match authenticated student
|
||||
if assignment.student_id.id != student.id:
|
||||
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this invoice", status=403)
|
||||
|
||||
# Company isolation
|
||||
if student.company_id and assignment.company_id and assignment.company_id.id != student.company_id.id:
|
||||
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this invoice", status=403)
|
||||
|
||||
curr = assignment.currency_id or student.currency_id or student.company_id.currency_id
|
||||
curr_dict = {
|
||||
"id": curr.id if curr else 1,
|
||||
"name": curr.name if curr else "INR",
|
||||
"symbol": curr.symbol if curr else "₹"
|
||||
}
|
||||
|
||||
lines = []
|
||||
if assignment.fee_structure_id and assignment.fee_structure_id.line_ids:
|
||||
for line in assignment.fee_structure_id.line_ids:
|
||||
lines.append({
|
||||
"id": line.id,
|
||||
"name": line.fee_type_id.name,
|
||||
"category": line.fee_type_id.category_id.name if line.fee_type_id.category_id else None,
|
||||
"amount": float(line.amount),
|
||||
"mandatory": line.mandatory
|
||||
})
|
||||
|
||||
payments = []
|
||||
for p in assignment.payment_ids.filtered(lambda x: x.status == 'confirmed'):
|
||||
payments.append({
|
||||
"id": p.id,
|
||||
"receipt_number": p.receipt_number,
|
||||
"payment_date": str(p.payment_date or ''),
|
||||
"payment_method": p.payment_method,
|
||||
"payment_method_label": dict(p._fields['payment_method'].selection).get(p.payment_method, p.payment_method),
|
||||
"amount": float(p.amount),
|
||||
"reference_number": p.reference_number
|
||||
})
|
||||
|
||||
data = {
|
||||
"invoice": {
|
||||
"id": assignment.id,
|
||||
"invoice_number": assignment.name or f"INV/{assignment.id:05d}",
|
||||
"title": assignment.fee_structure_id.name or "Semester Fees",
|
||||
"academic_year": self._get_rel(assignment.academic_year_id),
|
||||
"academic_term": self._get_rel(assignment.academic_term_id),
|
||||
"semester": self._get_rel(assignment.semester_id),
|
||||
"assignment_date": str(assignment.assignment_date or ''),
|
||||
"due_date": str(assignment.due_date or ''),
|
||||
"amount": float(assignment.amount),
|
||||
"amount_paid": float(assignment.amount_paid),
|
||||
"amount_due": float(assignment.amount_due),
|
||||
"payment_status": assignment.payment_status,
|
||||
"state": assignment.state,
|
||||
"currency": curr_dict,
|
||||
"lines": lines,
|
||||
"payments": payments
|
||||
}
|
||||
}
|
||||
return json_response(True, data=data)
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/fees/payments', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_fee_payments(self, student, **kwargs):
|
||||
try:
|
||||
page = int(kwargs.get('page', 1) or 1)
|
||||
limit = int(kwargs.get('limit', 20) or 20)
|
||||
if page < 1:
|
||||
page = 1
|
||||
if limit < 1 or limit > 100:
|
||||
limit = 20
|
||||
offset = (page - 1) * limit
|
||||
|
||||
domain = [
|
||||
('student_id', '=', student.id),
|
||||
('status', '=', 'confirmed'),
|
||||
]
|
||||
if student.company_id:
|
||||
domain.append(('company_id', '=', student.company_id.id))
|
||||
|
||||
total_count = request.env['education.student.payment'].sudo().search_count(domain)
|
||||
payments = request.env['education.student.payment'].sudo().search(
|
||||
domain,
|
||||
order='payment_date desc, id desc',
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
curr = student.currency_id or student.company_id.currency_id
|
||||
curr_dict = {
|
||||
"id": curr.id if curr else 1,
|
||||
"name": curr.name if curr else "INR",
|
||||
"symbol": curr.symbol if curr else "₹"
|
||||
}
|
||||
|
||||
payments_data = []
|
||||
for p in payments:
|
||||
payments_data.append({
|
||||
"id": p.id,
|
||||
"receipt_number": p.receipt_number,
|
||||
"payment_date": str(p.payment_date or ''),
|
||||
"payment_method": p.payment_method,
|
||||
"payment_method_label": dict(p._fields['payment_method'].selection).get(p.payment_method, p.payment_method),
|
||||
"amount": float(p.amount),
|
||||
"reference_number": p.reference_number,
|
||||
"remarks": p.remarks,
|
||||
"status": p.status,
|
||||
"invoice": {
|
||||
"id": p.fee_assignment_id.id,
|
||||
"invoice_number": p.fee_assignment_id.name
|
||||
} if p.fee_assignment_id else None,
|
||||
"currency": curr_dict
|
||||
})
|
||||
|
||||
return json_response(True, data={
|
||||
"payments": payments_data,
|
||||
"total_count": total_count,
|
||||
"page": page,
|
||||
"limit": limit
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Student Documents API
|
||||
# -------------------------------------------------------------------------
|
||||
@http.route('/api/mobile/v1/student/documents', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_documents(self, student, **kwargs):
|
||||
try:
|
||||
docs = request.env['education.student.document'].sudo().search([
|
||||
('student_id', '=', student.id)
|
||||
], order='upload_date desc, id desc')
|
||||
|
||||
data = [{
|
||||
"id": doc.id,
|
||||
"name": doc.name,
|
||||
"category": doc.category,
|
||||
"category_label": dict(doc._fields['category'].selection).get(doc.category, doc.category),
|
||||
"file_name": doc.file_name,
|
||||
"file_size": doc.file_size,
|
||||
"mime_type": doc.mime_type,
|
||||
"verification_status": doc.verification_status,
|
||||
"upload_date": str(doc.upload_date or ''),
|
||||
"verification_date": str(doc.verification_date or '') if doc.verification_date else None,
|
||||
"rejection_reason": doc.rejection_reason or None,
|
||||
} for doc in docs]
|
||||
|
||||
return json_response(True, data={"documents": data})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/documents', type='http', auth='public', methods=['POST'], cors='*', csrf=False)
|
||||
@require_student
|
||||
def upload_document(self, student, **kwargs):
|
||||
try:
|
||||
raw_body = request.httprequest.data
|
||||
payload = {}
|
||||
if raw_body:
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except Exception:
|
||||
payload = {}
|
||||
if not payload:
|
||||
payload = dict(kwargs)
|
||||
|
||||
file_content = None
|
||||
filename = payload.get('file_name')
|
||||
if 'file' in request.httprequest.files:
|
||||
file_obj = request.httprequest.files['file']
|
||||
filename = filename or file_obj.filename
|
||||
file_content = base64.b64encode(file_obj.read()).decode('utf-8')
|
||||
elif payload.get('file'):
|
||||
file_content = payload.get('file')
|
||||
|
||||
if not file_content:
|
||||
return json_response(False, error_code="MISSING_FILE", error_message="Document file is required.", status=400)
|
||||
|
||||
title = payload.get('name') or payload.get('title')
|
||||
if not title:
|
||||
title = filename or 'Student Document'
|
||||
|
||||
category = payload.get('category') or 'academic'
|
||||
valid_categories = ['identity', 'academic', 'transfer', 'address', 'other']
|
||||
if category not in valid_categories:
|
||||
category = 'other'
|
||||
|
||||
mime_type = mimetypes.guess_type(filename or '')[0] or 'application/octet-stream'
|
||||
|
||||
doc = request.env['education.student.document'].sudo().create({
|
||||
'name': title,
|
||||
'student_id': student.id,
|
||||
'category': category,
|
||||
'document_file': file_content,
|
||||
'file_name': filename or f"doc_{student.id}.pdf",
|
||||
'mime_type': mime_type,
|
||||
'verification_status': 'submitted',
|
||||
})
|
||||
|
||||
return json_response(True, data={
|
||||
"document": {
|
||||
"id": doc.id,
|
||||
"name": doc.name,
|
||||
"category": doc.category,
|
||||
"category_label": dict(doc._fields['category'].selection).get(doc.category, doc.category),
|
||||
"file_name": doc.file_name,
|
||||
"file_size": doc.file_size,
|
||||
"mime_type": doc.mime_type,
|
||||
"verification_status": doc.verification_status,
|
||||
"upload_date": str(doc.upload_date or '')
|
||||
}
|
||||
}, status=201)
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="UPLOAD_ERROR", error_message=str(e), status=400)
|
||||
|
||||
@http.route('/api/mobile/v1/student/documents/<int:document_id>', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_document_detail(self, student, document_id, **kwargs):
|
||||
try:
|
||||
doc = request.env['education.student.document'].sudo().browse(document_id)
|
||||
if not doc.exists():
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Document not found.", status=404)
|
||||
if doc.student_id.id != student.id:
|
||||
return json_response(False, error_code="ACCESS_DENIED", error_message="Unauthorized access to document.", status=403)
|
||||
|
||||
return json_response(True, data={
|
||||
"document": {
|
||||
"id": doc.id,
|
||||
"name": doc.name,
|
||||
"category": doc.category,
|
||||
"category_label": dict(doc._fields['category'].selection).get(doc.category, doc.category),
|
||||
"file_name": doc.file_name,
|
||||
"file_size": doc.file_size,
|
||||
"mime_type": doc.mime_type,
|
||||
"verification_status": doc.verification_status,
|
||||
"upload_date": str(doc.upload_date or ''),
|
||||
"verification_date": str(doc.verification_date or '') if doc.verification_date else None,
|
||||
"rejection_reason": doc.rejection_reason or None,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/documents/<int:document_id>/download', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def download_document(self, student, document_id, **kwargs):
|
||||
try:
|
||||
doc = request.env['education.student.document'].sudo().browse(document_id)
|
||||
if not doc.exists():
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Document not found.", status=404)
|
||||
if doc.student_id.id != student.id:
|
||||
return json_response(False, error_code="ACCESS_DENIED", error_message="Unauthorized access to document.", status=403)
|
||||
if not doc.document_file:
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Document content is empty.", status=404)
|
||||
|
||||
file_data = base64.b64decode(doc.document_file)
|
||||
mime_type = doc.mime_type or 'application/octet-stream'
|
||||
filename = doc.file_name or f"document_{doc.id}"
|
||||
|
||||
headers = [
|
||||
('Content-Type', mime_type),
|
||||
('Content-Disposition', f'inline; filename="{filename}"'),
|
||||
('Content-Length', str(len(file_data))),
|
||||
]
|
||||
return request.make_response(file_data, headers=headers)
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Student Certificates API
|
||||
# -------------------------------------------------------------------------
|
||||
@http.route('/api/mobile/v1/student/certificates', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def get_certificates(self, student, **kwargs):
|
||||
try:
|
||||
certs = request.env['education.student.certificate'].sudo().search([
|
||||
('student_id', '=', student.id)
|
||||
], order='id desc')
|
||||
|
||||
data = [{
|
||||
"id": c.id,
|
||||
"name": c.name or f"REQ-{c.id:04d}",
|
||||
"certificate_type": c.certificate_type,
|
||||
"certificate_type_label": dict(c._fields['certificate_type'].selection).get(c.certificate_type, c.certificate_type),
|
||||
"state": c.state,
|
||||
"request_date": str(c.request_date or ''),
|
||||
"issue_date": str(c.issue_date or '') if c.issue_date else None,
|
||||
"expiry_date": str(c.expiry_date or '') if c.expiry_date else None,
|
||||
"file_name": c.file_name or None,
|
||||
"remarks": c.remarks or None,
|
||||
"rejection_reason": c.rejection_reason or None,
|
||||
"has_file": bool(c.document_file),
|
||||
} for c in certs]
|
||||
|
||||
return json_response(True, data={"certificates": data})
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@http.route('/api/mobile/v1/student/certificates/request', type='http', auth='public', methods=['POST'], cors='*', csrf=False)
|
||||
@require_student
|
||||
def request_certificate(self, student, **kwargs):
|
||||
try:
|
||||
raw_body = request.httprequest.data
|
||||
payload = {}
|
||||
if raw_body:
|
||||
try:
|
||||
payload = json.loads(raw_body.decode('utf-8'))
|
||||
except Exception:
|
||||
payload = {}
|
||||
if not payload:
|
||||
payload = dict(kwargs)
|
||||
|
||||
cert_type = payload.get('certificate_type')
|
||||
valid_types = ['bonafide', 'id_card', 'course_completion', 'conduct']
|
||||
if not cert_type or cert_type not in valid_types:
|
||||
return json_response(False, error_code="INVALID_TYPE", error_message=f"Invalid certificate type. Allowed: {', '.join(valid_types)}", status=400)
|
||||
|
||||
if student.status not in ('active', 'admitted'):
|
||||
return json_response(False, error_code="INELIGIBLE_STATUS", error_message="Only active or admitted students may request certificates.", status=400)
|
||||
|
||||
remarks = payload.get('remarks') or ''
|
||||
|
||||
cert = request.env['education.student.certificate'].sudo().create({
|
||||
'student_id': student.id,
|
||||
'certificate_type': cert_type,
|
||||
'remarks': remarks,
|
||||
'state': 'requested',
|
||||
})
|
||||
|
||||
return json_response(True, data={
|
||||
"certificate": {
|
||||
"id": cert.id,
|
||||
"name": cert.name or f"REQ-{cert.id:04d}",
|
||||
"certificate_type": cert.certificate_type,
|
||||
"certificate_type_label": dict(cert._fields['certificate_type'].selection).get(cert.certificate_type, cert.certificate_type),
|
||||
"state": cert.state,
|
||||
"request_date": str(cert.request_date or ''),
|
||||
"remarks": cert.remarks,
|
||||
}
|
||||
}, status=201)
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="REQUEST_FAILED", error_message=str(e), status=400)
|
||||
|
||||
@http.route('/api/mobile/v1/student/certificates/<int:certificate_id>/download', type='http', auth='public', methods=['GET'], cors='*')
|
||||
@require_student
|
||||
def download_certificate(self, student, certificate_id, **kwargs):
|
||||
try:
|
||||
cert = request.env['education.student.certificate'].sudo().browse(certificate_id)
|
||||
if not cert.exists():
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Certificate not found.", status=404)
|
||||
if cert.student_id.id != student.id:
|
||||
return json_response(False, error_code="ACCESS_DENIED", error_message="Unauthorized access to certificate.", status=403)
|
||||
if cert.state != 'issued':
|
||||
return json_response(False, error_code="NOT_ISSUED", error_message="Certificate has not been issued yet.", status=400)
|
||||
|
||||
if not cert.document_file:
|
||||
report_ref = None
|
||||
if cert.certificate_type == 'bonafide':
|
||||
report_ref = 'education_reports.action_report_bonafide_certificate'
|
||||
elif cert.certificate_type == 'id_card':
|
||||
report_ref = 'education_reports.action_report_student_id_card'
|
||||
|
||||
if report_ref and request.env.ref(report_ref, raise_if_not_found=False):
|
||||
try:
|
||||
pdf_content, _ = request.env['ir.actions.report'].sudo()._render_qweb_pdf(report_ref, student.ids)
|
||||
if pdf_content:
|
||||
cert.sudo().write({
|
||||
'document_file': base64.b64encode(pdf_content),
|
||||
'file_name': f"certificate_{cert.name or cert.id}.pdf"
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not cert.document_file:
|
||||
return json_response(False, error_code="NOT_FOUND", error_message="Certificate document file is not yet available.", status=404)
|
||||
|
||||
file_data = base64.b64decode(cert.document_file)
|
||||
filename = cert.file_name or f"certificate_{cert.id}.pdf"
|
||||
headers = [
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition', f'inline; filename="{filename}"'),
|
||||
('Content-Length', str(len(file_data))),
|
||||
]
|
||||
return request.make_response(file_data, headers=headers)
|
||||
except Exception as e:
|
||||
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
# pyrefly: ignore [missing-import]
|
||||
import logging
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EducationAnnouncement(models.Model):
|
||||
_name = 'education.announcement'
|
||||
_description = 'Education Announcement'
|
||||
_order = 'priority desc, date_published desc, id desc'
|
||||
_order = 'date_published desc, id desc'
|
||||
|
||||
title = fields.Char(string='Title', required=True, translate=True)
|
||||
content = fields.Html(string='Content', required=True)
|
||||
@ -20,23 +16,7 @@ class EducationAnnouncement(models.Model):
|
||||
('circular', 'Circular'),
|
||||
('event', 'Event')
|
||||
], string='Type', required=True, default='notice')
|
||||
priority = fields.Selection([
|
||||
('0', 'Normal'),
|
||||
('1', 'Important'),
|
||||
('2', 'Urgent')
|
||||
], string='Priority', default='0', index=True)
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('published', 'Published'),
|
||||
('archived', 'Archived')
|
||||
], string='Status', default='published', required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
author_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Author',
|
||||
default=lambda self: self.env.user,
|
||||
readonly=True
|
||||
)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
@ -46,131 +26,3 @@ class EducationAnnouncement(models.Model):
|
||||
)
|
||||
circular_file = fields.Binary(string='Circular File', attachment=True)
|
||||
circular_filename = fields.Char(string='Circular Filename')
|
||||
|
||||
# Audience & Cohort Targeting
|
||||
audience_type = fields.Selection([
|
||||
('all', 'All Students'),
|
||||
('cohort', 'Specific Academic Cohort')
|
||||
], string='Target Audience', default='all', required=True)
|
||||
program_id = fields.Many2one('education.program', string='Target Program', index=True)
|
||||
academic_year_id = fields.Many2one('education.academic.year', string='Target Academic Year', index=True)
|
||||
academic_term_id = fields.Many2one('education.academic.term', string='Target Academic Term', index=True)
|
||||
batch_id = fields.Many2one('education.batch', string='Target Batch', index=True)
|
||||
semester_id = fields.Many2one('education.semester', string='Target Semester', index=True)
|
||||
section_id = fields.Many2one('education.section', string='Target Section', index=True)
|
||||
|
||||
# Push Notification Audit
|
||||
notification_sent = fields.Boolean(string='Notification Dispatched', default=False, copy=False)
|
||||
notification_sent_date = fields.Datetime(string='Dispatched At', readonly=True, copy=False)
|
||||
notification_recipient_count = fields.Integer(string='Recipients Count', readonly=True, copy=False)
|
||||
|
||||
@api.constrains('date_published', 'expiry_date')
|
||||
def _check_dates(self):
|
||||
for rec in self:
|
||||
if rec.expiry_date and rec.expiry_date < rec.date_published:
|
||||
raise ValidationError("Expiry date must be on or after the publication date.")
|
||||
|
||||
def action_publish(self):
|
||||
for rec in self:
|
||||
if rec.expiry_date and rec.expiry_date < rec.date_published:
|
||||
raise ValidationError("Expiry date must be on or after the publication date.")
|
||||
rec.state = 'published'
|
||||
rec._dispatch_notifications()
|
||||
|
||||
def action_archive(self):
|
||||
for rec in self:
|
||||
rec.state = 'archived'
|
||||
|
||||
def action_draft(self):
|
||||
for rec in self:
|
||||
rec.state = 'draft'
|
||||
|
||||
def action_resend_notification(self):
|
||||
for rec in self:
|
||||
rec._dispatch_notifications(force=True)
|
||||
|
||||
def check_student_audience(self, student):
|
||||
"""Verify whether an announcement applies to a given student."""
|
||||
self.ensure_one()
|
||||
if self.audience_type == 'all':
|
||||
return True
|
||||
if self.program_id and self.program_id.id != student.program_id.id:
|
||||
return False
|
||||
if self.academic_year_id and self.academic_year_id.id != student.academic_year_id.id:
|
||||
return False
|
||||
if self.academic_term_id and self.academic_term_id.id != student.academic_term_id.id:
|
||||
return False
|
||||
if self.batch_id and self.batch_id.id != student.batch_id.id:
|
||||
return False
|
||||
if self.semester_id and self.semester_id.id != student.semester_id.id:
|
||||
return False
|
||||
if self.section_id and self.section_id.id != student.section_id.id:
|
||||
return False
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def get_announcements_for_student(self, student, limit=None, offset=0, announcement_type=None):
|
||||
"""Retrieve active published announcements valid for the student within the effective date window."""
|
||||
today = fields.Date.context_today(self)
|
||||
domain = [
|
||||
('active', '=', True),
|
||||
('state', '=', 'published'),
|
||||
('date_published', '<=', today),
|
||||
'|', ('expiry_date', '=', False), ('expiry_date', '>=', today),
|
||||
]
|
||||
if student.company_id:
|
||||
domain.append(('company_id', '=', student.company_id.id))
|
||||
if announcement_type:
|
||||
domain.append(('announcement_type', '=', announcement_type))
|
||||
|
||||
records = self.sudo().search(domain, order="priority desc, date_published desc, id desc")
|
||||
filtered = records.filtered(lambda a: a.check_student_audience(student))
|
||||
total_count = len(filtered)
|
||||
if offset:
|
||||
filtered = filtered[offset:]
|
||||
if limit:
|
||||
filtered = filtered[:limit]
|
||||
return filtered, total_count
|
||||
|
||||
def _dispatch_notifications(self, force=False):
|
||||
"""Provider-independent notification dispatcher with in-app logging and duplicate prevention."""
|
||||
for rec in self:
|
||||
if rec.state != 'published':
|
||||
continue
|
||||
if rec.notification_sent and not force:
|
||||
_logger.info("Notification already dispatched for announcement #%s ('%s'). Skipping duplicate.", rec.id, rec.title)
|
||||
continue
|
||||
|
||||
# Resolve targeted student recipients
|
||||
domain = []
|
||||
if rec.company_id:
|
||||
domain.append(('company_id', '=', rec.company_id.id))
|
||||
if rec.audience_type == 'cohort':
|
||||
if rec.program_id:
|
||||
domain.append(('program_id', '=', rec.program_id.id))
|
||||
if rec.academic_year_id:
|
||||
domain.append(('academic_year_id', '=', rec.academic_year_id.id))
|
||||
if rec.academic_term_id:
|
||||
domain.append(('academic_term_id', '=', rec.academic_term_id.id))
|
||||
if rec.batch_id:
|
||||
domain.append(('batch_id', '=', rec.batch_id.id))
|
||||
if rec.semester_id:
|
||||
domain.append(('semester_id', '=', rec.semester_id.id))
|
||||
if rec.section_id:
|
||||
domain.append(('section_id', '=', rec.section_id.id))
|
||||
|
||||
students = self.env['education.student'].sudo().search(domain)
|
||||
recipient_count = len(students)
|
||||
|
||||
# In-App / Decoupled Notification Dispatch Log
|
||||
_logger.info(
|
||||
"DISPATCH NOTIFICATION: Announcement #%s ('%s', Priority: %s, Audience: %s) dispatched to %s students.",
|
||||
rec.id, rec.title, rec.priority, rec.audience_type, recipient_count
|
||||
)
|
||||
|
||||
rec.write({
|
||||
'notification_sent': True,
|
||||
'notification_sent_date': fields.Datetime.now(),
|
||||
'notification_recipient_count': recipient_count,
|
||||
})
|
||||
|
||||
|
||||
@ -1,7 +1,21 @@
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo import models
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class EducationFeeAssignmentInherit(models.Model):
|
||||
_inherit = 'education.fee.assignment'
|
||||
# Base fields amount, payment_status, currency_id are provided by education_finance
|
||||
|
||||
currency_id = fields.Many2one('res.currency', related='fee_structure_id.currency_id')
|
||||
amount = fields.Monetary(compute='_compute_amount', string='Amount', currency_field='currency_id')
|
||||
payment_status = fields.Selection([
|
||||
('unpaid', 'Unpaid'),
|
||||
('paid', 'Paid'),
|
||||
], string='Payment Status', default='unpaid', required=True, index=True)
|
||||
|
||||
@api.depends('fee_structure_id', 'fee_structure_id.line_ids.amount')
|
||||
def _compute_amount(self):
|
||||
for rec in self:
|
||||
if rec.fee_structure_id:
|
||||
rec.amount = sum(line.amount for line in rec.fee_structure_id.line_ids)
|
||||
else:
|
||||
rec.amount = 0.0
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<record id="rule_announcement_company" model="ir.rule">
|
||||
<field name="name">Announcement Multi-Company Rule</field>
|
||||
<field name="model_id" ref="model_education_announcement"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
|
||||
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
|
||||
</record>
|
||||
|
||||
<!-- Portal User Record Rules -->
|
||||
@ -35,26 +35,5 @@
|
||||
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_fee_assignment_portal_own" model="ir.rule">
|
||||
<field name="name">Portal Student: View Own Fee Invoices</field>
|
||||
<field name="model_id" ref="education_finance.model_education_fee_assignment"/>
|
||||
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_student_document_portal_own" model="ir.rule">
|
||||
<field name="name">Portal Student: View and Manage Own Documents</field>
|
||||
<field name="model_id" ref="education_student.model_education_student_document"/>
|
||||
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="rule_student_certificate_portal_own" model="ir.rule">
|
||||
<field name="name">Portal Student: View Own Certificates</field>
|
||||
<field name="model_id" ref="education_student.model_education_student_certificate"/>
|
||||
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
|
||||
@ -12,10 +12,3 @@ access_term_portal,Term Portal Access,education_base.model_education_academic_te
|
||||
access_level_portal,Level Portal Access,education_base.model_education_academic_level,base.group_portal,1,0,0,0
|
||||
access_payment_transaction_user,Payment Transaction User,model_education_payment_transaction,base.group_portal,1,1,1,1
|
||||
access_payment_transaction_portal,Payment Transaction Portal,model_education_payment_transaction,base.group_portal,1,0,0,0
|
||||
access_fee_assignment_portal,Fee Assignment Portal,education_finance.model_education_fee_assignment,base.group_portal,1,0,0,0
|
||||
access_fee_structure_portal,Fee Structure Portal,education_finance.model_education_fee_structure,base.group_portal,1,0,0,0
|
||||
access_fee_structure_line_portal,Fee Structure Line Portal,education_finance.model_education_fee_structure_line,base.group_portal,1,0,0,0
|
||||
access_fee_type_portal,Fee Type Portal,education_finance.model_education_fee_type,base.group_portal,1,0,0,0
|
||||
access_fee_category_portal,Fee Category Portal,education_finance.model_education_fee_category,base.group_portal,1,0,0,0
|
||||
access_student_document_portal,Student Document Portal Access,education_student.model_education_student_document,base.group_portal,1,1,1,0
|
||||
access_student_certificate_portal,Student Certificate Portal Access,education_student.model_education_student_certificate,base.group_portal,1,0,1,0
|
||||
|
||||
|
@ -1,3 +1,2 @@
|
||||
from . import test_portal
|
||||
from . import test_payment_gateway
|
||||
from . import test_announcement
|
||||
|
||||
@ -1,238 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError, AccessError
|
||||
|
||||
|
||||
class TestEducationAnnouncement(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestEducationAnnouncement, self).setUp()
|
||||
|
||||
self.portal_group = self.env.ref('base.group_portal')
|
||||
|
||||
# Academic infrastructure
|
||||
self.campus = self.env['education.campus'].create({'name': 'Ann Campus', 'code': 'AC'})
|
||||
self.department = self.env['education.department'].create({'name': 'Ann Dept', 'code': 'AD', 'campus_id': self.campus.id})
|
||||
self.level = self.env['education.academic.level'].create({'name': 'Ann Level', 'code': 'AL'})
|
||||
self.program_a = self.env['education.program'].create({'name': 'Program A', 'code': 'PA', 'department_id': self.department.id, 'level_id': self.level.id})
|
||||
self.program_b = self.env['education.program'].create({'name': 'Program B', 'code': 'PB', 'department_id': self.department.id, 'level_id': self.level.id})
|
||||
self.year = self.env['education.academic.year'].create({'name': 'Ann Year', 'code': 'AY', 'start_date': '2026-01-01', 'end_date': '2026-12-31'})
|
||||
self.term = self.env['education.academic.term'].create({'name': 'Ann Term', 'code': 'AT', 'academic_year_id': self.year.id, 'start_date': '2026-01-01', 'end_date': '2026-06-30'})
|
||||
self.batch = self.env['education.batch'].create({'name': 'Ann Batch', 'code': 'AB'})
|
||||
self.semester = self.env['education.semester'].create({'name': 'Ann Sem', 'code': 'AS'})
|
||||
self.section_1 = self.env['education.section'].create({'name': 'Section 1', 'code': 'SEC1'})
|
||||
self.section_2 = self.env['education.section'].create({'name': 'Section 2', 'code': 'SEC2'})
|
||||
|
||||
# Student User
|
||||
self.user_student = self.env['res.users'].create({
|
||||
'name': 'Ann Student',
|
||||
'login': 'ann.student@test.com',
|
||||
'email': 'ann.student@test.com',
|
||||
'group_ids': [(6, 0, [self.portal_group.id])],
|
||||
})
|
||||
|
||||
self.student = self.env['education.student'].create({
|
||||
'first_name': 'Ann',
|
||||
'last_name': 'Student',
|
||||
'student_id': 'ANN-STU-01',
|
||||
'admission_number': 'ANN-ADM-01',
|
||||
'roll_number': 'ANN-RL-01',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program_a.id,
|
||||
'level_id': self.level.id,
|
||||
'academic_year_id': self.year.id,
|
||||
'academic_term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_1.id,
|
||||
'status': 'active',
|
||||
'email': 'ann.student@test.com',
|
||||
'portal_user_id': self.user_student.id,
|
||||
})
|
||||
|
||||
def test_announcement_workflow_and_constraints(self):
|
||||
"""Test draft, publish, archive states and date validation."""
|
||||
today = date.today()
|
||||
# Invalid expiry date constraint
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['education.announcement'].create({
|
||||
'title': 'Invalid Date Announcement',
|
||||
'content': '<p>Testing invalid date</p>',
|
||||
'date_published': today,
|
||||
'expiry_date': today - timedelta(days=2),
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Valid creation
|
||||
ann = self.env['education.announcement'].create({
|
||||
'title': 'Orientation Notice',
|
||||
'content': '<p>Welcome all new students!</p>',
|
||||
'date_published': today,
|
||||
'expiry_date': today + timedelta(days=10),
|
||||
'announcement_type': 'notice',
|
||||
'priority': '1',
|
||||
'state': 'draft',
|
||||
})
|
||||
self.assertEqual(ann.state, 'draft')
|
||||
self.assertFalse(ann.notification_sent)
|
||||
|
||||
# Publish
|
||||
ann.action_publish()
|
||||
self.assertEqual(ann.state, 'published')
|
||||
self.assertTrue(ann.notification_sent)
|
||||
self.assertIsNotNone(ann.notification_sent_date)
|
||||
|
||||
# Archive
|
||||
ann.action_archive()
|
||||
self.assertEqual(ann.state, 'archived')
|
||||
|
||||
# Reset to draft
|
||||
ann.action_draft()
|
||||
self.assertEqual(ann.state, 'draft')
|
||||
|
||||
def test_audience_filtering_all_vs_cohort(self):
|
||||
"""Test that all-student and cohort-specific announcements filter accurately."""
|
||||
today = date.today()
|
||||
# Announcement for All Students
|
||||
ann_all = self.env['education.announcement'].create({
|
||||
'title': 'General Holiday Notice',
|
||||
'content': '<p>Campus closed tomorrow</p>',
|
||||
'date_published': today,
|
||||
'audience_type': 'all',
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Cohort Announcement matching Student (Program A)
|
||||
ann_prog_a = self.env['education.announcement'].create({
|
||||
'title': 'Program A Seminar',
|
||||
'content': '<p>Mandatory for Program A</p>',
|
||||
'date_published': today,
|
||||
'audience_type': 'cohort',
|
||||
'program_id': self.program_a.id,
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Cohort Announcement for Program B (different program)
|
||||
ann_prog_b = self.env['education.announcement'].create({
|
||||
'title': 'Program B Workshop',
|
||||
'content': '<p>Exclusive for Program B</p>',
|
||||
'date_published': today,
|
||||
'audience_type': 'cohort',
|
||||
'program_id': self.program_b.id,
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Cohort Announcement for Section 2 (student is in Section 1)
|
||||
ann_sec_2 = self.env['education.announcement'].create({
|
||||
'title': 'Section 2 Lab Reschedule',
|
||||
'content': '<p>Section 2 only</p>',
|
||||
'date_published': today,
|
||||
'audience_type': 'cohort',
|
||||
'section_id': self.section_2.id,
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Check individual audience checks
|
||||
self.assertTrue(ann_all.check_student_audience(self.student))
|
||||
self.assertTrue(ann_prog_a.check_student_audience(self.student))
|
||||
self.assertFalse(ann_prog_b.check_student_audience(self.student))
|
||||
self.assertFalse(ann_sec_2.check_student_audience(self.student))
|
||||
|
||||
# Check get_announcements_for_student
|
||||
records, total_count = self.env['education.announcement'].get_announcements_for_student(self.student)
|
||||
record_ids = records.ids
|
||||
self.assertIn(ann_all.id, record_ids)
|
||||
self.assertIn(ann_prog_a.id, record_ids)
|
||||
self.assertNotIn(ann_prog_b.id, record_ids)
|
||||
self.assertNotIn(ann_sec_2.id, record_ids)
|
||||
|
||||
def test_publication_window_filtering(self):
|
||||
"""Test that future-published and expired announcements are excluded."""
|
||||
today = date.today()
|
||||
|
||||
# Currently active
|
||||
ann_active = self.env['education.announcement'].create({
|
||||
'title': 'Active Notice',
|
||||
'content': '<p>Active</p>',
|
||||
'date_published': today - timedelta(days=1),
|
||||
'expiry_date': today + timedelta(days=5),
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Future notice (scheduled)
|
||||
ann_future = self.env['education.announcement'].create({
|
||||
'title': 'Future Notice',
|
||||
'content': '<p>Not yet active</p>',
|
||||
'date_published': today + timedelta(days=2),
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Expired notice
|
||||
ann_expired = self.env['education.announcement'].create({
|
||||
'title': 'Expired Notice',
|
||||
'content': '<p>Expired yesterday</p>',
|
||||
'date_published': today - timedelta(days=10),
|
||||
'expiry_date': today - timedelta(days=1),
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
records, _ = self.env['education.announcement'].get_announcements_for_student(self.student)
|
||||
record_ids = records.ids
|
||||
self.assertIn(ann_active.id, record_ids)
|
||||
self.assertNotIn(ann_future.id, record_ids)
|
||||
self.assertNotIn(ann_expired.id, record_ids)
|
||||
|
||||
def test_notification_dispatch_and_duplicate_prevention(self):
|
||||
"""Test that publishing sends notifications once and avoids duplicates unless forced."""
|
||||
ann = self.env['education.announcement'].create({
|
||||
'title': 'Annual Fest Announcement',
|
||||
'content': '<p>Registrations open</p>',
|
||||
'date_published': date.today(),
|
||||
'audience_type': 'cohort',
|
||||
'program_id': self.program_a.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
self.assertFalse(ann.notification_sent)
|
||||
|
||||
# First publish sends notification
|
||||
ann.action_publish()
|
||||
self.assertTrue(ann.notification_sent)
|
||||
first_dispatch_time = ann.notification_sent_date
|
||||
self.assertIsNotNone(first_dispatch_time)
|
||||
self.assertGreaterEqual(ann.notification_recipient_count, 1)
|
||||
|
||||
# Calling _dispatch_notifications directly should NOT re-dispatch
|
||||
ann._dispatch_notifications(force=False)
|
||||
self.assertEqual(ann.notification_sent_date, first_dispatch_time)
|
||||
|
||||
# Forced resend should re-dispatch
|
||||
ann.action_resend_notification()
|
||||
self.assertTrue(ann.notification_sent)
|
||||
|
||||
def test_student_security_access_control(self):
|
||||
"""Verify that portal student users cannot create, modify, or delete announcements."""
|
||||
today = date.today()
|
||||
ann = self.env['education.announcement'].create({
|
||||
'title': 'Admin Announcement',
|
||||
'content': '<p>Administrative text</p>',
|
||||
'date_published': today,
|
||||
'state': 'published',
|
||||
})
|
||||
|
||||
# Student user attempt to create
|
||||
with self.assertRaises(AccessError):
|
||||
self.env['education.announcement'].with_user(self.user_student).create({
|
||||
'title': 'Student Hack Announcement',
|
||||
'content': '<p>Hacked</p>',
|
||||
'date_published': today,
|
||||
})
|
||||
|
||||
# Student user attempt to write
|
||||
with self.assertRaises(AccessError):
|
||||
ann.with_user(self.user_student).write({'title': 'Defaced Title'})
|
||||
|
||||
# Student user attempt to unlink
|
||||
with self.assertRaises(AccessError):
|
||||
ann.with_user(self.user_student).unlink()
|
||||
@ -5,16 +5,12 @@
|
||||
<field name="model">education.announcement</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Announcements">
|
||||
<field name="priority" widget="priority"/>
|
||||
<field name="title"/>
|
||||
<field name="announcement_type" widget="badge" decoration-info="announcement_type == 'notice'" decoration-warning="announcement_type == 'circular'" decoration-success="announcement_type == 'event'"/>
|
||||
<field name="audience_type" widget="badge" decoration-primary="audience_type == 'all'" decoration-muted="audience_type == 'cohort'"/>
|
||||
<field name="date_published"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'published'" decoration-danger="state == 'archived'"/>
|
||||
<field name="notification_sent" widget="boolean_toggle" optional="show"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active" widget="boolean_toggle" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
@ -24,30 +20,20 @@
|
||||
<field name="model">education.announcement</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Announcement">
|
||||
<header>
|
||||
<button name="action_publish" string="Publish & Notify" type="object" invisible="state == 'published'" class="oe_highlight"/>
|
||||
<button name="action_archive" string="Archive" type="object" invisible="state == 'archived'"/>
|
||||
<button name="action_draft" string="Reset to Draft" type="object" invisible="state == 'draft'"/>
|
||||
<button name="action_resend_notification" string="Resend Notification" type="object" invisible="state != 'published' or not notification_sent" confirm="Are you sure you want to re-dispatch notifications to students?"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,published,archived"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="title" class="oe_edit_only"/>
|
||||
<h1><field name="title" placeholder="Announcement Title..."/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="General Information">
|
||||
<group>
|
||||
<field name="announcement_type"/>
|
||||
<field name="priority" widget="priority"/>
|
||||
<field name="date_published"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="circular_filename" invisible="1"/>
|
||||
<field name="circular_file" filename="circular_filename" widget="binary" invisible="announcement_type != 'circular'"/>
|
||||
</group>
|
||||
<group string="Publication & Scope">
|
||||
<field name="author_id"/>
|
||||
<field name="audience_type"/>
|
||||
<group>
|
||||
<field name="active"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
@ -56,29 +42,6 @@
|
||||
<page string="Announcement Details" name="content_page">
|
||||
<field name="content" placeholder="Write full details here..." widget="html" options="{'safe': True}"/>
|
||||
</page>
|
||||
<page string="Audience & Cohort Targeting" name="cohort_targeting" invisible="audience_type != 'cohort'">
|
||||
<group>
|
||||
<group string="Academic Scope">
|
||||
<field name="program_id"/>
|
||||
<field name="academic_year_id"/>
|
||||
<field name="academic_term_id"/>
|
||||
</group>
|
||||
<group string="Class & Cohort Scope">
|
||||
<field name="batch_id"/>
|
||||
<field name="semester_id"/>
|
||||
<field name="section_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Notification Audit" name="notification_audit">
|
||||
<group>
|
||||
<group>
|
||||
<field name="notification_sent"/>
|
||||
<field name="notification_sent_date"/>
|
||||
<field name="notification_recipient_count"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
@ -92,23 +55,10 @@
|
||||
<search string="Announcements">
|
||||
<field name="title"/>
|
||||
<field name="announcement_type"/>
|
||||
<field name="program_id"/>
|
||||
<field name="author_id"/>
|
||||
<filter string="Published" name="published" domain="[('state', '=', 'published')]"/>
|
||||
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Archived" name="archived_status" domain="[('state', '=', 'archived')]"/>
|
||||
<separator/>
|
||||
<filter string="Urgent / Important" name="urgent" domain="[('priority', 'in', ['1', '2'])]"/>
|
||||
<filter string="All Students" name="all_students" domain="[('audience_type', '=', 'all')]"/>
|
||||
<filter string="Cohort Specific" name="cohort_students" domain="[('audience_type', '=', 'cohort')]"/>
|
||||
<separator/>
|
||||
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Inactive" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
|
||||
<group>
|
||||
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Type" name="group_type" context="{'group_by': 'announcement_type'}"/>
|
||||
<filter string="Priority" name="group_priority" context="{'group_by': 'priority'}"/>
|
||||
<filter string="Audience" name="group_audience" context="{'group_by': 'audience_type'}"/>
|
||||
<filter string="Published Date" name="group_date" context="{'group_by': 'date_published'}"/>
|
||||
</group>
|
||||
</search>
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
'data/education_student_sequence.xml',
|
||||
'views/academic_config_views.xml',
|
||||
'views/student_views.xml',
|
||||
'views/student_document_views.xml',
|
||||
'data/education_student_menus_actions.xml',
|
||||
],
|
||||
'demo': [
|
||||
|
||||
@ -26,13 +26,4 @@
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
|
||||
<record id="seq_education_student_certificate" model="ir.sequence">
|
||||
<field name="name">Education Student Certificate</field>
|
||||
<field name="code">education.student.certificate</field>
|
||||
<field name="prefix">CERT/%(year)s/</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="number_next">1</field>
|
||||
<field name="number_increment">1</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@ -1,4 +1,2 @@
|
||||
from . import student
|
||||
from . import academic_config
|
||||
from . import student_document
|
||||
from . import student_certificate
|
||||
|
||||
@ -138,11 +138,6 @@ class EducationStudent(models.Model):
|
||||
doc_mark_sheets = fields.Binary(string='Mark Sheets')
|
||||
doc_mark_sheets_name = fields.Char(string='Mark Sheets Filename')
|
||||
|
||||
# Document and Certificate Relational Links
|
||||
document_ids = fields.One2many('education.student.document', 'student_id', string='Documents')
|
||||
certificate_ids = fields.One2many('education.student.certificate', 'student_id', string='Certificates')
|
||||
profile_completion = fields.Integer(string='Profile Completion (%)', compute='_compute_profile_completion')
|
||||
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('admitted', 'Admitted'),
|
||||
@ -185,20 +180,6 @@ class EducationStudent(models.Model):
|
||||
else:
|
||||
rec.age = 0
|
||||
|
||||
@api.depends(
|
||||
'first_name', 'last_name', 'email', 'mobile', 'gender', 'date_of_birth',
|
||||
'blood_group', 'street', 'city', 'emergency_contact', 'emergency_phone', 'image'
|
||||
)
|
||||
def _compute_profile_completion(self):
|
||||
core_fields = [
|
||||
'first_name', 'last_name', 'email', 'mobile', 'gender', 'date_of_birth',
|
||||
'blood_group', 'street', 'city', 'emergency_contact', 'emergency_phone', 'image'
|
||||
]
|
||||
total = len(core_fields)
|
||||
for rec in self:
|
||||
filled = sum(1 for f in core_fields if getattr(rec, f, False))
|
||||
rec.profile_completion = int(round((filled / total) * 100))
|
||||
|
||||
@api.constrains('date_of_birth')
|
||||
def _check_date_of_birth(self):
|
||||
for rec in self:
|
||||
@ -268,47 +249,6 @@ class EducationStudent(models.Model):
|
||||
def action_cancel(self):
|
||||
self.write({'status': 'cancelled', 'leave_date': fields.Date.today()})
|
||||
|
||||
def update_student_profile(self, vals):
|
||||
self.ensure_one()
|
||||
forbidden_fields = {
|
||||
'student_id', 'admission_number', 'registration_number', 'roll_number',
|
||||
'program_id', 'department_id', 'campus_id', 'level_id',
|
||||
'academic_year_id', 'academic_term_id', 'batch_id', 'semester_id',
|
||||
'section_id', 'status', 'company_id', 'admission_date',
|
||||
'enrollment_date', 'graduation_date', 'leave_date'
|
||||
}
|
||||
for k in vals.keys():
|
||||
if k in forbidden_fields:
|
||||
raise ValidationError(f"Modifying protected administrative field '{k}' is strictly prohibited.")
|
||||
|
||||
allowed_fields = {
|
||||
'email', 'mobile', 'phone', 'blood_group',
|
||||
'emergency_contact', 'emergency_phone',
|
||||
'street', 'street2', 'city', 'zip', 'image'
|
||||
}
|
||||
clean_vals = {k: v for k, v in vals.items() if k in allowed_fields}
|
||||
|
||||
if 'email' in clean_vals and clean_vals['email']:
|
||||
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", str(clean_vals['email'])):
|
||||
raise ValidationError('Please enter a valid email address.')
|
||||
if 'mobile' in clean_vals and clean_vals['mobile']:
|
||||
if not re.match(r"^\+?[0-9\s\-()]+$", str(clean_vals['mobile'])):
|
||||
raise ValidationError('Please enter a valid mobile number.')
|
||||
if 'emergency_phone' in clean_vals and clean_vals['emergency_phone']:
|
||||
if not re.match(r"^\+?[0-9\s\-()]+$", str(clean_vals['emergency_phone'])):
|
||||
raise ValidationError('Please enter a valid emergency phone number.')
|
||||
if 'blood_group' in clean_vals and clean_vals['blood_group']:
|
||||
bg = str(clean_vals['blood_group']).strip().lower()
|
||||
valid_bgs = {'a+', 'a-', 'b+', 'b-', 'ab+', 'ab-', 'o+', 'o-'}
|
||||
if bg in valid_bgs:
|
||||
clean_vals['blood_group'] = bg
|
||||
else:
|
||||
raise ValidationError(f"Invalid blood group '{clean_vals['blood_group']}'. Must be one of A+, A-, B+, B-, AB+, AB-, O+, O-.")
|
||||
|
||||
if clean_vals:
|
||||
self.write(clean_vals)
|
||||
return True
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo import api, fields, models
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo.exceptions import ValidationError
|
||||
import base64
|
||||
|
||||
|
||||
class EducationStudentCertificate(models.Model):
|
||||
_name = 'education.student.certificate'
|
||||
_description = 'Student Certificate'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'issue_date desc, id desc'
|
||||
|
||||
name = fields.Char(string='Certificate Number', readonly=True, copy=False, index=True)
|
||||
student_id = fields.Many2one(
|
||||
'education.student',
|
||||
string='Student',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
tracking=True
|
||||
)
|
||||
certificate_type = fields.Selection([
|
||||
('bonafide', 'Bonafide Certificate'),
|
||||
('id_card', 'Student ID Card'),
|
||||
('course_completion', 'Course Completion Certificate'),
|
||||
('conduct', 'Conduct / Character Certificate'),
|
||||
], string='Certificate Type', required=True, default='bonafide', tracking=True)
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('requested', 'Requested'),
|
||||
('approved', 'Approved'),
|
||||
('issued', 'Issued'),
|
||||
('rejected', 'Rejected'),
|
||||
], string='Status', default='draft', required=True, tracking=True, index=True)
|
||||
|
||||
request_date = fields.Date(string='Request Date', default=fields.Date.context_today, readonly=True)
|
||||
issue_date = fields.Date(string='Issue Date', readonly=True, tracking=True)
|
||||
expiry_date = fields.Date(string='Expiry Date')
|
||||
issued_by = fields.Many2one('res.users', string='Issued By', readonly=True)
|
||||
|
||||
document_file = fields.Binary(string='Certificate PDF', attachment=True, readonly=True)
|
||||
file_name = fields.Char(string='Certificate Filename', readonly=True)
|
||||
remarks = fields.Text(string='Student Purpose / Remarks')
|
||||
rejection_reason = fields.Text(string='Rejection Reason')
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='student_id.company_id',
|
||||
store=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
def action_request(self):
|
||||
for rec in self:
|
||||
if rec.student_id.status not in ('active', 'admitted'):
|
||||
raise ValidationError("Only active or admitted students are eligible to request certificates.")
|
||||
rec.state = 'requested'
|
||||
|
||||
def action_approve(self):
|
||||
for rec in self:
|
||||
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
|
||||
raise ValidationError("Only authorized staff can approve certificate requests.")
|
||||
rec.state = 'approved'
|
||||
|
||||
def action_issue(self):
|
||||
for rec in self:
|
||||
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
|
||||
raise ValidationError("Only authorized staff can issue certificates.")
|
||||
if rec.student_id.status not in ('active', 'admitted'):
|
||||
raise ValidationError("Cannot issue official certificate for a student with inactive status.")
|
||||
if not rec.name:
|
||||
rec.name = self.env['ir.sequence'].next_by_code('education.student.certificate') or f"CERT-{rec.id}"
|
||||
rec.issue_date = fields.Date.context_today(self)
|
||||
rec.issued_by = self.env.user.id
|
||||
rec.state = 'issued'
|
||||
|
||||
# Generate PDF using existing report if available
|
||||
report_ref = None
|
||||
if rec.certificate_type == 'bonafide':
|
||||
report_ref = 'education_reports.action_report_bonafide_certificate'
|
||||
elif rec.certificate_type == 'id_card':
|
||||
report_ref = 'education_reports.action_report_student_id_card'
|
||||
|
||||
if report_ref and self.env.ref(report_ref, raise_if_not_found=False):
|
||||
try:
|
||||
pdf_content, _ = self.env['ir.actions.report'].sudo()._render_qweb_pdf(report_ref, rec.student_id.ids)
|
||||
if pdf_content:
|
||||
rec.document_file = base64.b64encode(pdf_content)
|
||||
clean_code = (rec.name or 'CERT').replace('/', '_')
|
||||
rec.file_name = f"{clean_code}.pdf"
|
||||
except Exception:
|
||||
# Non-blocking if wkhtmltopdf not present in current environment
|
||||
pass
|
||||
|
||||
def action_reject(self, reason=None):
|
||||
for rec in self:
|
||||
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
|
||||
raise ValidationError("Only authorized staff can reject certificate requests.")
|
||||
rec.state = 'rejected'
|
||||
if reason:
|
||||
rec.rejection_reason = reason
|
||||
@ -1,102 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo import api, fields, models
|
||||
# pyrefly: ignore [missing-import]
|
||||
from odoo.exceptions import ValidationError
|
||||
import base64
|
||||
|
||||
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||
|
||||
|
||||
class EducationStudentDocument(models.Model):
|
||||
_name = 'education.student.document'
|
||||
_description = 'Student Document'
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'upload_date desc, id desc'
|
||||
|
||||
name = fields.Char(string='Document Title', required=True, tracking=True)
|
||||
student_id = fields.Many2one(
|
||||
'education.student',
|
||||
string='Student',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
index=True,
|
||||
tracking=True
|
||||
)
|
||||
category = fields.Selection([
|
||||
('identity', 'Identity Proof'),
|
||||
('academic', 'Academic Certificate / Marksheet'),
|
||||
('transfer', 'Transfer / Migration Certificate'),
|
||||
('address', 'Address Proof'),
|
||||
('other', 'Other Document'),
|
||||
], string='Category', required=True, default='academic', tracking=True)
|
||||
|
||||
document_file = fields.Binary(string='Document File', required=True, attachment=True)
|
||||
file_name = fields.Char(string='Filename')
|
||||
file_size = fields.Integer(string='File Size (Bytes)', compute='_compute_file_metadata', store=True)
|
||||
mime_type = fields.Char(string='MIME Type')
|
||||
|
||||
verification_status = fields.Selection([
|
||||
('submitted', 'Submitted'),
|
||||
('verified', 'Verified'),
|
||||
('rejected', 'Rejected'),
|
||||
], string='Verification Status', default='submitted', required=True, tracking=True, index=True)
|
||||
|
||||
verified_by = fields.Many2one('res.users', string='Verified By', readonly=True, tracking=True)
|
||||
verification_date = fields.Datetime(string='Verification Date', readonly=True, tracking=True)
|
||||
rejection_reason = fields.Text(string='Rejection Reason', tracking=True)
|
||||
upload_date = fields.Datetime(string='Upload Date', default=fields.Datetime.now, readonly=True)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
related='student_id.company_id',
|
||||
store=True,
|
||||
index=True
|
||||
)
|
||||
|
||||
@api.depends('document_file')
|
||||
def _compute_file_metadata(self):
|
||||
for rec in self:
|
||||
if rec.document_file:
|
||||
try:
|
||||
decoded = base64.b64decode(rec.document_file)
|
||||
rec.file_size = len(decoded)
|
||||
except Exception:
|
||||
rec.file_size = 0
|
||||
else:
|
||||
rec.file_size = 0
|
||||
|
||||
@api.constrains('document_file', 'file_name')
|
||||
def _check_file_validity(self):
|
||||
for rec in self:
|
||||
if rec.document_file:
|
||||
decoded = base64.b64decode(rec.document_file)
|
||||
if len(decoded) > MAX_FILE_SIZE_BYTES:
|
||||
raise ValidationError(f"File size exceeds the maximum allowed limit of 5 MB ({len(decoded)} bytes).")
|
||||
if rec.file_name:
|
||||
ext = rec.file_name.split('.')[-1].lower() if '.' in rec.file_name else ''
|
||||
if ext not in ['pdf', 'jpg', 'jpeg', 'png']:
|
||||
raise ValidationError("Invalid file type. Allowed formats: PDF, JPG, JPEG, PNG.")
|
||||
|
||||
def action_verify(self):
|
||||
for rec in self:
|
||||
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
|
||||
raise ValidationError("Only authorized staff can verify student documents.")
|
||||
rec.write({
|
||||
'verification_status': 'verified',
|
||||
'verified_by': self.env.user.id,
|
||||
'verification_date': fields.Datetime.now(),
|
||||
'rejection_reason': False,
|
||||
})
|
||||
|
||||
def action_reject(self, reason="Document details could not be verified."):
|
||||
for rec in self:
|
||||
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
|
||||
raise ValidationError("Only authorized staff can reject student documents.")
|
||||
rec.write({
|
||||
'verification_status': 'rejected',
|
||||
'verified_by': self.env.user.id,
|
||||
'verification_date': fields.Datetime.now(),
|
||||
'rejection_reason': reason,
|
||||
})
|
||||
@ -8,9 +8,3 @@ access_education_semester_user,education.semester user,model_education_semester,
|
||||
access_education_semester_manager,education.semester manager,model_education_semester,group_education_student_manager,1,1,1,1
|
||||
access_education_section_user,education.section user,model_education_section,base.group_user,1,0,0,0
|
||||
access_education_section_manager,education.section manager,model_education_section,group_education_student_manager,1,1,1,1
|
||||
access_education_student_document_user,education.student.document user,model_education_student_document,group_education_student_user,1,1,1,0
|
||||
access_education_student_document_manager,education.student.document manager,model_education_student_document,group_education_student_manager,1,1,1,1
|
||||
access_education_student_document_admin,education.student.document admin,model_education_student_document,group_education_student_admin,1,1,1,1
|
||||
access_education_student_certificate_user,education.student.certificate user,model_education_student_certificate,group_education_student_user,1,1,1,0
|
||||
access_education_student_certificate_manager,education.student.certificate manager,model_education_student_certificate,group_education_student_manager,1,1,1,1
|
||||
access_education_student_certificate_admin,education.student.certificate admin,model_education_student_certificate,group_education_student_admin,1,1,1,1
|
||||
|
||||
|
@ -24,16 +24,4 @@
|
||||
<field name="model_id" ref="model_education_section"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_education_student_document_company" model="ir.rule">
|
||||
<field name="name">Education Student Document Company Rule</field>
|
||||
<field name="model_id" ref="model_education_student_document"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_education_student_certificate_company" model="ir.rule">
|
||||
<field name="name">Education Student Certificate Company Rule</field>
|
||||
<field name="model_id" ref="model_education_student_certificate"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@ -1,2 +1 @@
|
||||
from . import test_student
|
||||
from . import test_student_profile_security
|
||||
|
||||
@ -49,13 +49,13 @@ class TestEducationStudent(TransactionCase):
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.batch = self.env['education.batch'].create({
|
||||
'name': 'Batch 2025 Test', 'code': 'B2025T',
|
||||
'name': 'Batch 2025', 'code': 'B2025',
|
||||
})
|
||||
self.semester = self.env['education.semester'].create({
|
||||
'name': 'Semester 1 Test', 'code': 'SEM1T',
|
||||
'name': 'Semester 1', 'code': 'SEM1',
|
||||
})
|
||||
self.section = self.env['education.section'].create({
|
||||
'name': 'Section A Test', 'code': 'SEC-AT',
|
||||
'name': 'Section A', 'code': 'SEC-A',
|
||||
})
|
||||
|
||||
def test_student_create(self):
|
||||
|
||||
@ -1,275 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import base64
|
||||
from odoo import fields
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError, AccessError
|
||||
|
||||
|
||||
class TestStudentProfileAndDocumentsSecurity(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.company = self.env.company
|
||||
self.campus = self.env['education.campus'].create({
|
||||
'name': 'Main Campus',
|
||||
'code': 'MC',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.level = self.env['education.academic.level'].create({
|
||||
'name': 'Undergraduate',
|
||||
'code': 'UG',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.department = self.env['education.department'].create({
|
||||
'name': 'Computer Science',
|
||||
'code': 'CS',
|
||||
'campus_id': self.campus.id,
|
||||
})
|
||||
self.academic_year = self.env['education.academic.year'].create({
|
||||
'name': '2025-2026',
|
||||
'code': 'AY2025',
|
||||
'start_date': '2025-06-01',
|
||||
'end_date': '2026-05-31',
|
||||
'company_id': self.company.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
self.academic_term = self.env['education.academic.term'].create({
|
||||
'name': 'Semester 1',
|
||||
'code': 'SEM1',
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'start_date': '2025-06-01',
|
||||
'end_date': '2025-09-30',
|
||||
'sequence': 1,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.program = self.env['education.program'].create({
|
||||
'name': 'B.Tech CS',
|
||||
'code': 'BT-CS',
|
||||
'level_id': self.level.id,
|
||||
'department_id': self.department.id,
|
||||
'duration': 4,
|
||||
'duration_unit': 'years',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.batch = self.env['education.batch'].create({
|
||||
'name': 'Sec Batch 2025',
|
||||
'code': 'SB25',
|
||||
})
|
||||
self.semester = self.env['education.semester'].create({
|
||||
'name': 'Sec Semester 1',
|
||||
'code': 'SS1',
|
||||
})
|
||||
self.section = self.env['education.section'].create({
|
||||
'name': 'Sec Section A',
|
||||
'code': 'SSEC-A',
|
||||
})
|
||||
|
||||
# Student A
|
||||
self.student_a = self.env['education.student'].create({
|
||||
'first_name': 'Alice',
|
||||
'last_name': 'Smith',
|
||||
'email': 'alice.smith@example.com',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
'level_id': self.level.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'academic_term_id': self.academic_term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section.id,
|
||||
'status': 'active',
|
||||
})
|
||||
|
||||
# Student B
|
||||
self.student_b = self.env['education.student'].create({
|
||||
'first_name': 'Bob',
|
||||
'last_name': 'Jones',
|
||||
'email': 'bob.jones@example.com',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
'level_id': self.level.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'academic_term_id': self.academic_term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section.id,
|
||||
'status': 'draft',
|
||||
})
|
||||
|
||||
# Staff user
|
||||
self.staff_user = self.env['res.users'].create({
|
||||
'name': 'Academic Officer',
|
||||
'login': 'academic.officer@test.com',
|
||||
'email': 'academic.officer@test.com',
|
||||
'group_ids': [(6, 0, [
|
||||
self.env.ref('base.group_user').id,
|
||||
self.env.ref('education_student.group_education_student_user').id,
|
||||
])]
|
||||
})
|
||||
|
||||
# Portal user (student without internal staff group)
|
||||
self.portal_user = self.env['res.users'].create({
|
||||
'name': 'Portal User Student',
|
||||
'login': 'portal.student@test.com',
|
||||
'email': 'portal.student@test.com',
|
||||
'group_ids': [(6, 0, [
|
||||
self.env.ref('base.group_portal').id,
|
||||
])]
|
||||
})
|
||||
|
||||
def test_profile_completion_computation(self):
|
||||
"""Test calculation of profile completion percentage."""
|
||||
initial_completion = self.student_a.profile_completion
|
||||
self.assertGreater(initial_completion, 0)
|
||||
self.assertLessEqual(initial_completion, 100)
|
||||
|
||||
# Update several profile fields and verify increase
|
||||
self.student_a.write({
|
||||
'mobile': '+919876543210',
|
||||
'gender': 'female',
|
||||
'date_of_birth': '2004-05-15',
|
||||
'blood_group': 'a+',
|
||||
'street': '456 Campus Ave',
|
||||
'city': 'Chennai',
|
||||
'emergency_contact': 'Jane Smith',
|
||||
'emergency_phone': '+919876543219',
|
||||
})
|
||||
updated_completion = self.student_a.profile_completion
|
||||
self.assertGreater(updated_completion, initial_completion)
|
||||
|
||||
def test_update_student_profile_allowed_fields(self):
|
||||
"""Test updating allowed self-service fields via update_student_profile."""
|
||||
res = self.student_a.update_student_profile({
|
||||
'mobile': '+919876543210',
|
||||
'blood_group': 'o+',
|
||||
'emergency_contact': 'Parent Contact',
|
||||
'emergency_phone': '+919876543211',
|
||||
'street': '123 College Road',
|
||||
'city': 'Coimbatore',
|
||||
'zip': '641001',
|
||||
})
|
||||
self.assertTrue(res)
|
||||
self.assertEqual(self.student_a.mobile, '+919876543210')
|
||||
self.assertEqual(self.student_a.blood_group, 'o+')
|
||||
self.assertEqual(self.student_a.emergency_contact, 'Parent Contact')
|
||||
self.assertEqual(self.student_a.city, 'Coimbatore')
|
||||
|
||||
def test_update_student_profile_forbidden_fields(self):
|
||||
"""Test that modifying protected academic or administrative fields raises ValidationError."""
|
||||
forbidden_attempts = [
|
||||
{'student_id': 'NEW-STU-001'},
|
||||
{'admission_number': 'NEW-ADM-001'},
|
||||
{'registration_number': 'REG-001'},
|
||||
{'roll_number': 'ROLL-001'},
|
||||
{'status': 'graduated'},
|
||||
{'program_id': self.program.id},
|
||||
{'academic_year_id': self.academic_year.id},
|
||||
]
|
||||
for payload in forbidden_attempts:
|
||||
with self.subTest(payload=payload):
|
||||
with self.assertRaises(ValidationError):
|
||||
self.student_a.update_student_profile(payload)
|
||||
|
||||
def test_update_student_profile_format_validation(self):
|
||||
"""Test email and phone format validation in profile updates."""
|
||||
with self.assertRaises(ValidationError):
|
||||
self.student_a.update_student_profile({'email': 'invalid-email-address'})
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.student_a.update_student_profile({'mobile': 'invalid-mobile-letters-xyz'})
|
||||
|
||||
def test_document_creation_and_constraints(self):
|
||||
"""Test student document upload validation."""
|
||||
valid_pdf = base64.b64encode(b'%PDF-1.4 test document').decode('utf-8')
|
||||
doc = self.env['education.student.document'].create({
|
||||
'name': '10th Marksheet',
|
||||
'student_id': self.student_a.id,
|
||||
'category': 'academic',
|
||||
'document_file': valid_pdf,
|
||||
'file_name': 'marksheet_10th.pdf',
|
||||
})
|
||||
self.assertEqual(doc.verification_status, 'submitted')
|
||||
self.assertGreater(doc.file_size, 0)
|
||||
|
||||
# Invalid file extension
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['education.student.document'].create({
|
||||
'name': 'Executable Script',
|
||||
'student_id': self.student_a.id,
|
||||
'category': 'other',
|
||||
'document_file': valid_pdf,
|
||||
'file_name': 'payload.exe',
|
||||
})
|
||||
|
||||
# Oversized file (>5MB)
|
||||
oversized_data = base64.b64encode(b'A' * (6 * 1024 * 1024)).decode('utf-8')
|
||||
with self.assertRaises(ValidationError):
|
||||
self.env['education.student.document'].create({
|
||||
'name': 'Huge Document',
|
||||
'student_id': self.student_a.id,
|
||||
'category': 'academic',
|
||||
'document_file': oversized_data,
|
||||
'file_name': 'huge.pdf',
|
||||
})
|
||||
|
||||
def test_document_verification_workflow_and_security(self):
|
||||
"""Test document verification is restricted to authorized staff."""
|
||||
valid_pdf = base64.b64encode(b'%PDF-1.4 marksheet').decode('utf-8')
|
||||
doc = self.env['education.student.document'].create({
|
||||
'name': 'Transfer Certificate',
|
||||
'student_id': self.student_a.id,
|
||||
'category': 'transfer',
|
||||
'document_file': valid_pdf,
|
||||
'file_name': 'tc.pdf',
|
||||
})
|
||||
|
||||
# Non-staff portal user cannot verify
|
||||
with self.assertRaises(ValidationError):
|
||||
doc.with_user(self.portal_user).action_verify()
|
||||
|
||||
# Staff user can verify
|
||||
doc.with_user(self.staff_user).action_verify()
|
||||
self.assertEqual(doc.verification_status, 'verified')
|
||||
self.assertEqual(doc.verified_by, self.staff_user)
|
||||
self.assertTrue(doc.verification_date)
|
||||
|
||||
# Staff user can reject with reason
|
||||
doc.with_user(self.staff_user).action_reject(reason="Incomplete stamp on certificate.")
|
||||
self.assertEqual(doc.verification_status, 'rejected')
|
||||
self.assertEqual(doc.rejection_reason, "Incomplete stamp on certificate.")
|
||||
|
||||
def test_certificate_request_and_issuance_workflow(self):
|
||||
"""Test certificate request by eligible student, staff approval and issuance."""
|
||||
# Active student requests bonafide certificate
|
||||
cert = self.env['education.student.certificate'].create({
|
||||
'student_id': self.student_a.id,
|
||||
'certificate_type': 'bonafide',
|
||||
'remarks': 'For passport application',
|
||||
})
|
||||
cert.action_request()
|
||||
self.assertEqual(cert.state, 'requested')
|
||||
|
||||
# Inactive / draft student cannot request certificate
|
||||
draft_cert = self.env['education.student.certificate'].create({
|
||||
'student_id': self.student_b.id,
|
||||
'certificate_type': 'bonafide',
|
||||
})
|
||||
with self.assertRaises(ValidationError):
|
||||
draft_cert.action_request()
|
||||
|
||||
# Non-staff cannot approve
|
||||
with self.assertRaises(ValidationError):
|
||||
cert.with_user(self.portal_user).action_approve()
|
||||
|
||||
# Staff approves and issues certificate
|
||||
cert.with_user(self.staff_user).action_approve()
|
||||
self.assertEqual(cert.state, 'approved')
|
||||
|
||||
cert.with_user(self.staff_user).action_issue()
|
||||
self.assertEqual(cert.state, 'issued')
|
||||
self.assertTrue(cert.name)
|
||||
self.assertTrue(cert.name.startswith('CERT/'))
|
||||
self.assertEqual(cert.issue_date, fields.Date.today())
|
||||
self.assertEqual(cert.issued_by, self.staff_user)
|
||||
@ -1,188 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- ==================== STUDENT DOCUMENTS ==================== -->
|
||||
<record id="view_education_student_document_tree" model="ir.ui.view">
|
||||
<field name="name">education.student.document.tree</field>
|
||||
<field name="model">education.student.document</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Student Documents" decoration-info="verification_status == 'submitted'" decoration-success="verification_status == 'verified'" decoration-danger="verification_status == 'rejected'">
|
||||
<field name="name"/>
|
||||
<field name="student_id"/>
|
||||
<field name="category"/>
|
||||
<field name="file_name"/>
|
||||
<field name="file_size" widget="integer"/>
|
||||
<field name="upload_date"/>
|
||||
<field name="verification_status" widget="badge" decoration-success="verification_status == 'verified'" decoration-danger="verification_status == 'rejected'" decoration-warning="verification_status == 'submitted'"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_student_document_form" model="ir.ui.view">
|
||||
<field name="name">education.student.document.form</field>
|
||||
<field name="model">education.student.document</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Student Document">
|
||||
<header>
|
||||
<button name="action_verify" string="Verify" type="object" class="oe_highlight" invisible="verification_status == 'verified'" groups="education_student.group_education_student_user"/>
|
||||
<button name="action_reject" string="Reject" type="object" invisible="verification_status == 'rejected'" groups="education_student.group_education_student_user"/>
|
||||
<field name="verification_status" widget="statusbar" statusbar_visible="submitted,verified,rejected"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Document Details">
|
||||
<field name="name"/>
|
||||
<field name="student_id"/>
|
||||
<field name="category"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group string="File Info">
|
||||
<field name="document_file" filename="file_name"/>
|
||||
<field name="file_name"/>
|
||||
<field name="file_size"/>
|
||||
<field name="mime_type"/>
|
||||
<field name="upload_date"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Verification Details" invisible="verification_status == 'submitted'">
|
||||
<group>
|
||||
<field name="verified_by"/>
|
||||
<field name="verification_date"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="rejection_reason" invisible="verification_status != 'rejected'"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_student_document_search" model="ir.ui.view">
|
||||
<field name="name">education.student.document.search</field>
|
||||
<field name="model">education.student.document</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Student Documents">
|
||||
<field name="name" string="Document Title"/>
|
||||
<field name="student_id"/>
|
||||
<field name="category"/>
|
||||
<filter string="Pending Verification" name="filter_submitted" domain="[('verification_status', '=', 'submitted')]"/>
|
||||
<filter string="Verified" name="filter_verified" domain="[('verification_status', '=', 'verified')]"/>
|
||||
<filter string="Rejected" name="filter_rejected" domain="[('verification_status', '=', 'rejected')]"/>
|
||||
<group>
|
||||
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||
<filter string="Category" name="group_category" context="{'group_by': 'category'}"/>
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'verification_status'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_student_document" model="ir.actions.act_window">
|
||||
<field name="name">Student Documents</field>
|
||||
<field name="res_model">education.student.document</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_education_student_document_search"/>
|
||||
<field name="context">{'search_default_filter_submitted': 1}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_education_student_document"
|
||||
name="Student Documents"
|
||||
parent="education_base.menu_education_base_root"
|
||||
action="action_education_student_document"
|
||||
sequence="15"
|
||||
groups="education_student.group_education_student_user"/>
|
||||
|
||||
<!-- ==================== STUDENT CERTIFICATES ==================== -->
|
||||
<record id="view_education_student_certificate_tree" model="ir.ui.view">
|
||||
<field name="name">education.student.certificate.tree</field>
|
||||
<field name="model">education.student.certificate</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Student Certificates" decoration-info="state == 'requested'" decoration-warning="state == 'approved'" decoration-success="state == 'issued'" decoration-danger="state == 'rejected'">
|
||||
<field name="name"/>
|
||||
<field name="student_id"/>
|
||||
<field name="certificate_type"/>
|
||||
<field name="request_date"/>
|
||||
<field name="issue_date"/>
|
||||
<field name="state" widget="badge" decoration-success="state == 'issued'" decoration-danger="state == 'rejected'" decoration-warning="state == 'approved'" decoration-info="state == 'requested'"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_student_certificate_form" model="ir.ui.view">
|
||||
<field name="name">education.student.certificate.form</field>
|
||||
<field name="model">education.student.certificate</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Student Certificate">
|
||||
<header>
|
||||
<button name="action_approve" string="Approve" type="object" class="oe_highlight" invisible="state != 'requested'" groups="education_student.group_education_student_user"/>
|
||||
<button name="action_issue" string="Issue Official Certificate" type="object" class="btn-success" invisible="state not in ['draft', 'approved']" groups="education_student.group_education_student_user"/>
|
||||
<button name="action_reject" string="Reject" type="object" invisible="state in ['issued', 'rejected']" groups="education_student.group_education_student_user"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,requested,approved,issued"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Certificate Info">
|
||||
<field name="name"/>
|
||||
<field name="student_id"/>
|
||||
<field name="certificate_type"/>
|
||||
<field name="remarks"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
<group string="Dates & Issuance">
|
||||
<field name="request_date"/>
|
||||
<field name="issue_date"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="issued_by"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Generated Document" invisible="not document_file">
|
||||
<field name="document_file" filename="file_name"/>
|
||||
<field name="file_name" invisible="1"/>
|
||||
</group>
|
||||
<group string="Rejection Details" invisible="state != 'rejected'">
|
||||
<field name="rejection_reason"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<chatter/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_student_certificate_search" model="ir.ui.view">
|
||||
<field name="name">education.student.certificate.search</field>
|
||||
<field name="model">education.student.certificate</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Certificates">
|
||||
<field name="name" string="Certificate Number"/>
|
||||
<field name="student_id"/>
|
||||
<field name="certificate_type"/>
|
||||
<filter string="Requests Pending Approval" name="filter_requested" domain="[('state', '=', 'requested')]"/>
|
||||
<filter string="Approved (Ready to Issue)" name="filter_approved" domain="[('state', '=', 'approved')]"/>
|
||||
<filter string="Issued" name="filter_issued" domain="[('state', '=', 'issued')]"/>
|
||||
<group>
|
||||
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||
<filter string="Type" name="group_type" context="{'group_by': 'certificate_type'}"/>
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_student_certificate" model="ir.actions.act_window">
|
||||
<field name="name">Student Certificates</field>
|
||||
<field name="res_model">education.student.certificate</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_education_student_certificate_search"/>
|
||||
<field name="context">{'search_default_filter_requested': 1}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_education_student_certificate"
|
||||
name="Certificates & ID Cards"
|
||||
parent="education_base.menu_education_base_root"
|
||||
action="action_education_student_certificate"
|
||||
sequence="16"
|
||||
groups="education_student.group_education_student_user"/>
|
||||
</odoo>
|
||||
@ -142,7 +142,6 @@
|
||||
</group>
|
||||
<group>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="profile_completion" widget="progressbar"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
@ -252,34 +251,24 @@
|
||||
|
||||
<!-- Documents Tab -->
|
||||
<page string="Documents" name="documents">
|
||||
<field name="document_ids">
|
||||
<list string="Uploaded Documents" decoration-info="verification_status == 'submitted'" decoration-success="verification_status == 'verified'" decoration-danger="verification_status == 'rejected'">
|
||||
<field name="name"/>
|
||||
<field name="category"/>
|
||||
<field name="file_name"/>
|
||||
<field name="file_size"/>
|
||||
<field name="upload_date"/>
|
||||
<field name="verification_status" widget="badge" decoration-success="verification_status == 'verified'" decoration-danger="verification_status == 'rejected'" decoration-warning="verification_status == 'submitted'"/>
|
||||
<button name="action_verify" type="object" string="Verify" invisible="verification_status == 'verified'" class="btn-primary"/>
|
||||
<button name="action_reject" type="object" string="Reject" invisible="verification_status == 'rejected'"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- Certificates Tab -->
|
||||
<page string="Certificates" name="certificates">
|
||||
<field name="certificate_ids">
|
||||
<list string="Certificates & ID Cards" decoration-info="state == 'requested'" decoration-warning="state == 'approved'" decoration-success="state == 'issued'" decoration-danger="state == 'rejected'">
|
||||
<field name="name"/>
|
||||
<field name="certificate_type"/>
|
||||
<field name="request_date"/>
|
||||
<field name="issue_date"/>
|
||||
<field name="state" widget="badge" decoration-success="state == 'issued'" decoration-danger="state == 'rejected'" decoration-warning="state == 'approved'" decoration-info="state == 'requested'"/>
|
||||
<button name="action_approve" type="object" string="Approve" invisible="state != 'requested'" class="btn-primary"/>
|
||||
<button name="action_issue" type="object" string="Issue" invisible="state not in ['draft', 'approved']" class="btn-success"/>
|
||||
<button name="action_reject" type="object" string="Reject" invisible="state in ['issued', 'rejected']"/>
|
||||
</list>
|
||||
</field>
|
||||
<group>
|
||||
<group string="Identity Documents">
|
||||
<field name="doc_passport" filename="doc_passport_name"/>
|
||||
<field name="doc_passport_name" invisible="1"/>
|
||||
<field name="doc_aadhaar" filename="doc_aadhaar_name"/>
|
||||
<field name="doc_aadhaar_name" invisible="1"/>
|
||||
</group>
|
||||
<group string="Certificates">
|
||||
<field name="doc_birth_cert" filename="doc_birth_cert_name"/>
|
||||
<field name="doc_birth_cert_name" invisible="1"/>
|
||||
<field name="doc_transfer_cert" filename="doc_transfer_cert_name"/>
|
||||
<field name="doc_transfer_cert_name" invisible="1"/>
|
||||
<field name="doc_community_cert" filename="doc_community_cert_name"/>
|
||||
<field name="doc_community_cert_name" invisible="1"/>
|
||||
<field name="doc_mark_sheets" filename="doc_mark_sheets_name"/>
|
||||
<field name="doc_mark_sheets_name" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
|
||||
<!-- Notes Tab -->
|
||||
|
||||
@ -36,9 +36,6 @@ class TimetablePortalController(StudentPortalController):
|
||||
term_id = student.academic_term_id.id if student.academic_term_id else False
|
||||
batch_id = student.batch_id.id if student.batch_id else False
|
||||
|
||||
semester_id = student.semester_id.id if getattr(student, 'semester_id', False) else False
|
||||
section_id = student.section_id.id if getattr(student, 'section_id', False) else False
|
||||
|
||||
if not program_id or not year_id:
|
||||
# If a student isn't actively placed in a program/year, they have no timetable.
|
||||
return request.render("education_timetable.portal_student_timetable", {
|
||||
@ -49,14 +46,14 @@ class TimetablePortalController(StudentPortalController):
|
||||
})
|
||||
|
||||
# Build Domain: The portal query must match the student's actual academic placement.
|
||||
# Respecting optional-field logic (established in Phase C):
|
||||
# - term_id = False means it applies to all terms.
|
||||
# - batch_id = False means it applies to all batches.
|
||||
domain = [
|
||||
('program_id', '=', program_id),
|
||||
('academic_year_id', '=', year_id),
|
||||
('active', '=', True),
|
||||
('state', '!=', 'cancelled'),
|
||||
('active', '=', True)
|
||||
]
|
||||
if student.company_id:
|
||||
domain.append(('company_id', '=', student.company_id.id))
|
||||
|
||||
# We must use sudo() since portal users don't have backend employee groups.
|
||||
# The domain rigorously isolates records to ONLY those belonging to the student's exact academic profile.
|
||||
@ -73,18 +70,6 @@ class TimetablePortalController(StudentPortalController):
|
||||
domain += ['|', ('batch_id', '=', False), ('batch_id', '=', batch_id)]
|
||||
else:
|
||||
domain += [('batch_id', '=', False)]
|
||||
|
||||
# Handle nullable semester_id
|
||||
if semester_id:
|
||||
domain += ['|', ('semester_id', '=', False), ('semester_id', '=', semester_id)]
|
||||
else:
|
||||
domain += [('semester_id', '=', False)]
|
||||
|
||||
# Handle nullable section_id
|
||||
if section_id:
|
||||
domain += ['|', ('section_id', '=', False), ('section_id', '=', section_id)]
|
||||
else:
|
||||
domain += [('section_id', '=', False)]
|
||||
|
||||
# Optional: Filter by date if provided in query params
|
||||
filter_date = kw.get('date')
|
||||
|
||||
@ -11,16 +11,7 @@ class EducationRoom(models.Model):
|
||||
building = fields.Char(string='Building / Location')
|
||||
active = fields.Boolean(default=True)
|
||||
description = fields.Text(string='Description')
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
index=True,
|
||||
)
|
||||
|
||||
_uniq_code_company = models.Constraint(
|
||||
'unique(code, company_id)',
|
||||
'Room code must be unique per company!'
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('code_unique', 'unique(code)', 'Room code must be unique!')
|
||||
]
|
||||
|
||||
@ -11,15 +11,6 @@ class EducationTimetableSlot(models.Model):
|
||||
academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True)
|
||||
term_id = fields.Many2one('education.academic.term', string='Term')
|
||||
batch_id = fields.Many2one('education.batch', string='Batch')
|
||||
semester_id = fields.Many2one('education.semester', string='Semester', index=True)
|
||||
section_id = fields.Many2one('education.section', string='Section', index=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Slot Details
|
||||
subject_id = fields.Many2one('education.subject', string='Subject', required=True)
|
||||
@ -38,71 +29,37 @@ class EducationTimetableSlot(models.Model):
|
||||
('cancelled', 'Cancelled')
|
||||
], string='Status', default='draft')
|
||||
|
||||
@api.depends('subject_id.name', 'date', 'start_time')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
subj = rec.subject_id.name or 'Class'
|
||||
d = str(rec.date) if rec.date else ''
|
||||
rec.display_name = f"{subj} ({d})"
|
||||
|
||||
def action_confirm(self):
|
||||
for rec in self:
|
||||
rec.state = 'confirmed'
|
||||
|
||||
def action_cancel(self):
|
||||
for rec in self:
|
||||
rec.state = 'cancelled'
|
||||
|
||||
def action_draft(self):
|
||||
for rec in self:
|
||||
rec.state = 'draft'
|
||||
|
||||
@api.constrains('start_time', 'end_time')
|
||||
def _check_time(self):
|
||||
for rec in self:
|
||||
if rec.start_time < 0.0 or rec.end_time > 24.0:
|
||||
raise ValidationError("Start and end times must be within a valid 24-hour day (0:00 to 24:00).")
|
||||
if rec.start_time >= rec.end_time:
|
||||
raise ValidationError("Start time must be strictly earlier than end time.")
|
||||
|
||||
@api.constrains(
|
||||
'date', 'start_time', 'end_time', 'faculty_id', 'room_id',
|
||||
'program_id', 'academic_year_id', 'term_id', 'batch_id',
|
||||
'semester_id', 'section_id', 'company_id', 'state', 'active'
|
||||
)
|
||||
@api.constrains('date', 'start_time', 'end_time', 'faculty_id', 'room_id', 'program_id', 'academic_year_id', 'term_id', 'batch_id')
|
||||
def _check_overlap(self):
|
||||
for rec in self:
|
||||
if rec.state == 'cancelled' or not rec.active:
|
||||
continue
|
||||
|
||||
domain_base = [
|
||||
('id', '!=', rec.id),
|
||||
('date', '=', rec.date),
|
||||
('state', '!=', 'cancelled'),
|
||||
('active', '=', True),
|
||||
('id', '!=', rec.id),
|
||||
# Interval logic: existing_start < new_end AND existing_end > new_start
|
||||
('start_time', '<', rec.end_time),
|
||||
('end_time', '>', rec.start_time),
|
||||
]
|
||||
if rec.company_id:
|
||||
domain_base.append(('company_id', '=', rec.company_id.id))
|
||||
|
||||
# 1. Check Faculty Conflict
|
||||
faculty_conflict = self.search(domain_base + [('faculty_id', '=', rec.faculty_id.id)], limit=1)
|
||||
if faculty_conflict:
|
||||
raise ValidationError(
|
||||
f"Faculty conflict: {rec.faculty_id.name} already has an active class scheduled on {rec.date} overlapping this time."
|
||||
)
|
||||
raise ValidationError(f"Faculty conflict: {rec.faculty_id.name} already has a class overlapping this time on {rec.date}.")
|
||||
|
||||
# 2. Check Room Conflict
|
||||
room_conflict = self.search(domain_base + [('room_id', '=', rec.room_id.id)], limit=1)
|
||||
if room_conflict:
|
||||
raise ValidationError(
|
||||
f"Room conflict: Room '{rec.room_id.name}' is already booked for an overlapping class on {rec.date}."
|
||||
)
|
||||
raise ValidationError(f"Room conflict: {rec.room_id.name} is already booked for an overlapping time on {rec.date}.")
|
||||
|
||||
# 3. Check Student Population Conflict
|
||||
# Population is defined by Program + Academic Year + Term + Batch + Semester + Section
|
||||
# Population is defined by Program + Academic Year + Term + Batch
|
||||
# If a slot has NO term, it applies to the whole year, so it should conflict with ANY term in that year.
|
||||
# So if either the new slot or the existing slot has no term (or they match), it's a conflict.
|
||||
population_domain = domain_base + [
|
||||
('program_id', '=', rec.program_id.id),
|
||||
('academic_year_id', '=', rec.academic_year_id.id),
|
||||
@ -119,21 +76,7 @@ class EducationTimetableSlot(models.Model):
|
||||
population_domain.append('|')
|
||||
population_domain.append(('batch_id', '=', False))
|
||||
population_domain.append(('batch_id', '=', rec.batch_id.id))
|
||||
|
||||
# OR logic for semester_id: either matches exactly, or one of them is False
|
||||
if rec.semester_id:
|
||||
population_domain.append('|')
|
||||
population_domain.append(('semester_id', '=', False))
|
||||
population_domain.append(('semester_id', '=', rec.semester_id.id))
|
||||
|
||||
# OR logic for section_id: either matches exactly, or one of them is False
|
||||
if rec.section_id:
|
||||
population_domain.append('|')
|
||||
population_domain.append(('section_id', '=', False))
|
||||
population_domain.append(('section_id', '=', rec.section_id.id))
|
||||
|
||||
population_conflict = self.search(population_domain, limit=1)
|
||||
if population_conflict:
|
||||
raise ValidationError(
|
||||
f"Student group conflict: The academic cohort already has an overlapping class scheduled on {rec.date}."
|
||||
)
|
||||
raise ValidationError(f"Student Population conflict: This specific academic group already has a class overlapping this time on {rec.date}.")
|
||||
|
||||
@ -2,10 +2,7 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_education_room_admin,education.room.admin,model_education_room,group_education_timetable_admin,1,1,1,1
|
||||
access_education_room_faculty,education.room.faculty,model_education_room,group_education_timetable_faculty,1,0,0,0
|
||||
access_education_room_student,education.room.student,model_education_room,group_education_timetable_student,1,0,0,0
|
||||
access_education_room_portal,education.room.portal,model_education_room,base.group_portal,1,0,0,0
|
||||
|
||||
access_education_timetable_slot_admin,education.timetable.slot.admin,model_education_timetable_slot,group_education_timetable_admin,1,1,1,1
|
||||
access_education_timetable_slot_faculty,education.timetable.slot.faculty,model_education_timetable_slot,group_education_timetable_faculty,1,0,0,0
|
||||
access_education_timetable_slot_student,education.timetable.slot.student,model_education_timetable_slot,group_education_timetable_student,1,0,0,0
|
||||
access_education_timetable_slot_portal,education.timetable.slot.portal,model_education_timetable_slot,base.group_portal,1,0,0,0
|
||||
|
||||
|
||||
|
@ -7,25 +7,12 @@
|
||||
|
||||
<record id="group_education_timetable_faculty" model="res.groups">
|
||||
<field name="name">Faculty</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_education_timetable_student')), (4, ref('base.group_user')), (4, ref('education_exam.group_education_exam_teacher'))]"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_education_timetable_student'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_education_timetable_admin" model="res.groups">
|
||||
<field name="name">Timetable Administrator</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_education_timetable_faculty')), (4, ref('education_exam.group_education_exam_administrator'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Multi-Company Isolation -->
|
||||
<record id="rule_education_room_company" model="ir.rule">
|
||||
<field name="name">Education Room Company Isolation</field>
|
||||
<field name="model_id" ref="model_education_room"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
|
||||
</record>
|
||||
|
||||
<record id="rule_education_timetable_slot_company" model="ir.rule">
|
||||
<field name="name">Timetable Slot Company Isolation</field>
|
||||
<field name="model_id" ref="model_education_timetable_slot"/>
|
||||
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
|
||||
<field name="implied_ids" eval="[(4, ref('group_education_timetable_faculty'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@ -1 +0,0 @@
|
||||
from . import test_timetable
|
||||
@ -1,565 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError, AccessError
|
||||
from odoo.addons.education_mobile_api.controllers.student import MobileStudent
|
||||
|
||||
|
||||
class TestEducationTimetable(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.company = cls.env.company
|
||||
|
||||
# Setup Company B for multi-company isolation tests
|
||||
cls.company_b = cls.env['res.company'].create({
|
||||
'name': 'Timetable Test Company B',
|
||||
})
|
||||
|
||||
# Academic Master Data
|
||||
cls.campus = cls.env['education.campus'].create({
|
||||
'name': 'Timetable Test Campus',
|
||||
'code': 'TTC-01',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.department = cls.env['education.department'].create({
|
||||
'name': 'Timetable Test Dept',
|
||||
'code': 'TTD-01',
|
||||
'campus_id': cls.campus.id,
|
||||
})
|
||||
cls.level = cls.env['education.academic.level'].create({
|
||||
'name': 'Timetable Test Level',
|
||||
'code': 'TTL-01',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.program = cls.env['education.program'].create({
|
||||
'name': 'Timetable Test Program',
|
||||
'code': 'TTP-01',
|
||||
'department_id': cls.department.id,
|
||||
'level_id': cls.level.id,
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.academic_year = cls.env['education.academic.year'].create({
|
||||
'name': 'Timetable Academic Year 2026',
|
||||
'code': 'TTY-2026',
|
||||
'start_date': '2026-01-01',
|
||||
'end_date': '2026-12-31',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.term = cls.env['education.academic.term'].create({
|
||||
'name': 'Timetable Term 1',
|
||||
'code': 'TTT-01',
|
||||
'academic_year_id': cls.academic_year.id,
|
||||
'start_date': '2026-01-01',
|
||||
'end_date': '2026-06-30',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.batch = cls.env['education.batch'].create({
|
||||
'name': 'Timetable Batch 2026',
|
||||
'code': 'TTB-2026',
|
||||
})
|
||||
cls.semester = cls.env['education.semester'].create({
|
||||
'name': 'Timetable Semester 1',
|
||||
'code': 'TTS-01',
|
||||
})
|
||||
cls.section_a = cls.env['education.section'].create({
|
||||
'name': 'Timetable Section A',
|
||||
'code': 'TTS-A',
|
||||
})
|
||||
cls.section_b = cls.env['education.section'].create({
|
||||
'name': 'Timetable Section B',
|
||||
'code': 'TTS-B',
|
||||
})
|
||||
|
||||
# Subjects
|
||||
cls.subject_1 = cls.env['education.subject'].create({
|
||||
'name': 'Data Structures',
|
||||
'code': 'CS-101-TT',
|
||||
'program_id': cls.program.id,
|
||||
})
|
||||
cls.subject_2 = cls.env['education.subject'].create({
|
||||
'name': 'Operating Systems',
|
||||
'code': 'CS-102-TT',
|
||||
'program_id': cls.program.id,
|
||||
})
|
||||
|
||||
# Rooms
|
||||
cls.room_1 = cls.env['education.room'].create({
|
||||
'name': 'Room 101',
|
||||
'code': 'RM-101',
|
||||
'capacity': 50,
|
||||
'building': 'Block A',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
cls.room_2 = cls.env['education.room'].create({
|
||||
'name': 'Room 102',
|
||||
'code': 'RM-102',
|
||||
'capacity': 40,
|
||||
'building': 'Block B',
|
||||
'company_id': cls.company.id,
|
||||
})
|
||||
|
||||
# Security Groups
|
||||
cls.group_admin = cls.env.ref('education_timetable.group_education_timetable_admin')
|
||||
cls.group_faculty = cls.env.ref('education_timetable.group_education_timetable_faculty')
|
||||
cls.group_portal = cls.env.ref('base.group_portal')
|
||||
|
||||
# Users
|
||||
cls.user_admin = cls.env['res.users'].create({
|
||||
'name': 'Timetable Admin User',
|
||||
'login': 'tt.admin',
|
||||
'email': 'tt.admin@test.edu',
|
||||
'company_id': cls.company.id,
|
||||
'group_ids': [(6, 0, [cls.group_admin.id])],
|
||||
})
|
||||
cls.user_faculty_1 = cls.env['res.users'].create({
|
||||
'name': 'Faculty Alan Turing',
|
||||
'login': 'faculty.turing',
|
||||
'email': 'turing@test.edu',
|
||||
'company_id': cls.company.id,
|
||||
'group_ids': [(6, 0, [cls.group_faculty.id])],
|
||||
})
|
||||
cls.user_faculty_2 = cls.env['res.users'].create({
|
||||
'name': 'Faculty Ada Lovelace',
|
||||
'login': 'faculty.lovelace',
|
||||
'email': 'lovelace@test.edu',
|
||||
'company_id': cls.company.id,
|
||||
'group_ids': [(6, 0, [cls.group_faculty.id])],
|
||||
})
|
||||
|
||||
# Students
|
||||
cls.user_student_1 = cls.env['res.users'].create({
|
||||
'name': 'Student One',
|
||||
'login': 'student.one',
|
||||
'email': 'student.one@test.edu',
|
||||
'company_id': cls.company.id,
|
||||
'group_ids': [(6, 0, [cls.group_portal.id])],
|
||||
})
|
||||
cls.student_1 = cls.env['education.student'].create({
|
||||
'first_name': 'Student',
|
||||
'last_name': 'One',
|
||||
'student_id': 'STU-TT-01',
|
||||
'admission_number': 'ADM-TT-01',
|
||||
'campus_id': cls.campus.id,
|
||||
'department_id': cls.department.id,
|
||||
'level_id': cls.level.id,
|
||||
'program_id': cls.program.id,
|
||||
'academic_year_id': cls.academic_year.id,
|
||||
'academic_term_id': cls.term.id,
|
||||
'batch_id': cls.batch.id,
|
||||
'semester_id': cls.semester.id,
|
||||
'section_id': cls.section_a.id,
|
||||
'company_id': cls.company.id,
|
||||
'portal_user_id': cls.user_student_1.id,
|
||||
})
|
||||
|
||||
cls.user_student_2 = cls.env['res.users'].create({
|
||||
'name': 'Student Two',
|
||||
'login': 'student.two',
|
||||
'email': 'student.two@test.edu',
|
||||
'company_id': cls.company.id,
|
||||
'group_ids': [(6, 0, [cls.group_portal.id])],
|
||||
})
|
||||
cls.student_2 = cls.env['education.student'].create({
|
||||
'first_name': 'Student',
|
||||
'last_name': 'Two',
|
||||
'student_id': 'STU-TT-02',
|
||||
'admission_number': 'ADM-TT-02',
|
||||
'campus_id': cls.campus.id,
|
||||
'department_id': cls.department.id,
|
||||
'level_id': cls.level.id,
|
||||
'program_id': cls.program.id,
|
||||
'academic_year_id': cls.academic_year.id,
|
||||
'academic_term_id': cls.term.id,
|
||||
'batch_id': cls.batch.id,
|
||||
'semester_id': cls.semester.id,
|
||||
'section_id': cls.section_b.id,
|
||||
'company_id': cls.company.id,
|
||||
'portal_user_id': cls.user_student_2.id,
|
||||
})
|
||||
|
||||
cls.slot_model = cls.env['education.timetable.slot']
|
||||
|
||||
def test_01_valid_slot_creation_and_state_workflow(self):
|
||||
"""Verify slot creation and lifecycle state transitions."""
|
||||
test_date = date.today() + timedelta(days=1)
|
||||
slot = self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_a.id,
|
||||
'subject_id': self.subject_1.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_1.id,
|
||||
'date': test_date,
|
||||
'start_time': 9.0, # 09:00 AM
|
||||
'end_time': 10.5, # 10:30 AM
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.assertEqual(slot.state, 'draft')
|
||||
self.assertTrue('Data Structures' in slot.display_name)
|
||||
|
||||
# Confirm
|
||||
slot.action_confirm()
|
||||
self.assertEqual(slot.state, 'confirmed')
|
||||
|
||||
# Cancel
|
||||
slot.action_cancel()
|
||||
self.assertEqual(slot.state, 'cancelled')
|
||||
|
||||
# Reset to draft
|
||||
slot.action_draft()
|
||||
self.assertEqual(slot.state, 'draft')
|
||||
|
||||
def test_02_invalid_time_ranges(self):
|
||||
"""Verify server-side validation rejects invalid start/end time ranges."""
|
||||
test_date = date.today() + timedelta(days=2)
|
||||
base_vals = {
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_a.id,
|
||||
'subject_id': self.subject_1.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_1.id,
|
||||
'date': test_date,
|
||||
'company_id': self.company.id,
|
||||
}
|
||||
|
||||
# 1. Start >= End
|
||||
with self.assertRaises(ValidationError):
|
||||
self.slot_model.with_user(self.user_admin).create({
|
||||
**base_vals,
|
||||
'start_time': 10.0,
|
||||
'end_time': 9.0,
|
||||
})
|
||||
|
||||
# 2. Start == End
|
||||
with self.assertRaises(ValidationError):
|
||||
self.slot_model.with_user(self.user_admin).create({
|
||||
**base_vals,
|
||||
'start_time': 9.0,
|
||||
'end_time': 9.0,
|
||||
})
|
||||
|
||||
# 3. Negative start time
|
||||
with self.assertRaises(ValidationError):
|
||||
self.slot_model.with_user(self.user_admin).create({
|
||||
**base_vals,
|
||||
'start_time': -1.0,
|
||||
'end_time': 10.0,
|
||||
})
|
||||
|
||||
# 4. End time exceeds 24
|
||||
with self.assertRaises(ValidationError):
|
||||
self.slot_model.with_user(self.user_admin).create({
|
||||
**base_vals,
|
||||
'start_time': 23.0,
|
||||
'end_time': 25.0,
|
||||
})
|
||||
|
||||
def test_03_faculty_double_booking_conflict(self):
|
||||
"""Verify faculty cannot be scheduled in two overlapping classes on the same date."""
|
||||
test_date = date.today() + timedelta(days=3)
|
||||
# Create Slot 1: 09:00 - 10:30 with Faculty 1 in Room 1
|
||||
slot_1 = self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_a.id,
|
||||
'subject_id': self.subject_1.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_1.id,
|
||||
'date': test_date,
|
||||
'start_time': 9.0,
|
||||
'end_time': 10.5,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
# Attempt Slot 2: Overlapping 10:00 - 11:30 with same Faculty 1 in different Room 2
|
||||
with self.assertRaises(ValidationError):
|
||||
self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_b.id,
|
||||
'subject_id': self.subject_2.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_2.id,
|
||||
'date': test_date,
|
||||
'start_time': 10.0,
|
||||
'end_time': 11.5,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
# Non-overlapping slot (11:00 - 12:30) with Faculty 1 succeeds
|
||||
slot_non_overlap = self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_b.id,
|
||||
'subject_id': self.subject_2.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_2.id,
|
||||
'date': test_date,
|
||||
'start_time': 11.0,
|
||||
'end_time': 12.5,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.assertTrue(slot_non_overlap.id)
|
||||
|
||||
# Cancel Slot 1 -> overlapping slot in original time window now succeeds
|
||||
slot_1.action_cancel()
|
||||
slot_overlap_after_cancel = self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_b.id,
|
||||
'subject_id': self.subject_2.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_2.id,
|
||||
'date': test_date,
|
||||
'start_time': 9.0,
|
||||
'end_time': 10.5,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.assertTrue(slot_overlap_after_cancel.id)
|
||||
|
||||
def test_04_room_double_booking_conflict(self):
|
||||
"""Verify same classroom cannot be double-booked for overlapping sessions."""
|
||||
test_date = date.today() + timedelta(days=4)
|
||||
# Book Room 1: 14:00 - 15:30 with Faculty 1
|
||||
self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_a.id,
|
||||
'subject_id': self.subject_1.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_1.id,
|
||||
'date': test_date,
|
||||
'start_time': 14.0,
|
||||
'end_time': 15.5,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
# Attempt to book same Room 1: 15:00 - 16:30 with different Faculty 2
|
||||
with self.assertRaises(ValidationError):
|
||||
self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_b.id,
|
||||
'subject_id': self.subject_2.id,
|
||||
'faculty_id': self.user_faculty_2.id,
|
||||
'room_id': self.room_1.id,
|
||||
'date': test_date,
|
||||
'start_time': 15.0,
|
||||
'end_time': 16.5,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
# Booking different Room 2 at overlapping time succeeds
|
||||
diff_room_slot = self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_b.id,
|
||||
'subject_id': self.subject_2.id,
|
||||
'faculty_id': self.user_faculty_2.id,
|
||||
'room_id': self.room_2.id,
|
||||
'date': test_date,
|
||||
'start_time': 15.0,
|
||||
'end_time': 16.5,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.assertTrue(diff_room_slot.id)
|
||||
|
||||
def test_05_student_population_schedule_conflict(self):
|
||||
"""Verify the same student population cohort cannot have two overlapping classes."""
|
||||
test_date = date.today() + timedelta(days=5)
|
||||
# Class for Section A: 09:00 - 10:00 (Faculty 1, Room 1)
|
||||
self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_a.id,
|
||||
'subject_id': self.subject_1.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_1.id,
|
||||
'date': test_date,
|
||||
'start_time': 9.0,
|
||||
'end_time': 10.0,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
# Attempt another class for same Section A: 09:30 - 10:30 (even with different Faculty 2 and Room 2)
|
||||
with self.assertRaises(ValidationError):
|
||||
self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_a.id,
|
||||
'subject_id': self.subject_2.id,
|
||||
'faculty_id': self.user_faculty_2.id,
|
||||
'room_id': self.room_2.id,
|
||||
'date': test_date,
|
||||
'start_time': 9.5,
|
||||
'end_time': 10.5,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
# Class at same time for different Section B succeeds
|
||||
section_b_slot = self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_b.id,
|
||||
'subject_id': self.subject_2.id,
|
||||
'faculty_id': self.user_faculty_2.id,
|
||||
'room_id': self.room_2.id,
|
||||
'date': test_date,
|
||||
'start_time': 9.0,
|
||||
'end_time': 10.0,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.assertTrue(section_b_slot.id)
|
||||
|
||||
def test_06_permissions_and_company_isolation(self):
|
||||
"""Verify ACL restrictions and multi-company timetable isolation."""
|
||||
test_date = date.today() + timedelta(days=6)
|
||||
# Faculty user without admin rights cannot create a timetable slot
|
||||
with self.assertRaises(AccessError):
|
||||
self.slot_model.with_user(self.user_faculty_1).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'subject_id': self.subject_1.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_1.id,
|
||||
'date': test_date,
|
||||
'start_time': 9.0,
|
||||
'end_time': 10.0,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
# Slot in Company A
|
||||
slot_a = self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'subject_id': self.subject_1.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_1.id,
|
||||
'date': test_date,
|
||||
'start_time': 14.0,
|
||||
'end_time': 15.0,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
self.assertTrue(slot_a.id)
|
||||
|
||||
# Room in Company B with same code must succeed due to per-company unique constraint
|
||||
room_comp_b = self.env['education.room'].create({
|
||||
'name': 'Room 101 B',
|
||||
'code': 'RM-101',
|
||||
'capacity': 30,
|
||||
'company_id': self.company_b.id,
|
||||
})
|
||||
self.assertTrue(room_comp_b.id)
|
||||
|
||||
def test_07_mobile_api_controller_isolation_and_date_filtering(self):
|
||||
"""Verify student timetable retrieval isolates by cohort and supports date filtering."""
|
||||
test_date_1 = date.today() + timedelta(days=7)
|
||||
test_date_2 = date.today() + timedelta(days=8)
|
||||
|
||||
# Slot for Student 1 (Section A)
|
||||
slot_a = self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_a.id,
|
||||
'subject_id': self.subject_1.id,
|
||||
'faculty_id': self.user_faculty_1.id,
|
||||
'room_id': self.room_1.id,
|
||||
'date': test_date_1,
|
||||
'start_time': 9.0,
|
||||
'end_time': 10.0,
|
||||
'state': 'confirmed',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
# Slot for Student 2 (Section B)
|
||||
slot_b = self.slot_model.with_user(self.user_admin).create({
|
||||
'program_id': self.program.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'term_id': self.term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section_b.id,
|
||||
'subject_id': self.subject_2.id,
|
||||
'faculty_id': self.user_faculty_2.id,
|
||||
'room_id': self.room_2.id,
|
||||
'date': test_date_2,
|
||||
'start_time': 11.0,
|
||||
'end_time': 12.0,
|
||||
'state': 'confirmed',
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
controller = MobileStudent()
|
||||
|
||||
# 1. Student 1 receives only Slot A
|
||||
slots_student_1 = controller._get_timetable_slots(self.student_1)
|
||||
slot_ids_1 = [s['id'] for s in slots_student_1]
|
||||
self.assertIn(slot_a.id, slot_ids_1)
|
||||
self.assertNotIn(slot_b.id, slot_ids_1)
|
||||
|
||||
# 2. Student 2 receives only Slot B
|
||||
slots_student_2 = controller._get_timetable_slots(self.student_2)
|
||||
slot_ids_2 = [s['id'] for s in slots_student_2]
|
||||
self.assertIn(slot_b.id, slot_ids_2)
|
||||
self.assertNotIn(slot_a.id, slot_ids_2)
|
||||
|
||||
# 3. Date filtering for Student 1
|
||||
filtered_slots = controller._get_timetable_slots(self.student_1, date_filter=str(test_date_1))
|
||||
self.assertEqual(len(filtered_slots), 1)
|
||||
self.assertEqual(filtered_slots[0]['subject']['name'], 'Data Structures')
|
||||
self.assertEqual(filtered_slots[0]['room']['name'], 'Room 101')
|
||||
self.assertEqual(filtered_slots[0]['start_time'], '09:00 AM')
|
||||
|
||||
empty_date_slots = controller._get_timetable_slots(self.student_1, date_filter=str(test_date_2))
|
||||
self.assertEqual(len(empty_date_slots), 0)
|
||||
|
||||
# 4. Cancelled slot is excluded
|
||||
slot_a.action_cancel()
|
||||
slots_after_cancel = controller._get_timetable_slots(self.student_1)
|
||||
self.assertNotIn(slot_a.id, [s['id'] for s in slots_after_cancel])
|
||||
@ -31,7 +31,6 @@
|
||||
</group>
|
||||
<group>
|
||||
<field name="building"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
@ -45,27 +44,9 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_room_search" model="ir.ui.view">
|
||||
<field name="name">education.room.search</field>
|
||||
<field name="model">education.room</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Rooms">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="building"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group>
|
||||
<filter string="Building" name="group_building" context="{'group_by': 'building'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_room" model="ir.actions.act_window">
|
||||
<field name="name">Rooms / Venues</field>
|
||||
<field name="res_model">education.room</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_education_room_search"/>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@ -12,8 +12,6 @@
|
||||
<field name="faculty_id"/>
|
||||
<field name="room_id"/>
|
||||
<field name="batch_id"/>
|
||||
<field name="semester_id" optional="show"/>
|
||||
<field name="section_id" optional="show"/>
|
||||
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-success="state == 'confirmed'" decoration-danger="state == 'cancelled'"/>
|
||||
</list>
|
||||
</field>
|
||||
@ -25,10 +23,7 @@
|
||||
<field name="arch" type="xml">
|
||||
<form string="Timetable Slot">
|
||||
<header>
|
||||
<button name="action_confirm" string="Confirm" type="object" invisible="state != 'draft'" class="oe_highlight"/>
|
||||
<button name="action_cancel" string="Cancel" type="object" invisible="state == 'cancelled'"/>
|
||||
<button name="action_draft" string="Reset to Draft" type="object" invisible="state == 'draft'"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,cancelled"/>
|
||||
<field name="state" widget="statusbar" options="{'clickable': '1'}"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
@ -45,9 +40,6 @@
|
||||
<field name="academic_year_id"/>
|
||||
<field name="term_id"/>
|
||||
<field name="batch_id"/>
|
||||
<field name="semester_id"/>
|
||||
<field name="section_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
@ -68,13 +60,6 @@
|
||||
<field name="academic_year_id"/>
|
||||
<field name="term_id"/>
|
||||
<field name="batch_id"/>
|
||||
<field name="semester_id"/>
|
||||
<field name="section_id"/>
|
||||
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="Confirmed" name="confirmed" domain="[('state', '=', 'confirmed')]"/>
|
||||
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
|
||||
<group>
|
||||
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
|
||||
@ -82,8 +67,6 @@
|
||||
<filter string="Room" name="group_room" context="{'group_by': 'room_id'}"/>
|
||||
<filter string="Program" name="group_program" context="{'group_by': 'program_id'}"/>
|
||||
<filter string="Batch" name="group_batch" context="{'group_by': 'batch_id'}"/>
|
||||
<filter string="Semester" name="group_semester" context="{'group_by': 'semester_id'}"/>
|
||||
<filter string="Section" name="group_section" context="{'group_by': 'section_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# Metatron Student ERP - Production Backup & Recovery Script
|
||||
# Usage:
|
||||
# ./backup_restore.sh backup [DEST_DIR]
|
||||
# ./backup_restore.sh restore <DB_DUMP_FILE> <FILESTORE_ARCHIVE> <TARGET_DB>
|
||||
# ./backup_restore.sh verify <DB_DUMP_FILE> <FILESTORE_ARCHIVE>
|
||||
# ==============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DB_CONTAINER="${DB_CONTAINER:-metatron_academic_db}"
|
||||
ODOO_CONTAINER="${ODOO_CONTAINER:-metatron_academic}"
|
||||
DB_USER="${POSTGRES_USER:-odoo}"
|
||||
DB_NAME="${POSTGRES_DB:-metatron_aca_erp}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
ACTION="${1:-help}"
|
||||
|
||||
case "$ACTION" in
|
||||
backup)
|
||||
BACKUP_DIR="${2:-./backups}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
DB_FILE="$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.dump"
|
||||
FS_FILE="$BACKUP_DIR/filestore_${DB_NAME}_${TIMESTAMP}.tar.gz"
|
||||
|
||||
echo "[+] Starting PostgreSQL database backup for '$DB_NAME'..."
|
||||
docker exec -t "$DB_CONTAINER" pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$DB_FILE"
|
||||
echo "[✓] Database backup created: $DB_FILE ($(du -h "$DB_FILE" | cut -f1))"
|
||||
|
||||
echo "[+] Starting Odoo filestore archive for '$DB_NAME'..."
|
||||
docker exec "$ODOO_CONTAINER" tar -czf - -C /var/lib/odoo/filestore "$DB_NAME" > "$FS_FILE"
|
||||
echo "[✓] Filestore archive created: $FS_FILE ($(du -h "$FS_FILE" | cut -f1))"
|
||||
|
||||
echo "[+] Calculating SHA256 checksums..."
|
||||
if command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$DB_FILE" "$FS_FILE" > "$BACKUP_DIR/checksums_${TIMESTAMP}.sha256"
|
||||
elif command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$DB_FILE" "$FS_FILE" > "$BACKUP_DIR/checksums_${TIMESTAMP}.sha256"
|
||||
fi
|
||||
|
||||
echo "=================================================================="
|
||||
echo "BACKUP SUCCESSFUL: $TIMESTAMP"
|
||||
echo "Database: $DB_FILE"
|
||||
echo "Filestore: $FS_FILE"
|
||||
echo "=================================================================="
|
||||
;;
|
||||
|
||||
verify)
|
||||
DB_FILE="${2:-}"
|
||||
FS_FILE="${3:-}"
|
||||
|
||||
if [[ -z "$DB_FILE" || -z "$FS_FILE" ]]; then
|
||||
echo "[-] Error: Provide both database dump and filestore archive to verify."
|
||||
echo " Usage: $0 verify <DB_DUMP_FILE> <FILESTORE_ARCHIVE>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[+] Verifying database archive integrity..."
|
||||
# pg_restore -l lists table of contents without restoring
|
||||
docker exec -i "$DB_CONTAINER" pg_restore -l < "$DB_FILE" > /dev/null
|
||||
echo "[✓] Database dump archive is valid."
|
||||
|
||||
echo "[+] Verifying filestore tar.gz integrity..."
|
||||
tar -tzf "$FS_FILE" > /dev/null
|
||||
echo "[✓] Filestore tar.gz archive is valid."
|
||||
echo "All backup archives verified intact."
|
||||
;;
|
||||
|
||||
restore)
|
||||
DB_FILE="${2:-}"
|
||||
FS_FILE="${3:-}"
|
||||
TARGET_DB="${4:-${DB_NAME}_restored}"
|
||||
|
||||
if [[ -z "$DB_FILE" || -z "$FS_FILE" ]]; then
|
||||
echo "[-] Error: Provide DB dump file, filestore archive, and target database name."
|
||||
echo " Usage: $0 restore <DB_DUMP_FILE> <FILESTORE_ARCHIVE> [TARGET_DB]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[!] WARNING: You are about to restore to database '$TARGET_DB'."
|
||||
read -p "Are you sure you want to continue? (y/N): " CONFIRM
|
||||
if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then
|
||||
echo "Restore aborted by user."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[+] Creating database '$TARGET_DB' if not exists..."
|
||||
docker exec -i "$DB_CONTAINER" psql -U "$DB_USER" -c "CREATE DATABASE \"$TARGET_DB\" OWNER \"$DB_USER\";" 2>/dev/null || true
|
||||
|
||||
echo "[+] Restoring PostgreSQL database from $DB_FILE..."
|
||||
docker exec -i "$DB_CONTAINER" pg_restore -U "$DB_USER" -d "$TARGET_DB" --clean --if-exists < "$DB_FILE" || true
|
||||
echo "[✓] Database restore completed."
|
||||
|
||||
echo "[+] Restoring filestore to container..."
|
||||
docker exec -i "$ODOO_CONTAINER" mkdir -p /var/lib/odoo/filestore/"$TARGET_DB"
|
||||
docker exec -i "$ODOO_CONTAINER" tar -xzf - -C /var/lib/odoo/filestore/"$TARGET_DB" --strip-components=1 < "$FS_FILE"
|
||||
echo "[✓] Filestore restored to /var/lib/odoo/filestore/$TARGET_DB."
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "Metatron Student ERP Backup & Recovery Utility"
|
||||
echo "Usage:"
|
||||
echo " $0 backup [DEST_DIR] - Take full DB and filestore backup"
|
||||
echo " $0 verify <DB_DUMP_FILE> <FILESTORE_ARCHIVE> - Test backup archive integrity"
|
||||
echo " $0 restore <DB_DUMP_FILE> <FS_ARCHIVE> <TARGET_DB> - Restore to target database"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@ -1,87 +0,0 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: metatron_erp_db_prod
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-metatron_aca_erp}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-odoo}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- prod_pgdata:/var/lib/postgresql/data
|
||||
networks:
|
||||
- internal_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-odoo} -d ${POSTGRES_DB:-metatron_aca_erp}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
odoo:
|
||||
image: odoo:19.0
|
||||
container_name: metatron_erp_app_prod
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
HOST: db
|
||||
PORT: 5432
|
||||
USER: ${POSTGRES_USER:-odoo}
|
||||
PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- prod_odoo_data:/var/lib/odoo
|
||||
- ../addons:/mnt/extra-addons:ro
|
||||
- ./odoo.prod.conf:/etc/odoo/odoo.conf:ro
|
||||
command: odoo -c /etc/odoo/odoo.conf
|
||||
networks:
|
||||
- internal_network
|
||||
- proxy_network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://127.0.0.1:8069/web/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: metatron_erp_nginx_prod
|
||||
restart: always
|
||||
depends_on:
|
||||
odoo:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/metatron_erp.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
networks:
|
||||
- proxy_network
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
|
||||
networks:
|
||||
internal_network:
|
||||
internal: true
|
||||
proxy_network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
prod_pgdata:
|
||||
prod_odoo_data:
|
||||
@ -1,93 +0,0 @@
|
||||
# Upstream servers
|
||||
upstream odoo_backend {
|
||||
server odoo:8069;
|
||||
}
|
||||
|
||||
upstream odoo_chat {
|
||||
server odoo:8072;
|
||||
}
|
||||
|
||||
# Redirect all HTTP traffic to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name erp.yourdomain.edu;
|
||||
|
||||
# ACME-challenge for Let's Encrypt certificates
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name erp.yourdomain.edu;
|
||||
|
||||
# SSL certificate paths
|
||||
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||
|
||||
# Modern TLS configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# Security Headers
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Request body limits for mobile document and certificate uploads
|
||||
client_max_body_size 25M;
|
||||
|
||||
# Buffer sizes
|
||||
proxy_read_timeout 720s;
|
||||
proxy_connect_timeout 720s;
|
||||
proxy_send_timeout 720s;
|
||||
|
||||
# Log format
|
||||
access_log /var/log/nginx/odoo.access.log;
|
||||
error_log /var/log/nginx/odoo.error.log;
|
||||
|
||||
# Longpolling / WebSocket requests
|
||||
location /websocket {
|
||||
proxy_pass http://odoo_chat;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Main Odoo application and Mobile API endpoints
|
||||
location / {
|
||||
proxy_pass http://odoo_backend;
|
||||
proxy_redirect off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Gzip compression for API payloads and static files
|
||||
gzip on;
|
||||
gzip_types text/css text/less text/plain text/xml application/xml application/json application/javascript;
|
||||
}
|
||||
|
||||
# Cache static files
|
||||
location ~* /web/static/ {
|
||||
proxy_cache_valid 200 60m;
|
||||
proxy_buffering on;
|
||||
expires 864000;
|
||||
proxy_pass http://odoo_backend;
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
[options]
|
||||
; Database server settings
|
||||
db_host = db
|
||||
db_port = 5432
|
||||
db_user = odoo
|
||||
db_name = metatron_aca_erp
|
||||
|
||||
; Security: Disable Database Manager interface in production
|
||||
list_db = False
|
||||
|
||||
; Master Password for administrative database actions
|
||||
admin_passwd = CHANGE_ME_STRONG_ODOO_MASTER_PASSWORD
|
||||
|
||||
; Addons paths
|
||||
addons_path = /usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons
|
||||
|
||||
; Multi-processing and concurrency for production
|
||||
workers = 5
|
||||
max_cron_threads = 2
|
||||
|
||||
; Reverse proxy support (trusts X-Forwarded-For and X-Forwarded-Proto)
|
||||
proxy_mode = True
|
||||
|
||||
; Time and memory limits
|
||||
limit_time_cpu = 120
|
||||
limit_time_real = 240
|
||||
limit_memory_hard = 2684354560
|
||||
limit_memory_soft = 2147483648
|
||||
limit_request = 8192
|
||||
|
||||
; Logging configuration
|
||||
log_level = info
|
||||
log_handler = :INFO,odoo.addons.education_mobile_api:INFO
|
||||
logfile = False
|
||||
log_db = False
|
||||
48
flutter_app/.gitignore
vendored
48
flutter_app/.gitignore
vendored
@ -1,48 +0,0 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
@ -1,45 +0,0 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "abaf9c523780a608bd46686fd5e53740a07077f8"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
- platform: android
|
||||
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
- platform: ios
|
||||
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
- platform: linux
|
||||
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
- platform: macos
|
||||
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
- platform: web
|
||||
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
- platform: windows
|
||||
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
@ -1,17 +0,0 @@
|
||||
# metatron_student_erp
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
@ -1,40 +0,0 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- build/**
|
||||
- android/**
|
||||
- ios/**
|
||||
- web/**
|
||||
- windows/**
|
||||
- macos/**
|
||||
- linux/**
|
||||
errors:
|
||||
deprecated_member_use: ignore
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
14
flutter_app/android/.gitignore
vendored
14
flutter_app/android/.gitignore
vendored
@ -1,14 +0,0 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
@ -1,65 +0,0 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "in.metatroncube.metatron_student_erp"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
storeFile = keystoreProperties.getProperty("storeFile")?.let { file(it) }
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// Application ID matches reverse-domain package identifier
|
||||
applicationId = "in.metatroncube.metatron_student_erp"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
val releaseConfig = signingConfigs.getByName("release")
|
||||
signingConfig = if (keystorePropertiesFile.exists() && releaseConfig.storeFile?.exists() == true) {
|
||||
releaseConfig
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@ -1,48 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<application
|
||||
android:label="Metatron Student ERP"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@ -1,5 +0,0 @@
|
||||
package `in`.metatroncube.metatron_student_erp
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 544 B |
Binary file not shown.
|
Before Width: | Height: | Size: 442 B |
Binary file not shown.
|
Before Width: | Height: | Size: 721 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Base configuration: Enforce secure HTTPS connections by default -->
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- Debug/Development and Local Staging Overrides -->
|
||||
<debug-overrides>
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
<certificates src="user" />
|
||||
</trust-anchors>
|
||||
</debug-overrides>
|
||||
|
||||
<!-- Allow cleartext HTTP only for local emulator and localhost loopbacks during development -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@ -1,7 +0,0 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@ -1,24 +0,0 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
# This newDsl flag was added by the Flutter template
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
android.builtInKotlin=false
|
||||
@ -1,5 +0,0 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
|
||||
@ -1,10 +0,0 @@
|
||||
# Metatron Student ERP - Android Release Keystore Configuration Template
|
||||
# DO NOT COMMIT key.properties WITH REAL SECRETS TO SOURCE CONTROL
|
||||
# Copy this file to key.properties and fill in your actual production keystore details:
|
||||
# cp key.properties.example key.properties
|
||||
# Ensure key.properties and *.keystore/*.jks files are in .gitignore (already configured).
|
||||
|
||||
storePassword=<YOUR_KEYSTORE_PASSWORD>
|
||||
keyPassword=<YOUR_KEY_PASSWORD>
|
||||
keyAlias=<YOUR_KEY_ALIAS>
|
||||
storeFile=<PATH_TO_YOUR_KEYSTORE_FILE_E_G_upload_keystore_jks>
|
||||
@ -1,26 +0,0 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "9.1.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
34
flutter_app/ios/.gitignore
vendored
34
flutter_app/ios/.gitignore
vendored
@ -1,34 +0,0 @@
|
||||
**/dgph
|
||||
*.mode1v3
|
||||
*.mode2v3
|
||||
*.moved-aside
|
||||
*.pbxuser
|
||||
*.perspectivev3
|
||||
**/*sync/
|
||||
.sconsign.dblite
|
||||
.tags*
|
||||
**/.vagrant/
|
||||
**/DerivedData/
|
||||
Icon?
|
||||
**/Pods/
|
||||
**/.symlinks/
|
||||
profile
|
||||
xcuserdata
|
||||
**/.generated/
|
||||
Flutter/App.framework
|
||||
Flutter/Flutter.framework
|
||||
Flutter/Flutter.podspec
|
||||
Flutter/Generated.xcconfig
|
||||
Flutter/ephemeral/
|
||||
Flutter/app.flx
|
||||
Flutter/app.zip
|
||||
Flutter/flutter_assets/
|
||||
Flutter/flutter_export_environment.sh
|
||||
ServiceDefinitions.json
|
||||
Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!default.mode1v3
|
||||
!default.mode2v3
|
||||
!default.pbxuser
|
||||
!default.perspectivev3
|
||||
@ -1,24 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>App</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>io.flutter.flutter.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>App</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -1 +0,0 @@
|
||||
#include "Generated.xcconfig"
|
||||
@ -1 +0,0 @@
|
||||
#include "Generated.xcconfig"
|
||||
@ -1,647 +0,0 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
||||
remoteInfo = Runner;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 10;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Frameworks";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
||||
);
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
);
|
||||
name = Flutter;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146E51CF9000F007C117D = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146EF1CF9000F007C117D /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
||||
97C147021CF9000F007C117D /* Info.plist */,
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
||||
);
|
||||
name = RunnerTests;
|
||||
productName = RunnerTests;
|
||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
97C146E61CF9000F007C117D /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
};
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
||||
compatibilityVersion = "Xcode 9.3";
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
||||
);
|
||||
name = "Thin Binary";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Run Script";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXVariantGroup section */
|
||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C146FB1CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = Main.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
||||
isa = PBXVariantGroup;
|
||||
children = (
|
||||
97C147001CF9000F007C117D /* Base */,
|
||||
);
|
||||
name = LaunchScreen.storyboard;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = in.metatroncube.metatronStudentErp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = in.metatroncube.metatronStudentErp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = in.metatroncube.metatronStudentErp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = in.metatroncube.metatronStudentErp.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
97C147031CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147041CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
97C147061CF9000F007C117D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = in.metatroncube.metatronStudentErp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
97C147071CF9000F007C117D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = in.metatroncube.metatronStudentErp;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
331C8088294A63A400263BE5 /* Debug */,
|
||||
331C8089294A63A400263BE5 /* Release */,
|
||||
331C808A294A63A400263BE5 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147031CF9000F007C117D /* Debug */,
|
||||
97C147041CF9000F007C117D /* Release */,
|
||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
97C147061CF9000F007C117D /* Debug */,
|
||||
97C147071CF9000F007C117D /* Release */,
|
||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user