From 6dd327ce3d76bd20bf3c99cc4bd40128d1cc4f05 Mon Sep 17 00:00:00 2001 From: vidhya Date: Thu, 17 Sep 2026 17:14:59 +0530 Subject: [PATCH] changes on Exam, timetable and lms added --- .DS_Store | Bin 8196 -> 12292 bytes .../__pycache__/__manifest__.cpython-310.pyc | Bin 1117 -> 0 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 189 -> 0 bytes .../test_attendance.cpython-310.pyc | Bin 4996 -> 0 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 291 -> 0 bytes .../test_academic_year.cpython-310.pyc | Bin 1502 -> 0 bytes .../__pycache__/test_campus.cpython-310.pyc | Bin 998 -> 0 bytes .../__pycache__/test_program.cpython-310.pyc | Bin 1349 -> 0 bytes .../__pycache__/test_security.cpython-310.pyc | Bin 783 -> 0 bytes .../__pycache__/__manifest__.cpython-310.pyc | Bin 1290 -> 0 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 187 -> 0 bytes .../test_dashboard.cpython-310.pyc | Bin 2602 -> 0 bytes .../views/portal_exam_templates.xml | 1217 +++++++++++++++-- .../__pycache__/__manifest__.cpython-310.pyc | Bin 1133 -> 0 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 183 -> 0 bytes .../__pycache__/test_finance.cpython-310.pyc | Bin 6630 -> 0 bytes addons/education_lms/__init__.py | 2 + addons/education_lms/__manifest__.py | 31 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 201 bytes addons/education_lms/controllers/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 177 bytes .../__pycache__/main.cpython-310.pyc | Bin 0 -> 10422 bytes addons/education_lms/controllers/main.py | 376 +++++ addons/education_lms/models/__init__.py | 4 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 326 bytes .../education_lms_assignment.cpython-310.pyc | Bin 0 -> 4655 bytes .../education_lms_content.cpython-310.pyc | Bin 0 -> 4652 bytes .../education_lms_course.cpython-310.pyc | Bin 0 -> 3694 bytes .../education_lms_submission.cpython-310.pyc | Bin 0 -> 7322 bytes .../models/education_lms_assignment.py | 104 ++ .../models/education_lms_content.py | 109 ++ .../models/education_lms_course.py | 76 + .../models/education_lms_submission.py | 197 +++ .../security/ir.model.access.csv | 19 + addons/education_lms/security/security.xml | 257 ++++ addons/education_lms/tests/__init__.py | 2 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 233 bytes .../test_education_lms.cpython-310.pyc | Bin 0 -> 27929 bytes .../test_education_lms_portal.cpython-310.pyc | Bin 0 -> 7680 bytes .../education_lms/tests/test_education_lms.py | 1051 ++++++++++++++ .../tests/test_education_lms_phase_h.py | 14 + .../tests/test_education_lms_portal.py | 242 ++++ .../views/education_lms_assignment_views.xml | 95 ++ .../views/education_lms_content_views.xml | 114 ++ .../views/education_lms_course_views.xml | 120 ++ .../views/education_lms_submission_views.xml | 92 ++ addons/education_lms/views/menu_views.xml | 31 + .../views/portal_lms_templates.xml | 736 ++++++++++ .../__pycache__/__manifest__.cpython-310.pyc | Bin 1022 -> 0 bytes .../payment_transaction.cpython-310.pyc | Bin 4640 -> 4640 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 224 -> 0 bytes .../test_payment_gateway.cpython-310.pyc | Bin 8096 -> 0 bytes .../__pycache__/test_portal.cpython-310.pyc | Bin 7937 -> 0 bytes .../views/portal_templates.xml | 46 + .../__pycache__/__manifest__.cpython-310.pyc | Bin 1162 -> 0 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 183 -> 0 bytes .../__pycache__/test_reports.cpython-310.pyc | Bin 3510 -> 0 bytes .../__pycache__/__init__.cpython-310.pyc | Bin 183 -> 0 bytes .../__pycache__/test_student.cpython-310.pyc | Bin 3039 -> 0 bytes addons/education_timetable/__init__.py | 2 + addons/education_timetable/__manifest__.py | 30 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 207 bytes .../controllers/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 183 bytes .../__pycache__/main.cpython-310.pyc | Bin 0 -> 2611 bytes .../education_timetable/controllers/main.py | 113 ++ addons/education_timetable/models/__init__.py | 2 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 235 bytes .../education_room.cpython-310.pyc | Bin 0 -> 863 bytes .../education_timetable_slot.cpython-310.pyc | Bin 0 -> 2733 bytes .../models/education_room.py | 17 + .../models/education_timetable_slot.py | 82 ++ .../security/ir.model.access.csv | 8 + .../education_timetable/security/security.xml | 21 + .../views/education_room_views.xml | 52 + .../views/education_timetable_slot_views.xml | 94 ++ .../education_timetable/views/menu_views.xml | 20 + .../views/portal_timetable_templates.xml | 411 ++++++ append_tests 2.py | 259 ++++ append_tests.py | 259 ++++ debug_domain 2.py | 16 + debug_domain.py | 16 + fix_all_perms 2.py | 48 + fix_all_perms.py | 48 + fix_mapped 2.py | 13 + fix_mapped.py | 13 + fix_perms 2.py | 33 + fix_perms.py | 33 + fix_security 2.py | 24 + fix_security.py | 24 + fix_security2 2.py | 18 + fix_security2.py | 18 + fix_state 2.py | 10 + fix_state.py | 10 + fix_student_user_type 2.py | 10 + fix_student_user_type.py | 10 + fix_test 2.py | 9 + fix_test.py | 9 + fix_test_users 2.py | 37 + fix_test_users.py | 37 + get_rule_db 2.py | 18 + get_rule_db.py | 18 + print_debug 2.py | 14 + print_debug.py | 14 + print_debug2 2.py | 14 + print_debug2.py | 14 + print_ids 2.py | 6 + print_ids.py | 6 + print_perms 2.py | 14 + print_perms.py | 14 + print_test_39 2.py | 9 + print_test_39.py | 9 + replace_tests 2.py | 10 + replace_tests.py | 10 + restore_domain 2.py | 26 + restore_domain.py | 26 + rewrite_security 2.py | 30 + rewrite_security.py | 30 + test_1_1 2.py | 23 + test_1_1.py | 23 + test_db_rule 2.py | 23 + test_db_rule.py | 23 + test_db_rules 2.py | 2 + test_db_rules.py | 2 + test_domain 2.py | 20 + test_domain.py | 20 + test_domain2 2.py | 18 + test_domain2.py | 18 + test_domain_eval 2.py | 9 + test_domain_eval.py | 9 + test_env_student_ids 2.py | 14 + test_env_student_ids.py | 14 + test_groups 2.py | 14 + test_groups.py | 14 + test_invalidate 2.py | 12 + test_invalidate.py | 12 + test_rule_domain 2.py | 16 + test_rule_domain.py | 16 + test_rule_name 2.py | 14 + test_rule_name.py | 14 + test_rules 2.py | 14 + test_rules.py | 14 + test_rules_odoo 2.py | 2 + test_rules_odoo.py | 2 + test_search 2.py | 14 + test_search.py | 14 + 146 files changed, 7304 insertions(+), 111 deletions(-) delete mode 100644 addons/education_attendance/__pycache__/__manifest__.cpython-310.pyc delete mode 100644 addons/education_attendance/tests/__pycache__/__init__.cpython-310.pyc delete mode 100644 addons/education_attendance/tests/__pycache__/test_attendance.cpython-310.pyc delete mode 100644 addons/education_base/tests/__pycache__/__init__.cpython-310.pyc delete mode 100644 addons/education_base/tests/__pycache__/test_academic_year.cpython-310.pyc delete mode 100644 addons/education_base/tests/__pycache__/test_campus.cpython-310.pyc delete mode 100644 addons/education_base/tests/__pycache__/test_program.cpython-310.pyc delete mode 100644 addons/education_base/tests/__pycache__/test_security.cpython-310.pyc delete mode 100644 addons/education_dashboard/__pycache__/__manifest__.cpython-310.pyc delete mode 100644 addons/education_dashboard/tests/__pycache__/__init__.cpython-310.pyc delete mode 100644 addons/education_dashboard/tests/__pycache__/test_dashboard.cpython-310.pyc delete mode 100644 addons/education_finance/__pycache__/__manifest__.cpython-310.pyc delete mode 100644 addons/education_finance/tests/__pycache__/__init__.cpython-310.pyc delete mode 100644 addons/education_finance/tests/__pycache__/test_finance.cpython-310.pyc create mode 100644 addons/education_lms/__init__.py create mode 100644 addons/education_lms/__manifest__.py create mode 100644 addons/education_lms/__pycache__/__init__.cpython-310.pyc create mode 100644 addons/education_lms/controllers/__init__.py create mode 100644 addons/education_lms/controllers/__pycache__/__init__.cpython-310.pyc create mode 100644 addons/education_lms/controllers/__pycache__/main.cpython-310.pyc create mode 100644 addons/education_lms/controllers/main.py create mode 100644 addons/education_lms/models/__init__.py create mode 100644 addons/education_lms/models/__pycache__/__init__.cpython-310.pyc create mode 100644 addons/education_lms/models/__pycache__/education_lms_assignment.cpython-310.pyc create mode 100644 addons/education_lms/models/__pycache__/education_lms_content.cpython-310.pyc create mode 100644 addons/education_lms/models/__pycache__/education_lms_course.cpython-310.pyc create mode 100644 addons/education_lms/models/__pycache__/education_lms_submission.cpython-310.pyc create mode 100644 addons/education_lms/models/education_lms_assignment.py create mode 100644 addons/education_lms/models/education_lms_content.py create mode 100644 addons/education_lms/models/education_lms_course.py create mode 100644 addons/education_lms/models/education_lms_submission.py create mode 100644 addons/education_lms/security/ir.model.access.csv create mode 100644 addons/education_lms/security/security.xml create mode 100644 addons/education_lms/tests/__init__.py create mode 100644 addons/education_lms/tests/__pycache__/__init__.cpython-310.pyc create mode 100644 addons/education_lms/tests/__pycache__/test_education_lms.cpython-310.pyc create mode 100644 addons/education_lms/tests/__pycache__/test_education_lms_portal.cpython-310.pyc create mode 100644 addons/education_lms/tests/test_education_lms.py create mode 100644 addons/education_lms/tests/test_education_lms_phase_h.py create mode 100644 addons/education_lms/tests/test_education_lms_portal.py create mode 100644 addons/education_lms/views/education_lms_assignment_views.xml create mode 100644 addons/education_lms/views/education_lms_content_views.xml create mode 100644 addons/education_lms/views/education_lms_course_views.xml create mode 100644 addons/education_lms/views/education_lms_submission_views.xml create mode 100644 addons/education_lms/views/menu_views.xml create mode 100644 addons/education_lms/views/portal_lms_templates.xml delete mode 100644 addons/education_portal/__pycache__/__manifest__.cpython-310.pyc delete mode 100644 addons/education_portal/tests/__pycache__/__init__.cpython-310.pyc delete mode 100644 addons/education_portal/tests/__pycache__/test_payment_gateway.cpython-310.pyc delete mode 100644 addons/education_portal/tests/__pycache__/test_portal.cpython-310.pyc delete mode 100644 addons/education_reports/__pycache__/__manifest__.cpython-310.pyc delete mode 100644 addons/education_reports/tests/__pycache__/__init__.cpython-310.pyc delete mode 100644 addons/education_reports/tests/__pycache__/test_reports.cpython-310.pyc delete mode 100644 addons/education_student/tests/__pycache__/__init__.cpython-310.pyc delete mode 100644 addons/education_student/tests/__pycache__/test_student.cpython-310.pyc create mode 100644 addons/education_timetable/__init__.py create mode 100644 addons/education_timetable/__manifest__.py create mode 100644 addons/education_timetable/__pycache__/__init__.cpython-310.pyc create mode 100644 addons/education_timetable/controllers/__init__.py create mode 100644 addons/education_timetable/controllers/__pycache__/__init__.cpython-310.pyc create mode 100644 addons/education_timetable/controllers/__pycache__/main.cpython-310.pyc create mode 100644 addons/education_timetable/controllers/main.py create mode 100644 addons/education_timetable/models/__init__.py create mode 100644 addons/education_timetable/models/__pycache__/__init__.cpython-310.pyc create mode 100644 addons/education_timetable/models/__pycache__/education_room.cpython-310.pyc create mode 100644 addons/education_timetable/models/__pycache__/education_timetable_slot.cpython-310.pyc create mode 100644 addons/education_timetable/models/education_room.py create mode 100644 addons/education_timetable/models/education_timetable_slot.py create mode 100644 addons/education_timetable/security/ir.model.access.csv create mode 100644 addons/education_timetable/security/security.xml create mode 100644 addons/education_timetable/views/education_room_views.xml create mode 100644 addons/education_timetable/views/education_timetable_slot_views.xml create mode 100644 addons/education_timetable/views/menu_views.xml create mode 100644 addons/education_timetable/views/portal_timetable_templates.xml create mode 100644 append_tests 2.py create mode 100644 append_tests.py create mode 100644 debug_domain 2.py create mode 100644 debug_domain.py create mode 100644 fix_all_perms 2.py create mode 100644 fix_all_perms.py create mode 100644 fix_mapped 2.py create mode 100644 fix_mapped.py create mode 100644 fix_perms 2.py create mode 100644 fix_perms.py create mode 100644 fix_security 2.py create mode 100644 fix_security.py create mode 100644 fix_security2 2.py create mode 100644 fix_security2.py create mode 100644 fix_state 2.py create mode 100644 fix_state.py create mode 100644 fix_student_user_type 2.py create mode 100644 fix_student_user_type.py create mode 100644 fix_test 2.py create mode 100644 fix_test.py create mode 100644 fix_test_users 2.py create mode 100644 fix_test_users.py create mode 100644 get_rule_db 2.py create mode 100644 get_rule_db.py create mode 100644 print_debug 2.py create mode 100644 print_debug.py create mode 100644 print_debug2 2.py create mode 100644 print_debug2.py create mode 100644 print_ids 2.py create mode 100644 print_ids.py create mode 100644 print_perms 2.py create mode 100644 print_perms.py create mode 100644 print_test_39 2.py create mode 100644 print_test_39.py create mode 100644 replace_tests 2.py create mode 100644 replace_tests.py create mode 100644 restore_domain 2.py create mode 100644 restore_domain.py create mode 100644 rewrite_security 2.py create mode 100644 rewrite_security.py create mode 100644 test_1_1 2.py create mode 100644 test_1_1.py create mode 100644 test_db_rule 2.py create mode 100644 test_db_rule.py create mode 100644 test_db_rules 2.py create mode 100644 test_db_rules.py create mode 100644 test_domain 2.py create mode 100644 test_domain.py create mode 100644 test_domain2 2.py create mode 100644 test_domain2.py create mode 100644 test_domain_eval 2.py create mode 100644 test_domain_eval.py create mode 100644 test_env_student_ids 2.py create mode 100644 test_env_student_ids.py create mode 100644 test_groups 2.py create mode 100644 test_groups.py create mode 100644 test_invalidate 2.py create mode 100644 test_invalidate.py create mode 100644 test_rule_domain 2.py create mode 100644 test_rule_domain.py create mode 100644 test_rule_name 2.py create mode 100644 test_rule_name.py create mode 100644 test_rules 2.py create mode 100644 test_rules.py create mode 100644 test_rules_odoo 2.py create mode 100644 test_rules_odoo.py create mode 100644 test_search 2.py create mode 100644 test_search.py diff --git a/.DS_Store b/.DS_Store index 792f7b9c9bf98556908063c6212f6f4e8e731b43..7e7bb1d22a1514db60cdeba68600c12eeddb966a 100644 GIT binary patch literal 12292 zcmeHN&2QX96o0c#+YR|xZ$9V;A6lAArIwPkRiy|)w`nSfprq=ClF$#cAI`?A*IwC9 z(zGhd4GAP9dZ#BYNQgsENL)Df0!MC${{Xmh0p1(i@r-w7w+IO#>RH({Yx}+5Z{EB& zUp4@+h0WF+z;OU5;z7AFil>hNDkGQ+iSEi!%Ry@yXU>Wt~DYrmwf!qSQ1#$~KjTWG>7gI4a-bYvS zb8dm$0#CICsDG&7L3uUKccYwl9eASYINhfyJWp80X*$1FiSufl??yQnMHw!Nc_hl> zB?gPc@p^?G$GjTnyHOsA1B=9g#mFpPp)ih)auxnKuxga&=iCCh1$r%T7B3x@*4~@Y z0t;MR!JDv&t9c82EQPRxt8ojX^0aDQ&K}?v!0XAn9CaT48#-0%d>?h}t@E)wwx=&d z7PQBpVM03u5|fc0Nd+D}!Y*T6;6K?Tm0BavVk2t_yWCfcipN5RhXHM@FSUh_t>IwV z#P(_E7y!!e`&y{SxJF)q&5re8cM;W&eSy59fgIXeY_wqpTP?4`evrW~qqnKBPwUW$ zy@X!UYEa;l*GTO@E=k*Hb8V}0-xqB@s*qJ-qPIwxh9pPP=0iu1%RODhl{u&K3T*Nd z_MzY>6B2e3V2s*}kXV%aYEkickg>%y*RYLe8@W)_Q%~H) z_=~RW=vswpByovq)K8Ow503}YE@PYp*~Nr8erRk#(IDZE^hm1k{7n7Jf)w?4Y5%9e zCB_nNNvlDD&*R}2!H*`i(pq1nc#s$veRQZeJVG0Y9Ax>yUP!UYibaejDV^Ori-~ME|65 z)zd{WA4Nn4qHn15sJIj|T*Mfm(X@_n(u55i2kMg~j}T)-lviN$82Lf)CS1bgDYw9X z&H_h}(E*Hv7LP|40{MMA`7xXl{8)PSpS*wP@8au?Yqqs*8dDcNH>}&P6uqvy=vZeIZ{eV=?&@UvkgUlj~LGcr0hUNXuP zljW)M^z`(zhYlY(`rJ{|m~7OWx7$9kz34ffzu;_I*W13`s&(s4+ikCg)}63s-?dC* zBJ=1~t92{Aw6fF1XQt8%T-_VKWw~76^7?vUg{wk&%`{3u)AtQS$=?dkId>?-S_Km+gjMU>C_vRv+OQ-Vd!;M?WX5O%u8G? z{=MQ`q;lr`Z;>Tus&6F)`j!Nf0W(nn-!M%jr8s9Z-?gLSitD-7@J#he^miDyflt5% zxD40eHr$2J;B)vAzJI^E=n?EQ|nxEOz2QoQ^{;j7k5k1zB-(;E0pccVYfR?T-E&n0^h z_c8`!7k@O#Y~dtx@DiA&K;lqS(rQo)R5Z{^0bWMbWTWSa7QcMTBjf8dDDY{JmGCEc zN7TgDr|plIae$_&TNlqX3AM4!6Lj17txbeUHndn<#GaGSKU7u3Q3OXAfDiCU63$g< zxI0Hi-L$PO0 zesWSye$r+^#&2ws6&TlUX6InxVC0(2qp*j=$k0?r!PvlP^96-7Oq1uR%w`4ZV`!M{ ps3W~OoM!{GAT!83pf%h;!WCo{(3tPcllgTF;VP9k$Mei#1^{}yANl|Q diff --git a/addons/education_attendance/__pycache__/__manifest__.cpython-310.pyc b/addons/education_attendance/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index be2e903184023fdf6157b874b7721bf78d6955b4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1117 zcmZ`&UuzRV5Wh>)q)nQF;)^16>4Ok4cP)Z~h*)VWg;omj{g~cPlWp&IkKMhrxv$}? zPkr+%E%+q(A$-xfyEI9$?r{V2@3*tRnNhu7$D{LW@bzNa^t_*?vV1m4`9Qx3NN|t) zyunv^ldq0E-n#Kgwr_mCHuCv8eI4HA8{c}q$9qGszgavw;CT#ID9QG$Mae74TL!aYp5G~Dan(}b!pMebOo_bY{g@}0 zECqcj{3&2C+&rkTnA{3#1t(2&0mU%q8F>X;DLL#RM4#Gd@$G zxp~PlF;SLTI7^VUip>2(rm=irS%b!rAZm04RXiS|6vWX`B^4=bPsd|nF}D2`w~4n+ zjLyKId$7<=-4xeMsb~Fm@xox7YhkD1A_nG-k8Zueb($1UZ?#b9AXOZb0OFXa3t}@V zwkHCwOn4``Vl}E96~dE$Vivw8Wd*Jf*3ax-Lf(Ymt=`=~|H6 zvC^*1F{7O^u36qklCWJ@(Ck8zl$~{5$g)J#8|F4BP0<@Gw`-FfrNPhNJ^he&cOibi z4pV7EytWz!z&TCoy>AJlCg`k0(ZU1S#m)7F^Gc(44TX@8G*u@ zjJNnpQj1IC6H7``^HLJ?l2iROnQk!@fi$mVC}IIpVB(jBer{fgeriQYQKD{QN=klS zv3_bwX>wvoW_})$Zheqx#rpB_nR%Hd@$q^EmA5!-a`RJ4b5iX<78ZjH7GMAXybCRf diff --git a/addons/education_attendance/tests/__pycache__/test_attendance.cpython-310.pyc b/addons/education_attendance/tests/__pycache__/test_attendance.cpython-310.pyc deleted file mode 100644 index a4d7c5e7782eb54728f4066d9250b3d78d77b119..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4996 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 p?|!= diff --git a/addons/education_base/tests/__pycache__/test_campus.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_campus.cpython-310.pyc deleted file mode 100644 index 591d7172a3c8b79ba11bbfbe8920389dc69756b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 998 zcmZ`&&ubJh6n>e>PPfzDwt`r_N%68^cdg(}#IlH|7SXCWmk=i@oiabR$&9v3Z!Pqw z2;Mxj$No#Xdh%ZoioTcK)B8Q_iH3wJNP<(?VT!5Xq@9G5gb6r`45G@&;JjRHo6Y!&Mq2+j}^ItWK^J#29Z(xTX>H3If64@x6aw86;JcZ zul2XzTdX93J-{J7WL|fzKYexMEgYm+q%*wkH!Cf@m*nII|y%#!O z%0fGyR!X~gDxApFcyXhw>RoN7H+qpF?k-A=mbErS%mtZp9aL4D`lZaQwuXvk=E-!g z%_6z3kfc*FMN?+`jTV%10S|qZV-{eKSo^XQUCram)nDV)8Ik`Jq!+oi5JW@>7Yk8T zs>x~I5n{iQ`Q*eG(A*+4-RW~kb)E|`gO%VI0&J+rEgY80-iA)F%>CXLKHVFYs;WML uv3-=Rs7gaG)-CyceUR!A{VP*BDAG&{FO1iu{dfZO2|anArvn{!GyDxhCh?B| diff --git a/addons/education_base/tests/__pycache__/test_program.cpython-310.pyc b/addons/education_base/tests/__pycache__/test_program.cpython-310.pyc deleted file mode 100644 index 289e518cbe94d10d6976ef8c35bef24739ebc41c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1349 zcmZuxOK%e~5VrS~WSh_yTHXh!xI}_PTsR>FN~@lT5YQrWvCP_(jrN7Tfi~)`m3l-% z;>Mvl@|S$ ydX!Hl!CtHN6TcI>goGxLp|cRC?~@6orf<0FrdpE#(0Fh`z3*Y^Pg z5i}tw9Z`z1lQ^k6aw*v)!WG^j5uT(6?#L7VE7A>q!4A@;X7RP+ndVU$=h=X3iBsD= zi3P`*4W)7w;nMo&Rs($x1W86zkdbpl-cvz^bJ!fY^8-iT5%I_fkMqsKYpKf@D&J8& zjcCmp$^mpVsue&<4#+W`5lZ&S*crR~gh3xWg5Dsp1g$sL|f`EJN;dDugNn~U0MLaDgdfFc5s{C+gU`NIu4xT-K(RGcN z@l+Zw%7rvxl&1yH_E;>YE9VA;EVwFDnU!W?Bio5HDOH^97x30s~P~NH9rcs zUq9c@BQ9hbN4-RTl8NbTWkM>*o0#xY8fR;>+cLhM6jH%YmRp5!W%kK5DrZa(DQQ>6 z0_3^-Va+Gs+WkR|r)BaU{w>C$glo;%4|4LTpJruW?v{!_;6mh??w`5F-a&=*OJGu4 zWW}7&Ua_YZAs`G&uK|Pf6^RnC|R8B239Nl8O_IYN1q@= L_MA3kV8#6dsM3?45lg2VTPPEbPI@r7DE(#U* z1xR!hNy#tpDQ>Cm7Z3$Aj)Zc8SGFpO7Uzbt=n#(vV^Fk&2xG2ICQ zFyI9%`HXY+mO%vKhJn!hvzSE?U$Q}Ri>ugx`}* zW*iuDaENZ$On^YH*mtMe)t5SJY+heysP1pjZqj{$>CO>0Y>C>Quq`rxfEW^JQEeN+ zy&YeQ1)pO=`Yqn^Pkh6+@rLiv1u#6RJz2M{cEiSL>(;$D&}52)pS;6@?`vozP01SN z$kWra_0xY!V|}H{RiTHODQ{aS$dcUkb6mLRlJD^rh6`(&mAqRF+~_v0RH=PZm_=Us zM3*Wrd^>D#GKWFpg|0sOR%W%TwHH>;eUyXmD2LkWscrNi^0Cv!+{YyMJF|V%@ayHM ztm=`zs;zpV08HgZcLj{dmkTJdQ{lFUt84!#_$A$Y`zp({b7ao-5IQ^x-(#Ns?zPs> z4x2Un@74*rOr<2BQu>aRrGciP|E`oD8ddCP>_cP^+7I9X)dT`PNJo&eZWJn$f$)6; j#)MNHwV*VWr8g4h4_N3zEK&LNe1G_B>QmtBvSaZFXx`FC diff --git a/addons/education_dashboard/__pycache__/__manifest__.cpython-310.pyc b/addons/education_dashboard/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index 6de49512b1fac801cbbd5cf36385ca4cba7b8cad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1290 zcmZuxO>fgc5OqSDv??YK z2RX>Y11P{+&x7Kx9F$k{uU?jpB1!Ya)B7(=t z5e?vg8<*vjaq?&o+k5cSvEmt8EEV&lPZ0movXOuuI?HzLOg0GXjOb9+w5n%`E~i;r z(sf7M6_T1n3@4M&b#^+>r_P$wTToDtU^w+XC>M>d#Stvwn0*O5$~jIiT# zYpLp#MR-Mo$(vviV~z>P4^D)EHDPdRSM8t%CXH1K%TS8BYD?Q;G?1BBv#h?JCB=&m z&5OO4wQ5=*zhGJ#Qdh|Y={g-(>}fMjOE!)oP9GIO8yYdlOp+FP?E2cIB{Cw3v1tLg z<1{_dm{x=$AyV>=#I(W(11YBGKYd6*-JUI{C7MTN@$+xbY}5Z`@wzz-BIN?Sc82e9 z0CL1CMM1kwr(AT&Snaky7^mxRsV}j_cl1l}>@L0Xx%s_O%;ifoy3Mam=5F)<^2Og`kf^O5vS+YR-F^Gc(44TX@8G*u@ zjJNnoQj1ICQxc0alJXOaQv5WTZZQ;r^sZzmVgXWM;+MI8ZeEFgYDGy=qHbbJN`79k yerif-a$-qlejY-zKFF+M{rLFIyv&mLc)fzkTO2mI`6;D2sdgaiib3WIFaQ96qb&0P diff --git a/addons/education_dashboard/tests/__pycache__/test_dashboard.cpython-310.pyc b/addons/education_dashboard/tests/__pycache__/test_dashboard.cpython-310.pyc deleted file mode 100644 index 3a7465d9f255ab060efc78ea533fec032795dca5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2602 zcmaJ@OLG%P5T4mxNvoA)$-*%vafkvWAcAEZ3X;mJ5-f@&MI0(J2Xxt5cE(tWc2}8M zxj{e~EY0V>*oKDd_zO6(+Ys*=G#su2AaKXxYQbHGT}<$`ssr)-}K zF03bR*PdkWI!~Fyx|lm>R`;Y%|D7e|3xScZ!-Mhl0Z1uJ`4M|;J-1#k&Qkj^J7T_b z#QHhGPqF8DVPU3Y>jhZ(1=8n8pBE?<`mS(~ZCWkTYLQkwQJQ&qq%V`cB9=fZ_FbSJ z_*Rb{@-2}~jck@hU7Qdr;;n40OlzyOc2cZ?R0huz;*@y%I7i+UvUrCq*2TNC%$O@D zPLuvU(U|G0q<^3EXT;f=zDD|Ur2jyCD9(!uq6s?KCD2LLrD^S;fjWRzh%OPW60Jdf z7sW@%RjO`TFgTw}yW^UijzB96qTxu7OE?RS9TJSy-f3FKiGxTQCrpGKugo2U90n?l zWSowR05?I`BItwW(^GYC%aVaJyJUI_DOZZR*DhqZOSO|7^>|Nfvd~IWE8~!qW z`|`$>?bnicI)&Z(B1oliWh{t$V}?|!X}NpALWaF*hHD#Fwwk$x(reSYe5?ySRY^+RkDhwWO;*GYE~-?%ddU%_JQO zI88&RNpN6Fc!4{a-fe%~`KGgXXWJ0>b^H5mQ$XF1x3*0lq2*9EtH#^!DV_Q>G{r$M zWubu-9*?LNh^VJEAder9qI*)A5~5Bfs3P_NaF7`5q_2%rLVd^rZ-b~?SG|{=THGHTpz*g|)=}&+xao1Y2PxCc?j<54G@E^aP zZSm*(*!M82H&a>sR=SGu6ojnQ4tTy98R1MG418`(ZMIOSqH`RVFvB$*sWnz3O?! zcAKthV%++DiLjK7h5ysArzcg^VW6Z+Z_*5_8$XYNfhkRNBq;nbp*Ao^@xeep@b`NI z37GdWYFvne5Eqy-aRa|h0Oa#HDTd;6-dpgSv5ZS zuG%$TxAT03kFWm6!2N&JX5P3yL5=Sl&-bH5j0O_;vhU-D5rll7?u7altE^- + + + - - - - + + + + + + diff --git a/addons/education_finance/__pycache__/__manifest__.cpython-310.pyc b/addons/education_finance/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index c6267ca92e46cc0c68cf2777bfc4e94ca7a5820b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1133 zcmZux&2H2%5KhXnyWJl_PoN5@MGsJ^OBz(J>-m?DKYE_`#~Ix*MdJ;=>LpIh zV?Ntr_gIhJXMHwU_zRB>zxiw%pAj2>o%kM`%)EnXx$~Bpm};I0a?S-6F_fc|SHW>` zg70y8Hq(ZI&@D(Vsh|r;ooiMo4JnytlE8|D3#GX>j!>09N2D27ORh+oF_QpED+)#C z00g&&MJ73@!^+l0-hL`AO{|S{HdTr*gxgget~eQ2ab}#0sCbzrRH`M{9s~x zf~H^xHy{-jw;d>xrc@So%s|Bw%gT9&t?sr@jb3Kb4iX-NP`Hvo4p?Qj=hAG)u2X1| z(CY;3nC3agRefkDxH7|YRq*@oo_vTcy`KJ_hN;jY+-gaWC}Y@YcWWk$B5X502c@GZ h$O}6>tGn;D@BSoqaoX~mr{{iqe&65PabKg~{{SSKkcI#N diff --git a/addons/education_finance/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_finance/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index b324482cfd0704580f8b78c1208770036d633589..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 183 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 E0jl}-bN~PV diff --git a/addons/education_lms/__init__.py b/addons/education_lms/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/education_lms/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/education_lms/__manifest__.py b/addons/education_lms/__manifest__.py new file mode 100644 index 0000000..7691e61 --- /dev/null +++ b/addons/education_lms/__manifest__.py @@ -0,0 +1,31 @@ +{ + 'name': 'Education LMS', + 'version': '17.0.1.0.0', + 'category': 'Education', + 'summary': 'Learning Management System Foundation for Education', + 'description': """ + Education LMS Module for managing courses, sections, lessons and materials. + """, + 'author': 'Metatron', + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'education_base', + 'education_student', + 'education_exam', + 'education_portal' + ], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'views/education_lms_assignment_views.xml', + 'views/education_lms_submission_views.xml', + 'views/education_lms_course_views.xml', + 'views/education_lms_content_views.xml', + 'views/menu_views.xml', + 'views/portal_lms_templates.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/addons/education_lms/__pycache__/__init__.cpython-310.pyc b/addons/education_lms/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2d79b0391957db33cca4243608e3b8f1450cfec6 GIT binary patch literal 201 zcmd1j<>g`kf;$&hW$6Ry#~=g`kf-M(TWeEf6#~=WHa^HWN5Qtd#N6obqaU;qH^2`Rt; literal 0 HcmV?d00001 diff --git a/addons/education_lms/controllers/__pycache__/main.cpython-310.pyc b/addons/education_lms/controllers/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef7becf1f91b7e20494d8c6cca3ea811c70e7cb9 GIT binary patch literal 10422 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 literal 0 HcmV?d00001 diff --git a/addons/education_lms/controllers/main.py b/addons/education_lms/controllers/main.py new file mode 100644 index 0000000..10a4172 --- /dev/null +++ b/addons/education_lms/controllers/main.py @@ -0,0 +1,376 @@ +import base64 +import werkzeug +from odoo import http, _ +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager +from odoo.exceptions import AccessError, MissingError + +class StudentLMSController(CustomerPortal): + + def _get_student(self): + """Helper to return the mapped student for the authenticated portal user.""" + return request.env.user.student_id + + def _get_student_domain(self): + """ + Returns the LMS course eligibility domain matching Phase H security semantics exactly. + A student sees published/closed courses matching their program and academic year, + and optionally matching term and batch (or empty). + """ + student = self._get_student() + if not student: + return [('id', '=', 0)] # No access + + domain = [ + ('state', 'in', ['published', 'closed']), + ('program_id', '=', student.program_id.id), + ('academic_year_id', '=', student.academic_year_id.id), + '|', ('term_id', '=', False), ('term_id', '=', student.academic_term_id.id), + '|', ('batch_id', '=', False), ('batch_id', '=', student.batch_id.id) + ] + return domain + + @http.route(['/my/lms', '/my/lms/page/'], type='http', auth="user", website=True) + def my_lms_courses(self, page=1, **kw): + student = self._get_student() + if not student: + return request.render("education_portal.portal_no_student") + + domain = self._get_student_domain() + Course = request.env['education.lms.course'].sudo() + course_count = Course.search_count(domain) + + pager = portal_pager( + url="/my/lms", + total=course_count, + page=page, + step=12 + ) + courses = Course.search(domain, limit=12, offset=pager['offset'], order='id desc') + + values = { + 'student': student, + 'courses': courses, + 'page_name': 'lms_courses', + 'pager': pager, + 'default_url': '/my/lms', + } + return request.render("education_lms.portal_my_lms_course_list", values) + + def _check_course_access(self, course_id): + """ + Server-side validation to prevent IDOR. + Returns the course if the student is eligible to access it, else raises AccessError. + """ + student = self._get_student() + if not student: + raise AccessError(_("Student mapping not found.")) + + domain = self._get_student_domain() + [('id', '=', int(course_id))] + course = request.env['education.lms.course'].sudo().search(domain, limit=1) + if not course: + raise AccessError(_("You do not have access to this course or it does not exist.")) + return course + + @http.route(['/my/lms/course/'], type='http', auth="user", website=True) + def my_lms_course_detail(self, course_id, **kw): + try: + course = self._check_course_access(course_id) + except AccessError: + return request.redirect('/my/lms') + + student = self._get_student() + + # Only fetch published sections and lessons + # Using sudo is safe here since we manually filtered by course access above, + # but we also explicitly filter sections to only this course. + sections = request.env['education.lms.section'].sudo().search([ + ('course_id', '=', course.id), + ('published', '=', True) + ], order='sequence, id') + + values = { + 'student': student, + 'course': course, + 'sections': sections, + 'page_name': 'lms_course_detail', + } + return request.render("education_lms.portal_my_lms_course_detail", values) + + @http.route(['/my/lms/course//lesson/'], type='http', auth="user", website=True) + def my_lms_lesson_detail(self, course_id, lesson_id, **kw): + try: + course = self._check_course_access(course_id) + except AccessError: + return request.redirect('/my/lms') + + student = self._get_student() + + lesson = request.env['education.lms.lesson'].sudo().search([ + ('id', '=', lesson_id), + ('course_id', '=', course.id), + ('published', '=', True), + ('section_id.published', '=', True) + ], limit=1) + + if not lesson: + return request.redirect(f'/my/lms/course/{course.id}') + + # For navigation: Next/Prev Lesson logic + all_lessons = request.env['education.lms.lesson'].sudo().search([ + ('course_id', '=', course.id), + ('published', '=', True), + ('section_id.published', '=', True) + ], order='sequence, id') + + lesson_ids = all_lessons.ids + current_idx = lesson_ids.index(lesson.id) + + prev_lesson = all_lessons[current_idx - 1] if current_idx > 0 else None + next_lesson = all_lessons[current_idx + 1] if current_idx < len(lesson_ids) - 1 else None + + values = { + 'student': student, + 'course': course, + 'lesson': lesson, + 'prev_lesson': prev_lesson, + 'next_lesson': next_lesson, + 'page_name': 'lms_lesson_detail', + } + return request.render("education_lms.portal_my_lms_lesson_detail", values) + + @http.route(['/my/lms/material//download'], type='http', auth="user", website=True) + def download_material(self, material_id, **kw): + """ + Secure attachment download for LMS materials. + Validates the entire hierarchy down to course eligibility before returning binary. + """ + student = self._get_student() + if not student: + return request.render("education_portal.portal_no_student") + + material = request.env['education.lms.material'].sudo().search([ + ('id', '=', material_id), + ('published', '=', True), + ('lesson_id.published', '=', True), + ('lesson_id.section_id.published', '=', True) + ], limit=1) + + if not material or material.material_type != 'file' or not material.attachment_id: + raise werkzeug.exceptions.NotFound() + + # Re-check course access + try: + self._check_course_access(material.lesson_id.course_id.id) + except AccessError: + raise werkzeug.exceptions.Forbidden() + + attachment = material.attachment_id + content = base64.b64decode(attachment.datas) if attachment.datas else b'' + + headers = [ + ('Content-Type', attachment.mimetype or 'application/octet-stream'), + ('Content-Length', len(content)), + ('Content-Disposition', f'attachment; filename="{attachment.name}"') + ] + return request.make_response(content, headers=headers) + + @http.route(['/my/lms/assignments', '/my/lms/assignments/page/'], type='http', auth="user", website=True) + def my_lms_assignments(self, page=1, **kw): + student = self._get_student() + if not student: + return request.render("education_portal.portal_no_student") + + # To get assignments for eligible courses: + domain = self._get_student_domain() + eligible_courses = request.env['education.lms.course'].sudo().search(domain) + + assignment_domain = [ + ('course_id', 'in', eligible_courses.ids), + ('state', 'in', ['published', 'closed']) + ] + + Assignment = request.env['education.lms.assignment'].sudo() + assignment_count = Assignment.search_count(assignment_domain) + + pager = portal_pager( + url="/my/lms/assignments", + total=assignment_count, + page=page, + step=12 + ) + assignments = Assignment.search(assignment_domain, limit=12, offset=pager['offset'], order='due_date asc, id desc') + + # Get submissions for these assignments by this student + submissions = request.env['education.lms.submission'].sudo().search([ + ('student_id', '=', student.id), + ('assignment_id', 'in', assignments.ids) + ]) + submission_map = {sub.assignment_id.id: sub for sub in submissions} + + values = { + 'student': student, + 'assignments': assignments, + 'submission_map': submission_map, + 'page_name': 'lms_assignments', + 'pager': pager, + 'default_url': '/my/lms/assignments', + } + return request.render("education_lms.portal_my_lms_assignment_list", values) + + def _check_assignment_access(self, assignment_id): + student = self._get_student() + if not student: + raise AccessError(_("Student mapping not found.")) + + assignment = request.env['education.lms.assignment'].sudo().search([ + ('id', '=', int(assignment_id)), + ('state', 'in', ['published', 'closed']) + ], limit=1) + + if not assignment: + raise AccessError(_("Assignment not found or draft.")) + + # Re-check course eligibility + self._check_course_access(assignment.course_id.id) + return assignment + + @http.route(['/my/lms/assignment/'], type='http', auth="user", website=True) + def my_lms_assignment_detail(self, assignment_id, **kw): + try: + assignment = self._check_assignment_access(assignment_id) + except AccessError: + return request.redirect('/my/lms/assignments') + + student = self._get_student() + submission = request.env['education.lms.submission'].sudo().search([ + ('student_id', '=', student.id), + ('assignment_id', '=', assignment.id) + ], limit=1) + + values = { + 'student': student, + 'assignment': assignment, + 'submission': submission, + 'page_name': 'lms_assignment_detail', + 'error': kw.get('error'), + } + return request.render("education_lms.portal_my_lms_assignment_detail", values) + + @http.route(['/my/lms/assignment//submit'], type='http', auth="user", methods=['POST'], website=True, csrf=True) + def my_lms_assignment_submit(self, assignment_id, **post): + try: + assignment = self._check_assignment_access(assignment_id) + except AccessError: + return request.redirect('/my/lms/assignments') + + student = self._get_student() + + if assignment.state == 'closed': + return request.redirect(f'/my/lms/assignment/{assignment_id}?error=closed') + + # Verify no existing submission + existing = request.env['education.lms.submission'].sudo().search([ + ('student_id', '=', student.id), + ('assignment_id', '=', assignment.id) + ], limit=1) + + if existing: + return request.redirect(f'/my/lms/assignment/{assignment_id}?error=exists') + + content = post.get('content', '').strip() + attachment = post.get('attachment') + + if not content and (not attachment or not attachment.filename): + return request.redirect(f'/my/lms/assignment/{assignment_id}?error=empty') + + submission_vals = { + 'student_id': student.id, + 'assignment_id': assignment.id, + 'text_submission': content, + } + + try: + # We use sudo to create, but we explicitly hardcode the student_id to the authenticated student's ID. + # IDOR is prevented by deriving the student_id safely. + submission = request.env['education.lms.submission'].sudo().create(submission_vals) + + if attachment and attachment.filename: + # Add attachment to submission + attachment_val = { + 'name': attachment.filename, + 'type': 'binary', + 'datas': base64.b64encode(attachment.read()), + 'res_model': 'education.lms.submission', + 'res_id': submission.id, + # We leave it open to student read through normal IR rules, + # or they can view it from the portal detail. + } + request.env['ir.attachment'].sudo().create(attachment_val) + + return request.redirect(f'/my/lms/submission/{submission.id}') + except Exception as e: + # Fallback error mapping + return request.redirect(f'/my/lms/assignment/{assignment_id}?error=server') + + @http.route(['/my/lms/submission/'], type='http', auth="user", website=True) + def my_lms_submission_detail(self, submission_id, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/lms/assignments') + + # Secure fetch of the submission to ensure it belongs to the authenticated student + submission = request.env['education.lms.submission'].sudo().search([ + ('id', '=', int(submission_id)), + ('student_id', '=', student.id) + ], limit=1) + + if not submission: + return request.redirect('/my/lms/assignments') + + # Also ensure the student still has access to the course + try: + self._check_course_access(submission.assignment_id.course_id.id) + except AccessError: + return request.redirect('/my/lms/assignments') + + # Get submission attachments + attachments = request.env['ir.attachment'].sudo().search([ + ('res_model', '=', 'education.lms.submission'), + ('res_id', '=', submission.id) + ]) + + values = { + 'student': student, + 'submission': submission, + 'assignment': submission.assignment_id, + 'attachments': attachments, + 'page_name': 'lms_submission_detail', + } + return request.render("education_lms.portal_my_lms_submission_detail", values) + + @http.route(['/my/lms/submission/attachment/'], type='http', auth="user", website=True) + def download_submission_attachment(self, attachment_id, **kw): + """ + Secure download for submission attachments (so student can download their own). + """ + student = self._get_student() + if not student: + raise werkzeug.exceptions.Forbidden() + + attachment = request.env['ir.attachment'].sudo().browse(attachment_id) + if not attachment.exists() or attachment.res_model != 'education.lms.submission': + raise werkzeug.exceptions.NotFound() + + submission = request.env['education.lms.submission'].sudo().browse(attachment.res_id) + if submission.student_id.id != student.id: + raise werkzeug.exceptions.Forbidden() + + content = base64.b64decode(attachment.datas) if attachment.datas else b'' + + headers = [ + ('Content-Type', attachment.mimetype or 'application/octet-stream'), + ('Content-Length', len(content)), + ('Content-Disposition', f'attachment; filename="{attachment.name}"') + ] + return request.make_response(content, headers=headers) diff --git a/addons/education_lms/models/__init__.py b/addons/education_lms/models/__init__.py new file mode 100644 index 0000000..7052cf8 --- /dev/null +++ b/addons/education_lms/models/__init__.py @@ -0,0 +1,4 @@ +from . import education_lms_course +from . import education_lms_content +from . import education_lms_assignment +from . import education_lms_submission diff --git a/addons/education_lms/models/__pycache__/__init__.cpython-310.pyc b/addons/education_lms/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f132a3ccba6bca80bfaeb20de05499362d8afc0 GIT binary patch literal 326 zcmZvXF>1pw6o&O=yD`Cr3@P*q9$G=@SVB(FC7T!FL?WnIQqhy4JwxUkB8TWjymsmp zGUX{#68b=&zW08FBwyE4M)k8lejI53P0e+2YF4!Hm7th{o>^!Cl5=w2@{4EWS({xv zC(qlQyjZj5;ScC&1LIt)VcSLHo$M{jtseqvnm^vpUDPg8@N^q=h-T;PzZni2Ybf{b zy(vZ((z8RBkS7#`BMXz{38#gyE(-k>L-*V%-QyVpCOdMUt~zx)b?Q4`op##kv<&?6 zOaB=B^Ppk;iz<^p2bJr1}MZfg#%i5tm=IbwOMu?wG+Da5bAU6Bs+!iR{t<+N6_YBuA{nqT9-dS`Piz#=KpT8 zGi(7p&cd?e{KTu1>>NA4ZLtfi`@(o`_D^ZuhubFmh<&Uzp4MDsJ+`=QvrpKkT6*mXz82bhLQftnz%k0VvqrbquVps7#`;)O^vai{-7jFNYE`P(mMfv@cW)SHFw><2hyiXn>iD+0r~eV!cX*B33DzVPiw^ILKgHO=hX!daa@< zxa{SU3pqK#N)YC8wy7t$Q)Pd> zbpHn9D*+}h+@+lB=Av^icp44!p?5D3>vFXWq3^F3ok;Y8EDORlIx_^`PMH=ugnoCr zHbS(D}Tjjg_Kc9OU9oMJTte9LeJqRTn%=1$%9XGf@A0 zb8(nti~MOOf{OuTX(AVQkm3XFizU(RR{G-*=xuDOwjZwXa9!_M(yWqD=~_oj*R+0g z{#%^6Jqn;*fz_KFr#i#(VErK-?B1U9Ft_&HROvZpuA8EhnarB-mAx#;^~Hq|TJ)se zFqhhAtAf*Edf8f#cm)?}4=_E40oqrBmeB4g#R953o759^p{X|6qD}tl{Q6s))Z}^h$iiX|n2NDO@$tuOzhI8>36A`OU%ABE4=+kRQbba<3J! z()S;d$|FHYnmB-&3N3397l8=ZHW$$@ry)wDRcQOAIRAdM?TjZ{0}A%`_#ZL1_P94f z+7m;z$=hNc(i$LXfLG_>RhzH}9TJ9hOShVG9{Sn@ChN-p*-_fmF%ncNuHP!-xFR7h z6`tlD54ao$nTH4`O`hzKl~)3(r{^lJ+}PE91oLt4iX@_ph=W+8WVc?|5f?|s=!4ih z0QOh@uO`Libr~b&41{aVm~F(tOd0V1DK5PK2*y+F?ffOoyJY~rrkv7m>lMJgjZh~t z)WNt*5S)&5^K24kU^3UgYQCe?Rr1^%wOXmk{7$wEc>hAJ}eE0}g zoc30l#G78I8Drv1q&^fxMI^lRhom|W+3UK>=E)Of$y_;fPpalxAbq`N3H8?avJ@YY z^C=H}C*+S*E*}lRIxuQ2+PIDp8v1jV1E0^1IB4*)}=i(ZwDGi2jlSJ~v4 zkZ$_Xk3K!c1O80xu%o=we4oowp4@d|vlZ6dp9 zEej&llAFj&I95t_N}l?r%z}2|ze+&_&vKE_6`@lqqOkj;Jj&Ay+bgbqpXTFO;tV`A zX16kuN;`PiSMGlDI_TGZM6CWv}wAq(Hir#OEY-ViXrfzQKhc-UD%-rUA~A0H1+wBz?^w zVjfBaLDToc7+duH@eQWO-AB8egP1dMZCS4A*c83L`akZB!>rlMK1GpQS9=9B1gNb|+4QOlP2~saG9hX*E6mputj7HEyP9 z%maMY5#2oDIyP1FfllAZY|U=OHmZfC@TnER3UL++ah}A-B4@17#Oa!*$xu>_I)>a<{3Rl%=U4^3?tpdL#Qt+2c<`u1`jm(Mijz+CBOw dj2=F5(`aMKPUT)JkNjKu6wo9-3$x~|{{b|s+9v=2 literal 0 HcmV?d00001 diff --git a/addons/education_lms/models/__pycache__/education_lms_content.cpython-310.pyc b/addons/education_lms/models/__pycache__/education_lms_content.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d1f748dd34583ff1e23e89e259cf378af964c1b GIT binary patch literal 4652 zcmbW5OLH5?5y$rhVDTcs7f9;4a%?%UWiV0Rv207GEZd35IHsb+s7g?qouRl8uuIJ> z6ceRXr6ON)(07o0<2_#^_tac-^4T|)E6%@XcL9(BnM7c1W2R?z9z8w3ZZNLbD+WGK z{&v{=$D(2Uiz?F}2bJr1R1E2W1FP2`p%%xDGZ98qG^0+Fq=7F z7|h{juk_gNxX=_>5t?GADMM3YE;MeYsj%{n(W-ofIgFOAii4Q(zEs8CkoTEXw!a^? zEH!`E?}yA!!Z`Xsh*+rVjUeDs>PkTGPJfE1T*o87gy2TUWJbqgX2)h$$6B>S zb7*Z(td5IXnO22*nN^-RoeDHnR%JC-M|%ymI-6tjPt497%JZzj7IaHP*9vSAEej|w zvL&|6R?yS#Eb-;1E3C;@pIK~;t$$%WHaktNTYYA-GwiI^tg>_1{du+Y0UHK-d$+fC zc6dM=eZInTWxGF+ZJDY^Rdn(4J~hc740#mrD_+Qyv+WP~okL3%Wg^1po^l16L&4ee z<)ecI@lZ&viksz9N@7v~(v`Vtm&h_7hdT z$z>qIeOmRQnKqU`8UC>!%013*LDj@-$CuE@`Nff{Wc%u3{iWMlCslbre_NH)rJ^}7 zF-rciP_XfA;*sBlcx2o(en0HJmOBigE&gCiuV42YIcJ? z9^CJaZPV40E@5y<=i*w6rfXV%asEAeV}?JqXTN^3Rc~URtKl0+J zf8Ygz!>nE`JQye3J;;wpdb`jazP6vfXl(&7+6Pl0)STB^Ro^=gY31Ej_2o zt{=c<56IiBkO@MCxI}`^C>5`vq}DQP<=%;DxR86{{yJLa97NM{VD4oy`M1uuqbsMF z{DncbTWiYgb|YNZZdX;hU2JdIr*f^^eK7Ru63V#)|5MfJ(Ms>fai9BQ@oJI>2zM zlHOAoObQAKssfh;Ui>hU;xZJXO=4m`T_PKb5(K>>>Aty!W76LoymZd2;k#xwtcq1J z-NKxy`v&NC z&_v53%1dmOFpi#fXPK`&Z4$zP_H*of3UaMG3$$Ng7qw=Md4SC&wK(k;Dc{6dY-dy< zj%SK86oT&X9Z{vC3!zFoDe*;vR0|}???%hHJB6%0hNGBA69Q;S@II~#BO=9ur#L4> zh-DHh5URA9LPe~RiX8fuI0N~-IlhrP;tU9Bq2E{Ku@9*7R;H9G{m$XN6A|(?`GN*T zJp=Q+2D2gO6To>2%GUM_9;dS&!*RCx89cTpE7BmIb+5I@{s61`Iwj^@PR!Pe$Mk;# z^OoN2slZ&A0JB%hkXZsUy~I6+Oz}EK8@+ux4pR%AM8mS6z$jWI-XQTN2|6(i2yul< zZ;?0=gR2<^GY1$`@Mm~yih|9Yf=2?+Z5_>XGaeseyR|MhX!7ro_&$lNBs?s`?^HDPBi?^wMjRfIFyhGw$2vy7dc+4{#q1My4>L}aLzXb==-14z_) zDrK&Elmp7YJd{-AVvM=XuhP7fiuaZU;%m6<_A+h)Sz{~?mBXOSeI zuRD$<_ps}!ShAMKlJ;pfeQ}uW*4dYcC$te&-ofu&{i9n<+?_767zY{gbE?jYF_ht^ z0p)j8(ouz+er8nJM#KAfB*o+Ds8X?tPVt+$Vj5W}y3~*wS4vrWOz&;Cs>+F391HsK zn?^^PT+NfR%|8#g{s}Hqr=^S0F56rEiQicx|Fnzb_JUQ2(2ylbL|{yn*DG|WY7 I@j}!4577aUHvj+t literal 0 HcmV?d00001 diff --git a/addons/education_lms/models/__pycache__/education_lms_course.cpython-310.pyc b/addons/education_lms/models/__pycache__/education_lms_course.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c4b2039374c8663f4a715f888398f004d4c76607 GIT binary patch literal 3694 zcma)9S#KLh5}uxe98#o2%d!ribbN)eLnY3+N4)Ws-2kx?KzYdk!C*Ail!l&z?j9=9 zKzdml2(Uo@gS~Iw{YU+}Py3uVBiL+J4=GYI<=r8sr>eWUy85f?>SESx)(w3A@wb2W zSDS|MFJh)28=24Xl^ z^DHnun>+mqEeX!grdLJIg$h(PZ+SJ%yHFePbwFR{qXl71mJhEJ`e5?`fpZp~Mo>XW%?sfQvZ8 z@|HD!3Hi3>SCC(Y%kUOl!S&ZPcNMN_?kqp|{5)KL!QckG{mgjEybI8Qcb*yEMYsvK z@Ouf~g)V;EunzCx_cG3S3*N^Wx8ValCFP8YYuNx<_8F) z-uw7UQoC>TjXv8nUa}3`yT#L8Ws@=tVO#RJ_tFrHDE8{kdXnbr{Ks4b?*;%_D%TI~ z@e$JX@)#WL^h=z(gFWT`F%FU~2<|M<$|g3hQJi}?_R68Bx?{PHrME3=D5|Q!djalD z%{|~U6w!dZ+p*AM2Sm`zmGgi;$kztLtvHf998~R_F^TQ%Hgr{is-9uUytqDWH zhr??KR5BRm+@H)L>{8s3MnP3a0!0f0FNsiFmcBL_gL5=gPFC|%>LQRwB7t0B;j1Iz zEE4<11LJ%5faS(M>$9h9pZVq!6O2tm%;PYoIxFU>xQeuDO)1uPzBI)t8mI^!W&(7~ zl+9`E7MHQ1S5oG4&3R`3R@|E5{1b6FVmg^kinQsskZ^luMgA@>oxHG?{NKx-d*ia>eUFmH2PCW)e#4GOvZ~%IlQ721iDt-qsIg4F za!!BE=N4e3iY6U6?XU%mXV$m-PC4Fp9$VsKUIC2bzp^I_B-gPvUfnoMI+TXH+aend z{3CsS07;Y#)SiO-#q4!^M?*;=GCXhb9Wz|s2wAyxGdiJE*nmO<`CsGVcyGz-A+wo zCTNcuH1R1>N8!T5IvjAJoF7D#bG)^5*0pzoSn5QB$GPVFVT?ZX{iy`}2UPVZd}RZh zM>Gi;U4W}1n_?9T$I)W0G(>$LYj{i4K;`eBystnUO zN*|Xo_8Im}Awp=7!;c|SH;c6yh?I&tH2UA>oOF%MEzp_ICd>wlf{+j#W%@Ny~zQJpOB_v)8v z&bRSc)4e2aQA2;4s16(O6s71g7gwlxpBkMYKO~8Ih@~NBg(#KccQiziCkSlS90g{` z<9HmLR9gz|8C@sP-9igVa-VD1%x3dui_vc$f6HdWteX|P@@u2g!v1-zBwVx3T-$Z< z#b2E@s!LTtdNw+)vNOmsK?h#rUv6T7I=lSGkn1v1>O7>JNAy2V&z!miwS8n77OzE~@ffoH2+f3X` literal 0 HcmV?d00001 diff --git a/addons/education_lms/models/__pycache__/education_lms_submission.cpython-310.pyc b/addons/education_lms/models/__pycache__/education_lms_submission.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70d4442478f71065baf93aefc098461424316b01 GIT binary patch literal 7322 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 literal 0 HcmV?d00001 diff --git a/addons/education_lms/models/education_lms_assignment.py b/addons/education_lms/models/education_lms_assignment.py new file mode 100644 index 0000000..4d06e19 --- /dev/null +++ b/addons/education_lms/models/education_lms_assignment.py @@ -0,0 +1,104 @@ +# pyrefly: ignore [missing-import] +from odoo import models, fields, api +# pyrefly: ignore [missing-import] +from odoo.exceptions import ValidationError, AccessError + +class EducationLMSAssignment(models.Model): + _name = 'education.lms.assignment' + _description = 'LMS Assignment' + + course_id = fields.Many2one('education.lms.course', string='Course', required=True, ondelete='cascade') + section_id = fields.Many2one('education.lms.section', string='Section', ondelete='set null') + lesson_id = fields.Many2one('education.lms.lesson', string='Lesson', ondelete='set null') + + subject_id = fields.Many2one('education.subject', related='course_id.subject_id', store=True, string='Subject') + faculty_id = fields.Many2one('res.users', related='course_id.faculty_id', store=True, string='Faculty') + + title = fields.Char(string='Title', required=True) + instructions = fields.Html(string='Instructions') + + assigned_date = fields.Date(string='Assigned Date', required=True, default=fields.Date.context_today) + due_date = fields.Date(string='Due Date', required=True) + + maximum_marks = fields.Float(string='Maximum Marks', required=True, default=100.0) + attachment_ids = fields.Many2many('ir.attachment', string='Attachments') + + active = fields.Boolean(default=True) + published = fields.Boolean(default=False) + + state = fields.Selection([ + ('draft', 'Draft'), + ('published', 'Published'), + ('closed', 'Closed') + ], string='State', default='draft', required=True) + + @api.constrains('assigned_date', 'due_date') + def _check_dates(self): + for record in self: + if record.assigned_date and record.due_date and record.due_date < record.assigned_date: + raise ValidationError("Due Date cannot be before Assigned Date.") + + @api.constrains('maximum_marks') + def _check_marks(self): + for record in self: + if record.maximum_marks <= 0: + raise ValidationError("Maximum marks must be greater than zero.") + + @api.constrains('course_id', 'section_id', 'lesson_id') + def _check_hierarchy(self): + for record in self: + if record.section_id and record.section_id.course_id.id != record.course_id.id: + raise ValidationError("The selected Section does not belong to the selected Course.") + if record.lesson_id and record.lesson_id.section_id.id != record.section_id.id: + raise ValidationError("The selected Lesson does not belong to the selected Section.") + + @api.constrains('state', 'course_id') + def _check_publishing(self): + for record in self: + if record.state in ['published', 'closed'] or record.published: + if record.course_id.state != 'published': + raise ValidationError("Cannot publish or close an assignment for an unpublished course.") + if record.state == 'closed' and not record.published: + raise ValidationError("An assignment must be published before it can be closed.") + + @api.constrains('course_id') + def _check_faculty_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.course_id.faculty_id.id != self.env.user.id: + raise AccessError("Faculty members can only create assignments for their own courses.") + + def action_publish(self): + for record in self: + record.write({'state': 'published', 'published': True}) + + def action_close(self): + for record in self: + record.write({'state': 'closed'}) + + def write(self, vals): + is_admin = self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin') + + for record in self: + # Lifecycle protections + if record.state == 'closed' and vals.get('state') in ['draft', 'published']: + raise ValidationError("Closed assignments cannot return to Draft or Published state.") + + # Faculty restrictions + if not is_admin: + if record.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only edit assignments for courses assigned to you.") + + # Prevent bypassing lifecycle by changing course + if 'course_id' in vals and record.state != 'draft' and vals['course_id'] != record.course_id.id: + raise AccessError("Cannot change the course of an assignment after it has been published.") + + return super().write(vals) + + def unlink(self): + if not self.env.su and not self.env.user.has_group('education_lms.group_education_lms_admin'): + for record in self: + if record.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only delete assignments for courses assigned to you.") + return super().unlink() diff --git a/addons/education_lms/models/education_lms_content.py b/addons/education_lms/models/education_lms_content.py new file mode 100644 index 0000000..d14c056 --- /dev/null +++ b/addons/education_lms/models/education_lms_content.py @@ -0,0 +1,109 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError, AccessError + +class EducationLMSSection(models.Model): + _name = 'education.lms.section' + _description = 'LMS Section' + _order = 'sequence, id' + + name = fields.Char(string='Name', required=True) + course_id = fields.Many2one('education.lms.course', string='Course', required=True, ondelete='cascade') + sequence = fields.Integer(string='Sequence', default=10) + description = fields.Text(string='Description') + + published = fields.Boolean(string='Published', default=False) + active = fields.Boolean(default=True) + + lesson_ids = fields.One2many('education.lms.lesson', 'section_id', string='Lessons') + + @api.constrains('published', 'course_id') + def _check_published_state(self): + for record in self: + if record.published and record.course_id.state != 'published': + raise ValidationError("Cannot publish a section if the parent course is not published.") + + @api.constrains('course_id') + def _check_section_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only create or modify sections for your own courses.") + + +class EducationLMSLesson(models.Model): + _name = 'education.lms.lesson' + _description = 'LMS Lesson' + _order = 'sequence, id' + + name = fields.Char(string='Name', required=True) + section_id = fields.Many2one('education.lms.section', string='Section', required=True, ondelete='cascade') + course_id = fields.Many2one('education.lms.course', related='section_id.course_id', store=True, string='Course') + + sequence = fields.Integer(string='Sequence', default=10) + description = fields.Html(string='Content') + + published = fields.Boolean(string='Published', default=False) + active = fields.Boolean(default=True) + + material_ids = fields.One2many('education.lms.material', 'lesson_id', string='Materials') + + @api.constrains('published', 'section_id') + def _check_published_state(self): + for record in self: + if record.published and not record.section_id.published: + raise ValidationError("Cannot publish a lesson if the parent section is not published.") + + @api.constrains('section_id') + def _check_lesson_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.section_id.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only create or modify lessons for your own courses.") + + +class EducationLMSMaterial(models.Model): + _name = 'education.lms.material' + _description = 'LMS Material' + _order = 'sequence, id' + + name = fields.Char(string='Name', required=True) + lesson_id = fields.Many2one('education.lms.lesson', string='Lesson', required=True, ondelete='cascade') + + material_type = fields.Selection([ + ('file', 'File/Document'), + ('url', 'URL/Link'), + ('video', 'Video Link') + ], string='Type', required=True, default='file') + + attachment_id = fields.Many2one('ir.attachment', string='Attachment') + url = fields.Char(string='URL') + + sequence = fields.Integer(string='Sequence', default=10) + description = fields.Text(string='Description') + + published = fields.Boolean(string='Published', default=False) + active = fields.Boolean(default=True) + + @api.constrains('material_type', 'attachment_id', 'url') + def _check_material_content(self): + for record in self: + if record.material_type == 'file' and not record.attachment_id: + raise ValidationError("File material must have an attachment.") + elif record.material_type in ['url', 'video'] and not record.url: + raise ValidationError("URL or Video material must have a URL provided.") + + @api.constrains('published', 'lesson_id') + def _check_published_state(self): + for record in self: + if record.published and not record.lesson_id.published: + raise ValidationError("Cannot publish a material if the parent lesson is not published.") + + @api.constrains('lesson_id') + def _check_material_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.lesson_id.section_id.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only create or modify materials for your own courses.") diff --git a/addons/education_lms/models/education_lms_course.py b/addons/education_lms/models/education_lms_course.py new file mode 100644 index 0000000..f8acfda --- /dev/null +++ b/addons/education_lms/models/education_lms_course.py @@ -0,0 +1,76 @@ +from odoo import models, fields, api +from odoo.exceptions import AccessError + +class EducationLMSCourse(models.Model): + _name = 'education.lms.course' + _description = 'LMS Course' + + name = fields.Char(string='Name', required=True) + subject_id = fields.Many2one('education.subject', string='Subject', required=True) + program_id = fields.Many2one('education.program', string='Program', required=True) + academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True) + term_id = fields.Many2one('education.academic.term', string='Term') + batch_id = fields.Many2one('education.batch', string='Batch') + + faculty_id = fields.Many2one('res.users', string='Faculty / Instructor', required=True, default=lambda self: self.env.user) + + description = fields.Html(string='Description') + + state = fields.Selection([ + ('draft', 'Draft'), + ('published', 'Published'), + ('archived', 'Archived') + ], string='State', default='draft', required=True) + + active = fields.Boolean(default=True) + + section_ids = fields.One2many('education.lms.section', 'course_id', string='Sections') + assignment_ids = fields.One2many('education.lms.assignment', 'course_id', string='Assignments') + + assignment_count = fields.Integer(string='Assignment Count', compute='_compute_assignment_count') + submission_count = fields.Integer(string='Submission Count', compute='_compute_submission_count') + + @api.depends('assignment_ids') + def _compute_assignment_count(self): + for record in self: + record.assignment_count = len(record.assignment_ids) + + def _compute_submission_count(self): + for record in self: + submissions = self.env['education.lms.submission'].search([('assignment_id.course_id', '=', record.id)]) + record.submission_count = len(submissions) + + def action_publish(self): + for record in self: + record.state = 'published' + + def action_draft(self): + for record in self: + record.state = 'draft' + + def action_archive(self): + for record in self: + record.state = 'archived' + + @api.constrains('faculty_id') + def _check_faculty_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin'): + return + for record in self: + if record.faculty_id.id != self.env.user.id: + raise AccessError("Faculty members can only manage courses assigned to themselves. Ownership cannot be transferred or spoofed.") + + def write(self, vals): + if not self.env.su and not self.env.user.has_group('education_lms.group_education_lms_admin'): + for record in self: + if record.faculty_id.id != self.env.user.id: + raise AccessError("You can only edit courses assigned to you.") + return super().write(vals) + + def unlink(self): + if not self.env.su and not self.env.user.has_group('education_lms.group_education_lms_admin'): + for record in self: + if record.faculty_id.id != self.env.user.id: + raise AccessError("You can only delete courses assigned to you.") + return super().unlink() + diff --git a/addons/education_lms/models/education_lms_submission.py b/addons/education_lms/models/education_lms_submission.py new file mode 100644 index 0000000..ade74dc --- /dev/null +++ b/addons/education_lms/models/education_lms_submission.py @@ -0,0 +1,197 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError, AccessError + +class EducationLMSSubmission(models.Model): + _name = 'education.lms.submission' + _description = 'LMS Submission' + + assignment_id = fields.Many2one('education.lms.assignment', string='Assignment', required=True, ondelete='cascade') + student_id = fields.Many2one('education.student', string='Student', required=True) + + submission_date = fields.Datetime(string='Submission Date', readonly=True) + + attachment_ids = fields.Many2many('ir.attachment', string='Attachments') + text_submission = fields.Html(string='Text Submission') + + status = fields.Selection([ + ('draft', 'Draft'), + ('submitted', 'Submitted'), + ('evaluated', 'Evaluated'), + ('late', 'Late') + ], string='Status', default='draft', required=True) + + marks = fields.Float(string='Marks') + feedback = fields.Html(string='Feedback') + + evaluation_date = fields.Datetime(string='Evaluation Date') + evaluated_by = fields.Many2one('res.users', string='Evaluated By', readonly=True) + + is_late = fields.Boolean(string='Is Late', compute='_compute_is_late', store=True) + + _sql_constraints = [ + ('unique_assignment_student', 'unique(assignment_id, student_id)', 'A student can only have one submission per assignment.') + ] + + @api.depends('submission_date', 'assignment_id.due_date') + def _compute_is_late(self): + for record in self: + if record.submission_date and record.assignment_id.due_date: + # Compare Datetime to Date (convert due_date to end of day conceptually) + record.is_late = record.submission_date.date() > record.assignment_id.due_date + else: + record.is_late = False + + def action_submit(self): + for record in self: + if record.status != 'draft': + raise ValidationError("Only draft submissions can be submitted.") + # validation for content + if not record.attachment_ids and not record.text_submission: + raise ValidationError("You must provide either a text submission or an attachment to submit.") + record.write({'status': 'submitted'}) + + def action_evaluate(self): + for record in self: + if record.status not in ['submitted', 'late', 'evaluated']: + raise ValidationError("Can only evaluate submitted or late assignments.") + record.write({'status': 'evaluated'}) + + @api.constrains('status', 'attachment_ids', 'text_submission') + def _check_content(self): + for record in self: + if record.status in ['submitted', 'late', 'evaluated'] and not record.attachment_ids and not record.text_submission: + raise ValidationError("You must provide either a text submission or an attachment to submit.") + + @api.constrains('marks', 'status') + def _check_marks(self): + for record in self: + if record.status == 'evaluated': + # To distinguish empty marks from 0.0, we can check if it's False, but Odoo Float often returns 0.0. + # However, if we literally need to reject "empty" marks, we check if it is literally False. + # In Odoo, Float field without default=0.0 will return 0.0. If we must reject empty, how? + # Actually, in Odoo 17, if a Float field is null in the database, it is 0.0 in ORM. + # But let's check if we can verify it's provided in vals when evaluated. + pass + if record.status == 'evaluated': + if record.marks < 0: + raise ValidationError("Marks cannot be negative.") + if record.marks > record.assignment_id.maximum_marks: + raise ValidationError(f"Marks cannot exceed the maximum allowed ({record.assignment_id.maximum_marks}).") + + @api.constrains('student_id', 'assignment_id') + def _check_eligibility(self): + for record in self: + student = record.student_id.sudo() + course = record.assignment_id.course_id + + if course.program_id != student.program_id: + raise ValidationError("Student program does not match the course program.") + if course.academic_year_id != student.academic_year_id: + raise ValidationError("Student academic year does not match the course academic year.") + if course.term_id and course.term_id != student.academic_term_id: + raise ValidationError("Student term does not match the course term.") + if course.batch_id and course.batch_id != student.batch_id: + raise ValidationError("Student batch does not match the course batch.") + + @api.constrains('student_id') + def _check_student_ownership(self): + if self.env.su or self.env.user.has_group('education_lms.group_education_lms_faculty'): + return + if self.env.user.has_group('education_lms.group_education_lms_student'): + for record in self: + if record.student_id.sudo().portal_user_id.id != self.env.user.id: + raise AccessError("You can only create or edit submissions for yourself.") + + @api.constrains('assignment_id') + def _check_assignment_closed(self): + for record in self: + if record.assignment_id.state == 'closed': + raise ValidationError("Cannot create or modify submissions for a closed assignment.") + + @api.model_create_multi + def create(self, vals_list): + is_faculty = self.env.user.has_group('education_lms.group_education_lms_faculty') + is_admin = self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin') + is_student = self.env.user.has_group('education_lms.group_education_lms_student') + + if is_student and not (is_admin or is_faculty): + for vals in vals_list: + if 'marks' in vals or 'feedback' in vals: + raise AccessError("Students cannot set marks or feedback.") + if 'status' in vals and vals['status'] not in ['draft', 'submitted']: + raise AccessError("Students cannot set submission status to this value.") + if 'evaluated_by' in vals or 'evaluation_date' in vals: + raise AccessError("Students cannot set evaluation metadata.") + return super().create(vals_list) + + def write(self, vals): + is_faculty = self.env.user.has_group('education_lms.group_education_lms_faculty') + is_admin = self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin') + is_student = self.env.user.has_group('education_lms.group_education_lms_student') + + for record in self: + if 'student_id' in vals and vals['student_id'] != record.student_id.id: + raise AccessError("Cannot change the student of an existing submission.") + if 'assignment_id' in vals and vals['assignment_id'] != record.assignment_id.id: + raise AccessError("Cannot change the assignment of an existing submission.") + + # Lifecycle block: cannot modify if closed + if record.assignment_id.state == 'closed' and not is_admin: + raise AccessError("Closed assignments cannot be modified.") + + if is_faculty and not is_admin: + if record.assignment_id.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only evaluate or modify submissions for assignments in your courses.") + if 'marks' in vals and vals['marks'] is False: + # If faculty explicitly clears marks while status is evaluated + if vals.get('status', record.status) == 'evaluated': + raise ValidationError("Marks cannot be empty for an evaluated submission.") + + if is_student and not (is_admin or is_faculty): + if record.status not in ['draft']: + raise AccessError("You cannot modify a submission once it has been submitted or evaluated.") + if 'marks' in vals or 'feedback' in vals: + raise AccessError("Students cannot modify marks or feedback.") + if 'status' in vals and vals['status'] not in ['draft', 'submitted']: + raise AccessError("Students cannot modify submission status directly.") + if 'evaluated_by' in vals or 'evaluation_date' in vals: + raise AccessError("Students cannot modify evaluation metadata.") + + # Check if status changes to evaluated and marks is empty/False + for record in self: + new_status = vals.get('status', record.status) + if new_status == 'evaluated': + new_marks = vals.get('marks', record.marks) + # In python, 0.0 is falsy, but we accept 0.0. We reject False or None. + if new_marks is False or new_marks is None: + raise ValidationError("Marks cannot be empty for an evaluated submission.") + + res = super().write(vals) + + for record in self: + updates = {} + if record.status == 'evaluated' and not record.evaluation_date: + updates.update({'evaluation_date': fields.Datetime.now(), 'evaluated_by': self.env.user.id}) + elif record.status == 'submitted' and not record.submission_date: + # Calculate if late + is_late = fields.Datetime.now().date() > record.assignment_id.due_date + updates.update({'submission_date': fields.Datetime.now(), 'status': 'late' if is_late else 'submitted'}) + if updates: + record.sudo().write(updates) + + return res + + def unlink(self): + is_faculty = self.env.user.has_group('education_lms.group_education_lms_faculty') + is_admin = self.env.su or self.env.user.has_group('education_lms.group_education_lms_admin') + is_student = self.env.user.has_group('education_lms.group_education_lms_student') + + for record in self: + if is_faculty and not is_admin: + if record.assignment_id.course_id.faculty_id.id != self.env.user.id: + raise AccessError("You can only delete submissions for assignments in your courses.") + if is_student and not (is_admin or is_faculty): + if record.status != 'draft': + raise AccessError("You can only delete submissions while they are in Draft state.") + + return super().unlink() diff --git a/addons/education_lms/security/ir.model.access.csv b/addons/education_lms/security/ir.model.access.csv new file mode 100644 index 0000000..6c3b327 --- /dev/null +++ b/addons/education_lms/security/ir.model.access.csv @@ -0,0 +1,19 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_education_lms_course_admin,education.lms.course.admin,model_education_lms_course,group_education_lms_admin,1,1,1,1 +access_education_lms_section_admin,education.lms.section.admin,model_education_lms_section,group_education_lms_admin,1,1,1,1 +access_education_lms_lesson_admin,education.lms.lesson.admin,model_education_lms_lesson,group_education_lms_admin,1,1,1,1 +access_education_lms_material_admin,education.lms.material.admin,model_education_lms_material,group_education_lms_admin,1,1,1,1 +access_education_lms_course_faculty,education.lms.course.faculty,model_education_lms_course,group_education_lms_faculty,1,1,1,1 +access_education_lms_section_faculty,education.lms.section.faculty,model_education_lms_section,group_education_lms_faculty,1,1,1,1 +access_education_lms_lesson_faculty,education.lms.lesson.faculty,model_education_lms_lesson,group_education_lms_faculty,1,1,1,1 +access_education_lms_material_faculty,education.lms.material.faculty,model_education_lms_material,group_education_lms_faculty,1,1,1,1 +access_education_lms_course_student,education.lms.course.student,model_education_lms_course,group_education_lms_student,1,0,0,0 +access_education_lms_section_student,education.lms.section.student,model_education_lms_section,group_education_lms_student,1,0,0,0 +access_education_lms_lesson_student,education.lms.lesson.student,model_education_lms_lesson,group_education_lms_student,1,0,0,0 +access_education_lms_material_student,education.lms.material.student,model_education_lms_material,group_education_lms_student,1,0,0,0 +access_education_lms_assignment_admin,education.lms.assignment.admin,model_education_lms_assignment,group_education_lms_admin,1,1,1,1 +access_education_lms_submission_admin,education.lms.submission.admin,model_education_lms_submission,group_education_lms_admin,1,1,1,1 +access_education_lms_assignment_faculty,education.lms.assignment.faculty,model_education_lms_assignment,group_education_lms_faculty,1,1,1,1 +access_education_lms_submission_faculty,education.lms.submission.faculty,model_education_lms_submission,group_education_lms_faculty,1,1,1,1 +access_education_lms_assignment_student,education.lms.assignment.student,model_education_lms_assignment,group_education_lms_student,1,0,0,0 +access_education_lms_submission_student,education.lms.submission.student,model_education_lms_submission,group_education_lms_student,1,1,1,1 diff --git a/addons/education_lms/security/security.xml b/addons/education_lms/security/security.xml new file mode 100644 index 0000000..49681d9 --- /dev/null +++ b/addons/education_lms/security/security.xml @@ -0,0 +1,257 @@ + + + + + Education LMS + Helps you manage LMS courses and content. + 20 + + + + + LMS Student + + + + + + + LMS Faculty + + + + + + + LMS Administrator + + + + + + + + + Faculty Own Courses + + [('faculty_id', '=', user.id)] + + + + + + + + + Faculty Own Sections + + [('course_id.faculty_id', '=', user.id)] + + + + + + + + + Faculty Own Lessons + + [('course_id.faculty_id', '=', user.id)] + + + + + + + + + Faculty Own Materials + + [('lesson_id.course_id.faculty_id', '=', user.id)] + + + + + + + + + + Admin All Courses + + [(1, '=', 1)] + + + + + + + + Admin All Sections + + [(1, '=', 1)] + + + + + + + + Admin All Lessons + + [(1, '=', 1)] + + + + + + + + Admin All Materials + + [(1, '=', 1)] + + + + + + + + + + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.program_id.ids), + ('academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + Student Section Access + + [ + ('course_id.state', 'in', ['published', 'closed']), + ('course_id.program_id', 'in', user.student_ids.program_id.ids), + ('course_id.academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('course_id.term_id', '=', False), ('course_id.term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('course_id.batch_id', '=', False), ('course_id.batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + Student Lesson Access + + [ + ('course_id.state', 'in', ['published', 'closed']), + ('course_id.program_id', 'in', user.student_ids.program_id.ids), + ('course_id.academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('course_id.term_id', '=', False), ('course_id.term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('course_id.batch_id', '=', False), ('course_id.batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + Student Material Access + + [ + ('lesson_id.course_id.state', 'in', ['published', 'closed']), + ('lesson_id.course_id.program_id', 'in', user.student_ids.program_id.ids), + ('lesson_id.course_id.academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('lesson_id.course_id.term_id', '=', False), ('lesson_id.course_id.term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('lesson_id.course_id.batch_id', '=', False), ('lesson_id.course_id.batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + + + Faculty Own Assignments + + [('course_id.faculty_id', '=', user.id)] + + + + + + + + Admin All Assignments + + [(1, '=', 1)] + + + + + + + + Student Assignment Access + + [ + ('state', 'in', ['published', 'closed']), + ('course_id.state', 'in', ['published', 'closed']), + ('course_id.program_id', 'in', user.student_ids.program_id.ids), + ('course_id.academic_year_id', 'in', user.student_ids.academic_year_id.ids), + '|', ('course_id.term_id', '=', False), ('course_id.term_id', 'in', user.student_ids.academic_term_id.ids), + '|', ('course_id.batch_id', '=', False), ('course_id.batch_id', 'in', user.student_ids.batch_id.ids), + ] + + + + + + + + + + Faculty Own Submissions + + [('assignment_id.course_id.faculty_id', '=', user.id)] + + + + + + + + Admin All Submissions + + [(1, '=', 1)] + + + + + + + + Student Submission Sandbox + + [('student_id.portal_user_id', '=', user.id)] + + + + + + + + diff --git a/addons/education_lms/tests/__init__.py b/addons/education_lms/tests/__init__.py new file mode 100644 index 0000000..7f70c47 --- /dev/null +++ b/addons/education_lms/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_education_lms +from . import test_education_lms_portal diff --git a/addons/education_lms/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_lms/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c69e5049ee9670ea05876b6f44701ca441e857df GIT binary patch literal 233 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 A0ssI2 literal 0 HcmV?d00001 diff --git a/addons/education_lms/tests/test_education_lms.py b/addons/education_lms/tests/test_education_lms.py new file mode 100644 index 0000000..4b45c2f --- /dev/null +++ b/addons/education_lms/tests/test_education_lms.py @@ -0,0 +1,1051 @@ +import base64 +# pyrefly: ignore [missing-import] +from odoo.tests.common import TransactionCase +# pyrefly: ignore [missing-import] +from odoo.exceptions import AccessError, ValidationError +from datetime import date, timedelta +# pyrefly: ignore [missing-import] +from odoo import fields + +class TestEducationLMS(TransactionCase): + + def setUp(self): + super(TestEducationLMS, self).setUp() + self.env.user.tz = 'UTC' + + # Groups + self.group_faculty = self.env.ref('education_lms.group_education_lms_faculty') + self.group_student = self.env.ref('education_lms.group_education_lms_student') + self.group_admin = self.env.ref('education_lms.group_education_lms_admin') + + # Create Users + self.user_faculty_a = self.env['res.users'].create({ + 'name': 'Faculty A', + 'login': 'faculty_a', + 'groups_id': [(4, self.group_faculty.id)] + }) + self.user_faculty_b = self.env['res.users'].create({ + 'name': 'Faculty B', + 'login': 'faculty_b', + 'groups_id': [(4, self.group_faculty.id)] + }) + self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('education_student.group_education_student_user').id)] + }) + self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('education_student.group_education_student_user').id)] + }) + + # Academic structure + self.campus = self.env['education.campus'].create({'name': 'Campus A', 'code': 'CA'}) + self.department = self.env['education.department'].create({ + 'name': 'Dept A', 'code': 'DA', 'campus_id': self.campus.id + }) + self.level = self.env['education.academic.level'].create({'name': 'Level A', 'code': 'LA'}) + self.program = self.env['education.program'].create({ + 'name': 'Program A', 'code': 'PA', 'department_id': self.department.id, 'level_id': self.level.id + }) + self.academic_year = self.env['education.academic.year'].create({ + 'name': 'Year 2024', + 'code': 'Y24', + 'start_date': date(2024, 1, 1), + 'end_date': date(2024, 12, 31) + }) + self.term = self.env['education.academic.term'].create({ + 'name': 'Term 1', 'code': 'T1', 'academic_year_id': self.academic_year.id, + 'start_date': date(2024, 1, 1), 'end_date': date(2024, 6, 30) + }) + self.batch = self.env['education.batch'].create({ + 'name': 'Batch A', 'code': 'BA' + }) + self.semester = self.env['education.semester'].create({ + 'name': 'Sem 1', 'code': 'S1' + }) + self.section = self.env['education.section'].create({'name': 'Section A', 'code': 'SECA'}) + + self.subject = self.env['education.subject'].create({ + 'name': 'Subject A', 'code': 'SUB_A', 'program_id': self.program.id + }) + + # Create Students + self.student_a = self.env['education.student'].create({ + 'first_name': 'Student', + 'last_name': 'A', + 'portal_user_id': self.user_student_a.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.term.id, + 'batch_id': self.batch.id, + 'semester_id': self.semester.id, + 'section_id': self.section.id, + 'admission_type': 'regular' + }) + self.student_b = self.env['education.student'].create({ + 'first_name': 'Student', + 'last_name': 'B', + 'portal_user_id': self.user_student_b.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.term.id, + 'batch_id': self.batch.id, + 'semester_id': self.semester.id, + 'section_id': self.section.id, + 'admission_type': 'regular' + }) + + # Courses + self.course_faculty_a = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course A', + 'subject_id': self.subject.id, + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': self.batch.id, + 'state': 'draft' + }) + self.course_faculty_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'name': 'Course B', + 'subject_id': self.subject.id, + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'state': 'draft' + }) + self.course_faculty_a.write({'state': 'published'}) + self.course_faculty_b.write({'state': 'published'}) + + # 1. Faculty A creates assignment on Faculty A course -> PASS + def test_01_faculty_creates_own_assignment(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'assigned_date': fields.Date.today(), + 'due_date': fields.Date.today() + timedelta(days=5), + 'maximum_marks': 100 + }) + self.assertTrue(assignment.id) + + # 2. Faculty A creates assignment on Faculty B course -> REJECT + def test_02_faculty_creates_other_assignment(self): + with self.assertRaises(AccessError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign B', + 'course_id': self.course_faculty_b.id, + 'assigned_date': fields.Date.today(), + 'due_date': fields.Date.today() + timedelta(days=5) + }) + + # 3. Faculty B edits Faculty A assignment -> REJECT + def test_03_faculty_edits_other_assignment(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(AccessError): + assignment.with_user(self.user_faculty_b).write({'name': 'Hacked'}) + + # 4. Faculty B deletes Faculty A assignment -> REJECT + def test_04_faculty_deletes_other_assignment(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(AccessError): + assignment.with_user(self.user_faculty_b).unlink() + + # 5. Faculty A publishes assignment under Draft course -> REJECT + def test_05_publish_under_draft_course(self): + self.course_faculty_a.with_user(self.user_faculty_a).write({'state': 'draft'}) + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(ValidationError): + assignment.with_user(self.user_faculty_a).write({'state': 'published'}) + + # 6. Faculty A publishes assignment under Published course -> PASS + def test_06_publish_under_published_course(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.with_user(self.user_faculty_a).write({'state': 'published'}) + self.assertEqual(assignment.state, 'published') + + # 7. due_date before assigned_date -> REJECT + def test_07_invalid_dates(self): + with self.assertRaises(ValidationError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'assigned_date': fields.Date.today(), + 'due_date': fields.Date.today() - timedelta(days=1) + }) + + # 8. maximum_marks <= 0 -> REJECT + def test_08_invalid_marks(self): + with self.assertRaises(ValidationError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'maximum_marks': 0 + }) + + # 9. Section from another course selected -> REJECT + def test_09_invalid_section(self): + section_b = self.env['education.lms.section'].create({ + 'name': 'Section B', 'course_id': self.course_faculty_b.id + }) + with self.assertRaises(ValidationError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'section_id': section_b.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + + # 10. Lesson from another section/course selected -> REJECT + def test_10_invalid_lesson(self): + section_a = self.env['education.lms.section'].create({ + 'name': 'Section A', 'course_id': self.course_faculty_a.id + }) + section_b = self.env['education.lms.section'].create({ + 'name': 'Section B', 'course_id': self.course_faculty_b.id + }) + lesson_b = self.env['education.lms.lesson'].create({ + 'name': 'Lesson B', 'section_id': section_b.id, 'course_id': self.course_faculty_b.id + }) + with self.assertRaises(ValidationError): + self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'section_id': section_a.id, + 'lesson_id': lesson_b.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + + # 11. Student A creates submission for eligible assignment -> PASS + def test_11_student_creates_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + self.assertTrue(submission.id) + + # 12. Student A creates submission for Student B -> REJECT + def test_12_student_creates_for_other(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(AccessError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_b.id + }) + + # 13. Student A submits assignment for an ineligible course -> REJECT + def test_13_student_ineligible_course(self): + # Create course for different program + program2 = self.env['education.program'].create({ + 'name': 'Program 2', 'code': 'P2', 'department_id': self.department.id, 'level_id': self.level.id + }) + course_ineligible = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course 2', + 'subject_id': self.subject.id, + 'faculty_id': self.user_faculty_a.id, + 'program_id': program2.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'state': 'published' + }) + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 2', + 'course_id': course_ineligible.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + + # 14. Student A submits with no text and no attachment -> REJECT + def test_14_submit_without_content(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + with self.assertRaises(ValidationError): + submission.with_user(self.user_student_a).write({'status': 'submitted'}) + + # 15. Student A submits before due date -> status Submitted + def test_15_submit_before_due_date(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + submission.with_user(self.user_student_a).write({'status': 'submitted', 'text_submission': 'Done'}) + self.assertEqual(submission.status, 'submitted') + self.assertFalse(submission.is_late) + + # 16. Student A submits after due date -> status Late + def test_16_submit_after_due_date(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'assigned_date': fields.Date.today() - timedelta(days=2), + 'due_date': fields.Date.today() - timedelta(days=1) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + submission.with_user(self.user_student_a).write({'status': 'submitted', 'text_submission': 'Done'}) + self.assertEqual(submission.status, 'late') + self.assertTrue(submission.is_late) + + # 17. Student A cannot edit another student's submission -> REJECT + def test_17_student_edit_other_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_student_b).write({'text_submission': 'Hacked'}) + + # 18. Faculty A can view/evaluate Student A submission for Faculty A course -> PASS + def test_18_faculty_evaluates_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'status': 'evaluated', 'marks': 90}) + self.assertEqual(submission.status, 'evaluated') + self.assertEqual(submission.marks, 90) + self.assertTrue(submission.evaluation_date) + + # 19. Faculty B cannot view/evaluate that submission -> REJECT + def test_19_faculty_b_evaluates_faculty_a_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_faculty_b).write({'status': 'evaluated', 'marks': 90}) + + # 20. Faculty evaluates with marks = 0 -> PASS + def test_20_faculty_evaluate_zero_marks(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'status': 'evaluated', 'marks': 0.0}) + self.assertEqual(submission.status, 'evaluated') + self.assertEqual(submission.marks, 0.0) + + # 21. Faculty evaluates with marks > maximum_marks -> REJECT + def test_21_faculty_evaluate_exceed_max_marks(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'maximum_marks': 100 + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(ValidationError): + submission.with_user(self.user_faculty_a).write({'status': 'evaluated', 'marks': 105}) + + # 22. Faculty evaluates with negative marks -> REJECT + def test_22_faculty_evaluate_negative_marks(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(ValidationError): + submission.with_user(self.user_faculty_a).write({'status': 'evaluated', 'marks': -5}) + + # 23. Empty marks remain unevaluated; never convert empty to 0. + def test_23_empty_marks(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + self.assertFalse(submission.marks) + + # 24. Duplicate submission for same student + assignment -> REJECT + def test_24_duplicate_submission(self): + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + # Try to create another one + with self.assertRaises(Exception): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + + # 25. Admin can manage all assignments and submissions -> PASS + def test_25_admin_access(self): + user_admin = self.env['res.users'].create({ + 'name': 'Admin User', + 'login': 'lms_admin', + 'groups_id': [(4, self.group_admin.id)] + }) + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign 1', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.with_user(user_admin).write({'title': 'Admin Edited'}) + self.assertEqual(assignment.title, 'Admin Edited') + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(user_admin).write({'marks': 100, 'status': 'evaluated'}) + self.assertEqual(submission.marks, 100) + + + # PHASE G TESTS + + # 26. Draft -> Published + def test_26_draft_to_published(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + self.assertEqual(assignment.state, 'draft') + assignment.action_publish() + self.assertEqual(assignment.state, 'published') + + # 27. Published -> Closed + def test_27_published_to_closed(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + assignment.action_close() + self.assertEqual(assignment.state, 'closed') + + # 28. Closed -> Published rejected + def test_28_closed_to_published_rejected(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + assignment.action_close() + with self.assertRaises(ValidationError): + assignment.write({'state': 'published'}) + + # 29. Closed -> Draft rejected + def test_29_closed_to_draft_rejected(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + assignment.action_close() + with self.assertRaises(ValidationError): + assignment.write({'state': 'draft'}) + + # 30. Closed assignment rejects new submission + def test_30_closed_assignment_rejects_new_submission(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + assignment.action_close() + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id + }) + + # 31. Faculty evaluation succeeds on own course + def test_31_faculty_evaluation_own_course(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'marks': 10, 'status': 'evaluated'}) + self.assertEqual(submission.status, 'evaluated') + + # 32. Cross-faculty evaluation rejected + def test_32_cross_faculty_evaluation_rejected(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_faculty_b).write({'marks': 10, 'status': 'evaluated'}) + + # 33. Student cannot modify evaluation fields + def test_33_student_cannot_modify_evaluation(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'marks': 10}) + + # 34. Faculty cannot modify another faculty's evaluation + def test_34_faculty_cannot_modify_other_faculty_eval(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'marks': 10, 'status': 'evaluated'}) + with self.assertRaises(AccessError): + submission.with_user(self.user_faculty_b).write({'marks': 15}) + + # 35. 0 marks accepted + def test_35_0_marks_accepted(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + submission.with_user(self.user_faculty_a).write({'marks': 0.0, 'status': 'evaluated'}) + self.assertEqual(submission.marks, 0.0) + + # 36. empty marks rejected for Evaluated status + def test_36_empty_marks_rejected(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Phase G', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5) + }) + assignment.action_publish() + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Done', + 'status': 'submitted' + }) + with self.assertRaises(ValidationError): + submission.with_user(self.user_faculty_a).write({'marks': False, 'status': 'evaluated'}) + + + # ========================================== + # PHASE H SECURITY TESTS + # ========================================== + + # 37. Faculty A cannot read/modify Faculty B courses/content + def test_37_faculty_isolation(self): + # Faculty B course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'name': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + }) + # Faculty A tries to read -> AccessError + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).read(['name']) + + # Faculty A tries to modify + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).write({'name': 'Hacked'}) + + # Faculty A tries to delete + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).unlink() + + # Faculty A tries to create section under Course B -> AccessError + with self.assertRaises(AccessError): + self.env['education.lms.section'].with_user(self.user_faculty_a).create({ + 'name': 'Sec B', + 'course_id': course_b.id + }) + + # 38. Student Isolation (cannot read/modify other student submission) + def test_38_student_isolation(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + # Student B tries to read + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).read(['text_submission']) + + # Student B tries to write + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).write({'text_submission': 'Hacked'}) + + # Student B tries to create submission for Student A + assignment_new = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H New', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + with self.assertRaises(AccessError): + self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment_new.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student B Work', + }) + + # 39. Course Eligibility Security + def test_39_course_eligibility(self): + # A. Matching + course_a = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course A', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + + # Student A matches program, year, term, batch + self.env.invalidate_all() + res = course_a.with_user(self.user_student_a).read(['name']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_a.id)]))) + + # B. Different Program + program_2 = self.env['education.program'].create({ + 'name': 'Program 2', 'code': 'P2', 'department_id': self.department.id, 'level_id': self.level.id + }) + course_b = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course B', + 'faculty_id': self.user_faculty_a.id, + 'program_id': program_2.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + with self.assertRaises(AccessError): + course_b.with_user(self.user_student_a).read(['name']) + self.assertFalse(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_b.id)]))) + + # C. Different Year + year_2 = self.env['education.academic.year'].create({ + 'name': 'Year 2025', 'code': 'Y25', 'start_date': date(2025, 1, 1), 'end_date': date(2025, 12, 31) + }) + course_c = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course C', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': year_2.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + with self.assertRaises(AccessError): + course_c.with_user(self.user_student_a).read(['name']) + self.assertFalse(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_c.id)]))) + + # D. Different Term + term_2 = self.env['education.academic.term'].create({ + 'name': 'Term 2', 'code': 'T2', 'academic_year_id': self.academic_year.id, + 'start_date': date(2024, 7, 1), 'end_date': date(2024, 12, 31), + 'sequence': 2 + }) + course_d = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course D', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': term_2.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + with self.assertRaises(AccessError): + course_d.with_user(self.user_student_a).read(['name']) + self.assertFalse(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_d.id)]))) + + # E. Course with no term -> Matches + course_e = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course E', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'batch_id': self.batch.id, + 'state': 'published' + }) + res = course_e.with_user(self.user_student_a).read(['name']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_e.id)]))) + + # F. Different batch + batch_2 = self.env['education.batch'].create({'name': 'Batch B', 'code': 'BB'}) + course_f = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course F', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'batch_id': batch_2.id, + 'state': 'published' + }) + with self.assertRaises(AccessError): + course_f.with_user(self.user_student_a).read(['name']) + self.assertFalse(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_f.id)]))) + + # G. Course with no batch + course_g = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course G', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'term_id': self.term.id, + 'state': 'published' + }) + res = course_g.with_user(self.user_student_a).read(['name']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_g.id)]))) + + # H. Course with no term AND no batch (should match) + course_h = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'name': 'Course H', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + 'state': 'published' + }) + res = course_h.with_user(self.user_student_a).read(['name']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.course'].with_user(self.user_student_a).search([('id', '=', course_h.id)]))) + + + # 40. Assignment access and states + def test_40_assignment_access(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Draft', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'draft' + }) + self.env.invalidate_all() + # Draft assignment -> Student A cannot read + with self.assertRaises(AccessError): + assignment.with_user(self.user_student_a).read(['title']) + self.assertFalse(bool(self.env['education.lms.assignment'].with_user(self.user_student_a).search([('id', '=', assignment.id)]))) + + # Published -> Student A can read + assignment.with_user(self.user_faculty_a).action_publish() + self.env.invalidate_all() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + self.assertTrue(bool(self.env['education.lms.assignment'].with_user(self.user_student_a).search([('id', '=', assignment.id)]))) + + # Closed -> Student A can read, but cannot submit + assignment.with_user(self.user_faculty_a).action_close() + self.env.invalidate_all() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # 41. Submission edge cases + def test_41_submission_security_edge_cases(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H2', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # Spoof student_id + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'student_id': self.student_b.id}) + + # Spoof assignment_id + assignment_2 = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H3', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'assignment_id': assignment_2.id}) + + submission.with_user(self.user_student_a).action_submit() + + # Change marks + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'marks': 100}) + + # Change feedback + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'feedback': 'Good job me'}) + + # Change status + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'status': 'evaluated'}) + + # Change evaluation metadata + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'evaluated_by': self.user_student_a.id}) + + # Faculty B cannot evaluate Faculty A's submissions + with self.assertRaises(AccessError): + submission.with_user(self.user_faculty_b).write({'marks': 50}) + + # Faculty A CAN evaluate + submission.with_user(self.user_faculty_a).write({'marks': 50, 'status': 'evaluated'}) + self.assertEqual(submission.marks, 50) + + # 42. Attachment Security Direct Test + def test_42_attachment_security(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H Attachments', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + + # Student A submission + sub_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'A', + }) + attach_a = self.env['ir.attachment'].with_user(self.user_student_a).create({ + 'name': 'file_a.txt', + 'datas': base64.b64encode(b'student A file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_a.id, + }) + + sub_b = self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_b.id, + 'text_submission': 'B', + }) + attach_b = self.env['ir.attachment'].with_user(self.user_student_b).create({ + 'name': 'file_b.txt', + 'datas': base64.b64encode(b'student B file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_b.id, + }) + + # Verify A can read attach A binary data + res_a = attach_a.with_user(self.user_student_a).read(['raw', 'datas']) + self.assertTrue(len(res_a) > 0) + + # Verify A cannot read attach B binary data + with self.assertRaises(AccessError): + attach_b.with_user(self.user_student_a).read(['raw', 'datas']) + + # Verify Faculty A can read both + res_fa1 = attach_a.with_user(self.user_faculty_a).read(['raw']) + self.assertTrue(len(res_fa1) > 0) + res_fa2 = attach_b.with_user(self.user_faculty_a).read(['raw']) + self.assertTrue(len(res_fa2) > 0) + + # Verify Faculty B cannot read either + with self.assertRaises(AccessError): + attach_a.with_user(self.user_faculty_b).read(['raw']) + with self.assertRaises(AccessError): + attach_b.with_user(self.user_faculty_b).read(['raw']) + + # 43. Admin override + def test_43_admin_override(self): + user_admin = self.env['res.users'].create({ + 'name': 'Admin User 2', + 'login': 'lms_admin2', + 'groups_id': [(4, self.group_admin.id)] + }) + # Admin can read faculty B's course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'name': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'subject_id': self.subject.id, + }) + res = course_b.with_user(user_admin).read(['name']) + self.assertTrue(len(res) > 0) + + course_b.with_user(user_admin).write({'name': 'Admin changed'}) + self.assertEqual(course_b.name, 'Admin changed') + + # 44. Portal User Mapping + def test_44_portal_user_mapping(self): + # A user without student_id should not see Student A's courses/submissions in LMS Context + user_no_student = self.env['res.users'].create({ + 'name': 'No Student', + 'login': 'no_student', + 'groups_id': [(4, self.group_student.id)] + }) + self.course_faculty_a.state = 'published' + with self.assertRaises(AccessError): + self.course_faculty_a.with_user(user_no_student).read(['name']) diff --git a/addons/education_lms/tests/test_education_lms_phase_h.py b/addons/education_lms/tests/test_education_lms_phase_h.py new file mode 100644 index 0000000..70a1d85 --- /dev/null +++ b/addons/education_lms/tests/test_education_lms_phase_h.py @@ -0,0 +1,14 @@ +from odoo.tests.common import TransactionCase +from odoo.exceptions import AccessError, ValidationError +from odoo import fields +from datetime import timedelta +import base64 + +class TestEducationLMSPhaseH(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Same setup as test_education_lms.py + # Actually it's easier to append to test_education_lms.py because setup is already there! + pass diff --git a/addons/education_lms/tests/test_education_lms_portal.py b/addons/education_lms/tests/test_education_lms_portal.py new file mode 100644 index 0000000..e52afc4 --- /dev/null +++ b/addons/education_lms/tests/test_education_lms_portal.py @@ -0,0 +1,242 @@ +# pyrefly: ignore [missing-import] +from odoo.tests.common import TransactionCase +# pyrefly: ignore [missing-import] +from odoo.exceptions import AccessError +from unittest.mock import patch +# pyrefly: ignore [missing-import] +import werkzeug +# pyrefly: ignore [missing-import] +import odoo.addons.education_lms.controllers.main as main_controller +from contextlib import contextmanager + +# pyrefly: ignore [missing-import] +from odoo.http import Response + +class MockRequest: + def __init__(self, env): + self.env = env + self.httprequest = type('obj', (object,), {'path': '/my/lms'})() + def render(self, template, qcontext=None, **kwargs): + return Response(template=template, qcontext=qcontext) + def redirect(self, location, **kwargs): + return location + def make_response(self, content, headers=None): + return Response(content, headers=headers) + +@contextmanager +def mock_request(env): + old_req = main_controller.request + main_controller.request = MockRequest(env) + try: + yield main_controller.request + finally: + main_controller.request = old_req + +class TestEducationLMSPortal(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Ensure we don't accidentally update record rules during test run + cls.env['ir.model.data'].search([ + ('module', '=', 'education_lms'), + ('model', '=', 'ir.rule') + ]).write({'noupdate': False}) + + # We need a student and a faculty + cls.company = cls.env.company + + cls.campus = cls.env['education.campus'].create({'name': 'Campus A', 'code': 'CA'}) + cls.department = cls.env['education.department'].create({'name': 'Test Dept', 'code': 'TD', 'campus_id': cls.campus.id}) + cls.level = cls.env['education.academic.level'].create({'name': 'Level A', 'code': 'LA'}) + cls.program = cls.env['education.program'].create({'name': 'Computer Science', 'code': 'CS', 'department_id': cls.department.id, 'level_id': cls.level.id}) + + from datetime import date + cls.academic_year = cls.env['education.academic.year'].create({'name': '2025-2026', 'code': 'AY25', 'start_date': date(2025,1,1), 'end_date': date(2025,12,31)}) + cls.term = cls.env['education.academic.term'].create({'name': 'Fall 2025', 'code': 'FT25', 'start_date': date(2025,1,1), 'end_date': date(2025,6,30), 'academic_year_id': cls.academic_year.id, 'sequence': 1}) + cls.batch = cls.env['education.batch'].create({'name': 'Batch 2025', 'code': 'B25'}) + cls.semester = cls.env['education.semester'].create({'name': 'Sem 1', 'code': 'S1'}) + cls.section = cls.env['education.section'].create({'name': 'Section A', 'code': 'SECA'}) + + # Create student user A + cls.student_user_a = cls.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a@test.com', + 'groups_id': [(4, cls.env.ref('base.group_portal').id)] + }) + cls.student_a = cls.env['education.student'].create({ + 'first_name': 'Student', + 'last_name': 'A', + 'portal_user_id': cls.student_user_a.id, + 'campus_id': cls.campus.id, + 'department_id': cls.department.id, + 'program_id': cls.program.id, + 'level_id': cls.level.id, + 'academic_year_id': cls.academic_year.id, + 'academic_term_id': cls.term.id, + 'batch_id': cls.batch.id, + 'semester_id': cls.semester.id, + 'section_id': cls.section.id, + }) + + # Create student user B (Different Batch) + cls.batch_b = cls.env['education.batch'].create({'name': 'Batch 2024', 'code': 'B24'}) + cls.student_user_b = cls.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b@test.com', + 'groups_id': [(4, cls.env.ref('base.group_portal').id)] + }) + cls.student_b = cls.env['education.student'].create({ + 'first_name': 'Student', + 'last_name': 'B', + 'portal_user_id': cls.student_user_b.id, + 'campus_id': cls.campus.id, + 'department_id': cls.department.id, + 'program_id': cls.program.id, + 'level_id': cls.level.id, + 'academic_year_id': cls.academic_year.id, + 'academic_term_id': cls.term.id, + 'batch_id': cls.batch_b.id, + 'semester_id': cls.semester.id, + 'section_id': cls.section.id, + }) + + # Create faculty + cls.faculty_user = cls.env['res.users'].create({ + 'name': 'Faculty User', + 'login': 'faculty@test.com', + 'groups_id': [(4, cls.env.ref('education_lms.group_education_lms_faculty').id)] + }) + + # Create Course A (Accessible to Student A) + cls.subject = cls.env['education.subject'].create({'name': 'Database Systems', 'code': 'DB1', 'program_id': cls.program.id}) + cls.course_a = cls.env['education.lms.course'].with_user(cls.faculty_user).create({ + 'name': 'CS 101', + 'subject_id': cls.subject.id, + 'program_id': cls.program.id, + 'academic_year_id': cls.academic_year.id, + 'term_id': cls.term.id, + 'batch_id': cls.batch.id, + 'state': 'published', + }) + + # Create Course B (Accessible only to Student B) + cls.course_b = cls.env['education.lms.course'].with_user(cls.faculty_user).create({ + 'name': 'CS 202', + 'subject_id': cls.subject.id, + 'program_id': cls.program.id, + 'academic_year_id': cls.academic_year.id, + 'term_id': cls.term.id, + 'batch_id': cls.batch_b.id, + 'state': 'published', + }) + + # Course A Content + cls.section_a = cls.env['education.lms.section'].with_user(cls.faculty_user).create({ + 'name': 'Section 1', + 'course_id': cls.course_a.id, + 'published': True, + }) + cls.lesson_a = cls.env['education.lms.lesson'].with_user(cls.faculty_user).create({ + 'name': 'Lesson 1', + 'section_id': cls.section_a.id, + 'published': True, + }) + + cls.attachment_a = cls.env['ir.attachment'].create({ + 'name': 'test.pdf', + 'type': 'binary', + 'datas': b'dGVzdA==', # 'test' + 'res_model': 'education.lms.material', + 'res_id': 0, # Will update after material creation + }) + cls.material_a = cls.env['education.lms.material'].with_user(cls.faculty_user).create({ + 'name': 'Material 1', + 'lesson_id': cls.lesson_a.id, + 'material_type': 'file', + 'attachment_id': cls.attachment_a.id, + 'published': True, + }) + cls.attachment_a.write({'res_id': cls.material_a.id}) + + # Assignment A + cls.assignment_a = cls.env['education.lms.assignment'].with_user(cls.faculty_user).create({ + 'title': 'Test Assignment A', + 'course_id': cls.course_a.id, + 'maximum_marks': 100, + 'state': 'published', + 'assigned_date': date(2025,1,1), + 'due_date': date(2025,1,10), + }) + + # Instantiate controller for tests + # pyrefly: ignore [missing-import] + from odoo.addons.education_lms.controllers.main import StudentLMSController + cls.controller = StudentLMSController() + + def test_01_course_access(self): + """Student A should only see Course A.""" + with mock_request(self.env(user=self.student_user_a)): + res = self.controller.my_lms_courses() + self.assertIn('courses', res.qcontext) + courses = res.qcontext['courses'] + self.assertEqual(len(courses), 1) + self.assertEqual(courses[0].id, self.course_a.id) + + # Test specific course access + res_course = self.controller.my_lms_course_detail(self.course_a.id) + self.assertEqual(res_course.qcontext['course'].id, self.course_a.id) + + with self.assertRaises(AccessError): + self.controller._check_course_access(self.course_b.id) + + def test_02_material_download_idor(self): + """Student B tries to download Student A's course material.""" + with mock_request(self.env(user=self.student_user_b)): + # B tries to access A's material (B is not enrolled in Course A) + with self.assertRaises(werkzeug.exceptions.Forbidden): + res = self.controller.download_material(self.material_a.id) + + def test_03_assignment_submission_idor(self): + """Student B tries to submit an assignment for Course A (which B shouldn't access).""" + with mock_request(self.env(user=self.student_user_b)): + # Simulate B submitting assignment for Course A + res = self.controller.my_lms_assignment_submit( + self.assignment_a.id, + submission_text="Hacked Submission" + ) + # Controller redirects to /my/lms/assignments on AccessError + # Since our mock returns the location string and Odoo's wrapper turns it into a Response, + # we check the response data. + self.assertEqual(res.data, b'/my/lms/assignments') + + submission = self.env['education.lms.submission'].search([ + ('assignment_id', '=', self.assignment_a.id) + ]) + self.assertEqual(len(submission), 0, "Student B should not have been able to submit.") + + # Try submitting properly with Student A + with mock_request(self.env(user=self.student_user_a)): + res2 = self.controller.my_lms_assignment_submit( + self.assignment_a.id, + content='My Second Answer' + ) + # Should render the redirect to assignments page or assignment detail + self.assertTrue(res2) + + submission = self.env['education.lms.submission'].search([ + ('assignment_id', '=', self.assignment_a.id) + ]) + self.assertEqual(len(submission), 1, "Student A should be able to submit.") + + def test_04_no_student_mapping(self): + """User without student mapping cannot access.""" + no_student_user = self.env['res.users'].create({ + 'name': 'No Student', + 'login': 'nostudent@test.com', + 'groups_id': [(4, self.env.ref('base.group_portal').id)] + }) + + with mock_request(self.env(user=no_student_user)): + res = self.controller.my_lms_courses() + self.assertEqual(res.template, "education_portal.portal_no_student") diff --git a/addons/education_lms/views/education_lms_assignment_views.xml b/addons/education_lms/views/education_lms_assignment_views.xml new file mode 100644 index 0000000..fff281d --- /dev/null +++ b/addons/education_lms/views/education_lms_assignment_views.xml @@ -0,0 +1,95 @@ + + + + + + education.lms.assignment.form + education.lms.assignment + +
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + education.lms.assignment.tree + education.lms.assignment + + + + + + + + + + + + + + education.lms.assignment.search + education.lms.assignment + + + + + + + + + + + + + + + + + + Assignments + education.lms.assignment + tree,form + +

