full erp files

This commit is contained in:
Vidhya 2026-09-15 10:47:19 +05:30
commit dccaef02f4
258 changed files with 16165 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

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

BIN
addons/.DS_Store vendored Normal file

Binary file not shown.

View File

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

View File

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

View File

@ -0,0 +1,440 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Demo Campus -->
<record id="demo_attendance_campus" model="education.campus">
<field name="name">Attendance Demo Campus</field>
<field name="code">ADC</field>
</record>
<!-- Demo Department -->
<record id="demo_attendance_dept" model="education.department">
<field name="name">Attendance Demo Department</field>
<field name="code">ADD</field>
<field name="campus_id" ref="demo_attendance_campus"/>
</record>
<!-- Demo Level -->
<record id="demo_attendance_level" model="education.academic.level">
<field name="name">Attendance Demo Level</field>
<field name="code">ADL</field>
</record>
<!-- Demo Program -->
<record id="demo_attendance_program" model="education.program">
<field name="name">Attendance Demo Program</field>
<field name="code">ADP</field>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="level_id" ref="demo_attendance_level"/>
</record>
<!-- Demo Academic Year -->
<record id="demo_attendance_year" model="education.academic.year">
<field name="name">Attendance Demo 2026</field>
<field name="code">ADY26</field>
<field name="start_date">2026-01-01</field>
<field name="end_date">2026-12-31</field>
</record>
<!-- Demo Academic Term -->
<record id="demo_attendance_term" model="education.academic.term">
<field name="name">Attendance Demo Term 1</field>
<field name="code">ADT1</field>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="start_date">2026-01-01</field>
<field name="end_date">2026-06-30</field>
</record>
<!-- Demo Academic Batch -->
<record id="demo_attendance_batch" model="education.batch">
<field name="name">Attendance Batch 2026</field>
<field name="code">ADB26</field>
</record>
<!-- Demo Academic Semester -->
<record id="demo_attendance_semester" model="education.semester">
<field name="name">Attendance Semester 1</field>
<field name="code">ADS1</field>
</record>
<!-- Demo Academic Section -->
<record id="demo_attendance_section" model="education.section">
<field name="name">Attendance Section A</field>
<field name="code">ADSA</field>
</record>
<!-- Create 20 Demo Students -->
<record id="demo_student_1" model="education.student">
<field name="first_name">Alex</field>
<field name="last_name">Smith</field>
<field name="student_id">STU001</field>
<field name="admission_number">ADM001</field>
<field name="roll_number">RL001</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_2" model="education.student">
<field name="first_name">Bella</field>
<field name="last_name">Jones</field>
<field name="student_id">STU002</field>
<field name="admission_number">ADM002</field>
<field name="roll_number">RL002</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_3" model="education.student">
<field name="first_name">Charlie</field>
<field name="last_name">Brown</field>
<field name="student_id">STU003</field>
<field name="admission_number">ADM003</field>
<field name="roll_number">RL003</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_4" model="education.student">
<field name="first_name">Diana</field>
<field name="last_name">Prince</field>
<field name="student_id">STU004</field>
<field name="admission_number">ADM004</field>
<field name="roll_number">RL004</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_5" model="education.student">
<field name="first_name">Evan</field>
<field name="last_name">Wright</field>
<field name="student_id">STU005</field>
<field name="admission_number">ADM005</field>
<field name="roll_number">RL005</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_6" model="education.student">
<field name="first_name">Fiona</field>
<field name="last_name">Gallagher</field>
<field name="student_id">STU006</field>
<field name="admission_number">ADM006</field>
<field name="roll_number">RL006</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_7" model="education.student">
<field name="first_name">George</field>
<field name="last_name">Clooney</field>
<field name="student_id">STU007</field>
<field name="admission_number">ADM007</field>
<field name="roll_number">RL007</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_8" model="education.student">
<field name="first_name">Hannah</field>
<field name="last_name">Baker</field>
<field name="student_id">STU008</field>
<field name="admission_number">ADM008</field>
<field name="roll_number">RL008</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_9" model="education.student">
<field name="first_name">Ian</field>
<field name="last_name">Somerhalder</field>
<field name="student_id">STU009</field>
<field name="admission_number">ADM009</field>
<field name="roll_number">RL009</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_10" model="education.student">
<field name="first_name">Julia</field>
<field name="last_name">Roberts</field>
<field name="student_id">STU010</field>
<field name="admission_number">ADM010</field>
<field name="roll_number">RL010</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_11" model="education.student">
<field name="first_name">Kevin</field>
<field name="last_name">Hart</field>
<field name="student_id">STU011</field>
<field name="admission_number">ADM011</field>
<field name="roll_number">RL011</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_12" model="education.student">
<field name="first_name">Lily</field>
<field name="last_name">Collins</field>
<field name="student_id">STU012</field>
<field name="admission_number">ADM012</field>
<field name="roll_number">RL012</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_13" model="education.student">
<field name="first_name">Mike</field>
<field name="last_name">Tyson</field>
<field name="student_id">STU013</field>
<field name="admission_number">ADM013</field>
<field name="roll_number">RL013</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_14" model="education.student">
<field name="first_name">Nina</field>
<field name="last_name">Dobrev</field>
<field name="student_id">STU014</field>
<field name="admission_number">ADM014</field>
<field name="roll_number">RL014</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_15" model="education.student">
<field name="first_name">Oscar</field>
<field name="last_name">Isaac</field>
<field name="student_id">STU015</field>
<field name="admission_number">ADM015</field>
<field name="roll_number">RL015</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_16" model="education.student">
<field name="first_name">Penelope</field>
<field name="last_name">Cruz</field>
<field name="student_id">STU016</field>
<field name="admission_number">ADM016</field>
<field name="roll_number">RL016</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_17" model="education.student">
<field name="first_name">Quincy</field>
<field name="last_name">Jones</field>
<field name="student_id">STU017</field>
<field name="admission_number">ADM017</field>
<field name="roll_number">RL017</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_18" model="education.student">
<field name="first_name">Rachel</field>
<field name="last_name">Green</field>
<field name="student_id">STU018</field>
<field name="admission_number">ADM018</field>
<field name="roll_number">RL018</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_19" model="education.student">
<field name="first_name">Sam</field>
<field name="last_name">Winchester</field>
<field name="student_id">STU019</field>
<field name="admission_number">ADM019</field>
<field name="roll_number">RL019</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<record id="demo_student_20" model="education.student">
<field name="first_name">Tom</field>
<field name="last_name">Hanks</field>
<field name="student_id">STU020</field>
<field name="admission_number">ADM020</field>
<field name="roll_number">RL020</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="batch_id" ref="demo_attendance_batch"/>
<field name="semester_id" ref="demo_attendance_semester"/>
<field name="section_id" ref="demo_attendance_section"/>
<field name="status">active</field>
</record>
<!-- Create 1 Demo Attendance Session -->
<record id="demo_attendance_session_1" model="education.attendance.session">
<field name="name">Demo Session MCA Period 1</field>
<field name="campus_id" ref="demo_attendance_campus"/>
<field name="department_id" ref="demo_attendance_dept"/>
<field name="program_id" ref="demo_attendance_program"/>
<field name="academic_level_id" ref="demo_attendance_level"/>
<field name="academic_year_id" ref="demo_attendance_year"/>
<field name="academic_term_id" ref="demo_attendance_term"/>
<field name="attendance_date" eval="time.strftime('%Y-%m-%d')"/>
<field name="period">period_1</field>
<field name="faculty_id" ref="base.user_admin"/>
<field name="state">draft</field>
</record>
</odoo>

