Library Management updated
This commit is contained in:
parent
855d71f886
commit
c5a0a747a5
@ -1,6 +1,6 @@
|
||||
# 📚 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.
|
||||
This document contains a comprehensive, field-level technical summary of all **10 custom education modules** built for the Odoo Education & Student Portal ecosystem.
|
||||
|
||||
---
|
||||
|
||||
@ -425,6 +425,78 @@ Generates formal printable PDF documents and analytical export files for identit
|
||||
|
||||
---
|
||||
|
||||
## 8. 📝 `education_exam` (Education Examinations)
|
||||
|
||||
### **Module Overview**
|
||||
Complete College Examination Module featuring exam scheduling, grading systems, marks entry, and portal integration for students to view results and schedules.
|
||||
|
||||
---
|
||||
|
||||
### **Models**
|
||||
|
||||
- **`education.exam`**: Examination
|
||||
- **`education.exam.line`**: Exam Schedule Line
|
||||
- **`education.exam.type`**: Examination Type
|
||||
- **`education.exam.result`**: Exam Result
|
||||
- **`education.exam.result.line`**: Exam Result Line
|
||||
- **`education.grading.system`**: Grading System
|
||||
- **`education.grade`**: Education Grade
|
||||
- **`education.subject`**: Education Subject
|
||||
|
||||
---
|
||||
|
||||
## 9. 🎓 `education_lms` (Learning Management System)
|
||||
|
||||
### **Module Overview**
|
||||
Learning Management System Foundation for Education managing courses, sections, lessons, materials, assignments, and student submissions.
|
||||
|
||||
---
|
||||
|
||||
### **Models**
|
||||
|
||||
- **`education.lms.course`**: LMS Course
|
||||
- **`education.lms.section`**: LMS Section
|
||||
- **`education.lms.lesson`**: LMS Lesson
|
||||
- **`education.lms.material`**: LMS Material
|
||||
- **`education.lms.assignment`**: LMS Assignment
|
||||
- **`education.lms.submission`**: LMS Submission
|
||||
|
||||
---
|
||||
|
||||
## 10. ⏰ `education_timetable` (Academic Timetable)
|
||||
|
||||
### **Module Overview**
|
||||
Native Timetable module integrated with the education base architecture. Provides robust server-side validation for room, faculty, and student population conflicts.
|
||||
|
||||
---
|
||||
|
||||
### **Models**
|
||||
|
||||
- **`education.room`**: Education Room/Venue
|
||||
- **`education.timetable.slot`**: Timetable Slot
|
||||
|
||||
---
|
||||
|
||||
## 11. 📚 `education_library` (Library Management System)
|
||||
|
||||
### **Module Overview**
|
||||
Provides comprehensive library management including book cataloging, physical copies, member management, issuing/returning of books, reservations, and automated fine calculations.
|
||||
|
||||
---
|
||||
|
||||
### **Models**
|
||||
|
||||
- **`education.library`**: Library Branch
|
||||
- **`education.library.category`**: Book Category
|
||||
- **`education.library.book`**: Book Master
|
||||
- **`education.library.book.copy`**: Physical Book Copy
|
||||
- **`education.library.member`**: Library Member
|
||||
- **`education.library.issue`**: Book Issue / Return Record
|
||||
- **`education.library.reservation`**: Book Reservation
|
||||
- **`education.library.fine`**: Library Fine
|
||||
|
||||
---
|
||||
|
||||
## 📋 Comprehensive Summary Matrix of Custom Addons
|
||||
|
||||
| Module | Core Purpose | Models Defined | Key Output / UI View |
|
||||
@ -436,3 +508,7 @@ Generates formal printable PDF documents and analytical export files for identit
|
||||
| **`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 |
|
||||
| **`education_exam`** | Examination Management | `education.exam`, `education.exam.result`, `education.subject`, `education.grading.system` | Exam Schedule, Grading Views, Student Portal Results |
|
||||
| **`education_lms`** | Learning Management System | `education.lms.course`, `education.lms.assignment`, `education.lms.submission` | Course Views, Submissions, Portal LMS Templates |
|
||||
| **`education_timetable`** | Academic Timetable | `education.room`, `education.timetable.slot` | Room Management, Timetable Slots |
|
||||
| **`education_library`** | Library Management | `education.library.book`, `education.library.member`, `education.library.issue` | Book Catalog, Issues & Returns, Library Fine Ledger |
|
||||
|
||||
40
ODOO19_DEVELOPMENT_GUIDELINES.md
Normal file
40
ODOO19_DEVELOPMENT_GUIDELINES.md
Normal file
@ -0,0 +1,40 @@
|
||||
# 🚀 Odoo 19 Custom Module Development Guidelines
|
||||
|
||||
This document summarizes the coding standards, strict constraints, and Odoo 19 specific architectural changes discovered and enforced across the `student_ERP_Metatron` modules.
|
||||
|
||||
---
|
||||
|
||||
## 1. 🖥️ User Interface & Views
|
||||
- **Tree Views Deprecated**: The `<tree>` XML tag and `tree` view mode have been completely replaced by `<list>`.
|
||||
- **Action Window Definitions**: You must define `view_mode` as `list,form` instead of `tree,form`.
|
||||
- **View Architectures**: Use `<list>` tags instead of `<tree>` when defining list view layouts.
|
||||
|
||||
## 2. 🗄️ Database & ORM Constraints
|
||||
- **SQL Constraints Deprecated**: The `_sql_constraints` list attribute is no longer supported.
|
||||
- **Odoo 19 Standard**: Define SQL constraints using `models.Constraint` at the class level.
|
||||
- **Example**:
|
||||
```python
|
||||
_code_unique = models.Constraint('unique(code)', 'The Code must be unique!')
|
||||
```
|
||||
- **Strict Validation**: Models like `education.student` have strict database-level `NotNullViolation` constraints for academic fields (`batch_id`, `semester_id`, `section_id`). These must always be populated when creating records, especially during automated testing.
|
||||
|
||||
## 3. 🔐 Security & Access Rights
|
||||
- **Group Fields**: When assigning groups to `res.users` or defining access, note that the underlying model for groups uses `res.groups.privilege`.
|
||||
- **Field Name Changes**: When creating or writing to `res.users`, the relational field for groups has been renamed from `groups_id` to `group_ids`.
|
||||
- **Portal Controllers**: Portal users lack access to standard internal models (like `ir.sequence`). Sequence generation triggered from portal controllers (e.g., creating a library issue) must be executed using `.sudo()`.
|
||||
|
||||
## 4. 🌐 Web Controllers & Translation
|
||||
- **Translation Function `_()`**: The standard translation function `_()` attempts to retrieve the user's language by accessing `self.env`.
|
||||
- **The Issue**: In HTTP controllers, `self` refers to the Controller instance, not an ORM Model. Calling `_("Error")` inside a controller (e.g., within a `raise AccessError(...)`) throws `AttributeError: 'NoneType' object has no attribute 'uid'`.
|
||||
- **The Solution**: Avoid wrapping error strings in `_()` within controllers unless explicitly using `request.env._()`.
|
||||
|
||||
## 5. 🧪 Automated Testing
|
||||
- **Buffered ORM Operations**: Odoo 19 heavily buffers database inserts and updates.
|
||||
- **Testing Constraints**: When testing database-level exceptions (like `UniqueViolation`), the error will not trigger immediately upon calling `.create()`. You must call `self.env.flush_all()` within the `assertRaises` block to force the ORM to flush the transaction to PostgreSQL and trigger the exception.
|
||||
- **Example**:
|
||||
```python
|
||||
with self.assertRaises(Exception):
|
||||
self.env['my.model'].create({'unique_field': 'duplicate'})
|
||||
self.env.flush_all() # Crucial for triggering the DB error!
|
||||
```
|
||||
- **Constraint Exceptions**: Odoo 19 often raises raw database exceptions (`Exception` / `psycopg2` errors) rather than cleanly wrapped `ValidationError` objects for database-level violations. Adjust assertions accordingly.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -48,9 +48,7 @@ class EducationAttendanceSession(models.Model):
|
||||
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.'),
|
||||
]
|
||||
_uniq_session_period = models.Constraint('unique(attendance_date, period, program_id, academic_level_id, company_id)', 'An attendance session has already been created for this period, program, and academic level on this date.')
|
||||
|
||||
def action_open(self):
|
||||
for rec in self:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -202,4 +202,4 @@ class TestStudentAttendance(TransactionCase):
|
||||
'attendance_date': '2026-02-01',
|
||||
'period': 'period_1',
|
||||
})
|
||||
self.env['education.attendance.session'].flush_model()
|
||||
self.env.flush_all()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -8,6 +8,6 @@ class TestSecurity(TransactionCase):
|
||||
'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])],
|
||||
'group_ids': [(6, 0, [self.env.ref('education_base.group_education_base_user').id])],
|
||||
})
|
||||
self.assertTrue(user)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2
addons/education_library/__init__.py
Normal file
2
addons/education_library/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
39
addons/education_library/__manifest__.py
Normal file
39
addons/education_library/__manifest__.py
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
'name': 'Education Library',
|
||||
'version': '19.0.1.0.0',
|
||||
'summary': 'Library Management for Education ERP',
|
||||
'description': '''
|
||||
Education Library Management Module.
|
||||
Features:
|
||||
- Manage books, categories, and physical copies.
|
||||
- Member tracking linked to existing education profiles.
|
||||
- Issue, return, fine calculations, and reservations.
|
||||
- Student portal for library history and book search.
|
||||
''',
|
||||
'category': 'Education',
|
||||
'author': 'Metatron',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'base',
|
||||
'education_base',
|
||||
'education_student',
|
||||
'education_portal'
|
||||
],
|
||||
'data': [
|
||||
'security/security.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'security/record_rules.xml',
|
||||
'views/education_library_menu.xml',
|
||||
'views/education_library_category_views.xml',
|
||||
'views/education_library_book_views.xml',
|
||||
'views/education_library_member_views.xml',
|
||||
'views/education_library_issue_views.xml',
|
||||
'views/education_library_fine_views.xml',
|
||||
'views/education_library_reservation_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/portal_templates.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
BIN
addons/education_library/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
addons/education_library/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
1
addons/education_library/controllers/__init__.py
Normal file
1
addons/education_library/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
Binary file not shown.
Binary file not shown.
158
addons/education_library/controllers/main.py
Normal file
158
addons/education_library/controllers/main.py
Normal file
@ -0,0 +1,158 @@
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
|
||||
|
||||
|
||||
class LibraryPortal(CustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
|
||||
user = request.env.user
|
||||
member = request.env['education.library.member'].sudo().search([('user_id', '=', user.id)], limit=1)
|
||||
|
||||
if member:
|
||||
issue_count = request.env['education.library.issue'].sudo().search_count([
|
||||
('member_id', '=', member.id),
|
||||
('status', 'in', ['issued', 'overdue'])
|
||||
])
|
||||
res_count = request.env['education.library.reservation'].sudo().search_count([
|
||||
('member_id', '=', member.id),
|
||||
('state', '=', 'active')
|
||||
])
|
||||
fine_count = request.env['education.library.fine'].sudo().search_count([
|
||||
('member_id', '=', member.id),
|
||||
('state', '=', 'assessed')
|
||||
])
|
||||
values.update({
|
||||
'library_issue_count': issue_count,
|
||||
'library_res_count': res_count,
|
||||
'library_fine_count': fine_count,
|
||||
})
|
||||
return values
|
||||
|
||||
def _get_library_member(self):
|
||||
user = request.env.user
|
||||
if user._is_public():
|
||||
return request.env['education.library.member']
|
||||
return request.env['education.library.member'].sudo().search([('user_id', '=', user.id)], limit=1)
|
||||
|
||||
@http.route(['/my/library'], type='http', auth='user', website=True)
|
||||
def portal_my_library(self, **kw):
|
||||
member = self._get_library_member()
|
||||
if not member:
|
||||
return request.redirect('/my')
|
||||
|
||||
issues = request.env['education.library.issue'].sudo().search([
|
||||
('member_id', '=', member.id),
|
||||
('status', 'in', ['issued', 'overdue'])
|
||||
])
|
||||
fines = request.env['education.library.fine'].sudo().search([
|
||||
('member_id', '=', member.id),
|
||||
('state', '=', 'assessed')
|
||||
])
|
||||
reservations = request.env['education.library.reservation'].sudo().search([
|
||||
('member_id', '=', member.id),
|
||||
('state', '=', 'active')
|
||||
])
|
||||
|
||||
values = {
|
||||
'member': member,
|
||||
'issues': issues,
|
||||
'fines': fines,
|
||||
'reservations': reservations,
|
||||
'page_name': 'library_dashboard',
|
||||
}
|
||||
return request.render('education_library.portal_my_library', values)
|
||||
|
||||
@http.route(['/my/library/books', '/my/library/books/page/<int:page>'], type='http', auth='user', website=True)
|
||||
def portal_library_books(self, page=1, search='', search_in='title', **kw):
|
||||
domain = [('active', '=', True)]
|
||||
if search:
|
||||
if search_in == 'title':
|
||||
domain += [('title', 'ilike', search)]
|
||||
elif search_in == 'author':
|
||||
domain += [('author', 'ilike', search)]
|
||||
elif search_in == 'isbn':
|
||||
domain += [('isbn', 'ilike', search)]
|
||||
|
||||
Book = request.env['education.library.book'].sudo()
|
||||
total = Book.search_count(domain)
|
||||
pager = portal_pager(
|
||||
url="/my/library/books",
|
||||
url_args={'search': search, 'search_in': search_in},
|
||||
total=total,
|
||||
page=page,
|
||||
step=12
|
||||
)
|
||||
books = Book.search(domain, limit=12, offset=pager['offset'])
|
||||
|
||||
values = {
|
||||
'books': books,
|
||||
'page_name': 'library_books',
|
||||
'pager': pager,
|
||||
'search': search,
|
||||
'search_in': search_in,
|
||||
'search_in_options': [
|
||||
{'key': 'title', 'label': 'Title'},
|
||||
{'key': 'author', 'label': 'Author'},
|
||||
{'key': 'isbn', 'label': 'ISBN'},
|
||||
]
|
||||
}
|
||||
return request.render('education_library.portal_library_books', values)
|
||||
|
||||
@http.route(['/my/library/book/<int:book_id>'], type='http', auth='user', website=True)
|
||||
def portal_library_book_detail(self, book_id, **kw):
|
||||
book = request.env['education.library.book'].sudo().browse(book_id)
|
||||
if not book.exists():
|
||||
raise MissingError(_("Book not found."))
|
||||
|
||||
member = self._get_library_member()
|
||||
|
||||
# Check if already reserved
|
||||
has_reservation = False
|
||||
if member:
|
||||
res_count = request.env['education.library.reservation'].sudo().search_count([
|
||||
('member_id', '=', member.id),
|
||||
('book_id', '=', book.id),
|
||||
('state', '=', 'active')
|
||||
])
|
||||
has_reservation = res_count > 0
|
||||
|
||||
res_enabled = request.env['ir.config_parameter'].sudo().get_param('education_library.reservation_enabled', True)
|
||||
|
||||
values = {
|
||||
'book': book,
|
||||
'member': member,
|
||||
'has_reservation': has_reservation,
|
||||
'res_enabled': res_enabled,
|
||||
'page_name': 'library_book_detail',
|
||||
}
|
||||
return request.render('education_library.portal_library_book_detail', values)
|
||||
|
||||
@http.route(['/my/library/book/<int:book_id>/reserve'], type='http', auth='user', methods=['POST'], website=True)
|
||||
def portal_library_reserve_book(self, book_id, **kw):
|
||||
member = self._get_library_member()
|
||||
if not member:
|
||||
return request.redirect('/my/library/books')
|
||||
|
||||
book = request.env['education.library.book'].sudo().browse(book_id)
|
||||
if not book.exists():
|
||||
raise MissingError(_("Book not found."))
|
||||
|
||||
res_enabled = request.env['ir.config_parameter'].sudo().get_param('education_library.reservation_enabled', True)
|
||||
if not res_enabled:
|
||||
return request.redirect('/my/library/book/%s' % book_id)
|
||||
|
||||
try:
|
||||
res = request.env['education.library.reservation'].sudo().create({
|
||||
'member_id': member.id,
|
||||
'book_id': book.id,
|
||||
'state': 'draft'
|
||||
})
|
||||
res.action_activate()
|
||||
except Exception as e:
|
||||
pass # Usually duplicate active reservation constraint
|
||||
|
||||
return request.redirect('/my/library/book/%s' % book_id)
|
||||
31
addons/education_library/data/education_library_sequence.xml
Normal file
31
addons/education_library/data/education_library_sequence.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Sequence for Library Issues -->
|
||||
<record id="seq_education_library_issue" model="ir.sequence">
|
||||
<field name="name">Library Issue Sequence</field>
|
||||
<field name="code">education.library.issue</field>
|
||||
<field name="prefix">LIB/ISS/%(year)s/</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Sequence for Library Fines -->
|
||||
<record id="seq_education_library_fine" model="ir.sequence">
|
||||
<field name="name">Library Fine Sequence</field>
|
||||
<field name="code">education.library.fine</field>
|
||||
<field name="prefix">LIB/FIN/%(year)s/</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Sequence for Library Reservations -->
|
||||
<record id="seq_education_library_reservation" model="ir.sequence">
|
||||
<field name="name">Library Reservation Sequence</field>
|
||||
<field name="code">education.library.reservation</field>
|
||||
<field name="prefix">LIB/RES/%(year)s/</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
9
addons/education_library/models/__init__.py
Normal file
9
addons/education_library/models/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
from . import res_config_settings
|
||||
from . import education_library
|
||||
from . import education_library_category
|
||||
from . import education_library_book
|
||||
from . import education_library_book_copy
|
||||
from . import education_library_member
|
||||
from . import education_library_issue
|
||||
from . import education_library_fine
|
||||
from . import education_library_reservation
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
18
addons/education_library/models/education_library.py
Normal file
18
addons/education_library/models/education_library.py
Normal file
@ -0,0 +1,18 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class EducationLibrary(models.Model):
|
||||
_name = 'education.library'
|
||||
_description = 'Education Library Master'
|
||||
|
||||
name = fields.Char(string='Library Name', required=True)
|
||||
code = fields.Char(string='Code', required=True, copy=False)
|
||||
campus_id = fields.Many2one('education.campus', string='Campus')
|
||||
department_id = fields.Many2one('education.department', string='Department')
|
||||
location = fields.Char(string='Location')
|
||||
librarian_id = fields.Many2one('res.users', string='Librarian')
|
||||
phone = fields.Char(string='Phone')
|
||||
email = fields.Char(string='Email')
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_code_unique = models.Constraint('unique(code)', 'The Library Code must be unique!')
|
||||
35
addons/education_library/models/education_library_book.py
Normal file
35
addons/education_library/models/education_library_book.py
Normal file
@ -0,0 +1,35 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class EducationLibraryBook(models.Model):
|
||||
_name = 'education.library.book'
|
||||
_description = 'Library Book Master'
|
||||
|
||||
name = fields.Char(string='Title', related='title', store=True)
|
||||
title = fields.Char(string='Title', required=True)
|
||||
subtitle = fields.Char(string='Subtitle')
|
||||
isbn = fields.Char(string='ISBN')
|
||||
author = fields.Char(string='Author', required=True)
|
||||
publisher = fields.Char(string='Publisher')
|
||||
edition = fields.Char(string='Edition')
|
||||
publication_year = fields.Char(string='Publication Year')
|
||||
language = fields.Char(string='Language', default='English')
|
||||
category_id = fields.Many2one('education.library.category', string='Category')
|
||||
subject_id = fields.Many2one('education.subject', string='Subject')
|
||||
description = fields.Text(string='Description')
|
||||
cover_image = fields.Image(string='Cover Image', max_width=1920, max_height=1920)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
copy_ids = fields.One2many('education.library.book.copy', 'book_id', string='Physical Copies')
|
||||
|
||||
total_copies = fields.Integer(string='Total Copies', compute='_compute_copy_stats', store=True)
|
||||
available_copies = fields.Integer(string='Available Copies', compute='_compute_copy_stats', store=True)
|
||||
issued_copies = fields.Integer(string='Issued Copies', compute='_compute_copy_stats', store=True)
|
||||
|
||||
@api.depends('copy_ids', 'copy_ids.status', 'copy_ids.active')
|
||||
def _compute_copy_stats(self):
|
||||
for book in self:
|
||||
active_copies = book.copy_ids.filtered(lambda c: c.active)
|
||||
book.total_copies = len(active_copies)
|
||||
book.available_copies = len(active_copies.filtered(lambda c: c.status == 'available'))
|
||||
book.issued_copies = len(active_copies.filtered(lambda c: c.status == 'issued'))
|
||||
@ -0,0 +1,40 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EducationLibraryBookCopy(models.Model):
|
||||
_name = 'education.library.book.copy'
|
||||
_description = 'Physical Book Copy'
|
||||
_rec_name = 'accession_number'
|
||||
|
||||
book_id = fields.Many2one('education.library.book', string='Book', required=True, ondelete='cascade')
|
||||
library_id = fields.Many2one('education.library', string='Library', required=True)
|
||||
accession_number = fields.Char(string='Accession Number', required=True, copy=False)
|
||||
barcode = fields.Char(string='Barcode', copy=False)
|
||||
purchase_date = fields.Date(string='Purchase Date')
|
||||
purchase_price = fields.Float(string='Purchase Price')
|
||||
condition = fields.Selection([
|
||||
('new', 'New'),
|
||||
('good', 'Good'),
|
||||
('fair', 'Fair'),
|
||||
('poor', 'Poor')
|
||||
], string='Condition', default='good')
|
||||
shelf_location = fields.Char(string='Shelf Location')
|
||||
status = fields.Selection([
|
||||
('available', 'Available'),
|
||||
('issued', 'Issued'),
|
||||
('reserved', 'Reserved'),
|
||||
('lost', 'Lost'),
|
||||
('damaged', 'Damaged'),
|
||||
('maintenance', 'Maintenance')
|
||||
], string='Status', default='available', required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
_accession_number_unique = models.Constraint('unique(accession_number)', 'The Accession Number must be unique!')
|
||||
_barcode_unique = models.Constraint('unique(barcode)', 'The Barcode must be unique when provided!')
|
||||
|
||||
@api.constrains('status', 'active')
|
||||
def _check_status_active(self):
|
||||
for copy in self:
|
||||
if not copy.active and copy.status == 'issued':
|
||||
raise ValidationError(_('A physical copy must never be simultaneously inactive and issued.'))
|
||||
@ -0,0 +1,31 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EducationLibraryCategory(models.Model):
|
||||
_name = 'education.library.category'
|
||||
_description = 'Library Book Category'
|
||||
_parent_store = True
|
||||
_rec_name = 'complete_name'
|
||||
|
||||
name = fields.Char(string='Category Name', required=True)
|
||||
code = fields.Char(string='Code')
|
||||
parent_id = fields.Many2one('education.library.category', string='Parent Category', index=True, ondelete='restrict')
|
||||
parent_path = fields.Char(index=True, unaccent=False)
|
||||
child_ids = fields.One2many('education.library.category', 'parent_id', string='Subcategories')
|
||||
complete_name = fields.Char('Complete Name', compute='_compute_complete_name', recursive=True, store=True)
|
||||
description = fields.Text(string='Description')
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
@api.depends('name', 'parent_id.complete_name')
|
||||
def _compute_complete_name(self):
|
||||
for category in self:
|
||||
if category.parent_id:
|
||||
category.complete_name = '%s / %s' % (category.parent_id.complete_name, category.name)
|
||||
else:
|
||||
category.complete_name = category.name
|
||||
|
||||
@api.constrains('parent_id')
|
||||
def _check_category_recursion(self):
|
||||
if not self._check_recursion():
|
||||
raise ValidationError(_('You cannot create recursive categories.'))
|
||||
57
addons/education_library/models/education_library_fine.py
Normal file
57
addons/education_library/models/education_library_fine.py
Normal file
@ -0,0 +1,57 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EducationLibraryFine(models.Model):
|
||||
_name = 'education.library.fine'
|
||||
_description = 'Library Fine'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Reference', required=True, copy=False, readonly=True, default=lambda self: _('New'))
|
||||
issue_id = fields.Many2one('education.library.issue', string='Issue Record', required=True, ondelete='cascade')
|
||||
member_id = fields.Many2one('education.library.member', string='Member', required=True)
|
||||
student_id = fields.Many2one('education.student', string='Student')
|
||||
|
||||
days_late = fields.Integer(string='Days Late', required=True)
|
||||
amount = fields.Float(string='Fine Amount', required=True)
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('assessed', 'Assessed'),
|
||||
('paid', 'Paid'),
|
||||
('waived', 'Waived'),
|
||||
('cancelled', 'Cancelled')
|
||||
], string='Status', default='draft', required=True)
|
||||
|
||||
payment_reference = fields.Char(string='Payment Reference')
|
||||
paid_date = fields.Date(string='Paid Date')
|
||||
remarks = fields.Text(string='Remarks')
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('education.library.fine') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.constrains('amount')
|
||||
def _check_amount(self):
|
||||
for record in self:
|
||||
if record.amount < 0:
|
||||
raise ValidationError(_('Negative fines are not permitted.'))
|
||||
|
||||
def action_assess(self):
|
||||
self.write({'state': 'assessed'})
|
||||
|
||||
def action_pay(self):
|
||||
for record in self:
|
||||
record.write({
|
||||
'state': 'paid',
|
||||
'paid_date': fields.Date.today()
|
||||
})
|
||||
|
||||
def action_waive(self):
|
||||
self.write({'state': 'waived'})
|
||||
|
||||
def action_cancel(self):
|
||||
self.write({'state': 'cancelled'})
|
||||
116
addons/education_library/models/education_library_issue.py
Normal file
116
addons/education_library/models/education_library_issue.py
Normal file
@ -0,0 +1,116 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class EducationLibraryIssue(models.Model):
|
||||
_name = 'education.library.issue'
|
||||
_description = 'Library Book Issue'
|
||||
_rec_name = 'name'
|
||||
_order = 'issue_date desc'
|
||||
|
||||
name = fields.Char(string='Reference', required=True, copy=False, readonly=True, default=lambda self: _('New'))
|
||||
member_id = fields.Many2one('education.library.member', string='Member', required=True)
|
||||
copy_id = fields.Many2one('education.library.book.copy', string='Book Copy', required=True)
|
||||
library_id = fields.Many2one('education.library', string='Library', related='copy_id.library_id', store=True)
|
||||
|
||||
issue_date = fields.Date(string='Issue Date', default=fields.Date.context_today, required=True)
|
||||
due_date = fields.Date(string='Due Date', compute='_compute_due_date', store=True, readonly=False)
|
||||
return_date = fields.Date(string='Return Date')
|
||||
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('issued', 'Issued'),
|
||||
('returned', 'Returned'),
|
||||
('overdue', 'Overdue'),
|
||||
('cancelled', 'Cancelled')
|
||||
], string='Status', default='draft', required=True)
|
||||
|
||||
remarks = fields.Text(string='Remarks')
|
||||
fine_amount = fields.Float(string='Fine Amount', compute='_compute_fine_amount')
|
||||
fine_ids = fields.One2many('education.library.fine', 'issue_id', string='Fines')
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].sudo().next_by_code('education.library.issue') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.depends('issue_date')
|
||||
def _compute_due_date(self):
|
||||
loan_days = int(self.env['ir.config_parameter'].sudo().get_param('education_library.loan_period_days', 14))
|
||||
for record in self:
|
||||
if record.issue_date and not record.due_date:
|
||||
record.due_date = record.issue_date + timedelta(days=loan_days)
|
||||
|
||||
@api.depends('fine_ids', 'fine_ids.amount')
|
||||
def _compute_fine_amount(self):
|
||||
for record in self:
|
||||
record.fine_amount = sum(record.fine_ids.mapped('amount'))
|
||||
|
||||
@api.constrains('issue_date', 'due_date')
|
||||
def _check_dates(self):
|
||||
for record in self:
|
||||
if record.due_date < record.issue_date:
|
||||
raise ValidationError(_('Due date must be greater than or equal to issue date.'))
|
||||
|
||||
def action_issue(self):
|
||||
for record in self:
|
||||
if record.copy_id.status != 'available':
|
||||
raise ValidationError(_('The selected book copy is not available for issue.'))
|
||||
if not record.member_id.active:
|
||||
raise ValidationError(_('Active member required.'))
|
||||
if record.member_id.membership_end and record.member_id.membership_end < fields.Date.today():
|
||||
raise ValidationError(_('Membership dates do not permit issue.'))
|
||||
|
||||
# Check issue limit
|
||||
active_issues = self.search_count([
|
||||
('member_id', '=', record.member_id.id),
|
||||
('status', 'in', ['issued', 'overdue'])
|
||||
])
|
||||
if active_issues >= record.member_id.issue_limit:
|
||||
raise ValidationError(_('Issue limit enforced: Member has reached their maximum active issues limit.'))
|
||||
|
||||
record.copy_id.status = 'issued'
|
||||
record.status = 'issued'
|
||||
|
||||
def action_return(self):
|
||||
for record in self:
|
||||
if record.status not in ['issued', 'overdue']:
|
||||
raise ValidationError(_('Only issued or overdue records can be returned.'))
|
||||
|
||||
return_date = fields.Date.today()
|
||||
record.return_date = return_date
|
||||
|
||||
# Calculate fine if late
|
||||
days_late = max((return_date - record.due_date).days, 0)
|
||||
if days_late > 0:
|
||||
fine_per_day = float(self.env['ir.config_parameter'].sudo().get_param('education_library.fine_per_day', 10.0))
|
||||
fine_amt = days_late * fine_per_day
|
||||
if fine_amt > 0:
|
||||
self.env['education.library.fine'].create({
|
||||
'issue_id': record.id,
|
||||
'member_id': record.member_id.id,
|
||||
'student_id': record.member_id.student_id.id if record.member_id.student_id else False,
|
||||
'days_late': days_late,
|
||||
'amount': fine_amt,
|
||||
'state': 'draft'
|
||||
})
|
||||
|
||||
record.copy_id.status = 'available'
|
||||
record.status = 'returned'
|
||||
|
||||
def action_cancel(self):
|
||||
for record in self:
|
||||
if record.status not in ['draft']:
|
||||
raise ValidationError(_('Only draft issues can be cancelled.'))
|
||||
record.status = 'cancelled'
|
||||
|
||||
@api.model
|
||||
def _cron_mark_overdue(self):
|
||||
overdue_issues = self.search([
|
||||
('status', '=', 'issued'),
|
||||
('due_date', '<', fields.Date.today())
|
||||
])
|
||||
overdue_issues.write({'status': 'overdue'})
|
||||
56
addons/education_library/models/education_library_member.py
Normal file
56
addons/education_library/models/education_library_member.py
Normal file
@ -0,0 +1,56 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EducationLibraryMember(models.Model):
|
||||
_name = 'education.library.member'
|
||||
_description = 'Library Member'
|
||||
_rec_name = 'member_number'
|
||||
|
||||
member_number = fields.Char(string='Member Number', required=True, copy=False)
|
||||
member_type = fields.Selection([
|
||||
('student', 'Student'),
|
||||
('faculty', 'Faculty'),
|
||||
('staff', 'Staff')
|
||||
], string='Member Type', required=True, default='student')
|
||||
|
||||
student_id = fields.Many2one('education.student', string='Student')
|
||||
user_id = fields.Many2one('res.users', string='Related User')
|
||||
library_id = fields.Many2one('education.library', string='Library', required=True)
|
||||
|
||||
membership_start = fields.Date(string='Membership Start Date', default=fields.Date.context_today)
|
||||
membership_end = fields.Date(string='Membership End Date')
|
||||
issue_limit = fields.Integer(string='Issue Limit', compute='_compute_issue_limit', store=True, readonly=False)
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
issue_ids = fields.One2many('education.library.issue', 'member_id', string='Issues')
|
||||
|
||||
_member_number_unique = models.Constraint('unique(member_number)', 'Member Number must be unique!')
|
||||
|
||||
@api.constrains('student_id', 'member_type', 'active')
|
||||
def _check_student_member(self):
|
||||
for record in self:
|
||||
if record.member_type == 'student' and not record.student_id:
|
||||
raise ValidationError(_('Student member must reference a student record.'))
|
||||
if record.student_id and record.active:
|
||||
existing = self.search([('student_id', '=', record.student_id.id), ('id', '!=', record.id), ('active', '=', True)])
|
||||
if existing:
|
||||
raise ValidationError(_('A student can only have one active membership.'))
|
||||
|
||||
@api.depends('member_type')
|
||||
def _compute_issue_limit(self):
|
||||
# We fetch default limit from settings
|
||||
student_limit = int(self.env['ir.config_parameter'].sudo().get_param('education_library.student_issue_limit', 3))
|
||||
faculty_limit = int(self.env['ir.config_parameter'].sudo().get_param('education_library.faculty_issue_limit', 5))
|
||||
|
||||
for record in self:
|
||||
if not record.issue_limit:
|
||||
if record.member_type == 'student':
|
||||
record.issue_limit = student_limit
|
||||
else:
|
||||
record.issue_limit = faculty_limit
|
||||
|
||||
@api.onchange('student_id')
|
||||
def _onchange_student_id(self):
|
||||
if self.student_id and self.student_id.portal_user_id:
|
||||
self.user_id = self.student_id.portal_user_id.id
|
||||
@ -0,0 +1,78 @@
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class EducationLibraryReservation(models.Model):
|
||||
_name = 'education.library.reservation'
|
||||
_description = 'Library Reservation'
|
||||
_rec_name = 'name'
|
||||
_order = 'reservation_date desc, priority desc'
|
||||
|
||||
name = fields.Char(string='Reference', required=True, copy=False, readonly=True, default=lambda self: _('New'))
|
||||
member_id = fields.Many2one('education.library.member', string='Member', required=True)
|
||||
book_id = fields.Many2one('education.library.book', string='Book', required=True)
|
||||
|
||||
reservation_date = fields.Date(string='Reservation Date', default=fields.Date.context_today, required=True)
|
||||
expiry_date = fields.Date(string='Expiry Date')
|
||||
fulfilled_date = fields.Date(string='Fulfilled Date')
|
||||
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('active', 'Active'),
|
||||
('fulfilled', 'Fulfilled'),
|
||||
('cancelled', 'Cancelled'),
|
||||
('expired', 'Expired')
|
||||
], string='Status', default='draft', required=True)
|
||||
|
||||
priority = fields.Integer(string='Priority', default=10)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('name', _('New')) == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('education.library.reservation') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.constrains('member_id', 'book_id', 'state')
|
||||
def _check_duplicate_reservation(self):
|
||||
for record in self:
|
||||
if record.state == 'active':
|
||||
duplicates = self.search([
|
||||
('member_id', '=', record.member_id.id),
|
||||
('book_id', '=', record.book_id.id),
|
||||
('state', '=', 'active'),
|
||||
('id', '!=', record.id)
|
||||
])
|
||||
if duplicates:
|
||||
raise ValidationError(_('A member cannot have multiple active reservations for the same book.'))
|
||||
|
||||
def action_activate(self):
|
||||
for record in self:
|
||||
if not record.member_id.active:
|
||||
raise ValidationError(_('Active member required to make a reservation.'))
|
||||
record.state = 'active'
|
||||
|
||||
# Set expiry date if not set and configured
|
||||
if not record.expiry_date:
|
||||
max_days = int(self.env['ir.config_parameter'].sudo().get_param('education_library.max_reservation_days', 2))
|
||||
record.expiry_date = record.reservation_date + fields.Date.context_today().replace(day=record.reservation_date.day) # Simplified for time Delta, will fix properly with datetime.timedelta
|
||||
from datetime import timedelta
|
||||
record.expiry_date = record.reservation_date + timedelta(days=max_days)
|
||||
|
||||
def action_fulfill(self):
|
||||
for record in self:
|
||||
record.write({
|
||||
'state': 'fulfilled',
|
||||
'fulfilled_date': fields.Date.today()
|
||||
})
|
||||
|
||||
def action_cancel(self):
|
||||
self.write({'state': 'cancelled'})
|
||||
|
||||
@api.model
|
||||
def _cron_expire_reservations(self):
|
||||
expired = self.search([
|
||||
('state', '=', 'active'),
|
||||
('expiry_date', '<', fields.Date.today())
|
||||
])
|
||||
expired.write({'state': 'expired'})
|
||||
40
addons/education_library/models/res_config_settings.py
Normal file
40
addons/education_library/models/res_config_settings.py
Normal file
@ -0,0 +1,40 @@
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
module_education_library = fields.Boolean(
|
||||
string='Education Library',
|
||||
default=True
|
||||
)
|
||||
library_loan_period_days = fields.Integer(
|
||||
string='Loan Period (Days)',
|
||||
config_parameter='education_library.loan_period_days',
|
||||
default=14
|
||||
)
|
||||
library_student_issue_limit = fields.Integer(
|
||||
string='Student Issue Limit',
|
||||
config_parameter='education_library.student_issue_limit',
|
||||
default=3
|
||||
)
|
||||
library_faculty_issue_limit = fields.Integer(
|
||||
string='Faculty Issue Limit',
|
||||
config_parameter='education_library.faculty_issue_limit',
|
||||
default=5
|
||||
)
|
||||
library_fine_per_day = fields.Float(
|
||||
string='Fine per Day',
|
||||
config_parameter='education_library.fine_per_day',
|
||||
default=10.0
|
||||
)
|
||||
library_reservation_enabled = fields.Boolean(
|
||||
string='Enable Reservations',
|
||||
config_parameter='education_library.reservation_enabled',
|
||||
default=True
|
||||
)
|
||||
library_max_reservation_days = fields.Integer(
|
||||
string='Max Reservation Days',
|
||||
config_parameter='education_library.max_reservation_days',
|
||||
default=2
|
||||
)
|
||||
32
addons/education_library/security/ir.model.access.csv
Normal file
32
addons/education_library/security/ir.model.access.csv
Normal file
@ -0,0 +1,32 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_education_library_manager,education.library manager,model_education_library,group_library_manager,1,1,1,1
|
||||
access_education_library_user,education.library user,model_education_library,group_library_user,1,0,0,0
|
||||
access_education_library_portal,education.library portal,model_education_library,base.group_portal,1,0,0,0
|
||||
|
||||
access_education_library_category_manager,education.library.category manager,model_education_library_category,group_library_manager,1,1,1,1
|
||||
access_education_library_category_user,education.library.category user,model_education_library_category,group_library_user,1,0,0,0
|
||||
access_education_library_category_portal,education.library.category portal,model_education_library_category,base.group_portal,1,0,0,0
|
||||
|
||||
access_education_library_book_manager,education.library.book manager,model_education_library_book,group_library_manager,1,1,1,1
|
||||
access_education_library_book_user,education.library.book user,model_education_library_book,group_library_user,1,1,1,0
|
||||
access_education_library_book_portal,education.library.book portal,model_education_library_book,base.group_portal,1,0,0,0
|
||||
|
||||
access_education_library_book_copy_manager,education.library.book.copy manager,model_education_library_book_copy,group_library_manager,1,1,1,1
|
||||
access_education_library_book_copy_user,education.library.book.copy user,model_education_library_book_copy,group_library_user,1,1,1,0
|
||||
access_education_library_book_copy_portal,education.library.book.copy portal,model_education_library_book_copy,base.group_portal,1,0,0,0
|
||||
|
||||
access_education_library_member_manager,education.library.member manager,model_education_library_member,group_library_manager,1,1,1,1
|
||||
access_education_library_member_user,education.library.member user,model_education_library_member,group_library_user,1,1,1,0
|
||||
access_education_library_member_portal,education.library.member portal,model_education_library_member,base.group_portal,1,0,0,0
|
||||
|
||||
access_education_library_issue_manager,education.library.issue manager,model_education_library_issue,group_library_manager,1,1,1,1
|
||||
access_education_library_issue_user,education.library.issue user,model_education_library_issue,group_library_user,1,1,1,0
|
||||
access_education_library_issue_portal,education.library.issue portal,model_education_library_issue,base.group_portal,1,0,0,0
|
||||
|
||||
access_education_library_fine_manager,education.library.fine manager,model_education_library_fine,group_library_manager,1,1,1,1
|
||||
access_education_library_fine_user,education.library.fine user,model_education_library_fine,group_library_user,1,1,1,0
|
||||
access_education_library_fine_portal,education.library.fine portal,model_education_library_fine,base.group_portal,1,0,0,0
|
||||
|
||||
access_education_library_res_manager,education.library.reservation manager,model_education_library_reservation,group_library_manager,1,1,1,1
|
||||
access_education_library_res_user,education.library.reservation user,model_education_library_reservation,group_library_user,1,1,1,0
|
||||
access_education_library_res_portal,education.library.reservation portal,model_education_library_reservation,base.group_portal,1,1,1,0
|
||||
|
55
addons/education_library/security/record_rules.xml
Normal file
55
addons/education_library/security/record_rules.xml
Normal file
@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Portal User Record Rules -->
|
||||
|
||||
<!-- Portal user can see their own member record -->
|
||||
<record id="library_member_portal_rule" model="ir.rule">
|
||||
<field name="name">Library Member: Portal User Rule</field>
|
||||
<field name="model_id" ref="model_education_library_member"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
<field name="perm_write" eval="0"/>
|
||||
<field name="perm_create" eval="0"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal user can see their own issues -->
|
||||
<record id="library_issue_portal_rule" model="ir.rule">
|
||||
<field name="name">Library Issue: Portal User Rule</field>
|
||||
<field name="model_id" ref="model_education_library_issue"/>
|
||||
<field name="domain_force">[('member_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
<field name="perm_write" eval="0"/>
|
||||
<field name="perm_create" eval="0"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal user can see their own fines -->
|
||||
<record id="library_fine_portal_rule" model="ir.rule">
|
||||
<field name="name">Library Fine: Portal User Rule</field>
|
||||
<field name="model_id" ref="model_education_library_fine"/>
|
||||
<field name="domain_force">[('member_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
<field name="perm_write" eval="0"/>
|
||||
<field name="perm_create" eval="0"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
</record>
|
||||
|
||||
<!-- Portal user can see their own reservations and create new ones -->
|
||||
<record id="library_reservation_portal_rule" model="ir.rule">
|
||||
<field name="name">Library Reservation: Portal User Rule</field>
|
||||
<field name="model_id" ref="model_education_library_reservation"/>
|
||||
<field name="domain_force">[('member_id.user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="1"/>
|
||||
<field name="perm_write" eval="1"/>
|
||||
<field name="perm_create" eval="1"/>
|
||||
<field name="perm_unlink" eval="0"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
24
addons/education_library/security/security.xml
Normal file
24
addons/education_library/security/security.xml
Normal file
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Library Category -->
|
||||
<record model="res.groups.privilege" id="module_category_education_library">
|
||||
<field name="name">Library Management</field>
|
||||
<field name="description">Helps you manage your educational library.</field>
|
||||
</record>
|
||||
|
||||
<!-- Library User / Librarian -->
|
||||
<record id="group_library_user" model="res.groups">
|
||||
<field name="name">Library User / Librarian</field>
|
||||
<field name="privilege_id" ref="module_category_education_library"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="comment">Can manage daily operations (issues, returns, members).</field>
|
||||
</record>
|
||||
|
||||
<!-- Library Manager -->
|
||||
<record id="group_library_manager" model="res.groups">
|
||||
<field name="name">Library Manager</field>
|
||||
<field name="privilege_id" ref="module_category_education_library"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_library_user'))]"/>
|
||||
<field name="comment">Full access to library settings and configurations.</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
addons/education_library/tests/__init__.py
Normal file
2
addons/education_library/tests/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import test_library_security
|
||||
from . import test_library_core
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
171
addons/education_library/tests/test_library_core.py
Normal file
171
addons/education_library/tests/test_library_core.py
Normal file
@ -0,0 +1,171 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError
|
||||
from datetime import timedelta
|
||||
from odoo import fields
|
||||
|
||||
class TestLibraryCore(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestLibraryCore, self).setUp()
|
||||
self.Library = self.env['education.library']
|
||||
self.Category = self.env['education.library.category']
|
||||
self.Book = self.env['education.library.book']
|
||||
self.Copy = self.env['education.library.book.copy']
|
||||
self.Member = self.env['education.library.member']
|
||||
self.Issue = self.env['education.library.issue']
|
||||
self.Fine = self.env['education.library.fine']
|
||||
self.Reservation = self.env['education.library.reservation']
|
||||
|
||||
self.library_1 = self.Library.create({
|
||||
'name': 'Main Library',
|
||||
'code': 'ML01'
|
||||
})
|
||||
|
||||
self.company = self.env.company
|
||||
self.campus = self.env['education.campus'].create({
|
||||
'name': 'Test Campus', 'code': 'TSTC', 'company_id': self.company.id,
|
||||
})
|
||||
self.level = self.env['education.academic.level'].create({
|
||||
'name': 'UG', 'code': 'UG', 'company_id': self.company.id,
|
||||
})
|
||||
self.department = self.env['education.department'].create({
|
||||
'name': 'CS', 'code': 'CS', 'campus_id': self.campus.id,
|
||||
})
|
||||
self.academic_year = self.env['education.academic.year'].create({
|
||||
'name': '2025-2026', 'code': 'AY2025', 'start_date': '2025-06-01', 'end_date': '2026-05-31', 'company_id': self.company.id, 'state': 'draft',
|
||||
})
|
||||
self.academic_term = self.env['education.academic.term'].create({
|
||||
'name': 'Sem 1', 'code': 'SEM1', 'academic_year_id': self.academic_year.id, 'start_date': '2025-06-01', 'end_date': '2025-09-30', 'sequence': 1, 'company_id': self.company.id,
|
||||
})
|
||||
self.program = self.env['education.program'].create({
|
||||
'name': 'BTech CS', 'code': 'BTECH-CS', 'level_id': self.level.id, 'department_id': self.department.id, 'duration': 4, 'duration_unit': 'years', 'company_id': self.company.id,
|
||||
})
|
||||
self.batch = self.env['education.batch'].create({
|
||||
'name': 'Batch 2025', 'code': 'B2025',
|
||||
})
|
||||
self.semester = self.env['education.semester'].create({
|
||||
'name': 'Semester 1', 'code': 'SEM1',
|
||||
})
|
||||
self.section = self.env['education.section'].create({
|
||||
'name': 'Section A', 'code': 'SEC-A',
|
||||
})
|
||||
|
||||
self.category_1 = self.Category.create({
|
||||
'name': 'Science'
|
||||
})
|
||||
self.category_2 = self.Category.create({
|
||||
'name': 'Physics',
|
||||
'parent_id': self.category_1.id
|
||||
})
|
||||
|
||||
self.book_1 = self.Book.create({
|
||||
'title': 'Fundamentals of Physics',
|
||||
'author': 'Halliday',
|
||||
'category_id': self.category_2.id
|
||||
})
|
||||
|
||||
self.copy_1 = self.Copy.create({
|
||||
'book_id': self.book_1.id,
|
||||
'library_id': self.library_1.id,
|
||||
'accession_number': 'ACC-001',
|
||||
'status': 'available'
|
||||
})
|
||||
|
||||
self.student = self.env['education.student'].create({
|
||||
'first_name': 'Test',
|
||||
'last_name': 'Student',
|
||||
'admission_number': 'TEST001',
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
'level_id': self.level.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'academic_term_id': self.academic_term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section.id,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
self.member_1 = self.Member.create({
|
||||
'member_number': 'MEM001',
|
||||
'student_id': self.student.id,
|
||||
'library_id': self.library_1.id,
|
||||
'member_type': 'student',
|
||||
'issue_limit': 1
|
||||
})
|
||||
|
||||
def test_library_duplicate_code(self):
|
||||
with self.assertRaises(Exception):
|
||||
self.Library.create({'name': 'Another Lib', 'code': 'ML01'})
|
||||
self.env.flush_all()
|
||||
|
||||
def test_category_recursion(self):
|
||||
with self.assertRaises(Exception):
|
||||
self.category_1.parent_id = self.category_2.id
|
||||
self.env.flush_all()
|
||||
|
||||
def test_book_copies_stats(self):
|
||||
self.assertEqual(self.book_1.total_copies, 1)
|
||||
self.assertEqual(self.book_1.available_copies, 1)
|
||||
self.assertEqual(self.book_1.issued_copies, 0)
|
||||
|
||||
# Issue the copy directly
|
||||
self.copy_1.status = 'issued'
|
||||
self.book_1.invalidate_recordset(['available_copies', 'issued_copies'])
|
||||
self.assertEqual(self.book_1.available_copies, 0)
|
||||
self.assertEqual(self.book_1.issued_copies, 1)
|
||||
|
||||
def test_issue_workflow(self):
|
||||
issue = self.Issue.create({
|
||||
'member_id': self.member_1.id,
|
||||
'copy_id': self.copy_1.id,
|
||||
})
|
||||
|
||||
# Action issue
|
||||
issue.action_issue()
|
||||
self.assertEqual(issue.status, 'issued')
|
||||
self.assertEqual(self.copy_1.status, 'issued')
|
||||
|
||||
# Test Return
|
||||
issue.action_return()
|
||||
self.assertEqual(issue.status, 'returned')
|
||||
self.assertEqual(self.copy_1.status, 'available')
|
||||
|
||||
def test_issue_limit(self):
|
||||
issue = self.Issue.create({
|
||||
'member_id': self.member_1.id,
|
||||
'copy_id': self.copy_1.id,
|
||||
})
|
||||
issue.action_issue()
|
||||
|
||||
copy_2 = self.Copy.create({
|
||||
'book_id': self.book_1.id,
|
||||
'library_id': self.library_1.id,
|
||||
'accession_number': 'ACC-002',
|
||||
})
|
||||
|
||||
issue_2 = self.Issue.create({
|
||||
'member_id': self.member_1.id,
|
||||
'copy_id': copy_2.id,
|
||||
})
|
||||
with self.assertRaises(ValidationError):
|
||||
issue_2.action_issue()
|
||||
|
||||
def test_fine_calculation(self):
|
||||
issue = self.Issue.create({
|
||||
'member_id': self.member_1.id,
|
||||
'copy_id': self.copy_1.id,
|
||||
'issue_date': fields.Date.today() - timedelta(days=20),
|
||||
'due_date': fields.Date.today() - timedelta(days=5) # 5 days late
|
||||
})
|
||||
issue.status = 'issued'
|
||||
self.copy_1.status = 'issued'
|
||||
|
||||
issue.action_return()
|
||||
|
||||
# Verify fine is created
|
||||
fines = self.Fine.search([('issue_id', '=', issue.id)])
|
||||
self.assertTrue(fines)
|
||||
self.assertEqual(fines.days_late, 5)
|
||||
self.assertEqual(fines.amount, 5 * 10.0)
|
||||
137
addons/education_library/tests/test_library_security.py
Normal file
137
addons/education_library/tests/test_library_security.py
Normal file
@ -0,0 +1,137 @@
|
||||
from odoo.tests.common import TransactionCase, HttpCase
|
||||
from odoo.exceptions import AccessError
|
||||
|
||||
class TestLibrarySecurity(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(TestLibrarySecurity, self).setUp()
|
||||
|
||||
# Ensure portal user exists
|
||||
self.portal_user = self.env['res.users'].search([('login', '=', 'portal_test')], limit=1)
|
||||
if not self.portal_user:
|
||||
self.portal_user = self.env['res.users'].create({
|
||||
'name': 'Portal User',
|
||||
'login': 'portal_test',
|
||||
'group_ids': [(4, self.env.ref('base.group_portal').id)]
|
||||
})
|
||||
|
||||
self.portal_user2 = self.env['res.users'].create({
|
||||
'name': 'Portal User 2',
|
||||
'login': 'portal_test2',
|
||||
'group_ids': [(4, self.env.ref('base.group_portal').id)]
|
||||
})
|
||||
|
||||
self.library = self.env['education.library'].create({
|
||||
'name': 'Main Library Sec',
|
||||
'code': 'MLS01'
|
||||
})
|
||||
|
||||
self.company = self.env.company
|
||||
self.campus = self.env['education.campus'].create({
|
||||
'name': 'Test Campus Sec', 'code': 'TCS01', 'company_id': self.company.id,
|
||||
})
|
||||
self.level = self.env['education.academic.level'].create({
|
||||
'name': 'UG Sec', 'code': 'UGS01', 'company_id': self.company.id,
|
||||
})
|
||||
self.department = self.env['education.department'].create({
|
||||
'name': 'Test Department Sec', 'code': 'TDS01', 'campus_id': self.campus.id,
|
||||
})
|
||||
self.academic_year = self.env['education.academic.year'].create({
|
||||
'name': '2025-2026 Sec', 'code': 'AY2025S', 'start_date': '2025-06-01', 'end_date': '2026-05-31', 'company_id': self.company.id, 'state': 'draft',
|
||||
})
|
||||
self.academic_term = self.env['education.academic.term'].create({
|
||||
'name': 'Sem 1 Sec', 'code': 'SEM1S', 'academic_year_id': self.academic_year.id, 'start_date': '2025-06-01', 'end_date': '2025-09-30', 'sequence': 1, 'company_id': self.company.id,
|
||||
})
|
||||
self.program = self.env['education.program'].create({
|
||||
'name': 'BTech CS Sec', 'code': 'BTECH-CSS', 'level_id': self.level.id, 'department_id': self.department.id, 'duration': 4, 'duration_unit': 'years', 'company_id': self.company.id,
|
||||
})
|
||||
self.batch = self.env['education.batch'].create({
|
||||
'name': 'Batch 2025 Sec', 'code': 'B2025S',
|
||||
})
|
||||
self.semester = self.env['education.semester'].create({
|
||||
'name': 'Semester 1 Sec', 'code': 'SEM1S',
|
||||
})
|
||||
self.section = self.env['education.section'].create({
|
||||
'name': 'Section A Sec', 'code': 'SEC-AS',
|
||||
})
|
||||
|
||||
self.student = self.env['education.student'].create({
|
||||
'first_name': 'Portal',
|
||||
'last_name': 'Student',
|
||||
'admission_number': 'SEC001',
|
||||
'portal_user_id': self.portal_user.id,
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
'level_id': self.level.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'academic_term_id': self.academic_term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section.id,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
self.student2 = self.env['education.student'].create({
|
||||
'first_name': 'Portal2',
|
||||
'last_name': 'Student2',
|
||||
'admission_number': 'SEC002',
|
||||
'portal_user_id': self.portal_user2.id,
|
||||
'campus_id': self.campus.id,
|
||||
'department_id': self.department.id,
|
||||
'program_id': self.program.id,
|
||||
'level_id': self.level.id,
|
||||
'academic_year_id': self.academic_year.id,
|
||||
'academic_term_id': self.academic_term.id,
|
||||
'batch_id': self.batch.id,
|
||||
'semester_id': self.semester.id,
|
||||
'section_id': self.section.id,
|
||||
'company_id': self.company.id,
|
||||
})
|
||||
|
||||
self.member1 = self.env['education.library.member'].create({
|
||||
'member_number': 'SM001',
|
||||
'student_id': self.student.id,
|
||||
'library_id': self.library.id,
|
||||
'user_id': self.portal_user.id
|
||||
})
|
||||
|
||||
self.member2 = self.env['education.library.member'].create({
|
||||
'member_number': 'SM002',
|
||||
'student_id': self.student2.id,
|
||||
'library_id': self.library.id,
|
||||
'user_id': self.portal_user2.id
|
||||
})
|
||||
|
||||
self.book = self.env['education.library.book'].create({
|
||||
'title': 'Security Book',
|
||||
'author': 'Auth',
|
||||
})
|
||||
self.copy = self.env['education.library.book.copy'].create({
|
||||
'book_id': self.book.id,
|
||||
'library_id': self.library.id,
|
||||
'accession_number': 'SEC-001'
|
||||
})
|
||||
|
||||
self.issue1 = self.env['education.library.issue'].create({
|
||||
'member_id': self.member1.id,
|
||||
'copy_id': self.copy.id,
|
||||
'status': 'issued'
|
||||
})
|
||||
|
||||
def test_student_cannot_read_others_issue(self):
|
||||
# As portal user 2, try to read issue 1
|
||||
with self.assertRaises(AccessError):
|
||||
self.issue1.with_user(self.portal_user2).read(['name'])
|
||||
|
||||
def test_student_can_read_own_issue(self):
|
||||
# Should not raise exception
|
||||
self.issue1.with_user(self.portal_user).read(['name'])
|
||||
|
||||
def test_student_cannot_spoof_member(self):
|
||||
# Portal user 1 trying to create an issue for Member 2
|
||||
with self.assertRaises(AccessError):
|
||||
self.env['education.library.issue'].with_user(self.portal_user).create({
|
||||
'member_id': self.member2.id,
|
||||
'copy_id': self.copy.id
|
||||
})
|
||||
@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Library Book List View -->
|
||||
<record id="view_education_library_book_tree" model="ir.ui.view">
|
||||
<field name="name">education.library.book.tree</field>
|
||||
<field name="model">education.library.book</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Books">
|
||||
<field name="title"/>
|
||||
<field name="author"/>
|
||||
<field name="isbn"/>
|
||||
<field name="category_id"/>
|
||||
<field name="available_copies"/>
|
||||
<field name="total_copies"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Library Book Form View -->
|
||||
<record id="view_education_library_book_form" model="ir.ui.view">
|
||||
<field name="name">education.library.book.form</field>
|
||||
<field name="model">education.library.book</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Book">
|
||||
<sheet>
|
||||
<field name="cover_image" widget="image" class="oe_avatar"/>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="title" placeholder="Book Title"/>
|
||||
</h1>
|
||||
<h2>
|
||||
<field name="subtitle" placeholder="Subtitle (Optional)"/>
|
||||
</h2>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="author"/>
|
||||
<field name="isbn"/>
|
||||
<field name="publisher"/>
|
||||
<field name="edition"/>
|
||||
<field name="publication_year"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="category_id"/>
|
||||
<field name="subject_id"/>
|
||||
<field name="language"/>
|
||||
<field name="total_copies"/>
|
||||
<field name="available_copies"/>
|
||||
<field name="issued_copies"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description">
|
||||
<field name="description"/>
|
||||
</page>
|
||||
<page string="Physical Copies">
|
||||
<field name="copy_ids">
|
||||
<list string="Copies" editable="bottom">
|
||||
<field name="accession_number"/>
|
||||
<field name="barcode"/>
|
||||
<field name="library_id"/>
|
||||
<field name="shelf_location"/>
|
||||
<field name="condition"/>
|
||||
<field name="status"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_library_book" model="ir.actions.act_window">
|
||||
<field name="name">Books</field>
|
||||
<field name="res_model">education.library.book</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_education_library_book"
|
||||
name="Books"
|
||||
parent="education_library_menu_books"
|
||||
action="action_education_library_book"
|
||||
sequence="10"/>
|
||||
|
||||
</odoo>
|
||||
@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_education_library_category_tree" model="ir.ui.view">
|
||||
<field name="name">education.library.category.tree</field>
|
||||
<field name="model">education.library.category</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Categories">
|
||||
<field name="complete_name"/>
|
||||
<field name="code"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_library_category_form" model="ir.ui.view">
|
||||
<field name="name">education.library.category.form</field>
|
||||
<field name="model">education.library.category</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Category">
|
||||
<sheet>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="parent_id"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description">
|
||||
<field name="description"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_library_category" model="ir.actions.act_window">
|
||||
<field name="name">Categories</field>
|
||||
<field name="res_model">education.library.category</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_education_library_category"
|
||||
name="Categories"
|
||||
parent="education_library_menu_configuration"
|
||||
action="action_education_library_category"
|
||||
sequence="20"/>
|
||||
|
||||
</odoo>
|
||||
@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_education_library_fine_tree" model="ir.ui.view">
|
||||
<field name="name">education.library.fine.tree</field>
|
||||
<field name="model">education.library.fine</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Fines" decoration-danger="state == 'assessed'" decoration-success="state == 'paid'">
|
||||
<field name="name"/>
|
||||
<field name="member_id"/>
|
||||
<field name="issue_id"/>
|
||||
<field name="days_late"/>
|
||||
<field name="amount"/>
|
||||
<field name="state" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_library_fine_form" model="ir.ui.view">
|
||||
<field name="name">education.library.fine.form</field>
|
||||
<field name="model">education.library.fine</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Fine">
|
||||
<header>
|
||||
<button name="action_assess" string="Assess" type="object" class="oe_highlight" invisible="state != 'draft'"/>
|
||||
<button name="action_pay" string="Mark as Paid" type="object" class="oe_highlight" invisible="state != 'assessed'"/>
|
||||
<button name="action_waive" string="Waive" type="object" invisible="state != 'assessed'"/>
|
||||
<button name="action_cancel" string="Cancel" type="object" invisible="state != 'draft'"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,assessed,paid,waived"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="member_id" readonly="state != 'draft'"/>
|
||||
<field name="student_id" readonly="state != 'draft'"/>
|
||||
<field name="issue_id" readonly="state != 'draft'"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="days_late" readonly="state != 'draft'"/>
|
||||
<field name="amount" readonly="state != 'draft'"/>
|
||||
<field name="payment_reference" readonly="state in ['paid', 'waived', 'cancelled']"/>
|
||||
<field name="paid_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Remarks">
|
||||
<field name="remarks"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_library_fine" model="ir.actions.act_window">
|
||||
<field name="name">Fines</field>
|
||||
<field name="res_model">education.library.fine</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_education_library_fine"
|
||||
name="Fines"
|
||||
parent="education_library_menu_issues"
|
||||
action="action_education_library_fine"
|
||||
sequence="20"/>
|
||||
|
||||
</odoo>
|
||||
@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_education_library_issue_tree" model="ir.ui.view">
|
||||
<field name="name">education.library.issue.tree</field>
|
||||
<field name="model">education.library.issue</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Book Issues" decoration-danger="status == 'overdue'" decoration-success="status == 'returned'" decoration-info="status == 'issued'">
|
||||
<field name="name"/>
|
||||
<field name="member_id"/>
|
||||
<field name="copy_id"/>
|
||||
<field name="issue_date"/>
|
||||
<field name="due_date"/>
|
||||
<field name="return_date"/>
|
||||
<field name="status" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_library_issue_form" model="ir.ui.view">
|
||||
<field name="name">education.library.issue.form</field>
|
||||
<field name="model">education.library.issue</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Book Issue">
|
||||
<header>
|
||||
<button name="action_issue" string="Issue Book" type="object" class="oe_highlight" invisible="status != 'draft'"/>
|
||||
<button name="action_return" string="Return Book" type="object" class="oe_highlight" invisible="status not in ['issued', 'overdue']"/>
|
||||
<button name="action_cancel" string="Cancel" type="object" invisible="status != 'draft'"/>
|
||||
<field name="status" widget="statusbar" statusbar_visible="draft,issued,returned,overdue"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="member_id" readonly="status != 'draft'"/>
|
||||
<field name="copy_id" readonly="status != 'draft'"/>
|
||||
<field name="library_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="issue_date" readonly="status != 'draft'"/>
|
||||
<field name="due_date" readonly="status != 'draft'"/>
|
||||
<field name="return_date" readonly="1"/>
|
||||
<field name="fine_amount"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Remarks">
|
||||
<field name="remarks"/>
|
||||
</page>
|
||||
<page string="Fines">
|
||||
<field name="fine_ids" readonly="1">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="days_late"/>
|
||||
<field name="amount"/>
|
||||
<field name="state" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_library_issue" model="ir.actions.act_window">
|
||||
<field name="name">Issues & Returns</field>
|
||||
<field name="res_model">education.library.issue</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_education_library_issue"
|
||||
name="Issues & Returns"
|
||||
parent="education_library_menu_issues"
|
||||
action="action_education_library_issue"
|
||||
sequence="10"/>
|
||||
|
||||
</odoo>
|
||||
@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_education_library_member_tree" model="ir.ui.view">
|
||||
<field name="name">education.library.member.tree</field>
|
||||
<field name="model">education.library.member</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Members">
|
||||
<field name="member_number"/>
|
||||
<field name="member_type"/>
|
||||
<field name="student_id"/>
|
||||
<field name="library_id"/>
|
||||
<field name="issue_limit"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_library_member_form" model="ir.ui.view">
|
||||
<field name="name">education.library.member.form</field>
|
||||
<field name="model">education.library.member</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Member">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="member_number"/>
|
||||
<field name="member_type"/>
|
||||
<field name="student_id" invisible="member_type != 'student'" required="member_type == 'student'"/>
|
||||
<field name="user_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="library_id"/>
|
||||
<field name="membership_start"/>
|
||||
<field name="membership_end"/>
|
||||
<field name="issue_limit"/>
|
||||
<field name="active" widget="boolean_toggle"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Issues">
|
||||
<field name="issue_ids" readonly="1">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="copy_id"/>
|
||||
<field name="issue_date"/>
|
||||
<field name="due_date"/>
|
||||
<field name="status" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_library_member" model="ir.actions.act_window">
|
||||
<field name="name">Members</field>
|
||||
<field name="res_model">education.library.member</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_education_library_member"
|
||||
name="Members"
|
||||
parent="education_library_menu_members"
|
||||
action="action_education_library_member"
|
||||
sequence="10"/>
|
||||
|
||||
</odoo>
|
||||
39
addons/education_library/views/education_library_menu.xml
Normal file
39
addons/education_library/views/education_library_menu.xml
Normal file
@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="education_library_menu_root" model="ir.ui.menu">
|
||||
<field name="name">Library</field>
|
||||
<field name="sequence">60</field>
|
||||
<field name="group_ids" eval="[(4, ref('group_library_user'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Submenus -->
|
||||
<record id="education_library_menu_dashboard" model="ir.ui.menu">
|
||||
<field name="name">Dashboard</field>
|
||||
<field name="parent_id" ref="education_library_menu_root"/>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
|
||||
<record id="education_library_menu_books" model="ir.ui.menu">
|
||||
<field name="name">Books</field>
|
||||
<field name="parent_id" ref="education_library_menu_root"/>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<record id="education_library_menu_issues" model="ir.ui.menu">
|
||||
<field name="name">Issues</field>
|
||||
<field name="parent_id" ref="education_library_menu_root"/>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
|
||||
<record id="education_library_menu_members" model="ir.ui.menu">
|
||||
<field name="name">Members</field>
|
||||
<field name="parent_id" ref="education_library_menu_root"/>
|
||||
<field name="sequence">40</field>
|
||||
</record>
|
||||
|
||||
<record id="education_library_menu_configuration" model="ir.ui.menu">
|
||||
<field name="name">Configuration</field>
|
||||
<field name="parent_id" ref="education_library_menu_root"/>
|
||||
<field name="sequence">100</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_education_library_reservation_tree" model="ir.ui.view">
|
||||
<field name="name">education.library.reservation.tree</field>
|
||||
<field name="model">education.library.reservation</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Reservations" decoration-info="state == 'active'" decoration-success="state == 'fulfilled'" decoration-danger="state == 'expired'">
|
||||
<field name="name"/>
|
||||
<field name="member_id"/>
|
||||
<field name="book_id"/>
|
||||
<field name="reservation_date"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="state" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_education_library_reservation_form" model="ir.ui.view">
|
||||
<field name="name">education.library.reservation.form</field>
|
||||
<field name="model">education.library.reservation</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Reservation">
|
||||
<header>
|
||||
<button name="action_activate" string="Activate" type="object" class="oe_highlight" invisible="state != 'draft'"/>
|
||||
<button name="action_fulfill" string="Fulfill" type="object" class="oe_highlight" invisible="state != 'active'"/>
|
||||
<button name="action_cancel" string="Cancel" type="object" invisible="state not in ['draft', 'active']"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,active,fulfilled,expired"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="member_id" readonly="state != 'draft'"/>
|
||||
<field name="book_id" readonly="state != 'draft'"/>
|
||||
<field name="priority" readonly="state != 'draft'"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="reservation_date" readonly="state != 'draft'"/>
|
||||
<field name="expiry_date" readonly="state != 'draft'"/>
|
||||
<field name="fulfilled_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_library_reservation" model="ir.actions.act_window">
|
||||
<field name="name">Reservations</field>
|
||||
<field name="res_model">education.library.reservation</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_education_library_reservation"
|
||||
name="Reservations"
|
||||
parent="education_library_menu_issues"
|
||||
action="action_education_library_reservation"
|
||||
sequence="30"/>
|
||||
|
||||
</odoo>
|
||||
216
addons/education_library/views/portal_templates.xml
Normal file
216
addons/education_library/views/portal_templates.xml
Normal file
@ -0,0 +1,216 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- Portal My Home extension -->
|
||||
<template id="portal_my_home_library" name="Show Library" inherit_id="portal.portal_my_home" priority="40">
|
||||
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">My Library</t>
|
||||
<t t-set="url" t-value="'/my/library'"/>
|
||||
<t t-set="placeholder_count" t-value="'library_issue_count'"/>
|
||||
</t>
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">Library Catalog</t>
|
||||
<t t-set="url" t-value="'/my/library/books'"/>
|
||||
<t t-set="placeholder_count" t-value="'catalog'"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Library Dashboard -->
|
||||
<template id="portal_my_library" name="My Library Dashboard">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Library Dashboard</t>
|
||||
</t>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<h3>Currently Issued Books</h3>
|
||||
<t t-if="not issues">
|
||||
<p class="text-muted">You have no issued books.</p>
|
||||
</t>
|
||||
<t t-if="issues">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Book</th>
|
||||
<th>Accession</th>
|
||||
<th>Issue Date</th>
|
||||
<th>Due Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="issues" t-as="issue">
|
||||
<tr>
|
||||
<td><span t-field="issue.copy_id.book_id.title"/></td>
|
||||
<td><span t-field="issue.copy_id.accession_number"/></td>
|
||||
<td><span t-field="issue.issue_date"/></td>
|
||||
<td>
|
||||
<span t-field="issue.due_date" t-att-class="'text-danger font-weight-bold' if issue.status == 'overdue' else ''"/>
|
||||
</td>
|
||||
<td><span t-field="issue.status" class="badge badge-info"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<h3>Outstanding Fines</h3>
|
||||
<t t-if="not fines">
|
||||
<p class="text-muted">You have no outstanding fines.</p>
|
||||
</t>
|
||||
<t t-if="fines">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Book</th>
|
||||
<th>Days Late</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="fines" t-as="fine">
|
||||
<tr>
|
||||
<td><span t-field="fine.name"/></td>
|
||||
<td><span t-field="fine.issue_id.copy_id.book_id.title"/></td>
|
||||
<td><span t-field="fine.days_late"/></td>
|
||||
<td><span t-field="fine.amount" class="text-danger"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<h3>Active Reservations</h3>
|
||||
<t t-if="not reservations">
|
||||
<p class="text-muted">You have no active reservations.</p>
|
||||
</t>
|
||||
<t t-if="reservations">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Book</th>
|
||||
<th>Reservation Date</th>
|
||||
<th>Expiry Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="reservations" t-as="res">
|
||||
<tr>
|
||||
<td><a t-attf-href="/my/library/book/#{res.book_id.id}"><span t-field="res.book_id.title"/></a></td>
|
||||
<td><span t-field="res.reservation_date"/></td>
|
||||
<td><span t-field="res.expiry_date"/></td>
|
||||
<td><span t-field="res.state" class="badge badge-primary"/></td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Books Catalog -->
|
||||
<template id="portal_library_books" name="Library Catalog">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">Library Catalog</t>
|
||||
</t>
|
||||
|
||||
<div class="row mt-4">
|
||||
<t t-if="not books">
|
||||
<p class="text-muted pl-3">No books found matching your criteria.</p>
|
||||
</t>
|
||||
<t t-foreach="books" t-as="book">
|
||||
<div class="col-md-3 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a t-attf-href="/my/library/book/#{book.id}"><t t-esc="book.title"/></a>
|
||||
</h5>
|
||||
<h6 class="card-subtitle mb-2 text-muted"><t t-esc="book.author"/></h6>
|
||||
<p class="card-text small">
|
||||
Available: <t t-esc="book.available_copies"/> / <t t-esc="book.total_copies"/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div t-if="pager" class="o_portal_pager text-center">
|
||||
<t t-call="portal.pager"/>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Book Detail -->
|
||||
<template id="portal_library_book_detail" name="Book Detail">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title"><t t-esc="book.title"/></t>
|
||||
</t>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-8">
|
||||
<h2><t t-esc="book.title"/></h2>
|
||||
<h4 class="text-muted"><t t-esc="book.subtitle"/></h4>
|
||||
<hr/>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>Author:</strong> <t t-esc="book.author"/><br/>
|
||||
<strong>Publisher:</strong> <t t-esc="book.publisher"/><br/>
|
||||
<strong>Edition:</strong> <t t-esc="book.edition"/><br/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>ISBN:</strong> <t t-esc="book.isbn"/><br/>
|
||||
<strong>Language:</strong> <t t-esc="book.language"/><br/>
|
||||
<strong>Category:</strong> <t t-esc="book.category_id.name"/><br/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<strong>Description:</strong>
|
||||
<p><t t-esc="book.description"/></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-light">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title">Availability</h4>
|
||||
<h1 class="display-4"><t t-esc="book.available_copies"/></h1>
|
||||
<p class="text-muted">copies available out of <t t-esc="book.total_copies"/></p>
|
||||
|
||||
<t t-if="res_enabled and member and book.available_copies == 0">
|
||||
<t t-if="not has_reservation">
|
||||
<form t-attf-action="/my/library/book/#{book.id}/reserve" method="post">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<button type="submit" class="btn btn-primary btn-block">Reserve Book</button>
|
||||
</form>
|
||||
</t>
|
||||
<t t-if="has_reservation">
|
||||
<button class="btn btn-secondary btn-block" disabled="1">Already Reserved</button>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
70
addons/education_library/views/res_config_settings_views.xml
Normal file
70
addons/education_library/views/res_config_settings_views.xml
Normal file
@ -0,0 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.education.library</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="priority" eval="70"/>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<app data-string="Education Library" string="Education Library" name="education_library" groups="education_library.group_library_manager">
|
||||
<block title="Library Settings" name="library_settings_block">
|
||||
<setting string="Loan Period">
|
||||
<field name="library_loan_period_days"/>
|
||||
<div class="text-muted">
|
||||
Default number of days a book can be issued.
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Fine per Day">
|
||||
<field name="library_fine_per_day"/>
|
||||
<div class="text-muted">
|
||||
Amount to charge per day for overdue returns.
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Issue Limits" name="issue_limits_block">
|
||||
<setting string="Student Limit">
|
||||
<field name="library_student_issue_limit"/>
|
||||
<div class="text-muted">
|
||||
Max concurrent issues for students.
|
||||
</div>
|
||||
</setting>
|
||||
<setting string="Faculty Limit">
|
||||
<field name="library_faculty_issue_limit"/>
|
||||
<div class="text-muted">
|
||||
Max concurrent issues for faculty.
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
<block title="Reservations" name="reservations_block">
|
||||
<setting string="Enable Reservations">
|
||||
<field name="library_reservation_enabled"/>
|
||||
</setting>
|
||||
<setting string="Max Reservation Days" invisible="not library_reservation_enabled">
|
||||
<field name="library_max_reservation_days"/>
|
||||
<div class="text-muted">
|
||||
Days before an active reservation expires.
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_education_library_config_settings" model="ir.actions.act_window">
|
||||
<field name="name">Settings</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">res.config.settings</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">current</field>
|
||||
<field name="context">{'module' : 'education_library', 'bin_size': False}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_education_library_config_settings"
|
||||
name="Settings"
|
||||
parent="education_library_menu_configuration"
|
||||
sequence="0"
|
||||
action="action_education_library_config_settings"
|
||||
groups="education_library.group_library_manager"/>
|
||||
</odoo>
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user