assignment submission updated
Some checks failed
CI / Brand-leak check (push) Has been cancelled
CI / flake8 / pylint-odoo / manifest completeness (push) Has been cancelled
CI / Install all modules with --test-enable (push) Has been cancelled

This commit is contained in:
Selvi 2026-10-06 20:36:21 +05:30
parent b04e39b202
commit 40f4b9dc81
11 changed files with 1049 additions and 3 deletions

View File

@ -1,6 +1,6 @@
{ {
'name': 'Community OS - School', 'name': 'Community OS - School',
'version': '19.0.1.0.0', 'version': '19.0.1.1.0',
'category': 'Website/Education', 'category': 'Website/Education',
'summary': 'Programs, levels, classes, enrollment, attendance and parent portal for any weekend/community school', 'summary': 'Programs, levels, classes, enrollment, attendance and parent portal for any weekend/community school',
'description': """ 'description': """
@ -35,9 +35,11 @@ with no code change.
'data/mail_templates.xml', 'data/mail_templates.xml',
'data/ir_cron.xml', 'data/ir_cron.xml',
'views/school_views.xml', 'views/school_views.xml',
'views/school_assignment_views.xml',
'views/school_menus.xml', 'views/school_menus.xml',
'views/res_config_settings_views.xml', 'views/res_config_settings_views.xml',
'views/portal_attendance_templates.xml', 'views/portal_attendance_templates.xml',
'views/portal_assignment_templates.xml',
'views/registration_templates.xml', 'views/registration_templates.xml',
], ],
'demo': [], 'demo': [],

View File

@ -1,2 +1,3 @@
from . import attendance from . import attendance
from . import registration from . import registration
from . import assignment

View File

@ -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/<int:assignment_id>/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/<int:assignment_id>/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/<int:submission_id>/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/<int:assignment_id>',
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,
})

View File

@ -5,4 +5,5 @@ from . import school_class
from . import school_student from . import school_student
from . import school_enrollment from . import school_enrollment
from . import school_attendance from . import school_attendance
from . import school_assignment
from . import res_config_settings from . import res_config_settings

View File

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

View File

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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
15 access_community_school_enrollment_portal community.school.enrollment portal read model_community_school_enrollment base.group_portal 1 0 0 0
16 access_community_school_attendance_portal community.school.attendance portal read model_community_school_attendance base.group_portal 1 0 0 0
17 access_community_school_class_portal community.school.class portal read model_community_school_class base.group_portal 1 0 0 0
18 access_community_school_assignment_coordinator community.school.assignment coordinator model_community_school_assignment group_school_coordinator 1 1 1 1
19 access_community_school_assignment_teacher community.school.assignment teacher model_community_school_assignment group_school_teacher 1 1 1 0
20 access_community_school_assignment_portal community.school.assignment portal read model_community_school_assignment base.group_portal 1 0 0 0
21 access_community_school_submission_coordinator community.school.submission coordinator model_community_school_submission group_school_coordinator 1 1 1 1
22 access_community_school_submission_teacher community.school.submission teacher model_community_school_submission group_school_teacher 1 1 1 0
23 access_community_school_submission_portal community.school.submission portal model_community_school_submission base.group_portal 1 1 1 0
24 access_community_school_class_student community.school.class student model_community_school_class group_school_student 1 0 0 0
25 access_community_school_student_student community.school.student student model_community_school_student group_school_student 1 0 0 0
26 access_community_school_enrollment_student community.school.enrollment student model_community_school_enrollment group_school_student 1 0 0 0
27 access_community_school_assignment_student community.school.assignment student model_community_school_assignment group_school_student 1 0 0 0
28 access_community_school_submission_student community.school.submission student model_community_school_submission group_school_student 1 1 1 0

View File