View File

@ -0,0 +1,4 @@
from . import attendance_session
from . import attendance_line
from . import attendance
from . import student_inherit

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Daily Attendance Report Action -->
<record id="action_report_daily_attendance" model="ir.actions.report">
<field name="name">Daily Attendance Register</field>
<field name="model">education.attendance.session</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">education_attendance.report_daily_attendance_template</field>
<field name="report_file">education_attendance.report_daily_attendance_template</field>
<field name="binding_model_id" ref="model_education_attendance_session"/>
<field name="binding_type">report</field>
</record>
<!-- Student Attendance History Report Action -->
<record id="action_report_student_attendance" model="ir.actions.report">
<field name="name">Student Attendance Report</field>
<field name="model">education.student</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">education_attendance.report_student_attendance_template</field>
<field name="report_file">education_attendance.report_student_attendance_template</field>
<field name="binding_model_id" ref="model_education_student"/>
<field name="binding_type">report</field>
</record>
<!-- Daily Attendance Template -->
<template id="report_daily_attendance_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<div class="text-center mb-4">
<h2>DAILY ATTENDANCE REGISTER</h2>
<h4 t-field="o.name"/>
</div>
<div class="row mb-4">
<div class="col-6">
<strong>Academic Details:</strong>
<div>Campus: <span t-field="o.campus_id.name"/></div>
<div>Dept: <span t-field="o.department_id.name"/></div>
<div>Program: <span t-field="o.program_id.name"/></div>
<div>Level: <span t-field="o.academic_level_id.name"/></div>
</div>
<div class="col-6 text-right">
<strong>Session Details:</strong>
<div>Date: <span t-field="o.attendance_date"/></div>
<div>Period: <span t-field="o.period"/></div>
<div>Faculty: <span t-field="o.faculty_id.name"/></div>
<div>Status: <span t-field="o.state"/></div>
</div>
</div>
<table class="table table-striped table-bordered mt-3">
<thead>
<tr>
<th>Roll No</th>
<th>Student Name</th>
<th>Attendance Status</th>
<th>Time In</th>
<th>Remarks</th>
</tr>
</thead>
<tbody>
<t t-foreach="o.line_ids" t-as="line">
<tr>
<td><span t-field="line.student_id.roll_number"/></td>
<td><span t-field="line.student_id.full_name"/></td>
<td>
<span t-field="line.status"/>
</td>
<td><span t-field="line.time_in" t-options='{"widget": "datetime"}'/></td>
<td><span t-field="line.remarks"/></td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
<!-- Student Attendance Template -->
<template id="report_student_attendance_template">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div class="page">
<div class="text-center mb-4">
<h2>STUDENT ATTENDANCE REPORT</h2>
<h4 t-field="o.full_name"/>
</div>
<div class="row mb-4">
<div class="col-6">
<strong>Student Profile:</strong>
<div>Student ID: <span t-field="o.student_id"/></div>
<div>Roll No: <span t-field="o.roll_number"/></div>
<div>Program: <span t-field="o.program_id.name"/></div>
<div>Campus: <span t-field="o.campus_id.name"/></div>
</div>
<div class="col-6 text-right">
<strong>Attendance Summary:</strong>
<div>Present Count: <span t-field="o.present_count"/></div>
<div>Absent Count: <span t-field="o.absent_count"/></div>
<div>Late Count: <span t-field="o.late_count"/></div>
<div>Overall %: <strong><span t-field="o.attendance_pct"/>%</strong></div>
</div>
</div>
<table class="table table-striped table-bordered mt-3">
<thead>
<tr>
<th>Date</th>
<th>Period / Session</th>
<th>Faculty</th>
<th>Status</th>
<th>Remarks</th>
</tr>
</thead>
<tbody>
<t t-foreach="o.attendance_line_ids" t-as="line">
<tr>
<td><span t-field="line.date"/></td>
<td><span t-field="line.session_id.name"/></td>
<td><span t-field="line.faculty_id.name"/></td>
<td><span t-field="line.status"/></td>
<td><span t-field="line.remarks"/></td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</t>
</t>
</template>
</odoo>

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Category -->
<record id="module_category_education_attendance" model="ir.module.category">
<field name="name">Education Attendance</field>
<field name="sequence">40</field>
</record>
<!-- Groups -->
<record id="group_attendance_user" model="res.groups">
<field name="name">Attendance User</field>
<field name="category_id" ref="module_category_education_attendance"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Allows marking attendance lines and tracking their sessions.</field>
</record>
<record id="group_attendance_manager" model="res.groups">
<field name="name">Attendance Manager</field>
<field name="category_id" ref="module_category_education_attendance"/>
<field name="implied_ids" eval="[(4, ref('group_attendance_user'))]"/>
<field name="comment">Full managerial oversight, configuration, and reports.</field>
</record>
<!-- Multi-Company Isolation -->
<record id="rule_attendance_session_company" model="ir.rule">
<field name="name">Attendance Session Company Rule</field>
<field name="model_id" ref="model_education_attendance_session"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
</record>
<record id="rule_attendance_line_company" model="ir.rule">
<field name="name">Attendance Line Company Rule</field>
<field name="model_id" ref="model_education_attendance_line"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
</record>
<record id="rule_attendance_history_company" model="ir.rule">
<field name="name">Attendance History Company Rule</field>
<field name="model_id" ref="model_education_attendance"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
</record>
<!-- Faculty Only Sessions Rule (Attendance Users see their own, Managers see all) -->
<record id="rule_attendance_session_faculty" model="ir.rule">
<field name="name">Faculty Sessions Only</field>
<field name="model_id" ref="model_education_attendance_session"/>
<field name="domain_force">[('faculty_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_attendance_user'))]"/>
</record>
<record id="rule_attendance_session_manager" model="ir.rule">
<field name="name">Manager All Sessions</field>
<field name="model_id" ref="model_education_attendance_session"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_attendance_manager'))]"/>
</record>
</odoo>

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_attendance_session_user Attendance Session User model_education_attendance_session group_attendance_user 1 1 1 0
3 access_attendance_session_mgr Attendance Session Mgr model_education_attendance_session group_attendance_manager 1 1 1 1
4 access_attendance_line_user Attendance Line User model_education_attendance_line group_attendance_user 1 1 1 0
5 access_attendance_line_mgr Attendance Line Mgr model_education_attendance_line group_attendance_manager 1 1 1 1
6 access_attendance_user Attendance User model_education_attendance group_attendance_user 1 1 1 0
7 access_attendance_mgr Attendance Mgr model_education_attendance group_attendance_manager 1 1 1 1

