Compare commits

..

No commits in common. "faf9e758494328e33dc230d3572bff800ac2011a" and "a20b916aa8d97b0450b9c2161e4bc4b22955a111" have entirely different histories.

256 changed files with 241 additions and 20345 deletions

21
.gitignore vendored
View File

@ -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/

View File

@ -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.*

View File

@ -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.*

View File

@ -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.*

View File

@ -16,7 +16,6 @@
'depends': [
'education_base',
'education_student',
'education_portal',
'mail',
],
'data': [

View File

@ -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

View File

@ -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()

View File

@ -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.")

View File

@ -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>

View File

@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_attendance_line_mgr Attendance Line Mgr model_education_attendance_line group_attendance_manager 1 1 1 1
6 access_attendance_user Attendance User model_education_attendance group_attendance_user 1 1 1 0
7 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

View File

@ -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)

View File

@ -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 &amp; Faculty">
<field name="academic_year_id" readonly="state != 'draft'"/>

View File

@ -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()

View File

@ -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")

View File

@ -1,3 +1,2 @@
from . import models
from . import controllers
from . import wizard

View File

@ -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,

View File

@ -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):

View File

@ -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

View File

@ -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!')
]

View File

@ -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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_education_subject_admin education.subject.admin model_education_subject group_education_exam_administrator 1 1 1 1
3 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
4 access_education_exam_type_admin education.exam.type.admin model_education_exam_type group_education_exam_administrator 1 1 1 1
5 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
6 access_education_grading_system_admin education.grading.system.admin model_education_grading_system group_education_exam_administrator 1 1 1 1
7 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
8 access_education_grade_admin education.grade.admin model_education_grade group_education_exam_administrator 1 1 1 1
9 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
10 access_education_exam_admin education.exam.admin model_education_exam group_education_exam_administrator 1 1 1 1
11 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
12 access_education_exam_line_admin education.exam.line.admin model_education_exam_line group_education_exam_administrator 1 1 1 1
13 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
14 access_education_exam_result_admin education.exam.result.admin model_education_exam_result group_education_exam_administrator 1 1 1 1
15 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
16 access_education_exam_result_line_admin education.exam.result.line.admin model_education_exam_result_line group_education_exam_administrator 1 1 1 1
17 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

View File

@ -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>

View File

@ -1 +0,0 @@
from . import test_exam_workflow

View File

@ -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()

View File

@ -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>

View File

@ -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 -->

View File

@ -1 +0,0 @@
from . import marks_entry_wizard

View File

@ -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')

View File

@ -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 &amp; 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>

View File

@ -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>

View File

@ -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)

View File

@ -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'

View File

@ -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:

View File

@ -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'),

View File

@ -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')

View File

@ -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 &amp; 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 &amp; Post to Ledger" type="object" class="oe_highlight" invisible="state == 'confirmed'"/>
<button name="action_cancel" string="Cancel &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>

View File

@ -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({

View File

@ -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)

View File

@ -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,
})

View File

@ -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

View File

@ -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>

View File

@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 access_level_portal Level Portal Access education_base.model_education_academic_level base.group_portal 1 0 0 0
13 access_payment_transaction_user Payment Transaction User model_education_payment_transaction base.group_portal 1 1 1 1
14 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

View File

@ -1,3 +1,2 @@
from . import test_portal
from . import test_payment_gateway
from . import test_announcement

View File

@ -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()

View File

@ -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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>

View File

@ -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': [

View File

@ -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>

View File

@ -1,4 +1,2 @@
from . import student
from . import academic_config
from . import student_document
from . import student_certificate

View File

@ -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:

View File

@ -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

View File

@ -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,
})

View File

@ -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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
8 access_education_semester_manager education.semester manager model_education_semester group_education_student_manager 1 1 1 1
9 access_education_section_user education.section user model_education_section base.group_user 1 0 0 0
10 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

View File

@ -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>

View File

@ -1,2 +1 @@
from . import test_student
from . import test_student_profile_security

View File

@ -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):

View File

@ -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)

View File

@ -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 &amp; 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 &amp; ID Cards"
parent="education_base.menu_education_base_root"
action="action_education_student_certificate"
sequence="16"
groups="education_student.group_education_student_user"/>
</odoo>

View File

@ -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 &amp; 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 -->

View File

@ -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')

View File

@ -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!')
]

View File

@ -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}.")

View File

@ -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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_education_room_admin education.room.admin model_education_room group_education_timetable_admin 1 1 1 1
3 access_education_room_faculty education.room.faculty model_education_room group_education_timetable_faculty 1 0 0 0
4 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
5 access_education_timetable_slot_admin education.timetable.slot.admin model_education_timetable_slot group_education_timetable_admin 1 1 1 1
6 access_education_timetable_slot_faculty education.timetable.slot.faculty model_education_timetable_slot group_education_timetable_faculty 1 0 0 0
7 access_education_timetable_slot_student education.timetable.slot.student model_education_timetable_slot group_education_timetable_student 1 0 0 0
8

View File

@ -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>

View File

@ -1 +0,0 @@
from . import test_timetable

View File

@ -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])

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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:

View File

@ -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;
}
}

View File

@ -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

View File

@ -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/

View File

@ -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'

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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 = "../.."
}

View File

@ -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>

View File

@ -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>

View File

@ -1,5 +0,0 @@
package `in`.metatroncube.metatron_student_erp
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)
}

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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")

View File

@ -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

View File

@ -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>

View File

@ -1 +0,0 @@
#include "Generated.xcconfig"

View File

@ -1 +0,0 @@
#include "Generated.xcconfig"

View File

@ -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