+ Create the first Assignment! +

+
+
+ +
diff --git a/addons/education_lms/views/education_lms_content_views.xml b/addons/education_lms/views/education_lms_content_views.xml new file mode 100644 index 0000000..a06220a --- /dev/null +++ b/addons/education_lms/views/education_lms_content_views.xml @@ -0,0 +1,114 @@ + + + + + + education.lms.section.form + education.lms.section + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + education.lms.lesson.form + education.lms.lesson + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + education.lms.material.form + education.lms.material + +
+ +
+
+ + + + + + + + + + + + + + + + + + +
+
+
+
+ +
diff --git a/addons/education_lms/views/education_lms_course_views.xml b/addons/education_lms/views/education_lms_course_views.xml new file mode 100644 index 0000000..bab2fcf --- /dev/null +++ b/addons/education_lms/views/education_lms_course_views.xml @@ -0,0 +1,120 @@ + + + + + + education.lms.course.form + education.lms.course + +
+
+
+ +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + education.lms.course.tree + education.lms.course + + + + + + + + + + + + + + education.lms.course.search + education.lms.course + + + + + + + + + + + + + + + + + + + + Courses + education.lms.course + tree,form + +

+ Create your first LMS Course! +

+
+
+ +
diff --git a/addons/education_lms/views/education_lms_submission_views.xml b/addons/education_lms/views/education_lms_submission_views.xml new file mode 100644 index 0000000..cdaf769 --- /dev/null +++ b/addons/education_lms/views/education_lms_submission_views.xml @@ -0,0 +1,92 @@ + + + + + + education.lms.submission.form + education.lms.submission + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + education.lms.submission.tree + education.lms.submission + + + + + + + + + + + + + + education.lms.submission.search + education.lms.submission + + + + + + + + + + + + + + + + + + + Submissions + education.lms.submission + tree,form + +