View File

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

View File

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

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Attendance Root Menu under Education Parent Menu -->
<menuitem id="menu_education_attendance_root"
name="Attendance"
parent="education_base.menu_education_base_root"
sequence="25"
groups="group_attendance_user"/>
<!-- Submenus -->
<menuitem id="menu_education_attendance_session"
name="Attendance Sessions"
parent="menu_education_attendance_root"
action="action_education_attendance_session"
sequence="10"/>
<menuitem id="menu_education_attendance_history_menu"
name="Attendance History"
parent="menu_education_attendance_root"
action="action_education_attendance_history"
sequence="20"/>
</odoo>

View File

@ -0,0 +1,188 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_attendance_session_tree" model="ir.ui.view">
<field name="name">education.attendance.session.tree</field>
<field name="model">education.attendance.session</field>
<field name="arch" type="xml">
<tree string="Attendance Sessions">
<field name="name"/>
<field name="campus_id"/>
<field name="program_id"/>
<field name="academic_year_id"/>
<field name="attendance_date"/>
<field name="period"/>
<field name="faculty_id"/>
<field name="state" widget="badge" decoration-info="state == 'draft'" decoration-warning="state == 'open'" decoration-success="state == 'completed'" decoration-danger="state == 'cancelled'"/>
</tree>
</field>
</record>
<record id="view_education_attendance_session_kanban" model="ir.ui.view">
<field name="name">education.attendance.session.kanban</field>
<field name="model">education.attendance.session</field>
<field name="arch" type="xml">
<kanban default_group_by="state" class="o_kanban_small_column">
<field name="name"/>
<field name="attendance_date"/>
<field name="period"/>
<field name="faculty_id"/>
<field name="state"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click o_kanban_card">
<div class="oe_kanban_content">
<div class="o_kanban_record_title font-weight-bold">
<strong><field name="name"/></strong>
</div>
<div class="text-muted small">
Date: <field name="attendance_date"/>
</div>
<div class="text-muted small">
Period: <field name="period"/>
</div>
<div class="text-muted small">
Faculty: <field name="faculty_id"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="view_education_attendance_session_calendar" model="ir.ui.view">
<field name="name">education.attendance.session.calendar</field>
<field name="model">education.attendance.session</field>
<field name="arch" type="xml">
<calendar string="Attendance Calendar" date_start="attendance_date" color="state" mode="month">
<field name="name"/>
<field name="period"/>
<field name="faculty_id"/>
</calendar>
</field>
</record>
<record id="view_education_attendance_session_pivot" model="ir.ui.view">
<field name="name">education.attendance.session.pivot</field>
<field name="model">education.attendance.session</field>
<field name="arch" type="xml">
<pivot string="Sessions Analysis">
<field name="program_id" type="row"/>
<field name="state" type="col"/>
<field name="attendance_date" interval="month" type="row"/>
</pivot>
</field>
</record>
<record id="view_education_attendance_session_graph" model="ir.ui.view">
<field name="name">education.attendance.session.graph</field>
<field name="model">education.attendance.session</field>
<field name="arch" type="xml">
<graph string="Sessions Analysis" type="bar">
<field name="program_id"/>
<field name="state"/>
</graph>
</field>
</record>
<record id="view_education_attendance_session_form" model="ir.ui.view">
<field name="name">education.attendance.session.form</field>
<field name="model">education.attendance.session</field>
<field name="arch" type="xml">
<form string="Attendance Session">
<header>
<button name="action_open" string="Open Session" type="object" class="oe_highlight" invisible="state != 'draft'"/>
<button name="action_complete" string="Complete Session" type="object" class="oe_highlight" invisible="state != 'open'"/>
<button name="action_cancel" string="Cancel Session" type="object" invisible="state in ('cancelled')"/>
<button name="action_draft" string="Reset to Draft" type="object" invisible="state not in ('open', 'completed', 'cancelled')"/>
<field name="state" widget="statusbar" statusbar_visible="draft,open,completed"/>
</header>
<sheet>
<div class="oe_title">
<label for="name" class="oe_edit_only"/>
<h1><field name="name" placeholder="Session Title e.g. Year 1 MCA Lecture" readonly="state != 'draft'"/></h1>
</div>
<div class="alert alert-info mt-2" role="alert" invisible="state != 'open'">
<strong>Bulk Actions:</strong>
<button name="action_bulk_present" string="Present All" type="object" class="btn btn-outline-success btn-sm mx-1"/>
<button name="action_bulk_absent" string="Absent All" type="object" class="btn btn-outline-danger btn-sm mx-1"/>
<button name="action_bulk_late" string="Late All" type="object" class="btn btn-outline-warning btn-sm mx-1"/>
<button name="action_bulk_medical" string="Medical All" type="object" class="btn btn-outline-info btn-sm mx-1"/>
<button name="action_bulk_duty" string="On Duty All" type="object" class="btn btn-outline-secondary btn-sm mx-1"/>
</div>
<group>
<group string="Academic Grouping">
<field name="campus_id" readonly="state != 'draft'"/>
<field name="department_id" readonly="state != 'draft'"/>
<field name="program_id" readonly="state != 'draft'"/>
<field name="academic_level_id" readonly="state != 'draft'"/>
</group>
<group string="Schedule &amp; Faculty">
<field name="academic_year_id" readonly="state != 'draft'"/>
<field name="academic_term_id" readonly="state != 'draft'"/>
<field name="attendance_date" readonly="state != 'draft'"/>
<field name="period" readonly="state != 'draft'"/>
<field name="faculty_id" readonly="state != 'draft'"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Students List" name="students_list" invisible="state == 'draft'">
<field name="line_ids" readonly="state != 'open'">
<tree editable="bottom" create="false" delete="false">
<field name="student_id" readonly="1"/>
<field name="status" widget="radio" options="{'horizontal': true}"/>
<field name="remarks"/>
<field name="time_in"/>
<field name="time_out"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="view_education_attendance_session_search" model="ir.ui.view">
<field name="name">education.attendance.session.search</field>
<field name="model">education.attendance.session</field>
<field name="arch" type="xml">
<search string="Attendance Sessions">
<field name="name"/>
<field name="campus_id"/>
<field name="department_id"/>
<field name="program_id"/>
<field name="faculty_id"/>
<field name="attendance_date"/>
<filter string="Draft" name="draft" domain="[('state', '=', 'draft')]"/>
<filter string="Open (Active)" name="open" domain="[('state', '=', 'open')]"/>
<filter string="Completed" name="completed" domain="[('state', '=', 'completed')]"/>
<filter string="Cancelled" name="cancelled" domain="[('state', '=', 'cancelled')]"/>
<group expand="1" string="Group By">
<filter string="Campus" name="group_campus" context="{'group_by': 'campus_id'}"/>
<filter string="Department" name="group_dept" context="{'group_by': 'department_id'}"/>
<filter string="Program" name="group_program" context="{'group_by': 'program_id'}"/>
<filter string="Status" name="group_state" context="{'group_by': 'state'}"/>
<filter string="Faculty" name="group_faculty" context="{'group_by': 'faculty_id'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'attendance_date'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_attendance_session" model="ir.actions.act_window">
<field name="name">Attendance Sessions</field>
<field name="res_model">education.attendance.session</field>
<field name="view_mode">tree,kanban,form,calendar,pivot,graph</field>
<field name="search_view_id" ref="view_education_attendance_session_search"/>
</record>
</odoo>