@ -23,4 +23,67 @@
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Can take attendance for their own classes.</field> <field name="comment">Can take attendance for their own classes.</field>
</record> </record>
<record id="group_school_student" model="res.groups">
<field name="name">Student</field>
<field name="privilege_id" ref="privilege_school"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">Student access to view assignments and submit work.</field>
</record>
<!-- Record Rules -->
<record id="rule_class_teacher" model="ir.rule">
<field name="name">Teacher Classes</field>
<field name="model_id" ref="model_community_school_class"/>
<field name="domain_force">[('teacher_id.user_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('group_school_teacher'))]"/>
</record>
<record id="rule_assignment_teacher" model="ir.rule">
<field name="name">Teacher Assignments</field>
<field name="model_id" ref="model_community_school_assignment"/>
<field name="domain_force">[('teacher_id.user_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('group_school_teacher'))]"/>
</record>
<record id="rule_submission_teacher" model="ir.rule">
<field name="name">Teacher Submissions</field>
<field name="model_id" ref="model_community_school_submission"/>
<field name="domain_force">[('assignment_id.teacher_id.user_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('group_school_teacher'))]"/>
</record>
<record id="rule_class_student" model="ir.rule">
<field name="name">Student Classes</field>
<field name="model_id" ref="model_community_school_class"/>
<field name="domain_force">[('enrollment_ids.student_id.partner_id.user_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('group_school_student'))]"/>
</record>
<record id="rule_assignment_student" model="ir.rule">
<field name="name">Student Assignments</field>
<field name="model_id" ref="model_community_school_assignment"/>
<field name="domain_force">[('class_id.enrollment_ids.student_id.partner_id.user_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('group_school_student'))]"/>
</record>
<record id="rule_submission_student" model="ir.rule">
<field name="name">Student Submissions</field>
<field name="model_id" ref="model_community_school_submission"/>
<field name="domain_force">[('student_id.partner_id.user_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('group_school_student'))]"/>
</record>
<record id="rule_class_coordinator" model="ir.rule">
<field name="name">Coordinator Classes</field>
<field name="model_id" ref="model_community_school_class"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_school_coordinator'))]"/>
</record>
<record id="rule_assignment_coordinator" model="ir.rule">
<field name="name">Coordinator Assignments</field>
<field name="model_id" ref="model_community_school_assignment"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_school_coordinator'))]"/>
</record>
<record id="rule_submission_coordinator" model="ir.rule">
<field name="name">Coordinator Submissions</field>
<field name="model_id" ref="model_community_school_submission"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_school_coordinator'))]"/>
</record>
</odoo> </odoo>

View File

@ -0,0 +1,437 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ═══════════════════════════════════════════════════════════
SHARED: No student record found
═══════════════════════════════════════════════════════════ -->
<template id="portal_no_student" name="School: No Student Profile">
<t t-call="website.layout">
<div class="container" style="max-width: 520px; margin-top: 60px;">
<p class="alert alert-info">
No student profile is linked to your account. Please contact the school coordinator.
</p>
</div>
</t>
</template>
<!-- ═══════════════════════════════════════════════════════════
TEACHER: Assignment List
═══════════════════════════════════════════════════════════ -->
<template id="portal_teacher_assignment_list" name="School: Teacher – My Assignments">
<t t-call="website.layout">
<div class="container o_school_assignments" style="max-width: 900px; margin-top: 32px; margin-bottom: 80px;">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">My Assignments</h2>
<a href="/school/assignments/teacher/new" class="btn btn-primary">
<i class="fa fa-plus me-1"/> New Assignment
</a>
</div>
<t t-if="not assignments">
<div class="alert alert-info">
No assignments yet. Click <strong>New Assignment</strong> to create one.
</div>
</t>
<t t-if="assignments">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Title</th>
<th>Class</th>
<th>Due Date</th>
<th>Status</th>
<th>Submissions</th>
<th></th>
</tr>
</thead>
<tbody>
<t t-foreach="assignments" t-as="asgn">
<tr>
<td>
<strong t-out="asgn.name"/>
</td>
<td t-out="asgn.class_id.name"/>
<td t-out="asgn.due_date" t-options='{"widget": "datetime"}'/>
<td>
<t t-if="asgn.state == 'draft'">
<span class="badge bg-secondary">Draft</span>
</t>
<t t-elif="asgn.state == 'published'">
<span class="badge bg-success">Published</span>
</t>
<t t-elif="asgn.state == 'closed'">
<span class="badge bg-dark">Closed</span>
</t>
</td>
<td>
<a t-attf-href="/school/assignments/teacher/#{asgn.id}/submissions"
class="badge bg-primary text-decoration-none">
<t t-out="asgn.submission_count"/> submitted
</a>
</td>
<td class="text-end">
<a t-attf-href="/school/assignments/teacher/#{asgn.id}/edit"
class="btn btn-sm btn-outline-secondary">Edit</a>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</div>
</t>
</template>
<!-- ═══════════════════════════════════════════════════════════
TEACHER: Create / Edit Assignment Form
═══════════════════════════════════════════════════════════ -->
<template id="portal_teacher_assignment_form" name="School: Teacher – Assignment Form">
<t t-call="website.layout">
<div class="container o_school_assignment_form" style="max-width: 700px; margin-top: 32px; margin-bottom: 80px;">
<h2>
<t t-if="not assignment">New Assignment</t>
<t t-else="">Edit Assignment</t>
</h2>
<form method="post" enctype="multipart/form-data" class="mt-4">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label class="form-label fw-semibold" for="asgn_title">Title <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="asgn_title" name="title" required="required"
t-att-value="assignment.name if assignment else ''"/>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="asgn_class">Class <span class="text-danger">*</span></label>
<select class="form-select" id="asgn_class" name="class_id" required="required">
<option value="">-- Select Class --</option>
<t t-foreach="classes" t-as="klass">
<option t-att-value="klass.id"
t-att-selected="'selected' if assignment and assignment.class_id.id == klass.id else None"
t-out="klass.name"/>
</t>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="asgn_due">Due Date &amp; Time <span class="text-danger">*</span></label>
<input type="datetime-local" class="form-control" id="asgn_due" name="due_date" required="required"
t-att-value="assignment.due_date.strftime('%Y-%m-%dT%H:%M') if assignment and assignment.due_date else ''"/>
</div>
<div class="mb-3">
<label class="form-label fw-semibold" for="asgn_score">Max Score</label>
<input type="number" class="form-control" id="asgn_score" name="max_score" min="1" max="1000"
t-att-value="assignment.max_score if assignment else 100"/>
</div>
<div class="mb-4">
<label class="form-label fw-semibold" for="asgn_desc">Instructions / Description</label>
<textarea class="form-control" id="asgn_desc" name="description" rows="6">
<t t-if="assignment and assignment.description" t-out="assignment.description"/>
</textarea>
</div>
<div class="d-flex gap-2 flex-wrap">
<button type="submit" class="btn btn-primary" name="action_state" value="publish">
<t t-if="not assignment">Create &amp; Publish</t>
<t t-else="">Save &amp; Publish</t>
</button>
<t t-if="assignment">
<t t-if="assignment.state != 'draft'">
<button type="submit" class="btn btn-outline-secondary" name="action_state" value="draft">
Set to Draft
</button>
</t>
<t t-if="assignment.state != 'closed'">
<button type="submit" class="btn btn-outline-dark" name="action_state" value="close">
Close Assignment
</button>
</t>
</t>
<a href="/school/assignments/teacher" class="btn btn-link text-muted">Cancel</a>
</div>
</form>
</div>
</t>
</template>
<!-- ═══════════════════════════════════════════════════════════
TEACHER: View Submissions for an Assignment
═══════════════════════════════════════════════════════════ -->
<template id="portal_teacher_submissions" name="School: Teacher – Submissions">
<t t-call="website.layout">
<div class="container o_school_submissions" style="max-width: 950px; margin-top: 32px; margin-bottom: 80px;">
<a href="/school/assignments/teacher" class="btn btn-sm btn-outline-secondary mb-3">
← Back to Assignments
</a>
<h2 t-out="assignment.name"/>
<p class="text-muted mb-1">
Class: <strong t-out="assignment.class_id.name"/>
· Due: <span t-out="assignment.due_date" t-options='{"widget": "datetime"}'/>
· Max Score: <strong t-out="assignment.max_score"/>
</p>
<t t-if="not submissions">
<div class="alert alert-info mt-4">No submissions received yet.</div>
</t>
<t t-if="submissions">
<div class="table-responsive mt-4">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Student</th>
<th>Submitted On</th>
<th>Late?</th>
<th>File</th>
<th>Notes</th>
<th>Score</th>
<th>Status</th>
<th>Grade</th>
</tr>
</thead>
<tbody>
<t t-foreach="submissions" t-as="sub">
<tr>
<td t-out="sub.student_id.partner_id.name"/>
<td t-out="sub.submitted_on" t-options='{"widget": "datetime"}'/>
<td>
<t t-if="sub.is_late">
<span class="badge bg-warning text-dark">Late</span>
</t>
<t t-else="">
<span class="text-muted">—</span>
</t>
</td>
<td>
<t t-if="sub.attachment_name">
<a t-attf-href="/web/content/community.school.submission/#{sub.id}/attachment_data/#{sub.attachment_name}?download=true"
class="btn btn-sm btn-outline-primary">
<i class="fa fa-download me-1"/>
<t t-out="sub.attachment_name"/>
</a>
</t>
<t t-else=""><span class="text-muted">No file</span></t>
</td>
<td>
<t t-if="sub.notes">
<small t-out="sub.notes"/>
</t>
<t t-else=""><span class="text-muted">—</span></t>
</td>
<td>
<t t-if="sub.state == 'graded'">
<strong t-out="sub.score"/> / <t t-out="assignment.max_score"/>
</t>
<t t-else=""><span class="text-muted">—</span></t>
</td>
<td>
<t t-if="sub.state == 'graded'">
<span class="badge bg-success">Graded</span>
</t>
<t t-else="">
<span class="badge bg-secondary">Pending</span>
</t>
</td>
<td>
<!-- Inline grade form -->
<form t-attf-action="/school/assignments/teacher/submission/#{sub.id}/grade"
method="post" class="d-flex gap-1 align-items-center">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="number" name="score" class="form-control form-control-sm"
style="width: 64px;"
t-att-value="sub.score if sub.state == 'graded' else ''"
t-att-max="assignment.max_score" min="0" placeholder="Pts"/>
<input type="text" name="feedback" class="form-control form-control-sm"
style="width: 110px;"
t-att-value="sub.feedback or ''"
placeholder="Feedback"/>
<button type="submit" class="btn btn-sm btn-success">✓</button>
</form>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
</div>
</t>
</template>
<!-- ═══════════════════════════════════════════════════════════
STUDENT: Assignment List
═══════════════════════════════════════════════════════════ -->
<template id="portal_student_assignment_list" name="School: Student – Assignments">
<t t-call="website.layout">
<div class="container o_school_student_assignments" style="max-width: 860px; margin-top: 32px; margin-bottom: 80px;">
<h2 class="mb-4">My Assignments</h2>
<t t-if="not assignments">
<div class="alert alert-info">No assignments have been published for your class yet.</div>
</t>
<t t-if="assignments">
<div class="row g-3">
<t t-foreach="assignments" t-as="asgn">
<div class="col-md-6">
<t t-set="my_sub" t-value="submission_by_assignment.get(asgn.id)"/>
<div class="card h-100 shadow-sm border-0">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0" t-out="asgn.name"/>
<t t-if="my_sub and my_sub.state == 'graded'">
<span class="badge bg-success">Graded</span>
</t>
<t t-elif="my_sub">
<span class="badge bg-primary">Submitted</span>
</t>
<t t-else="">
<span class="badge bg-warning text-dark">Pending</span>
</t>
</div>
<p class="text-muted small mb-1">
Class: <strong t-out="asgn.class_id.name"/>
</p>
<p class="text-muted small mb-2">
Due: <strong t-out="asgn.due_date" t-options='{"widget": "datetime"}'/>
</p>
<t t-if="my_sub and my_sub.state == 'graded'">
<p class="mb-2">
Score: <strong t-out="my_sub.score"/> / <t t-out="asgn.max_score"/>
</p>
<t t-if="my_sub.feedback">
<p class="text-muted small mb-2">
Feedback: <em t-out="my_sub.feedback"/>
</p>
</t>
</t>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<a t-attf-href="/school/assignments/student/#{asgn.id}"
class="btn btn-sm btn-primary w-100">
<t t-if="not my_sub">View &amp; Submit</t>
<t t-else="">View Submission</t>
</a>
</div>
</div>
</div>
</t>
</div>
</t>
</div>
</t>
</template>
<!-- ═══════════════════════════════════════════════════════════
STUDENT: Assignment Detail & Upload Form
═══════════════════════════════════════════════════════════ -->
<template id="portal_student_assignment_detail" name="School: Student – Assignment Detail">
<t t-call="website.layout">
<div class="container o_school_assignment_detail" style="max-width: 720px; margin-top: 32px; margin-bottom: 80px;">
<a href="/school/assignments/student" class="btn btn-sm btn-outline-secondary mb-3">
← Back to Assignments
</a>
<!-- Assignment header -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h3 t-out="assignment.name"/>
<p class="text-muted mb-1">
Class: <strong t-out="assignment.class_id.name"/>
</p>
<p class="text-muted mb-1">
Due: <strong t-out="assignment.due_date" t-options='{"widget": "datetime"}'/>
</p>
<p class="text-muted mb-0">
Max Score: <strong t-out="assignment.max_score"/>
</p>
<t t-if="assignment.description">
<hr/>
<div t-out="assignment.description"/>
</t>
</div>
</div>
<!-- Already submitted view -->
<t t-if="submission">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5>Your Submission</h5>
<p class="text-muted small">
Submitted on: <t t-out="submission.submitted_on" t-options='{"widget": "datetime"}'/>
<t t-if="submission.is_late">
<span class="badge bg-warning text-dark ms-2">Late</span>
</t>
</p>
<t t-if="submission.notes">
<p><strong>Your Notes:</strong><br/><t t-out="submission.notes"/></p>
</t>
<t t-if="submission.attachment_name">
<a t-attf-href="/web/content/community.school.submission/#{submission.id}/attachment_data/#{submission.attachment_name}?download=true"
class="btn btn-outline-primary">
<i class="fa fa-download me-1"/> Download: <t t-out="submission.attachment_name"/>
</a>
</t>
<!-- Graded result -->
<t t-if="submission.state == 'graded'">
<hr/>
<div class="alert alert-success mb-0">
<h6 class="alert-heading">Graded ✓</h6>
<p class="mb-1">
Score: <strong t-out="submission.score"/> / <t t-out="assignment.max_score"/>
</p>
<t t-if="submission.feedback">
<p class="mb-0">Feedback: <em t-out="submission.feedback"/></p>
</t>
</div>
</t>
<t t-else="">
<div class="alert alert-info mt-3 mb-0">
Your submission is awaiting grading.
</div>
</t>
</div>
</div>
</t>
<!-- Submit form (shown only if not yet submitted) -->
<t t-if="not submission">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5>Submit Your Work</h5>
<form t-attf-action="/school/assignments/student/#{assignment.id}"
method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label class="form-label fw-semibold" for="sub_file">
Upload File <span class="text-muted fw-normal">(PDF, DOC, JPG, PNG, etc.)</span>
</label>
<input type="file" class="form-control" id="sub_file" name="submission_file"/>
</div>
<div class="mb-4">
<label class="form-label fw-semibold" for="sub_notes">Notes (optional)</label>
<textarea class="form-control" id="sub_notes" name="notes" rows="4"
placeholder="Add any notes or comments for your teacher…"/>
</div>
<button type="submit" class="btn btn-primary">
<i class="fa fa-upload me-1"/> Submit Assignment
</button>
</form>
</div>
</div>
</t>
</div>
</t>
</template>
</odoo>

View File

@ -0,0 +1,155 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ── Assignment list view ── -->
<record id="view_school_assignment_list" model="ir.ui.view">
<field name="name">community.school.assignment.list</field>
<field name="model">community.school.assignment</field>
<field name="arch" type="xml">
<list string="Assignments">
<field name="name"/>
<field name="class_id"/>
<field name="teacher_id"/>
<field name="due_date"/>
<field name="max_score"/>
<field name="submission_count" string="Submissions"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- ── Assignment form view ── -->
<record id="view_school_assignment_form" model="ir.ui.view">
<field name="name">community.school.assignment.form</field>
<field name="model">community.school.assignment</field>
<field name="arch" type="xml">
<form string="Assignment">
<header>
<button name="action_publish" string="Publish" type="object"
class="btn-primary" invisible="state != 'draft'"/>
<button name="action_close" string="Close" type="object"
invisible="state != 'published'"/>
<button name="action_reset_draft" string="Reset to Draft" type="object"
invisible="state == 'draft'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,published,closed"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_submissions" type="object"
class="oe_stat_button" icon="fa-upload">
<field name="submission_count" widget="statinfo" string="Submissions"/>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="Assignment Title…"/></h1>
</div>
<group>
<group>
<field name="class_id"/>
<field name="teacher_id" readonly="1"/>
<field name="term_id" readonly="1"/>
</group>
<group>
<field name="due_date"/>
<field name="max_score"/>
</group>
</group>
<notebook>
<page string="Instructions" name="description">
<field name="description" widget="html" options="{'collaborative': True}"/>
</page>
<page string="Submissions" name="submissions">
<field name="submission_ids" mode="list" readonly="1">
<list>
<field name="student_id"/>
<field name="submitted_on"/>
<field name="is_late" string="Late?"/>
<field name="attachment_name" string="File"/>
<field name="score"/>
<field name="state" widget="badge"/>
</list>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="action_school_assignment" model="ir.actions.act_window">
<field name="name">Assignments</field>
<field name="res_model">community.school.assignment</field>
<field name="view_mode">list,form</field>
</record>
<!-- ── Submission list view ── -->
<record id="view_school_submission_list" model="ir.ui.view">
<field name="name">community.school.submission.list</field>
<field name="model">community.school.submission</field>
<field name="arch" type="xml">
<list string="Submissions">
<field name="assignment_id"/>
<field name="student_id"/>
<field name="class_id"/>
<field name="submitted_on"/>
<field name="is_late" string="Late?"/>
<field name="attachment_name" string="File"/>
<field name="score"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<!-- ── Submission form view ── -->
<record id="view_school_submission_form" model="ir.ui.view">
<field name="name">community.school.submission.form</field>
<field name="model">community.school.submission</field>
<field name="arch" type="xml">
<form string="Submission">
<header>
<button name="action_grade" string="Mark as Graded" type="object"
class="btn-primary" invisible="state == 'graded'"/>
<field name="state" widget="statusbar" statusbar_visible="submitted,graded"/>
</header>
<sheet>
<group>
<group string="Assignment">
<field name="assignment_id"/>
<field name="class_id" readonly="1"/>
</group>
<group string="Student">
<field name="student_id"/>
<field name="enrollment_id"/>
</group>
</group>
<group>
<group string="Submission">
<field name="submitted_on"/>
<field name="is_late" readonly="1"/>
<field name="attachment_name" string="File Name" readonly="1"/>
<field name="attachment_data" string="File" widget="binary"
filename="attachment_name"/>
</group>
<group string="Grading">
<field name="score"/>
<field name="feedback"/>
</group>
</group>
<group string="Student Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_school_submission" model="ir.actions.act_window">
<field name="name">Submissions</field>
<field name="res_model">community.school.submission</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<menuitem id="menu_school_root" name="School" sequence="26" <menuitem id="menu_school_root" name="School" sequence="26"
groups="community_school.group_school_coordinator,community_school.group_school_teacher"/> groups="community_school.group_school_coordinator,community_school.group_school_teacher,community_school.group_school_student"/>
<menuitem id="menu_school_students" name="Students" <menuitem id="menu_school_students" name="Students"
parent="menu_school_root" action="action_school_student" sequence="10" parent="menu_school_root" action="action_school_student" sequence="10"
@ -9,7 +9,7 @@
<menuitem id="menu_school_classes" name="Classes" <menuitem id="menu_school_classes" name="Classes"
parent="menu_school_root" action="action_school_class" sequence="20" parent="menu_school_root" action="action_school_class" sequence="20"
groups="community_school.group_school_coordinator"/> groups="community_school.group_school_coordinator,community_school.group_school_teacher,community_school.group_school_student"/>
<menuitem id="menu_school_enrollments" name="Enrollments" <menuitem id="menu_school_enrollments" name="Enrollments"
parent="menu_school_root" action="action_school_enrollment" sequence="30" parent="menu_school_root" action="action_school_enrollment" sequence="30"
@ -23,6 +23,14 @@
parent="menu_school_root" action="action_school_attendance_report" sequence="40" parent="menu_school_root" action="action_school_attendance_report" sequence="40"
groups="community_school.group_school_coordinator"/> groups="community_school.group_school_coordinator"/>
<menuitem id="menu_school_assignments" name="Assignments"
parent="menu_school_root" action="action_school_assignment" sequence="50"
groups="community_school.group_school_coordinator,community_school.group_school_teacher,community_school.group_school_student"/>
<menuitem id="menu_school_submissions" name="Submissions"
parent="menu_school_root" action="action_school_submission" sequence="55"
groups="community_school.group_school_coordinator,community_school.group_school_teacher,community_school.group_school_student"/>
<menuitem id="menu_school_configuration" name="Configuration" <menuitem id="menu_school_configuration" name="Configuration"
parent="menu_school_root" sequence="90" parent="menu_school_root" sequence="90"
groups="community_school.group_school_coordinator"/> groups="community_school.group_school_coordinator"/>

View File

@ -228,4 +228,15 @@
<field name="res_model">community.school.attendance</field> <field name="res_model">community.school.attendance</field>
<field name="view_mode">pivot,list</field> <field name="view_mode">pivot,list</field>
</record> </record>
<!-- Partner (Teacher) -->
<record id="view_partner_form_school" model="ir.ui.view">
<field name="name">res.partner.form.school</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='category_id']" position="after">
<field name="is_teacher"/>
</xpath>
</field>
</record>
</odoo> </odoo>