diff --git a/addons/community_school/__manifest__.py b/addons/community_school/__manifest__.py index 67f35e9..1a018da 100644 --- a/addons/community_school/__manifest__.py +++ b/addons/community_school/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Community OS - School', - 'version': '19.0.1.0.0', + 'version': '19.0.1.1.0', 'category': 'Website/Education', 'summary': 'Programs, levels, classes, enrollment, attendance and parent portal for any weekend/community school', 'description': """ @@ -35,9 +35,11 @@ with no code change. 'data/mail_templates.xml', 'data/ir_cron.xml', 'views/school_views.xml', + 'views/school_assignment_views.xml', 'views/school_menus.xml', 'views/res_config_settings_views.xml', 'views/portal_attendance_templates.xml', + 'views/portal_assignment_templates.xml', 'views/registration_templates.xml', ], 'demo': [], diff --git a/addons/community_school/controllers/__init__.py b/addons/community_school/controllers/__init__.py index 7419fd2..1f8c622 100644 --- a/addons/community_school/controllers/__init__.py +++ b/addons/community_school/controllers/__init__.py @@ -1,2 +1,3 @@ from . import attendance from . import registration +from . import assignment diff --git a/addons/community_school/controllers/assignment.py b/addons/community_school/controllers/assignment.py new file mode 100644 index 0000000..f120112 --- /dev/null +++ b/addons/community_school/controllers/assignment.py @@ -0,0 +1,244 @@ +import base64 +import logging + +from odoo import fields, http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def _require_login(path): + """Redirect to login if visitor is not authenticated.""" + if request.env.user._is_public(): + return request.redirect(f'/web/login?redirect={path}') + return None + + +class SchoolAssignmentController(http.Controller): + + def _get_student_for_user(self): + """Return the community.school.student record linked to the current portal user.""" + partner = request.env.user.partner_id + return request.env['community.school.student'].sudo().search( + [('partner_id', '=', partner.id)], limit=1 + ) + + def _get_teacher_classes(self): + partner = request.env.user.partner_id + return request.env['community.school.class'].sudo().search( + [('teacher_id', '=', partner.id)] + ) + + # ───────────────────────────────────────────────────────────── + # TEACHER: Create / manage assignments + # ───────────────────────────────────────────────────────────── + + @http.route('/school/assignments/teacher', type='http', auth='public', website=True) + def teacher_assignment_list(self, **kwargs): + redirect = _require_login('/school/assignments/teacher') + if redirect: + return redirect + classes = self._get_teacher_classes() + if not classes: + return request.render('community_school.portal_no_classes', {}) + assignments = request.env['community.school.assignment'].sudo().search( + [('class_id', 'in', classes.ids)], order='due_date asc' + ) + return request.render('community_school.portal_teacher_assignment_list', { + 'assignments': assignments, + 'classes': classes, + }) + + @http.route('/school/assignments/teacher/new', type='http', auth='public', website=True) + def teacher_assignment_new(self, **kwargs): + redirect = _require_login('/school/assignments/teacher/new') + if redirect: + return redirect + classes = self._get_teacher_classes() + if not classes: + return request.render('community_school.portal_no_classes', {}) + + if request.httprequest.method == 'POST': + class_id = int(kwargs.get('class_id', 0)) + klass = classes.filtered(lambda c: c.id == class_id) + if not klass: + return request.redirect('/school/assignments/teacher') + vals = { + 'name': kwargs.get('title', '').strip(), + 'class_id': class_id, + 'description': kwargs.get('description', '').strip() or False, + 'due_date': kwargs.get('due_date') or False, + 'max_score': int(kwargs.get('max_score') or 100), + 'state': 'published', + } + request.env['community.school.assignment'].sudo().create(vals) + return request.redirect('/school/assignments/teacher') + + return request.render('community_school.portal_teacher_assignment_form', { + 'classes': classes, + 'assignment': None, + }) + + @http.route('/school/assignments/teacher//edit', + type='http', auth='public', website=True) + def teacher_assignment_edit(self, assignment_id, **kwargs): + redirect = _require_login(f'/school/assignments/teacher/{assignment_id}/edit') + if redirect: + return redirect + classes = self._get_teacher_classes() + assignment = request.env['community.school.assignment'].sudo().browse(assignment_id) + if not assignment.exists() or assignment.class_id not in classes: + return request.redirect('/school/assignments/teacher') + + if request.httprequest.method == 'POST': + vals = { + 'name': kwargs.get('title', '').strip(), + 'class_id': int(kwargs.get('class_id', assignment.class_id.id)), + 'description': kwargs.get('description', '').strip() or False, + 'due_date': kwargs.get('due_date') or False, + 'max_score': int(kwargs.get('max_score') or 100), + } + action = kwargs.get('action_state') + if action == 'publish': + vals['state'] = 'published' + elif action == 'close': + vals['state'] = 'closed' + elif action == 'draft': + vals['state'] = 'draft' + assignment.write(vals) + return request.redirect('/school/assignments/teacher') + + return request.render('community_school.portal_teacher_assignment_form', { + 'classes': classes, + 'assignment': assignment, + }) + + @http.route('/school/assignments/teacher//submissions', + type='http', auth='public', website=True) + def teacher_view_submissions(self, assignment_id, **kwargs): + redirect = _require_login(f'/school/assignments/teacher/{assignment_id}/submissions') + if redirect: + return redirect + classes = self._get_teacher_classes() + assignment = request.env['community.school.assignment'].sudo().browse(assignment_id) + if not assignment.exists() or assignment.class_id not in classes: + return request.redirect('/school/assignments/teacher') + + submissions = request.env['community.school.submission'].sudo().search( + [('assignment_id', '=', assignment_id)] + ) + return request.render('community_school.portal_teacher_submissions', { + 'assignment': assignment, + 'submissions': submissions, + }) + + @http.route('/school/assignments/teacher/submission//grade', + type='http', auth='public', website=True, methods=['POST']) + def teacher_grade_submission(self, submission_id, **kwargs): + redirect = _require_login(f'/school/assignments/teacher/submission/{submission_id}/grade') + if redirect: + return redirect + classes = self._get_teacher_classes() + submission = request.env['community.school.submission'].sudo().browse(submission_id) + if not submission.exists() or submission.assignment_id.class_id not in classes: + return request.redirect('/school/assignments/teacher') + + score = kwargs.get('score', '').strip() + feedback = kwargs.get('feedback', '').strip() + vals = {'feedback': feedback or False, 'state': 'graded'} + if score.isdigit(): + vals['score'] = int(score) + submission.write(vals) + return request.redirect( + f'/school/assignments/teacher/{submission.assignment_id.id}/submissions' + ) + + # ───────────────────────────────────────────────────────────── + # STUDENT: View assignments & upload submissions + # ───────────────────────────────────────────────────────────── + + @http.route('/school/assignments/student', type='http', auth='public', website=True) + def student_assignment_list(self, **kwargs): + redirect = _require_login('/school/assignments/student') + if redirect: + return redirect + student = self._get_student_for_user() + if not student: + return request.render('community_school.portal_no_student', {}) + + enrolled_class_ids = student.enrollment_ids.filtered( + lambda e: e.state == 'enrolled' + ).mapped('class_id').ids + + assignments = request.env['community.school.assignment'].sudo().search( + [('class_id', 'in', enrolled_class_ids), ('state', '=', 'published')], + order='due_date asc', + ) + # Build map of existing submissions for this student + my_submissions = request.env['community.school.submission'].sudo().search( + [('student_id', '=', student.id), ('assignment_id', 'in', assignments.ids)] + ) + submission_by_assignment = {s.assignment_id.id: s for s in my_submissions} + + return request.render('community_school.portal_student_assignment_list', { + 'assignments': assignments, + 'submission_by_assignment': submission_by_assignment, + 'student': student, + }) + + @http.route('/school/assignments/student/', + type='http', auth='public', website=True) + def student_assignment_detail(self, assignment_id, **kwargs): + redirect = _require_login(f'/school/assignments/student/{assignment_id}') + if redirect: + return redirect + student = self._get_student_for_user() + if not student: + return request.render('community_school.portal_no_student', {}) + + assignment = request.env['community.school.assignment'].sudo().browse(assignment_id) + if not assignment.exists() or assignment.state != 'published': + return request.redirect('/school/assignments/student') + + # Verify student is enrolled in this class + enrollment = student.enrollment_ids.filtered( + lambda e: e.class_id.id == assignment.class_id.id and e.state == 'enrolled' + ) + if not enrollment: + return request.redirect('/school/assignments/student') + + existing_submission = request.env['community.school.submission'].sudo().search( + [('assignment_id', '=', assignment_id), ('student_id', '=', student.id)], limit=1 + ) + + if request.httprequest.method == 'POST': + if existing_submission: + return request.redirect(f'/school/assignments/student/{assignment_id}') + + notes = kwargs.get('notes', '').strip() + file_upload = request.httprequest.files.get('submission_file') + attachment_data = False + attachment_name = False + if file_upload and file_upload.filename: + try: + attachment_data = base64.b64encode(file_upload.read()) + attachment_name = file_upload.filename + except Exception: + _logger.warning('Failed to read uploaded submission file') + + request.env['community.school.submission'].sudo().create({ + 'assignment_id': assignment_id, + 'enrollment_id': enrollment[0].id, + 'notes': notes or False, + 'attachment_data': attachment_data, + 'attachment_name': attachment_name, + 'submitted_on': fields.Datetime.now(), + 'state': 'submitted', + }) + return request.redirect(f'/school/assignments/student/{assignment_id}') + + return request.render('community_school.portal_student_assignment_detail', { + 'assignment': assignment, + 'submission': existing_submission, + 'student': student, + }) diff --git a/addons/community_school/models/__init__.py b/addons/community_school/models/__init__.py index 8262bba..01a53e4 100644 --- a/addons/community_school/models/__init__.py +++ b/addons/community_school/models/__init__.py @@ -5,4 +5,5 @@ from . import school_class from . import school_student from . import school_enrollment from . import school_attendance +from . import school_assignment from . import res_config_settings diff --git a/addons/community_school/models/school_assignment.py b/addons/community_school/models/school_assignment.py new file mode 100644 index 0000000..203cac0 --- /dev/null +++ b/addons/community_school/models/school_assignment.py @@ -0,0 +1,113 @@ +import base64 + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class CommunitySchoolAssignment(models.Model): + _name = 'community.school.assignment' + _description = 'School Assignment' + _order = 'due_date asc, id desc' + _inherit = ['mail.thread', 'mail.activity.mixin'] + + name = fields.Char(string='Title', required=True, tracking=True) + class_id = fields.Many2one( + 'community.school.class', string='Class', required=True, ondelete='cascade', + ) + term_id = fields.Many2one(related='class_id.term_id', store=True) + teacher_id = fields.Many2one(related='class_id.teacher_id', store=True) + description = fields.Html(string='Instructions') + due_date = fields.Datetime(string='Due Date', required=True, tracking=True) + max_score = fields.Integer(string='Max Score', default=100) + state = fields.Selection( + [('draft', 'Draft'), ('published', 'Published'), ('closed', 'Closed')], + default='draft', required=True, tracking=True, + ) + submission_ids = fields.One2many('community.school.submission', 'assignment_id') + submission_count = fields.Integer(compute='_compute_submission_count', store=True) + + @api.depends('submission_ids') + def _compute_submission_count(self): + for assignment in self: + assignment.submission_count = len(assignment.submission_ids) + + def action_publish(self): + for rec in self: + rec.state = 'published' + + def action_close(self): + for rec in self: + rec.state = 'closed' + + def action_reset_draft(self): + for rec in self: + rec.state = 'draft' + + def action_view_submissions(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': f'Submissions – {self.name}', + 'res_model': 'community.school.submission', + 'view_mode': 'list,form', + 'domain': [('assignment_id', '=', self.id)], + 'context': {'default_assignment_id': self.id}, + } + + +class CommunitySchoolSubmission(models.Model): + _name = 'community.school.submission' + _description = 'Assignment Submission' + _order = 'submitted_on desc, id desc' + + assignment_id = fields.Many2one( + 'community.school.assignment', required=True, ondelete='cascade', + ) + enrollment_id = fields.Many2one( + 'community.school.enrollment', string='Student Enrollment', required=True, ondelete='cascade', + ) + student_id = fields.Many2one(related='enrollment_id.student_id', store=True) + class_id = fields.Many2one(related='assignment_id.class_id', store=True) + + submitted_on = fields.Datetime(string='Submitted On', readonly=True) + notes = fields.Text(string='Student Notes') + + # File attachment stored as binary + attachment_name = fields.Char(string='File Name') + attachment_data = fields.Binary(string='Uploaded File', attachment=True) + + # Grading + score = fields.Integer(string='Score') + feedback = fields.Text(string='Teacher Feedback') + state = fields.Selection( + [('submitted', 'Submitted'), ('graded', 'Graded')], + default='submitted', required=True, + ) + is_late = fields.Boolean(compute='_compute_is_late', store=True) + + _assignment_enrollment_uniq = models.Constraint( + 'unique(assignment_id, enrollment_id)', + 'A submission for this assignment by this student already exists.', + ) + + @api.depends('submitted_on', 'assignment_id.due_date') + def _compute_is_late(self): + for sub in self: + if sub.submitted_on and sub.assignment_id.due_date: + sub.is_late = sub.submitted_on > sub.assignment_id.due_date + else: + sub.is_late = False + + @api.constrains('score', 'assignment_id') + def _check_score(self): + for sub in self: + if sub.score < 0: + raise ValidationError('Score cannot be negative.') + if sub.assignment_id and sub.score > sub.assignment_id.max_score: + raise ValidationError( + f'Score ({sub.score}) cannot exceed max score ({sub.assignment_id.max_score}).' + ) + + def action_grade(self): + for sub in self: + sub.state = 'graded' diff --git a/addons/community_school/security/ir.model.access.csv b/addons/community_school/security/ir.model.access.csv index 3a4cc6e..edcd277 100644 --- a/addons/community_school/security/ir.model.access.csv +++ b/addons/community_school/security/ir.model.access.csv @@ -15,3 +15,14 @@ access_community_school_student_portal,community.school.student portal read,mode access_community_school_enrollment_portal,community.school.enrollment portal read,model_community_school_enrollment,base.group_portal,1,0,0,0 access_community_school_attendance_portal,community.school.attendance portal read,model_community_school_attendance,base.group_portal,1,0,0,0 access_community_school_class_portal,community.school.class portal read,model_community_school_class,base.group_portal,1,0,0,0 +access_community_school_assignment_coordinator,community.school.assignment coordinator,model_community_school_assignment,group_school_coordinator,1,1,1,1 +access_community_school_assignment_teacher,community.school.assignment teacher,model_community_school_assignment,group_school_teacher,1,1,1,0 +access_community_school_assignment_portal,community.school.assignment portal read,model_community_school_assignment,base.group_portal,1,0,0,0 +access_community_school_submission_coordinator,community.school.submission coordinator,model_community_school_submission,group_school_coordinator,1,1,1,1 +access_community_school_submission_teacher,community.school.submission teacher,model_community_school_submission,group_school_teacher,1,1,1,0 +access_community_school_submission_portal,community.school.submission portal,model_community_school_submission,base.group_portal,1,1,1,0 +access_community_school_class_student,community.school.class student,model_community_school_class,group_school_student,1,0,0,0 +access_community_school_student_student,community.school.student student,model_community_school_student,group_school_student,1,0,0,0 +access_community_school_enrollment_student,community.school.enrollment student,model_community_school_enrollment,group_school_student,1,0,0,0 +access_community_school_assignment_student,community.school.assignment student,model_community_school_assignment,group_school_student,1,0,0,0 +access_community_school_submission_student,community.school.submission student,model_community_school_submission,group_school_student,1,1,1,0 diff --git a/addons/community_school/security/school_security.xml b/addons/community_school/security/school_security.xml index 041c8ad..f8ed2e2 100644 --- a/addons/community_school/security/school_security.xml +++ b/addons/community_school/security/school_security.xml @@ -23,4 +23,67 @@ Can take attendance for their own classes. + + + Student + + + Student access to view assignments and submit work. + + + + Teacher Classes + + [('teacher_id.user_ids', 'in', [user.id])] + + + + Teacher Assignments + + [('teacher_id.user_ids', 'in', [user.id])] + + + + Teacher Submissions + + [('assignment_id.teacher_id.user_ids', 'in', [user.id])] + + + + + Student Classes + + [('enrollment_ids.student_id.partner_id.user_ids', 'in', [user.id])] + + + + Student Assignments + + [('class_id.enrollment_ids.student_id.partner_id.user_ids', 'in', [user.id])] + + + + Student Submissions + + [('student_id.partner_id.user_ids', 'in', [user.id])] + + + + Coordinator Classes + + [(1, '=', 1)] + + + + Coordinator Assignments + + [(1, '=', 1)] + + + + Coordinator Submissions + + [(1, '=', 1)] + + diff --git a/addons/community_school/views/portal_assignment_templates.xml b/addons/community_school/views/portal_assignment_templates.xml new file mode 100644 index 0000000..c21d27a --- /dev/null +++ b/addons/community_school/views/portal_assignment_templates.xml @@ -0,0 +1,437 @@ + + + + + + + + + + + + + + + + + + + +