View File

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_attendance_tree" model="ir.ui.view">
<field name="name">education.attendance.tree</field>
<field name="model">education.attendance</field>
<field name="arch" type="xml">
<tree string="Attendance History" create="false" edit="false" delete="false">
<field name="date"/>
<field name="student_id"/>
<field name="session_id"/>
<field name="status" widget="badge" decoration-success="status == 'present' or status == 'on_duty'" decoration-danger="status == 'absent'" decoration-warning="status == 'late'" decoration-info="status == 'medical_leave'"/>
<field name="faculty_id"/>
<field name="remarks"/>
</tree>
</field>
</record>
<record id="view_education_attendance_calendar" model="ir.ui.view">
<field name="name">education.attendance.calendar</field>
<field name="model">education.attendance</field>
<field name="arch" type="xml">
<calendar string="Attendance Calendar" date_start="date" color="status" mode="month">
<field name="student_id"/>
<field name="status"/>
</calendar>
</field>
</record>
<record id="view_education_attendance_pivot" model="ir.ui.view">
<field name="name">education.attendance.pivot</field>
<field name="model">education.attendance</field>
<field name="arch" type="xml">
<pivot string="Attendance Analysis">
<field name="student_id" type="row"/>
<field name="status" type="col"/>
<field name="date" interval="month" type="row"/>
</pivot>
</field>
</record>
<record id="view_education_attendance_graph" model="ir.ui.view">
<field name="name">education.attendance.graph</field>
<field name="model">education.attendance</field>
<field name="arch" type="xml">
<graph string="Attendance Analysis" type="bar">
<field name="status"/>
</graph>
</field>
</record>
<record id="view_education_attendance_search" model="ir.ui.view">
<field name="name">education.attendance.search</field>
<field name="model">education.attendance</field>
<field name="arch" type="xml">
<search string="Attendance History">
<field name="student_id"/>
<field name="date"/>
<field name="status"/>
<field name="faculty_id"/>
<filter string="Present" name="present" domain="[('status', '=', 'present')]"/>
<filter string="Absent" name="absent" domain="[('status', '=', 'absent')]"/>
<filter string="Late" name="late" domain="[('status', '=', 'late')]"/>
<filter string="Medical Leave" name="medical_leave" domain="[('status', '=', 'medical_leave')]"/>
<filter string="On Duty" name="on_duty" domain="[('status', '=', 'on_duty')]"/>
<group expand="1" string="Group By">
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'date'}"/>
</group>
<searchpanel>
<field name="status" icon="fa-check-square-o"/>
</searchpanel>
</search>
</field>
</record>
<record id="action_education_attendance_history" model="ir.actions.act_window">
<field name="name">Attendance History</field>
<field name="res_model">education.attendance</field>
<field name="view_mode">tree,calendar,pivot,graph</field>
<field name="search_view_id" ref="view_education_attendance_search"/>
</record>
</odoo>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_student_form_inherit_attendance" model="ir.ui.view">
<field name="name">education.student.form.inherit.attendance</field>
<field name="model">education.student</field>
<field name="inherit_id" ref="education_student.view_education_student_form"/>
<field name="arch" type="xml">
<!-- Replace placeholder Attendance button with dynamic record action -->
<xpath expr="//button[@icon='fa-calendar-check-o']" position="replace">
<button class="oe_stat_button" icon="fa-calendar-check-o" type="action" name="%(action_education_attendance_history)d" context="{'search_default_student_id': active_id}">
<div class="o_stat_info">
<span class="o_stat_value">
<field name="attendance_pct" widget="float" precision="1"/>%
</span>
<span class="o_stat_text">Attendance</span>
</div>
</button>
</xpath>
<!-- Inject Attendance summary page in student tabs -->
<xpath expr="//notebook" position="inside">
<page string="Attendance Details" name="attendance_details" groups="education_attendance.group_attendance_user">
<group>
<group string="Summarized Metrics">
<field name="attendance_pct" widget="float" precision="1"/>
<field name="present_count"/>
<field name="absent_count"/>
</group>
<group string="Detailed Delineations">
<field name="late_count"/>
<field name="medical_leave_count"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
</odoo>

View File

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