+ Manage Student Submissions! +

+
+
+ +
diff --git a/addons/education_lms/views/menu_views.xml b/addons/education_lms/views/menu_views.xml new file mode 100644 index 0000000..29bf1cd --- /dev/null +++ b/addons/education_lms/views/menu_views.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + diff --git a/addons/education_lms/views/portal_lms_templates.xml b/addons/education_lms/views/portal_lms_templates.xml new file mode 100644 index 0000000..e84d288 --- /dev/null +++ b/addons/education_lms/views/portal_lms_templates.xml @@ -0,0 +1,736 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/education_portal/__pycache__/__manifest__.cpython-310.pyc b/addons/education_portal/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index 82bf573412fc4e2f587c4df7c928c5120f0db006..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1022 zcmZWoO>Yx15Z$z4(!}Y;W4^-{8ur zH~tPs-w?k7PRwpT8o-twdt|()6 zwxJ0RCK1GU4cc3G4cMW%3vK9p?KUFlj-uXP`RE)9!M!w^4UP3&mF?r#$x(7l>nQB5 z(Xv1C1)%n9glgO$p`A&AOCL5imaVDm{Ns@OMijN#a;#hzH!xS^Io&1od>08RLBry6@@()Mtd$ilbSNg>T-G;{1d?eGbtFL=TbQa z(&dWJHz*f~>=I#u_K=yvJI^&#p`k!`$jD&BvkPWXpv*l3bEB2v@K;h(PP8_K7FfX)^U8SY;4=8Bw4zYlZU!8oDD1k$r&OXfN}+XiIyEZ&oJnU?U2$Da z`E-O@k{lzWidxnaE2h$;@V#buh%2?zYwj>SzPH^%-p!-2)SU7Saitbly=M8$RXNeE zgL}sXn68lIHdE!n${I+UWCl7tA;KMXJXuX^3k)I7t`no&dO^@bgK#W*o`9$JehGY=g=CoJhz;W01#)Y#Ah%u$02 zP2iq~j?|>JQv6zB=6uzTx61jcawi@f%jvH~j6))`ek3SXUOo9La diff --git a/addons/education_portal/models/__pycache__/payment_transaction.cpython-310.pyc b/addons/education_portal/models/__pycache__/payment_transaction.cpython-310.pyc index 2c57d36f0d417e10961600f111367f06a501f28e..ccc59b8d102b5287d980a95d91aa77f89b0ead80 100644 GIT binary patch delta 14 VcmZ3WvOr}+I49$;%@Ld@H~=bI1$Y1e delta 14 VcmZ3WvOr}+I49$+%@Ld@H~=Y{1y}$8 diff --git a/addons/education_portal/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_portal/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index e94c705ae75443cf7ef8036e05af5f2943740192..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224 zcmd1j<>g`kf*Gf#X4wMi#~=+?L4Hw5Vh)fi0^ufB=BDPA#HS~gq?RXE`e`!XVkiO`Uc?L} zRx%W^g4iJ9mx+FEUWtBcMM+VjZemJGeqOPDYD#HxVo7Fx9@JQUkeS8$@$s2?nI-Y@ bdIgoYIBatBQ%ZAE?Le+5W&sjB3<8V*|7$m* diff --git a/addons/education_portal/tests/__pycache__/test_payment_gateway.cpython-310.pyc b/addons/education_portal/tests/__pycache__/test_payment_gateway.cpython-310.pyc deleted file mode 100644 index cc1dd1ac7e7d4d2e20d9b071779dde4ca4558bd1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8096 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 C?;mUc diff --git a/addons/education_portal/tests/__pycache__/test_portal.cpython-310.pyc b/addons/education_portal/tests/__pycache__/test_portal.cpython-310.pyc deleted file mode 100644 index db983f7c6abd71ede684a2ded8b071b015bbad2c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7937 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 QTxUs5II{yMjYaeS0F?BokpKVy diff --git a/addons/education_portal/views/portal_templates.xml b/addons/education_portal/views/portal_templates.xml index 4b877ea..0a6aa6a 100644 --- a/addons/education_portal/views/portal_templates.xml +++ b/addons/education_portal/views/portal_templates.xml @@ -881,6 +881,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -1725,6 +1733,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -2421,6 +2437,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -3323,6 +3347,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -4036,6 +4068,14 @@ Receipts + + + Exams + + + + Results + Notices @@ -4189,6 +4229,12 @@ + + +
+ © 2026 My Company. All rights reserved. + Powered by odoo +
diff --git a/addons/education_reports/__pycache__/__manifest__.cpython-310.pyc b/addons/education_reports/__pycache__/__manifest__.cpython-310.pyc deleted file mode 100644 index 16a53f27f9c6dad7d7499b6ab8f42517a6135240..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1162 zcmY*Y&2AGh5Z;tDN%MnL#Dzb}HwbEzmL8A-LX@OINP$KK4xCo*da^O{+AG`JCi@Uv zxN_=^*WfYsf&_2DiP@iQQd?{7%y_;Te>0;xou-59_p|R`zf~RQZz^oB4hpaFGf(kg z4y&*oc8gWnuJ5qgbp_A*b%ix>@3AIp{b*Ml)}A@tPP~7{B9B_GB>4!T($>Vy^}Po|?Wp^}lar3a!*=1P;&k(_;;(q9$FX_8_M zl{89lMs114rB6WmTteo2&Xn393`);KGP4l_X$R!phzv2dIUsM8q;t-I3_)8=5uXF7 zo@zDc0?2FuU{NkS$%Gqgwh&J}${^t0fP4X}@g4+VEZ`p^_?iX>+0#Z4Qf&i_O=eLL zP`x5LCu|$bT9AxN4+iUa2jsnyb|F?d%o?2tb?Hr;A+8`iDlUQh$c{zfj~w^j4}!O7jtEn2eI z-Hjg_@FLCas?T*VPz*$mdL9_l^UP)Zpy0k=AiIU!TLxldEMnX+K3;onqPetafg2Ye zU2?cG{rtLn#bfL?H`Ap-K8@qrX%414pP!cJJ45pmDF>JS4oS3 z@idk+wby857fL5}1|hbKNvb#sG-+{(r51u-2uNBq3`N??sY4qblv|jSI_g0h`0&%w zcW~Y}$G?+)AZ;I(R?|bu7|vOLL+|?Gt{YGsWo&ZS?S-qP@e=8c1iZo-bwPCb{KfBpkWaDtZr diff --git a/addons/education_reports/tests/__pycache__/__init__.cpython-310.pyc b/addons/education_reports/tests/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index d659f6e59807a7c25c5ee4f5c615f56f04a48846..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 183 zcmd1j<>g`k0&(NXS<*oIF^Gc(44TX@8G*u@ zjJJ47Qj1ICi&6{ni%N?9G?{KO6oGWEWGG?*QefhjseW!=iGFHDNl~J1VoFMWUa@{^ wN@;RpNoIZ?OshV~oMQd>_{_Y_lK6PNg34PQHo5sJr8%i~AghW&rV20s0DR9YMF0Q* diff --git a/addons/education_reports/tests/__pycache__/test_reports.cpython-310.pyc b/addons/education_reports/tests/__pycache__/test_reports.cpython-310.pyc deleted file mode 100644 index 7377f337da69ed25e73918f8a2b075ae23c4d91e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3510 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 diff --git a/addons/education_timetable/__init__.py b/addons/education_timetable/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/education_timetable/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/education_timetable/__manifest__.py b/addons/education_timetable/__manifest__.py new file mode 100644 index 0000000..c0bd992 --- /dev/null +++ b/addons/education_timetable/__manifest__.py @@ -0,0 +1,30 @@ +{ + 'name': 'Education Timetable', + 'version': '17.0.1.0.0', + 'summary': 'Academic Timetable Management', + 'description': ''' + Native Timetable module integrated with the education base architecture. + Provides robust server-side validation for room, faculty, and student population conflicts. + ''', + 'category': 'Education', + 'author': 'Metatron', + 'license': 'LGPL-3', + 'depends': [ + 'base', + 'education_base', + 'education_student', + 'education_exam', + 'education_portal' + ], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'views/education_room_views.xml', + 'views/education_timetable_slot_views.xml', + 'views/menu_views.xml', + 'views/portal_timetable_templates.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, +} diff --git a/addons/education_timetable/__pycache__/__init__.cpython-310.pyc b/addons/education_timetable/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..412592318cc1e041ce00665afa6b69ba6a0ff03d GIT binary patch literal 207 zcmYj~JqiLb5QQh%RYYCz0A64V4fZ0Qz{YNy5KW3mek7R%J(BE=wzcvKR!%w}%$x7Q zFfTUE97uc^Zi4o^gnw}gN1D7Q)1a9H&pO5mb7J0c;_3`-{b6*{vT^F8HZBt_t}$SW z&Y6@?Z8btF?@~qff;1^y?7I^_pfC?iYNND{Aq%7Xt3n%tMC%Qzi!(w>8!Sq>y+6m< Qo?3cm4y82LWJ_4FA0Hks!vFvP literal 0 HcmV?d00001 diff --git a/addons/education_timetable/controllers/__init__.py b/addons/education_timetable/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/education_timetable/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/education_timetable/controllers/__pycache__/__init__.cpython-310.pyc b/addons/education_timetable/controllers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2ec9a71e7883abc0323a3bd7e67cfd46f2d58df7 GIT binary patch literal 183 zcmd1j<>g`kf@-amS;9d2F^Gc(44TX@8G*u@ zjJH^F6EpMtG?{KO6oGWCWGG?*Qefhjt$uD^iGFHDNl~J1VoFMWUa@{^N@;RpNoIat zd`V_*YDr>JPO5%#eqKpYeojtmQL%n}d}dx|NqoFsLFFwDo80`A(wtN~kX6MX(*+m+ DbI>bM literal 0 HcmV?d00001 diff --git a/addons/education_timetable/controllers/__pycache__/main.cpython-310.pyc b/addons/education_timetable/controllers/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed9e8875837f901eb3aeb2eb1edcf314dcc8f14a GIT binary patch literal 2611 zcmZ`5O>Z1UwyOGbdV0q8#P|aa+bl%Nu<R9qv9M+!gL) zYv^%$kMzA~z=hoDQ*9qAHHF@j;^9nG3ee4aYQ{yO?v_#|*#~8zWSM0`j&RS5p9_Qc zpjFoZ2r{Ic3>l}KJt0F&*b|3aW6Eum&%MuW?(^0Y@|X@?9)J#Qy}g&_LM8i|cu5ok zj%Oc=aiDzwtx^CKsp(_7V2UVO)0%C8JJx|Q(5%|x^Z}DwoB__b^$uAu=+imOExL8P|Lg`RE-8R#25cb~0?_Q%ZO!i9 z*4DK@^XK&4=QQdwZKZ|Mjx1*d@4MQrM0TJ9BR595+Bqy|veI6j7BeL(?Id|T&2@*T z)ig_vV>A5?k((gl`SsnrP`lzYC6k?m^RlRR1)q%)m6nBJXJW8BI*)X%n1 z+}IRf$Zi44Y6F1J)~QczXuh?|00O?n=2w3=s)LuA%{#mK@h+NVbdVT#ZO22$vMMw&dHjUEKuEK8N&5Nt4}A?NfG-aF6@7 zbzt!p^fvUt*jjjsPFlRZO-=&P%dZ{zY3=clcX$_8IS1AlQ`9RQpmxR-@!FN8S+l}d zVU7E%H7mzMrCx>pmB|LcO#`Ja?8_0(FdEj_)4{_StynQvmcoG0%tPpZTHGLihl`PFmkMM@8*<_e3e=0dxe z(5>^7oB#8EIg9dHrJ{WiB~_J<3pl`Vnv){BHA=Y1(@}I^BoYWbx+}}EOmdt*O2wn- z7|x-&3QYbE`nL9#(sXAG=XWZ2TxF%IbZeT7MO-9!dWDpw)WNi27N>CV|E5=yrL_1M zqyZ65fSZg|3YX5Ckl=gst#ca(=lTzj`*T{uVowQ~!#=b6LwN=E0M*}5)adYn+y}C@ zd7{MJzkGk^a=vq!&$n<4){m~j)s;wPT!mw(2Tm!ukn{GfEQ{VpUZR?}aIgpBs=lvT z%5`wDP5YwYh5`6uwm%UgrM-h>G|SYnwq;r7+A%u8`34SsOa1{=k`aQ}0rX$hVLTS< zENs#OqV}cWsRUl~4P1B?!DXDdaNC*r#zI$u7D8%E6i0A@&UmRUV5)tz6uqZ?xTL3u z$+wVC@JW)*#6L`A+zKKvI+WWeWe33km$=Dv3S7b1#w>8EwoOEXrF}^8&_wjN2|!P- z;a2wGQDY`*uf1i2%@Wz@%S*g;fj7i6&Py&b-9AV&Fe*lsb$gn~3bM%z9FtSVXJ8!c zXzYs1{fS;VFGJu#h^6ja5Kw#f2o0@FZD;@%Bfa+0DR~XZ)f9lwY#Pv>)uZ_P%w>!= zzyG5j`heR%b$#2~paE-OVA(+JL4^pRDzKlsmTmde#acl_D+C@m&wzseD*V9@EHsp# zLTCP`y|aq&|1X2VOIqIPd)kl9!HQ!Y#OS+OCIAoP_~9(cFhFtK#M0CktgGPGyI|Q1 zPQX$r(GwCM8y>XE2jGMCykRAP(nR|=8e?u^(W~%<=#vm!8nSiv3mnVbO#lD@ literal 0 HcmV?d00001 diff --git a/addons/education_timetable/controllers/main.py b/addons/education_timetable/controllers/main.py new file mode 100644 index 0000000..a75ba94 --- /dev/null +++ b/addons/education_timetable/controllers/main.py @@ -0,0 +1,113 @@ +import datetime +from odoo import http +from odoo.http import request +# Inherit existing StudentPortalController to reuse _get_student() +from odoo.addons.education_portal.controllers.main import StudentPortalController + +class TimetablePortalController(StudentPortalController): + + def _format_float_time(self, float_time): + """Convert float time (e.g. 9.5) to formatted string (09:30 AM)""" + hours = int(float_time) + minutes = int(round((float_time - hours) * 60)) + if minutes == 60: + minutes = 0 + hours += 1 + + # Handle 24-hour wrap around + hours = hours % 24 + + am_pm = 'AM' if hours < 12 else 'PM' + display_hours = hours % 12 + if display_hours == 0: + display_hours = 12 + + return f"{display_hours:02d}:{minutes:02d} {am_pm}" + + @http.route(['/my/timetable'], type='http', auth="user", website=True) + def student_timetable(self, **kw): + student = self._get_student() + if not student: + return request.redirect('/my/education/dashboard') + + # Determine eligibility exactly mapping to student's current academic placement + program_id = student.program_id.id if student.program_id else False + year_id = student.academic_year_id.id if student.academic_year_id else False + term_id = student.academic_term_id.id if student.academic_term_id else False + batch_id = student.batch_id.id if student.batch_id else False + + if not program_id or not year_id: + # If a student isn't actively placed in a program/year, they have no timetable. + return request.render("education_timetable.portal_student_timetable", { + 'student': student, + 'grouped_slots': {}, + 'page_name': 'timetable', + 'error': 'You must be assigned to an Academic Year and Program to view your timetable.' + }) + + # Build Domain: The portal query must match the student's actual academic placement. + # Respecting optional-field logic (established in Phase C): + # - term_id = False means it applies to all terms. + # - batch_id = False means it applies to all batches. + domain = [ + ('program_id', '=', program_id), + ('academic_year_id', '=', year_id), + ('active', '=', True) + ] + + # We must use sudo() since portal users don't have backend employee groups. + # The domain rigorously isolates records to ONLY those belonging to the student's exact academic profile. + slot_model = request.env['education.timetable.slot'].sudo() + + # Handle nullable term_id + if term_id: + domain += ['|', ('term_id', '=', False), ('term_id', '=', term_id)] + else: + domain += [('term_id', '=', False)] + + # Handle nullable batch_id + if batch_id: + domain += ['|', ('batch_id', '=', False), ('batch_id', '=', batch_id)] + else: + domain += [('batch_id', '=', False)] + + # Optional: Filter by date if provided in query params + filter_date = kw.get('date') + if filter_date: + try: + # Ensure it's a valid date string + parsed_date = datetime.datetime.strptime(filter_date, '%Y-%m-%d').date() + domain += [('date', '=', parsed_date)] + except ValueError: + pass + + slots = slot_model.search(domain, order="date asc, start_time asc") + + # Group records by Date for clean UI display + grouped_slots = {} + for slot in slots: + date_obj = slot.date + if date_obj not in grouped_slots: + grouped_slots[date_obj] = [] + + # Format time natively in Python so templates stay clean + formatted_start = self._format_float_time(slot.start_time) + formatted_end = self._format_float_time(slot.end_time) + + grouped_slots[date_obj].append({ + 'id': slot.id, + 'start_time': formatted_start, + 'end_time': formatted_end, + 'subject': slot.subject_id.name, + 'faculty': slot.faculty_id.name, + 'room': slot.room_id.name, + 'batch': slot.batch_id.name if slot.batch_id else 'All Batches', + 'term': slot.term_id.name if slot.term_id else 'All Terms', + }) + + values = { + 'student': student, + 'grouped_slots': grouped_slots, + 'page_name': 'timetable', + } + return request.render("education_timetable.portal_student_timetable", values) diff --git a/addons/education_timetable/models/__init__.py b/addons/education_timetable/models/__init__.py new file mode 100644 index 0000000..41371ec --- /dev/null +++ b/addons/education_timetable/models/__init__.py @@ -0,0 +1,2 @@ +from . import education_room +from . import education_timetable_slot diff --git a/addons/education_timetable/models/__pycache__/__init__.cpython-310.pyc b/addons/education_timetable/models/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..604decdab4c8ebf60f9e322986dacc8653b2656c GIT binary patch literal 235 zcmd1j<>g`kf)Lr2SuQ~OF^GcG8D0b*dXGUg??^ciGFHDNl~J1VoFMWUa>wFWAtz>aT@XToMS_Y$E|xdrE?FhvvIEsy7$nas>*J2DIh1jqcv?eeol$ZrGohlAi8SN#eF5TFGqX-Z2g zwJ3Q{zykXh0b9_e{nJhzaK4h*y~9`%TiPonh@#fsRW1ssGh^N#E`oDhb%jEZlmbaD zps5WOrm|C4cuOA~aKYPJY0x1bkqg1rP7e?d-~a|NMDMWkjv(C9bcA|{QDfA{s2{>% zXP>}iLDM5R!X_W-@Ex=nZ*p0Ek+R&3#eaCJ41RL&j*LI~CaP9wr{X2b8Olq;ux?ad zEp(v7y3LgU?M#t#Y%3orsQ`s`^9sbZ4yJs?v%I;vdx_TA>LRoc;)=ILvw3{c<^^D` zD2YDGzOR^eLAZq`(eSOPGnKDQ%dxG88CGI#m9JZ|8TI>XV~aP>O~;YZqq41=XfC4O ze>_9t0b_;*V|u_CUQt^ZeZ<(h<;Bk9b|Gklhnm`BQh`v)Hr+VW?_4Q!h1xNdwEv-M z#6lgqPyF(Zl`p6IQPamdOeamFHDc-x3vJ{!aWk zOUkNA@FT@fH~>Z^_cz9xyc7+e7b5AO+@8x^=IQE2nU`!Dub-g^h(paBTN65_qi$Mu t@VASa#jbWFNU89TAU#|66H+WX_d`#;c8ozfc+(%`88szLtqFN*{R5*s@T&j- literal 0 HcmV?d00001 diff --git a/addons/education_timetable/models/__pycache__/education_timetable_slot.cpython-310.pyc b/addons/education_timetable/models/__pycache__/education_timetable_slot.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5255bd029befeef1b6c080fb2cb5fb47a1bbbb84 GIT binary patch literal 2733 zcma(TU60d7(Ce=_xqRIB7YCTnHZ9FbsH#*1hyq932TrJWLT#kV@_9CQ4vrnx4&tnw zDivPuwJ%jA=tEV0QKfzD6Mx}amCkHFL8z*u9nZ|p%+ActjFVI>CIWB=3se9%`0kUNG%{mTC2ybxrF%gz6NW*GFXRGuz_B#@YNS< za2n3M)Zr|+&$VZ)xvqHUhzsZ80$hYka2Y;$S%eRvt|U&uN0`!&#BXGyjYdj4)v=(fB$qJd+y$NnSKKu7Mdp!0J~uJY70JHvJF@U#fb7!y4nk}MC8?pQIRB_ zy!;J96nV(w9+&1_OhjJVp&YoBq}(1>h|oP1X&J-U8vbGHKnV7t+NbiCdxQ zaFE42BUJJIIN(vF_+!+@BeGs9Y8Yr;SMiD};<7Z!zjSyE>Lvm#_VJ@5X5hMmD?N{( zuie$YFCVc!YiqdJBTG~>Y>H>>NA_3;4U0tI&aV!FqKkDqy)<+8INr4ggDg69@lv9Y z3pYFPV;4_#Nvu&f#4_3!D+DYA7>=3ps3i@-gIAhZN8ZU-o1Hk@fe|c<@LmxoA42f#m3A<}m$@`6fTVH7aPjnsK2wrqrZK zr7TOW!QFcy$O7FnA*Epv6FxhtPmk(TiBSO(;;3Rn?5(U9&=p!L>ksihRMvkxV!%MB z%(o1hc$9y{aQq;qBUS(hF{5Sdn@4(IYny!?hY@L5B)QL4G{A9mtRESDLwuguP*!_| zpEal;rs2E#i$!-i)UGLQ=N)acZ;EeGdlq$^Hfhhjqpjm0qjQVO!v%$xlofqNJF;+R zM*PsXUX)?+C1aYxmd4n!lAj@Y#bfP?_~{+#m9g}ylCF%UDQ;^Rr`@;3@962;*gs>c zQrEY|X-xA8yCL-ua{b&5Rf8w&x+=pHc2k_e{nykH*pgNhcET*5AN4vlVT3h(&CMIG z9|`Wmp?lz`uI~nspQdi|gp0`Uc5!fFwTG#ybT^4z$r|D;8qI5JBD`e?#YVRFlH?Hw zx0MJNTjecl)LCdqxM)S3LvX_S*h18z;v#~33PsL^35U>pe<$k!k2Ci{((OfRlDm`h zcgf4N%Y(2L2JWbC-F=btx?{KgSBUkh%B{FW=c}fs_QpAmEf5^2ys9>rX6$#k%ElxZ z<-D{pbvPeX2=#(kB};2$WCoIsAI9FEpK>`nYQIqwvZ9)BOwM)3k$M9~xiG~V$*BP| z0H^4X>af#?X}^rT^cn(3FX<&l%lzw1(PSI!j6qPh%dE`oe{36f*&?%z!Mm!PY@TMy zANi&Ko+-nVw(A?x@jOb4=gE@i;WX<-gqJ<hxkDj zKH;*s!z276(zzCXN^q#r|2A2|l;X6<8%o6nnfsIg&F?8dITkhoQc15Ma5R(AZ`PP) zRs7K#F@x|jrQ5XsE@5(4ttwNR+jU2p2_%W2&y)&z;6RO-4gNIXDkE<`drGb?YGt;p{|h-e^xOad literal 0 HcmV?d00001 diff --git a/addons/education_timetable/models/education_room.py b/addons/education_timetable/models/education_room.py new file mode 100644 index 0000000..15f36be --- /dev/null +++ b/addons/education_timetable/models/education_room.py @@ -0,0 +1,17 @@ +from odoo import models, fields + +class EducationRoom(models.Model): + _name = 'education.room' + _description = 'Education Room/Venue' + _order = 'name' + + name = fields.Char(string='Name', required=True) + code = fields.Char(string='Code', required=True, index=True) + capacity = fields.Integer(string='Capacity', default=30) + building = fields.Char(string='Building / Location') + active = fields.Boolean(default=True) + description = fields.Text(string='Description') + + _sql_constraints = [ + ('code_unique', 'unique(code)', 'Room code must be unique!') + ] diff --git a/addons/education_timetable/models/education_timetable_slot.py b/addons/education_timetable/models/education_timetable_slot.py new file mode 100644 index 0000000..ecd1141 --- /dev/null +++ b/addons/education_timetable/models/education_timetable_slot.py @@ -0,0 +1,82 @@ +from odoo import models, fields, api +from odoo.exceptions import ValidationError + +class EducationTimetableSlot(models.Model): + _name = 'education.timetable.slot' + _description = 'Timetable Slot' + _order = 'date desc, start_time' + + # Academic References (Student Population) + program_id = fields.Many2one('education.program', string='Program', required=True) + academic_year_id = fields.Many2one('education.academic.year', string='Academic Year', required=True) + term_id = fields.Many2one('education.academic.term', string='Term') + batch_id = fields.Many2one('education.batch', string='Batch') + + # Slot Details + subject_id = fields.Many2one('education.subject', string='Subject', required=True) + faculty_id = fields.Many2one('res.users', string='Faculty / Instructor', required=True) + room_id = fields.Many2one('education.room', string='Room / Venue', required=True) + + # Schedule + date = fields.Date(string='Date', required=True) + start_time = fields.Float(string='Start Time', required=True) + end_time = fields.Float(string='End Time', required=True) + + active = fields.Boolean(default=True) + state = fields.Selection([ + ('draft', 'Draft'), + ('confirmed', 'Confirmed'), + ('cancelled', 'Cancelled') + ], string='Status', default='draft') + + @api.constrains('start_time', 'end_time') + def _check_time(self): + for rec in self: + if rec.start_time >= rec.end_time: + raise ValidationError("Start time must be strictly earlier than end time.") + + @api.constrains('date', 'start_time', 'end_time', 'faculty_id', 'room_id', 'program_id', 'academic_year_id', 'term_id', 'batch_id') + def _check_overlap(self): + for rec in self: + domain_base = [ + ('date', '=', rec.date), + ('id', '!=', rec.id), + # Interval logic: existing_start < new_end AND existing_end > new_start + ('start_time', '<', rec.end_time), + ('end_time', '>', rec.start_time), + ] + + # 1. Check Faculty Conflict + faculty_conflict = self.search(domain_base + [('faculty_id', '=', rec.faculty_id.id)], limit=1) + if faculty_conflict: + raise ValidationError(f"Faculty conflict: {rec.faculty_id.name} already has a class overlapping this time on {rec.date}.") + + # 2. Check Room Conflict + room_conflict = self.search(domain_base + [('room_id', '=', rec.room_id.id)], limit=1) + if room_conflict: + raise ValidationError(f"Room conflict: {rec.room_id.name} is already booked for an overlapping time on {rec.date}.") + + # 3. Check Student Population Conflict + # Population is defined by Program + Academic Year + Term + Batch + # If a slot has NO term, it applies to the whole year, so it should conflict with ANY term in that year. + # So if either the new slot or the existing slot has no term (or they match), it's a conflict. + population_domain = domain_base + [ + ('program_id', '=', rec.program_id.id), + ('academic_year_id', '=', rec.academic_year_id.id), + ] + + # OR logic for term_id: either matches exactly, or one of them is False + if rec.term_id: + population_domain.append('|') + population_domain.append(('term_id', '=', False)) + population_domain.append(('term_id', '=', rec.term_id.id)) + + # OR logic for batch_id: either matches exactly, or one of them is False + if rec.batch_id: + population_domain.append('|') + population_domain.append(('batch_id', '=', False)) + population_domain.append(('batch_id', '=', rec.batch_id.id)) + + population_conflict = self.search(population_domain, limit=1) + if population_conflict: + raise ValidationError(f"Student Population conflict: This specific academic group already has a class overlapping this time on {rec.date}.") diff --git a/addons/education_timetable/security/ir.model.access.csv b/addons/education_timetable/security/ir.model.access.csv new file mode 100644 index 0000000..3295554 --- /dev/null +++ b/addons/education_timetable/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_education_room_admin,education.room.admin,model_education_room,group_education_timetable_admin,1,1,1,1 +access_education_room_faculty,education.room.faculty,model_education_room,group_education_timetable_faculty,1,0,0,0 +access_education_room_student,education.room.student,model_education_room,group_education_timetable_student,1,0,0,0 + +access_education_timetable_slot_admin,education.timetable.slot.admin,model_education_timetable_slot,group_education_timetable_admin,1,1,1,1 +access_education_timetable_slot_faculty,education.timetable.slot.faculty,model_education_timetable_slot,group_education_timetable_faculty,1,0,0,0 +access_education_timetable_slot_student,education.timetable.slot.student,model_education_timetable_slot,group_education_timetable_student,1,0,0,0 diff --git a/addons/education_timetable/security/security.xml b/addons/education_timetable/security/security.xml new file mode 100644 index 0000000..1b69afc --- /dev/null +++ b/addons/education_timetable/security/security.xml @@ -0,0 +1,21 @@ + + + + + Student / Portal + + + + + Faculty + + + + + + Timetable Administrator + + + + + diff --git a/addons/education_timetable/views/education_room_views.xml b/addons/education_timetable/views/education_room_views.xml new file mode 100644 index 0000000..c34b476 --- /dev/null +++ b/addons/education_timetable/views/education_room_views.xml @@ -0,0 +1,52 @@ + + + + education.room.tree + education.room + + + + + + + + + + + + education.room.form + education.room + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + +
+
+
+
+ + + Rooms / Venues + education.room + tree,form + +
diff --git a/addons/education_timetable/views/education_timetable_slot_views.xml b/addons/education_timetable/views/education_timetable_slot_views.xml new file mode 100644 index 0000000..b386168 --- /dev/null +++ b/addons/education_timetable/views/education_timetable_slot_views.xml @@ -0,0 +1,94 @@ + + + + education.timetable.slot.tree + education.timetable.slot + + + + + + + + + + + + + + + + education.timetable.slot.form + education.timetable.slot + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + education.timetable.slot.search + education.timetable.slot + + + + + + + + + + + + + + + + + + + + + + + education.timetable.slot.calendar + education.timetable.slot + + + + + + + + + + + + Timetable Slots + education.timetable.slot + tree,calendar,form + + +
diff --git a/addons/education_timetable/views/menu_views.xml b/addons/education_timetable/views/menu_views.xml new file mode 100644 index 0000000..1ec25d3 --- /dev/null +++ b/addons/education_timetable/views/menu_views.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/addons/education_timetable/views/portal_timetable_templates.xml b/addons/education_timetable/views/portal_timetable_templates.xml new file mode 100644 index 0000000..315c823 --- /dev/null +++ b/addons/education_timetable/views/portal_timetable_templates.xml @@ -0,0 +1,411 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/append_tests 2.py b/append_tests 2.py new file mode 100644 index 0000000..65c5e33 --- /dev/null +++ b/append_tests 2.py @@ -0,0 +1,259 @@ +import os + +with open('addons/education_lms/tests/test_education_lms.py', 'a') as f: + f.write(''' + # ========================================== + # PHASE H SECURITY TESTS + # ========================================== + + # 37. Faculty A cannot read/modify Faculty B courses/content + def test_37_faculty_isolation(self): + # Faculty B course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'title': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + }) + # Faculty A tries to read -> AccessError + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).read(['title']) + + # Faculty A tries to modify + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).write({'title': 'Hacked'}) + + # Faculty A tries to delete + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).unlink() + + # Faculty A tries to create section under Course B -> AccessError + with self.assertRaises(AccessError): + self.env['education.lms.section'].with_user(self.user_faculty_a).create({ + 'title': 'Sec B', + 'course_id': course_b.id + }) + + # 38. Student Isolation (cannot read/modify other student submission) + def test_38_student_isolation(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + # Student B tries to read + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).read(['text_submission']) + + # Student B tries to write + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).write({'text_submission': 'Hacked'}) + + # Student B tries to create submission for Student A + with self.assertRaises(AccessError): + self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student B Work', + }) + + # 39. Course Eligibility Security + def test_39_course_eligibility(self): + # Course A uses Program, Year, Term A, Batch A. Student A matches all. + # Course B uses no Term -> Student A matches. + course_b = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'title': 'Course No Term', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'state': 'published' + }) + # Student A can read course_b + res = course_b.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + # Course C uses Term B -> Student A does not match. + term_b = self.env['education.academic.term'].create({ + 'name': 'Term B', + 'code': 'TB', + 'academic_year_id': self.academic_year.id, + }) + course_c = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'title': 'Course Diff Term', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'term_id': term_b.id, + 'state': 'published' + }) + # Wait, the rule should block Student A + with self.assertRaises(AccessError): + course_c.with_user(self.user_student_a).read(['title']) + + # 40. Assignment access and states + def test_40_assignment_access(self): + # Draft assignment -> Student A cannot read + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Draft', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'draft' + }) + with self.assertRaises(AccessError): + assignment.with_user(self.user_student_a).read(['title']) + + # Published -> Student A can read + assignment.with_user(self.user_faculty_a).action_publish() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + # Closed -> Student A can read, but cannot submit + assignment.with_user(self.user_faculty_a).action_close() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # 41. Submission edge cases + def test_41_submission_security_edge_cases(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H2', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # change student_id -> AccessError/ValidationError? + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'student_id': self.student_b.id}) + + # change assignment_id + assignment_2 = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H3', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + with self.assertRaises(AccessError): + # student a might not have access to change assignment if it violates unique rule or is blocked. + # actually students can edit assignment_id if in draft, but let's test. + # In Phase F we blocked changing assignment if submission is not draft. But it is draft here. + # wait, they can change it if it's draft and they own the new assignment's course. + # But the prompt says "assignment_id changed after submission -> REJECT". + pass + + submission.with_user(self.user_student_a).action_submit() + + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'assignment_id': assignment_2.id}) + + # change marks + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'marks': 100}) + + # change feedback + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'feedback': 'Good job me'}) + + # 42. Attachment Security Direct Test + import base64 + def test_42_attachment_security(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H Attachments', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + + # Student A submission + sub_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'A', + }) + attach_a = self.env['ir.attachment'].with_user(self.user_student_a).create({ + 'name': 'file_a.txt', + 'datas': base64.b64encode(b'student A file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_a.id, + }) + + sub_b = self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_b.id, + 'text_submission': 'B', + }) + attach_b = self.env['ir.attachment'].with_user(self.user_student_b).create({ + 'name': 'file_b.txt', + 'datas': base64.b64encode(b'student B file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_b.id, + }) + + # Verify A can read attach A + self.assertTrue(attach_a.with_user(self.user_student_a).read(['name'])) + + # Verify A cannot read attach B + with self.assertRaises(AccessError): + attach_b.with_user(self.user_student_a).read(['name']) + + # Verify Faculty A can read both + self.assertTrue(attach_a.with_user(self.user_faculty_a).read(['name'])) + self.assertTrue(attach_b.with_user(self.user_faculty_a).read(['name'])) + + # Verify Faculty B cannot read either + with self.assertRaises(AccessError): + attach_a.with_user(self.user_faculty_b).read(['name']) + + # 43. Admin override + def test_43_admin_override(self): + user_admin = self.env['res.users'].create({ + 'name': 'Admin User 2', + 'login': 'lms_admin2', + 'groups_id': [(4, self.group_admin.id)] + }) + # Admin can read faculty B's course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'title': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + }) + res = course_b.with_user(user_admin).read(['title']) + self.assertTrue(len(res) > 0) + + course_b.with_user(user_admin).write({'title': 'Admin changed'}) + self.assertEqual(course_b.title, 'Admin changed') + + # 44. Portal User Mapping + def test_44_portal_user_mapping(self): + # A user without student_id should not see Student A's courses/submissions in LMS Context + user_no_student = self.env['res.users'].create({ + 'name': 'No Student', + 'login': 'no_student', + 'groups_id': [(4, self.group_student.id)] + }) + self.course_faculty_a.state = 'published' + with self.assertRaises(AccessError): + self.course_faculty_a.with_user(user_no_student).read(['title']) +''') diff --git a/append_tests.py b/append_tests.py new file mode 100644 index 0000000..65c5e33 --- /dev/null +++ b/append_tests.py @@ -0,0 +1,259 @@ +import os + +with open('addons/education_lms/tests/test_education_lms.py', 'a') as f: + f.write(''' + # ========================================== + # PHASE H SECURITY TESTS + # ========================================== + + # 37. Faculty A cannot read/modify Faculty B courses/content + def test_37_faculty_isolation(self): + # Faculty B course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'title': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + }) + # Faculty A tries to read -> AccessError + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).read(['title']) + + # Faculty A tries to modify + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).write({'title': 'Hacked'}) + + # Faculty A tries to delete + with self.assertRaises(AccessError): + course_b.with_user(self.user_faculty_a).unlink() + + # Faculty A tries to create section under Course B -> AccessError + with self.assertRaises(AccessError): + self.env['education.lms.section'].with_user(self.user_faculty_a).create({ + 'title': 'Sec B', + 'course_id': course_b.id + }) + + # 38. Student Isolation (cannot read/modify other student submission) + def test_38_student_isolation(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + # Student B tries to read + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).read(['text_submission']) + + # Student B tries to write + with self.assertRaises(AccessError): + submission_a.with_user(self.user_student_b).write({'text_submission': 'Hacked'}) + + # Student B tries to create submission for Student A + with self.assertRaises(AccessError): + self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student B Work', + }) + + # 39. Course Eligibility Security + def test_39_course_eligibility(self): + # Course A uses Program, Year, Term A, Batch A. Student A matches all. + # Course B uses no Term -> Student A matches. + course_b = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'title': 'Course No Term', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'state': 'published' + }) + # Student A can read course_b + res = course_b.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + # Course C uses Term B -> Student A does not match. + term_b = self.env['education.academic.term'].create({ + 'name': 'Term B', + 'code': 'TB', + 'academic_year_id': self.academic_year.id, + }) + course_c = self.env['education.lms.course'].with_user(self.user_faculty_a).create({ + 'title': 'Course Diff Term', + 'faculty_id': self.user_faculty_a.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + 'term_id': term_b.id, + 'state': 'published' + }) + # Wait, the rule should block Student A + with self.assertRaises(AccessError): + course_c.with_user(self.user_student_a).read(['title']) + + # 40. Assignment access and states + def test_40_assignment_access(self): + # Draft assignment -> Student A cannot read + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign Draft', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'draft' + }) + with self.assertRaises(AccessError): + assignment.with_user(self.user_student_a).read(['title']) + + # Published -> Student A can read + assignment.with_user(self.user_faculty_a).action_publish() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + # Closed -> Student A can read, but cannot submit + assignment.with_user(self.user_faculty_a).action_close() + res = assignment.with_user(self.user_student_a).read(['title']) + self.assertTrue(len(res) > 0) + + with self.assertRaises(ValidationError): + self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # 41. Submission edge cases + def test_41_submission_security_edge_cases(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H2', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + submission = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'Student A Work', + }) + + # change student_id -> AccessError/ValidationError? + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'student_id': self.student_b.id}) + + # change assignment_id + assignment_2 = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H3', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + with self.assertRaises(AccessError): + # student a might not have access to change assignment if it violates unique rule or is blocked. + # actually students can edit assignment_id if in draft, but let's test. + # In Phase F we blocked changing assignment if submission is not draft. But it is draft here. + # wait, they can change it if it's draft and they own the new assignment's course. + # But the prompt says "assignment_id changed after submission -> REJECT". + pass + + submission.with_user(self.user_student_a).action_submit() + + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'assignment_id': assignment_2.id}) + + # change marks + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'marks': 100}) + + # change feedback + with self.assertRaises(AccessError): + submission.with_user(self.user_student_a).write({'feedback': 'Good job me'}) + + # 42. Attachment Security Direct Test + import base64 + def test_42_attachment_security(self): + self.course_faculty_a.state = 'published' + assignment = self.env['education.lms.assignment'].with_user(self.user_faculty_a).create({ + 'title': 'Assign H Attachments', + 'course_id': self.course_faculty_a.id, + 'due_date': fields.Date.today() + timedelta(days=5), + 'state': 'published' + }) + + # Student A submission + sub_a = self.env['education.lms.submission'].with_user(self.user_student_a).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_a.id, + 'text_submission': 'A', + }) + attach_a = self.env['ir.attachment'].with_user(self.user_student_a).create({ + 'name': 'file_a.txt', + 'datas': base64.b64encode(b'student A file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_a.id, + }) + + sub_b = self.env['education.lms.submission'].with_user(self.user_student_b).create({ + 'assignment_id': assignment.id, + 'student_id': self.student_b.id, + 'text_submission': 'B', + }) + attach_b = self.env['ir.attachment'].with_user(self.user_student_b).create({ + 'name': 'file_b.txt', + 'datas': base64.b64encode(b'student B file'), + 'res_model': 'education.lms.submission', + 'res_id': sub_b.id, + }) + + # Verify A can read attach A + self.assertTrue(attach_a.with_user(self.user_student_a).read(['name'])) + + # Verify A cannot read attach B + with self.assertRaises(AccessError): + attach_b.with_user(self.user_student_a).read(['name']) + + # Verify Faculty A can read both + self.assertTrue(attach_a.with_user(self.user_faculty_a).read(['name'])) + self.assertTrue(attach_b.with_user(self.user_faculty_a).read(['name'])) + + # Verify Faculty B cannot read either + with self.assertRaises(AccessError): + attach_a.with_user(self.user_faculty_b).read(['name']) + + # 43. Admin override + def test_43_admin_override(self): + user_admin = self.env['res.users'].create({ + 'name': 'Admin User 2', + 'login': 'lms_admin2', + 'groups_id': [(4, self.group_admin.id)] + }) + # Admin can read faculty B's course + course_b = self.env['education.lms.course'].with_user(self.user_faculty_b).create({ + 'title': 'Course B', + 'faculty_id': self.user_faculty_b.id, + 'program_id': self.program.id, + 'academic_year_id': self.academic_year.id, + }) + res = course_b.with_user(user_admin).read(['title']) + self.assertTrue(len(res) > 0) + + course_b.with_user(user_admin).write({'title': 'Admin changed'}) + self.assertEqual(course_b.title, 'Admin changed') + + # 44. Portal User Mapping + def test_44_portal_user_mapping(self): + # A user without student_id should not see Student A's courses/submissions in LMS Context + user_no_student = self.env['res.users'].create({ + 'name': 'No Student', + 'login': 'no_student', + 'groups_id': [(4, self.group_student.id)] + }) + self.course_faculty_a.state = 'published' + with self.assertRaises(AccessError): + self.course_faculty_a.with_user(user_no_student).read(['title']) +''') diff --git a/debug_domain 2.py b/debug_domain 2.py new file mode 100644 index 0000000..24d5f99 --- /dev/null +++ b/debug_domain 2.py @@ -0,0 +1,16 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" # Debug Domain + rule = self.env['ir.rule'].sudo().search([('name', '=', 'Student Course Access')], limit=1) + domain = rule._compute_domain(rule.model_id.model, 'read') + print(f"\\nDEBUG DOMAIN GENERATED FOR STUDENT A: {domain}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/debug_domain.py b/debug_domain.py new file mode 100644 index 0000000..24d5f99 --- /dev/null +++ b/debug_domain.py @@ -0,0 +1,16 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" # Debug Domain + rule = self.env['ir.rule'].sudo().search([('name', '=', 'Student Course Access')], limit=1) + domain = rule._compute_domain(rule.model_id.model, 'read') + print(f"\\nDEBUG DOMAIN GENERATED FOR STUDENT A: {domain}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_all_perms 2.py b/fix_all_perms 2.py new file mode 100644 index 0000000..057dcbd --- /dev/null +++ b/fix_all_perms 2.py @@ -0,0 +1,48 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace all student rules to explicitly have perm_read="True" +# We will do this by finding \n +# and adding perm_read="True" after it. + +def replace_perms(match): + return match.group(0) + '\n \n \n \n ' + +# First, remove existing perms to avoid duplicates +text = re.sub(r' *\n', '', text) + +# Now add default perms back to Faculty +text = re.sub( + r'()', + r'\1\n \n \n \n ', + text +) + +# And Admin +text = re.sub( + r'()', + r'\1\n \n \n \n ', + text +) + +# And Student (Read Only except for submission) +text = re.sub( + r'(.*?)', + r'\1\n \n \n \n ', + text, + flags=re.DOTALL +) + +# Exception for Student Submission Sandbox (needs write, create, unlink maybe?) +# Actually Submission Sandbox might need create/write +text = re.sub( + r'(.*?)\n \n ', + r'\1\n \n ', + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_all_perms.py b/fix_all_perms.py new file mode 100644 index 0000000..057dcbd --- /dev/null +++ b/fix_all_perms.py @@ -0,0 +1,48 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace all student rules to explicitly have perm_read="True" +# We will do this by finding \n +# and adding perm_read="True" after it. + +def replace_perms(match): + return match.group(0) + '\n \n \n \n ' + +# First, remove existing perms to avoid duplicates +text = re.sub(r' *\n', '', text) + +# Now add default perms back to Faculty +text = re.sub( + r'()', + r'\1\n \n \n \n ', + text +) + +# And Admin +text = re.sub( + r'()', + r'\1\n \n \n \n ', + text +) + +# And Student (Read Only except for submission) +text = re.sub( + r'(.*?)', + r'\1\n \n \n \n ', + text, + flags=re.DOTALL +) + +# Exception for Student Submission Sandbox (needs write, create, unlink maybe?) +# Actually Submission Sandbox might need create/write +text = re.sub( + r'(.*?)\n \n ', + r'\1\n \n ', + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_mapped 2.py b/fix_mapped 2.py new file mode 100644 index 0000000..983c7fd --- /dev/null +++ b/fix_mapped 2.py @@ -0,0 +1,13 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace .mapped('field_name').ids with .field_name.ids +text = text.replace("user.student_ids.mapped('program_id').ids", "user.student_ids.program_id.ids") +text = text.replace("user.student_ids.mapped('academic_year_id').ids", "user.student_ids.academic_year_id.ids") +text = text.replace("user.student_ids.mapped('academic_term_id').ids", "user.student_ids.academic_term_id.ids") +text = text.replace("user.student_ids.mapped('batch_id').ids", "user.student_ids.batch_id.ids") + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_mapped.py b/fix_mapped.py new file mode 100644 index 0000000..983c7fd --- /dev/null +++ b/fix_mapped.py @@ -0,0 +1,13 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace .mapped('field_name').ids with .field_name.ids +text = text.replace("user.student_ids.mapped('program_id').ids", "user.student_ids.program_id.ids") +text = text.replace("user.student_ids.mapped('academic_year_id').ids", "user.student_ids.academic_year_id.ids") +text = text.replace("user.student_ids.mapped('academic_term_id').ids", "user.student_ids.academic_term_id.ids") +text = text.replace("user.student_ids.mapped('batch_id').ids", "user.student_ids.batch_id.ids") + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_perms 2.py b/fix_perms 2.py new file mode 100644 index 0000000..587cb0f --- /dev/null +++ b/fix_perms 2.py @@ -0,0 +1,33 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# I need to restore the original domain AND add perm_read="True" +# Let's replace the whole Student Course Access rule. +new_course_rule = """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ] + + + + + + """ + +text = re.sub( + r'.*?', + new_course_rule, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_perms.py b/fix_perms.py new file mode 100644 index 0000000..587cb0f --- /dev/null +++ b/fix_perms.py @@ -0,0 +1,33 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# I need to restore the original domain AND add perm_read="True" +# Let's replace the whole Student Course Access rule. +new_course_rule = """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ] + + + + + + """ + +text = re.sub( + r'.*?', + new_course_rule, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_security 2.py b/fix_security 2.py new file mode 100644 index 0000000..e99983e --- /dev/null +++ b/fix_security 2.py @@ -0,0 +1,24 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace the Course rule domain +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_security.py b/fix_security.py new file mode 100644 index 0000000..e99983e --- /dev/null +++ b/fix_security.py @@ -0,0 +1,24 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace the Course rule domain +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_security2 2.py b/fix_security2 2.py new file mode 100644 index 0000000..67c66b6 --- /dev/null +++ b/fix_security2 2.py @@ -0,0 +1,18 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace all occurrences of user.student_ids.field.ids with user.student_ids.mapped('field').ids +# Specifically for program_id, academic_year_id, academic_term_id, batch_id + +fields = ['program_id', 'academic_year_id', 'academic_term_id', 'batch_id'] +for field in fields: + text = re.sub( + rf'user\.student_ids\.{field}\.ids', + f"user.student_ids.mapped('{field}').ids", + text + ) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_security2.py b/fix_security2.py new file mode 100644 index 0000000..67c66b6 --- /dev/null +++ b/fix_security2.py @@ -0,0 +1,18 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace all occurrences of user.student_ids.field.ids with user.student_ids.mapped('field').ids +# Specifically for program_id, academic_year_id, academic_term_id, batch_id + +fields = ['program_id', 'academic_year_id', 'academic_term_id', 'batch_id'] +for field in fields: + text = re.sub( + rf'user\.student_ids\.{field}\.ids', + f"user.student_ids.mapped('{field}').ids", + text + ) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_state 2.py b/fix_state 2.py new file mode 100644 index 0000000..44905b6 --- /dev/null +++ b/fix_state 2.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace state check +text = text.replace("('state', 'in', ['published', 'closed']),", "('state', '=', 'published'),") + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_state.py b/fix_state.py new file mode 100644 index 0000000..44905b6 --- /dev/null +++ b/fix_state.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace state check +text = text.replace("('state', 'in', ['published', 'closed']),", "('state', '=', 'published'),") + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/fix_student_user_type 2.py b/fix_student_user_type 2.py new file mode 100644 index 0000000..3362838 --- /dev/null +++ b/fix_student_user_type 2.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +# Replace base.group_portal with education_student.group_education_student_user +text = text.replace("self.env.ref('base.group_portal').id", "self.env.ref('education_student.group_education_student_user').id") + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_student_user_type.py b/fix_student_user_type.py new file mode 100644 index 0000000..3362838 --- /dev/null +++ b/fix_student_user_type.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +# Replace base.group_portal with education_student.group_education_student_user +text = text.replace("self.env.ref('base.group_portal').id", "self.env.ref('education_student.group_education_student_user').id") + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_test 2.py b/fix_test 2.py new file mode 100644 index 0000000..33986a4 --- /dev/null +++ b/fix_test 2.py @@ -0,0 +1,9 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = re.sub(r' # Debug Domain\n.*?\n.*?\n.*?\n', '', text, flags=re.DOTALL) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_test.py b/fix_test.py new file mode 100644 index 0000000..33986a4 --- /dev/null +++ b/fix_test.py @@ -0,0 +1,9 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = re.sub(r' # Debug Domain\n.*?\n.*?\n.*?\n', '', text, flags=re.DOTALL) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_test_users 2.py b/fix_test_users 2.py new file mode 100644 index 0000000..e005fbc --- /dev/null +++ b/fix_test_users 2.py @@ -0,0 +1,37 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +# Add base.group_portal to user_student_a and b +text = text.replace( +""" self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id)] + })""", +""" self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('base.group_portal').id)] + })""" +) + +text = text.replace( +""" self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id)] + })""", +""" self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('base.group_portal').id)] + })""" +) + +# Also remove the debug prints I added previously, to keep the test clean +text = re.sub(r' *print\(f"\\nDEBUG RULE PERM_READ.*?student_ids\n', '', text, flags=re.DOTALL) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/fix_test_users.py b/fix_test_users.py new file mode 100644 index 0000000..e005fbc --- /dev/null +++ b/fix_test_users.py @@ -0,0 +1,37 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +# Add base.group_portal to user_student_a and b +text = text.replace( +""" self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id)] + })""", +""" self.user_student_a = self.env['res.users'].create({ + 'name': 'Student A', + 'login': 'student_a', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('base.group_portal').id)] + })""" +) + +text = text.replace( +""" self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id)] + })""", +""" self.user_student_b = self.env['res.users'].create({ + 'name': 'Student B', + 'login': 'student_b', + 'groups_id': [(4, self.group_student.id), (4, self.env.ref('base.group_portal').id)] + })""" +) + +# Also remove the debug prints I added previously, to keep the test clean +text = re.sub(r' *print\(f"\\nDEBUG RULE PERM_READ.*?student_ids\n', '', text, flags=re.DOTALL) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/get_rule_db 2.py b/get_rule_db 2.py new file mode 100644 index 0000000..057e0ea --- /dev/null +++ b/get_rule_db 2.py @@ -0,0 +1,18 @@ +import psycopg2 +import sys + +try: + conn = psycopg2.connect("dbname=student_db user=odoo password=odoo host=db") + cur = conn.cursor() + cur.execute(""" + SELECT r.name, r.domain_force, r.perm_read, r.global, g.name + FROM ir_rule r + LEFT JOIN rule_group_rel rg ON r.id = rg.rule_group_id + LEFT JOIN res_groups g ON rg.group_id = g.id + WHERE r.model_id = (SELECT id FROM ir_model WHERE model = 'education.lms.course') + """) + rows = cur.fetchall() + for r in rows: + print(r) +except Exception as e: + print(f"DB Error: {e}") diff --git a/get_rule_db.py b/get_rule_db.py new file mode 100644 index 0000000..057e0ea --- /dev/null +++ b/get_rule_db.py @@ -0,0 +1,18 @@ +import psycopg2 +import sys + +try: + conn = psycopg2.connect("dbname=student_db user=odoo password=odoo host=db") + cur = conn.cursor() + cur.execute(""" + SELECT r.name, r.domain_force, r.perm_read, r.global, g.name + FROM ir_rule r + LEFT JOIN rule_group_rel rg ON r.id = rg.rule_group_id + LEFT JOIN res_groups g ON rg.group_id = g.id + WHERE r.model_id = (SELECT id FROM ir_model WHERE model = 'education.lms.course') + """) + rows = cur.fetchall() + for r in rows: + print(r) +except Exception as e: + print(f"DB Error: {e}") diff --git a/print_debug 2.py b/print_debug 2.py new file mode 100644 index 0000000..1c08b69 --- /dev/null +++ b/print_debug 2.py @@ -0,0 +1,14 @@ +import sys + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" print(f"\\nDEBUG COURSE: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}") + print(f"DEBUG STUDENT: p={self.student_a.program_id.id}, y={self.student_a.academic_year_id.id}, t={self.student_a.academic_term_id.id}, b={self.student_a.batch_id.id}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_debug.py b/print_debug.py new file mode 100644 index 0000000..1c08b69 --- /dev/null +++ b/print_debug.py @@ -0,0 +1,14 @@ +import sys + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" print(f"\\nDEBUG COURSE: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}") + print(f"DEBUG STUDENT: p={self.student_a.program_id.id}, y={self.student_a.academic_year_id.id}, t={self.student_a.academic_term_id.id}, b={self.student_a.batch_id.id}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_debug2 2.py b/print_debug2 2.py new file mode 100644 index 0000000..165d433 --- /dev/null +++ b/print_debug2 2.py @@ -0,0 +1,14 @@ +import sys + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG COURSE: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}")""", +""" print(f"\\nDEBUG COURSE_A: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}, state={course_a.state}") + print(f"DEBUG COURSE_FACULTY_A: p={self.course_faculty_a.program_id.id}, y={self.course_faculty_a.academic_year_id.id}, t={self.course_faculty_a.term_id.id}, b={self.course_faculty_a.batch_id.id}, state={self.course_faculty_a.state}") +""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_debug2.py b/print_debug2.py new file mode 100644 index 0000000..165d433 --- /dev/null +++ b/print_debug2.py @@ -0,0 +1,14 @@ +import sys + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG COURSE: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}")""", +""" print(f"\\nDEBUG COURSE_A: p={course_a.program_id.id}, y={course_a.academic_year_id.id}, t={course_a.term_id.id}, b={course_a.batch_id.id}, state={course_a.state}") + print(f"DEBUG COURSE_FACULTY_A: p={self.course_faculty_a.program_id.id}, y={self.course_faculty_a.academic_year_id.id}, t={self.course_faculty_a.term_id.id}, b={self.course_faculty_a.batch_id.id}, state={self.course_faculty_a.state}") +""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_ids 2.py b/print_ids 2.py new file mode 100644 index 0000000..ce49761 --- /dev/null +++ b/print_ids 2.py @@ -0,0 +1,6 @@ +import sys + +def print_student_ids(): + print("Writing diagnostic test...") + +print_student_ids() diff --git a/print_ids.py b/print_ids.py new file mode 100644 index 0000000..ce49761 --- /dev/null +++ b/print_ids.py @@ -0,0 +1,6 @@ +import sys + +def print_student_ids(): + print("Writing diagnostic test...") + +print_student_ids() diff --git a/print_perms 2.py b/print_perms 2.py new file mode 100644 index 0000000..537740e --- /dev/null +++ b/print_perms 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}")""", +""" rule = self.env.ref('education_lms.rule_lms_course_student') + print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}") + print(f"DEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_perms.py b/print_perms.py new file mode 100644 index 0000000..537740e --- /dev/null +++ b/print_perms.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}")""", +""" rule = self.env.ref('education_lms.rule_lms_course_student') + print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}") + print(f"DEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/print_test_39 2.py b/print_test_39 2.py new file mode 100644 index 0000000..9ae8138 --- /dev/null +++ b/print_test_39 2.py @@ -0,0 +1,9 @@ +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + in_test = False + for line in f: + if 'def test_39_course_eligibility' in line: + in_test = True + elif in_test and line.strip().startswith('def test_'): + break + if in_test: + print(line, end='') diff --git a/print_test_39.py b/print_test_39.py new file mode 100644 index 0000000..9ae8138 --- /dev/null +++ b/print_test_39.py @@ -0,0 +1,9 @@ +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + in_test = False + for line in f: + if 'def test_39_course_eligibility' in line: + in_test = True + elif in_test and line.strip().startswith('def test_'): + break + if in_test: + print(line, end='') diff --git a/replace_tests 2.py b/replace_tests 2.py new file mode 100644 index 0000000..7ce0835 --- /dev/null +++ b/replace_tests 2.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace(" res = course_a.with_user(self.user_student_a).read(['name'])", +" print(f'\\nDEBUG STUDENT IDS: {self.user_student_a.student_ids}')\n print(f'DEBUG STUDENT TERM: {self.user_student_a.student_ids.academic_term_id}')\n res = course_a.with_user(self.user_student_a).read(['name'])") + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/replace_tests.py b/replace_tests.py new file mode 100644 index 0000000..7ce0835 --- /dev/null +++ b/replace_tests.py @@ -0,0 +1,10 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace(" res = course_a.with_user(self.user_student_a).read(['name'])", +" print(f'\\nDEBUG STUDENT IDS: {self.user_student_a.student_ids}')\n print(f'DEBUG STUDENT TERM: {self.user_student_a.student_ids.academic_term_id}')\n res = course_a.with_user(self.user_student_a).read(['name'])") + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/restore_domain 2.py b/restore_domain 2.py new file mode 100644 index 0000000..cb31d66 --- /dev/null +++ b/restore_domain 2.py @@ -0,0 +1,26 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with original logic +new_course_rule = """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ]""" + +text = re.sub( + r'.*?\[.*?\]', + new_course_rule, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/restore_domain.py b/restore_domain.py new file mode 100644 index 0000000..cb31d66 --- /dev/null +++ b/restore_domain.py @@ -0,0 +1,26 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with original logic +new_course_rule = """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ]""" + +text = re.sub( + r'.*?\[.*?\]', + new_course_rule, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/rewrite_security 2.py b/rewrite_security 2.py new file mode 100644 index 0000000..218f5d3 --- /dev/null +++ b/rewrite_security 2.py @@ -0,0 +1,30 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Fix Course Student Rule +text = re.sub( + r'.*?', + """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ] + + + + + + """, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/rewrite_security.py b/rewrite_security.py new file mode 100644 index 0000000..218f5d3 --- /dev/null +++ b/rewrite_security.py @@ -0,0 +1,30 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Fix Course Student Rule +text = re.sub( + r'.*?', + """ + Student Course Access + + [ + ('state', 'in', ['published', 'closed']), + ('program_id', 'in', user.student_ids.mapped('program_id').ids), + ('academic_year_id', 'in', user.student_ids.mapped('academic_year_id').ids), + '|', ('term_id', '=', False), ('term_id', 'in', user.student_ids.mapped('academic_term_id').ids), + '|', ('batch_id', '=', False), ('batch_id', 'in', user.student_ids.mapped('batch_id').ids), + ] + + + + + + """, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_1_1 2.py b/test_1_1 2.py new file mode 100644 index 0000000..a7538ad --- /dev/null +++ b/test_1_1 2.py @@ -0,0 +1,23 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +text = re.sub( + r'.*?', + """ + Student Course Access + + [(1, '=', 1)] + + + + + + """, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_1_1.py b/test_1_1.py new file mode 100644 index 0000000..a7538ad --- /dev/null +++ b/test_1_1.py @@ -0,0 +1,23 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +text = re.sub( + r'.*?', + """ + Student Course Access + + [(1, '=', 1)] + + + + + + """, + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_db_rule 2.py b/test_db_rule 2.py new file mode 100644 index 0000000..9f0dfac --- /dev/null +++ b/test_db_rule 2.py @@ -0,0 +1,23 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}") + + all_course_rules = self.env['ir.rule'].sudo().search([('model_id.model', '=', 'education.lms.course')]) + print(f"DEBUG ALL COURSE RULES: {all_course_rules.mapped('name')}") + for r in all_course_rules: + print(f"RULE: {r.name}, dom: {r.domain_force}, read: {r.perm_read}, grps: {r.groups.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_db_rule.py b/test_db_rule.py new file mode 100644 index 0000000..9f0dfac --- /dev/null +++ b/test_db_rule.py @@ -0,0 +1,23 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}") + + all_course_rules = self.env['ir.rule'].sudo().search([('model_id.model', '=', 'education.lms.course')]) + print(f"DEBUG ALL COURSE RULES: {all_course_rules.mapped('name')}") + for r in all_course_rules: + print(f"RULE: {r.name}, dom: {r.domain_force}, read: {r.perm_read}, grps: {r.groups.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_db_rules 2.py b/test_db_rules 2.py new file mode 100644 index 0000000..4d4eb59 --- /dev/null +++ b/test_db_rules 2.py @@ -0,0 +1,2 @@ +import xmlrpc.client +# Actually I'm inside the docker so I will just run a simple python script via Odoo shell! diff --git a/test_db_rules.py b/test_db_rules.py new file mode 100644 index 0000000..4d4eb59 --- /dev/null +++ b/test_db_rules.py @@ -0,0 +1,2 @@ +import xmlrpc.client +# Actually I'm inside the docker so I will just run a simple python script via Odoo shell! diff --git a/test_domain 2.py b/test_domain 2.py new file mode 100644 index 0000000..40e71e0 --- /dev/null +++ b/test_domain 2.py @@ -0,0 +1,20 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with just state +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [ + ('state', '=', 'published'), + ]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_domain.py b/test_domain.py new file mode 100644 index 0000000..40e71e0 --- /dev/null +++ b/test_domain.py @@ -0,0 +1,20 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with just state +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [ + ('state', '=', 'published'), + ]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_domain2 2.py b/test_domain2 2.py new file mode 100644 index 0000000..2c6ce69 --- /dev/null +++ b/test_domain2 2.py @@ -0,0 +1,18 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with (1, '=', 1) +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [(1, '=', 1)]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_domain2.py b/test_domain2.py new file mode 100644 index 0000000..2c6ce69 --- /dev/null +++ b/test_domain2.py @@ -0,0 +1,18 @@ +import re + +with open('addons/education_lms/security/security.xml', 'r') as f: + text = f.read() + +# Replace Course rule domain with (1, '=', 1) +text = re.sub( + r'.*?\[.*?\]', + """ + Student Course Access + + [(1, '=', 1)]""", + text, + flags=re.DOTALL +) + +with open('addons/education_lms/security/security.xml', 'w') as f: + f.write(text) diff --git a/test_domain_eval 2.py b/test_domain_eval 2.py new file mode 100644 index 0000000..5811909 --- /dev/null +++ b/test_domain_eval 2.py @@ -0,0 +1,9 @@ +env = self.env +course = env['education.lms.course'].search([('name', '=', 'Course No Term')], limit=1) +import odoo.osv.expression as expression +domain = [('term_id', '=', False)] +print("Is course term_id empty?", not course.term_id) +print("Does domain match in DB?", bool(env['education.lms.course'].search([('id', '=', course.id), ('term_id', '=', False)]))) +# Test in-memory +domain_evaluator = expression.expression(domain, env['education.lms.course'])._process_domain() +print("Domain evaluated:", domain_evaluator) diff --git a/test_domain_eval.py b/test_domain_eval.py new file mode 100644 index 0000000..5811909 --- /dev/null +++ b/test_domain_eval.py @@ -0,0 +1,9 @@ +env = self.env +course = env['education.lms.course'].search([('name', '=', 'Course No Term')], limit=1) +import odoo.osv.expression as expression +domain = [('term_id', '=', False)] +print("Is course term_id empty?", not course.term_id) +print("Does domain match in DB?", bool(env['education.lms.course'].search([('id', '=', course.id), ('term_id', '=', False)]))) +# Test in-memory +domain_evaluator = expression.expression(domain, env['education.lms.course'])._process_domain() +print("Domain evaluated:", domain_evaluator) diff --git a/test_env_student_ids 2.py b/test_env_student_ids 2.py new file mode 100644 index 0000000..fad3f6c --- /dev/null +++ b/test_env_student_ids 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}")""", +""" print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}") + student_ids_as_student = self.user_student_a.with_user(self.user_student_a).student_ids + print(f"DEBUG STUDENT IDS AS STUDENT: {student_ids_as_student}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_env_student_ids.py b/test_env_student_ids.py new file mode 100644 index 0000000..fad3f6c --- /dev/null +++ b/test_env_student_ids.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}")""", +""" print(f"\\nDEBUG RULE PERM_READ: {rule.perm_read}") + student_ids_as_student = self.user_student_a.with_user(self.user_student_a).student_ids + print(f"DEBUG STUDENT IDS AS STUDENT: {student_ids_as_student}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_groups 2.py b/test_groups 2.py new file mode 100644 index 0000000..dd15b73 --- /dev/null +++ b/test_groups 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""", +""" print(f"\\nDEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}") + print(f"DEBUG HAS STUDENT GROUP: {self.group_student in self.user_student_a.groups_id}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_groups.py b/test_groups.py new file mode 100644 index 0000000..dd15b73 --- /dev/null +++ b/test_groups.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""", +""" print(f"\\nDEBUG USER GROUPS: {self.user_student_a.groups_id.mapped('name')}") + print(f"DEBUG HAS STUDENT GROUP: {self.group_student in self.user_student_a.groups_id}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_invalidate 2.py b/test_invalidate 2.py new file mode 100644 index 0000000..a778b3f --- /dev/null +++ b/test_invalidate 2.py @@ -0,0 +1,12 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +" self.env.invalidate_all()\n res = course_a.with_user(self.user_student_a).read(['name'])" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_invalidate.py b/test_invalidate.py new file mode 100644 index 0000000..a778b3f --- /dev/null +++ b/test_invalidate.py @@ -0,0 +1,12 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +" self.env.invalidate_all()\n res = course_a.with_user(self.user_student_a).read(['name'])" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rule_domain 2.py b/test_rule_domain 2.py new file mode 100644 index 0000000..0bc4afc --- /dev/null +++ b/test_rule_domain 2.py @@ -0,0 +1,16 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rule_domain.py b/test_rule_domain.py new file mode 100644 index 0000000..0bc4afc --- /dev/null +++ b/test_rule_domain.py @@ -0,0 +1,16 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read').sudo() + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}") + print(f"DEBUG RULE DOMAINS: {rules.mapped('domain_force')}") + print(f"DEBUG RULE PERMS: {[(r.perm_read, r.perm_write, r.perm_create, r.perm_unlink) for r in rules]}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rule_name 2.py b/test_rule_name 2.py new file mode 100644 index 0000000..85d0da2 --- /dev/null +++ b/test_rule_name 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rule_name.py b/test_rule_name.py new file mode 100644 index 0000000..85d0da2 --- /dev/null +++ b/test_rule_name.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules}")""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules.mapped('name')}")""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rules 2.py b/test_rules 2.py new file mode 100644 index 0000000..5a38c58 --- /dev/null +++ b/test_rules 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([])""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules}") + all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rules.py b/test_rules.py new file mode 100644 index 0000000..5a38c58 --- /dev/null +++ b/test_rules.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +""" all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([])""", +""" rules = self.env['ir.rule'].with_user(self.user_student_a)._get_rules('education.lms.course', 'read') + print(f"\\nDEBUG APPLIED RULES FOR STUDENT A: {rules}") + all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_rules_odoo 2.py b/test_rules_odoo 2.py new file mode 100644 index 0000000..21da2aa --- /dev/null +++ b/test_rules_odoo 2.py @@ -0,0 +1,2 @@ +from odoo import api, SUPERUSER_ID +# I'll just put this in test_education_lms.py directly diff --git a/test_rules_odoo.py b/test_rules_odoo.py new file mode 100644 index 0000000..21da2aa --- /dev/null +++ b/test_rules_odoo.py @@ -0,0 +1,2 @@ +from odoo import api, SUPERUSER_ID +# I'll just put this in test_education_lms.py directly diff --git a/test_search 2.py b/test_search 2.py new file mode 100644 index 0000000..3834830 --- /dev/null +++ b/test_search 2.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([]) + print(f"\\nDEBUG ALL COURSES: {all_courses.ids}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text) diff --git a/test_search.py b/test_search.py new file mode 100644 index 0000000..3834830 --- /dev/null +++ b/test_search.py @@ -0,0 +1,14 @@ +import re + +with open('addons/education_lms/tests/test_education_lms.py', 'r') as f: + text = f.read() + +text = text.replace( +" res = course_a.with_user(self.user_student_a).read(['name'])", +""" all_courses = self.env['education.lms.course'].with_user(self.user_student_a).search([]) + print(f"\\nDEBUG ALL COURSES: {all_courses.ids}") + res = course_a.with_user(self.user_student_a).read(['name'])""" +) + +with open('addons/education_lms/tests/test_education_lms.py', 'w') as f: + f.write(text)