# pyrefly: ignore [missing-import] from odoo import api, fields, models # pyrefly: ignore [missing-import] from odoo.exceptions import ValidationError class EducationHostelMember(models.Model): """Student residential allocation — the core membership/allocation record. Design decision: We use a SINGLE model (education.hostel.member) to serve as both the current membership record AND the historical record. Status tracks the lifecycle (pending → active → exited/transferred/cancelled). This avoids duplicate logic between 'member' and 'allocation' models. """ _name = 'education.hostel.member' _description = 'Hostel Member (Allocation)' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'allocation_date desc, id desc' _rec_name = 'name' name = fields.Char( string='Reference', required=True, copy=False, readonly=True, default=lambda self: self.env['ir.sequence'].next_by_code('education.hostel.member'), index=True, ) student_id = fields.Many2one( 'education.student', string='Student', required=True, ondelete='restrict', index=True, tracking=True, ) hostel_id = fields.Many2one( 'education.hostel', string='Hostel', required=True, ondelete='restrict', index=True, tracking=True, ) room_id = fields.Many2one( 'education.hostel.room', string='Room', required=True, ondelete='restrict', index=True, tracking=True, ) bed_id = fields.Many2one( 'education.hostel.bed', string='Bed', required=True, ondelete='restrict', index=True, tracking=True, ) allocation_date = fields.Date( string='Allocation Date', required=True, default=fields.Date.context_today, tracking=True, ) expected_exit_date = fields.Date(string='Expected Exit Date', tracking=True) actual_exit_date = fields.Date(string='Actual Exit Date', tracking=True) status = fields.Selection([ ('pending', 'Pending'), ('active', 'Active'), ('transferred', 'Transferred'), ('exited', 'Exited'), ('cancelled', 'Cancelled'), ], string='Status', default='pending', required=True, tracking=True, index=True) guardian_name = fields.Char(string='Guardian Name') guardian_phone = fields.Char(string='Guardian Phone') emergency_contact = fields.Char(string='Emergency Contact') notes = fields.Text(string='Notes') allocated_by = fields.Many2one( 'res.users', string='Allocated By', default=lambda self: self.env.user, readonly=True, ) company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company, index=True, ) # Convenience relational student_gender = fields.Selection( related='student_id.gender', store=False, string='Student Gender' ) # ── Constraints ────────────────────────────────────────────────────────── @api.constrains('student_id', 'status') def _check_unique_active_allocation(self): """A student cannot have more than one active allocation.""" for rec in self: if rec.status == 'active': duplicate = self.search([ ('student_id', '=', rec.student_id.id), ('status', '=', 'active'), ('id', '!=', rec.id), ]) if duplicate: raise ValidationError( f"Student '{rec.student_id.full_name}' already has an active hostel allocation " f"({duplicate[0].name}). A student cannot be allocated to multiple hostels simultaneously." ) @api.constrains('bed_id', 'status') def _check_unique_bed_occupant(self): """A bed cannot have more than one active occupant.""" for rec in self: if rec.status == 'active': duplicate = self.search([ ('bed_id', '=', rec.bed_id.id), ('status', '=', 'active'), ('id', '!=', rec.id), ]) if duplicate: raise ValidationError( f"Bed '{rec.bed_id.code}' is already occupied by student " f"'{duplicate[0].student_id.full_name}'. Please select a different bed." ) @api.constrains('room_id', 'hostel_id') def _check_room_hostel_consistency(self): for rec in self: if rec.room_id and rec.room_id.hostel_id != rec.hostel_id: raise ValidationError( 'The selected room does not belong to the selected hostel.' ) @api.constrains('bed_id', 'room_id') def _check_bed_room_consistency(self): for rec in self: if rec.bed_id and rec.bed_id.room_id != rec.room_id: raise ValidationError( 'The selected bed does not belong to the selected room.' ) @api.constrains('student_id', 'room_id', 'status') def _check_gender_restriction(self): """Enforce gender restriction based on room.gender setting.""" for rec in self: if rec.status in ('pending', 'active') and rec.room_id and rec.student_id: room_gender = rec.room_id.gender student_gender = rec.student_id.gender if room_gender == 'male' and student_gender not in ('male', False): raise ValidationError( f"Room '{rec.room_id.room_number}' is restricted to male students." ) if room_gender == 'female' and student_gender not in ('female', False): raise ValidationError( f"Room '{rec.room_id.room_number}' is restricted to female students." ) @api.constrains('room_id', 'status') def _check_room_capacity(self): """Active allocations for a room must not exceed room.capacity.""" for rec in self: if rec.status == 'active' and rec.room_id: active_count = self.search_count([ ('room_id', '=', rec.room_id.id), ('status', '=', 'active'), ('id', '!=', rec.id), ]) if active_count >= rec.room_id.capacity: raise ValidationError( f"Room '{rec.room_id.room_number}' has reached its maximum capacity " f"of {rec.room_id.capacity} occupants." ) # ── Workflow Actions ────────────────────────────────────────────────────── def action_activate(self): """Activate the allocation and mark the bed as occupied.""" for rec in self: if rec.status != 'pending': raise ValidationError("Only pending allocations can be activated.") rec.status = 'active' rec.bed_id.sudo().write({'status': 'occupied'}) rec.message_post(body=f"Allocation activated. Bed {rec.bed_id.code} marked as occupied.") def action_exit(self): """Mark student as exited and free the bed.""" for rec in self: if rec.status != 'active': raise ValidationError("Only active allocations can be exited.") rec.actual_exit_date = fields.Date.today() rec.status = 'exited' rec.bed_id.sudo().write({'status': 'available'}) rec.message_post(body=f"Student exited on {rec.actual_exit_date}. Bed {rec.bed_id.code} freed.") def action_cancel(self): """Cancel the allocation and free the bed if it was active.""" for rec in self: if rec.status in ('exited', 'cancelled'): raise ValidationError("This allocation is already closed.") if rec.status == 'active': rec.bed_id.sudo().write({'status': 'available'}) rec.status = 'cancelled' rec.message_post(body="Allocation cancelled.") def action_transfer(self, new_hostel_id, new_room_id, new_bed_id): """Transfer student to a different room/bed. Creates new member record.""" self.ensure_one() if self.status != 'active': raise ValidationError("Only active allocations can be transferred.") # Close current allocation self.actual_exit_date = fields.Date.today() self.status = 'transferred' self.bed_id.sudo().write({'status': 'available'}) # Create new allocation new_member = self.create({ 'student_id': self.student_id.id, 'hostel_id': new_hostel_id, 'room_id': new_room_id, 'bed_id': new_bed_id, 'allocation_date': fields.Date.today(), 'status': 'pending', 'guardian_name': self.guardian_name, 'guardian_phone': self.guardian_phone, 'emergency_contact': self.emergency_contact, 'company_id': self.company_id.id, }) self.message_post(body=f"Student transferred. New allocation: {new_member.name}") return new_member @api.model_create_multi def create(self, vals_list): for vals in vals_list: if not vals.get('name') or vals.get('name') == '/': vals['name'] = ( self.env['ir.sequence'].next_by_code('education.hostel.member') or '/' ) return super().create(vals_list)