View File

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

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_education_base_root" name="Education" sequence="10" groups="base.group_user" web_icon="fa fa-graduation-cap"/>
<menuitem id="menu_education_base_configuration" name="Configuration" parent="menu_education_base_root" sequence="1" groups="base.group_user"/>
<menuitem id="menu_education_base_master_data" name="Master Data" parent="menu_education_base_root" sequence="2" groups="base.group_user"/>
</odoo>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="seq_education_academic_year" model="ir.sequence">
<field name="name">Academic Year</field>
<field name="code">education.academic.year</field>
<field name="prefix">AY/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_education_academic_term" model="ir.sequence">
<field name="name">Academic Term</field>
<field name="code">education.academic.term</field>
<field name="prefix">TERM/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_education_campus" model="ir.sequence">
<field name="name">Campus</field>
<field name="code">education.campus</field>
<field name="prefix">CAMP/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_education_department" model="ir.sequence">
<field name="name">Department</field>
<field name="code">education.department</field>
<field name="prefix">DEP/</field>
<field name="padding">4</field>
<field name="company_id" eval="False"/>
</record>
<record id="seq_education_program" model="ir.sequence">
<field name="name">Program</field>
<field name="code">education.program</field>
<field name="prefix">PROG/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
</odoo>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_academic_level_undergraduate" model="education.academic.level">
<field name="name">Undergraduate</field>
<field name="code">UG</field>
<field name="sequence">10</field>
<field name="description">Undergraduate study level.</field>
<field name="company_id" ref="base.main_company"/>
</record>
</odoo>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_academic_year_2025" model="education.academic.year">
<field name="name">2025-2026</field>
<field name="code">AY2025</field>
<field name="start_date">2025-06-01</field>
<field name="end_date">2026-05-31</field>
<field name="state">active</field>
<field name="current_year">True</field>
<field name="company_id" ref="base.main_company"/>
</record>
</odoo>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_campus_main" model="education.campus">
<field name="name">Main Campus</field>
<field name="code">MAIN</field>
<field name="phone">+1234567890</field>
<field name="email">main@school.example</field>
<field name="company_id" ref="base.main_company"/>
</record>
</odoo>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_department_engineering" model="education.department">
<field name="name">Engineering</field>
<field name="code">ENG</field>
<field name="campus_id" ref="demo_campus_main"/>
</record>
</odoo>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="demo_program_btech" model="education.program">
<field name="name">B.Tech</field>
<field name="code">BTECH</field>
<field name="level_id" ref="demo_academic_level_undergraduate"/>
<field name="department_id" ref="demo_department_engineering"/>
<field name="duration">4</field>
<field name="duration_unit">years</field>
</record>
</odoo>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_education_base" model="ir.module.category">
<field name="name">Education Base</field>
<field name="sequence">10</field>
</record>
<record id="group_education_base_user" model="res.groups">
<field name="name">Education Base User</field>
<field name="category_id" ref="module_category_education_base"/>
<field name="comment">Basic access to education master data.</field>
</record>
<record id="group_education_base_manager" model="res.groups">
<field name="name">Education Base Manager</field>
<field name="category_id" ref="module_category_education_base"/>
<field name="implied_ids" eval="[(4, ref('group_education_base_user'))]"/>
<field name="comment">Manage academic configuration and master setup.</field>
</record>
<record id="group_education_base_admin" model="res.groups">
<field name="name">Education Base Admin</field>
<field name="category_id" ref="module_category_education_base"/>
<field name="implied_ids" eval="[(4, ref('group_education_base_manager'))]"/>
<field name="comment">Full administrative access to base education configuration.</field>
</record>
</odoo>

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_education_academic_year_user education.academic.year user model_education_academic_year group_education_base_user 1 0 0 0
3 access_education_academic_year_manager education.academic.year manager model_education_academic_year group_education_base_manager 1 1 1 1
4 access_education_academic_year_admin education.academic.year admin model_education_academic_year group_education_base_admin 1 1 1 1
5 access_education_academic_term_user education.academic.term user model_education_academic_term group_education_base_user 1 0 0 0
6 access_education_academic_term_manager education.academic.term manager model_education_academic_term group_education_base_manager 1 1 1 1
7 access_education_academic_term_admin education.academic.term admin model_education_academic_term group_education_base_admin 1 1 1 1
8 access_education_campus_user education.campus user model_education_campus group_education_base_user 1 0 0 0
9 access_education_campus_manager education.campus manager model_education_campus group_education_base_manager 1 1 1 1
10 access_education_campus_admin education.campus admin model_education_campus group_education_base_admin 1 1 1 1
11 access_education_department_user education.department user model_education_department group_education_base_user 1 0 0 0
12 access_education_department_manager education.department manager model_education_department group_education_base_manager 1 1 1 1
13 access_education_department_admin education.department admin model_education_department group_education_base_admin 1 1 1 1
14 access_education_academic_level_user education.academic.level user model_education_academic_level group_education_base_user 1 0 0 0
15 access_education_academic_level_manager education.academic.level manager model_education_academic_level group_education_base_manager 1 1 1 1
16 access_education_academic_level_admin education.academic.level admin model_education_academic_level group_education_base_admin 1 1 1 1
17 access_education_program_user education.program user model_education_program group_education_base_user 1 0 0 0
18 access_education_program_manager education.program manager model_education_program group_education_base_manager 1 1 1 1
19 access_education_program_admin education.program admin model_education_program group_education_base_admin 1 1 1 1
20 access_education_configuration_user education.configuration user model_education_configuration group_education_base_user 1 0 0 0
21 access_education_configuration_manager education.configuration manager model_education_configuration group_education_base_manager 1 1 1 1
22 access_education_configuration_admin education.configuration admin model_education_configuration group_education_base_admin 1 1 1 1

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="0">
<record id="rule_education_academic_year_company" model="ir.rule">
<field name="name">Academic Year Company Rule</field>
<field name="model_id" ref="model_education_academic_year"/>
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
<field name="groups" eval="[(4, ref('group_education_base_user')),(4, ref('group_education_base_manager')),(4, ref('group_education_base_admin'))]"/>
</record>
<record id="rule_education_academic_term_company" model="ir.rule">
<field name="name">Academic Term Company Rule</field>
<field name="model_id" ref="model_education_academic_term"/>
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
<field name="groups" eval="[(4, ref('group_education_base_user')),(4, ref('group_education_base_manager')),(4, ref('group_education_base_admin'))]"/>
</record>
<record id="rule_education_campus_company" model="ir.rule">
<field name="name">Campus Company Rule</field>
<field name="model_id" ref="model_education_campus"/>
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
<field name="groups" eval="[(4, ref('group_education_base_user')),(4, ref('group_education_base_manager')),(4, ref('group_education_base_admin'))]"/>
</record>
<record id="rule_education_department_company" model="ir.rule">
<field name="name">Department Company Rule</field>
<field name="model_id" ref="model_education_department"/>
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
<field name="groups" eval="[(4, ref('group_education_base_user')),(4, ref('group_education_base_manager')),(4, ref('group_education_base_admin'))]"/>
</record>
<record id="rule_education_academic_level_company" model="ir.rule">
<field name="name">Academic Level Company Rule</field>
<field name="model_id" ref="model_education_academic_level"/>
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
<field name="groups" eval="[(4, ref('group_education_base_user')),(4, ref('group_education_base_manager')),(4, ref('group_education_base_admin'))]"/>
</record>
<record id="rule_education_program_company" model="ir.rule">
<field name="name">Program Company Rule</field>
<field name="model_id" ref="model_education_program"/>
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
<field name="groups" eval="[(4, ref('group_education_base_user')),(4, ref('group_education_base_manager')),(4, ref('group_education_base_admin'))]"/>
</record>
<record id="rule_education_configuration_company" model="ir.rule">
<field name="name">Configuration Company Rule</field>
<field name="model_id" ref="model_education_configuration"/>
<field name="domain_force">[('company_id', '=', user.company_id.id)]</field>
<field name="groups" eval="[(4, ref('group_education_base_user')),(4, ref('group_education_base_manager')),(4, ref('group_education_base_admin'))]"/>
</record>
</odoo>

