Maintainers
+Maintainers
Current maintainers:
This module is part of the OpenSPP/OpenSPP2 project on GitHub.
diff --git a/spp_session_tracking/tests/__init__.py b/spp_session_tracking/tests/__init__.py index 35c2ad2a..23f6bb15 100644 --- a/spp_session_tracking/tests/__init__.py +++ b/spp_session_tracking/tests/__init__.py @@ -2,3 +2,5 @@ from . import test_session from . import test_attendance +from . import test_security +from . import test_constraints diff --git a/spp_session_tracking/tests/test_attendance.py b/spp_session_tracking/tests/test_attendance.py index 5cb4c76c..d3a49e54 100644 --- a/spp_session_tracking/tests/test_attendance.py +++ b/spp_session_tracking/tests/test_attendance.py @@ -1,6 +1,6 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -from odoo import fields +from odoo import Command, fields from odoo.tests.common import TransactionCase @@ -12,6 +12,7 @@ def setUpClass(cls): super().setUpClass() cls.Session = cls.env["spp.session"] cls.SessionType = cls.env["spp.session.type"] + cls.SessionTopic = cls.env["spp.session.topic"] cls.Attendance = cls.env["spp.session.attendance"] cls.Partner = cls.env["res.partner"] @@ -34,8 +35,8 @@ def setUpClass(cls): "date": fields.Date.today(), "facilitator_id": cls.facilitator.id, "expected_participant_ids": [ - (4, cls.participant1.id), - (4, cls.participant2.id), + Command.link(cls.participant1.id), + Command.link(cls.participant2.id), ], } ) @@ -122,3 +123,203 @@ def test_attendance_with_notes(self): } ) self.assertEqual(attendance.notes, "Called in sick") + + # --- Edge case tests --- + + def test_attendance_rate_zero_expected(self): + """Test attendance rate is 0.0 when session has no expected participants.""" + empty_session = self.Session.create( + { + "name": "No Expected Participants Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + self.Attendance.create( + { + "session_id": empty_session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + empty_session.invalidate_recordset(["attendance_count", "attendance_rate"]) + self.assertEqual(empty_session.attendance_rate, 0.0) + + def test_attendance_rate_no_records(self): + """Test attendance rate and count are 0 when expected participants exist but no records.""" + no_records_session = self.Session.create( + { + "name": "No Attendance Records Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "expected_participant_ids": [ + Command.link(self.participant1.id), + Command.link(self.participant2.id), + ], + } + ) + self.assertEqual(no_records_session.attendance_rate, 0.0) + self.assertEqual(no_records_session.attendance_count, 0) + + def test_attendance_count_all_excused(self): + """Test attendance count is 0 when all records have is_attended=False.""" + excused_session = self.Session.create( + { + "name": "All Excused Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "expected_participant_ids": [ + Command.link(self.participant1.id), + Command.link(self.participant2.id), + ], + } + ) + self.Attendance.create( + { + "session_id": excused_session.id, + "participant_id": self.participant1.id, + "is_attended": False, + "is_excused": True, + } + ) + self.Attendance.create( + { + "session_id": excused_session.id, + "participant_id": self.participant2.id, + "is_attended": False, + "is_excused": True, + } + ) + excused_session.invalidate_recordset(["attendance_count", "attendance_rate"]) + self.assertEqual(excused_session.attendance_count, 0) + + # --- Topic tests --- + + def test_topic_assignment_with_track_topics_enabled(self): + """Test assigning topics to a session with a type that has track_topics=True.""" + topic_type = self.SessionType.create( + { + "name": "Topic Tracking Type", + "code": "TOPTRACK", + "track_topics": True, + } + ) + topic1 = self.SessionTopic.create( + { + "name": "Introduction", + "session_type_id": topic_type.id, + } + ) + topic2 = self.SessionTopic.create( + { + "name": "Advanced Techniques", + "session_type_id": topic_type.id, + } + ) + session_with_topics = self.Session.create( + { + "name": "Session With Topics", + "session_type_id": topic_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "topic_ids": [ + Command.link(topic1.id), + Command.link(topic2.id), + ], + } + ) + self.assertEqual(len(session_with_topics.topic_ids), 2) + self.assertIn(topic1, session_with_topics.topic_ids) + self.assertIn(topic2, session_with_topics.topic_ids) + + def test_topic_assignment_type_without_tracking(self): + """Test that topics can be assigned to a session even when track_topics is False.""" + no_track_type = self.SessionType.create( + { + "name": "No Topic Tracking Type", + "code": "NOTRACK", + "track_topics": False, + } + ) + standalone_topic = self.SessionTopic.create( + { + "name": "Standalone Topic", + } + ) + session_without_tracking = self.Session.create( + { + "name": "Session Without Topic Tracking", + "session_type_id": no_track_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "topic_ids": [Command.link(standalone_topic.id)], + } + ) + self.assertEqual(len(session_without_tracking.topic_ids), 1) + self.assertIn(standalone_topic, session_without_tracking.topic_ids) + + # --- Related field and field coverage tests --- + + def test_attendance_related_fields(self): + """Test session_date and session_type_id are populated from session.""" + attendance = self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + self.assertEqual(attendance.session_date, self.session.date) + self.assertEqual(attendance.session_type_id, self.session_type) + + def test_attendance_with_time(self): + """Test attendance record with attendance_time.""" + now = fields.Datetime.now() + attendance = self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": True, + "attendance_time": now, + } + ) + self.assertEqual(attendance.attendance_time, now) + + def test_attendance_excused_with_reason(self): + """Test excused attendance with reason.""" + attendance = self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": False, + "is_excused": True, + "excuse_reason": "Medical appointment", + } + ) + self.assertTrue(attendance.is_excused) + self.assertEqual(attendance.excuse_reason, "Medical appointment") + + def test_topic_full_creation(self): + """Test topic creation with all fields.""" + topic = self.SessionTopic.create( + { + "name": "Full Topic", + "code": "FULL", + "description": "A complete topic", + "sequence": 5, + } + ) + self.assertEqual(topic.code, "FULL") + self.assertEqual(topic.description, "A complete topic") + self.assertEqual(topic.sequence, 5) + self.assertTrue(topic.active) + + def test_topic_archive(self): + """Test topic can be archived.""" + topic = self.SessionTopic.create({"name": "Archivable Topic"}) + self.assertTrue(topic.active) + topic.active = False + self.assertFalse(topic.active) diff --git a/spp_session_tracking/tests/test_constraints.py b/spp_session_tracking/tests/test_constraints.py new file mode 100644 index 00000000..6b2358a7 --- /dev/null +++ b/spp_session_tracking/tests/test_constraints.py @@ -0,0 +1,135 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from psycopg2 import IntegrityError + +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestSessionConstraints(TransactionCase): + """Test database and model constraints for session tracking.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Session = cls.env["spp.session"] + cls.SessionType = cls.env["spp.session.type"] + cls.Attendance = cls.env["spp.session.attendance"] + cls.Partner = cls.env["res.partner"] + + cls.session_type = cls.SessionType.create( + { + "name": "Constraint Test Type", + "code": "CONSTR", + } + ) + + cls.facilitator = cls.env.user + + cls.participant1 = cls.Partner.create({"name": "Constraint Participant 1"}) + cls.participant2 = cls.Partner.create({"name": "Constraint Participant 2"}) + + cls.session = cls.Session.create( + { + "name": "Constraint Test Session", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.facilitator.id, + } + ) + + def test_duplicate_attendance_raises_integrity_error(self): + """Test that creating two attendance records for the same participant and session raises IntegrityError.""" + self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + with self.assertRaises(IntegrityError), self.cr.savepoint(): + self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": self.participant1.id, + "is_attended": False, + } + ) + + def test_end_time_before_start_time_raises_validation_error(self): + """Test that setting end_time before start_time raises ValidationError.""" + with self.assertRaises(ValidationError): + self.Session.create( + { + "name": "Invalid Time Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "start_time": 14.0, + "end_time": 10.0, + } + ) + + def test_end_time_before_start_time_on_write_raises_validation_error(self): + """Test that writing end_time before start_time raises ValidationError.""" + session = self.Session.create( + { + "name": "Valid Time Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "start_time": 9.0, + "end_time": 11.0, + } + ) + with self.assertRaises(ValidationError): + session.write({"end_time": 8.0}) + + def test_delete_session_type_with_sessions_raises_integrity_error(self): + """Test that deleting a session type that has sessions raises IntegrityError.""" + session_type_to_delete = self.SessionType.create( + { + "name": "Type With Sessions", + "code": "TWSESS", + } + ) + self.Session.create( + { + "name": "Session Blocking Deletion", + "session_type_id": session_type_to_delete.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + with self.assertRaises(IntegrityError), self.cr.savepoint(): + session_type_to_delete.unlink() + + def test_delete_participant_with_attendance_raises_integrity_error(self): + """Test that deleting a participant who has attendance records raises IntegrityError.""" + participant_to_delete = self.Partner.create({"name": "Participant With Attendance"}) + self.Attendance.create( + { + "session_id": self.session.id, + "participant_id": participant_to_delete.id, + "is_attended": True, + } + ) + with self.assertRaises(IntegrityError), self.cr.savepoint(): + participant_to_delete.unlink() + + def test_unique_session_type_code_raises_integrity_error(self): + """Test that creating two session types with the same code raises IntegrityError.""" + self.SessionType.create( + { + "name": "Unique Code Type A", + "code": "DUPCODE", + } + ) + with self.assertRaises(IntegrityError), self.cr.savepoint(): + self.SessionType.create( + { + "name": "Unique Code Type B", + "code": "DUPCODE", + } + ) diff --git a/spp_session_tracking/tests/test_security.py b/spp_session_tracking/tests/test_security.py new file mode 100644 index 00000000..a1ffb967 --- /dev/null +++ b/spp_session_tracking/tests/test_security.py @@ -0,0 +1,273 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. + +from odoo import Command, fields +from odoo.exceptions import AccessError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestSessionSecurity(TransactionCase): + """Test access control for session tracking module.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.SessionType = cls.env["spp.session.type"] + cls.Session = cls.env["spp.session"] + cls.Attendance = cls.env["spp.session.attendance"] + cls.Partner = cls.env["res.partner"] + + cls.session_user = cls.env["res.users"].create( + { + "name": "Session User", + "login": "test_session_user", + "group_ids": [ + Command.link(cls.env.ref("spp_session_tracking.group_session_user").id), + ], + } + ) + cls.session_manager = cls.env["res.users"].create( + { + "name": "Session Manager", + "login": "test_session_manager", + "group_ids": [ + Command.link(cls.env.ref("spp_session_tracking.group_session_manager").id), + ], + } + ) + + cls.session_type = cls.SessionType.create( + { + "name": "Security Test Type", + "code": "SECTEST", + } + ) + + cls.participant1 = cls.Partner.create({"name": "Security Participant 1"}) + cls.participant2 = cls.Partner.create({"name": "Security Participant 2"}) + + # Session facilitated by session_user + cls.own_session = cls.Session.create( + { + "name": "Own Facilitated Session", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.session_user.id, + } + ) + + # Session where session_user is co-facilitator + cls.co_facilitated_session = cls.Session.create( + { + "name": "Co-Facilitated Session", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.session_manager.id, + "co_facilitator_ids": [Command.link(cls.session_user.id)], + } + ) + + # Session facilitated by someone else entirely (manager) + cls.other_session = cls.Session.create( + { + "name": "Another Facilitator Session", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.session_manager.id, + } + ) + + # Second company for multi-company tests + cls.company_b = cls.env["res.company"].create({"name": "Company B"}) + + cls.company_b_session = cls.Session.create( + { + "name": "Company B Session", + "session_type_id": cls.session_type.id, + "date": fields.Date.today(), + "facilitator_id": cls.session_manager.id, + "company_id": cls.company_b.id, + } + ) + + def test_group_hierarchy_manager_has_user(self): + """Test manager group implies user group permissions.""" + self.assertTrue( + self.session_manager.has_group("spp_session_tracking.group_session_user"), + "Manager should have user group permissions", + ) + + def test_admin_has_manager(self): + """Test OpenSPP admin has session manager access.""" + admin = self.env["res.users"].create( + { + "name": "Admin Test User", + "login": "test_admin_session", + "group_ids": [ + Command.link(self.env.ref("spp_security.group_spp_admin").id), + ], + } + ) + self.assertTrue( + admin.has_group("spp_session_tracking.group_session_manager"), + "SPP admin should have session manager access", + ) + + def test_user_can_read_all_sessions(self): + """Test session user can read all sessions including ones they don't facilitate.""" + sessions_as_user = self.Session.with_user(self.session_user).search([]) + session_ids = sessions_as_user.ids + self.assertIn(self.own_session.id, session_ids, "User should read own session") + self.assertIn(self.other_session.id, session_ids, "User should read sessions from other facilitators") + self.assertIn(self.co_facilitated_session.id, session_ids, "User should read co-facilitated sessions") + + def test_user_can_read_session_not_facilitated(self): + """Test session user can read a session where they have no facilitation role.""" + session_as_user = self.other_session.with_user(self.session_user) + # Reading a field should not raise + self.assertEqual(session_as_user.name, "Another Facilitator Session") + + def test_user_can_write_own_facilitated_session(self): + """Test session user can write a session they facilitate.""" + self.own_session.with_user(self.session_user).write({"location": "Room A"}) + self.assertEqual(self.own_session.location, "Room A") + + def test_user_can_write_co_facilitated_session(self): + """Test session user can write a session where they are co-facilitator.""" + self.co_facilitated_session.with_user(self.session_user).write({"location": "Room B"}) + self.assertEqual(self.co_facilitated_session.location, "Room B") + + def test_user_cannot_write_other_facilitator_session(self): + """Test session user cannot write a session they do not facilitate or co-facilitate.""" + with self.assertRaises(AccessError): + self.other_session.with_user(self.session_user).write({"location": "Unauthorized"}) + + def test_user_can_create_attendance_on_own_session(self): + """Test session user can create attendance records on their own facilitated session.""" + attendance = self.Attendance.with_user(self.session_user).create( + { + "session_id": self.own_session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + self.assertTrue(attendance.is_attended) + + def test_user_can_create_attendance_on_co_facilitated_session(self): + """Test session user can create attendance records on a co-facilitated session.""" + attendance = self.Attendance.with_user(self.session_user).create( + { + "session_id": self.co_facilitated_session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + self.assertTrue(attendance.is_attended) + + def test_user_cannot_create_attendance_on_other_session(self): + """Test session user cannot create attendance on a session they do not facilitate.""" + with self.assertRaises(AccessError): + self.Attendance.with_user(self.session_user).create( + { + "session_id": self.other_session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + + def test_manager_can_create_session(self): + """Test session manager can create sessions.""" + session = self.Session.with_user(self.session_manager).create( + { + "name": "Manager Created Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.session_manager.id, + } + ) + self.assertEqual(session.name, "Manager Created Session") + + def test_manager_can_read_all_sessions(self): + """Test session manager can read all sessions.""" + sessions_as_manager = self.Session.with_user(self.session_manager).search([]) + session_ids = sessions_as_manager.ids + self.assertIn(self.own_session.id, session_ids, "Manager should read user-facilitated session") + self.assertIn(self.other_session.id, session_ids, "Manager should read all sessions") + + def test_manager_can_write_any_session(self): + """Test session manager can write sessions regardless of facilitator.""" + self.own_session.with_user(self.session_manager).write({"location": "Manager Edit"}) + self.assertEqual(self.own_session.location, "Manager Edit") + + def test_manager_can_delete_session(self): + """Test session manager can delete sessions.""" + session_to_delete = self.Session.create( + { + "name": "Session To Delete", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.session_manager.id, + } + ) + session_to_delete.with_user(self.session_manager).unlink() + self.assertFalse(session_to_delete.exists(), "Session should have been deleted") + + def test_manager_can_create_session_type(self): + """Test session manager can create session types.""" + session_type = self.SessionType.with_user(self.session_manager).create( + { + "name": "Manager Created Type", + "code": "MGRTYPE", + } + ) + self.assertEqual(session_type.name, "Manager Created Type") + + def test_user_cannot_create_session_type(self): + """Test session user cannot create session types.""" + with self.assertRaises(AccessError): + self.SessionType.with_user(self.session_user).create( + { + "name": "User Created Type", + "code": "USRTYPE", + } + ) + + def test_user_cannot_create_session(self): + """Test session user cannot create sessions (manager-only).""" + with self.assertRaises(AccessError): + self.Session.with_user(self.session_user).create( + { + "name": "User Created Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.session_user.id, + } + ) + + def test_user_cannot_delete_session(self): + """Test session user cannot delete sessions.""" + with self.assertRaises(AccessError): + self.own_session.with_user(self.session_user).unlink() + + def test_user_cannot_delete_attendance(self): + """Test session user cannot delete attendance records.""" + attendance = self.Attendance.create( + { + "session_id": self.own_session.id, + "participant_id": self.participant1.id, + "is_attended": True, + } + ) + with self.assertRaises(AccessError): + attendance.with_user(self.session_user).unlink() + + def test_multi_company_user_cannot_read_other_company_session(self): + """Test user in company A cannot see sessions belonging to company B.""" + # session_user belongs to the default company (company A) + # company_b_session belongs to company B + sessions_as_user = self.Session.with_user(self.session_user).search([]) + self.assertNotIn( + self.company_b_session.id, + sessions_as_user.ids, + "User should not see sessions from a company they do not belong to", + ) diff --git a/spp_session_tracking/tests/test_session.py b/spp_session_tracking/tests/test_session.py index 8c69e3a2..4791302b 100644 --- a/spp_session_tracking/tests/test_session.py +++ b/spp_session_tracking/tests/test_session.py @@ -1,6 +1,7 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. -from odoo import fields +from odoo import Command, fields +from odoo.exceptions import UserError from odoo.tests.common import TransactionCase @@ -103,9 +104,275 @@ def test_session_with_expected_participants(self): "date": fields.Date.today(), "facilitator_id": self.facilitator.id, "expected_participant_ids": [ - (4, self.participant1.id), - (4, self.participant2.id), + Command.link(self.participant1.id), + Command.link(self.participant2.id), ], } ) self.assertEqual(len(session.expected_participant_ids), 2) + + # --- State transition tests --- + + def test_valid_transition_start(self): + """Test valid transition from scheduled to in_progress.""" + session = self.Session.create( + { + "name": "Start Transition Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + self.assertEqual(session.state, "scheduled") + session.action_start() + self.assertEqual(session.state, "in_progress") + + def test_valid_transition_complete(self): + """Test valid transition from in_progress to completed.""" + session = self.Session.create( + { + "name": "Complete Transition Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + session.action_start() + session.action_complete() + self.assertEqual(session.state, "completed") + + def test_valid_transition_cancel_from_scheduled(self): + """Test valid transition from scheduled to cancelled.""" + session = self.Session.create( + { + "name": "Cancel From Scheduled Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + session.action_cancel() + self.assertEqual(session.state, "cancelled") + + def test_valid_transition_cancel_from_in_progress(self): + """Test valid transition from in_progress to cancelled.""" + session = self.Session.create( + { + "name": "Cancel From In Progress Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + session.action_start() + session.action_cancel() + self.assertEqual(session.state, "cancelled") + + def test_invalid_transition_start_from_completed(self): + """Test that starting a completed session raises UserError.""" + session = self.Session.create( + { + "name": "Start From Completed Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + session.action_start() + session.action_complete() + with self.assertRaises(UserError): + session.action_start() + + def test_invalid_transition_start_from_cancelled(self): + """Test that starting a cancelled session raises UserError.""" + session = self.Session.create( + { + "name": "Start From Cancelled Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + session.action_cancel() + with self.assertRaises(UserError): + session.action_start() + + def test_invalid_transition_complete_from_scheduled(self): + """Test that completing a scheduled session raises UserError.""" + session = self.Session.create( + { + "name": "Complete From Scheduled Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + with self.assertRaises(UserError): + session.action_complete() + + def test_invalid_transition_cancel_from_completed(self): + """Test that cancelling a completed session raises UserError.""" + session = self.Session.create( + { + "name": "Cancel From Completed Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + session.action_start() + session.action_complete() + with self.assertRaises(UserError): + session.action_cancel() + + def test_invalid_transition_cancel_from_cancelled(self): + """Test that cancelling an already cancelled session raises UserError.""" + session = self.Session.create( + { + "name": "Cancel From Cancelled Test", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + session.action_cancel() + with self.assertRaises(UserError): + session.action_cancel() + + # --- Computed field edge cases --- + + def test_duration_only_start_time(self): + """Test that duration is 0.0 when only start_time is set.""" + session = self.Session.create( + { + "name": "Only Start Time Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "start_time": 9.0, + } + ) + self.assertEqual(session.duration_hours, 0.0) + + def test_duration_only_end_time(self): + """Test that duration is 0.0 when only end_time is set.""" + session = self.Session.create( + { + "name": "Only End Time Session", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + "end_time": 12.0, + } + ) + self.assertEqual(session.duration_hours, 0.0) + + def test_session_type_session_count(self): + """Test that session_count on session type reflects actual sessions.""" + dedicated_type = self.SessionType.create( + { + "name": "Counted Type", + "code": "CNTTYPE", + } + ) + initial_count = dedicated_type.session_count + self.Session.create( + { + "name": "Count Session 1", + "session_type_id": dedicated_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + self.Session.create( + { + "name": "Count Session 2", + "session_type_id": dedicated_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + dedicated_type.invalidate_recordset(["session_count"]) + self.assertEqual(dedicated_type.session_count, initial_count + 2) + + def test_action_view_sessions(self): + """Test action_view_sessions returns correct action dictionary.""" + action = self.session_type.action_view_sessions() + self.assertEqual(action["type"], "ir.actions.act_window") + self.assertEqual(action["res_model"], "spp.session") + self.assertEqual(action["domain"], [("session_type_id", "=", self.session_type.id)]) + self.assertEqual(action["context"]["default_session_type_id"], self.session_type.id) + + def test_session_type_frequency(self): + """Test session type frequency field.""" + session_type = self.SessionType.create( + { + "name": "Weekly Type", + "frequency": "weekly", + } + ) + self.assertEqual(session_type.frequency, "weekly") + + def test_session_type_archive(self): + """Test session type can be archived.""" + session_type = self.SessionType.create({"name": "Archivable Type"}) + self.assertTrue(session_type.active) + session_type.active = False + self.assertFalse(session_type.active) + + def test_session_type_count_multiple_types(self): + """Test session_count works correctly across multiple types simultaneously.""" + type_a = self.SessionType.create({"name": "Type A"}) + type_b = self.SessionType.create({"name": "Type B"}) + self.Session.create( + { + "name": "S1", + "session_type_id": type_a.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + self.Session.create( + { + "name": "S2", + "session_type_id": type_a.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + self.Session.create( + { + "name": "S3", + "session_type_id": type_b.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + (type_a | type_b).invalidate_recordset(["session_count"]) + self.assertEqual(type_a.session_count, 2) + self.assertEqual(type_b.session_count, 1) + + def test_write_state_multi_record_invalid(self): + """Test that write() on multiple records rejects invalid transition for any record.""" + session1 = self.Session.create( + { + "name": "Multi 1", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + session2 = self.Session.create( + { + "name": "Multi 2", + "session_type_id": self.session_type.id, + "date": fields.Date.today(), + "facilitator_id": self.facilitator.id, + } + ) + session1.action_start() + session1.action_complete() + # session1 is completed, session2 is scheduled + # Both to in_progress should fail because session1 cannot transition + with self.assertRaises(UserError): + (session1 | session2).write({"state": "in_progress"}) diff --git a/spp_session_tracking/views/session_type_views.xml b/spp_session_tracking/views/session_type_views.xml index 4d15456d..88763a54 100644 --- a/spp_session_tracking/views/session_type_views.xml +++ b/spp_session_tracking/views/session_type_views.xml @@ -36,16 +36,17 @@ />





