Library Management updated

This commit is contained in:
Vidhya 2026-09-23 23:29:32 +05:30
parent 855d71f886
commit c5a0a747a5
155 changed files with 3673 additions and 68 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

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

View 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

View 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!')

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

View File

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

View File

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

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

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

View 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

View File

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

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

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_education_library_manager education.library manager model_education_library group_library_manager 1 1 1 1
3 access_education_library_user education.library user model_education_library group_library_user 1 0 0 0
4 access_education_library_portal education.library portal model_education_library base.group_portal 1 0 0 0
5 access_education_library_category_manager education.library.category manager model_education_library_category group_library_manager 1 1 1 1
6 access_education_library_category_user education.library.category user model_education_library_category group_library_user 1 0 0 0
7 access_education_library_category_portal education.library.category portal model_education_library_category base.group_portal 1 0 0 0
8 access_education_library_book_manager education.library.book manager model_education_library_book group_library_manager 1 1 1 1
9 access_education_library_book_user education.library.book user model_education_library_book group_library_user 1 1 1 0
10 access_education_library_book_portal education.library.book portal model_education_library_book base.group_portal 1 0 0 0
11 access_education_library_book_copy_manager education.library.book.copy manager model_education_library_book_copy group_library_manager 1 1 1 1
12 access_education_library_book_copy_user education.library.book.copy user model_education_library_book_copy group_library_user 1 1 1 0
13 access_education_library_book_copy_portal education.library.book.copy portal model_education_library_book_copy base.group_portal 1 0 0 0
14 access_education_library_member_manager education.library.member manager model_education_library_member group_library_manager 1 1 1 1
15 access_education_library_member_user education.library.member user model_education_library_member group_library_user 1 1 1 0
16 access_education_library_member_portal education.library.member portal model_education_library_member base.group_portal 1 0 0 0
17 access_education_library_issue_manager education.library.issue manager model_education_library_issue group_library_manager 1 1 1 1
18 access_education_library_issue_user education.library.issue user model_education_library_issue group_library_user 1 1 1 0
19 access_education_library_issue_portal education.library.issue portal model_education_library_issue base.group_portal 1 0 0 0
20 access_education_library_fine_manager education.library.fine manager model_education_library_fine group_library_manager 1 1 1 1
21 access_education_library_fine_user education.library.fine user model_education_library_fine group_library_user 1 1 1 0
22 access_education_library_fine_portal education.library.fine portal model_education_library_fine base.group_portal 1 0 0 0
23 access_education_library_res_manager education.library.reservation manager model_education_library_reservation group_library_manager 1 1 1 1
24 access_education_library_res_user education.library.reservation user model_education_library_reservation group_library_user 1 1 1 0
25 access_education_library_res_portal education.library.reservation portal model_education_library_reservation base.group_portal 1 1 1 0

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

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

View File

@ -0,0 +1,2 @@
from . import test_library_security
from . import test_library_core

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

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

View File

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

View File

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

View File

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

View File

@ -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 &amp; 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 &amp; Returns"
parent="education_library_menu_issues"
action="action_education_library_issue"
sequence="10"/>
</odoo>

View File

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

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

View File

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

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

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

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