View File

@ -0,0 +1,4 @@
from . import test_academic_year
from . import test_campus
from . import test_program
from . import test_security

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_academic_level_tree" model="ir.ui.view">
<field name="name">education.academic.level.tree</field>
<field name="model">education.academic.level</field>
<field name="arch" type="xml">
<tree string="Academic Levels">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="code"/>
<field name="description"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="view_education_academic_level_form" model="ir.ui.view">
<field name="name">education.academic.level.form</field>
<field name="model">education.academic.level</field>
<field name="arch" type="xml">
<form string="Academic Level">
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="sequence"/>
</group>
<group>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group>
<field name="description"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_education_academic_level_search" model="ir.ui.view">
<field name="name">education.academic.level.search</field>
<field name="model">education.academic.level</field>
<field name="arch" type="xml">
<search string="Academic Levels">
<field name="name"/>
<field name="code"/>
<group expand="1" string="Group By">
<filter string="Company" name="company" context="{'group_by': 'company_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_academic_level" model="ir.actions.act_window">
<field name="name">Academic Levels</field>
<field name="res_model">education.academic.level</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_education_academic_level_search"/>
</record>
</odoo>

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_academic_term_tree" model="ir.ui.view">
<field name="name">education.academic.term.tree</field>
<field name="model">education.academic.term</field>
<field name="arch" type="xml">
<tree string="Academic Terms">
<field name="name"/>
<field name="code"/>
<field name="academic_year_id"/>
<field name="sequence"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="state"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="view_education_academic_term_form" model="ir.ui.view">
<field name="name">education.academic.term.form</field>
<field name="model">education.academic.term</field>
<field name="arch" type="xml">
<form string="Academic Term">
<header>
<button name="action_activate" type="object" string="Activate" class="btn-primary" invisible="state != 'draft'"/>
<button name="action_close" type="object" string="Close" invisible="state != 'active'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="academic_year_id"/>
</group>
<group>
<field name="sequence"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_education_academic_term_search" model="ir.ui.view">
<field name="name">education.academic.term.search</field>
<field name="model">education.academic.term</field>
<field name="arch" type="xml">
<search string="Academic Terms">
<field name="name"/>
<field name="code"/>
<field name="academic_year_id"/>
<filter string="Active" name="active" domain="[('state', '=', 'active')]"/>
<group expand="1" string="Group By">
<filter string="Academic Year" name="academic_year" context="{'group_by': 'academic_year_id'}"/>
<filter string="Company" name="company" context="{'group_by': 'company_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_academic_term" model="ir.actions.act_window">
<field name="name">Academic Terms</field>
<field name="res_model">education.academic.term</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_education_academic_term_search"/>
</record>
</odoo>

View File

@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_academic_year_tree" model="ir.ui.view">
<field name="name">education.academic.year.tree</field>
<field name="model">education.academic.year</field>
<field name="arch" type="xml">
<tree string="Academic Years">
<field name="name"/>
<field name="code"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="state"/>
<field name="current_year"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="view_education_academic_year_form" model="ir.ui.view">
<field name="name">education.academic.year.form</field>
<field name="model">education.academic.year</field>
<field name="arch" type="xml">
<form string="Academic Year">
<header>
<button name="action_activate" type="object" string="Activate" class="btn-primary" invisible="state != 'draft'"/>
<button name="action_close" type="object" string="Close" invisible="state != 'active'"/>
<button name="action_set_draft" type="object" string="Set to Draft" invisible="state != 'closed'"/>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="start_date"/>
<field name="end_date"/>
<field name="current_year"/>
</group>
</group>
<notebook>
<page string="Terms">
<field name="term_ids" context="{'default_academic_year_id': active_id}">
<tree>
<field name="name"/>
<field name="code"/>
<field name="sequence"/>
<field name="start_date"/>
<field name="end_date"/>
<field name="state"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_education_academic_year_search" model="ir.ui.view">
<field name="name">education.academic.year.search</field>
<field name="model">education.academic.year</field>
<field name="arch" type="xml">
<search string="Academic Years">
<field name="name"/>
<field name="code"/>
<filter string="Current Year" name="current_year" domain="[('current_year', '=', True)]"/>
<filter string="Active" name="active" domain="[('state', '=', 'active')]"/>
<group expand="1" string="Group By">
<filter string="Company" name="company" context="{'group_by': 'company_id'}"/>
<filter string="State" name="state" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_academic_year" model="ir.actions.act_window">
<field name="name">Academic Years</field>
<field name="res_model">education.academic.year</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_education_academic_year_search"/>
</record>
</odoo>

View File

@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_campus_tree" model="ir.ui.view">
<field name="name">education.campus.tree</field>
<field name="model">education.campus</field>
<field name="arch" type="xml">
<tree string="Campuses">
<field name="name"/>
<field name="code"/>
<field name="phone"/>
<field name="email"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="view_education_campus_form" model="ir.ui.view">
<field name="name">education.campus.form</field>
<field name="model">education.campus</field>
<field name="arch" type="xml">
<form string="Campus">
<sheet>
<field name="logo" widget="image" class="oe_avatar"/>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="phone"/>
<field name="email"/>
<field name="address_id"/>
</group>
</group>
<notebook>
<page string="Departments">
<field name="department_ids">
<tree>
<field name="name"/>
<field name="code"/>
<field name="head_teacher_id"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_education_campus_search" model="ir.ui.view">
<field name="name">education.campus.search</field>
<field name="model">education.campus</field>
<field name="arch" type="xml">
<search string="Campuses">
<field name="name"/>
<field name="code"/>
<filter string="Archived" name="archived" domain="[('active', '=', False)]"/>
<group expand="1" string="Group By">
<filter string="Company" name="company" context="{'group_by': 'company_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_campus" model="ir.actions.act_window">
<field name="name">Campuses</field>
<field name="res_model">education.campus</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_education_campus_search"/>
</record>
</odoo>

View File

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_department_tree" model="ir.ui.view">
<field name="name">education.department.tree</field>
<field name="model">education.department</field>
<field name="arch" type="xml">
<tree string="Departments">
<field name="name"/>
<field name="code"/>
<field name="campus_id"/>
<field name="head_teacher_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="view_education_department_form" model="ir.ui.view">
<field name="name">education.department.form</field>
<field name="model">education.department</field>
<field name="arch" type="xml">
<form string="Department">
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="campus_id"/>
</group>
<group>
<field name="head_teacher_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Programs">
<field name="program_ids">
<tree>
<field name="name"/>
<field name="code"/>
<field name="level_id"/>
</tree>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_education_department_search" model="ir.ui.view">
<field name="name">education.department.search</field>
<field name="model">education.department</field>
<field name="arch" type="xml">
<search string="Departments">
<field name="name"/>
<field name="code"/>
<field name="campus_id"/>
<group expand="1" string="Group By">
<filter string="Campus" name="campus" context="{'group_by': 'campus_id'}"/>
<filter string="Company" name="company" context="{'group_by': 'company_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_department" model="ir.actions.act_window">
<field name="name">Departments</field>
<field name="res_model">education.department</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_education_department_search"/>
</record>
</odoo>

View File

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_configuration_tree" model="ir.ui.view">
<field name="name">education.configuration.tree</field>
<field name="model">education.configuration</field>
<field name="arch" type="xml">
<tree string="Education Configuration">
<field name="school_name"/>
<field name="school_code"/>
<field name="default_campus_id"/>
<field name="default_academic_year_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="view_education_configuration_form" model="ir.ui.view">
<field name="name">education.configuration.form</field>
<field name="model">education.configuration</field>
<field name="arch" type="xml">
<form string="Education Configuration">
<sheet>
<group>
<group>
<field name="school_name"/>
<field name="school_code"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="default_campus_id"/>
<field name="default_academic_year_id"/>
<field name="default_term_id"/>
<field name="default_currency_id"/>
</group>
</group>
<group>
<group>
<field name="contact_email"/>
<field name="contact_phone"/>
</group>
<group>
<field name="logo" widget="image" class="oe_avatar"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_education_configuration_search" model="ir.ui.view">
<field name="name">education.configuration.search</field>
<field name="model">education.configuration</field>
<field name="arch" type="xml">
<search string="Education Configuration">
<field name="school_name"/>
<field name="school_code"/>
<group expand="1" string="Group By">
<filter string="Company" name="company" context="{'group_by': 'company_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_configuration" model="ir.actions.act_window">
<field name="name">Education Configuration</field>
<field name="res_model">education.configuration</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_education_configuration_search"/>
</record>
</odoo>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_education_base_academic_year" name="Academic Years" parent="menu_education_base_master_data" action="action_education_academic_year" sequence="10" groups="base.group_user"/>
<menuitem id="menu_education_base_academic_term" name="Academic Terms" parent="menu_education_base_master_data" action="action_education_academic_term" sequence="20" groups="base.group_user"/>
<menuitem id="menu_education_base_campus" name="Campuses" parent="menu_education_base_master_data" action="action_education_campus" sequence="30" groups="base.group_user"/>
<menuitem id="menu_education_base_department" name="Departments" parent="menu_education_base_master_data" action="action_education_department" sequence="40" groups="base.group_user"/>
<menuitem id="menu_education_base_academic_level" name="Academic Levels" parent="menu_education_base_master_data" action="action_education_academic_level" sequence="50" groups="base.group_user"/>
<menuitem id="menu_education_base_program" name="Programs" parent="menu_education_base_master_data" action="action_education_program" sequence="60" groups="base.group_user"/>
<menuitem id="menu_education_base_config" name="School Settings" parent="menu_education_base_configuration" action="action_education_configuration" sequence="10" groups="base.group_user"/>
</odoo>

View File

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_education_program_tree" model="ir.ui.view">
<field name="name">education.program.tree</field>
<field name="model">education.program</field>
<field name="arch" type="xml">
<tree string="Programs">
<field name="name"/>
<field name="code"/>
<field name="level_id"/>
<field name="department_id"/>
<field name="duration"/>
<field name="duration_unit"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="view_education_program_form" model="ir.ui.view">
<field name="name">education.program.form</field>
<field name="model">education.program</field>
<field name="arch" type="xml">
<form string="Program">
<sheet>
<group>
<group>
<field name="name"/>
<field name="code"/>
<field name="level_id"/>
</group>
<group>
<field name="department_id"/>
<field name="duration"/>
<field name="duration_unit"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group>
<field name="description"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_education_program_search" model="ir.ui.view">
<field name="name">education.program.search</field>
<field name="model">education.program</field>
<field name="arch" type="xml">
<search string="Programs">
<field name="name"/>
<field name="code"/>
<field name="department_id"/>
<field name="level_id"/>
<group expand="1" string="Group By">
<filter string="Department" name="department" context="{'group_by': 'department_id'}"/>
<filter string="Academic Level" name="level" context="{'group_by': 'level_id'}"/>
<filter string="Company" name="company" context="{'group_by': 'company_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_program" model="ir.actions.act_window">
<field name="name">Programs</field>
<field name="res_model">education.program</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_education_program_search"/>
</record>
</odoo>

View File

@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_education_dashboard Education Dashboard model_education_dashboard base.group_user 1 1 1 1

View File

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

View File

@ -0,0 +1,172 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="education_dashboard.DashboardMain">
<div class="container-fluid py-4 bg-light overflow-auto" style="height: 100vh;">
<!-- Filter Bar -->
<div class="row bg-white p-3 shadow-sm rounded mb-4 mx-1">
<div class="col-md-3">
<label class="form-label font-weight-bold small text-uppercase text-muted">Campus</label>
<select class="form-control form-control-sm" t-on-change="(ev) => this.onFilterChange('campus_id', ev)">
<option value="">All Campuses</option>
<t t-foreach="state.kpis.campuses || []" t-as="c" t-key="c.id">
<option t-att-value="c.id"><t t-esc="c.name"/></option>
</t>
</select>
</div>
<div class="col-md-3">
<label class="form-label font-weight-bold small text-uppercase text-muted">Program</label>
<select class="form-control form-control-sm" t-on-change="(ev) => this.onFilterChange('program_id', ev)">
<option value="">All Programs</option>
<t t-foreach="state.kpis.programs || []" t-as="p" t-key="p.id">
<option t-att-value="p.id"><t t-esc="p.name"/></option>
</t>
</select>
</div>
<div class="col-md-3">
<label class="form-label font-weight-bold small text-uppercase text-muted">Academic Year</label>
<select class="form-control form-control-sm" t-on-change="(ev) => this.onFilterChange('academic_year_id', ev)">
<option value="">All Years</option>
<t t-foreach="state.kpis.years || []" t-as="y" t-key="y.id">
<option t-att-value="y.id"><t t-esc="y.name"/></option>
</t>
</select>
</div>
<div class="col-md-3">
<label class="form-label font-weight-bold small text-uppercase text-muted">Semester</label>
<select class="form-control form-control-sm" t-on-change="(ev) => this.onFilterChange('semester_id', ev)">
<option value="">All Semesters</option>
<t t-foreach="state.kpis.semesters || []" t-as="s" t-key="s.id">
<option t-att-value="s.id"><t t-esc="s.name"/></option>
</t>
</select>
</div>
</div>
<!-- Executive KPIs -->
<div class="row mb-4 mx-1">
<div class="col-md-3 mb-2" t-on-click="() => this.openStudents('')" style="cursor: pointer;">
<div class="card shadow-sm border-0 bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-uppercase small mb-1">Total Students</h6>
<h2 class="font-weight-bold mb-0"><t t-esc="state.kpis.total_students || 0"/></h2>
</div>
<i class="fa fa-users fa-3x opacity-50"/>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-2" t-on-click="() => this.openStudents('active')" style="cursor: pointer;">
<div class="card shadow-sm border-0 bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-uppercase small mb-1">Active Students</h6>
<h2 class="font-weight-bold mb-0"><t t-esc="state.kpis.active_students || 0"/></h2>
</div>
<i class="fa fa-user-check fa-3x opacity-50"/>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-2" t-on-click="() => this.openStudents('admitted')" style="cursor: pointer;">
<div class="card shadow-sm border-0 bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-uppercase small mb-1">Admissions</h6>
<h2 class="font-weight-bold mb-0"><t t-esc="state.kpis.admissions || 0"/></h2>
</div>
<i class="fa fa-user-plus fa-3x opacity-50"/>
</div>
</div>
</div>
</div>
<div class="col-md-3 mb-2" t-on-click="() => this.openStudents('graduated')" style="cursor: pointer;">
<div class="card shadow-sm border-0 bg-warning text-white">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-uppercase small mb-1">Graduates</h6>
<h2 class="font-weight-bold mb-0"><t t-esc="state.kpis.graduates || 0"/></h2>
</div>
<i class="fa fa-graduation-cap fa-3x opacity-50"/>
</div>
</div>
</div>
</div>
</div>
<!-- Finance KPIs -->
<div class="row mb-4 mx-1">
<div class="col-md-4 mb-2">
<div class="card shadow-sm border-0 bg-white p-3">
<div class="text-muted small text-uppercase">Total Fees Assigned</div>
<h2 class="font-weight-bold text-dark mb-0"><t t-esc="state.kpis.total_fees || 0"/></h2>
</div>
</div>
<div class="col-md-4 mb-2">
<div class="card shadow-sm border-0 bg-white p-3">
<div class="text-success small text-uppercase">Collected Fees</div>
<h2 class="font-weight-bold text-success mb-0"><t t-esc="state.kpis.collected_fees || 0"/></h2>
</div>
</div>
<div class="col-md-4 mb-2">
<div class="card shadow-sm border-0 bg-white p-3">
<div class="text-danger small text-uppercase">Outstanding Fees</div>
<h2 class="font-weight-bold text-danger mb-0"><t t-esc="state.kpis.outstanding_fees || 0"/></h2>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="row mb-4 mx-1">
<div class="col-md-8 mb-2">
<div class="card shadow-sm border-0 p-3 bg-white" style="height: 350px;">
<h5 class="card-title font-weight-bold">Program-wise Distribution</h5>
<div class="position-relative flex-grow-1" style="height: 280px;">
<canvas t-ref="programChart"/>
</div>
</div>
</div>
<div class="col-md-4 mb-2">
<div class="card shadow-sm border-0 p-3 bg-white" style="height: 350px;">
<h5 class="card-title font-weight-bold">Campus Distribution</h5>
<div class="position-relative flex-grow-1" style="height: 280px;">
<canvas t-ref="campusChart"/>
</div>
</div>
</div>
</div>
<!-- Academic KPIs -->
<div class="row mb-4 mx-1 text-center">
<div class="col-md-3 mb-2" t-on-click="openPrograms" style="cursor: pointer;">
<div class="card shadow-sm border-0 bg-white p-3">
<div class="text-muted small text-uppercase">Programs</div>
<h3 class="font-weight-bold mb-0 text-primary"><t t-esc="state.kpis.programs_count || 0"/></h3>
</div>
</div>
<div class="col-md-3 mb-2">
<div class="card shadow-sm border-0 bg-white p-3">
<div class="text-muted small text-uppercase">Departments</div>
<h3 class="font-weight-bold mb-0 text-primary"><t t-esc="state.kpis.dept_count || 0"/></h3>
</div>
</div>
<div class="col-md-3 mb-2">
<div class="card shadow-sm border-0 bg-white p-3">
<div class="text-muted small text-uppercase">Campuses</div>
<h3 class="font-weight-bold mb-0 text-primary"><t t-esc="state.kpis.campus_count || 0"/></h3>
</div>
</div>
<div class="col-md-3 mb-2">
<div class="card shadow-sm border-0 bg-white p-3">
<div class="text-muted small text-uppercase">Academic Years</div>
<h3 class="font-weight-bold mb-0 text-primary"><t t-esc="state.kpis.years_count || 0"/></h3>
</div>
</div>
</div>
</div>
</t>
</templates>

View File

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

View File

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

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Client Action for OWL Dashboard -->
<record id="action_education_dashboard_main" model="ir.actions.client">
<field name="name">Management Dashboard</field>
<field name="tag">education_dashboard_main_tag</field>
</record>
<!-- Top-level Menu Item -->
<menuitem id="menu_education_dashboard_root"
name="Dashboard"
parent="education_base.menu_education_base_root"
action="action_education_dashboard_main"
sequence="1"
groups="education_student.group_education_student_manager"/>
</odoo>

View File

@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More