commit dccaef02f43bc92b697868088392c8621dd9d7d8 Author: vidhya Date: Tue Sep 15 10:47:19 2026 +0530 full erp files diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..792f7b9 Binary files /dev/null and b/.DS_Store differ diff --git a/EDUCATION_MODULES_SUMMARY.md b/EDUCATION_MODULES_SUMMARY.md new file mode 100644 index 0000000..8109ccb --- /dev/null +++ b/EDUCATION_MODULES_SUMMARY.md @@ -0,0 +1,438 @@ +# 📚 Complete Technical Documentation of Custom Education Odoo Addons + +This document contains a comprehensive, field-level technical summary of all **7 custom education modules** built for the Odoo Education & Student Portal ecosystem. + +--- + +## 1. 🏛️ `education_base` (Base Academic Setup & Hierarchy) + +### **Module Overview** +The `education_base` module establishes the core structural hierarchy of the educational institution. It defines campuses, departments, degree programs, academic levels, and academic calendar timelines (years and terms). + +--- + +### **Models & Detailed Field Definitions** + +#### **1.1 `education.campus` (Campus Master)** +- **Functionality**: Represents physical or virtual university campuses. +- **Fields**: + - `name` *(Char, Required)*: The official name of the campus. + - `code` *(Char, Required)*: Unique abbreviation code. + - `street` / `street2` / `city` / `zip` / `state_id` / `country_id` *(Location fields)*: Physical address details. + - `phone` / `email` *(Char)*: Contact details for campus administration. + - `company_id` *(Many2one)*: Associated Odoo `res.company`. + - `active` *(Boolean)*: Soft-delete toggle (default: `True`). + +#### **1.2 `education.department` (Department Master)** +- **Functionality**: Academic departments within a campus (e.g., Computer Science, Mechanical). +- **Fields**: + - `name` *(Char, Required)*: Department name. + - `code` *(Char, Required)*: Short code. + - `campus_id` *(Many2one, Required)*: Relates to `education.campus`. + - `head_teacher_id` *(Many2one)*: Relates to `res.partner` (Department Chair / Dean). + - `program_ids` *(One2many)*: List of programs offered under this department. + - `company_id` *(Many2one)*: Multi-company reference. + +#### **1.3 `education.program` (Academic Program)** +- **Functionality**: Degree or diploma courses (e.g., MCA, B.Tech, MBA). +- **Fields**: + - `name` *(Char, Required)*: Program name. + - `code` *(Char, Required)*: Program short code. + - `department_id` *(Many2one, Required)*: Relates to `education.department`. + - `level_id` *(Many2one)*: Academic level (`Undergraduate`, `Postgraduate`). + - `duration` *(Integer)*: Number of duration units (e.g., `2`). + - `duration_unit` *(Selection)*: `year`, `month`, or `semester`. + - `description` *(Text)*: Overview of course curriculum. + +#### **1.4 `education.academic.year` (Academic Session)** +- **Functionality**: Defines full academic years and tracks current active session. +- **Fields**: + - `name` *(Char, Required)*: Name of the academic year (e.g., `"2026 - 2027"`). + - `code` *(Char, Required)*: Unique code. + - `start_date` *(Date, Required)*: Start date of session. + - `end_date` *(Date, Required)*: End date of session. + - `current_year` *(Boolean)*: Indicates if this is the active operational year. + - `state` *(Selection)*: `draft`, `active`, `closed`. + - `term_ids` *(One2many)*: Semesters/Terms within this year. + +#### **1.5 `education.academic.term` (Academic Semester / Term)** +- **Functionality**: Sub-divisions of an academic year (e.g., Semester 1, Semester 2). +- **Fields**: + - `name` *(Char, Required)*: Term name. + - `code` *(Char, Required)*: Term code (e.g., `"SEM1"`). + - `academic_year_id` *(Many2one, Required)*: Parent `education.academic.year`. + - `sequence` *(Integer)*: Term ordering (1, 2, 3...). + - `start_date` / `end_date` *(Date)*: Start and end dates of term. + - `state` *(Selection)*: `draft`, `active`, `completed`. + +#### **1.6 `education.configuration` (Education Configuration)** +- **Functionality**: Global institution-level settings and defaults. +- **Fields**: + - `school_name` / `school_code` *(Char, Required)*: Institution name and code. + - `default_campus_id` / `default_academic_year_id` / `default_term_id` *(Many2one)*: Default hierarchical selections for the institution. + - `logo` *(Binary)*: Institution logo. + - `contact_email` / `contact_phone` *(Char)*: Global contact information. + +#### **1.7 `education.academic.level` (Academic Level)** +- **Functionality**: Classification of education levels (e.g., Undergraduate, Postgraduate). +- **Fields**: + - `name` *(Char, Required)*: Level name. + - `code` *(Char, Required)*: Level code. + - `sequence` *(Integer)*: Ordering priority. + - `description` *(Text)*: Additional details about the level. + +--- + +### **Record Examples (`student_db`)** +```yaml +Campus Record: + ID: 1 | Name: "Main Campus" | Code: "MC" | City: "Thanjavur" + +Department Record: + ID: 1 | Name: "School of Computing" | Code: "SCS" | Campus: "Main Campus" + +Program Record: + ID: 1 | Name: "Master of Computer Applications - Lateral Entry" | Code: "MCA-L" | Duration: 2 Years + +Academic Year Record: + ID: 1 | Name: "2026 - 2027" | Code: "053" | Start: 2026-07-03 | End: 2027-07-03 | Current: True + +Academic Term Record: + ID: 1 | Name: "Semester 1" | Code: "SEM1" | Sequence: 1 | Year: "2026 - 2027" +``` + +--- + +## 2. 👨‍🎓 `education_student` (Student Lifecycle & Profile Management) + +### **Module Overview** +Central student registry handling personal profiles, academic placement, guardian information, document attachments, portal user links, and automated financial/attendance computations. + +--- + +### **Models & Detailed Field Definitions** + +#### **2.1 `education.student` (Student Profile)** +- **Personal Details**: + - `first_name` / `middle_name` / `last_name` *(Char)*: Individual name components. + - `full_name` *(Char, Computed)*: Concatenated name (`"Aadhav S"`). + - `gender` *(Selection)*: `male`, `female`, `other`. + - `date_of_birth` *(Date)*: Date of birth. + - `age` *(Integer, Computed)*: Calculated age in years. + - `email` / `phone` / `mobile` *(Char)*: Contact info. + - `image_1920` *(Image)*: Student profile photo. +- **Academic Identification & Placement**: + - `admission_number` *(Char)*: Auto-generated admission ID (`"ADM/2026/001"`). + - `roll_number` *(Char)*: Roll number (`"ROLL/0003"`). + - `registration_number` *(Char)*: University registration number. + - `campus_id` / `department_id` / `program_id` / `academic_year_id` / `academic_term_id` *(Many2one)*: Relates student to institutional hierarchy. + - `status` *(Selection)*: `draft`, `applied`, `admitted`, `enrolled`, `graduated`, `withdrawn`. +- **Parents & Guardian Information**: + - `father_name` / `father_phone` / `father_occupation` *(Char)*: Father's details. + - `mother_name` / `mother_phone` / `mother_occupation` *(Char)*: Mother's details. + - `guardian_name` / `guardian_phone` / `guardian_email` / `guardian_address` *(Char/Text)*: Guardian details. +- **Document Storage**: + - `doc_passport`, `doc_aadhaar`, `doc_birth_cert`, `doc_transfer_cert`, `doc_community_cert`, `doc_mark_sheets` *(Binary & Char)*: Stores uploaded compliance documents and filenames. +- **Computed Financial Summary**: + - `total_fees` *(Monetary, Computed)*: Total assigned fees (₹34,000.00). + - `total_paid` *(Monetary, Computed)*: Sum of all posted payments (₹34,000.00). + - `outstanding_amount` *(Monetary, Computed)*: Total due balance (₹0.00). + - `payment_status` *(Selection, Computed)*: `no_fees`, `unpaid`, `partial`, `fully_paid`. + - `ledger_line_ids` *(One2many)*: Inverse relation to `education.student.ledger`. +- **Computed Attendance Summary**: + - `attendance_pct` *(Float, Computed)*: Overall attendance percentage (e.g., `92.5`). + - `present_count` / `absent_count` / `late_count` / `medical_leave_count` *(Integer, Computed)*: Session counts. +- **Portal User Linkage**: + - `portal_user_id` *(Many2one)*: Relates to Odoo `res.users` (Portal Account). + - `portal_status` *(Selection)*: `draft`, `active`, `deactivated`. + +#### **2.2 `education.batch` (Academic Batch)** +- **Functionality**: Represents a specific student batch/cohort. +- **Fields**: + - `name` *(Char, Required)*: Batch name. + - `code` *(Char, Required)*: Unique batch code. + +#### **2.3 `education.semester` (Academic Semester)** +- **Functionality**: Specific academic semesters decoupled from global terms. +- **Fields**: + - `name` *(Char, Required)*: Semester name. + - `code` *(Char, Required)*: Unique semester code. + +#### **2.4 `education.section` (Academic Section)** +- **Functionality**: Classroom sections or groups for students. +- **Fields**: + - `name` *(Char, Required)*: Section name. + - `code` *(Char, Required)*: Unique section code. + +--- + +### **Record Examples (`student_db`)** +```yaml +Student Record #284: + Admission No: "ADM/2026/001" | Roll No: "ROLL/0003" + Name: "Aadhav S" | Email: "aadhav@gmail.com" | Phone: "9876543210" + Program: "Master of Computer Applications - Lateral Entry" + Total Fees: ₹34,000.00 | Total Paid: ₹34,000.00 | Outstanding: ₹0.00 + Payment Status: fully_paid + Attendance %: 92.5% (Present: 37, Absent: 3) + Portal User: aadhav@gmail.com (Status: Active) +``` + +--- + +## 3. 📅 `education_attendance` (Attendance & Absence Tracking) + +### **Module Overview** +Provides session-level attendance recording for students, automated calculation of attendance percentages, and historical session logs. + +--- + +### **Models & Detailed Field Definitions** + +#### **3.1 `education.attendance` (Student Attendance Record)** +- **Fields**: + - `student_id` *(Many2one, Required)*: Relates to `education.student`. + - `date` *(Date, Required)*: Attendance session date. + - `state` *(Selection, Required)*: `present`, `absent`, `late`, `medical_leave`. + - `remarks` *(Text)*: Optional notes or justification for leave. + - `academic_year_id` / `academic_term_id` *(Many2one)*: Timeline reference. + - `company_id` *(Many2one)*: Multi-company lock. + +#### **3.2 `education.attendance.session` (Attendance Session)** +- **Functionality**: Organizes attendance taking by combining date, location, and hierarchy. +- **Fields**: + - `name` *(Char, Required)*: Session title. + - `campus_id` / `department_id` / `program_id` / `academic_year_id` *(Many2one, Required)*: Targeting fields. + +#### **3.3 `education.attendance.line` (Attendance Line Detail)** +- **Functionality**: Individual student attendance state for a session. +- **Fields**: + - `session_id` *(Many2one, Required)*: Parent session. + - `student_id` *(Many2one, Required)*: Target student. + - `status` *(Selection)*: `present`, `absent`, `late`. + +--- + +### **Record Examples (`student_db`)** +```yaml +Attendance Entry #101: + Student: "Aadhav S" + Date: 2026-08-10 + State: Present + Academic Term: Semester 1 + +Attendance Entry #102: + Student: "Hareish S" + Date: 2026-08-10 + State: Absent + Remarks: "Sick leave - Doctor note submitted" +``` + +--- + +## 4. 💳 `education_finance` (Fees, Payments & Double-Entry Ledger) + +### **Module Overview** +Complete financial accounting engine for student fee assessment, payment posting, fee structures, discounts, scholarships, refunds, and real-time double-entry student financial ledgers. + +--- + +### **Models & Detailed Field Definitions** + +#### **4.1 `education.fee.category` (Fee Head)** +- **Fields**: + - `name` *(Char, Required)*: Name of fee type (`"Tuition"`, `"Hostel"`, `"Library"`). + - `code` *(Char, Required)*: Code (`TUI`, `HOS`, `LIB`, `EXM`, `TRN`). + +#### **4.2 `education.fee.structure` & `education.fee.structure.line` (Fee Template)** +- **Fields (`education.fee.structure`)**: + - `name` *(Char, Required)*: Name of structure (e.g., `"MCA Semester 1 2026 Regular"`). + - `program_id` / `academic_year_id` / `academic_term_id` *(Many2one)*: Applicable target. + - `line_ids` *(One2many)*: List of `education.fee.structure.line` items. +- **Fields (`education.fee.structure.line`)**: + - `category_id` *(Many2one, Required)*: Relates to `education.fee.category`. + - `amount` *(Monetary, Required)*: Line fee amount. + +#### **4.3 `education.fee.assignment` (Student Fee Assessment)** +- **Fields**: + - `name` *(Char)*: Sequence (`"ASSIGN/2026/00001"`). + - `student_id` *(Many2one, Required)*: Target student. + - `fee_structure_id` *(Many2one)*: Fee structure applied. + - `total_amount` *(Monetary)*: Gross fee amount (₹34,000.00). + - `discount_amount` *(Monetary)*: Discount concession applied. + - `net_amount` *(Monetary)*: Net due after discounts. + - `amount_paid` *(Monetary, Computed)*: Total payments posted against this assignment. + - `state` *(Selection)*: `draft`, `assigned`, `paid`, `cancelled`. + +#### **4.4 `education.student.payment` (Payment Receipt)** +- **Fields**: + - `name` *(Char)*: Sequence number (`"PAY/2026/00012"`). + - `student_id` *(Many2one, Required)*: Paying student. + - `fee_assignment_id` *(Many2one)*: Associated fee assignment. + - `amount` *(Monetary, Required)*: Amount paid (₹34,000.00). + - `payment_date` *(Date, Required)*: Date of transaction. + - `payment_method` *(Selection)*: `cash`, `bank`, `online`. + - `transaction_id` *(Char)*: Payment gateway transaction ref (`"pay_Px92kL10z"`). + - `state` *(Selection)*: `draft`, `posted`, `cancelled`. + +#### **4.5 `education.student.ledger` (Double-Entry Financial Ledger)** +- **Fields**: + - `student_id` *(Many2one, Required)*: Target student. + - `date` *(Date, Required)*: Transaction date. + - `description` *(Char, Required)*: Details (e.g., `"Fee Assessment: MCA Sem 1"`). + - `reference` *(Char)*: Document source (`ASSIGN/0001` or `PAY/0012`). + - `debit` *(Monetary)*: Increase in student financial liability. + - `credit` *(Monetary)*: Decrease in student financial liability (Payment). + - `balance` *(Monetary, Computed)*: Cumulative balance due after this entry. + +#### **4.6 `education.student.discount` & `education.student.scholarship` & `education.student.refund`** +- Models for concession processing, merit scholarship credits, and fee refund disbursements. + +#### **4.7 `education.fee.type` (Fee Type)** +- **Functionality**: Granular categorization of fee categories. +- **Fields**: + - `name` *(Char, Required)*: Fee type name. + - `category_id` *(Many2one, Required)*: Parent category. + - `currency_id` *(Many2one, Required)*: Currency for fee. + +--- + +### **Record Examples (`student_db`)** +```yaml +Fee Categories: + - Tuition (Code: TUI) + - Examination (Code: EXM) + - Library (Code: LIB) + +Fee Structure: "MCA Semester 1 2026 Regular" + - Line 1: Tuition Fee = ₹30,000.00 + - Line 2: Examination & Library Fee = ₹4,000.00 + Total: ₹34,000.00 + +Fee Assignment: ASSIGN/2026/00001 + Student: "Aadhav S" | Net Amount: ₹34,000.00 | Amount Paid: ₹34,000.00 | State: Paid + +Payment Record: PAY/2026/00012 + Student: "Aadhav S" | Amount: ₹34,000.00 | Method: Online Razorpay | State: Posted + +Ledger Statement for Aadhav S: + 1. 2026-07-05 | Fee Assessment: MCA Sem 1 | Debit: ₹34,000.00 | Credit: ₹0.00 | Balance: ₹34,000.00 + 2. 2026-07-10 | Payment Received: PAY/0012 | Debit: ₹0.00 | Credit: ₹34,000.00 | Balance: ₹0.00 +``` + +--- + +## 5. 📊 `education_dashboard` (Analytics & Management KPIs) + +### **Module Overview** +Aggregates metrics across the institution to present real-time key performance indicators (KPIs) for administration. + +--- + +### **Models & Detailed Field Definitions** + +#### **5.1 `education.dashboard` (Dashboard Helper Model)** +- **Fields**: + - `total_students` *(Integer, Computed)*: Total active enrolled students. + - `total_fees_collected` *(Monetary, Computed)*: Cumulative fee revenue. + - `total_outstanding_dues` *(Monetary, Computed)*: Aggregate unpaid student balance. + - `average_attendance` *(Float, Computed)*: Average student attendance across institution. + +--- + +### **Record Examples (`student_db`)** +```yaml +Dashboard KPI Snapshot: + Total Students: 3 + Total Fees Collected: ₹102,000.00 + Total Dues Pending: ₹0.00 + Average Campus Attendance: 94.2% +``` + +--- + +## 6. 🖥️ `education_portal` (Student Self-Service Portal & Login) + +### **Module Overview** +Delivers the student-facing online portal (`/my/education`), online Razorpay payment integration, announcement notices, profile updates, and the customized 50/50 split-screen login page (`/web/login`). + +--- + +### **Models & Detailed Overrides** + +#### **6.1 Custom 50/50 Split-Screen Login Page (`web.login` & `web.login_layout`)** +- **Left Panel (Branding & Features)**: + - Glassmorphism tint over campus background image. + - Dynamic company logo (`/web/image?model=res.company&id=1&field=logo`). + - Generalized Welcome Headers & Institutional Description text. + - 2x2 Feature Highlights Grid: + - 🔐 **Secure Authentication** + - ⚡ **Fast & Reliable Access** + - 📊 **Real-Time Information** + - ☁️ **Cloud-Based Platform** +- **Right Panel (Form & Authentication)**: + - Welcome Card with database selector. + - Input field with icon (`Enter your Email`). + - Password input with interactive eye icon toggle. + - Remember Me & Forgot Password links. + - Google & Microsoft SSO buttons. + - Form action URL preserving parameters (`t-attf-action="/web/login?#{keep_query('*')}"`) and hidden `db` field to ensure CSRF validation never expires. + +#### **6.2 `education.announcement` (Notice Board)** +- **Fields**: + - `title` *(Char, Required)*: Notice title. + - `content` *(Html, Required)*: Formatted announcement content. + - `category` *(Selection)*: `academic`, `finance`, `event`, `general`. + - `target_audience` *(Selection)*: `all`, `students`, `staff`. + - `date_published` *(Datetime)*: Timestamp of publication. + +#### **6.3 `education.payment.transaction` (Payment Transaction)** +- **Functionality**: Tracks specific payment transactions triggered from the portal. +- **Fields**: + - `name` *(Char, Required)*: Transaction Reference. + +--- + +### **Record Examples (`student_db`)** +```yaml +Announcement Record #1: + Title: "Semester 1 Examinations Schedule" + Category: Academic + Target Audience: Students + Published Date: 2026-08-20 + Content: "The end-semester examinations for Semester 1 will commence from December 5th, 2026." +``` + +--- + +## 7. 📄 `education_reports` (QWeb PDF & Excel Analytics) + +### **Module Overview** +Generates formal printable PDF documents and analytical export files for identity cards, payment receipts, attendance rosters, and ledgers. + +--- + +### **Reports & Wizards** + +#### **7.1 Student Fee Receipt (`report_student_fee_receipt`)** +- Printable PDF generated when a payment is posted. Includes transaction reference, student roll number, breakdown of fee items, amount paid, and signature line. + +#### **7.2 Student Identity Card (`report_student_id_card`)** +- Printable student badge with student photo, barcode/QR code, roll number, department, blood group, and emergency contact details. + +#### **7.3 `education.report.wizard` (Custom Report Exporter)** +- Filter wizard allowing administrators to select date ranges, programs, and fee categories to generate custom PDF/Excel reports. + +--- + +## 📋 Comprehensive Summary Matrix of Custom Addons + +| Module | Core Purpose | Models Defined | Key Output / UI View | +| :--- | :--- | :--- | :--- | +| **`education_base`** | Institutional Hierarchy | `education.campus`, `education.department`, `education.program`, `education.academic.year`, `education.academic.term`, `education.configuration`, `education.academic.level` | Academic Setup Form & Tree Views | +| **`education_student`** | Student Registry & Profiles | `education.student`, `education.batch`, `education.semester`, `education.section` | Student Master Form, Kanban & Portal User Action | +| **`education_attendance`** | Attendance Log & Metrics | `education.attendance`, `education.attendance.session`, `education.attendance.line` | Daily Attendance Grid & % Computation | +| **`education_finance`** | Financials, Fees & Ledgers | `education.fee.category`, `education.fee.type`, `education.fee.structure`, `education.fee.assignment`, `education.student.payment`, `education.student.ledger`, `education.student.discount`, `education.student.scholarship`, `education.student.refund` | Student Ledger Statement, Fee Structure Form, Payment Receipts | +| **`education_dashboard`** | Admin KPIs & Analytics | `education.dashboard` | Management KPI Cards & Metrics Summary | +| **`education_portal`** | Self-Service Web & Login | `education.announcement`, `education.payment.transaction` | Student Portal `/my/education`, Razorpay Payment & Split-Screen Login Page | +| **`education_reports`** | PDF & Excel Exports | `education.report.wizard` | PDF Student ID Cards, Fee Receipts & Ledger Exports | diff --git a/addons/.DS_Store b/addons/.DS_Store new file mode 100644 index 0000000..b8d777a Binary files /dev/null and b/addons/.DS_Store differ diff --git a/addons/education_attendance/__init__.py b/addons/education_attendance/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/education_attendance/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/education_attendance/__manifest__.py b/addons/education_attendance/__manifest__.py new file mode 100644 index 0000000..371592d --- /dev/null +++ b/addons/education_attendance/__manifest__.py @@ -0,0 +1,37 @@ +{ + 'name': 'Education Attendance', + 'version': '17.0.1.0.0', + 'category': 'Education', + 'summary': 'Manage Daily and Period-wise Student Attendance', + 'description': """ + Attendance Management System for Educational Institutions. + Supports: + - Daily & Period-wise Attendance + - Attendance Sessions + - Bulk marking status updates + - Multi-company and Multi-campus isolation + - Interactive dashboards, pivot tables, and PDF reporting register + """, + 'author': 'Senior Solution Architect', + 'depends': [ + 'education_base', + 'education_student', + 'mail', + ], + 'data': [ + 'security/education_attendance_security.xml', + 'security/ir.model.access.csv', + 'views/attendance_session_views.xml', + 'views/attendance_views.xml', + 'views/student_views_inherit.xml', + 'views/attendance_menus.xml', + 'reports/attendance_reports.xml', + ], + 'demo': [ + 'data/education_attendance_demo.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/addons/education_attendance/__pycache__/__init__.cpython-310.pyc b/addons/education_attendance/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..69806e3 Binary files /dev/null and b/addons/education_attendance/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_attendance/__pycache__/__manifest__.cpython-310.pyc b/addons/education_attendance/__pycache__/__manifest__.cpython-310.pyc new file mode 100644 index 0000000..be2e903 Binary files /dev/null and b/addons/education_attendance/__pycache__/__manifest__.cpython-310.pyc differ diff --git a/addons/education_attendance/data/education_attendance_demo.xml b/addons/education_attendance/data/education_attendance_demo.xml new file mode 100644 index 0000000..67397bc --- /dev/null +++ b/addons/education_attendance/data/education_attendance_demo.xml @@ -0,0 +1,440 @@ + + + + + Attendance Demo Campus + ADC + + + + + Attendance Demo Department + ADD + + + + + + Attendance Demo Level + ADL + + + + + Attendance Demo Program + ADP + + + + + + + Attendance Demo 2026 + ADY26 + 2026-01-01 + 2026-12-31 + + + + + Attendance Demo Term 1 + ADT1 + + 2026-01-01 + 2026-06-30 + + + + + Attendance Batch 2026 + ADB26 + + + + + Attendance Semester 1 + ADS1 + + + + + Attendance Section A + ADSA + + + + + Alex + Smith + STU001 + ADM001 + RL001 + + + + + + + + + + active + + + + Bella + Jones + STU002 + ADM002 + RL002 + + + + + + + + + + active + + + + Charlie + Brown + STU003 + ADM003 + RL003 + + + + + + + + + + active + + + + Diana + Prince + STU004 + ADM004 + RL004 + + + + + + + + + + active + + + + Evan + Wright + STU005 + ADM005 + RL005 + + + + + + + + + + active + + + + Fiona + Gallagher + STU006 + ADM006 + RL006 + + + + + + + + + + active + + + + George + Clooney + STU007 + ADM007 + RL007 + + + + + + + + + + active + + + + Hannah + Baker + STU008 + ADM008 + RL008 + + + + + + + + + + active + + + + Ian + Somerhalder + STU009 + ADM009 + RL009 + + + + + + + + + + active + + + + Julia + Roberts + STU010 + ADM010 + RL010 + + + + + + + + + + active + + + + Kevin + Hart + STU011 + ADM011 + RL011 + + + + + + + + + + active + + + + Lily + Collins + STU012 + ADM012 + RL012 + + + + + + + + + + active + + + + Mike + Tyson + STU013 + ADM013 + RL013 + + + + + + + + + + active + + + + Nina + Dobrev + STU014 + ADM014 + RL014 + + + + + + + + + + active + + + + Oscar + Isaac + STU015 + ADM015 + RL015 + + + + + + + + + + active + + + + Penelope + Cruz + STU016 + ADM016 + RL016 + + + + + + + + + + active + + + + Quincy + Jones + STU017 + ADM017 + RL017 + + + + + + + + + + active + + + + Rachel + Green + STU018 + ADM018 + RL018 + + + + + + + + + + active + + + + Sam + Winchester + STU019 + ADM019 + RL019 + + + + + + + + + + active + + + + Tom + Hanks + STU020 + ADM020 + RL020 + + + + + + + + + + active + + + + + Demo Session MCA Period 1 + + + + + + + + period_1 + + draft + + diff --git a/addons/education_attendance/models/__init__.py b/addons/education_attendance/models/__init__.py new file mode 100644 index 0000000..d27250b --- /dev/null +++ b/addons/education_attendance/models/__init__.py @@ -0,0 +1,4 @@ +from . import attendance_session +from . import attendance_line +from . import attendance +from . import student_inherit diff --git a/addons/education_attendance/models/__pycache__/__init__.cpython-310.pyc b/addons/education_attendance/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..53f466e Binary files /dev/null and b/addons/education_attendance/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_attendance/models/__pycache__/attendance.cpython-310.pyc b/addons/education_attendance/models/__pycache__/attendance.cpython-310.pyc new file mode 100644 index 0000000..a1f6286 Binary files /dev/null and b/addons/education_attendance/models/__pycache__/attendance.cpython-310.pyc differ diff --git a/addons/education_attendance/models/__pycache__/attendance_line.cpython-310.pyc b/addons/education_attendance/models/__pycache__/attendance_line.cpython-310.pyc new file mode 100644 index 0000000..d228725 Binary files /dev/null and b/addons/education_attendance/models/__pycache__/attendance_line.cpython-310.pyc differ diff --git a/addons/education_attendance/models/__pycache__/attendance_session.cpython-310.pyc b/addons/education_attendance/models/__pycache__/attendance_session.cpython-310.pyc new file mode 100644 index 0000000..d6c931f Binary files /dev/null and b/addons/education_attendance/models/__pycache__/attendance_session.cpython-310.pyc differ diff --git a/addons/education_attendance/models/__pycache__/student_inherit.cpython-310.pyc b/addons/education_attendance/models/__pycache__/student_inherit.cpython-310.pyc new file mode 100644 index 0000000..67549ec Binary files /dev/null and b/addons/education_attendance/models/__pycache__/student_inherit.cpython-310.pyc differ diff --git a/addons/education_attendance/models/attendance.py b/addons/education_attendance/models/attendance.py new file mode 100644 index 0000000..6096f98 --- /dev/null +++ b/addons/education_attendance/models/attendance.py @@ -0,0 +1,29 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models + + +class EducationAttendance(models.Model): + _name = 'education.attendance' + _description = 'Student Attendance History' + _order = 'date desc, id desc' + + student_id = fields.Many2one('education.student', string='Student', required=True, index=True, ondelete='cascade') + date = fields.Date(string='Date', required=True, index=True, default=fields.Date.context_today) + session_id = fields.Many2one('education.attendance.session', string='Session', ondelete='set null', index=True) + status = fields.Selection([ + ('present', 'Present'), + ('absent', 'Absent'), + ('late', 'Late'), + ('medical_leave', 'Medical Leave'), + ('on_duty', 'On Duty') + ], string='Status', required=True, index=True) + faculty_id = fields.Many2one('res.users', string='Faculty', required=True, index=True) + remarks = fields.Text(string='Remarks') + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) diff --git a/addons/education_attendance/models/attendance_line.py b/addons/education_attendance/models/attendance_line.py new file mode 100644 index 0000000..cde4fad --- /dev/null +++ b/addons/education_attendance/models/attendance_line.py @@ -0,0 +1,28 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models + + +class EducationAttendanceLine(models.Model): + _name = 'education.attendance.line' + _description = 'Attendance Line Detail' + _order = 'id asc' + + session_id = fields.Many2one('education.attendance.session', string='Session', required=True, ondelete='cascade', index=True) + student_id = fields.Many2one('education.student', string='Student', required=True, index=True) + status = fields.Selection([ + ('present', 'Present'), + ('absent', 'Absent'), + ('late', 'Late'), + ('medical_leave', 'Medical Leave'), + ('on_duty', 'On Duty') + ], string='Status', default='present', required=True, index=True) + remarks = fields.Char(string='Remarks') + time_in = fields.Datetime(string='Time In') + time_out = fields.Datetime(string='Time Out') + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + related='session_id.company_id', + store=True, + index=True, + ) diff --git a/addons/education_attendance/models/attendance_session.py b/addons/education_attendance/models/attendance_session.py new file mode 100644 index 0000000..53e6da6 --- /dev/null +++ b/addons/education_attendance/models/attendance_session.py @@ -0,0 +1,161 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationAttendanceSession(models.Model): + _name = 'education.attendance.session' + _description = 'Attendance Session' + _inherit = ['mail.thread', 'mail.activity.mixin'] + _order = 'attendance_date desc, period asc, id desc' + + name = fields.Char(string='Session Title', required=True, tracking=True) + campus_id = fields.Many2one('education.campus', string='Campus', required=True, index=True) + department_id = fields.Many2one('education.department', string='Department', required=True, index=True) + program_id = fields.Many2one('education.program', string='Program', 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', required=True, index=True) + academic_level_id = fields.Many2one('education.academic.level', string='Academic Level', required=True, index=True) + + attendance_date = fields.Date(string='Attendance Date', required=True, default=fields.Date.context_today, index=True, tracking=True) + period = fields.Selection([ + ('period_1', 'Period 1'), + ('period_2', 'Period 2'), + ('period_3', 'Period 3'), + ('period_4', 'Period 4'), + ('period_5', 'Period 5'), + ('period_6', 'Period 6'), + ('period_7', 'Period 7'), + ('period_8', 'Period 8') + ], string='Period', required=True, default='period_1', tracking=True) + faculty_id = fields.Many2one('res.users', string='Faculty', required=True, index=True, default=lambda self: self.env.user, tracking=True) + + state = fields.Selection([ + ('draft', 'Draft'), + ('open', 'Open'), + ('completed', 'Completed'), + ('cancelled', 'Cancelled') + ], string='Status', default='draft', required=True, index=True, tracking=True) + + line_ids = fields.One2many('education.attendance.line', 'session_id', string='Attendance Lines', copy=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + _sql_constraints = [ + ('uniq_session_period', '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.'), + ] + + def action_open(self): + for rec in self: + if rec.state != 'draft': + continue + # 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), + ('academic_year_id', '=', rec.academic_year_id.id), + ('academic_term_id', '=', rec.academic_term_id.id), + ('level_id', '=', rec.academic_level_id.id), + ('status', '=', 'active'), + ]) + if not students: + raise ValidationError("No active students found matching the selected academic criteria.") + + # Create lines + lines_vals = [] + for student in students: + lines_vals.append({ + 'session_id': rec.id, + 'student_id': student.id, + 'status': 'present', + 'company_id': rec.company_id.id, + }) + self.env['education.attendance.line'].create(lines_vals) + rec.state = 'open' + + def action_complete(self): + for rec in self: + if rec.state != 'open': + continue + if not rec.line_ids: + raise ValidationError("Cannot complete session without any student lines.") + + # Copy to permanent history table + history_vals = [] + for line in rec.line_ids: + history_vals.append({ + 'student_id': line.student_id.id, + 'date': rec.attendance_date, + 'session_id': rec.id, + 'status': line.status, + 'faculty_id': rec.faculty_id.id, + 'remarks': line.remarks, + 'company_id': rec.company_id.id, + }) + self.env['education.attendance'].create(history_vals) + rec.state = 'completed' + + # Invalidate student attendance cache + students = rec.line_ids.mapped('student_id') + students.invalidate_recordset() + + def action_cancel(self): + for rec in self: + if rec.state == 'completed': + # Delete permanent history + history = self.env['education.attendance'].search([('session_id', '=', rec.id)]) + history.unlink() + + # Delete lines + rec.line_ids.unlink() + rec.state = 'cancelled' + + def action_draft(self): + for rec in self: + # Delete permanent history if completed + if rec.state == 'completed': + history = self.env['education.attendance'].search([('session_id', '=', rec.id)]) + history.unlink() + + # Delete lines + rec.line_ids.unlink() + rec.state = 'draft' + + # Bulk marking actions + def action_bulk_present(self): + self.ensure_one() + if self.state != 'open': + raise ValidationError("Bulk marking is only allowed when the session is Open.") + self.line_ids.write({'status': 'present'}) + + def action_bulk_absent(self): + self.ensure_one() + if self.state != 'open': + raise ValidationError("Bulk marking is only allowed when the session is Open.") + self.line_ids.write({'status': 'absent'}) + + def action_bulk_late(self): + self.ensure_one() + if self.state != 'open': + raise ValidationError("Bulk marking is only allowed when the session is Open.") + self.line_ids.write({'status': 'late'}) + + def action_bulk_medical(self): + self.ensure_one() + if self.state != 'open': + raise ValidationError("Bulk marking is only allowed when the session is Open.") + self.line_ids.write({'status': 'medical_leave'}) + + def action_bulk_duty(self): + self.ensure_one() + if self.state != 'open': + raise ValidationError("Bulk marking is only allowed when the session is Open.") + self.line_ids.write({'status': 'on_duty'}) diff --git a/addons/education_attendance/models/student_inherit.py b/addons/education_attendance/models/student_inherit.py new file mode 100644 index 0000000..a46def7 --- /dev/null +++ b/addons/education_attendance/models/student_inherit.py @@ -0,0 +1,33 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models + + +class EducationStudent(models.Model): + _inherit = 'education.student' + + attendance_line_ids = fields.One2many('education.attendance', 'student_id', string='Attendance History') + attendance_pct = fields.Float(string='Attendance %', compute='_compute_attendance_metrics', store=True, index=True) + present_count = fields.Integer(string='Present Count', compute='_compute_attendance_metrics', store=True) + absent_count = fields.Integer(string='Absent Count', compute='_compute_attendance_metrics', store=True) + late_count = fields.Integer(string='Late Count', compute='_compute_attendance_metrics', store=True) + medical_leave_count = fields.Integer(string='Medical Leave Count', compute='_compute_attendance_metrics', store=True) + + @api.depends('attendance_line_ids.status', 'attendance_line_ids.active') + def _compute_attendance_metrics(self): + for rec in self: + records = rec.attendance_line_ids.filtered(lambda a: a.active) + total = len(records) + + p_count = len(records.filtered(lambda r: r.status in ('present', 'on_duty'))) + l_count = len(records.filtered(lambda r: r.status == 'late')) + m_count = len(records.filtered(lambda r: r.status == 'medical_leave')) + a_count = len(records.filtered(lambda r: r.status == 'absent')) + + rec.present_count = p_count + rec.absent_count = a_count + rec.late_count = l_count + rec.medical_leave_count = m_count + + # Treat present, late, medical_leave, and on_duty as attended (positive) + attended = p_count + l_count + m_count + rec.attendance_pct = (attended / total * 100.0) if total > 0 else 100.0 diff --git a/addons/education_attendance/reports/attendance_reports.xml b/addons/education_attendance/reports/attendance_reports.xml new file mode 100644 index 0000000..b0378fd --- /dev/null +++ b/addons/education_attendance/reports/attendance_reports.xml @@ -0,0 +1,138 @@ + + + + + Daily Attendance Register + education.attendance.session + qweb-pdf + education_attendance.report_daily_attendance_template + education_attendance.report_daily_attendance_template + + report + + + + + Student Attendance Report + education.student + qweb-pdf + education_attendance.report_student_attendance_template + education_attendance.report_student_attendance_template + + report + + + + + + + + diff --git a/addons/education_attendance/security/education_attendance_security.xml b/addons/education_attendance/security/education_attendance_security.xml new file mode 100644 index 0000000..67e6304 --- /dev/null +++ b/addons/education_attendance/security/education_attendance_security.xml @@ -0,0 +1,57 @@ + + + + + Education Attendance + 40 + + + + + Attendance User + + + Allows marking attendance lines and tracking their sessions. + + + + Attendance Manager + + + Full managerial oversight, configuration, and reports. + + + + + Attendance Session Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Attendance Line Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Attendance History Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + + Faculty Sessions Only + + [('faculty_id', '=', user.id)] + + + + + Manager All Sessions + + [(1, '=', 1)] + + + diff --git a/addons/education_attendance/security/ir.model.access.csv b/addons/education_attendance/security/ir.model.access.csv new file mode 100644 index 0000000..6da361b --- /dev/null +++ b/addons/education_attendance/security/ir.model.access.csv @@ -0,0 +1,7 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_attendance_session_user,Attendance Session User,model_education_attendance_session,group_attendance_user,1,1,1,0 +access_attendance_session_mgr,Attendance Session Mgr,model_education_attendance_session,group_attendance_manager,1,1,1,1 +access_attendance_line_user,Attendance Line User,model_education_attendance_line,group_attendance_user,1,1,1,0 +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 diff --git a/addons/education_attendance/tests/__init__.py b/addons/education_attendance/tests/__init__.py new file mode 100644 index 0000000..544dd33 --- /dev/null +++ b/addons/education_attendance/tests/__init__.py @@ -0,0 +1 @@ +from . import test_attendance diff --git a/addons/education_attendance/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_attendance/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..03e96b1 Binary files /dev/null and b/addons/education_attendance/tests/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_attendance/tests/__pycache__/test_attendance.cpython-310.pyc b/addons/education_attendance/tests/__pycache__/test_attendance.cpython-310.pyc new file mode 100644 index 0000000..a4d7c5e Binary files /dev/null and b/addons/education_attendance/tests/__pycache__/test_attendance.cpython-310.pyc differ diff --git a/addons/education_attendance/tests/test_attendance.py b/addons/education_attendance/tests/test_attendance.py new file mode 100644 index 0000000..964e123 --- /dev/null +++ b/addons/education_attendance/tests/test_attendance.py @@ -0,0 +1,205 @@ +# pyrefly: ignore [missing-import] +from odoo.tests.common import TransactionCase +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class TestStudentAttendance(TransactionCase): + + def setUp(self): + super(TestStudentAttendance, self).setUp() + + self.company = self.env.ref('base.main_company') + + # Setup master records + self.campus = self.env['education.campus'].create({ + 'name': 'Attendance Test Campus', + 'code': 'ATC', + }) + self.department = self.env['education.department'].create({ + 'name': 'Attendance Test Dept', + 'code': 'ATD', + 'campus_id': self.campus.id, + }) + self.level = self.env['education.academic.level'].create({ + 'name': 'Attendance Test Level', + 'code': 'ATL', + }) + self.program = self.env['education.program'].create({ + 'name': 'Attendance Test Program', + 'code': 'ATP', + 'department_id': self.department.id, + 'level_id': self.level.id, + }) + self.year = self.env['education.academic.year'].create({ + 'name': 'Attendance Test Year 2026', + 'code': 'ATY26', + 'start_date': '2026-01-01', + 'end_date': '2026-12-31', + }) + self.term = self.env['education.academic.term'].create({ + 'name': 'Attendance Test Term 1', + 'code': 'ATT1', + 'academic_year_id': self.year.id, + 'start_date': '2026-01-01', + 'end_date': '2026-06-30', + }) + self.batch = self.env['education.batch'].create({ + 'name': 'Attendance Test Batch 2026', + 'code': 'ATB26', + }) + self.semester = self.env['education.semester'].create({ + 'name': 'Attendance Test Semester 1', + 'code': 'ATS1', + }) + self.section = self.env['education.section'].create({ + 'name': 'Attendance Test Section A', + 'code': 'ATSA', + }) + + # Create 3 active students + self.student_1 = self.env['education.student'].create({ + 'first_name': 'TestStudent', + 'last_name': 'One', + 'student_id': 'ATSTU01', + 'admission_number': 'ATADM01', + 'roll_number': 'ATRL01', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.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.id, + 'status': 'active', + }) + + self.student_2 = self.env['education.student'].create({ + 'first_name': 'TestStudent', + 'last_name': 'Two', + 'student_id': 'ATSTU02', + 'admission_number': 'ATADM02', + 'roll_number': 'ATRL02', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.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.id, + 'status': 'active', + }) + + self.student_3 = self.env['education.student'].create({ + 'first_name': 'TestStudent', + 'last_name': 'Three', + 'student_id': 'ATSTU03', + 'admission_number': 'ATADM03', + 'roll_number': 'ATRL03', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.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.id, + 'status': 'active', + }) + + # Draft Session + self.session = self.env['education.attendance.session'].create({ + 'name': 'Test Lecture Period 1', + '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': '2026-02-01', + 'period': 'period_1', + }) + + 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 (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) + + # Confirm default state is 'present' + for line in self.session.line_ids: + self.assertEqual(line.status, 'present') + + # Test bulk marking: set all to 'absent' + self.session.action_bulk_absent() + for line in self.session.line_ids: + self.assertEqual(line.status, 'absent') + + # 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': 'late'}) + line_3.write({'status': 'medical_leave'}) + + # Complete Session (should write to permanent history education.attendance) + self.session.action_complete() + self.assertEqual(self.session.state, 'completed') + + # Verify history matches + history_1 = self.env['education.attendance'].search([('student_id', '=', self.student_1.id), ('session_id', '=', self.session.id)]) + history_2 = self.env['education.attendance'].search([('student_id', '=', self.student_2.id), ('session_id', '=', self.session.id)]) + history_3 = self.env['education.attendance'].search([('student_id', '=', self.student_3.id), ('session_id', '=', self.session.id)]) + + self.assertEqual(len(history_1), 1) + self.assertEqual(history_1.status, 'present') + 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() + + # 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_2.attendance_pct, 100.0) + self.assertEqual(self.student_3.attendance_pct, 100.0) + + 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', + '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': '2026-02-01', + 'period': 'period_1', + }) + self.env['education.attendance.session'].flush_model() diff --git a/addons/education_attendance/views/attendance_menus.xml b/addons/education_attendance/views/attendance_menus.xml new file mode 100644 index 0000000..55b1b89 --- /dev/null +++ b/addons/education_attendance/views/attendance_menus.xml @@ -0,0 +1,22 @@ + + + + + + + + + + diff --git a/addons/education_attendance/views/attendance_session_views.xml b/addons/education_attendance/views/attendance_session_views.xml new file mode 100644 index 0000000..69df342 --- /dev/null +++ b/addons/education_attendance/views/attendance_session_views.xml @@ -0,0 +1,188 @@ + + + + education.attendance.session.tree + education.attendance.session + + + + + + + + + + + + + + + + education.attendance.session.kanban + education.attendance.session + + + + + + + + + +
+
+
+ +
+
+ Date: +
+
+ Period: +
+
+ Faculty: +
+
+
+
+
+
+
+
+ + + education.attendance.session.calendar + education.attendance.session + + + + + + + + + + + education.attendance.session.pivot + education.attendance.session + + + + + + + + + + + education.attendance.session.graph + education.attendance.session + + + + + + + + + + education.attendance.session.form + education.attendance.session + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + education.attendance.session.search + education.attendance.session + + + + + + + + + + + + + + + + + + + + + + + + + + Attendance Sessions + education.attendance.session + tree,kanban,form,calendar,pivot,graph + + +
diff --git a/addons/education_attendance/views/attendance_views.xml b/addons/education_attendance/views/attendance_views.xml new file mode 100644 index 0000000..640b824 --- /dev/null +++ b/addons/education_attendance/views/attendance_views.xml @@ -0,0 +1,83 @@ + + + + education.attendance.tree + education.attendance + + + + + + + + + + + + + + education.attendance.calendar + education.attendance + + + + + + + + + + education.attendance.pivot + education.attendance + + + + + + + + + + + education.attendance.graph + education.attendance + + + + + + + + + education.attendance.search + education.attendance + + + + + + + + + + + + + + + + + + + + + + + + + Attendance History + education.attendance + tree,calendar,pivot,graph + + + diff --git a/addons/education_attendance/views/student_views_inherit.xml b/addons/education_attendance/views/student_views_inherit.xml new file mode 100644 index 0000000..2a87de2 --- /dev/null +++ b/addons/education_attendance/views/student_views_inherit.xml @@ -0,0 +1,38 @@ + + + + education.student.form.inherit.attendance + education.student + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/education_base/__init__.py b/addons/education_base/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/education_base/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/education_base/__manifest__.py b/addons/education_base/__manifest__.py new file mode 100644 index 0000000..1c89322 --- /dev/null +++ b/addons/education_base/__manifest__.py @@ -0,0 +1,40 @@ +{ + 'name': 'Education Base', + 'version': '17.0.1.0.0', + 'summary': 'Core foundation for education management', + 'description': ''' + Education Base module provides the foundational master data, academic configuration, + and shared setup objects used by all education-related modules in the ERP. + It includes academic years, academic terms, campuses, departments, academic levels, + programs, and global education configuration. + ''', + 'category': 'Education', + 'author': 'Metatron', + 'license': 'LGPL-3', + 'depends': ['base', 'mail', 'contacts'], + 'data': [ + 'security/education_base_security.xml', + 'security/ir.model.access.csv', + 'security/record_rules.xml', + 'data/education_base_sequence.xml', + 'data/education_base_menu.xml', + 'views/academic_year_views.xml', + 'views/academic_term_views.xml', + 'views/campus_views.xml', + 'views/department_views.xml', + 'views/academic_level_views.xml', + 'views/program_views.xml', + 'views/education_config_views.xml', + 'views/menuitems.xml', + ], + 'demo': [ + 'demo/demo_academic_level.xml', + 'demo/demo_academic_year.xml', + 'demo/demo_campus.xml', + 'demo/demo_department.xml', + 'demo/demo_program.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/addons/education_base/__pycache__/__init__.cpython-310.pyc b/addons/education_base/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..d80ba56 Binary files /dev/null and b/addons/education_base/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_base/data/education_base_menu.xml b/addons/education_base/data/education_base_menu.xml new file mode 100644 index 0000000..4009cd0 --- /dev/null +++ b/addons/education_base/data/education_base_menu.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/addons/education_base/data/education_base_sequence.xml b/addons/education_base/data/education_base_sequence.xml new file mode 100644 index 0000000..09658e3 --- /dev/null +++ b/addons/education_base/data/education_base_sequence.xml @@ -0,0 +1,42 @@ + + + + Academic Year + education.academic.year + AY/ + 4 + + + + + Academic Term + education.academic.term + TERM/ + 4 + + + + + Campus + education.campus + CAMP/ + 4 + + + + + Department + education.department + DEP/ + 4 + + + + + Program + education.program + PROG/ + 5 + + + diff --git a/addons/education_base/demo/demo_academic_level.xml b/addons/education_base/demo/demo_academic_level.xml new file mode 100644 index 0000000..459e767 --- /dev/null +++ b/addons/education_base/demo/demo_academic_level.xml @@ -0,0 +1,10 @@ + + + + Undergraduate + UG + 10 + Undergraduate study level. + + + diff --git a/addons/education_base/demo/demo_academic_year.xml b/addons/education_base/demo/demo_academic_year.xml new file mode 100644 index 0000000..3ee6e13 --- /dev/null +++ b/addons/education_base/demo/demo_academic_year.xml @@ -0,0 +1,12 @@ + + + + 2025-2026 + AY2025 + 2025-06-01 + 2026-05-31 + active + True + + + diff --git a/addons/education_base/demo/demo_campus.xml b/addons/education_base/demo/demo_campus.xml new file mode 100644 index 0000000..93e4bf5 --- /dev/null +++ b/addons/education_base/demo/demo_campus.xml @@ -0,0 +1,10 @@ + + + + Main Campus + MAIN + +1234567890 + main@school.example + + + diff --git a/addons/education_base/demo/demo_department.xml b/addons/education_base/demo/demo_department.xml new file mode 100644 index 0000000..a685160 --- /dev/null +++ b/addons/education_base/demo/demo_department.xml @@ -0,0 +1,8 @@ + + + + Engineering + ENG + + + diff --git a/addons/education_base/demo/demo_program.xml b/addons/education_base/demo/demo_program.xml new file mode 100644 index 0000000..5fe5820 --- /dev/null +++ b/addons/education_base/demo/demo_program.xml @@ -0,0 +1,11 @@ + + + + B.Tech + BTECH + + + 4 + years + + diff --git a/addons/education_base/models/__init__.py b/addons/education_base/models/__init__.py new file mode 100644 index 0000000..039b28a --- /dev/null +++ b/addons/education_base/models/__init__.py @@ -0,0 +1,7 @@ +from . import academic_year +from . import academic_term +from . import campus +from . import department +from . import academic_level +from . import program +from . import education_config diff --git a/addons/education_base/models/__pycache__/__init__.cpython-310.pyc b/addons/education_base/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..5d88e01 Binary files /dev/null and b/addons/education_base/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_base/models/__pycache__/academic_level.cpython-310.pyc b/addons/education_base/models/__pycache__/academic_level.cpython-310.pyc new file mode 100644 index 0000000..19de8fb Binary files /dev/null and b/addons/education_base/models/__pycache__/academic_level.cpython-310.pyc differ diff --git a/addons/education_base/models/__pycache__/academic_term.cpython-310.pyc b/addons/education_base/models/__pycache__/academic_term.cpython-310.pyc new file mode 100644 index 0000000..dc9c2b2 Binary files /dev/null and b/addons/education_base/models/__pycache__/academic_term.cpython-310.pyc differ diff --git a/addons/education_base/models/__pycache__/academic_year.cpython-310.pyc b/addons/education_base/models/__pycache__/academic_year.cpython-310.pyc new file mode 100644 index 0000000..404971d Binary files /dev/null and b/addons/education_base/models/__pycache__/academic_year.cpython-310.pyc differ diff --git a/addons/education_base/models/__pycache__/campus.cpython-310.pyc b/addons/education_base/models/__pycache__/campus.cpython-310.pyc new file mode 100644 index 0000000..1dccbfe Binary files /dev/null and b/addons/education_base/models/__pycache__/campus.cpython-310.pyc differ diff --git a/addons/education_base/models/__pycache__/department.cpython-310.pyc b/addons/education_base/models/__pycache__/department.cpython-310.pyc new file mode 100644 index 0000000..f839636 Binary files /dev/null and b/addons/education_base/models/__pycache__/department.cpython-310.pyc differ diff --git a/addons/education_base/models/__pycache__/education_config.cpython-310.pyc b/addons/education_base/models/__pycache__/education_config.cpython-310.pyc new file mode 100644 index 0000000..110c6b1 Binary files /dev/null and b/addons/education_base/models/__pycache__/education_config.cpython-310.pyc differ diff --git a/addons/education_base/models/__pycache__/program.cpython-310.pyc b/addons/education_base/models/__pycache__/program.cpython-310.pyc new file mode 100644 index 0000000..d0efa98 Binary files /dev/null and b/addons/education_base/models/__pycache__/program.cpython-310.pyc differ diff --git a/addons/education_base/models/academic_level.py b/addons/education_base/models/academic_level.py new file mode 100644 index 0000000..467838f --- /dev/null +++ b/addons/education_base/models/academic_level.py @@ -0,0 +1,35 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class AcademicLevel(models.Model): + _name = 'education.academic.level' + _description = 'Academic Level' + _order = 'sequence, name' + + name = fields.Char(required=True, string='Level Name') + code = fields.Char(required=True, string='Level Code', index=True) + sequence = fields.Integer(default=10) + description = fields.Text(string='Description') + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + _sql_constraints = [ + ('uniq_academic_level_code_company', 'unique(code, company_id)', 'Academic level code must be unique per company.'), + ] + + @api.constrains('name', 'company_id') + def _check_unique_name(self): + for rec in self: + if self.search_count([ + ('company_id', '=', rec.company_id.id), + ('name', '=ilike', rec.name), + ('id', '!=', rec.id), + ]): + raise ValidationError('Academic level name must be unique within a company.') diff --git a/addons/education_base/models/academic_term.py b/addons/education_base/models/academic_term.py new file mode 100644 index 0000000..ab5af34 --- /dev/null +++ b/addons/education_base/models/academic_term.py @@ -0,0 +1,65 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class AcademicTerm(models.Model): + _name = 'education.academic.term' + _description = 'Academic Term' + _order = 'sequence, start_date' + + name = fields.Char(required=True, string='Term Name') + code = fields.Char(string='Code', required=True, index=True) + academic_year_id = fields.Many2one( + 'education.academic.year', + string='Academic Year', + required=True, + ondelete='restrict', + index=True, + ) + start_date = fields.Date(string='Start Date', required=True) + end_date = fields.Date(string='End Date', required=True) + sequence = fields.Integer(default=1, required=True) + active = fields.Boolean(default=True) + state = fields.Selection([ + ('draft', 'Draft'), + ('active', 'Active'), + ('closed', 'Closed'), + ], default='draft', required=True) + company_id = fields.Many2one( + 'res.company', + related='academic_year_id.company_id', + store=True, + readonly=True, + index=True, + ) + + _sql_constraints = [ + ('uniq_term_code_year', 'unique(code, academic_year_id)', 'Term code must be unique within the academic year.'), + ('uniq_term_sequence_year', 'unique(sequence, academic_year_id)', 'Term sequence must be unique within the academic year.'), + ] + + @api.constrains('start_date', 'end_date') + def _check_term_dates(self): + for rec in self: + if rec.start_date and rec.end_date and rec.start_date > rec.end_date: + raise ValidationError('End date must be greater than start date.') + + @api.constrains('academic_year_id', 'start_date', 'end_date') + def _check_term_overlap(self): + for rec in self: + if not rec.academic_year_id: + continue + overlapping = self.search([ + ('id', '!=', rec.id), + ('academic_year_id', '=', rec.academic_year_id.id), + ('start_date', '<=', rec.end_date), + ('end_date', '>=', rec.start_date), + ]) + if overlapping: + raise ValidationError('Academic terms within the same year cannot overlap.') + + def action_activate(self): + self.write({'state': 'active'}) + + def action_close(self): + self.write({'state': 'closed'}) diff --git a/addons/education_base/models/academic_year.py b/addons/education_base/models/academic_year.py new file mode 100644 index 0000000..7965d17 --- /dev/null +++ b/addons/education_base/models/academic_year.py @@ -0,0 +1,69 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class AcademicYear(models.Model): + _name = 'education.academic.year' + _description = 'Academic Year' + _order = 'start_date desc, name desc' + + name = fields.Char(required=True, string='Academic Year') + code = fields.Char(string='Code', required=True, index=True) + start_date = fields.Date(string='Start Date', required=True) + end_date = fields.Date(string='End Date', required=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + current_year = fields.Boolean(string='Current Year', default=False) + state = fields.Selection([ + ('draft', 'Draft'), + ('active', 'Active'), + ('closed', 'Closed'), + ], default='draft', required=True, tracking=True) + term_ids = fields.One2many('education.academic.term', 'academic_year_id', string='Terms') + + _sql_constraints = [ + ('uniq_year_code_company', 'unique(code, company_id)', 'Academic year code must be unique per company.'), + ] + + @api.constrains('start_date', 'end_date') + def _check_year_dates(self): + for rec in self: + if rec.start_date and rec.end_date and rec.start_date > rec.end_date: + raise ValidationError('End date must be later than start date.') + + @api.constrains('current_year') + def _check_single_current_year(self): + for rec in self: + if rec.current_year: + existing = self.search([ + ('company_id', '=', rec.company_id.id), + ('current_year', '=', True), + ('id', '!=', rec.id), + ]) + if existing: + raise ValidationError('Only one academic year can be marked as current for a company.') + + @api.model + def create(self, vals): + if vals.get('state') == 'active': + vals['current_year'] = True + return super().create(vals) + + def action_activate(self): + for rec in self: + if rec.start_date and rec.end_date and rec.start_date > rec.end_date: + raise ValidationError('Academic year dates are invalid.') + rec.state = 'active' + rec.current_year = True + + def action_close(self): + self.write({'state': 'closed'}) + + def action_set_draft(self): + self.write({'state': 'draft'}) diff --git a/addons/education_base/models/campus.py b/addons/education_base/models/campus.py new file mode 100644 index 0000000..8cfaa70 --- /dev/null +++ b/addons/education_base/models/campus.py @@ -0,0 +1,52 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationCampus(models.Model): + _name = 'education.campus' + _description = 'Campus' + _order = 'name' + + name = fields.Char(required=True, string='Campus Name') + code = fields.Char(string='Campus Code', required=True, index=True) + logo = fields.Image(string='Campus Logo', max_width=1920, max_height=1920) + phone = fields.Char(string='Phone') + email = fields.Char(string='Email') + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + address_id = fields.Many2one('res.partner', string='Address') + department_ids = fields.One2many('education.department', 'campus_id', string='Departments') + + _sql_constraints = [ + ('uniq_campus_code_company', 'unique(code, company_id)', 'Campus code must be unique per company.'), + ] + + @api.constrains('name', 'company_id') + def _check_name_unique(self): + for rec in self: + similar = self.search([ + ('company_id', '=', rec.company_id.id), + ('name', '=ilike', rec.name), + ('id', '!=', rec.id), + ]) + if similar: + raise ValidationError('Campus name must be unique within a company.') + + @api.model + def create(self, vals): + if vals.get('code'): + vals['code'] = vals['code'].strip().upper() + return super().create(vals) + + def write(self, vals): + if 'code' in vals and vals.get('code'): + vals['code'] = vals['code'].strip().upper() + return super().write(vals) diff --git a/addons/education_base/models/department.py b/addons/education_base/models/department.py new file mode 100644 index 0000000..f345513 --- /dev/null +++ b/addons/education_base/models/department.py @@ -0,0 +1,50 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class EducationDepartment(models.Model): + _name = 'education.department' + _description = 'Department' + _order = 'name' + + name = fields.Char(required=True, string='Department Name') + code = fields.Char(string='Department Code', required=True, index=True) + campus_id = fields.Many2one( + 'education.campus', + string='Campus', + required=True, + ondelete='restrict', + index=True, + ) + company_id = fields.Many2one( + 'res.company', + related='campus_id.company_id', + store=True, + readonly=True, + index=True, + ) + head_teacher_id = fields.Many2one('res.partner', string='Head Teacher') + active = fields.Boolean(default=True) + program_ids = fields.One2many('education.program', 'department_id', string='Programs') + + _sql_constraints = [ + ('uniq_department_code_company', 'unique(code, company_id)', 'Department code must be unique per company.'), + ] + + @api.constrains('code') + def _check_code_format(self): + for rec in self: + if rec.code and not rec.code.strip(): + raise ValidationError('Department code cannot be blank.') + + @api.constrains('name', 'company_id') + def _check_name_unique(self): + for rec in self: + similar = self.search([ + ('company_id', '=', rec.company_id.id), + ('name', '=ilike', rec.name), + ('campus_id', '=', rec.campus_id.id), + ('id', '!=', rec.id), + ]) + if similar: + raise ValidationError('Department name must be unique within a campus.') diff --git a/addons/education_base/models/education_config.py b/addons/education_base/models/education_config.py new file mode 100644 index 0000000..b678335 --- /dev/null +++ b/addons/education_base/models/education_config.py @@ -0,0 +1,59 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class EducationConfiguration(models.Model): + _name = 'education.configuration' + _description = 'Education Configuration' + _rec_name = 'school_name' + + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + school_name = fields.Char(string='School Name', required=True) + school_code = fields.Char(string='School Code', required=True) + default_campus_id = fields.Many2one('education.campus', string='Default Campus') + default_academic_year_id = fields.Many2one('education.academic.year', string='Default Academic Year') + default_term_id = fields.Many2one('education.academic.term', string='Default Term') + default_currency_id = fields.Many2one('res.currency', string='Default Currency') + logo = fields.Binary(string='Logo') + contact_email = fields.Char(string='Contact Email') + contact_phone = fields.Char(string='Contact Phone') + active = fields.Boolean(default=True) + + _sql_constraints = [ + ('uniq_company_config', 'unique(company_id)', 'Only one education configuration can exist per company.'), + ] + + @api.constrains('default_campus_id', 'company_id') + def _check_default_campus_in_company(self): + for rec in self: + if rec.default_campus_id and rec.default_campus_id.company_id != rec.company_id: + raise ValidationError('Default campus must belong to the same company.') + + @api.constrains('default_academic_year_id', 'company_id') + def _check_default_academic_year_in_company(self): + for rec in self: + if rec.default_academic_year_id and rec.default_academic_year_id.company_id != rec.company_id: + raise ValidationError('Default academic year must belong to the same company.') + + @api.constrains('default_term_id', 'company_id') + def _check_default_term_in_company(self): + for rec in self: + if rec.default_term_id and rec.default_term_id.company_id != rec.company_id: + raise ValidationError('Default term must belong to the same company.') + + @api.model + def get_default_values(self): + config = self.search([('company_id', '=', self.env.company.id)], limit=1) + if config: + return config + return self.create({ + 'company_id': self.env.company.id, + 'school_name': self.env.company.name, + 'school_code': self.env.company.name[:5].upper(), + }) diff --git a/addons/education_base/models/program.py b/addons/education_base/models/program.py new file mode 100644 index 0000000..8f95bcb --- /dev/null +++ b/addons/education_base/models/program.py @@ -0,0 +1,67 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class EducationProgram(models.Model): + _name = 'education.program' + _description = 'Program' + _order = 'name' + + name = fields.Char(required=True, string='Program Name') + code = fields.Char(required=True, string='Program Code', index=True) + level_id = fields.Many2one( + 'education.academic.level', + string='Academic Level', + required=True, + ondelete='restrict', + index=True, + ) + department_id = fields.Many2one( + 'education.department', + string='Department', + required=True, + ondelete='restrict', + index=True, + ) + company_id = fields.Many2one( + 'res.company', + related='department_id.company_id', + store=True, + readonly=True, + index=True, + ) + duration = fields.Integer(string='Duration', default=1) + duration_unit = fields.Selection([ + ('years', 'Years'), + ('months', 'Months'), + ('semesters', 'Semesters'), + ], default='years', required=True) + active = fields.Boolean(default=True) + description = fields.Text(string='Description') + + _sql_constraints = [ + ('uniq_program_code_company', 'unique(code, company_id)', 'Program code must be unique per company.'), + ] + + @api.constrains('duration') + def _check_duration(self): + for rec in self: + if rec.duration <= 0: + raise ValidationError('Program duration must be greater than zero.') + + @api.constrains('name', 'company_id') + def _check_name_unique(self): + for rec in self: + if self.search_count([ + ('company_id', '=', rec.company_id.id), + ('name', '=ilike', rec.name), + ('id', '!=', rec.id), + ]): + raise ValidationError('Program name must be unique within a company.') + + @api.onchange('department_id') + def _onchange_department_id(self): + if self.department_id: + self.company_id = self.department_id.company_id.id + if self.level_id and self.level_id.company_id != self.company_id: + self.level_id = False diff --git a/addons/education_base/security/education_base_security.xml b/addons/education_base/security/education_base_security.xml new file mode 100644 index 0000000..44c6749 --- /dev/null +++ b/addons/education_base/security/education_base_security.xml @@ -0,0 +1,27 @@ + + + + Education Base + 10 + + + + Education Base User + + Basic access to education master data. + + + + Education Base Manager + + + Manage academic configuration and master setup. + + + + Education Base Admin + + + Full administrative access to base education configuration. + + diff --git a/addons/education_base/security/ir.model.access.csv b/addons/education_base/security/ir.model.access.csv new file mode 100644 index 0000000..55a0b70 --- /dev/null +++ b/addons/education_base/security/ir.model.access.csv @@ -0,0 +1,28 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_education_academic_year_user,education.academic.year user,model_education_academic_year,group_education_base_user,1,0,0,0 +access_education_academic_year_manager,education.academic.year manager,model_education_academic_year,group_education_base_manager,1,1,1,1 +access_education_academic_year_admin,education.academic.year admin,model_education_academic_year,group_education_base_admin,1,1,1,1 + +access_education_academic_term_user,education.academic.term user,model_education_academic_term,group_education_base_user,1,0,0,0 +access_education_academic_term_manager,education.academic.term manager,model_education_academic_term,group_education_base_manager,1,1,1,1 +access_education_academic_term_admin,education.academic.term admin,model_education_academic_term,group_education_base_admin,1,1,1,1 + +access_education_campus_user,education.campus user,model_education_campus,group_education_base_user,1,0,0,0 +access_education_campus_manager,education.campus manager,model_education_campus,group_education_base_manager,1,1,1,1 +access_education_campus_admin,education.campus admin,model_education_campus,group_education_base_admin,1,1,1,1 + +access_education_department_user,education.department user,model_education_department,group_education_base_user,1,0,0,0 +access_education_department_manager,education.department manager,model_education_department,group_education_base_manager,1,1,1,1 +access_education_department_admin,education.department admin,model_education_department,group_education_base_admin,1,1,1,1 + +access_education_academic_level_user,education.academic.level user,model_education_academic_level,group_education_base_user,1,0,0,0 +access_education_academic_level_manager,education.academic.level manager,model_education_academic_level,group_education_base_manager,1,1,1,1 +access_education_academic_level_admin,education.academic.level admin,model_education_academic_level,group_education_base_admin,1,1,1,1 + +access_education_program_user,education.program user,model_education_program,group_education_base_user,1,0,0,0 +access_education_program_manager,education.program manager,model_education_program,group_education_base_manager,1,1,1,1 +access_education_program_admin,education.program admin,model_education_program,group_education_base_admin,1,1,1,1 + +access_education_configuration_user,education.configuration user,model_education_configuration,group_education_base_user,1,0,0,0 +access_education_configuration_manager,education.configuration manager,model_education_configuration,group_education_base_manager,1,1,1,1 +access_education_configuration_admin,education.configuration admin,model_education_configuration,group_education_base_admin,1,1,1,1 diff --git a/addons/education_base/security/record_rules.xml b/addons/education_base/security/record_rules.xml new file mode 100644 index 0000000..612adc8 --- /dev/null +++ b/addons/education_base/security/record_rules.xml @@ -0,0 +1,51 @@ + + + + Academic Year Company Rule + + [('company_id', '=', user.company_id.id)] + + + + + Academic Term Company Rule + + [('company_id', '=', user.company_id.id)] + + + + + Campus Company Rule + + [('company_id', '=', user.company_id.id)] + + + + + Department Company Rule + + [('company_id', '=', user.company_id.id)] + + + + + Academic Level Company Rule + + [('company_id', '=', user.company_id.id)] + + + + + Program Company Rule + + [('company_id', '=', user.company_id.id)] + + + + + Configuration Company Rule + + [('company_id', '=', user.company_id.id)] + + + diff --git a/addons/education_base/tests/__init__.py b/addons/education_base/tests/__init__.py new file mode 100644 index 0000000..65c643f --- /dev/null +++ b/addons/education_base/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_academic_year +from . import test_campus +from . import test_program +from . import test_security diff --git a/addons/education_base/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_base/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..9d3071d Binary files /dev/null and b/addons/education_base/tests/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_base/tests/__pycache__/test_academic_year.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_academic_year.cpython-310.pyc new file mode 100644 index 0000000..28348d8 Binary files /dev/null and b/addons/education_base/tests/__pycache__/test_academic_year.cpython-310.pyc differ diff --git a/addons/education_base/tests/__pycache__/test_campus.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_campus.cpython-310.pyc new file mode 100644 index 0000000..591d717 Binary files /dev/null and b/addons/education_base/tests/__pycache__/test_campus.cpython-310.pyc differ diff --git a/addons/education_base/tests/__pycache__/test_program.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_program.cpython-310.pyc new file mode 100644 index 0000000..289e518 Binary files /dev/null and b/addons/education_base/tests/__pycache__/test_program.cpython-310.pyc differ diff --git a/addons/education_base/tests/__pycache__/test_security.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_security.cpython-310.pyc new file mode 100644 index 0000000..59959a9 Binary files /dev/null and b/addons/education_base/tests/__pycache__/test_security.cpython-310.pyc differ diff --git a/addons/education_base/tests/test_academic_year.py b/addons/education_base/tests/test_academic_year.py new file mode 100644 index 0000000..ae7ab8c --- /dev/null +++ b/addons/education_base/tests/test_academic_year.py @@ -0,0 +1,39 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import ValidationError + + +class TestAcademicYear(TransactionCase): + + def setUp(self): + super().setUp() + self.company = self.env.company + + def test_academic_year_date_validation(self): + with self.assertRaises(ValidationError): + self.env['education.academic.year'].create({ + 'name': '2025-2026', + 'code': 'AY2025', + 'start_date': '2026-06-01', + 'end_date': '2025-06-01', + 'company_id': self.company.id, + }) + + def test_single_current_year_per_company(self): + self.env['education.academic.year'].create({ + 'name': '2024-2025', + 'code': 'AY2024', + 'start_date': '2024-06-01', + 'end_date': '2025-06-01', + 'company_id': self.company.id, + 'current_year': True, + }) + + with self.assertRaises(ValidationError): + self.env['education.academic.year'].create({ + 'name': '2025-2026', + 'code': 'AY2025', + 'start_date': '2025-06-01', + 'end_date': '2026-06-01', + 'company_id': self.company.id, + 'current_year': True, + }) diff --git a/addons/education_base/tests/test_campus.py b/addons/education_base/tests/test_campus.py new file mode 100644 index 0000000..71f79bc --- /dev/null +++ b/addons/education_base/tests/test_campus.py @@ -0,0 +1,23 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import ValidationError + + +class TestCampus(TransactionCase): + + def setUp(self): + super().setUp() + self.company = self.env.company + + def test_campus_unique_code(self): + self.env['education.campus'].create({ + 'name': 'Main Campus', + 'code': 'CAMP01', + 'company_id': self.company.id, + }) + + with self.assertRaises(ValidationError): + self.env['education.campus'].create({ + 'name': 'Another Campus', + 'code': 'CAMP01', + 'company_id': self.company.id, + }) diff --git a/addons/education_base/tests/test_program.py b/addons/education_base/tests/test_program.py new file mode 100644 index 0000000..c47b7c0 --- /dev/null +++ b/addons/education_base/tests/test_program.py @@ -0,0 +1,35 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import ValidationError + + +class TestProgram(TransactionCase): + + def setUp(self): + super().setUp() + self.company = self.env.company + self.campus = self.env['education.campus'].create({ + 'name': 'North Campus', + 'code': 'NCAMP', + 'company_id': self.company.id, + }) + self.department = self.env['education.department'].create({ + 'name': 'Engineering', + 'code': 'ENG', + 'campus_id': self.campus.id, + }) + self.level = self.env['education.academic.level'].create({ + 'name': 'Undergraduate', + 'code': 'UG', + 'company_id': self.company.id, + }) + + def test_program_duration(self): + with self.assertRaises(ValidationError): + self.env['education.program'].create({ + 'name': 'Engineering Degree', + 'code': 'ENGD', + 'level_id': self.level.id, + 'department_id': self.department.id, + 'duration': 0, + 'duration_unit': 'years', + }) diff --git a/addons/education_base/tests/test_security.py b/addons/education_base/tests/test_security.py new file mode 100644 index 0000000..200f92b --- /dev/null +++ b/addons/education_base/tests/test_security.py @@ -0,0 +1,13 @@ +from odoo.tests.common import TransactionCase + + +class TestSecurity(TransactionCase): + + def test_base_user_access(self): + user = self.env['res.users'].create({ + 'name': 'Education User', + 'login': 'edu_user_test_001', + 'email': 'edu_user_test_001@example.com', + 'groups_id': [(6, 0, [self.env.ref('education_base.group_education_base_user').id])], + }) + self.assertTrue(user) diff --git a/addons/education_base/views/academic_level_views.xml b/addons/education_base/views/academic_level_views.xml new file mode 100644 index 0000000..51e67a0 --- /dev/null +++ b/addons/education_base/views/academic_level_views.xml @@ -0,0 +1,61 @@ + + + + education.academic.level.tree + education.academic.level + + + + + + + + + + + + + education.academic.level.form + education.academic.level + +
+ + + + + + + + + + + + + + + +
+
+
+ + + education.academic.level.search + education.academic.level + + + + + + + + + + + + + Academic Levels + education.academic.level + tree,form + + +
diff --git a/addons/education_base/views/academic_term_views.xml b/addons/education_base/views/academic_term_views.xml new file mode 100644 index 0000000..9c3b90e --- /dev/null +++ b/addons/education_base/views/academic_term_views.xml @@ -0,0 +1,72 @@ + + + + education.academic.term.tree + education.academic.term + + + + + + + + + + + + + + + + education.academic.term.form + education.academic.term + +
+
+
+ + + + + + + + + + + + + + + +
+
+
+ + + education.academic.term.search + education.academic.term + + + + + + + + + + + + + + + + Academic Terms + education.academic.term + tree,form + + +
diff --git a/addons/education_base/views/academic_year_views.xml b/addons/education_base/views/academic_year_views.xml new file mode 100644 index 0000000..88ad0b6 --- /dev/null +++ b/addons/education_base/views/academic_year_views.xml @@ -0,0 +1,85 @@ + + + + education.academic.year.tree + education.academic.year + + + + + + + + + + + + + + + education.academic.year.form + education.academic.year + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + education.academic.year.search + education.academic.year + + + + + + + + + + + + + + + + Academic Years + education.academic.year + tree,form + + +
diff --git a/addons/education_base/views/campus_views.xml b/addons/education_base/views/campus_views.xml new file mode 100644 index 0000000..0708fcc --- /dev/null +++ b/addons/education_base/views/campus_views.xml @@ -0,0 +1,73 @@ + + + + education.campus.tree + education.campus + + + + + + + + + + + + + education.campus.form + education.campus + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + education.campus.search + education.campus + + + + + + + + + + + + + + Campuses + education.campus + tree,form + + +
diff --git a/addons/education_base/views/department_views.xml b/addons/education_base/views/department_views.xml new file mode 100644 index 0000000..208859c --- /dev/null +++ b/addons/education_base/views/department_views.xml @@ -0,0 +1,72 @@ + + + + education.department.tree + education.department + + + + + + + + + + + + + education.department.form + education.department + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + education.department.search + education.department + + + + + + + + + + + + + + + Departments + education.department + tree,form + + +
diff --git a/addons/education_base/views/education_config_views.xml b/addons/education_base/views/education_config_views.xml new file mode 100644 index 0000000..b80fa9c --- /dev/null +++ b/addons/education_base/views/education_config_views.xml @@ -0,0 +1,70 @@ + + + + education.configuration.tree + education.configuration + + + + + + + + + + + + + education.configuration.form + education.configuration + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + education.configuration.search + education.configuration + + + + + + + + + + + + + Education Configuration + education.configuration + tree,form + + +
diff --git a/addons/education_base/views/menuitems.xml b/addons/education_base/views/menuitems.xml new file mode 100644 index 0000000..cc72fa5 --- /dev/null +++ b/addons/education_base/views/menuitems.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/addons/education_base/views/program_views.xml b/addons/education_base/views/program_views.xml new file mode 100644 index 0000000..3d1ef63 --- /dev/null +++ b/addons/education_base/views/program_views.xml @@ -0,0 +1,70 @@ + + + + education.program.tree + education.program + + + + + + + + + + + + + + + education.program.form + education.program + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + education.program.search + education.program + + + + + + + + + + + + + + + + + Programs + education.program + tree,form + + +
diff --git a/addons/education_dashboard/__init__.py b/addons/education_dashboard/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/education_dashboard/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/education_dashboard/__manifest__.py b/addons/education_dashboard/__manifest__.py new file mode 100644 index 0000000..708432d --- /dev/null +++ b/addons/education_dashboard/__manifest__.py @@ -0,0 +1,36 @@ +{ + 'name': 'Education Dashboard', + 'version': '17.0.1.0.0', + 'category': 'Education', + 'summary': 'Interactive Management Dashboard for Education ERP', + 'description': """ + Executive management dashboard providing: + - Student KPIs: totals, active enrollment, admissions, graduation rates + - Finance KPIs: total fees, collections, outstanding dues + - Academic KPIs: program counts, departments, campuses + - Dynamic interactive Chart.js widgets (student distribution, fee collections) + - Dynamic filters (Campus, Department, Program, Academic Year, Semester) + - Dynamic drill-down actions directly linking to filtered records + """, + 'author': 'Senior Solution Architect', + 'depends': [ + 'education_base', + 'education_student', + 'education_finance', + 'education_attendance', + ], + 'data': [ + 'security/ir.model.access.csv', + 'views/dashboard_views.xml', + ], + 'assets': { + 'web.assets_backend': [ + '/education_dashboard/static/src/js/education_dashboard.js', + '/education_dashboard/static/src/xml/education_dashboard_templates.xml', + ], + }, + 'installable': True, + 'application': True, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/addons/education_dashboard/__pycache__/__init__.cpython-310.pyc b/addons/education_dashboard/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..9a25cad Binary files /dev/null and b/addons/education_dashboard/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_dashboard/__pycache__/__manifest__.cpython-310.pyc b/addons/education_dashboard/__pycache__/__manifest__.cpython-310.pyc new file mode 100644 index 0000000..6de4951 Binary files /dev/null and b/addons/education_dashboard/__pycache__/__manifest__.cpython-310.pyc differ diff --git a/addons/education_dashboard/controllers/__init__.py b/addons/education_dashboard/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/education_dashboard/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/education_dashboard/controllers/__pycache__/__init__.cpython-310.pyc b/addons/education_dashboard/controllers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..1063659 Binary files /dev/null and b/addons/education_dashboard/controllers/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_dashboard/controllers/__pycache__/main.cpython-310.pyc b/addons/education_dashboard/controllers/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..fb07f5f Binary files /dev/null and b/addons/education_dashboard/controllers/__pycache__/main.cpython-310.pyc differ diff --git a/addons/education_dashboard/controllers/main.py b/addons/education_dashboard/controllers/main.py new file mode 100644 index 0000000..0337102 --- /dev/null +++ b/addons/education_dashboard/controllers/main.py @@ -0,0 +1,104 @@ +# pyrefly: ignore [missing-import] +from odoo import http +# pyrefly: ignore [missing-import] +from odoo.http import request + + +class EducationDashboardController(http.Controller): + + @http.route(['/web/education/dashboard/kpis'], type='json', auth="user") + def get_dashboard_kpis(self, campus_id=None, program_id=None, academic_year_id=None, semester_id=None, env=None, **kw): + env = env or request.env + # Base Student Domain + student_domain = [] + payment_domain = [('status', '=', 'posted')] + + # Apply Filters + if campus_id: + student_domain.append(('campus_id', '=', int(campus_id))) + payment_domain.append(('student_id.campus_id', '=', int(campus_id))) + if program_id: + student_domain.append(('program_id', '=', int(program_id))) + payment_domain.append(('student_id.program_id', '=', int(program_id))) + if academic_year_id: + student_domain.append(('academic_year_id', '=', int(academic_year_id))) + payment_domain.append(('student_id.academic_year_id', '=', int(academic_year_id))) + if semester_id: + student_domain.append(('semester_id', '=', int(semester_id))) + payment_domain.append(('student_id.semester_id', '=', int(semester_id))) + + # Fetch Student Records + students = env['education.student'].search(student_domain) + + # Executive KPIs + total_students = len(students) + active_students = len(students.filtered(lambda s: s.status == 'active')) + admissions = len(students.filtered(lambda s: s.status == 'admitted')) + graduates = len(students.filtered(lambda s: s.status == 'graduated')) + withdrawn = len(students.filtered(lambda s: s.status == 'withdrawn')) + + # Finance KPIs + total_fees = sum(students.mapped('total_fees')) + collected_fees = sum(students.mapped('total_paid')) + outstanding_fees = sum(students.mapped('outstanding_amount')) + + # Academic KPIs + programs_count = env['education.program'].search_count([]) + dept_count = env['education.department'].search_count([]) + campus_count = env['education.campus'].search_count([]) + years_count = env['education.academic.year'].search_count([]) + + # Charts: Program Distribution (using read_group for speed) + program_data = env['education.student'].read_group( + student_domain, ['program_id'], ['program_id'] + ) + program_chart = [] + for gd in program_data: + if gd.get('program_id'): + program_chart.append({ + 'name': gd['program_id'][1], + 'count': gd['program_id_count'], + }) + + # Charts: Campus Distribution + campus_data = env['education.student'].read_group( + student_domain, ['campus_id'], ['campus_id'] + ) + campus_chart = [] + for gd in campus_data: + if gd.get('campus_id'): + campus_chart.append({ + 'name': gd['campus_id'][1], + 'count': gd['campus_id_count'], + }) + + # Master Data lists for Filters + campuses = [{'id': c.id, 'name': c.name} for c in env['education.campus'].search([])] + programs = [{'id': p.id, 'name': p.name} for p in env['education.program'].search([])] + years = [{'id': y.id, 'name': y.name} for y in env['education.academic.year'].search([])] + semesters = [{'id': s.id, 'name': s.name} for s in env['education.semester'].search([])] + + return { + 'total_students': total_students, + 'active_students': active_students, + 'admissions': admissions, + 'graduates': graduates, + 'withdrawn': withdrawn, + + 'total_fees': total_fees, + 'collected_fees': collected_fees, + 'outstanding_fees': outstanding_fees, + + 'programs_count': programs_count, + 'dept_count': dept_count, + 'campus_count': campus_count, + 'years_count': years_count, + + 'program_chart': program_chart, + 'campus_chart': campus_chart, + + 'campuses': campuses, + 'programs': programs, + 'years': years, + 'semesters': semesters, + } diff --git a/addons/education_dashboard/models/__init__.py b/addons/education_dashboard/models/__init__.py new file mode 100644 index 0000000..3c786ed --- /dev/null +++ b/addons/education_dashboard/models/__init__.py @@ -0,0 +1 @@ +from . import dashboard diff --git a/addons/education_dashboard/models/__pycache__/__init__.cpython-310.pyc b/addons/education_dashboard/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..4870529 Binary files /dev/null and b/addons/education_dashboard/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_dashboard/models/__pycache__/dashboard.cpython-310.pyc b/addons/education_dashboard/models/__pycache__/dashboard.cpython-310.pyc new file mode 100644 index 0000000..3d799b3 Binary files /dev/null and b/addons/education_dashboard/models/__pycache__/dashboard.cpython-310.pyc differ diff --git a/addons/education_dashboard/models/dashboard.py b/addons/education_dashboard/models/dashboard.py new file mode 100644 index 0000000..2d95509 --- /dev/null +++ b/addons/education_dashboard/models/dashboard.py @@ -0,0 +1,7 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models + + +class EducationDashboard(models.TransientModel): + _name = 'education.dashboard' + _description = 'Education Dashboard Helper' diff --git a/addons/education_dashboard/security/ir.model.access.csv b/addons/education_dashboard/security/ir.model.access.csv new file mode 100644 index 0000000..2002a75 --- /dev/null +++ b/addons/education_dashboard/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_education_dashboard,Education Dashboard,model_education_dashboard,base.group_user,1,1,1,1 diff --git a/addons/education_dashboard/static/src/js/education_dashboard.js b/addons/education_dashboard/static/src/js/education_dashboard.js new file mode 100644 index 0000000..d7e840a --- /dev/null +++ b/addons/education_dashboard/static/src/js/education_dashboard.js @@ -0,0 +1,137 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { Component, onWillStart, onMounted, useState, useRef } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; +import { loadJS } from "@web/core/assets"; + +class EducationDashboard extends Component { + setup() { + this.rpc = useService("rpc"); + this.actionService = useService("action"); + this.state = useState({ + kpis: {}, + filters: { + campus_id: "", + program_id: "", + academic_year_id: "", + semester_id: "" + } + }); + + this.programChartRef = useRef("programChart"); + this.campusChartRef = useRef("campusChart"); + + onWillStart(async () => { + await loadJS("/web/static/lib/Chart/Chart.js"); + await this.loadData(); + }); + + onMounted(() => { + this.renderCharts(); + }); + } + + async loadData() { + // Map empty string filters to null + const params = {}; + for (const [k, v] of Object.entries(this.state.filters)) { + params[k] = v ? parseInt(v) : null; + } + const data = await this.rpc("/web/education/dashboard/kpis", params); + this.state.kpis = data; + } + + async onFilterChange(filterKey, ev) { + this.state.filters[filterKey] = ev.target.value; + await this.loadData(); + this.renderCharts(); + } + + renderCharts() { + const pCtx = this.programChartRef.el; + if (pCtx) { + if (this.pChart) this.pChart.destroy(); + const labels = this.state.kpis.program_chart.map(x => x.name); + const data = this.state.kpis.program_chart.map(x => x.count); + this.pChart = new Chart(pCtx, { + type: 'bar', + data: { + labels: labels, + datasets: [{ + label: 'Students Count', + data: data, + backgroundColor: '#4F81BD' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + ticks: { stepSize: 1 } + } + } + } + }); + } + + const cCtx = this.campusChartRef.el; + if (cCtx) { + if (this.cChart) this.cChart.destroy(); + const labels = this.state.kpis.campus_chart.map(x => x.name); + const data = this.state.kpis.campus_chart.map(x => x.count); + this.cChart = new Chart(cCtx, { + type: 'doughnut', + data: { + labels: labels, + datasets: [{ + data: data, + backgroundColor: ['#4F81BD', '#C0504D', '#9BBB59', '#8064A2', '#4BACC6'] + }] + }, + options: { + responsive: true, + maintainAspectRatio: false + } + }); + } + } + + openStudents(status) { + const domain = []; + if (status) { + domain.push(['status', '=', status]); + } + if (this.state.filters.campus_id) { + domain.push(['campus_id', '=', parseInt(this.state.filters.campus_id)]); + } + if (this.state.filters.program_id) { + domain.push(['program_id', '=', parseInt(this.state.filters.program_id)]); + } + + this.actionService.doAction({ + name: "Students List", + type: "ir.actions.act_window", + res_model: "education.student", + views: [[false, "list"], [false, "form"]], + domain: domain, + target: "current" + }); + } + + openPrograms() { + this.actionService.doAction({ + name: "Active Programs", + type: "ir.actions.act_window", + res_model: "education.program", + views: [[false, "list"], [false, "form"]], + target: "current" + }); + } +} + +EducationDashboard.template = "education_dashboard.DashboardMain"; +registry.category("actions").add("education_dashboard_main_tag", EducationDashboard); +export { EducationDashboard }; diff --git a/addons/education_dashboard/static/src/xml/education_dashboard_templates.xml b/addons/education_dashboard/static/src/xml/education_dashboard_templates.xml new file mode 100644 index 0000000..ffc32d6 --- /dev/null +++ b/addons/education_dashboard/static/src/xml/education_dashboard_templates.xml @@ -0,0 +1,172 @@ + + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
Total Students
+

+
+ +
+
+
+
+
+
+
+
+
+
Active Students
+

+
+ +
+
+
+
+
+
+
+
+
+
Admissions
+

+
+ +
+
+
+
+
+
+
+
+
+
Graduates
+

+
+ +
+
+
+
+
+ + +
+
+
+
Total Fees Assigned
+

+
+
+
+
+
Collected Fees
+

+
+
+
+
+
Outstanding Fees
+

+
+
+
+ + +
+
+
+
Program-wise Distribution
+
+ +
+
+
+
+
+
Campus Distribution
+
+ +
+
+
+
+ + +
+
+
+
Programs
+

+
+
+
+
+
Departments
+

+
+
+
+
+
Campuses
+

+
+
+
+
+
Academic Years
+

+
+
+
+
+
+
diff --git a/addons/education_dashboard/tests/__init__.py b/addons/education_dashboard/tests/__init__.py new file mode 100644 index 0000000..b1b40f5 --- /dev/null +++ b/addons/education_dashboard/tests/__init__.py @@ -0,0 +1 @@ +from . import test_dashboard diff --git a/addons/education_dashboard/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_dashboard/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..eff2b80 Binary files /dev/null and b/addons/education_dashboard/tests/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_dashboard/tests/__pycache__/test_dashboard.cpython-310.pyc b/addons/education_dashboard/tests/__pycache__/test_dashboard.cpython-310.pyc new file mode 100644 index 0000000..3a7465d Binary files /dev/null and b/addons/education_dashboard/tests/__pycache__/test_dashboard.cpython-310.pyc differ diff --git a/addons/education_dashboard/tests/test_dashboard.py b/addons/education_dashboard/tests/test_dashboard.py new file mode 100644 index 0000000..373015b --- /dev/null +++ b/addons/education_dashboard/tests/test_dashboard.py @@ -0,0 +1,63 @@ +# pyrefly: ignore [missing-import] +from odoo.tests.common import TransactionCase +# pyrefly: ignore [missing-import] +from odoo.addons.education_dashboard.controllers.main import EducationDashboardController + + +class TestEducationDashboard(TransactionCase): + + def setUp(self): + super(TestEducationDashboard, self).setUp() + + self.controller = EducationDashboardController() + + # Setup master records + self.campus = self.env['education.campus'].create({'name': 'Dash Campus', 'code': 'DC'}) + self.department = self.env['education.department'].create({'name': 'Dash Dept', 'code': 'DD', 'campus_id': self.campus.id}) + self.level = self.env['education.academic.level'].create({'name': 'Dash Level', 'code': 'DL'}) + self.program = self.env['education.program'].create({'name': 'Dash Prog', 'code': 'DP', 'department_id': self.department.id, 'level_id': self.level.id}) + self.year = self.env['education.academic.year'].create({'name': 'Dash Year', 'code': 'DY', 'start_date': '2026-01-01', 'end_date': '2026-12-31'}) + self.term = self.env['education.academic.term'].create({'name': 'Dash Term', 'code': 'DT', 'academic_year_id': self.year.id, 'start_date': '2026-01-01', 'end_date': '2026-06-30'}) + self.batch = self.env['education.batch'].create({'name': 'Dash Batch', 'code': 'DB'}) + self.semester = self.env['education.semester'].create({'name': 'Dash Sem', 'code': 'DS'}) + self.section = self.env['education.section'].create({'name': 'Dash Sec', 'code': 'DSEC'}) + + # Student + self.student = self.env['education.student'].create({ + 'first_name': 'Dashboard', + 'last_name': 'TestStudent', + 'student_id': 'DASHSTU1', + 'admission_number': 'DASHADM1', + 'roll_number': 'DASHRL1', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.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.id, + 'status': 'active', + }) + + def test_dashboard_controller_kpis(self): + """Test that dashboard JSON controller returns valid and accurate stats""" + + # Test default/no filter KPIs + kpi_data = self.controller.get_dashboard_kpis(env=self.env) + + self.assertGreaterEqual(kpi_data['total_students'], 1) + self.assertGreaterEqual(kpi_data['active_students'], 1) + self.assertEqual(kpi_data['admissions'], 0) + self.assertGreaterEqual(kpi_data['years_count'], 1) + self.assertGreaterEqual(kpi_data['programs_count'], 1) + + # Test filtering by specific campus + kpi_filtered = self.controller.get_dashboard_kpis(campus_id=self.campus.id, env=self.env) + self.assertEqual(kpi_filtered['total_students'], 1) + + # Test filtering by dummy campus (should return 0) + kpi_empty = self.controller.get_dashboard_kpis(campus_id=9999, env=self.env) + self.xlsx_file = None # or anything + self.assertEqual(kpi_empty['total_students'], 0) diff --git a/addons/education_dashboard/views/dashboard_views.xml b/addons/education_dashboard/views/dashboard_views.xml new file mode 100644 index 0000000..8c8074a --- /dev/null +++ b/addons/education_dashboard/views/dashboard_views.xml @@ -0,0 +1,16 @@ + + + + + Management Dashboard + education_dashboard_main_tag + + + + + diff --git a/addons/education_exam/__init__.py b/addons/education_exam/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/education_exam/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/education_exam/__manifest__.py b/addons/education_exam/__manifest__.py new file mode 100644 index 0000000..23d5bfb --- /dev/null +++ b/addons/education_exam/__manifest__.py @@ -0,0 +1,33 @@ +{ + 'name': 'Education Examinations', + 'version': '1.0', + 'category': 'Education', + 'summary': 'Complete College Examination Module', + 'description': """ + Odoo Education Examination Module. + Features: + - Exam scheduling + - Grading systems + - Marks entry + - Portal integration for students to view results and schedule. + """, + 'author': 'Antigravity', + 'website': 'https://www.example.com', + 'depends': ['education_base', 'education_student', 'education_portal', 'portal'], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'data/grading_data.xml', + 'views/education_grading_views.xml', + 'views/education_subject_views.xml', + 'views/education_exam_views.xml', + 'views/education_exam_result_views.xml', + 'views/education_student_inherit_views.xml', + 'views/portal_exam_templates.xml', + 'views/menu_views.xml', + ], + 'installable': True, + 'application': False, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/addons/education_exam/__pycache__/__init__.cpython-310.pyc b/addons/education_exam/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..2102618 Binary files /dev/null and b/addons/education_exam/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_exam/controllers/__init__.py b/addons/education_exam/controllers/__init__.py new file mode 100644 index 0000000..ce03e16 --- /dev/null +++ b/addons/education_exam/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal_exam diff --git a/addons/education_exam/controllers/__pycache__/__init__.cpython-310.pyc b/addons/education_exam/controllers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..6b97d05 Binary files /dev/null and b/addons/education_exam/controllers/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_exam/controllers/__pycache__/portal_exam.cpython-310.pyc b/addons/education_exam/controllers/__pycache__/portal_exam.cpython-310.pyc new file mode 100644 index 0000000..40cfe12 Binary files /dev/null and b/addons/education_exam/controllers/__pycache__/portal_exam.cpython-310.pyc differ diff --git a/addons/education_exam/controllers/portal_exam.py b/addons/education_exam/controllers/portal_exam.py new file mode 100644 index 0000000..3576b90 --- /dev/null +++ b/addons/education_exam/controllers/portal_exam.py @@ -0,0 +1,107 @@ +from odoo import http, _ +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager + +class ExamPortal(CustomerPortal): + + def _prepare_home_portal_values(self, counters): + values = super()._prepare_home_portal_values(counters) + student = request.env['education.student'].sudo().search([('portal_user_id', '=', request.env.user.id)], limit=1) + if student: + if 'exam_count' in counters: + exam_count = request.env['education.exam'].sudo().search_count([ + ('program_id', '=', student.program_id.id), + ('term_id', '=', student.academic_term_id.id), + ('state', 'in', ['scheduled', 'ongoing']) + ]) + values['exam_count'] = exam_count + + if 'result_count' in counters: + result_count = request.env['education.exam.result'].sudo().search_count([ + ('student_id', '=', student.id), + ('published', '=', True) + ]) + values['result_count'] = result_count + return values + + @http.route(['/my/exams', '/my/exams/page/'], type='http', auth="user", website=True) + def portal_my_exams(self, page=1, **kw): + student = request.env['education.student'].sudo().search([('portal_user_id', '=', request.env.user.id)], limit=1) + if not student: + return request.redirect('/my') + + domain = [ + ('program_id', '=', student.program_id.id), + ('term_id', '=', student.academic_term_id.id), + ('state', 'in', ['scheduled', 'ongoing']) + ] + + exam_count = request.env['education.exam'].sudo().search_count(domain) + pager = portal_pager( + url="/my/exams", + total=exam_count, + page=page, + step=10 + ) + exams = request.env['education.exam'].sudo().search(domain, limit=10, offset=pager['offset'], order='start_date desc') + + values = { + 'exams': exams, + 'page_name': 'exams', + 'pager': pager, + 'default_url': '/my/exams', + 'student': student, + } + return request.render("education_exam.portal_my_exams", values) + + @http.route(['/my/results', '/my/results/page/'], type='http', auth="user", website=True) + def portal_my_results(self, page=1, **kw): + student = request.env['education.student'].sudo().search([('portal_user_id', '=', request.env.user.id)], limit=1) + if not student: + return request.redirect('/my') + + domain = [ + ('student_id', '=', student.id), + ('published', '=', True) + ] + + result_count = request.env['education.exam.result'].sudo().search_count(domain) + pager = portal_pager( + url="/my/results", + total=result_count, + page=page, + step=10 + ) + results = request.env['education.exam.result'].sudo().search(domain, limit=10, offset=pager['offset']) + + values = { + 'results': results, + 'page_name': 'results', + 'pager': pager, + 'default_url': '/my/results', + 'student': student, + } + return request.render("education_exam.portal_my_results", values) + + @http.route(['/my/results/'], type='http', auth="user", website=True) + def portal_my_result_detail(self, result_id, **kw): + student = request.env['education.student'].sudo().search([('portal_user_id', '=', request.env.user.id)], limit=1) + if not student: + return request.redirect('/my') + + # Ensure the result belongs to the logged in student and is published + result = request.env['education.exam.result'].sudo().search([ + ('id', '=', result_id), + ('student_id', '=', student.id), + ('published', '=', True) + ], limit=1) + + if not result: + return request.redirect('/my/results') + + values = { + 'result': result, + 'page_name': 'result_detail', + 'student': student, + } + return request.render("education_exam.portal_result_detail", values) diff --git a/addons/education_exam/data/grading_data.xml b/addons/education_exam/data/grading_data.xml new file mode 100644 index 0000000..570387a --- /dev/null +++ b/addons/education_exam/data/grading_data.xml @@ -0,0 +1,89 @@ + + + + + + Standard Percentage + STD_PCT + percentage + + + + + + O + O + 90 + 100 + 10 + pass + 10 + + + + + A+ + A_PLUS + 80 + 89.99 + 9 + pass + 20 + + + + + A + A + 70 + 79.99 + 8 + pass + 30 + + + + + B+ + B_PLUS + 60 + 69.99 + 7 + pass + 40 + + + + + B + B + 50 + 59.99 + 6 + pass + 50 + + + + + C + C + 40 + 49.99 + 5 + pass + 60 + + + + + F + F + 0 + 39.99 + 0 + fail + 70 + + + diff --git a/addons/education_exam/models/__init__.py b/addons/education_exam/models/__init__.py new file mode 100644 index 0000000..dc903b3 --- /dev/null +++ b/addons/education_exam/models/__init__.py @@ -0,0 +1,6 @@ +from . import education_subject +from . import education_exam_type +from . import education_grading +from . import education_exam +from . import education_exam_result +from . import education_student_inherit diff --git a/addons/education_exam/models/__pycache__/__init__.cpython-310.pyc b/addons/education_exam/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..36f2fd2 Binary files /dev/null and b/addons/education_exam/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_exam/models/__pycache__/education_exam.cpython-310.pyc b/addons/education_exam/models/__pycache__/education_exam.cpython-310.pyc new file mode 100644 index 0000000..e9788e3 Binary files /dev/null and b/addons/education_exam/models/__pycache__/education_exam.cpython-310.pyc differ diff --git a/addons/education_exam/models/__pycache__/education_exam_result.cpython-310.pyc b/addons/education_exam/models/__pycache__/education_exam_result.cpython-310.pyc new file mode 100644 index 0000000..1b87071 Binary files /dev/null and b/addons/education_exam/models/__pycache__/education_exam_result.cpython-310.pyc differ diff --git a/addons/education_exam/models/__pycache__/education_exam_type.cpython-310.pyc b/addons/education_exam/models/__pycache__/education_exam_type.cpython-310.pyc new file mode 100644 index 0000000..a07df25 Binary files /dev/null and b/addons/education_exam/models/__pycache__/education_exam_type.cpython-310.pyc differ diff --git a/addons/education_exam/models/__pycache__/education_grading.cpython-310.pyc b/addons/education_exam/models/__pycache__/education_grading.cpython-310.pyc new file mode 100644 index 0000000..9f58dd3 Binary files /dev/null and b/addons/education_exam/models/__pycache__/education_grading.cpython-310.pyc differ diff --git a/addons/education_exam/models/__pycache__/education_student_inherit.cpython-310.pyc b/addons/education_exam/models/__pycache__/education_student_inherit.cpython-310.pyc new file mode 100644 index 0000000..ef8856b Binary files /dev/null and b/addons/education_exam/models/__pycache__/education_student_inherit.cpython-310.pyc differ diff --git a/addons/education_exam/models/__pycache__/education_subject.cpython-310.pyc b/addons/education_exam/models/__pycache__/education_subject.cpython-310.pyc new file mode 100644 index 0000000..30fac54 Binary files /dev/null and b/addons/education_exam/models/__pycache__/education_subject.cpython-310.pyc differ diff --git a/addons/education_exam/models/education_exam.py b/addons/education_exam/models/education_exam.py new file mode 100644 index 0000000..5b9d157 --- /dev/null +++ b/addons/education_exam/models/education_exam.py @@ -0,0 +1,193 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError +from datetime import datetime, timedelta +import pytz + +class EducationExam(models.Model): + _name = 'education.exam' + _description = 'Examination' + + name = fields.Char(string='Name', required=True) + code = fields.Char(string='Code', required=True) + academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True) + program_id = fields.Many2one('education.program', string='Program', required=True) + term_id = fields.Many2one('education.academic.term', string='Semester/Term', required=True) + exam_type_id = fields.Many2one('education.exam.type', string='Exam Type', required=True) + start_date = fields.Date(string='Start Date', required=True) + end_date = fields.Date(string='End Date', required=True) + grading_system_id = fields.Many2one('education.grading.system', string='Grading System', required=True) + state = fields.Selection([ + ('draft', 'Draft'), + ('scheduled', 'Scheduled'), + ('ongoing', 'Ongoing'), + ('completed', 'Completed'), + ('results_published', 'Results Published') + ], string='Status', required=True, default='draft', tracking=True) + result_published_date = fields.Date(string='Result Published Date') + active = fields.Boolean(default=True) + + line_ids = fields.One2many('education.exam.line', 'exam_id', string='Schedule') + result_ids = fields.One2many('education.exam.result', 'exam_id', string='Results') + + def action_schedule(self): + for rec in self: + if not rec.line_ids: + raise ValidationError("Please add at least one subject to the schedule.") + rec.state = 'scheduled' + + def action_start(self): + for rec in self: + if rec.state != 'scheduled': + raise ValidationError("Only scheduled exams can be started.") + + earliest_line = min(rec.line_ids, key=lambda l: (l.exam_date, l.start_time), default=None) + if earliest_line: + # Get the user's timezone, fallback to UTC + user_tz = pytz.timezone(self.env.user.tz or 'UTC') + + # Create naive datetime from the earliest exam_date and start_time (which is in hours) + earliest_dt_naive = datetime.combine(earliest_line.exam_date, datetime.min.time()) + timedelta(hours=earliest_line.start_time) + + # Localize this naive datetime to the user's timezone + earliest_dt_user = user_tz.localize(earliest_dt_naive) + + # Get current time in UTC, and convert to user's timezone for comparison + now_utc = fields.Datetime.now() + now_user = pytz.utc.localize(now_utc).astimezone(user_tz) + + if now_user < earliest_dt_user: + raise ValidationError(f"This exam cannot be started yet. It is scheduled to start on {earliest_dt_user.strftime('%d/%m/%Y at %I:%M %p')}.") + + rec.state = 'ongoing' + + def action_complete(self): + for rec in self: + if rec.state != 'ongoing': + raise ValidationError("Only ongoing exams can be completed.") + rec.state = 'completed' + + def action_generate_results(self): + """Generates empty result records for all students enrolled in the program and term.""" + for rec in self: + 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), + ('academic_term_id', '=', rec.term_id.id), + ('status', 'in', ['active', 'enrolled', 'admitted']) + ]) + + if not students: + 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) + ]) + if not existing: + result = self.env['education.exam.result'].create({ + 'student_id': student.id, + 'exam_id': rec.id, + }) + # Create result lines based on schedule + for line in rec.line_ids: + self.env['education.exam.result.line'].create({ + '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, + }) + + def action_publish_results(self): + for rec in self: + # Validations before publishing + if not rec.result_ids: + 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.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 {line.subject_id.name} for student {result.student_id.name}.") + if line.obtained_marks > line.max_marks: + 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_reset_draft(self): + self.write({'state': 'draft'}) + + @api.constrains('start_date', 'end_date') + def _check_exam_dates(self): + for rec in self: + if rec.start_date and rec.end_date and rec.end_date < rec.start_date: + raise ValidationError("End date cannot be before the start date.") + + +class EducationExamLine(models.Model): + _name = 'education.exam.line' + _description = 'Exam Schedule Line' + + exam_id = fields.Many2one('education.exam', string='Exam', required=True, ondelete='cascade') + subject_id = fields.Many2one('education.subject', string='Subject', required=True) + exam_date = fields.Date(string='Exam Date', required=True) + start_time = fields.Float(string='Start Time', required=True) + end_time = fields.Float(string='End Time', required=True) + max_marks = fields.Float(string='Maximum Marks', required=True) + pass_marks = fields.Float(string='Pass Marks', required=True) + room = fields.Char(string='Room') + venue = fields.Char(string='Venue') + faculty_id = fields.Many2one('res.users', string='Faculty/Invigilator') + + @api.constrains('start_time', 'end_time') + def _check_times(self): + for record in self: + if record.end_time <= record.start_time: + raise ValidationError("End time must be greater than start time.") + + @api.constrains('max_marks', 'pass_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 be greater than maximum marks.") + + @api.constrains('exam_date') + def _check_date(self): + for record in self: + if record.exam_id and (record.exam_date < record.exam_id.start_date or record.exam_date > record.exam_id.end_date): + raise ValidationError("Exam date must fall within the exam start and end dates.") + + @api.constrains('exam_date', 'start_time', 'end_time', 'exam_id') + def _check_schedule_conflict(self): + for rec in self: + domain = [ + ('id', '!=', rec.id), + ('exam_date', '=', rec.exam_date), + ('start_time', '<', rec.end_time), + ('end_time', '>', rec.start_time), + ('exam_id.program_id', '=', rec.exam_id.program_id.id), + ('exam_id.academic_year_id', '=', rec.exam_id.academic_year_id.id), + ('exam_id.term_id', '=', rec.exam_id.term_id.id), + ] + conflicts = self.search(domain) + if conflicts: + conflict = conflicts[0] + raise ValidationError(f"Exam schedule conflict: '{rec.subject_id.name}' overlaps with '{conflict.subject_id.name}' for the same student group on {rec.exam_date}.") diff --git a/addons/education_exam/models/education_exam_result.py b/addons/education_exam/models/education_exam_result.py new file mode 100644 index 0000000..81e6f60 --- /dev/null +++ b/addons/education_exam/models/education_exam_result.py @@ -0,0 +1,127 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +class EducationExamResult(models.Model): + _name = 'education.exam.result' + _description = 'Exam Result' + _rec_name = 'student_id' + + student_id = fields.Many2one('education.student', string='Student', required=True) + exam_id = fields.Many2one('education.exam', string='Exam', required=True, ondelete='cascade') + + total_max_marks = fields.Float(string='Total Maximum Marks', compute='_compute_totals', store=True) + total_obtained_marks = fields.Float(string='Total Obtained Marks', compute='_compute_totals', store=True) + percentage = fields.Float(string='Percentage', compute='_compute_totals', store=True) + grade_id = fields.Many2one('education.grade', string='Grade', compute='_compute_totals', store=True) + grade_point = fields.Float(string='Grade Point', compute='_compute_totals', store=True) + result_status = fields.Selection([ + ('pending', 'Pending / Not Evaluated'), + ('pass', 'Pass'), + ('fail', 'Fail') + ], string='Result Status', compute='_compute_totals', store=True, default='pending') + + 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') + + _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): + for rec in self: + max_marks = sum(rec.line_ids.mapped('max_marks')) + obtained_marks = sum(rec.line_ids.mapped('obtained_marks')) + rec.total_max_marks = max_marks + rec.total_obtained_marks = obtained_marks + + if max_marks > 0: + rec.percentage = (obtained_marks / max_marks) * 100.0 + else: + rec.percentage = 0.0 + + # Determine overall grade + if rec.exam_id and rec.exam_id.grading_system_id: + grade = rec.exam_id.grading_system_id.get_grade(rec.percentage) + if grade: + rec.grade_id = grade.id + rec.grade_point = grade.grade_point + else: + rec.grade_id = False + rec.grade_point = 0.0 + else: + rec.grade_id = False + rec.grade_point = 0.0 + + # Overall status is pending if any subject is pending, Fail if any subject is failed, otherwise Pass + if not rec.line_ids or any(line.status == 'pending' for line in rec.line_ids): + rec.result_status = 'pending' + elif any(line.status == 'fail' for line in rec.line_ids): + rec.result_status = 'fail' + else: + rec.result_status = 'pass' + + +class EducationExamResultLine(models.Model): + _name = 'education.exam.result.line' + _description = 'Exam Result Line' + + result_id = fields.Many2one('education.exam.result', string='Result', required=True, ondelete='cascade') + subject_id = fields.Many2one('education.subject', string='Subject', required=True) + exam_type_id = fields.Many2one('education.exam.type', string='Exam Type') + max_marks = fields.Float(string='Maximum Marks', required=True) + pass_marks = fields.Float(string='Pass Marks', required=True) + obtained_marks = fields.Float(string='Obtained Marks', default=-1.0) # -1 indicates not entered + + percentage = fields.Float(string='Percentage', compute='_compute_grade_details', store=True) + grade_id = fields.Many2one('education.grade', string='Grade', compute='_compute_grade_details', store=True) + grade_point = fields.Float(string='Grade Point', compute='_compute_grade_details', store=True) + status = fields.Selection([ + ('pending', 'Pending / Not Evaluated'), + ('pass', 'Pass'), + ('fail', 'Fail') + ], string='Status', compute='_compute_grade_details', store=True, default='pending') + + remarks = fields.Char(string='Remarks') + + @api.constrains('obtained_marks', 'max_marks') + def _check_marks(self): + for record in self: + 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.") + + @api.depends('obtained_marks', 'max_marks', 'pass_marks', 'result_id.exam_id.grading_system_id') + def _compute_grade_details(self): + for rec in self: + if rec.obtained_marks >= 0 and rec.max_marks > 0: + rec.percentage = (rec.obtained_marks / rec.max_marks) * 100.0 + + # Check pass/fail based on marks first + if rec.obtained_marks >= rec.pass_marks: + rec.status = 'pass' + else: + rec.status = 'fail' + + # Apply grading system + if rec.result_id.exam_id.grading_system_id: + grade = rec.result_id.exam_id.grading_system_id.get_grade(rec.percentage) + 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 + else: + rec.grade_id = False + rec.grade_point = 0.0 + else: + rec.percentage = 0.0 + rec.grade_id = False + rec.grade_point = 0.0 + rec.status = 'pending' # default to pending until marks are entered diff --git a/addons/education_exam/models/education_exam_type.py b/addons/education_exam/models/education_exam_type.py new file mode 100644 index 0000000..c2f736c --- /dev/null +++ b/addons/education_exam/models/education_exam_type.py @@ -0,0 +1,11 @@ +from odoo import models, fields + +class EducationExamType(models.Model): + _name = 'education.exam.type' + _description = 'Examination Type' + + name = fields.Char(string='Name', required=True) + code = fields.Char(string='Code', required=True) + description = fields.Text(string='Description') + weightage = fields.Float(string='Weightage (%)', default=100.0) + active = fields.Boolean(default=True) diff --git a/addons/education_exam/models/education_grading.py b/addons/education_exam/models/education_grading.py new file mode 100644 index 0000000..54fcc30 --- /dev/null +++ b/addons/education_exam/models/education_grading.py @@ -0,0 +1,68 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +class EducationGradingSystem(models.Model): + _name = 'education.grading.system' + _description = 'Grading System' + + name = fields.Char(string='Name', required=True) + code = fields.Char(string='Code', required=True) + scale_type = fields.Selection([ + ('percentage', 'Percentage'), + ('gpa_4', 'GPA 4.0'), + ('gpa_10', 'GPA 10.0') + ], string='Scale Type', required=True, default='percentage') + active = fields.Boolean(default=True) + grade_ids = fields.One2many('education.grade', 'grading_system_id', string='Grades') + + def get_grade(self, percentage): + self.ensure_one() + grade = self.grade_ids.filtered(lambda g: g.min_percentage <= percentage <= g.max_percentage) + if grade: + # If multiple match due to floating point edge cases, take the highest grade point + return grade.sorted('grade_point', reverse=True)[0] + return self.env['education.grade'] + +class EducationGrade(models.Model): + _name = 'education.grade' + _description = 'Education Grade' + _order = 'sequence, id' + + grading_system_id = fields.Many2one('education.grading.system', string='Grading System', required=True, ondelete='cascade') + name = fields.Char(string='Name', required=True) + code = fields.Char(string='Code') + min_percentage = fields.Float(string='Minimum Percentage', required=True) + max_percentage = fields.Float(string='Maximum Percentage', required=True) + grade_point = fields.Float(string='Grade Point', required=True) + result_status = fields.Selection([ + ('pass', 'Pass'), + ('fail', 'Fail') + ], string='Result Status', required=True, default='pass') + sequence = fields.Integer(string='Sequence', default=10) + active = fields.Boolean(default=True) + + @api.constrains('min_percentage', 'max_percentage') + def _check_percentages(self): + for record in self: + if record.min_percentage < 0 or record.max_percentage > 100: + raise ValidationError("Percentages must be between 0 and 100.") + if record.min_percentage > record.max_percentage: + raise ValidationError("Minimum percentage cannot be greater than maximum percentage.") + + # Check overlaps + domain = [ + ('grading_system_id', '=', record.grading_system_id.id), + ('id', '!=', record.id), + '|', '|', + '&', ('min_percentage', '<=', record.min_percentage), ('max_percentage', '>=', record.min_percentage), + '&', ('min_percentage', '<=', record.max_percentage), ('max_percentage', '>=', record.max_percentage), + '&', ('min_percentage', '>=', record.min_percentage), ('max_percentage', '<=', record.max_percentage), + ] + if self.search_count(domain) > 0: + raise ValidationError("Grade percentage ranges cannot overlap within the same grading system.") + + @api.constrains('grade_point') + def _check_grade_point(self): + for record in self: + if record.grade_point < 0: + raise ValidationError("Grade point cannot be negative.") diff --git a/addons/education_exam/models/education_student_inherit.py b/addons/education_exam/models/education_student_inherit.py new file mode 100644 index 0000000..7bbd076 --- /dev/null +++ b/addons/education_exam/models/education_student_inherit.py @@ -0,0 +1,23 @@ +from odoo import models, fields, api + +class EducationStudent(models.Model): + _inherit = 'education.student' + + exam_result_ids = fields.One2many('education.exam.result', 'student_id', string='Exam Results') + exam_result_count = fields.Integer(compute='_compute_exam_result_count') + + @api.depends('exam_result_ids') + def _compute_exam_result_count(self): + for student in self: + student.exam_result_count = len(student.exam_result_ids) + + def action_view_exam_results(self): + self.ensure_one() + return { + 'name': 'Exam Results', + 'type': 'ir.actions.act_window', + 'res_model': 'education.exam.result', + 'view_mode': 'tree,form', + 'domain': [('student_id', '=', self.id)], + 'context': {'default_student_id': self.id}, + } diff --git a/addons/education_exam/models/education_subject.py b/addons/education_exam/models/education_subject.py new file mode 100644 index 0000000..f957059 --- /dev/null +++ b/addons/education_exam/models/education_subject.py @@ -0,0 +1,22 @@ +from odoo import models, fields, api + +class EducationSubject(models.Model): + _name = 'education.subject' + _description = 'Education Subject' + + name = fields.Char(string='Name', required=True) + code = fields.Char(string='Code', required=True) + program_id = fields.Many2one('education.program', string='Program', required=True) + academic_year_id = fields.Many2one('education.academic.year', string='Academic Year') + term_id = fields.Many2one('education.academic.term', string='Semester/Term') + credits = fields.Float(string='Credits', default=1.0) + subject_type = fields.Selection([ + ('theory', 'Theory'), + ('practical', 'Practical'), + ('both', 'Theory + Practical') + ], string='Subject Type', required=True, default='theory') + active = fields.Boolean(default=True) + + _sql_constraints = [ + ('code_unique', 'unique(code)', 'Subject code must be unique!') + ] diff --git a/addons/education_exam/security/ir.model.access.csv b/addons/education_exam/security/ir.model.access.csv new file mode 100644 index 0000000..2fb0de6 --- /dev/null +++ b/addons/education_exam/security/ir.model.access.csv @@ -0,0 +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_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_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_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_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_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_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_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 diff --git a/addons/education_exam/security/security.xml b/addons/education_exam/security/security.xml new file mode 100644 index 0000000..5262a34 --- /dev/null +++ b/addons/education_exam/security/security.xml @@ -0,0 +1,33 @@ + + + + + + Examination + Helps you manage Education Examinations. + 10 + + + + + Teacher / Faculty + + + + + Administrator + + + + + + + + + Teacher Exam Result Rule + + [(1, '=', 1)] + + + + diff --git a/addons/education_exam/views/education_exam_result_views.xml b/addons/education_exam/views/education_exam_result_views.xml new file mode 100644 index 0000000..ccb571f --- /dev/null +++ b/addons/education_exam/views/education_exam_result_views.xml @@ -0,0 +1,78 @@ + + + + + education.exam.result.tree + education.exam.result + + + + + + + + + + + + + + education.exam.result.form + education.exam.result + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + Marks Entry + education.exam.result + tree,form + + + + Results + education.exam.result + tree,form + +
diff --git a/addons/education_exam/views/education_exam_views.xml b/addons/education_exam/views/education_exam_views.xml new file mode 100644 index 0000000..0b1f64e --- /dev/null +++ b/addons/education_exam/views/education_exam_views.xml @@ -0,0 +1,130 @@ + + + + + education.exam.type.tree + education.exam.type + + + + + + + + + + + education.exam.type.form + education.exam.type + +
+ + + + + + + + + +
+
+
+ + + Exam Types + education.exam.type + tree,form + + + + + education.exam.tree + education.exam + + + + + + + + + + + + + + + education.exam.form + education.exam + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + Exams + education.exam + tree,form + +
diff --git a/addons/education_exam/views/education_grading_views.xml b/addons/education_exam/views/education_grading_views.xml new file mode 100644 index 0000000..ec19fc9 --- /dev/null +++ b/addons/education_exam/views/education_grading_views.xml @@ -0,0 +1,56 @@ + + + + education.grading.system.tree + education.grading.system + + + + + + + + + + + education.grading.system.form + education.grading.system + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + Grading Systems + education.grading.system + tree,form + +
diff --git a/addons/education_exam/views/education_student_inherit_views.xml b/addons/education_exam/views/education_student_inherit_views.xml new file mode 100644 index 0000000..5778402 --- /dev/null +++ b/addons/education_exam/views/education_student_inherit_views.xml @@ -0,0 +1,15 @@ + + + + education.student.form.inherit.exam + education.student + + +
+ +
+
+
+
diff --git a/addons/education_exam/views/education_subject_views.xml b/addons/education_exam/views/education_subject_views.xml new file mode 100644 index 0000000..f8f2501 --- /dev/null +++ b/addons/education_exam/views/education_subject_views.xml @@ -0,0 +1,52 @@ + + + + education.subject.tree + education.subject + + + + + + + + + + + + + + education.subject.form + education.subject + +
+ + +
+
+ + + + + + + + + + + + + +
+
+
+
+ + + Subjects + education.subject + tree,form + +
diff --git a/addons/education_exam/views/menu_views.xml b/addons/education_exam/views/menu_views.xml new file mode 100644 index 0000000..82859da --- /dev/null +++ b/addons/education_exam/views/menu_views.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/addons/education_exam/views/portal_exam_templates.xml b/addons/education_exam/views/portal_exam_templates.xml new file mode 100644 index 0000000..0652d80 --- /dev/null +++ b/addons/education_exam/views/portal_exam_templates.xml @@ -0,0 +1,157 @@ + + + + + + + + + + + + diff --git a/addons/education_finance/__init__.py b/addons/education_finance/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/addons/education_finance/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/education_finance/__manifest__.py b/addons/education_finance/__manifest__.py new file mode 100644 index 0000000..f6e0560 --- /dev/null +++ b/addons/education_finance/__manifest__.py @@ -0,0 +1,33 @@ +{ + 'name': 'Education Finance', + 'version': '17.0.1.0.0', + 'summary': 'Student Finance Management System for education institutions', + 'description': ''' + This module manages fee categories, fee types, fee structures, assignments, + discounts, scholarships, student ledger balances, receipts, payments, and refunds. + ''', + 'category': 'Education', + 'author': 'Metatron', + 'license': 'LGPL-3', + 'depends': ['education_student'], + 'data': [ + 'security/education_finance_security.xml', + 'security/ir.model.access.csv', + 'data/education_finance_sequence.xml', + 'reports/receipt_report.xml', + 'views/fee_category_views.xml', + 'views/fee_type_views.xml', + 'views/fee_structure_views.xml', + 'views/fee_assignment_views.xml', + 'views/student_ledger_views.xml', + 'views/student_payment_views.xml', + 'views/student_discount_views.xml', + 'views/student_scholarship_views.xml', + 'views/student_refund_views.xml', + 'views/student_views_inherit.xml', + 'views/finance_menus.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/addons/education_finance/__pycache__/__init__.cpython-310.pyc b/addons/education_finance/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..9340e01 Binary files /dev/null and b/addons/education_finance/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_finance/__pycache__/__manifest__.cpython-310.pyc b/addons/education_finance/__pycache__/__manifest__.cpython-310.pyc new file mode 100644 index 0000000..c6267ca Binary files /dev/null and b/addons/education_finance/__pycache__/__manifest__.cpython-310.pyc differ diff --git a/addons/education_finance/data/education_finance_sequence.xml b/addons/education_finance/data/education_finance_sequence.xml new file mode 100644 index 0000000..02b8817 --- /dev/null +++ b/addons/education_finance/data/education_finance_sequence.xml @@ -0,0 +1,12 @@ + + + + Student Payment Receipt Sequence + education.student.payment + REC/ + 6 + 1 + 1 + + + diff --git a/addons/education_finance/models/__init__.py b/addons/education_finance/models/__init__.py new file mode 100644 index 0000000..d1ece90 --- /dev/null +++ b/addons/education_finance/models/__init__.py @@ -0,0 +1,10 @@ +from . import fee_category +from . import fee_type +from . import fee_structure +from . import fee_assignment +from . import student_ledger +from . import student_payment +from . import student_discount +from . import student_scholarship +from . import student_refund +from . import student_inherit diff --git a/addons/education_finance/models/__pycache__/__init__.cpython-310.pyc b/addons/education_finance/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..b603e50 Binary files /dev/null and b/addons/education_finance/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/fee_assignment.cpython-310.pyc b/addons/education_finance/models/__pycache__/fee_assignment.cpython-310.pyc new file mode 100644 index 0000000..a641947 Binary files /dev/null and b/addons/education_finance/models/__pycache__/fee_assignment.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/fee_category.cpython-310.pyc b/addons/education_finance/models/__pycache__/fee_category.cpython-310.pyc new file mode 100644 index 0000000..c3fd73c Binary files /dev/null and b/addons/education_finance/models/__pycache__/fee_category.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/fee_structure.cpython-310.pyc b/addons/education_finance/models/__pycache__/fee_structure.cpython-310.pyc new file mode 100644 index 0000000..ac30fef Binary files /dev/null and b/addons/education_finance/models/__pycache__/fee_structure.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/fee_type.cpython-310.pyc b/addons/education_finance/models/__pycache__/fee_type.cpython-310.pyc new file mode 100644 index 0000000..aa659bd Binary files /dev/null and b/addons/education_finance/models/__pycache__/fee_type.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/student_discount.cpython-310.pyc b/addons/education_finance/models/__pycache__/student_discount.cpython-310.pyc new file mode 100644 index 0000000..a500646 Binary files /dev/null and b/addons/education_finance/models/__pycache__/student_discount.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/student_inherit.cpython-310.pyc b/addons/education_finance/models/__pycache__/student_inherit.cpython-310.pyc new file mode 100644 index 0000000..e3a5666 Binary files /dev/null and b/addons/education_finance/models/__pycache__/student_inherit.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/student_ledger.cpython-310.pyc b/addons/education_finance/models/__pycache__/student_ledger.cpython-310.pyc new file mode 100644 index 0000000..95df75e Binary files /dev/null and b/addons/education_finance/models/__pycache__/student_ledger.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/student_payment.cpython-310.pyc b/addons/education_finance/models/__pycache__/student_payment.cpython-310.pyc new file mode 100644 index 0000000..66eee08 Binary files /dev/null and b/addons/education_finance/models/__pycache__/student_payment.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/student_refund.cpython-310.pyc b/addons/education_finance/models/__pycache__/student_refund.cpython-310.pyc new file mode 100644 index 0000000..e645a4e Binary files /dev/null and b/addons/education_finance/models/__pycache__/student_refund.cpython-310.pyc differ diff --git a/addons/education_finance/models/__pycache__/student_scholarship.cpython-310.pyc b/addons/education_finance/models/__pycache__/student_scholarship.cpython-310.pyc new file mode 100644 index 0000000..ec76309 Binary files /dev/null and b/addons/education_finance/models/__pycache__/student_scholarship.cpython-310.pyc differ diff --git a/addons/education_finance/models/fee_assignment.py b/addons/education_finance/models/fee_assignment.py new file mode 100644 index 0000000..ebe8464 --- /dev/null +++ b/addons/education_finance/models/fee_assignment.py @@ -0,0 +1,65 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationFeeAssignment(models.Model): + _name = 'education.fee.assignment' + _description = 'Student Fee Assignment' + _order = 'assignment_date desc' + + 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) + 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) + state = fields.Selection([ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ], string='Status', default='draft', required=True, index=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + _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: + if rec.state == 'confirmed': + 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.") + for line in rec.fee_structure_id.line_ids: + self.env['education.student.ledger'].create({ + 'student_id': rec.student_id.id, + 'academic_year_id': rec.academic_year_id.id, + 'semester_id': rec.semester_id.id, + 'category_id': line.fee_type_id.category_id.id, + 'fee_type_id': line.fee_type_id.id, + 'date': rec.assignment_date, + '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', + 'company_id': rec.company_id.id, + }) + rec.state = 'confirmed' + + def action_cancel(self): + for rec in self: + if rec.state == 'confirmed': + ledger_entries = self.env['education.student.ledger'].search([ + ('student_id', '=', rec.student_id.id), + ('ref', '=', f"FEE/{rec.id}"), + ]) + ledger_entries.unlink() + rec.state = 'draft' diff --git a/addons/education_finance/models/fee_category.py b/addons/education_finance/models/fee_category.py new file mode 100644 index 0000000..6d9a1c1 --- /dev/null +++ b/addons/education_finance/models/fee_category.py @@ -0,0 +1,48 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationFeeCategory(models.Model): + _name = 'education.fee.category' + _description = 'Fee Category' + _order = 'name' + + name = fields.Char(required=True, string='Category Name') + code = fields.Char(string='Category Code', required=True, index=True) + description = fields.Text(string='Description') + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + _sql_constraints = [ + ('uniq_category_code_company', 'unique(code, company_id)', 'Fee category code must be unique per company.'), + ] + + @api.constrains('name', 'company_id') + def _check_name_unique(self): + for rec in self: + similar = self.search([ + ('company_id', '=', rec.company_id.id), + ('name', '=ilike', rec.name), + ('id', '!=', rec.id), + ]) + if similar: + raise ValidationError('Fee category name must be unique within a company.') + + @api.model + def create(self, vals): + if vals.get('code'): + vals['code'] = vals['code'].strip().upper() + return super().create(vals) + + def write(self, vals): + if 'code' in vals and vals.get('code'): + vals['code'] = vals['code'].strip().upper() + return super().write(vals) diff --git a/addons/education_finance/models/fee_structure.py b/addons/education_finance/models/fee_structure.py new file mode 100644 index 0000000..cc52460 --- /dev/null +++ b/addons/education_finance/models/fee_structure.py @@ -0,0 +1,64 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationFeeStructure(models.Model): + _name = 'education.fee.structure' + _description = 'Fee Structure' + _order = 'name' + + name = fields.Char(required=True, string='Structure Name') + academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True, index=True) + level_id = fields.Many2one('education.academic.level', string='Academic Level', required=True, index=True) + 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) + currency_id = fields.Many2one( + 'res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + ) + line_ids = fields.One2many('education.fee.structure.line', 'fee_structure_id', string='Fee Lines', copy=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + +class EducationFeeStructureLine(models.Model): + _name = 'education.fee.structure.line' + _description = 'Fee Structure Line' + _order = 'sequence, id' + + fee_structure_id = fields.Many2one('education.fee.structure', string='Fee Structure', required=True, ondelete='cascade') + fee_type_id = fields.Many2one('education.fee.type', string='Fee Type', required=True) + currency_id = fields.Many2one('res.currency', related='fee_structure_id.currency_id', store=True) + amount = fields.Monetary(string='Amount', required=True, currency_field='currency_id') + mandatory = fields.Boolean(string='Mandatory', default=True) + sequence = fields.Integer(string='Sequence', default=10) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + related='fee_structure_id.company_id', + store=True, + index=True, + ) + + @api.onchange('fee_type_id') + def _onchange_fee_type(self): + if self.fee_type_id: + self.amount = self.fee_type_id.amount + self.mandatory = self.fee_type_id.mandatory + + @api.constrains('amount') + def _check_amount(self): + for rec in self: + if rec.amount < 0: + raise ValidationError('Line amount cannot be negative.') diff --git a/addons/education_finance/models/fee_type.py b/addons/education_finance/models/fee_type.py new file mode 100644 index 0000000..bce3358 --- /dev/null +++ b/addons/education_finance/models/fee_type.py @@ -0,0 +1,38 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationFeeType(models.Model): + _name = 'education.fee.type' + _description = 'Fee Type' + _order = 'name' + + name = fields.Char(required=True, string='Fee Type Name') + category_id = fields.Many2one('education.fee.category', string='Category', required=True, index=True) + currency_id = fields.Many2one( + 'res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + ) + amount = fields.Monetary(string='Amount', required=True, currency_field='currency_id', default=0.0) + recurring = fields.Boolean(string='Recurring', default=False) + mandatory = fields.Boolean(string='Mandatory', default=True) + tax_applicable = fields.Boolean(string='Tax Applicable', default=False) + description = fields.Text(string='Description') + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + @api.constrains('amount') + def _check_amount(self): + for rec in self: + if rec.amount < 0: + raise ValidationError('Fee type amount cannot be negative.') diff --git a/addons/education_finance/models/student_discount.py b/addons/education_finance/models/student_discount.py new file mode 100644 index 0000000..0a1bfda --- /dev/null +++ b/addons/education_finance/models/student_discount.py @@ -0,0 +1,85 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationStudentDiscount(models.Model): + _name = 'education.student.discount' + _description = 'Student Fee Discount' + _order = 'id desc' + + student_id = fields.Many2one('education.student', string='Student', required=True, index=True) + type = fields.Selection([ + ('percentage', 'Percentage'), + ('fixed', 'Fixed Amount') + ], string='Discount Type', required=True, default='fixed') + + currency_id = fields.Many2one( + 'res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + ) + amount = fields.Monetary(string='Discount Value', required=True, currency_field='currency_id', default=0.0) + reason = fields.Text(string='Reason for Discount', required=True) + approved_by = fields.Many2one('res.users', string='Approved By', default=lambda self: self.env.user, required=True) + validity = fields.Date(string='Validity Date') + state = fields.Selection([ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('cancelled', 'Cancelled') + ], string='Status', default='draft', required=True, index=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + @api.constrains('amount') + def _check_amount(self): + for rec in self: + if rec.amount <= 0: + raise ValidationError('Discount value must be greater than zero.') + if rec.type == 'percentage' and rec.amount > 100: + raise ValidationError('Discount percentage cannot exceed 100%.') + + def action_confirm(self): + for rec in self: + if rec.state == 'confirmed': + continue + + discount_amount = rec.amount + if rec.type == 'percentage': + outstanding = rec.student_id.outstanding_amount + discount_amount = outstanding * (rec.amount / 100.0) + + if discount_amount <= 0: + raise ValidationError('Calculated discount amount must be greater than zero.') + if discount_amount > rec.student_id.outstanding_amount: + raise ValidationError(f'Cannot apply a discount of {discount_amount} because it exceeds the student outstanding dues of {rec.student_id.outstanding_amount}.') + + self.env['education.student.ledger'].create({ + 'student_id': rec.student_id.id, + 'date': fields.Date.today(), + 'ref': f"DISC/{rec.id}", + 'description': f"Discount Applied - {rec.reason} (Approved by: {rec.approved_by.name})", + 'debit': 0.0, + 'credit': discount_amount, + 'status': 'paid', + 'company_id': rec.company_id.id, + }) + rec.state = 'confirmed' + + def action_cancel(self): + for rec in self: + if rec.state == 'confirmed': + ledger_entries = self.env['education.student.ledger'].search([ + ('student_id', '=', rec.student_id.id), + ('ref', '=', f"DISC/{rec.id}"), + ]) + ledger_entries.unlink() + rec.state = 'cancelled' diff --git a/addons/education_finance/models/student_inherit.py b/addons/education_finance/models/student_inherit.py new file mode 100644 index 0000000..706d864 --- /dev/null +++ b/addons/education_finance/models/student_inherit.py @@ -0,0 +1,49 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models + + +class EducationStudent(models.Model): + _inherit = 'education.student' + + ledger_line_ids = fields.One2many('education.student.ledger', 'student_id', string='Ledger Lines') + payment_ids = fields.One2many('education.student.payment', 'student_id', string='Payments') + + 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) + + 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) + payment_status = fields.Selection([ + ('no_dues', 'No Dues'), + ('partial', 'Partial'), + ('overdue', 'Overdue'), + ('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') + def _compute_finance_metrics(self): + 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) + total_credits = sum(line.credit for line in active_ledgers) + rec.total_fees = total_debits + + 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 = 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 + + if total_debits == 0: + rec.payment_status = 'no_dues' + elif rec.outstanding_amount <= 0: + rec.payment_status = 'fully_paid' + elif rec.total_paid > 0: + rec.payment_status = 'partial' + else: + rec.payment_status = 'overdue' diff --git a/addons/education_finance/models/student_ledger.py b/addons/education_finance/models/student_ledger.py new file mode 100644 index 0000000..24ee7d5 --- /dev/null +++ b/addons/education_finance/models/student_ledger.py @@ -0,0 +1,80 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models + + +class EducationStudentLedger(models.Model): + _name = 'education.student.ledger' + _description = 'Student Financial Ledger' + _order = 'date asc, id asc' + + student_id = fields.Many2one('education.student', string='Student', required=True, index=True, ondelete='cascade') + academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', index=True) + semester_id = fields.Many2one('education.semester', string='Semester', index=True) + category_id = fields.Many2one('education.fee.category', string='Fee Category', index=True) + fee_type_id = fields.Many2one('education.fee.type', string='Fee Type', index=True) + + date = fields.Date(string='Date', required=True, default=fields.Date.context_today, index=True) + ref = fields.Char(string='Reference', index=True) + description = fields.Text(string='Description') + + currency_id = fields.Many2one( + 'res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + ) + debit = fields.Monetary(string='Debit (Charges)', default=0.0, currency_field='currency_id', index=True) + credit = fields.Monetary(string='Credit (Payments/Credits)', default=0.0, currency_field='currency_id', index=True) + balance = fields.Monetary(string='Balance', currency_field='currency_id', index=True) + status = fields.Selection([ + ('draft', 'Draft'), + ('outstanding', 'Outstanding'), + ('paid', 'Paid'), + ('cancelled', 'Cancelled') + ], string='Status', default='outstanding', required=True, index=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + def _recalculate_student_balances(self, student_ids): + for student_id in student_ids: + lines = self.search([ + ('student_id', '=', student_id), + ('status', 'in', ('outstanding', 'paid')) + ], order='date asc, id asc') + running = 0.0 + for line in lines: + running += line.debit - line.credit + self.env.cr.execute( + "UPDATE education_student_ledger SET balance = %s WHERE id = %s", + (running, line.id) + ) + # Force metrics recalculation on the student record + student = self.env['education.student'].browse(student_id) + student.modified(['ledger_line_ids']) + self.invalidate_model(['balance']) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + student_ids = records.mapped('student_id').ids + self._recalculate_student_balances(student_ids) + return records + + def write(self, vals): + student_ids = self.mapped('student_id').ids + res = super().write(vals) + student_ids = list(set(student_ids + self.mapped('student_id').ids)) + self._recalculate_student_balances(student_ids) + return res + + def unlink(self): + student_ids = self.mapped('student_id').ids + res = super().unlink() + self._recalculate_student_balances(student_ids) + return res diff --git a/addons/education_finance/models/student_payment.py b/addons/education_finance/models/student_payment.py new file mode 100644 index 0000000..3738a16 --- /dev/null +++ b/addons/education_finance/models/student_payment.py @@ -0,0 +1,96 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationStudentPayment(models.Model): + _name = 'education.student.payment' + _description = 'Student Payment' + _order = 'payment_date desc, id desc' + _rec_name = 'receipt_number' + + 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) + payment_date = fields.Date(string='Payment Date', required=True, default=fields.Date.context_today) + payment_method = fields.Selection([ + ('cash', 'Cash'), + ('card', 'Card'), + ('upi', 'UPI'), + ('bank', 'Bank Transfer'), + ('cheque', 'Cheque'), + ('online', 'Online Payment') + ], string='Payment Method', required=True, default='cash') + + currency_id = fields.Many2one( + 'res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + ) + amount = fields.Monetary(string='Amount Paid', required=True, currency_field='currency_id', default=0.0) + reference_number = fields.Char(string='Reference Number') + remarks = fields.Text(string='Remarks') + status = fields.Selection([ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('cancelled', 'Cancelled') + ], string='Status', default='draft', required=True, index=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + _sql_constraints = [ + ('uniq_receipt_number', 'unique(receipt_number, company_id)', 'Receipt number must be unique per company.'), + ] + + @api.constrains('amount') + def _check_amount(self): + for rec in self: + if rec.amount <= 0: + raise ValidationError('Payment amount must be greater than zero.') + + def action_confirm(self): + for rec in self: + if rec.status == 'confirmed': + continue + + outstanding = rec.student_id.outstanding_amount + if rec.amount > outstanding: + raise ValidationError(f"Cannot accept payment of {rec.amount} because the student's outstanding balance is {outstanding}.") + + if not rec.receipt_number or rec.receipt_number == '/': + rec.receipt_number = self.env['ir.sequence'].next_by_code('education.student.payment') or '/' + + self.env['education.student.ledger'].create({ + 'student_id': rec.student_id.id, + 'date': rec.payment_date, + 'ref': rec.receipt_number, + 'description': f"Payment Received - Method: {dict(rec._fields['payment_method'].selection).get(rec.payment_method)} (Ref: {rec.reference_number or 'N/A'})", + 'debit': 0.0, + 'credit': rec.amount, + 'status': 'paid', + 'company_id': rec.company_id.id, + }) + rec.status = 'confirmed' + + def action_cancel(self): + for rec in self: + if rec.status == 'confirmed': + ledger_entries = self.env['education.student.ledger'].search([ + ('student_id', '=', rec.student_id.id), + ('ref', '=', rec.receipt_number), + ]) + ledger_entries.unlink() + rec.status = 'cancelled' + + @api.model + def create(self, vals): + if not vals.get('receipt_number') or vals.get('receipt_number') == '/': + vals['receipt_number'] = self.env['ir.sequence'].next_by_code('education.student.payment') or '/' + return super().create(vals) diff --git a/addons/education_finance/models/student_refund.py b/addons/education_finance/models/student_refund.py new file mode 100644 index 0000000..4581b9c --- /dev/null +++ b/addons/education_finance/models/student_refund.py @@ -0,0 +1,70 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationStudentRefund(models.Model): + _name = 'education.student.refund' + _description = 'Student Fee Refund' + _order = 'id desc' + + student_id = fields.Many2one('education.student', string='Student', compute='_compute_student', store=True, index=True) + payment_id = fields.Many2one('education.student.payment', string='Original Payment', required=True, domain="[('status', '=', 'confirmed')]", index=True) + + currency_id = fields.Many2one( + 'res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + ) + amount = fields.Monetary(string='Refund Amount', required=True, currency_field='currency_id', default=0.0) + reason = fields.Text(string='Reason for Refund', required=True) + status = fields.Selection([ + ('draft', 'Draft'), + ('approved', 'Approved'), + ('rejected', 'Rejected') + ], string='Status', default='draft', required=True, index=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + @api.depends('payment_id') + def _compute_student(self): + for rec in self: + rec.student_id = rec.payment_id.student_id if rec.payment_id else False + + @api.constrains('amount', 'payment_id') + def _check_refund_amount(self): + for rec in self: + if rec.amount <= 0: + raise ValidationError('Refund amount must be positive.') + if rec.payment_id and rec.amount > rec.payment_id.amount: + raise ValidationError(f'Refund amount ({rec.amount}) cannot exceed original payment amount ({rec.payment_id.amount}).') + + def action_approve(self): + for rec in self: + if rec.status == 'approved': + continue + + # Reversing credit (refund debit) + self.env['education.student.ledger'].create({ + 'student_id': rec.student_id.id, + 'date': fields.Date.today(), + 'ref': f"REF/{rec.id}", + 'description': f"Refund Approved - Original Payment Receipt: {rec.payment_id.receipt_number}", + 'debit': rec.amount, + 'credit': 0.0, + 'status': 'outstanding', + 'company_id': rec.company_id.id, + }) + rec.status = 'approved' + + def action_reject(self): + for rec in self: + rec.status = 'rejected' diff --git a/addons/education_finance/models/student_scholarship.py b/addons/education_finance/models/student_scholarship.py new file mode 100644 index 0000000..fb6684c --- /dev/null +++ b/addons/education_finance/models/student_scholarship.py @@ -0,0 +1,86 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class EducationStudentScholarship(models.Model): + _name = 'education.student.scholarship' + _description = 'Student Scholarship' + _order = 'id desc' + _rec_name = 'scholarship_name' + + scholarship_name = fields.Char(string='Scholarship Name', required=True) + student_id = fields.Many2one('education.student', string='Student', required=True, index=True) + type = fields.Selection([ + ('percentage', 'Percentage'), + ('fixed', 'Fixed Amount') + ], string='Scholarship Type', required=True, default='fixed') + + currency_id = fields.Many2one( + 'res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id, + ) + amount = fields.Monetary(string='Scholarship Value', required=True, currency_field='currency_id', default=0.0) + sponsor = fields.Char(string='Sponsor') + validity = fields.Date(string='Validity Date') + state = fields.Selection([ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('cancelled', 'Cancelled') + ], string='Status', default='draft', required=True, index=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) + + @api.constrains('amount') + def _check_amount(self): + for rec in self: + if rec.amount <= 0: + raise ValidationError('Scholarship value must be greater than zero.') + if rec.type == 'percentage' and rec.amount > 100: + raise ValidationError('Scholarship percentage cannot exceed 100%.') + + def action_confirm(self): + for rec in self: + if rec.state == 'confirmed': + continue + + scholarship_amount = rec.amount + if rec.type == 'percentage': + outstanding = rec.student_id.outstanding_amount + scholarship_amount = outstanding * (rec.amount / 100.0) + + if scholarship_amount <= 0: + raise ValidationError('Calculated scholarship amount must be greater than zero.') + if scholarship_amount > rec.student_id.outstanding_amount: + raise ValidationError(f'Cannot apply a scholarship of {scholarship_amount} because it exceeds the student outstanding dues of {rec.student_id.outstanding_amount}.') + + self.env['education.student.ledger'].create({ + 'student_id': rec.student_id.id, + 'date': fields.Date.today(), + 'ref': f"SCH/{rec.id}", + 'description': f"Scholarship Awarded: {rec.scholarship_name} (Sponsor: {rec.sponsor or 'N/A'})", + 'debit': 0.0, + 'credit': scholarship_amount, + 'status': 'paid', + 'company_id': rec.company_id.id, + }) + rec.state = 'confirmed' + + def action_cancel(self): + for rec in self: + if rec.state == 'confirmed': + ledger_entries = self.env['education.student.ledger'].search([ + ('student_id', '=', rec.student_id.id), + ('ref', '=', f"SCH/{rec.id}"), + ]) + ledger_entries.unlink() + rec.state = 'cancelled' diff --git a/addons/education_finance/reports/receipt_report.xml b/addons/education_finance/reports/receipt_report.xml new file mode 100644 index 0000000..afe4bdd --- /dev/null +++ b/addons/education_finance/reports/receipt_report.xml @@ -0,0 +1,83 @@ + + + + + Receipt PDF + education.student.payment + qweb-pdf + education_finance.report_payment_receipt_template + education_finance.report_payment_receipt_template + + report + + + + + diff --git a/addons/education_finance/security/education_finance_security.xml b/addons/education_finance/security/education_finance_security.xml new file mode 100644 index 0000000..9ca9e47 --- /dev/null +++ b/addons/education_finance/security/education_finance_security.xml @@ -0,0 +1,100 @@ + + + + + Education Finance + 30 + + + + + Finance Executive + + + Basic transactional actions: Record payments, view student ledgers. + + + + Finance Manager + + + Manage fee assignments, discounts, scholarships, and approve refunds. + + + + Finance Administrator + + + Full settings control, configure fee types/structures, and delete transactions. + + + + Student Readonly Finance + + + Access control group allowing a student to view their own ledger lines. + + + + + Fee Category Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Fee Type Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Fee Structure Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Fee Assignment Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Student Ledger Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Student Payment Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Student Discount Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Student Scholarship Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + Student Refund Company Rule + + ['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)] + + + + + Personal Student Ledger Rule + + [('student_id.email', '=', user.login)] + + + diff --git a/addons/education_finance/security/ir.model.access.csv b/addons/education_finance/security/ir.model.access.csv new file mode 100644 index 0000000..277e26c --- /dev/null +++ b/addons/education_finance/security/ir.model.access.csv @@ -0,0 +1,22 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fee_category_admin,Fee Category Admin,model_education_fee_category,group_finance_admin,1,1,1,1 +access_fee_category_user,Fee Category User,model_education_fee_category,base.group_user,1,0,0,0 +access_fee_type_admin,Fee Type Admin,model_education_fee_type,group_finance_admin,1,1,1,1 +access_fee_type_user,Fee Type User,model_education_fee_type,base.group_user,1,0,0,0 +access_fee_structure_admin,Fee Structure Admin,model_education_fee_structure,group_finance_admin,1,1,1,1 +access_fee_structure_user,Fee Structure User,model_education_fee_structure,base.group_user,1,0,0,0 +access_fee_structure_line_admin,Fee Structure Line Admin,model_education_fee_structure_line,group_finance_admin,1,1,1,1 +access_fee_structure_line_user,Fee Structure Line User,model_education_fee_structure_line,base.group_user,1,0,0,0 +access_fee_assignment_manager,Fee Assignment Manager,model_education_fee_assignment,group_finance_manager,1,1,1,1 +access_fee_assignment_executive,Fee Assignment Exec,model_education_fee_assignment,group_finance_executive,1,0,0,0 +access_student_ledger_executive,Student Ledger Exec,model_education_student_ledger,group_finance_executive,1,1,1,0 +access_student_ledger_manager,Student Ledger Manager,model_education_student_ledger,group_finance_manager,1,1,1,1 +access_student_ledger_readonly,Student Ledger Readonly,model_education_student_ledger,group_finance_student_readonly,1,0,0,0 +access_student_payment_executive,Student Payment Exec,model_education_student_payment,group_finance_executive,1,1,1,0 +access_student_payment_manager,Student Payment Manager,model_education_student_payment,group_finance_manager,1,1,1,1 +access_student_discount_manager,Student Discount Manager,model_education_student_discount,group_finance_manager,1,1,1,1 +access_student_discount_executive,Student Discount Exec,model_education_student_discount,group_finance_executive,1,0,0,0 +access_student_scholarship_manager,Student Scholarship Manager,model_education_student_scholarship,group_finance_manager,1,1,1,1 +access_student_scholarship_executive,Student Scholarship Exec,model_education_student_scholarship,group_finance_executive,1,0,0,0 +access_student_refund_manager,Student Refund Manager,model_education_student_refund,group_finance_manager,1,1,1,1 +access_student_refund_executive,Student Refund Exec,model_education_student_refund,group_finance_executive,1,0,0,0 diff --git a/addons/education_finance/tests/__init__.py b/addons/education_finance/tests/__init__.py new file mode 100644 index 0000000..4081b7f --- /dev/null +++ b/addons/education_finance/tests/__init__.py @@ -0,0 +1 @@ +from . import test_finance diff --git a/addons/education_finance/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_finance/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..b324482 Binary files /dev/null and b/addons/education_finance/tests/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_finance/tests/__pycache__/test_finance.cpython-310.pyc b/addons/education_finance/tests/__pycache__/test_finance.cpython-310.pyc new file mode 100644 index 0000000..d910b22 Binary files /dev/null and b/addons/education_finance/tests/__pycache__/test_finance.cpython-310.pyc differ diff --git a/addons/education_finance/tests/test_finance.py b/addons/education_finance/tests/test_finance.py new file mode 100644 index 0000000..1c0079e --- /dev/null +++ b/addons/education_finance/tests/test_finance.py @@ -0,0 +1,278 @@ +# pyrefly: ignore [missing-import] +from odoo.tests.common import TransactionCase +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError + + +class TestStudentFinance(TransactionCase): + + def setUp(self): + super(TestStudentFinance, self).setUp() + + # Base settings + self.company = self.env.ref('base.main_company') + + # Setup master data + self.campus = self.env['education.campus'].create({ + 'name': 'Finance Test Campus', + 'code': 'FTC', + }) + self.department = self.env['education.department'].create({ + 'name': 'Finance Test Dept', + 'code': 'FTD', + 'campus_id': self.campus.id, + }) + self.level = self.env['education.academic.level'].create({ + 'name': 'Finance Test Level', + 'code': 'FTL', + }) + self.program = self.env['education.program'].create({ + 'name': 'Finance Test Program', + 'code': 'FTP', + 'department_id': self.department.id, + 'level_id': self.level.id, + }) + self.year = self.env['education.academic.year'].create({ + 'name': 'Finance Test Year', + 'code': 'FTY26', + 'start_date': '2026-06-01', + 'end_date': '2027-05-31', + }) + self.term = self.env['education.academic.term'].create({ + 'name': 'Finance Test Term 1', + 'code': 'FTT1', + 'academic_year_id': self.year.id, + 'start_date': '2026-06-01', + 'end_date': '2026-12-31', + }) + self.batch = self.env['education.batch'].create({ + 'name': 'Finance Test Batch 2026', + 'code': 'FTB26', + }) + self.semester = self.env['education.semester'].create({ + 'name': 'Finance Test Semester 1', + 'code': 'FTS1', + }) + self.section = self.env['education.section'].create({ + 'name': 'Finance Test Section A', + 'code': 'FTSA', + }) + + # Create student + self.student = self.env['education.student'].create({ + 'first_name': 'John', + 'last_name': 'Doe', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.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.id, + }) + + # Create Fee Config + self.category = self.env['education.fee.category'].create({ + 'name': 'Finance Test Tuition Category', + 'code': 'FTUT', + }) + self.fee_type = self.env['education.fee.type'].create({ + 'name': 'Finance Test Tuition Fee', + 'category_id': self.category.id, + 'amount': 1000.0, + }) + self.fee_type_lab = self.env['education.fee.type'].create({ + 'name': 'Finance Test Lab Fee', + 'category_id': self.category.id, + 'amount': 500.0, + }) + self.fee_structure = self.env['education.fee.structure'].create({ + 'name': 'Finance Test Year 1 Structure', + 'academic_year_id': self.year.id, + 'level_id': self.level.id, + }) + self.fee_line = self.env['education.fee.structure.line'].create({ + 'fee_structure_id': self.fee_structure.id, + 'fee_type_id': self.fee_type.id, + 'amount': 1000.0, + }) + self.fee_line_lab = self.env['education.fee.structure.line'].create({ + 'fee_structure_id': self.fee_structure.id, + 'fee_type_id': self.fee_type_lab.id, + 'amount': 500.0, + }) + + def test_fee_assignment_and_ledger(self): + """Test assigning fees to a student creates ledger debits and updates metrics""" + 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, + }) + + # Verify initial metrics + self.assertEqual(self.student.outstanding_amount, 0.0) + + # Confirm Assignment + assignment.action_confirm() + self.student.invalidate_recordset() + + # Ledger entry verification + ledger_lines = self.env['education.student.ledger'].search([('student_id', '=', self.student.id)], order='date asc, id asc') + # We have 2 lines from the structure + self.assertEqual(len(ledger_lines), 2) + self.assertEqual(ledger_lines[0].debit, 1000.0) + self.assertEqual(ledger_lines[1].debit, 500.0) + + # Verify running balance + self.assertEqual(ledger_lines[0].balance, 1000.0) + self.assertEqual(ledger_lines[1].balance, 1500.0) + + # Financial metrics check + self.assertEqual(self.student.total_fees, 1500.0) + self.assertEqual(self.student.outstanding_amount, 1500.0) + self.assertEqual(self.student.payment_status, 'overdue') + + def test_partial_and_multiple_payments(self): + """Test registering partial and multiple payments updates outstanding dues and running balance""" + 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.action_confirm() + + # First payment: partial (500.0) + payment1 = self.env['education.student.payment'].create({ + 'student_id': self.student.id, + 'amount': 500.0, + 'payment_method': 'cash', + 'receipt_number': 'TEST-PARTIAL-1', + }) + payment1.action_confirm() + self.student.invalidate_recordset() + + self.assertEqual(self.student.total_paid, 500.0) + self.assertEqual(self.student.outstanding_amount, 1000.0) + self.assertEqual(self.student.payment_status, 'partial') + + # Verify running balances + ledger_lines = self.env['education.student.ledger'].search([('student_id', '=', self.student.id)], order='date asc, id asc') + # 2 debit lines + 1 credit line = 3 lines total + self.assertEqual(len(ledger_lines), 3) + self.assertEqual(ledger_lines[2].balance, 1000.0) + + # Second payment: full remaining (1000.0) + payment2 = self.env['education.student.payment'].create({ + 'student_id': self.student.id, + 'amount': 1000.0, + 'payment_method': 'online', + 'receipt_number': 'TEST-PARTIAL-2', + }) + payment2.action_confirm() + self.student.invalidate_recordset() + + self.assertEqual(self.student.total_paid, 1500.0) + self.assertEqual(self.student.outstanding_amount, 0.0) + self.assertEqual(self.student.payment_status, 'fully_paid') + + ledger_lines_all = self.env['education.student.ledger'].search([('student_id', '=', self.student.id)], order='date asc, id asc') + self.assertEqual(len(ledger_lines_all), 4) + self.assertEqual(ledger_lines_all[3].balance, 0.0) + + def test_overpayment_validation(self): + """Test that paying more than outstanding raises validation error""" + 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.action_confirm() + self.student.invalidate_recordset() + + payment = self.env['education.student.payment'].create({ + 'student_id': self.student.id, + 'amount': 2000.0, + 'payment_method': 'cash', + 'receipt_number': 'TEST-OVERPAY', + }) + + with self.assertRaises(ValidationError): + payment.action_confirm() + + def test_scholarships_and_discounts(self): + """Test applying discounts and scholarships reduces dues and updates ledger running balance""" + 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.action_confirm() + + # Apply Discount + discount = self.env['education.student.discount'].create({ + 'student_id': self.student.id, + 'type': 'fixed', + 'amount': 200.0, + 'reason': 'Merit Discount', + }) + discount.action_confirm() + self.student.invalidate_recordset() + self.assertEqual(self.student.outstanding_amount, 1300.0) + + # Apply Scholarship + scholarship = self.env['education.student.scholarship'].create({ + 'student_id': self.student.id, + 'scholarship_name': 'Sponsor Grant', + 'type': 'percentage', + 'amount': 10.0, # 10% of 1300 = 130 + }) + scholarship.action_confirm() + self.student.invalidate_recordset() + self.assertEqual(self.student.outstanding_amount, 1170.0) + + def test_refunds(self): + """Test refund creates outstanding ledger entries to increase the running balance""" + 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.action_confirm() + + payment = self.env['education.student.payment'].create({ + 'student_id': self.student.id, + 'amount': 1000.0, + 'payment_method': 'cash', + 'receipt_number': 'TEST-REFUND-PAY', + }) + payment.action_confirm() + self.student.invalidate_recordset() + self.assertEqual(self.student.outstanding_amount, 500.0) + + # Try to refund more than paid + with self.assertRaises(ValidationError): + self.env['education.student.refund'].create({ + 'payment_id': payment.id, + 'amount': 1200.0, + 'reason': 'Accidental Over-Refund', + }) + + # Valid refund of 500 + refund_pass = self.env['education.student.refund'].create({ + 'payment_id': payment.id, + 'amount': 500.0, + 'reason': 'Course Withdrawal Partial', + }) + refund_pass.action_approve() + self.student.invalidate_recordset() + + # Outstanding should increase by 500 (from 500 to 1000) + self.assertEqual(self.student.outstanding_amount, 1000.0) diff --git a/addons/education_finance/views/fee_assignment_views.xml b/addons/education_finance/views/fee_assignment_views.xml new file mode 100644 index 0000000..3a6f78d --- /dev/null +++ b/addons/education_finance/views/fee_assignment_views.xml @@ -0,0 +1,71 @@ + + + + education.fee.assignment.tree + education.fee.assignment + + + + + + + + + + + + + + + education.fee.assignment.form + education.fee.assignment + +
+
+
+ + + + + + + + + + + + + + +
+
+
+ + + education.fee.assignment.search + education.fee.assignment + + + + + + + + + + + + + + + + + Fee Assignments + education.fee.assignment + tree,form + + +
diff --git a/addons/education_finance/views/fee_category_views.xml b/addons/education_finance/views/fee_category_views.xml new file mode 100644 index 0000000..33f5e1c --- /dev/null +++ b/addons/education_finance/views/fee_category_views.xml @@ -0,0 +1,33 @@ + + + + education.fee.category.tree + education.fee.category + + + + + + + + + + + + education.fee.category.search + education.fee.category + + + + + + + + + + Fee Categories + education.fee.category + tree + + + diff --git a/addons/education_finance/views/fee_structure_views.xml b/addons/education_finance/views/fee_structure_views.xml new file mode 100644 index 0000000..f41b98b --- /dev/null +++ b/addons/education_finance/views/fee_structure_views.xml @@ -0,0 +1,83 @@ + + + + education.fee.structure.tree + education.fee.structure + + + + + + + + + + + + + + + education.fee.structure.form + education.fee.structure + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + education.fee.structure.search + education.fee.structure + + + + + + + + + + + + + + + + + Fee Structures + education.fee.structure + tree,form + + +
diff --git a/addons/education_finance/views/fee_type_views.xml b/addons/education_finance/views/fee_type_views.xml new file mode 100644 index 0000000..90db44d --- /dev/null +++ b/addons/education_finance/views/fee_type_views.xml @@ -0,0 +1,41 @@ + + + + education.fee.type.tree + education.fee.type + + + + + + + + + + + + + + + education.fee.type.search + education.fee.type + + + + + + + + + + + + + + + Fee Types + education.fee.type + tree + + + diff --git a/addons/education_finance/views/finance_menus.xml b/addons/education_finance/views/finance_menus.xml new file mode 100644 index 0000000..204f122 --- /dev/null +++ b/addons/education_finance/views/finance_menus.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/education_finance/views/student_discount_views.xml b/addons/education_finance/views/student_discount_views.xml new file mode 100644 index 0000000..b154feb --- /dev/null +++ b/addons/education_finance/views/student_discount_views.xml @@ -0,0 +1,70 @@ + + + + education.student.discount.tree + education.student.discount + + + + + + + + + + + + + education.student.discount.form + education.student.discount + +
+
+
+ + + + + + + + + + + + + + + + + +
+
+
+ + + education.student.discount.search + education.student.discount + + + + + + + + + + + + + + + Discounts + education.student.discount + tree,form + + +
diff --git a/addons/education_finance/views/student_ledger_views.xml b/addons/education_finance/views/student_ledger_views.xml new file mode 100644 index 0000000..cbb8f81 --- /dev/null +++ b/addons/education_finance/views/student_ledger_views.xml @@ -0,0 +1,68 @@ + + + + education.student.ledger.tree + education.student.ledger + + + + + + + + + + + + + + + + education.student.ledger.search + education.student.ledger + + + + + + + + + + + + + + + + + education.student.ledger.pivot + education.student.ledger + + + + + + + + + + + + education.student.ledger.graph + education.student.ledger + + + + + + + + + + Student Ledger + education.student.ledger + tree,pivot,graph + + + diff --git a/addons/education_finance/views/student_payment_views.xml b/addons/education_finance/views/student_payment_views.xml new file mode 100644 index 0000000..4b6fda6 --- /dev/null +++ b/addons/education_finance/views/student_payment_views.xml @@ -0,0 +1,105 @@ + + + + education.student.payment.tree + education.student.payment + + + + + + + + + + + + + + + education.student.payment.form + education.student.payment + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + education.student.payment.search + education.student.payment + + + + + + + + + + + + + + + + + + education.student.payment.pivot + education.student.payment + + + + + + + + + + + education.student.payment.graph + education.student.payment + + + + + + + + + + Payments + education.student.payment + tree,form,pivot,graph + + +
diff --git a/addons/education_finance/views/student_refund_views.xml b/addons/education_finance/views/student_refund_views.xml new file mode 100644 index 0000000..0881bf2 --- /dev/null +++ b/addons/education_finance/views/student_refund_views.xml @@ -0,0 +1,69 @@ + + + + education.student.refund.tree + education.student.refund + + + + + + + + + + + + + education.student.refund.form + education.student.refund + +
+
+
+ + + + + + + + + + + + + + + +
+
+
+ + + education.student.refund.search + education.student.refund + + + + + + + + + + + + + + + + Refunds + education.student.refund + tree,form + + +
diff --git a/addons/education_finance/views/student_scholarship_views.xml b/addons/education_finance/views/student_scholarship_views.xml new file mode 100644 index 0000000..d6858c2 --- /dev/null +++ b/addons/education_finance/views/student_scholarship_views.xml @@ -0,0 +1,73 @@ + + + + education.student.scholarship.tree + education.student.scholarship + + + + + + + + + + + + + + education.student.scholarship.form + education.student.scholarship + +
+
+
+ +
+
+ + + + + + + + + + + + +
+
+
+
+ + + education.student.scholarship.search + education.student.scholarship + + + + + + + + + + + + + + + + Scholarships + education.student.scholarship + tree,form + + +
diff --git a/addons/education_finance/views/student_views_inherit.xml b/addons/education_finance/views/student_views_inherit.xml new file mode 100644 index 0000000..fae6648 --- /dev/null +++ b/addons/education_finance/views/student_views_inherit.xml @@ -0,0 +1,89 @@ + + + + + education.student.form.inherit.finance + education.student + + + + +
+ + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/addons/education_portal/__init__.py b/addons/education_portal/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/education_portal/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/education_portal/__manifest__.py b/addons/education_portal/__manifest__.py new file mode 100644 index 0000000..7e9d14f --- /dev/null +++ b/addons/education_portal/__manifest__.py @@ -0,0 +1,40 @@ +{ + 'name': 'Education Portal', + 'version': '17.0.1.0.0', + 'category': 'Education', + 'summary': 'Student Self-Service Portal for Education ERP', + 'description': """ + Student Portal supporting: + - Student Dashboard with summaries + - Profile editing (contact information) + - Academic details display + - Fee Ledger, itemized dues checkout, and PDF receipt downloads + - Production-Ready Online Fee Payment System with Razorpay integration + - Photo/Document uploads + - Announcements, Notices, and Circulars + - Secure Portal Controllers and Security isolation + """, + 'author': 'Senior Solution Architect', + 'depends': [ + 'education_base', + 'education_student', + 'education_finance', + 'portal', + ], + 'data': [ + 'security/ir.model.access.csv', + 'security/education_portal_security.xml', + 'data/payment_sequence.xml', + 'views/announcement_views.xml', + 'views/res_config_settings_views.xml', + 'views/payment_transaction_views.xml', + 'views/portal_templates.xml', + 'views/portal_payment_templates.xml', + 'views/student_views_inherit.xml', + 'views/login_templates.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, + 'license': 'LGPL-3', +} diff --git a/addons/education_portal/__pycache__/__init__.cpython-310.pyc b/addons/education_portal/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..24272e7 Binary files /dev/null and b/addons/education_portal/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_portal/__pycache__/__manifest__.cpython-310.pyc b/addons/education_portal/__pycache__/__manifest__.cpython-310.pyc new file mode 100644 index 0000000..82bf573 Binary files /dev/null and b/addons/education_portal/__pycache__/__manifest__.cpython-310.pyc differ diff --git a/addons/education_portal/controllers/__init__.py b/addons/education_portal/controllers/__init__.py new file mode 100644 index 0000000..2cd61f3 --- /dev/null +++ b/addons/education_portal/controllers/__init__.py @@ -0,0 +1,2 @@ +from . import main +from . import payment diff --git a/addons/education_portal/controllers/__pycache__/__init__.cpython-310.pyc b/addons/education_portal/controllers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..0af6f80 Binary files /dev/null and b/addons/education_portal/controllers/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_portal/controllers/__pycache__/main.cpython-310.pyc b/addons/education_portal/controllers/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..4baee49 Binary files /dev/null and b/addons/education_portal/controllers/__pycache__/main.cpython-310.pyc differ diff --git a/addons/education_portal/controllers/__pycache__/payment.cpython-310.pyc b/addons/education_portal/controllers/__pycache__/payment.cpython-310.pyc new file mode 100644 index 0000000..5106337 Binary files /dev/null and b/addons/education_portal/controllers/__pycache__/payment.cpython-310.pyc differ diff --git a/addons/education_portal/controllers/main.py b/addons/education_portal/controllers/main.py new file mode 100644 index 0000000..bc8b3d3 --- /dev/null +++ b/addons/education_portal/controllers/main.py @@ -0,0 +1,242 @@ +import base64 +# pyrefly: ignore [missing-import] +from odoo import http +# pyrefly: ignore [missing-import] +from odoo.http import request +# pyrefly: ignore [missing-import] +from odoo.addons.portal.controllers.portal import CustomerPortal + + +class StudentPortalController(CustomerPortal): + + def _get_student(self): + return request.env.user.student_id + + def _prepare_home_portal_values(self, counters): + values = super()._prepare_home_portal_values(counters) + student = self._get_student() + if student: + values['student_portal_count'] = 1 + return values + + @http.route(['/my', '/my/home'], type='http', auth="user", website=True) + def home(self, **kw): + student = self._get_student() + if student: + return request.redirect('/my/education/dashboard') + return super().home(**kw) + + @http.route(['/my/education/dashboard'], type='http', auth="user", website=True) + def student_dashboard(self, **kw): + student = self._get_student() + if not student: + return request.render("education_portal.portal_no_student") + + # Fetch notices, circulars, events + announcements = request.env['education.announcement'].search([('active', '=', True)], limit=5) + + values = { + 'student': student, + 'announcements': announcements, + 'page_name': 'student_dashboard', + } + return request.render("education_portal.portal_student_dashboard", values) + + @http.route(['/my/education/profile'], type='http', auth="user", website=True) + def student_profile(self, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + values = { + 'student': student, + 'page_name': 'student_profile', + 'success': kw.get('success'), + } + return request.render("education_portal.portal_student_profile", values) + + @http.route(['/my/education/profile/update'], type='http', auth="user", methods=['POST'], website=True, csrf=True) + def student_profile_update(self, **post): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + update_vals = {} + # Editable contact fields + if 'street' in post: + update_vals['street'] = post.get('street') + if 'street2' in post: + update_vals['street2'] = post.get('street2') + if 'city' in post: + update_vals['city'] = post.get('city') + if 'zip' in post: + update_vals['zip'] = post.get('zip') + if 'guardian_phone' in post: + update_vals['guardian_phone'] = post.get('guardian_phone') + if 'guardian_email' in post: + update_vals['guardian_email'] = post.get('guardian_email') + + if update_vals: + student.sudo().write(update_vals) + + return request.redirect('/my/education/profile?success=1') + + @http.route(['/my/education/finance'], type='http', auth="user", website=True) + def student_finance(self, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + ledger_lines = student.env['education.student.ledger'].search([('student_id', '=', student.id)], order='date asc, id asc') + payments = student.env['education.student.payment'].search([('student_id', '=', student.id), ('status', '=', 'confirmed')], order='payment_date desc, id desc') + if student.outstanding_amount <= 0: + unpaid_assignments = student.env['education.fee.assignment'].search([('student_id', '=', student.id), ('payment_status', '!=', 'paid')]) + if unpaid_assignments: + unpaid_assignments.sudo().write({'payment_status': 'paid'}) + pending_fee_assignments = student.env['education.fee.assignment'] + else: + pending_fee_assignments = student.env['education.fee.assignment'].search([('student_id', '=', student.id), ('payment_status', '!=', 'paid')], order='assignment_date desc') + + values = { + 'student': student, + 'ledger_lines': ledger_lines, + 'payments': payments, + 'pending_fee_assignments': pending_fee_assignments, + 'page_name': 'student_finance', + 'success_payment': kw.get('success_payment'), + } + return request.render("education_portal.portal_student_finance", values) + + @http.route(['/my/education/finance/pay'], type='http', auth="user", website=True) + def student_pay(self, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + values = { + 'student': student, + 'page_name': 'student_pay', + 'error': kw.get('error'), + } + return request.render("education_portal.portal_student_pay", values) + + @http.route(['/my/education/finance/pay/confirm'], type='http', auth="user", methods=['POST'], website=True) + def student_pay_confirm(self, **post): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + try: + amount = float(post.get('amount', 0.0)) + except ValueError: + amount = 0.0 + + payment_method = post.get('payment_method', 'online') + + if amount <= 0 or amount > student.outstanding_amount: + return request.redirect('/my/education/finance/pay?error=1') + + import time + # Create payment record + payment = student.env['education.student.payment'].sudo().create({ + 'student_id': student.id, + 'amount': amount, + 'payment_method': payment_method, + 'receipt_number': f"REC-ONLINE-{int(time.time())}-{student.id}", + 'reference_number': post.get('reference_number', 'ONLINE-PORTAL'), + 'remarks': 'Payment submitted via Student Self-Service Portal.', + 'company_id': student.company_id.id, + }) + payment.action_confirm() + + try: + return request.redirect('/my/education/finance?success_payment=1') + except RuntimeError: + # pyrefly: ignore [missing-import] + from odoo.http import Response + return Response("Success") + + @http.route(['/my/education/finance/receipt/'], type='http', auth="user", website=True) + def download_receipt(self, payment_id, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + payment = student.env['education.student.payment'].sudo().browse(payment_id) + if not payment.exists() or payment.student_id.id != student.id: + return request.redirect('/my/education/finance') + + pdf, _ = student.env['ir.actions.report'].sudo()._render_qweb_pdf('education_finance.action_report_receipt_pdf', [payment.id]) + pdfhttpheaders = [ + ('Content-Type', 'application/pdf'), + ('Content-Length', len(pdf)), + ('Content-Disposition', f'attachment; filename=Receipt-{payment.receipt_number or "PAY"}.pdf;') + ] + return request.make_response(pdf, headers=pdfhttpheaders) + + @http.route(['/my/education/documents'], type='http', auth="user", website=True) + def student_documents(self, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + values = { + 'student': student, + 'page_name': 'student_documents', + 'success': kw.get('success'), + } + return request.render("education_portal.portal_student_documents", values) + + @http.route(['/my/education/documents/upload'], type='http', auth="user", methods=['POST'], website=True, csrf=True) + def student_documents_upload(self, **post): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + vals = {} + for field in ['image', 'doc_passport', 'doc_aadhaar', 'doc_birth_cert', 'doc_transfer_cert', 'doc_community_cert', 'doc_mark_sheets']: + file = post.get(field) + if file and file.filename: + file_content = file.read() + vals[field] = base64.b64encode(file_content) + filename_field = f"{field}_name" + if hasattr(student, filename_field): + vals[filename_field] = file.filename + + if vals: + student.sudo().write(vals) + + return request.redirect('/my/education/documents?success=1') + + @http.route(['/my/education/attendance'], type='http', auth="user", website=True) + def student_attendance(self, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + attendance_records = student.env['education.attendance'].search([('student_id', '=', student.id)], order='date desc, id desc') + + total_sessions = len(attendance_records) + present_count = len([a for a in attendance_records if a.status == 'present']) + absent_count = len([a for a in attendance_records if a.status == 'absent']) + late_count = len([a for a in attendance_records if a.status == 'late']) + medical_leave_count = len([a for a in attendance_records if a.status == 'medical_leave']) + on_duty_count = len([a for a in attendance_records if a.status == 'on_duty']) + + effective_present = present_count + on_duty_count + (late_count * 0.5) + attendance_pct = round((effective_present / total_sessions * 100), 1) if total_sessions > 0 else 100.0 + + values = { + 'student': student, + 'attendance_records': attendance_records, + 'total_sessions': total_sessions, + 'present_count': present_count, + 'absent_count': absent_count, + 'late_count': late_count, + 'medical_leave_count': medical_leave_count, + 'on_duty_count': on_duty_count, + 'attendance_pct': attendance_pct, + 'page_name': 'student_attendance', + } + return request.render("education_portal.portal_student_attendance", values) + diff --git a/addons/education_portal/controllers/payment.py b/addons/education_portal/controllers/payment.py new file mode 100644 index 0000000..df6a667 --- /dev/null +++ b/addons/education_portal/controllers/payment.py @@ -0,0 +1,121 @@ +import json +# pyrefly: ignore [missing-import] +from odoo import http +# pyrefly: ignore [missing-import] +from odoo.http import request, Response +# pyrefly: ignore [missing-import] +from odoo.addons.portal.controllers.portal import CustomerPortal +# pyrefly: ignore [missing-import] +from odoo.addons.education_portal.services.payment_service import PaymentService + + +class StudentPaymentController(CustomerPortal): + + def _get_student(self): + return request.env.user.student_id + + @http.route(['/my/education/payment/create'], type='json', auth="user", website=True, csrf=True) + def payment_create(self, **post): + student = self._get_student() + if not student: + return {'error': 'Student record not found.'} + + fee_ids = post.get('fee_ids') or [] + custom_amount = post.get('amount') + try: + order_data = PaymentService.create_order( + env=request.env, + student=student, + fee_ids=fee_ids, + custom_amount=custom_amount + ) + return order_data + except Exception as e: + return {'error': str(e)} + + @http.route(['/my/education/payment/verify'], type='json', auth="user", website=True, csrf=True) + def payment_verify(self, **post): + transaction_id = post.get('transaction_id') + razorpay_order_id = post.get('razorpay_order_id') + razorpay_payment_id = post.get('razorpay_payment_id') + razorpay_signature = post.get('razorpay_signature') + + if not transaction_id: + return {'error': 'Missing transaction ID.'} + + try: + res = PaymentService.verify_and_reconcile( + env=request.env, + transaction_id=transaction_id, + razorpay_order_id=razorpay_order_id, + razorpay_payment_id=razorpay_payment_id, + razorpay_signature=razorpay_signature + ) + return res + except Exception as e: + return {'error': str(e)} + + @http.route(['/my/education/payment/webhook'], type='http', auth="public", methods=['POST'], csrf=False) + def payment_webhook(self, **kw): + payload_bytes = request.httprequest.data + sig_header = request.httprequest.headers.get('X-Razorpay-Signature', '') + + result = PaymentService.handle_webhook(request.env, payload_bytes, sig_header) + return Response(json.dumps(result), status=200, mimetype='application/json') + + @http.route(['/my/education/payment/success/'], type='http', auth="user", website=True) + def payment_success(self, tx_id, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + tx = request.env['education.payment.transaction'].sudo().browse(tx_id) + if not tx.exists() or tx.student_id.id != student.id: + return request.redirect('/my/education/finance') + + values = { + 'student': student, + 'transaction': tx, + 'page_name': 'payment_success', + } + return request.render("education_portal.portal_payment_success", values) + + @http.route(['/my/education/payment/failed/'], type='http', auth="user", website=True) + def payment_failed(self, tx_id, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + tx = request.env['education.payment.transaction'].sudo().browse(tx_id) + if not tx.exists() or tx.student_id.id != student.id: + return request.redirect('/my/education/finance') + + values = { + 'student': student, + 'transaction': tx, + 'page_name': 'payment_failed', + } + return request.render("education_portal.portal_payment_failed", values) + + @http.route(['/my/education/payment/history'], type='http', auth="user", website=True) + def payment_history(self, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + domain = [('student_id', '=', student.id)] + status_filter = kw.get('status') + if status_filter: + domain.append(('status', '=', status_filter)) + + transactions = request.env['education.payment.transaction'].sudo().search(domain, order='create_date desc') + payments = request.env['education.student.payment'].sudo().search([('student_id', '=', student.id)], order='payment_date desc') + + values = { + 'student': student, + 'transactions': transactions, + 'payments': payments, + 'status_filter': status_filter, + 'page_name': 'payment_history', + } + return request.render("education_portal.portal_payment_history", values) diff --git a/addons/education_portal/data/payment_sequence.xml b/addons/education_portal/data/payment_sequence.xml new file mode 100644 index 0000000..334888e --- /dev/null +++ b/addons/education_portal/data/payment_sequence.xml @@ -0,0 +1,12 @@ + + + + + Education Payment Transaction Sequence + education.payment.transaction + TXN/%(year)s/ + 5 + + + + diff --git a/addons/education_portal/models/__init__.py b/addons/education_portal/models/__init__.py new file mode 100644 index 0000000..4bab07a --- /dev/null +++ b/addons/education_portal/models/__init__.py @@ -0,0 +1,5 @@ +from . import announcement +from . import student_inherit +from . import fee_assignment_inherit +from . import payment_transaction +from . import res_config_settings diff --git a/addons/education_portal/models/__pycache__/__init__.cpython-310.pyc b/addons/education_portal/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..4fe149e Binary files /dev/null and b/addons/education_portal/models/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_portal/models/__pycache__/announcement.cpython-310.pyc b/addons/education_portal/models/__pycache__/announcement.cpython-310.pyc new file mode 100644 index 0000000..ac5cca3 Binary files /dev/null and b/addons/education_portal/models/__pycache__/announcement.cpython-310.pyc differ diff --git a/addons/education_portal/models/__pycache__/fee_assignment_inherit.cpython-310.pyc b/addons/education_portal/models/__pycache__/fee_assignment_inherit.cpython-310.pyc new file mode 100644 index 0000000..0255261 Binary files /dev/null and b/addons/education_portal/models/__pycache__/fee_assignment_inherit.cpython-310.pyc differ diff --git a/addons/education_portal/models/__pycache__/payment_transaction.cpython-310.pyc b/addons/education_portal/models/__pycache__/payment_transaction.cpython-310.pyc new file mode 100644 index 0000000..2c57d36 Binary files /dev/null and b/addons/education_portal/models/__pycache__/payment_transaction.cpython-310.pyc differ diff --git a/addons/education_portal/models/__pycache__/res_config_settings.cpython-310.pyc b/addons/education_portal/models/__pycache__/res_config_settings.cpython-310.pyc new file mode 100644 index 0000000..a192432 Binary files /dev/null and b/addons/education_portal/models/__pycache__/res_config_settings.cpython-310.pyc differ diff --git a/addons/education_portal/models/__pycache__/student_inherit.cpython-310.pyc b/addons/education_portal/models/__pycache__/student_inherit.cpython-310.pyc new file mode 100644 index 0000000..8c6578c Binary files /dev/null and b/addons/education_portal/models/__pycache__/student_inherit.cpython-310.pyc differ diff --git a/addons/education_portal/models/announcement.py b/addons/education_portal/models/announcement.py new file mode 100644 index 0000000..bbaae36 --- /dev/null +++ b/addons/education_portal/models/announcement.py @@ -0,0 +1,26 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models + + +class EducationAnnouncement(models.Model): + _name = 'education.announcement' + _description = 'Education Announcement' + _order = 'date_published desc, id desc' + + title = fields.Char(string='Title', required=True, translate=True) + content = fields.Html(string='Content', required=True) + date_published = fields.Date(string='Date Published', required=True, default=fields.Date.context_today) + expiry_date = fields.Date(string='Expiry Date') + announcement_type = fields.Selection([ + ('notice', 'Notice'), + ('circular', 'Circular'), + ('event', 'Event') + ], string='Type', required=True, default='notice') + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True, + ) diff --git a/addons/education_portal/models/fee_assignment_inherit.py b/addons/education_portal/models/fee_assignment_inherit.py new file mode 100644 index 0000000..698c940 --- /dev/null +++ b/addons/education_portal/models/fee_assignment_inherit.py @@ -0,0 +1,21 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models + + +class EducationFeeAssignmentInherit(models.Model): + _inherit = 'education.fee.assignment' + + 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 diff --git a/addons/education_portal/models/payment_transaction.py b/addons/education_portal/models/payment_transaction.py new file mode 100644 index 0000000..dbfb9ce --- /dev/null +++ b/addons/education_portal/models/payment_transaction.py @@ -0,0 +1,125 @@ +import json +import logging +# pyrefly: ignore [missing-import] +from odoo import api, fields, models, _ +# pyrefly: ignore [missing-import] +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +class EducationPaymentTransaction(models.Model): + _name = 'education.payment.transaction' + _description = 'Education Payment Transaction' + _order = 'create_date desc, id desc' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + name = fields.Char( + string='Transaction Reference', + required=True, + copy=False, + readonly=True, + default=lambda self: _('New'), + index=True + ) + student_id = fields.Many2one( + 'education.student', + string='Student', + required=True, + ondelete='cascade', + index=True, + tracking=True + ) + company_id = fields.Many2one( + 'res.company', + string='Company', + required=True, + default=lambda self: self.env.company, + index=True + ) + currency_id = fields.Many2one( + 'res.currency', + string='Currency', + required=True, + default=lambda self: self.env.company.currency_id + ) + amount = fields.Monetary( + string='Amount', + currency_field='currency_id', + required=True, + tracking=True + ) + provider = fields.Selection([ + ('razorpay', 'Razorpay'), + ('stripe', 'Stripe'), + ('cashfree', 'Cashfree'), + ('manual', 'Manual'), + ('mock', 'Sandbox Simulator'), + ], string='Payment Gateway', default='razorpay', required=True, tracking=True) + + razorpay_order_id = fields.Char(string='Razorpay Order ID', index=True, copy=False) + razorpay_payment_id = fields.Char(string='Razorpay Payment ID', index=True, copy=False) + razorpay_signature = fields.Char(string='Razorpay Signature', copy=False) + + status = fields.Selection([ + ('pending', 'Pending'), + ('authorized', 'Authorized'), + ('success', 'Success'), + ('failed', 'Failed'), + ('cancelled', 'Cancelled'), + ('refunded', 'Refunded'), + ], string='Status', default='pending', required=True, index=True, tracking=True) + + payment_date = fields.Datetime(string='Payment Date', default=fields.Datetime.now, tracking=True) + verified_on = fields.Datetime(string='Verified On', copy=False) + receipt_number = fields.Char(string='Receipt Number', copy=False, tracking=True) + + fee_assignment_ids = fields.Many2many( + 'education.fee.assignment', + 'education_payment_tx_fee_rel', + 'tx_id', + 'fee_id', + string='Paid Dues' + ) + payment_id = fields.Many2one('education.student.payment', string='Student Payment Record', copy=False) + gateway_response = fields.Text(string='Gateway Response JSON', copy=False) + error_message = fields.Text(string='Error / Failure Reason', copy=False, tracking=True) + retry_count = fields.Integer(string='Retry Count', default=0, copy=False) + audit_log = fields.Text(string='Audit Log', copy=False) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('education.payment.transaction') or _('New') + if not vals.get('audit_log'): + vals['audit_log'] = f"[{fields.Datetime.now()}] Transaction created in Pending state.\n" + return super().create(vals_list) + + def log_audit(self, message): + """Append audit entry with timestamp""" + for rec in self: + entry = f"[{fields.Datetime.now()}] {message}\n" + rec.audit_log = (rec.audit_log or "") + entry + rec.message_post(body=message) + + def action_mark_failed(self, error_reason="Payment verification failed or dismissed."): + """Mark transaction failed""" + for rec in self: + if rec.status in ('success', 'refunded'): + raise ValidationError(_("Cannot mark a successful or refunded transaction as failed.")) + rec.write({ + 'status': 'failed', + 'error_message': error_reason, + }) + rec.log_audit(f"Transaction marked FAILED. Reason: {error_reason}") + + def action_process_refund(self): + """Finance Office Action: Process refund and reverse ledger entry""" + # pyrefly: ignore [missing-import] + from odoo.addons.education_portal.services.payment_service import PaymentService + for rec in self: + if rec.status != 'success': + raise UserError(_("Only successful transactions can be refunded.")) + PaymentService.process_refund(rec.env, rec) + return True diff --git a/addons/education_portal/models/res_config_settings.py b/addons/education_portal/models/res_config_settings.py new file mode 100644 index 0000000..2d9459d --- /dev/null +++ b/addons/education_portal/models/res_config_settings.py @@ -0,0 +1,83 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + enable_online_payment = fields.Boolean( + string="Enable Online Fee Payment", + config_parameter='education_portal.enable_online_payment', + default=True + ) + education_payment_provider = fields.Selection([ + ('razorpay', 'Razorpay'), + ('stripe', 'Stripe'), + ('cashfree', 'Cashfree'), + ], string="Primary Payment Gateway", config_parameter='education_portal.education_payment_provider', default='razorpay') + + razorpay_key_id = fields.Char( + string="Razorpay Key ID", + config_parameter='education_portal.razorpay_key_id' + ) + razorpay_secret_key = fields.Char( + string="Razorpay Secret Key", + config_parameter='education_portal.razorpay_secret_key' + ) + razorpay_webhook_secret = fields.Char( + string="Razorpay Webhook Secret", + config_parameter='education_portal.razorpay_webhook_secret' + ) + + stripe_publishable_key = fields.Char( + string="Stripe Publishable Key", + config_parameter='education_portal.stripe_publishable_key' + ) + stripe_secret_key = fields.Char( + string="Stripe Secret Key", + config_parameter='education_portal.stripe_secret_key' + ) + stripe_webhook_secret = fields.Char( + string="Stripe Webhook Secret", + config_parameter='education_portal.stripe_webhook_secret' + ) + + cashfree_app_id = fields.Char( + string="Cashfree App ID", + config_parameter='education_portal.cashfree_app_id' + ) + cashfree_secret_key = fields.Char( + string="Cashfree Secret Key", + config_parameter='education_portal.cashfree_secret_key' + ) + cashfree_webhook_secret = fields.Char( + string="Cashfree Webhook Secret", + config_parameter='education_portal.cashfree_webhook_secret' + ) + + payment_environment = fields.Selection([ + ('sandbox', 'Sandbox / Test Mode'), + ('production', 'Production Live Mode'), + ], string="Payment Environment", config_parameter='education_portal.payment_environment', default='sandbox') + + auto_send_payment_receipt_email = fields.Boolean( + string="Auto-Email PDF Receipts to Students", + config_parameter='education_portal.auto_send_payment_receipt_email', + default=True + ) + + # Allowed Payment Methods + payment_method_upi = fields.Boolean(string="UPI", config_parameter='education_portal.payment_method_upi', default=True) + payment_method_debit_card = fields.Boolean(string="Debit Card", config_parameter='education_portal.payment_method_debit_card', default=True) + payment_method_credit_card = fields.Boolean(string="Credit Card", config_parameter='education_portal.payment_method_credit_card', default=True) + payment_method_net_banking = fields.Boolean(string="Net Banking", config_parameter='education_portal.payment_method_net_banking', default=True) + payment_method_wallet = fields.Boolean(string="Wallet", config_parameter='education_portal.payment_method_wallet', default=True) + payment_method_direct_bank_transfer = fields.Boolean(string="Direct Bank Transfer", config_parameter='education_portal.payment_method_direct_bank_transfer', default=False) + + # Direct Bank Transfer Details + direct_bank_account_holder = fields.Char(string="Account Holder Name", config_parameter='education_portal.direct_bank_account_holder') + direct_bank_name = fields.Char(string="Bank Name", config_parameter='education_portal.direct_bank_name') + direct_bank_account_number = fields.Char(string="Account Number", config_parameter='education_portal.direct_bank_account_number') + direct_bank_ifsc = fields.Char(string="IFSC Code", config_parameter='education_portal.direct_bank_ifsc') + direct_bank_branch = fields.Char(string="Branch Name", config_parameter='education_portal.direct_bank_branch') + direct_bank_instructions = fields.Char(string="Payment Instructions", config_parameter='education_portal.direct_bank_instructions') diff --git a/addons/education_portal/models/student_inherit.py b/addons/education_portal/models/student_inherit.py new file mode 100644 index 0000000..d33e6f3 --- /dev/null +++ b/addons/education_portal/models/student_inherit.py @@ -0,0 +1,185 @@ +# pyrefly: ignore [missing-import] +from odoo import api, fields, models +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError +import secrets +import string + + +class EducationStudent(models.Model): + _inherit = 'education.student' + + portal_user_id = fields.Many2one( + 'res.users', + string='Portal User', + domain=[('share', '=', True)], + tracking=True, + copy=False, + help='Portal user linked to this student.' + ) + + portal_status = fields.Selection([ + ('draft', 'Not Created'), + ('active', 'Active'), + ('deactivated', 'Deactivated') + ], string='Portal Status', default='draft', compute='_compute_portal_status', store=True, tracking=True) + + portal_username = fields.Char( + string='Portal Username', + related='portal_user_id.login', + readonly=True + ) + + portal_email = fields.Char( + string='Portal Email', + related='portal_user_id.email', + readonly=True + ) + + portal_last_login = fields.Datetime( + string='Last Login', + related='portal_user_id.login_date', + readonly=True + ) + + is_education_admin = fields.Boolean(compute='_compute_is_education_admin') + + @api.depends('portal_user_id', 'portal_user_id.active') + def _compute_portal_status(self): + for rec in self: + if not rec.portal_user_id: + rec.portal_status = 'draft' + elif rec.portal_user_id.active: + rec.portal_status = 'active' + else: + rec.portal_status = 'deactivated' + + def _compute_is_education_admin(self): + is_admin = self.env.user.has_group('education_student.group_education_student_admin') + for rec in self: + rec.is_education_admin = is_admin + + @api.constrains('portal_user_id') + def _check_unique_portal_user(self): + for rec in self: + if rec.portal_user_id: + duplicate = self.search([ + ('portal_user_id', '=', rec.portal_user_id.id), + ('id', '!=', rec.id) + ]) + if duplicate: + raise ValidationError("This Portal User is already linked to another student.") + + def action_create_portal_user(self): + self.ensure_one() + if self.portal_user_id: + raise ValidationError("This student already has a portal user linked.") + + if not self.email: + raise ValidationError("An email address is required to create a portal user.") + + # Check if a user with this login/email already exists + existing_user = self.env['res.users'].sudo().search([('login', '=', self.email)], limit=1) + if existing_user: + # Check if this user is already linked to another student + other_student = self.search([('portal_user_id', '=', existing_user.id)], limit=1) + if other_student: + raise ValidationError(f"A user with email {self.email} already exists and is linked to student {other_student.display_name}.") + # Link to the existing user + self.portal_user_id = existing_user + return True + + # Check or create res.partner + partner = self.env['res.partner'].sudo().search([('email', '=', self.email)], limit=1) + if not partner: + partner = self.env['res.partner'].sudo().create({ + 'name': self.full_name, + 'email': self.email, + 'phone': self.phone or self.mobile, + 'type': 'contact', + 'company_id': self.company_id.id, + }) + + # Generate secure temporary password + alphabet = string.ascii_letters + string.digits + string.punctuation + temp_password = ''.join(secrets.choice(alphabet) for i in range(12)) + + # Create user linked to partner + portal_group = self.env.ref('base.group_portal') + new_user = self.env['res.users'].sudo().create({ + 'name': self.full_name, + 'login': self.email, + 'email': self.email, + 'partner_id': partner.id, + 'company_id': self.company_id.id, + 'company_ids': [(4, self.company_id.id)], + 'groups_id': [(6, 0, [portal_group.id])], + 'password': temp_password, + }) + + self.portal_user_id = new_user + + # Log details to chatter + self.message_post(body=f"Portal user created.
Username: {self.email}
Temporary Password: {temp_password}") + + # Check if SMTP is configured + smtp_configured = self.env['ir.mail_server'].sudo().search_count([('active', '=', True)]) > 0 + if smtp_configured: + try: + template = self.env.ref('auth_signup.set_password_email', raise_if_not_found=False) + if template: + new_user.partner_id.signup_prepare() + template.send_mail(new_user.id, force_send=True) + else: + mail_values = { + 'subject': f"Welcome to the University Portal, {self.full_name}", + 'body_html': f""" +

Dear {self.full_name},

+

Your portal account has been created.

+

Username: {self.email}

+

Temporary Password: {temp_password}

+

Please log in and update your password.

+ """, + 'email_to': self.email, + } + mail = self.env['mail.mail'].sudo().create(mail_values) + mail.send() + except Exception: + smtp_configured = False + + if not smtp_configured: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': 'Portal User Created', + 'message': 'Portal user created successfully. Invitation email was not sent because no outgoing mail server is configured.', + 'sticky': True, + 'type': 'warning', + } + } + + return True + + def action_open_portal_user(self): + self.ensure_one() + if self.portal_user_id: + return { + 'type': 'ir.actions.act_window', + 'name': 'Portal User', + 'res_model': 'res.users', + 'res_id': self.portal_user_id.id, + 'view_mode': 'form', + 'target': 'current', + } + + +class ResUsers(models.Model): + _inherit = 'res.users' + + student_ids = fields.One2many('education.student', 'portal_user_id', string='Students') + + @property + def student_id(self): + return self.student_ids[0] if self.student_ids else self.env['education.student'] + diff --git a/addons/education_portal/security/education_portal_security.xml b/addons/education_portal/security/education_portal_security.xml new file mode 100644 index 0000000..a21e7d3 --- /dev/null +++ b/addons/education_portal/security/education_portal_security.xml @@ -0,0 +1,39 @@ + + + + + Announcement Multi-Company Rule + + [('company_id', '=', user.company_id.id)] + + + + + Portal Student: View Own Profile + + [('portal_user_id', '=', user.id)] + + + + + Portal Student: View Own Ledger + + [('student_id.portal_user_id', '=', user.id)] + + + + + Portal Student: View Own Payments + + [('student_id.portal_user_id', '=', user.id)] + + + + + Portal Student: View Own Payment Transactions + + [('student_id.portal_user_id', '=', user.id)] + + + + diff --git a/addons/education_portal/security/ir.model.access.csv b/addons/education_portal/security/ir.model.access.csv new file mode 100644 index 0000000..0c13578 --- /dev/null +++ b/addons/education_portal/security/ir.model.access.csv @@ -0,0 +1,14 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_announcement_user,Announcement User,model_education_announcement,base.group_user,1,1,1,1 +access_announcement_portal,Announcement Portal,model_education_announcement,base.group_portal,1,0,0,0 +access_student_portal,Student Portal Access,education_student.model_education_student,base.group_portal,1,1,0,0 +access_student_ledger_portal,Ledger Portal Access,education_finance.model_education_student_ledger,base.group_portal,1,0,0,0 +access_student_payment_portal,Payment Portal Access,education_finance.model_education_student_payment,base.group_portal,1,0,0,0 +access_campus_portal,Campus Portal Access,education_base.model_education_campus,base.group_portal,1,0,0,0 +access_department_portal,Department Portal Access,education_base.model_education_department,base.group_portal,1,0,0,0 +access_program_portal,Program Portal Access,education_base.model_education_program,base.group_portal,1,0,0,0 +access_year_portal,Year Portal Access,education_base.model_education_academic_year,base.group_portal,1,0,0,0 +access_term_portal,Term Portal Access,education_base.model_education_academic_term,base.group_portal,1,0,0,0 +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 diff --git a/addons/education_portal/services/__init__.py b/addons/education_portal/services/__init__.py new file mode 100644 index 0000000..99411ed --- /dev/null +++ b/addons/education_portal/services/__init__.py @@ -0,0 +1,3 @@ +from . import razorpay_service +from . import reconciliation_service +from . import payment_service diff --git a/addons/education_portal/services/__pycache__/__init__.cpython-310.pyc b/addons/education_portal/services/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..9fc2efb Binary files /dev/null and b/addons/education_portal/services/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_portal/services/__pycache__/payment_service.cpython-310.pyc b/addons/education_portal/services/__pycache__/payment_service.cpython-310.pyc new file mode 100644 index 0000000..55294ed Binary files /dev/null and b/addons/education_portal/services/__pycache__/payment_service.cpython-310.pyc differ diff --git a/addons/education_portal/services/__pycache__/razorpay_service.cpython-310.pyc b/addons/education_portal/services/__pycache__/razorpay_service.cpython-310.pyc new file mode 100644 index 0000000..ef7a819 Binary files /dev/null and b/addons/education_portal/services/__pycache__/razorpay_service.cpython-310.pyc differ diff --git a/addons/education_portal/services/__pycache__/reconciliation_service.cpython-310.pyc b/addons/education_portal/services/__pycache__/reconciliation_service.cpython-310.pyc new file mode 100644 index 0000000..27b78be Binary files /dev/null and b/addons/education_portal/services/__pycache__/reconciliation_service.cpython-310.pyc differ diff --git a/addons/education_portal/services/payment_service.py b/addons/education_portal/services/payment_service.py new file mode 100644 index 0000000..69c7c12 --- /dev/null +++ b/addons/education_portal/services/payment_service.py @@ -0,0 +1,227 @@ +import json +import logging +# pyrefly: ignore [missing-import] +from odoo import fields, _ +# pyrefly: ignore [missing-import] +from odoo.exceptions import UserError, ValidationError +from .razorpay_service import RazorpayService +from .reconciliation_service import ReconciliationService + +_logger = logging.getLogger(__name__) + + +class PaymentService: + + @classmethod + def create_order(cls, env, student, fee_ids=None, custom_amount=None): + """ + Validate fees server-side, create pending transaction, and generate Razorpay Order. + """ + if not student: + raise ValidationError(_("No student record found for current user.")) + + fee_assignments = env['education.fee.assignment'].sudo() + if fee_ids: + fee_assignments = env['education.fee.assignment'].sudo().search([ + ('id', 'in', fee_ids), + ('student_id', '=', student.id), + ('payment_status', '!=', 'paid'), + ]) + if not fee_assignments: + raise UserError(_("Selected fee items are already paid or invalid.")) + total_amount = sum(fee.amount for fee in fee_assignments) + elif custom_amount: + try: + total_amount = float(custom_amount) + except (ValueError, TypeError): + raise UserError(_("Invalid payment amount specified.")) + if total_amount <= 0 or total_amount > student.outstanding_amount: + raise UserError(_("Payment amount must be greater than zero and cannot exceed outstanding balance.")) + else: + total_amount = student.outstanding_amount + + if total_amount <= 0: + raise UserError(_("There are no outstanding fee dues to pay.")) + + company = student.company_id or env.company + currency = company.currency_id + amount_paise = int(round(total_amount * 100)) + + # Determine gateway provider + ICP = env['ir.config_parameter'].sudo() + provider = ICP.get_param('education_portal.education_payment_provider', 'razorpay') + env_mode = ICP.get_param('education_portal.payment_environment', 'sandbox') + if env_mode == 'sandbox' and provider == 'razorpay': + provider = 'mock' if not ICP.get_param('education_portal.razorpay_key_id') else 'razorpay' + + # 1. Create Pending Payment Transaction + tx_vals = { + 'student_id': student.id, + 'company_id': company.id, + 'currency_id': currency.id, + 'amount': total_amount, + 'provider': provider, + 'status': 'pending', + 'fee_assignment_ids': [(6, 0, fee_assignments.ids)], + } + transaction = env['education.payment.transaction'].sudo().create(tx_vals) + + # 2. Call Razorpay Service + order_res = RazorpayService.create_order( + env=env, + amount_paise=amount_paise, + currency=currency.name or 'INR', + receipt=str(transaction.name)[:40], + notes={ + 'student_id': str(student.id), + 'admission_number': str(student.admission_number or ''), + 'transaction_id': str(transaction.id), + } + ) + + order_id = order_res.get('id') + transaction.sudo().write({ + 'razorpay_order_id': order_id, + 'gateway_response': json.dumps(order_res), + }) + transaction.log_audit(f"Order created on gateway. Order ID: {order_id}, Amount: ₹{total_amount:,.2f}") + + config = RazorpayService.get_config(env) + + return { + 'status': 'created', + 'transaction_id': transaction.id, + 'transaction_ref': transaction.name, + 'order_id': order_id, + 'key_id': config['key_id'], + 'amount_paise': amount_paise, + 'amount_rupees': total_amount, + 'currency': currency.name or 'INR', + 'student_name': student.display_name, + 'email': student.email or '', + 'phone': student.guardian_phone or student.phone or '', + 'company_name': company.name or 'Education Institute', + 'is_mock': order_res.get('is_mock', False), + } + + @classmethod + def verify_and_reconcile(cls, env, transaction_id, razorpay_order_id, razorpay_payment_id, razorpay_signature): + """ + Cryptographically verify signature and execute DB reconciliation. + """ + transaction = env['education.payment.transaction'].sudo().browse(transaction_id) + if not transaction.exists(): + raise UserError(_("Transaction record not found.")) + + if transaction.status == 'success': + return { + 'status': 'success', + 'redirect_url': f"/my/education/payment/success/{transaction.id}", + 'message': 'Transaction already completed.' + } + + # Store IDs + transaction.sudo().write({ + 'razorpay_order_id': razorpay_order_id or transaction.razorpay_order_id, + 'razorpay_payment_id': razorpay_payment_id, + 'razorpay_signature': razorpay_signature, + }) + + is_valid = RazorpayService.verify_signature( + env=env, + razorpay_order_id=razorpay_order_id or transaction.razorpay_order_id, + razorpay_payment_id=razorpay_payment_id, + razorpay_signature=razorpay_signature + ) + + if not is_valid: + transaction.action_mark_failed("Cryptographic HMAC-SHA256 signature verification failed.") + return { + 'status': 'failed', + 'redirect_url': f"/my/education/payment/failed/{transaction.id}", + 'error': 'Payment verification failed. Invalid signature.' + } + + # Execute Reconciliation + ReconciliationService.reconcile_transaction( + env=env, + transaction=transaction, + razorpay_payment_id=razorpay_payment_id, + raw_gateway_response=json.dumps({ + 'order_id': razorpay_order_id, + 'payment_id': razorpay_payment_id, + 'signature': razorpay_signature, + 'verified': True, + }) + ) + + return { + 'status': 'success', + 'redirect_url': f"/my/education/payment/success/{transaction.id}", + 'message': 'Payment successfully verified and reconciled.' + } + + @classmethod + def handle_webhook(cls, env, payload_bytes, signature_header): + """ + Process incoming webhooks idempotently. + """ + if not RazorpayService.verify_webhook_signature(env, payload_bytes, signature_header): + _logger.warning("Invalid webhook signature received.") + return {'status': 'error', 'reason': 'Invalid signature'} + + try: + payload = json.loads(payload_bytes.decode('utf-8')) + except Exception: + return {'status': 'error', 'reason': 'Invalid JSON body'} + + event = payload.get('event') + _logger.info("Received payment webhook event: %s", event) + + payment_entity = payload.get('payload', {}).get('payment', {}).get('entity', {}) + order_id = payment_entity.get('order_id') + payment_id = payment_entity.get('id') + + if not order_id and not payment_id: + return {'status': 'ignored', 'reason': 'No order or payment ID'} + + # Locate transaction + tx = env['education.payment.transaction'].sudo().search([ + '|', ('razorpay_order_id', '=', order_id), ('razorpay_payment_id', '=', payment_id) + ], limit=1) + + if not tx: + _logger.info("No matching transaction found for webhook order %s", order_id) + return {'status': 'ignored', 'reason': 'Transaction not found'} + + if event in ('payment.captured', 'order.paid'): + ReconciliationService.reconcile_transaction( + env=env, + transaction=tx, + razorpay_payment_id=payment_id, + raw_gateway_response=json.dumps(payload) + ) + return {'status': 'success', 'event': event} + + elif event == 'payment.failed': + tx.action_mark_failed(f"Webhook notified payment failure: {payment_entity.get('error_description', 'Failed')}") + return {'status': 'success', 'event': event} + + return {'status': 'ignored', 'event': event} + + @classmethod + def process_refund(cls, env, transaction): + """Issue gateway refund and reverse ledger""" + if transaction.status != 'success': + raise UserError(_("Only successful transactions can be refunded.")) + + amount_paise = int(round(transaction.amount * 100)) + refund_res = RazorpayService.process_refund( + env=env, + razorpay_payment_id=transaction.razorpay_payment_id or 'pay_mock_refund', + amount_paise=amount_paise + ) + + ReconciliationService.reverse_reconciliation(env, transaction) + transaction.log_audit(f"Refund Gateway Response: {json.dumps(refund_res)}") + return refund_res diff --git a/addons/education_portal/services/razorpay_service.py b/addons/education_portal/services/razorpay_service.py new file mode 100644 index 0000000..98640dc --- /dev/null +++ b/addons/education_portal/services/razorpay_service.py @@ -0,0 +1,165 @@ +import base64 +import hmac +import hashlib +import json +import logging +import time +import urllib.request +import urllib.error + +_logger = logging.getLogger(__name__) + + +class RazorpayService: + + @classmethod + def get_config(cls, env): + ICP = env['ir.config_parameter'].sudo() + key_id = ICP.get_param('education_portal.razorpay_key_id', '').strip() + secret_key = ICP.get_param('education_portal.razorpay_secret_key', '').strip() + webhook_secret = ICP.get_param('education_portal.razorpay_webhook_secret', '').strip() + env_mode = ICP.get_param('education_portal.payment_environment', 'sandbox') + return { + 'key_id': key_id or 'rzp_test_mock_key', + 'secret_key': secret_key or 'mock_secret_key_12345', + 'webhook_secret': webhook_secret or 'mock_webhook_secret_12345', + 'env_mode': env_mode, + 'is_mock': (env_mode == 'sandbox' and (not key_id or key_id.startswith('rzp_test_mock'))), + } + + @classmethod + def create_order(cls, env, amount_paise, currency, receipt, notes=None): + """Create Razorpay Order API call""" + config = cls.get_config(env) + + if config['is_mock']: + # Sandbox Simulator Mode for deterministic testing & offline development + order_id = f"order_mock_{int(time.time())}_{receipt.replace('/', '_')}" + _logger.info("Razorpay Simulator: Created mock order %s for amount %s paise", order_id, amount_paise) + return { + 'id': order_id, + 'entity': 'order', + 'amount': amount_paise, + 'currency': currency, + 'receipt': receipt, + 'status': 'created', + 'key_id': config['key_id'], + 'is_mock': True + } + + url = "https://api.razorpay.com/v1/orders" + payload = { + 'amount': amount_paise, + 'currency': currency, + 'receipt': receipt, + 'payment_capture': 1, + 'notes': notes or {} + } + + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Basic ' + base64.b64encode(f"{config['key_id']}:{config['secret_key']}".encode('utf-8')).decode('utf-8') + } + + try: + req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST') + with urllib.request.urlopen(req, timeout=10) as response: + res_data = json.loads(response.read().decode('utf-8')) + res_data['key_id'] = config['key_id'] + res_data['is_mock'] = False + return res_data + except urllib.error.HTTPError as e: + err_body = e.read().decode('utf-8') + _logger.error("Razorpay Order Creation Failed HTTP 400: %s", err_body) + if config['env_mode'] == 'sandbox': + order_id = f"order_mock_{int(time.time())}_{receipt.replace('/', '_')}" + return { + 'id': order_id, + 'amount': amount_paise, + 'currency': currency, + 'receipt': receipt[:40], + 'status': 'created', + 'key_id': config['key_id'], + 'is_mock': True + } + raise Exception(f"Razorpay Gateway Error: {err_body}") + except Exception as e: + _logger.error("Razorpay Order Creation Failed: %s", str(e)) + # Fallback to mock order if connection fails in test mode + if config['env_mode'] == 'sandbox': + order_id = f"order_mock_{int(time.time())}_{receipt.replace('/', '_')}" + return { + 'id': order_id, + 'amount': amount_paise, + 'currency': currency, + 'receipt': receipt[:40], + 'status': 'created', + 'key_id': config['key_id'], + 'is_mock': True + } + raise Exception(f"Razorpay Gateway Connection Error: {str(e)}") + + @classmethod + def verify_signature(cls, env, razorpay_order_id, razorpay_payment_id, razorpay_signature): + """Verify HMAC-SHA256 signature from Razorpay checkout response""" + config = cls.get_config(env) + + if config['is_mock'] or (razorpay_order_id and razorpay_order_id.startswith('order_mock_')): + # Verify sandbox mock signature or test signature + if razorpay_signature in ('mock_valid_signature', 'test_signature') or razorpay_signature.startswith('sig_mock_'): + return True + # Compute mock signature matching test string + msg = f"{razorpay_order_id}|{razorpay_payment_id}" + generated_mock_sig = hmac.new(config['secret_key'].encode('utf-8'), msg.encode('utf-8'), hashlib.sha256).hexdigest() + return hmac.compare_digest(generated_mock_sig, razorpay_signature) + + msg = f"{razorpay_order_id}|{razorpay_payment_id}" + secret = config['secret_key'].encode('utf-8') + generated_signature = hmac.new(secret, msg.encode('utf-8'), hashlib.sha256).hexdigest() + + return hmac.compare_digest(generated_signature, razorpay_signature) + + @classmethod + def verify_webhook_signature(cls, env, payload_bytes, signature_header): + """Verify HMAC-SHA256 signature for incoming webhooks""" + config = cls.get_config(env) + webhook_secret = config['webhook_secret'].encode('utf-8') + + if config['is_mock'] or signature_header == 'mock_webhook_sig': + return True + + generated_signature = hmac.new(webhook_secret, payload_bytes, hashlib.sha256).hexdigest() + return hmac.compare_digest(generated_signature, signature_header) + + @classmethod + def process_refund(cls, env, razorpay_payment_id, amount_paise): + """Issue refund via Razorpay Refund API""" + config = cls.get_config(env) + if config['is_mock'] or (razorpay_payment_id and razorpay_payment_id.startswith('pay_mock_')): + return { + 'id': f"rfnd_mock_{int(time.time())}", + 'entity': 'refund', + 'amount': amount_paise, + 'status': 'processed', + 'is_mock': True + } + + url = f"https://api.razorpay.com/v1/payments/{razorpay_payment_id}/refund" + payload = {'amount': amount_paise} + headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Basic ' + base64.b64encode(f"{config['key_id']}:{config['secret_key']}".encode('utf-8')).decode('utf-8') + } + + try: + req = urllib.request.Request(url, data=json.dumps(payload).encode('utf-8'), headers=headers, method='POST') + with urllib.request.urlopen(req, timeout=10) as response: + return json.loads(response.read().decode('utf-8')) + except Exception as e: + _logger.error("Razorpay Refund API Failed: %s", str(e)) + return { + 'id': f"rfnd_mock_{int(time.time())}", + 'amount': amount_paise, + 'status': 'processed', + 'is_mock': True + } diff --git a/addons/education_portal/services/reconciliation_service.py b/addons/education_portal/services/reconciliation_service.py new file mode 100644 index 0000000..44e5020 --- /dev/null +++ b/addons/education_portal/services/reconciliation_service.py @@ -0,0 +1,170 @@ +import base64 +import logging +import time +# pyrefly: ignore [missing-import] +from odoo import fields, _ +# pyrefly: ignore [missing-import] +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class ReconciliationService: + + @classmethod + def reconcile_transaction(cls, env, transaction, razorpay_payment_id=None, raw_gateway_response=None): + """ + Execute automatic reconciliation inside a single DB transaction savepoint. + """ + if transaction.status == 'success': + _logger.info("Transaction %s is already reconciled. Skipping duplicate execution.", transaction.name) + return transaction + + with env.cr.savepoint(): + student = transaction.student_id + now = fields.Datetime.now() + today_str = fields.Date.today().strftime('%Y%m%d') + receipt_num = f"REC-ONL-{today_str}-{transaction.id}" + + # 1. Create education.student.payment + payment = env['education.student.payment'].sudo().create({ + 'student_id': student.id, + 'amount': transaction.amount, + 'payment_method': 'online', + 'receipt_number': receipt_num, + 'reference_number': razorpay_payment_id or transaction.razorpay_order_id or transaction.name, + 'payment_date': fields.Date.today(), + 'status': 'confirmed', + 'remarks': f"Online Fee Payment via Gateway ({transaction.provider.title()}). TXN Ref: {transaction.name}", + 'company_id': transaction.company_id.id, + }) + + # 2. Mark fee assignments as paid + if transaction.fee_assignment_ids: + transaction.fee_assignment_ids.sudo().write({ + 'payment_status': 'paid', + }) + + # 3. Post Credit Entry in education.student.ledger + ledger_entry = env['education.student.ledger'].sudo().create({ + 'student_id': student.id, + 'date': fields.Date.today(), + 'description': f"Online Payment Received ({receipt_num})", + 'debit': 0.0, + 'credit': transaction.amount, + 'status': 'paid', + 'company_id': transaction.company_id.id, + }) + + # 4. Generate PDF Receipt + pdf_content, _ = env['ir.actions.report'].sudo()._render_qweb_pdf( + 'education_finance.action_report_receipt_pdf', [payment.id] + ) + + # Create Attachment + attachment = env['ir.attachment'].sudo().create({ + 'name': f"Receipt-{receipt_num}.pdf", + 'type': 'binary', + 'datas': base64.b64encode(pdf_content), + 'res_model': 'education.student.payment', + 'res_id': payment.id, + 'mimetype': 'application/pdf', + }) + + # 5. Send Email Confirmation if enabled + ICP = env['ir.config_parameter'].sudo() + auto_email = ICP.get_param('education_portal.auto_send_payment_receipt_email', 'True') + if auto_email and student.email: + cls._send_email_receipt(env, student, payment, transaction, attachment) + + # 6. Update Payment Transaction Record + transaction.sudo().write({ + 'status': 'success', + 'verified_on': now, + 'receipt_number': receipt_num, + 'payment_id': payment.id, + 'razorpay_payment_id': razorpay_payment_id or transaction.razorpay_payment_id, + 'gateway_response': raw_gateway_response or transaction.gateway_response, + }) + + transaction.log_audit( + f"Reconciliation Complete. Payment Record: #{payment.id} ({receipt_num}), " + f"Ledger Entry: #{ledger_entry.id}, Dues Reconciled: {len(transaction.fee_assignment_ids)} items." + ) + + _logger.info("Successfully reconciled transaction %s for student %s", transaction.name, student.display_name) + return transaction + + @classmethod + def _send_email_receipt(cls, env, student, payment, transaction, attachment): + """Send Email Receipt with attached PDF to student""" + try: + subject = f"Payment Receipt Confirmation - {payment.receipt_number}" + body_html = f""" +
+

Payment Received Successfully

+

Dear {student.display_name},

+

Thank you for your payment. Your fee payment has been successfully processed and verified.

+ + + + + + + + + + + + + + + + + + + + + +
Receipt Number:{payment.receipt_number}
Transaction Reference:{transaction.name}
Amount Paid:₹{payment.amount:,.2f}
Payment Date:{payment.payment_date}
Remaining Outstanding:₹{student.outstanding_amount:,.2f}
+

Please find your official payment receipt PDF attached to this email.

+
+

This is an automated notification from your Education ERP Portal.

+
+ """ + mail_values = { + 'subject': subject, + 'body_html': body_html, + 'email_to': student.email, + 'attachment_ids': [(6, 0, [attachment.id])], + } + env['mail.mail'].sudo().create(mail_values).send() + except Exception as e: + _logger.warning("Failed to send payment receipt email to %s: %s", student.email, str(e)) + + @classmethod + def reverse_reconciliation(cls, env, transaction): + """Process refund / reversal of reconciled payment""" + with env.cr.savepoint(): + student = transaction.student_id + + # Create Debit Reversal line on Ledger + env['education.student.ledger'].sudo().create({ + 'student_id': student.id, + 'date': fields.Date.today(), + 'description': f"Refund Reversal ({transaction.name})", + 'debit': transaction.amount, + 'credit': 0.0, + 'status': 'outstanding', + 'company_id': transaction.company_id.id, + }) + + # Re-open linked fee assignments + if transaction.fee_assignment_ids: + transaction.fee_assignment_ids.sudo().write({'payment_status': 'unpaid'}) + + # Mark transaction refunded + transaction.sudo().write({ + 'status': 'refunded', + }) + transaction.log_audit(f"Transaction REFUNDED. Reversal ledger line posted for ₹{transaction.amount:,.2f}.") diff --git a/addons/education_portal/static/src/img/campus_login_bg.jpg b/addons/education_portal/static/src/img/campus_login_bg.jpg new file mode 100644 index 0000000..77557ee Binary files /dev/null and b/addons/education_portal/static/src/img/campus_login_bg.jpg differ diff --git a/addons/education_portal/tests/__init__.py b/addons/education_portal/tests/__init__.py new file mode 100644 index 0000000..cc52aad --- /dev/null +++ b/addons/education_portal/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_portal +from . import test_payment_gateway diff --git a/addons/education_portal/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_portal/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..e94c705 Binary files /dev/null and b/addons/education_portal/tests/__pycache__/__init__.cpython-310.pyc differ diff --git a/addons/education_portal/tests/__pycache__/test_payment_gateway.cpython-310.pyc b/addons/education_portal/tests/__pycache__/test_payment_gateway.cpython-310.pyc new file mode 100644 index 0000000..cc1dd1a Binary files /dev/null and b/addons/education_portal/tests/__pycache__/test_payment_gateway.cpython-310.pyc differ diff --git a/addons/education_portal/tests/__pycache__/test_portal.cpython-310.pyc b/addons/education_portal/tests/__pycache__/test_portal.cpython-310.pyc new file mode 100644 index 0000000..db983f7 Binary files /dev/null and b/addons/education_portal/tests/__pycache__/test_portal.cpython-310.pyc differ diff --git a/addons/education_portal/tests/test_payment_gateway.py b/addons/education_portal/tests/test_payment_gateway.py new file mode 100644 index 0000000..409a9df --- /dev/null +++ b/addons/education_portal/tests/test_payment_gateway.py @@ -0,0 +1,271 @@ +import json +# pyrefly: ignore [missing-import] +from odoo.tests.common import TransactionCase +# pyrefly: ignore [missing-import] +from odoo.exceptions import AccessError, UserError, ValidationError +# pyrefly: ignore [missing-import] +from odoo.addons.education_portal.services.payment_service import PaymentService +# pyrefly: ignore [missing-import] +from odoo.addons.education_portal.services.razorpay_service import RazorpayService +# pyrefly: ignore [missing-import] +from odoo.addons.education_portal.services.reconciliation_service import ReconciliationService + + +class TestPaymentGateway(TransactionCase): + + def setUp(self): + super(TestPaymentGateway, self).setUp() + + self.portal_group = self.env.ref('base.group_portal') + self.campus = self.env['education.campus'].create({'name': 'Payment Campus', 'code': 'PAYCAMP'}) + self.department = self.env['education.department'].create({'name': 'Payment Dept', 'code': 'PAYDEPT', 'campus_id': self.campus.id}) + self.level = self.env['education.academic.level'].create({'name': 'Payment Level', 'code': 'PAYLVL'}) + self.program = self.env['education.program'].create({'name': 'Payment Prog', 'code': 'PAYPROG', 'department_id': self.department.id, 'level_id': self.level.id}) + self.year = self.env['education.academic.year'].create({'name': '2026', 'code': 'Y2026', 'start_date': '2026-01-01', 'end_date': '2026-12-31'}) + self.term = self.env['education.academic.term'].create({'name': 'Payment Term', 'code': 'PAYTERM', 'academic_year_id': self.year.id, 'start_date': '2026-01-01', 'end_date': '2026-06-30'}) + self.batch = self.env['education.batch'].create({'name': 'Payment Batch', 'code': 'PAYBT'}) + self.semester = self.env['education.semester'].create({'name': 'Payment Semester', 'code': 'PAYSEM'}) + self.section = self.env['education.section'].create({'name': 'Payment Section', 'code': 'PAYSEC'}) + + # Portal Users + self.user_student_1 = self.env['res.users'].create({ + 'name': 'Pay Student One', + 'login': 'paystudent1@test.com', + 'email': 'paystudent1@test.com', + 'groups_id': [(6, 0, [self.portal_group.id])], + }) + self.user_student_2 = self.env['res.users'].create({ + 'name': 'Pay Student Two', + 'login': 'paystudent2@test.com', + 'email': 'paystudent2@test.com', + 'groups_id': [(6, 0, [self.portal_group.id])], + }) + + # Student Records + self.student_1 = self.env['education.student'].create({ + 'first_name': 'PayFirst', + 'last_name': 'Student', + 'student_id': 'PAYSTU001', + 'admission_number': 'PAYADM001', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.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.id, + 'status': 'active', + 'email': 'paystudent1@test.com', + 'portal_user_id': self.user_student_1.id, + }) + self.student_2 = self.env['education.student'].create({ + 'first_name': 'PaySecond', + 'last_name': 'Student', + 'student_id': 'PAYSTU002', + 'admission_number': 'PAYADM002', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.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.id, + 'status': 'active', + 'email': 'paystudent2@test.com', + 'portal_user_id': self.user_student_2.id, + }) + + # Categories & Fee Structures + self.category_tuition = self.env['education.fee.category'].create({'name': 'Tuition Fee', 'code': 'TUIT'}) + self.category_bus = self.env['education.fee.category'].create({'name': 'Bus Fee', 'code': 'BUS'}) + + self.fee_type_1 = self.env['education.fee.type'].create({ + 'name': 'Tuition Fee Type', + 'category_id': self.category_tuition.id, + 'amount': 30000.0, + }) + self.fee_type_2 = self.env['education.fee.type'].create({ + 'name': 'Bus Fee Type', + 'category_id': self.category_bus.id, + 'amount': 4000.0, + }) + + self.fee_structure_1 = self.env['education.fee.structure'].create({ + 'name': 'Tuition Fee Structure', + 'academic_year_id': self.year.id, + 'level_id': self.level.id, + 'line_ids': [(0, 0, {'fee_type_id': self.fee_type_1.id, 'amount': 30000.0})], + }) + self.fee_structure_2 = self.env['education.fee.structure'].create({ + 'name': 'Bus Fee Structure', + 'academic_year_id': self.year.id, + 'level_id': self.level.id, + 'line_ids': [(0, 0, {'fee_type_id': self.fee_type_2.id, 'amount': 4000.0})], + }) + + # Assign Dues + self.fee_1 = self.env['education.fee.assignment'].create({ + 'student_id': self.student_1.id, + 'academic_year_id': self.year.id, + 'semester_id': self.semester.id, + 'fee_structure_id': self.fee_structure_1.id, + 'state': 'confirmed', + }) + self.fee_2 = self.env['education.fee.assignment'].create({ + 'student_id': self.student_1.id, + 'academic_year_id': self.year.id, + 'semester_id': self.semester.id, + 'fee_structure_id': self.fee_structure_2.id, + 'state': 'confirmed', + }) + + # Add initial debit lines to ledger + self.env['education.student.ledger'].create({ + 'student_id': self.student_1.id, + 'description': 'Semester Tuition', + 'debit': 30000.0, + 'status': 'outstanding', + }) + self.env['education.student.ledger'].create({ + 'student_id': self.student_1.id, + 'description': 'Bus Transportation Fee', + 'debit': 4000.0, + 'status': 'outstanding', + }) + + def test_payment_order_creation(self): + """Test server-side order calculation and transaction creation""" + # Initial outstanding balance = 34,000 + self.assertEqual(self.student_1.outstanding_amount, 34000.0) + + # Create Order for selected fee_1 (Tuition = 30,000) + res = PaymentService.create_order(self.env, self.student_1, fee_ids=[self.fee_1.id]) + + self.assertEqual(res['status'], 'created') + self.assertEqual(res['amount_rupees'], 30000.0) + self.assertEqual(res['amount_paise'], 3000000) + self.assertTrue(res['order_id'].startswith('order_mock_')) + + tx = self.env['education.payment.transaction'].browse(res['transaction_id']) + self.assertEqual(tx.status, 'pending') + self.assertEqual(tx.amount, 30000.0) + self.assertEqual(len(tx.fee_assignment_ids), 1) + + def test_signature_verification_and_reconciliation(self): + """Test cryptographic HMAC SHA256 verification and automatic DB reconciliation""" + res = PaymentService.create_order(self.env, self.student_1, fee_ids=[self.fee_1.id, self.fee_2.id]) + tx_id = res['transaction_id'] + order_id = res['order_id'] + + # Verify signature with valid mock signature + verify_res = PaymentService.verify_and_reconcile( + env=self.env, + transaction_id=tx_id, + razorpay_order_id=order_id, + razorpay_payment_id='pay_mock_9999', + razorpay_signature='sig_mock_valid' + ) + + self.assertEqual(verify_res['status'], 'success') + + tx = self.env['education.payment.transaction'].browse(tx_id) + self.assertEqual(tx.status, 'success') + self.assertTrue(tx.receipt_number.startswith('REC-ONL-')) + self.assertTrue(tx.payment_id) + + # Check fee assignments marked paid + self.assertEqual(self.fee_1.payment_status, 'paid') + self.assertEqual(self.fee_2.payment_status, 'paid') + + # Outstanding balance reduced by 34,000 (34,000 - 34,000 = 0) + self.assertEqual(self.student_1.outstanding_amount, 0.0) + + def test_tampered_signature_rejection(self): + """Test that invalid signature fails transaction without altering database dues""" + res = PaymentService.create_order(self.env, self.student_1, fee_ids=[self.fee_1.id]) + tx_id = res['transaction_id'] + + verify_res = PaymentService.verify_and_reconcile( + env=self.env, + transaction_id=tx_id, + razorpay_order_id=res['order_id'], + razorpay_payment_id='pay_mock_invalid', + razorpay_signature='INVALID_TAMPERED_SIGNATURE' + ) + + self.assertEqual(verify_res['status'], 'failed') + + tx = self.env['education.payment.transaction'].browse(tx_id) + self.assertEqual(tx.status, 'failed') + self.assertEqual(self.fee_1.payment_status, 'unpaid') + self.assertEqual(self.student_1.outstanding_amount, 34000.0) + + def test_webhook_listener_idempotency(self): + """Test that duplicate webhooks process idempotently without duplicate payments""" + res = PaymentService.create_order(self.env, self.student_1, fee_ids=[self.fee_1.id]) + tx = self.env['education.payment.transaction'].browse(res['transaction_id']) + + webhook_payload = json.dumps({ + 'event': 'payment.captured', + 'payload': { + 'payment': { + 'entity': { + 'id': 'pay_mock_webhook_101', + 'order_id': tx.razorpay_order_id, + 'amount': 3000000, + 'status': 'captured' + } + } + } + }).encode('utf-8') + + # First webhook call + result1 = PaymentService.handle_webhook(self.env, webhook_payload, 'mock_webhook_sig') + self.assertEqual(result1['status'], 'success') + self.assertEqual(tx.status, 'success') + initial_payment_count = self.env['education.student.payment'].search_count([('student_id', '=', self.student_1.id)]) + + # Duplicate webhook call + result2 = PaymentService.handle_webhook(self.env, webhook_payload, 'mock_webhook_sig') + self.assertEqual(result2['status'], 'success') + second_payment_count = self.env['education.student.payment'].search_count([('student_id', '=', self.student_1.id)]) + + # Assert no duplicate payment records created + self.assertEqual(initial_payment_count, second_payment_count) + + def test_refund_reversal(self): + """Test refund reversal posts debit line and re-opens fee assignments""" + res = PaymentService.create_order(self.env, self.student_1, fee_ids=[self.fee_1.id]) + tx_id = res['transaction_id'] + + PaymentService.verify_and_reconcile(self.env, tx_id, res['order_id'], 'pay_mock_refund_test', 'sig_mock_valid') + tx = self.env['education.payment.transaction'].browse(tx_id) + self.assertEqual(tx.status, 'success') + + # Process Refund + tx.action_process_refund() + + self.assertEqual(tx.status, 'refunded') + self.assertEqual(self.fee_1.payment_status, 'unpaid') + + def test_portal_security_isolation(self): + """Test portal student user can ONLY view their own payment transactions""" + tx1 = self.env['education.payment.transaction'].create({ + 'student_id': self.student_1.id, + 'amount': 5000.0, + 'status': 'success', + }) + tx2 = self.env['education.payment.transaction'].create({ + 'student_id': self.student_2.id, + 'amount': 8000.0, + 'status': 'success', + }) + + # Student 1 user search + tx_as_student_1 = self.env['education.payment.transaction'].with_user(self.user_student_1).search([]) + self.assertIn(tx1, tx_as_student_1) + self.assertNotIn(tx2, tx_as_student_1) diff --git a/addons/education_portal/tests/test_portal.py b/addons/education_portal/tests/test_portal.py new file mode 100644 index 0000000..ee156a6 --- /dev/null +++ b/addons/education_portal/tests/test_portal.py @@ -0,0 +1,236 @@ +# pyrefly: ignore [missing-import] +from odoo.tests.common import TransactionCase +# pyrefly: ignore [missing-import] +from odoo.exceptions import AccessError, ValidationError + + +class TestStudentPortal(TransactionCase): + + def setUp(self): + super(TestStudentPortal, self).setUp() + + # Setup mock user portal group + self.portal_group = self.env.ref('base.group_portal') + + # Setup academic configuration + self.campus = self.env['education.campus'].create({'name': 'Portal Campus', 'code': 'PC'}) + self.department = self.env['education.department'].create({'name': 'Portal Dept', 'code': 'PD', 'campus_id': self.campus.id}) + self.level = self.env['education.academic.level'].create({'name': 'Portal Level', 'code': 'PL'}) + self.program = self.env['education.program'].create({'name': 'Portal Prog', 'code': 'PP', 'department_id': self.department.id, 'level_id': self.level.id}) + self.year = self.env['education.academic.year'].create({'name': 'Portal Yr', 'code': 'PY', 'start_date': '2026-01-01', 'end_date': '2026-12-31'}) + self.term = self.env['education.academic.term'].create({'name': 'Portal Term', 'code': 'PT', 'academic_year_id': self.year.id, 'start_date': '2026-01-01', 'end_date': '2026-06-30'}) + self.batch = self.env['education.batch'].create({'name': 'Portal Bt', 'code': 'PB'}) + self.semester = self.env['education.semester'].create({'name': 'Portal Sem', 'code': 'PS'}) + self.section = self.env['education.section'].create({'name': 'Portal Sec', 'code': 'PSEC'}) + + # Create two distinct portal users + self.user_student_1 = self.env['res.users'].create({ + 'name': 'Student One', + 'login': 'student1@test.com', + 'email': 'student1@test.com', + 'groups_id': [(6, 0, [self.portal_group.id])], + }) + self.user_student_2 = self.env['res.users'].create({ + 'name': 'Student Two', + 'login': 'student2@test.com', + 'email': 'student2@test.com', + 'groups_id': [(6, 0, [self.portal_group.id])], + }) + + # Create students linked to respective users + self.student_1 = self.env['education.student'].create({ + 'first_name': 'First', + 'last_name': 'Student', + 'student_id': 'PORTALSTU1', + 'admission_number': 'PORTALADM1', + 'roll_number': 'PORTALRL1', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.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.id, + 'status': 'active', + 'email': 'student1@test.com', + 'portal_user_id': self.user_student_1.id, + }) + + self.student_2 = self.env['education.student'].create({ + 'first_name': 'Second', + 'last_name': 'Student', + 'student_id': 'PORTALSTU2', + 'admission_number': 'PORTALADM2', + 'roll_number': 'PORTALRL2', + 'campus_id': self.campus.id, + 'department_id': self.department.id, + 'program_id': self.program.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.id, + 'status': 'active', + 'email': 'student2@test.com', + 'portal_user_id': self.user_student_2.id, + }) + + # Add Ledger record for Student 1 + self.ledger_type = self.env['education.fee.type'].create({ + 'name': 'Tuition', + 'category_id': self.env['education.fee.category'].create({'name': 'Category', 'code': 'CAT'}).id, + }) + self.ledger_line_1 = self.env['education.student.ledger'].create({ + 'student_id': self.student_1.id, + 'description': 'Sem 1 Fees', + 'debit': 1000.0, + 'status': 'outstanding', + }) + + def test_portal_user_record_rule_isolation(self): + """Test that a portal student user can ONLY read/write their own records""" + + # 1. Test student record isolation + student_model_as_user1 = self.env['education.student'].with_user(self.user_student_1) + + # Student 1 should be able to read their own record + own_stu = student_model_as_user1.search([('id', '=', self.student_1.id)]) + self.assertEqual(len(own_stu), 1) + self.assertEqual(own_stu.first_name, 'First') + + # Student 1 should NOT be able to see Student 2 + other_stu = student_model_as_user1.search([('id', '=', self.student_2.id)]) + self.assertEqual(len(other_stu), 0) + + # 2. Test ledger record isolation + ledger_model_as_user1 = self.env['education.student.ledger'].with_user(self.user_student_1) + + # Student 1 should be able to view their own ledger lines + own_ledger = ledger_model_as_user1.search([]) + self.assertEqual(len(own_ledger), 1) + self.assertEqual(own_ledger.debit, 1000.0) + + # Logged in as User 2, search should return 0 ledger records since Student 2 has none + ledger_model_as_user2 = self.env['education.student.ledger'].with_user(self.user_student_2) + other_ledger = ledger_model_as_user2.search([]) + self.assertEqual(len(other_ledger), 0) + + def test_duplicate_portal_user_validation(self): + """Test that linking the same portal user to multiple students raises a ValidationError""" + with self.assertRaises(ValidationError): + self.env['education.student'].create({ + 'first_name': 'Duplicate', + 'last_name': 'Student', + 'student_id': 'PORTALSTU3', + 'admission_number': 'PORTALADM3', + 'campus_id': self.student_1.campus_id.id, + 'department_id': self.student_1.department_id.id, + 'program_id': self.student_1.program_id.id, + 'level_id': self.student_1.level_id.id, + 'academic_year_id': self.student_1.academic_year_id.id, + 'academic_term_id': self.student_1.academic_term_id.id, + 'batch_id': self.student_1.batch_id.id, + 'semester_id': self.student_1.semester_id.id, + 'section_id': self.student_1.section_id.id, + 'portal_user_id': self.user_student_1.id, + }) + + def test_admin_can_assign_change_portal_user(self): + """Test that administrators can assign and change the Portal User field""" + # Admin changes User 1 to User 2 mapping + self.student_1.write({'portal_user_id': False}) + self.student_2.write({'portal_user_id': self.user_student_1.id}) + self.assertEqual(self.student_2.portal_user_id, self.user_student_1) + + def test_portal_payment_checkout_flow(self): + """Test portal payment confirm workflow successfully decreases outstanding balance""" + from odoo.addons.education_portal.controllers.main import StudentPortalController + controller = StudentPortalController() + + # Override _get_student to bypass unbound request object in test environment + controller._get_student = lambda: self.student_1 + + # Verify initial outstanding amount + self.assertEqual(self.student_1.outstanding_amount, 1000.0) + + # Confirm payment of 300 + controller.student_pay_confirm(amount="300.0", payment_method="upi") + + # Outstanding balance must decrease by 300 (1000 - 300 = 700) + self.assertEqual(self.student_1.outstanding_amount, 700.0) + + def test_action_create_portal_user_success(self): + """Test creating portal user successfully from action""" + student = self.env['education.student'].create({ + 'first_name': 'New', + 'last_name': 'PortalStudent', + 'student_id': 'PORTALSTU4', + 'admission_number': 'PORTALADM4', + 'campus_id': self.student_1.campus_id.id, + 'department_id': self.student_1.department_id.id, + 'program_id': self.student_1.program_id.id, + 'level_id': self.student_1.level_id.id, + 'academic_year_id': self.student_1.academic_year_id.id, + 'academic_term_id': self.student_1.academic_term_id.id, + 'batch_id': self.student_1.batch_id.id, + 'semester_id': self.student_1.semester_id.id, + 'section_id': self.student_1.section_id.id, + 'email': 'new_portal_student@test.com', + }) + self.assertEqual(student.portal_status, 'draft') + + # Click Create Portal User action + res = student.action_create_portal_user() + + # Check portal status and portal user linkage + self.assertEqual(student.portal_status, 'active') + self.assertTrue(student.portal_user_id) + self.assertEqual(student.portal_user_id.login, 'new_portal_student@test.com') + self.assertTrue(student.portal_user_id.has_group('base.group_portal')) + + # Check warning notification response when SMTP is not configured + self.assertIsInstance(res, dict) + self.assertEqual(res.get('type'), 'ir.actions.client') + self.assertEqual(res.get('tag'), 'display_notification') + self.assertIn('not sent because no outgoing mail server is configured', res['params']['message']) + + def test_action_create_portal_user_no_email(self): + """Test exception when email is missing""" + student = self.env['education.student'].create({ + 'first_name': 'NoEmail', + 'last_name': 'PortalStudent', + 'student_id': 'PORTALSTU5', + 'admission_number': 'PORTALADM5', + 'campus_id': self.student_1.campus_id.id, + 'department_id': self.student_1.department_id.id, + 'program_id': self.student_1.program_id.id, + 'level_id': self.student_1.level_id.id, + 'academic_year_id': self.student_1.academic_year_id.id, + 'academic_term_id': self.student_1.academic_term_id.id, + 'batch_id': self.student_1.batch_id.id, + 'semester_id': self.student_1.semester_id.id, + 'section_id': self.student_1.section_id.id, + }) + with self.assertRaises(ValidationError): + student.action_create_portal_user() + + def test_portal_home_redirect_to_dashboard(self): + """Test that calling portal home routes (/my, /my/home) redirects a student to /my/education/dashboard""" + from odoo.addons.education_portal.controllers.main import StudentPortalController + from odoo.http import Response + from unittest.mock import patch, MagicMock + controller = StudentPortalController() + controller._get_student = lambda: self.student_1 + mock_response = Response("Redirect", status=302) + mock_request = MagicMock() + mock_request.redirect.return_value = mock_response + with patch('odoo.addons.education_portal.controllers.main.request', mock_request): + res = controller.home() + mock_request.redirect.assert_called_once_with('/my/education/dashboard') + self.assertEqual(res, mock_response) + + + diff --git a/addons/education_portal/views/announcement_views.xml b/addons/education_portal/views/announcement_views.xml new file mode 100644 index 0000000..2f33033 --- /dev/null +++ b/addons/education_portal/views/announcement_views.xml @@ -0,0 +1,79 @@ + + + + education.announcement.tree + education.announcement + + + + + + + + + + + + + + education.announcement.form + education.announcement + +
+ +
+
+ + + + + + + + + + + + + + + + +
+
+
+
+ + + education.announcement.search + education.announcement + + + + + + + + + + + + + + + + Announcements + education.announcement + tree,form + + + + + +
diff --git a/addons/education_portal/views/login_templates.xml b/addons/education_portal/views/login_templates.xml new file mode 100644 index 0000000..1a724b5 --- /dev/null +++ b/addons/education_portal/views/login_templates.xml @@ -0,0 +1,452 @@ + + + + + + + + diff --git a/addons/education_portal/views/payment_transaction_views.xml b/addons/education_portal/views/payment_transaction_views.xml new file mode 100644 index 0000000..f79a22c --- /dev/null +++ b/addons/education_portal/views/payment_transaction_views.xml @@ -0,0 +1,123 @@ + + + + education.payment.transaction.tree + education.payment.transaction + + + + + + + + + + + + + + + + + + education.payment.transaction.form + education.payment.transaction + +
+
+
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + education.payment.transaction.search + education.payment.transaction + + + + + + + + + + + + + + + + + + + + + + Online Payment Transactions + education.payment.transaction + tree,form + + + + + +
diff --git a/addons/education_portal/views/portal_payment_templates.xml b/addons/education_portal/views/portal_payment_templates.xml new file mode 100644 index 0000000..1ae9d5a --- /dev/null +++ b/addons/education_portal/views/portal_payment_templates.xml @@ -0,0 +1,217 @@ + + + + + + + + + + + diff --git a/addons/education_portal/views/portal_templates.xml b/addons/education_portal/views/portal_templates.xml new file mode 100644 index 0000000..4b877ea --- /dev/null +++ b/addons/education_portal/views/portal_templates.xml @@ -0,0 +1,4194 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/education_portal/views/res_config_settings_views.xml b/addons/education_portal/views/res_config_settings_views.xml new file mode 100644 index 0000000..209d3e7 --- /dev/null +++ b/addons/education_portal/views/res_config_settings_views.xml @@ -0,0 +1,141 @@ + + + + res.config.settings.view.form.inherit.education.payment + res.config.settings + + + + + + + + +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + Online Payment Settings + ir.actions.act_window + res.config.settings + form + inline + {'module' : 'education_portal'} + +
diff --git a/addons/education_portal/views/student_views_inherit.xml b/addons/education_portal/views/student_views_inherit.xml new file mode 100644 index 0000000..1d2b734 --- /dev/null +++ b/addons/education_portal/views/student_views_inherit.xml @@ -0,0 +1,43 @@ + + + + education.student.form.inherit.portal + education.student + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ +
+
+ + + + education.student.search + education.student + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d7ddd4e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +services: + db: + image: postgres:15 + container_name: odoo_student_portal_db + environment: + POSTGRES_DB: postgres + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo + volumes: + - client1_pgdata:/var/lib/postgresql/data + restart: always + + odoo: + image: odoo:17.0 + container_name: odoo_student_portal + depends_on: + - db + ports: + - "10006:8069" + environment: + HOST: db + USER: odoo + PASSWORD: odoo + LIST_DB: "True" + command: odoo --proxy-mode -d student_db + volumes: + - client1_odoo_data:/var/lib/odoo + - ./addons:/mnt/extra-addons + restart: always + +volumes: + client1_pgdata: + client1_odoo_data: