feat(education_student): add secure profile and document management

This commit is contained in:
Vidhya 2026-10-14 00:47:07 +05:30
parent ff2d7903b6
commit 1277f7af43
25 changed files with 2880 additions and 132 deletions

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
import base64
import json
import logging
import mimetypes
import re
from datetime import datetime, date
# pyrefly: ignore [missing-import]
@ -91,32 +92,110 @@ class MobileStudent(http.Controller):
return {"id": record.id, "name": record.name}
return None
def _format_profile(self, student):
return {
"id": student.id,
"student_id": student.student_id,
"admission_number": student.admission_number,
"registration_number": student.registration_number or None,
"roll_number": student.roll_number or None,
"full_name": student.full_name,
"first_name": student.first_name,
"middle_name": student.middle_name or None,
"last_name": student.last_name,
"email": student.email,
"mobile": student.mobile,
"phone": student.phone or None,
"gender": student.gender,
"date_of_birth": str(student.date_of_birth) if student.date_of_birth else None,
"blood_group": student.blood_group or None,
"emergency_contact": student.emergency_contact or None,
"emergency_phone": student.emergency_phone or None,
"street": student.street or None,
"street2": student.street2 or None,
"city": student.city or None,
"zip": student.zip or None,
"state": self._get_rel(student.state_id),
"country": self._get_rel(student.country_id),
"guardian": {
"name": student.guardian_name or None,
"relation": student.guardian_relation or None,
"phone": student.guardian_phone or None,
"mobile": student.guardian_mobile or None,
"email": student.guardian_email or None,
"address": student.guardian_address or None,
},
"status": student.status,
"admission_date": str(student.admission_date) if student.admission_date else None,
"enrollment_date": str(student.enrollment_date) if student.enrollment_date else None,
"profile_completion": getattr(student, 'profile_completion', 0),
"has_photo": bool(student.image),
"campus": self._get_rel(student.campus_id),
"department": self._get_rel(student.department_id),
"program": self._get_rel(student.program_id),
"level": self._get_rel(student.level_id),
"academic_year": self._get_rel(student.academic_year_id),
"academic_term": self._get_rel(student.academic_term_id),
"batch": self._get_rel(student.batch_id),
"semester": self._get_rel(student.semester_id),
"section": self._get_rel(student.section_id),
"editable_fields": [
"email", "mobile", "phone", "blood_group",
"emergency_contact", "emergency_phone",
"street", "street2", "city", "zip", "image"
],
"protected_fields": [
"student_id", "admission_number", "registration_number", "roll_number",
"campus", "department", "program", "level",
"academic_year", "academic_term", "batch", "semester", "section", "status"
]
}
@http.route('/api/mobile/v1/student/profile', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_profile(self, student, **kwargs):
try:
data = {
"id": student.id,
"student_id": student.student_id,
"admission_number": student.admission_number,
"roll_number": student.roll_number,
"full_name": student.full_name,
"email": student.email,
"mobile": student.mobile,
"gender": student.gender,
"date_of_birth": student.date_of_birth,
"status": student.status,
"campus": self._get_rel(student.campus_id),
"department": self._get_rel(student.department_id),
"program": self._get_rel(student.program_id),
"level": self._get_rel(student.level_id),
"academic_year": self._get_rel(student.academic_year_id),
"academic_term": self._get_rel(student.academic_term_id),
"batch": self._get_rel(student.batch_id),
"semester": self._get_rel(student.semester_id),
"section": self._get_rel(student.section_id)
}
return json_response(True, data=data)
return json_response(True, data={"profile": self._format_profile(student), **self._format_profile(student)})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/profile', type='http', auth='public', methods=['PATCH', 'POST'], cors='*', csrf=False)
@require_student
def update_profile(self, student, **kwargs):
try:
raw_body = request.httprequest.data
vals = {}
if raw_body:
try:
vals = json.loads(raw_body.decode('utf-8'))
except Exception:
vals = {}
if not vals:
vals = dict(kwargs)
# Check protected fields
protected = [
'student_id', 'admission_number', 'registration_number', 'roll_number',
'program', 'program_id', 'department', 'department_id', 'campus', 'campus_id',
'level', 'level_id', 'academic_year', 'academic_year_id', 'academic_term', 'academic_term_id',
'batch', 'batch_id', 'semester', 'semester_id', 'section', 'section_id',
'status', 'company_id', 'admission_date', 'enrollment_date', 'graduation_date', 'leave_date'
]
for p in protected:
if p in vals:
return json_response(
False,
error_code="PROTECTED_FIELD_MODIFICATION",
error_message=f"Modifying protected academic or administrative field '{p}' is strictly prohibited.",
status=400
)
try:
student.sudo().update_student_profile(vals)
except Exception as e:
return json_response(False, error_code="VALIDATION_ERROR", error_message=str(e), status=400)
return json_response(True, data={"profile": self._format_profile(student), **self._format_profile(student)})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@ -690,3 +769,267 @@ class MobileStudent(http.Controller):
})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
# -------------------------------------------------------------------------
# Student Documents API
# -------------------------------------------------------------------------
@http.route('/api/mobile/v1/student/documents', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_documents(self, student, **kwargs):
try:
docs = request.env['education.student.document'].sudo().search([
('student_id', '=', student.id)
], order='upload_date desc, id desc')
data = [{
"id": doc.id,
"name": doc.name,
"category": doc.category,
"category_label": dict(doc._fields['category'].selection).get(doc.category, doc.category),
"file_name": doc.file_name,
"file_size": doc.file_size,
"mime_type": doc.mime_type,
"verification_status": doc.verification_status,
"upload_date": str(doc.upload_date or ''),
"verification_date": str(doc.verification_date or '') if doc.verification_date else None,
"rejection_reason": doc.rejection_reason or None,
} for doc in docs]
return json_response(True, data={"documents": data})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/documents', type='http', auth='public', methods=['POST'], cors='*', csrf=False)
@require_student
def upload_document(self, student, **kwargs):
try:
raw_body = request.httprequest.data
payload = {}
if raw_body:
try:
payload = json.loads(raw_body.decode('utf-8'))
except Exception:
payload = {}
if not payload:
payload = dict(kwargs)
file_content = None
filename = payload.get('file_name')
if 'file' in request.httprequest.files:
file_obj = request.httprequest.files['file']
filename = filename or file_obj.filename
file_content = base64.b64encode(file_obj.read()).decode('utf-8')
elif payload.get('file'):
file_content = payload.get('file')
if not file_content:
return json_response(False, error_code="MISSING_FILE", error_message="Document file is required.", status=400)
title = payload.get('name') or payload.get('title')
if not title:
title = filename or 'Student Document'
category = payload.get('category') or 'academic'
valid_categories = ['identity', 'academic', 'transfer', 'address', 'other']
if category not in valid_categories:
category = 'other'
mime_type = mimetypes.guess_type(filename or '')[0] or 'application/octet-stream'
doc = request.env['education.student.document'].sudo().create({
'name': title,
'student_id': student.id,
'category': category,
'document_file': file_content,
'file_name': filename or f"doc_{student.id}.pdf",
'mime_type': mime_type,
'verification_status': 'submitted',
})
return json_response(True, data={
"document": {
"id": doc.id,
"name": doc.name,
"category": doc.category,
"category_label": dict(doc._fields['category'].selection).get(doc.category, doc.category),
"file_name": doc.file_name,
"file_size": doc.file_size,
"mime_type": doc.mime_type,
"verification_status": doc.verification_status,
"upload_date": str(doc.upload_date or '')
}
}, status=201)
except Exception as e:
return json_response(False, error_code="UPLOAD_ERROR", error_message=str(e), status=400)
@http.route('/api/mobile/v1/student/documents/<int:document_id>', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_document_detail(self, student, document_id, **kwargs):
try:
doc = request.env['education.student.document'].sudo().browse(document_id)
if not doc.exists():
return json_response(False, error_code="NOT_FOUND", error_message="Document not found.", status=404)
if doc.student_id.id != student.id:
return json_response(False, error_code="ACCESS_DENIED", error_message="Unauthorized access to document.", status=403)
return json_response(True, data={
"document": {
"id": doc.id,
"name": doc.name,
"category": doc.category,
"category_label": dict(doc._fields['category'].selection).get(doc.category, doc.category),
"file_name": doc.file_name,
"file_size": doc.file_size,
"mime_type": doc.mime_type,
"verification_status": doc.verification_status,
"upload_date": str(doc.upload_date or ''),
"verification_date": str(doc.verification_date or '') if doc.verification_date else None,
"rejection_reason": doc.rejection_reason or None,
}
})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/documents/<int:document_id>/download', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def download_document(self, student, document_id, **kwargs):
try:
doc = request.env['education.student.document'].sudo().browse(document_id)
if not doc.exists():
return json_response(False, error_code="NOT_FOUND", error_message="Document not found.", status=404)
if doc.student_id.id != student.id:
return json_response(False, error_code="ACCESS_DENIED", error_message="Unauthorized access to document.", status=403)
if not doc.document_file:
return json_response(False, error_code="NOT_FOUND", error_message="Document content is empty.", status=404)
file_data = base64.b64decode(doc.document_file)
mime_type = doc.mime_type or 'application/octet-stream'
filename = doc.file_name or f"document_{doc.id}"
headers = [
('Content-Type', mime_type),
('Content-Disposition', f'inline; filename="{filename}"'),
('Content-Length', len(file_data)),
]
return request.make_response(file_data, headers=headers)
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
# -------------------------------------------------------------------------
# Student Certificates API
# -------------------------------------------------------------------------
@http.route('/api/mobile/v1/student/certificates', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_certificates(self, student, **kwargs):
try:
certs = request.env['education.student.certificate'].sudo().search([
('student_id', '=', student.id)
], order='id desc')
data = [{
"id": c.id,
"name": c.name or f"REQ-{c.id:04d}",
"certificate_type": c.certificate_type,
"certificate_type_label": dict(c._fields['certificate_type'].selection).get(c.certificate_type, c.certificate_type),
"state": c.state,
"request_date": str(c.request_date or ''),
"issue_date": str(c.issue_date or '') if c.issue_date else None,
"expiry_date": str(c.expiry_date or '') if c.expiry_date else None,
"file_name": c.file_name or None,
"remarks": c.remarks or None,
"rejection_reason": c.rejection_reason or None,
"has_file": bool(c.document_file),
} for c in certs]
return json_response(True, data={"certificates": data})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/certificates/request', type='http', auth='public', methods=['POST'], cors='*', csrf=False)
@require_student
def request_certificate(self, student, **kwargs):
try:
raw_body = request.httprequest.data
payload = {}
if raw_body:
try:
payload = json.loads(raw_body.decode('utf-8'))
except Exception:
payload = {}
if not payload:
payload = dict(kwargs)
cert_type = payload.get('certificate_type')
valid_types = ['bonafide', 'id_card', 'course_completion', 'conduct']
if not cert_type or cert_type not in valid_types:
return json_response(False, error_code="INVALID_TYPE", error_message=f"Invalid certificate type. Allowed: {', '.join(valid_types)}", status=400)
if student.status not in ('active', 'admitted'):
return json_response(False, error_code="INELIGIBLE_STATUS", error_message="Only active or admitted students may request certificates.", status=400)
remarks = payload.get('remarks') or ''
cert = request.env['education.student.certificate'].sudo().create({
'student_id': student.id,
'certificate_type': cert_type,
'remarks': remarks,
'state': 'requested',
})
return json_response(True, data={
"certificate": {
"id": cert.id,
"name": cert.name or f"REQ-{cert.id:04d}",
"certificate_type": cert.certificate_type,
"certificate_type_label": dict(cert._fields['certificate_type'].selection).get(cert.certificate_type, cert.certificate_type),
"state": cert.state,
"request_date": str(cert.request_date or ''),
"remarks": cert.remarks,
}
}, status=201)
except Exception as e:
return json_response(False, error_code="REQUEST_FAILED", error_message=str(e), status=400)
@http.route('/api/mobile/v1/student/certificates/<int:certificate_id>/download', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def download_certificate(self, student, certificate_id, **kwargs):
try:
cert = request.env['education.student.certificate'].sudo().browse(certificate_id)
if not cert.exists():
return json_response(False, error_code="NOT_FOUND", error_message="Certificate not found.", status=404)
if cert.student_id.id != student.id:
return json_response(False, error_code="ACCESS_DENIED", error_message="Unauthorized access to certificate.", status=403)
if cert.state != 'issued':
return json_response(False, error_code="NOT_ISSUED", error_message="Certificate has not been issued yet.", status=400)
if not cert.document_file:
report = None
if cert.certificate_type == 'bonafide':
report = request.env.ref('education_reports.action_report_bonafide_certificate', raise_if_not_found=False)
elif cert.certificate_type == 'id_card':
report = request.env.ref('education_reports.action_report_student_id_card', raise_if_not_found=False)
if report:
try:
pdf_content, _ = report.sudo()._render_qweb_pdf(student.ids)
if pdf_content:
cert.sudo().write({
'document_file': base64.b64encode(pdf_content),
'file_name': f"certificate_{cert.name or cert.id}.pdf"
})
except Exception:
pass
if not cert.document_file:
return json_response(False, error_code="NOT_FOUND", error_message="Certificate document file is not yet available.", status=404)
file_data = base64.b64decode(cert.document_file)
filename = cert.file_name or f"certificate_{cert.id}.pdf"
headers = [
('Content-Type', 'application/pdf'),
('Content-Disposition', f'inline; filename="{filename}"'),
('Content-Length', len(file_data)),
]
return request.make_response(file_data, headers=headers)
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)

View File

@ -42,5 +42,19 @@
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
<record id="rule_student_document_portal_own" model="ir.rule">
<field name="name">Portal Student: View and Manage Own Documents</field>
<field name="model_id" ref="education_student.model_education_student_document"/>
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
<record id="rule_student_certificate_portal_own" model="ir.rule">
<field name="name">Portal Student: View Own Certificates</field>
<field name="model_id" ref="education_student.model_education_student_certificate"/>
<field name="domain_force">[('student_id.portal_user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
</odoo>

View File

@ -17,3 +17,5 @@ access_fee_structure_portal,Fee Structure Portal,education_finance.model_educati
access_fee_structure_line_portal,Fee Structure Line Portal,education_finance.model_education_fee_structure_line,base.group_portal,1,0,0,0
access_fee_type_portal,Fee Type Portal,education_finance.model_education_fee_type,base.group_portal,1,0,0,0
access_fee_category_portal,Fee Category Portal,education_finance.model_education_fee_category,base.group_portal,1,0,0,0
access_student_document_portal,Student Document Portal Access,education_student.model_education_student_document,base.group_portal,1,1,1,0
access_student_certificate_portal,Student Certificate Portal Access,education_student.model_education_student_certificate,base.group_portal,1,0,1,0

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
17 access_fee_structure_line_portal Fee Structure Line Portal education_finance.model_education_fee_structure_line base.group_portal 1 0 0 0
18 access_fee_type_portal Fee Type Portal education_finance.model_education_fee_type base.group_portal 1 0 0 0
19 access_fee_category_portal Fee Category Portal education_finance.model_education_fee_category base.group_portal 1 0 0 0
20 access_student_document_portal Student Document Portal Access education_student.model_education_student_document base.group_portal 1 1 1 0
21 access_student_certificate_portal Student Certificate Portal Access education_student.model_education_student_certificate base.group_portal 1 0 1 0

View File

@ -18,6 +18,7 @@
'data/education_student_sequence.xml',
'views/academic_config_views.xml',
'views/student_views.xml',
'views/student_document_views.xml',
'data/education_student_menus_actions.xml',
],
'demo': [

View File

@ -26,4 +26,13 @@
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
<record id="seq_education_student_certificate" model="ir.sequence">
<field name="name">Education Student Certificate</field>
<field name="code">education.student.certificate</field>
<field name="prefix">CERT/%(year)s/</field>
<field name="padding">5</field>
<field name="number_next">1</field>
<field name="number_increment">1</field>
</record>
</odoo>

View File

@ -1,2 +1,4 @@
from . import student
from . import academic_config
from . import student_document
from . import student_certificate

View File

@ -138,6 +138,11 @@ class EducationStudent(models.Model):
doc_mark_sheets = fields.Binary(string='Mark Sheets')
doc_mark_sheets_name = fields.Char(string='Mark Sheets Filename')
# Document and Certificate Relational Links
document_ids = fields.One2many('education.student.document', 'student_id', string='Documents')
certificate_ids = fields.One2many('education.student.certificate', 'student_id', string='Certificates')
profile_completion = fields.Integer(string='Profile Completion (%)', compute='_compute_profile_completion')
status = fields.Selection([
('draft', 'Draft'),
('admitted', 'Admitted'),
@ -180,6 +185,20 @@ class EducationStudent(models.Model):
else:
rec.age = 0
@api.depends(
'first_name', 'last_name', 'email', 'mobile', 'gender', 'date_of_birth',
'blood_group', 'street', 'city', 'emergency_contact', 'emergency_phone', 'image'
)
def _compute_profile_completion(self):
core_fields = [
'first_name', 'last_name', 'email', 'mobile', 'gender', 'date_of_birth',
'blood_group', 'street', 'city', 'emergency_contact', 'emergency_phone', 'image'
]
total = len(core_fields)
for rec in self:
filled = sum(1 for f in core_fields if getattr(rec, f, False))
rec.profile_completion = int(round((filled / total) * 100))
@api.constrains('date_of_birth')
def _check_date_of_birth(self):
for rec in self:
@ -249,6 +268,47 @@ class EducationStudent(models.Model):
def action_cancel(self):
self.write({'status': 'cancelled', 'leave_date': fields.Date.today()})
def update_student_profile(self, vals):
self.ensure_one()
forbidden_fields = {
'student_id', 'admission_number', 'registration_number', 'roll_number',
'program_id', 'department_id', 'campus_id', 'level_id',
'academic_year_id', 'academic_term_id', 'batch_id', 'semester_id',
'section_id', 'status', 'company_id', 'admission_date',
'enrollment_date', 'graduation_date', 'leave_date'
}
for k in vals.keys():
if k in forbidden_fields:
raise ValidationError(f"Modifying protected administrative field '{k}' is strictly prohibited.")
allowed_fields = {
'email', 'mobile', 'phone', 'blood_group',
'emergency_contact', 'emergency_phone',
'street', 'street2', 'city', 'zip', 'image'
}
clean_vals = {k: v for k, v in vals.items() if k in allowed_fields}
if 'email' in clean_vals and clean_vals['email']:
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", str(clean_vals['email'])):
raise ValidationError('Please enter a valid email address.')
if 'mobile' in clean_vals and clean_vals['mobile']:
if not re.match(r"^\+?[0-9\s\-()]+$", str(clean_vals['mobile'])):
raise ValidationError('Please enter a valid mobile number.')
if 'emergency_phone' in clean_vals and clean_vals['emergency_phone']:
if not re.match(r"^\+?[0-9\s\-()]+$", str(clean_vals['emergency_phone'])):
raise ValidationError('Please enter a valid emergency phone number.')
if 'blood_group' in clean_vals and clean_vals['blood_group']:
bg = str(clean_vals['blood_group']).strip().lower()
valid_bgs = {'a+', 'a-', 'b+', 'b-', 'ab+', 'ab-', 'o+', 'o-'}
if bg in valid_bgs:
clean_vals['blood_group'] = bg
else:
raise ValidationError(f"Invalid blood group '{clean_vals['blood_group']}'. Must be one of A+, A-, B+, B-, AB+, AB-, O+, O-.")
if clean_vals:
self.write(clean_vals)
return True
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:

View File

@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# pyrefly: ignore [missing-import]
from odoo import api, fields, models
# pyrefly: ignore [missing-import]
from odoo.exceptions import ValidationError
import base64
class EducationStudentCertificate(models.Model):
_name = 'education.student.certificate'
_description = 'Student Certificate'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'issue_date desc, id desc'
name = fields.Char(string='Certificate Number', readonly=True, copy=False, index=True)
student_id = fields.Many2one(
'education.student',
string='Student',
required=True,
ondelete='cascade',
index=True,
tracking=True
)
certificate_type = fields.Selection([
('bonafide', 'Bonafide Certificate'),
('id_card', 'Student ID Card'),
('course_completion', 'Course Completion Certificate'),
('conduct', 'Conduct / Character Certificate'),
], string='Certificate Type', required=True, default='bonafide', tracking=True)
state = fields.Selection([
('draft', 'Draft'),
('requested', 'Requested'),
('approved', 'Approved'),
('issued', 'Issued'),
('rejected', 'Rejected'),
], string='Status', default='draft', required=True, tracking=True, index=True)
request_date = fields.Date(string='Request Date', default=fields.Date.context_today, readonly=True)
issue_date = fields.Date(string='Issue Date', readonly=True, tracking=True)
expiry_date = fields.Date(string='Expiry Date')
issued_by = fields.Many2one('res.users', string='Issued By', readonly=True)
document_file = fields.Binary(string='Certificate PDF', attachment=True, readonly=True)
file_name = fields.Char(string='Certificate Filename', readonly=True)
remarks = fields.Text(string='Student Purpose / Remarks')
rejection_reason = fields.Text(string='Rejection Reason')
company_id = fields.Many2one(
'res.company',
string='Company',
related='student_id.company_id',
store=True,
index=True
)
def action_request(self):
for rec in self:
if rec.student_id.status not in ('active', 'admitted'):
raise ValidationError("Only active or admitted students are eligible to request certificates.")
rec.state = 'requested'
def action_approve(self):
for rec in self:
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
raise ValidationError("Only authorized staff can approve certificate requests.")
rec.state = 'approved'
def action_issue(self):
for rec in self:
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
raise ValidationError("Only authorized staff can issue certificates.")
if rec.student_id.status not in ('active', 'admitted'):
raise ValidationError("Cannot issue official certificate for a student with inactive status.")
if not rec.name:
rec.name = self.env['ir.sequence'].next_by_code('education.student.certificate') or f"CERT-{rec.id}"
rec.issue_date = fields.Date.context_today(self)
rec.issued_by = self.env.user.id
rec.state = 'issued'
# Generate PDF using existing report if available
report = None
if rec.certificate_type == 'bonafide':
report = self.env.ref('education_reports.action_report_bonafide_certificate', raise_if_not_found=False)
elif rec.certificate_type == 'id_card':
report = self.env.ref('education_reports.action_report_student_id_card', raise_if_not_found=False)
if report:
try:
pdf_content, _ = report.sudo()._render_qweb_pdf(rec.student_id.ids)
if pdf_content:
rec.document_file = base64.b64encode(pdf_content)
clean_code = (rec.name or 'CERT').replace('/', '_')
rec.file_name = f"{clean_code}.pdf"
except Exception:
# Non-blocking if wkhtmltopdf not present in current environment
pass
def action_reject(self, reason=None):
for rec in self:
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
raise ValidationError("Only authorized staff can reject certificate requests.")
rec.state = 'rejected'
if reason:
rec.rejection_reason = reason

View File

@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
# pyrefly: ignore [missing-import]
from odoo import api, fields, models
# pyrefly: ignore [missing-import]
from odoo.exceptions import ValidationError
import base64
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB
class EducationStudentDocument(models.Model):
_name = 'education.student.document'
_description = 'Student Document'
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'upload_date desc, id desc'
name = fields.Char(string='Document Title', required=True, tracking=True)
student_id = fields.Many2one(
'education.student',
string='Student',
required=True,
ondelete='cascade',
index=True,
tracking=True
)
category = fields.Selection([
('identity', 'Identity Proof'),
('academic', 'Academic Certificate / Marksheet'),
('transfer', 'Transfer / Migration Certificate'),
('address', 'Address Proof'),
('other', 'Other Document'),
], string='Category', required=True, default='academic', tracking=True)
document_file = fields.Binary(string='Document File', required=True, attachment=True)
file_name = fields.Char(string='Filename')
file_size = fields.Integer(string='File Size (Bytes)', compute='_compute_file_metadata', store=True)
mime_type = fields.Char(string='MIME Type')
verification_status = fields.Selection([
('submitted', 'Submitted'),
('verified', 'Verified'),
('rejected', 'Rejected'),
], string='Verification Status', default='submitted', required=True, tracking=True, index=True)
verified_by = fields.Many2one('res.users', string='Verified By', readonly=True, tracking=True)
verification_date = fields.Datetime(string='Verification Date', readonly=True, tracking=True)
rejection_reason = fields.Text(string='Rejection Reason', tracking=True)
upload_date = fields.Datetime(string='Upload Date', default=fields.Datetime.now, readonly=True)
company_id = fields.Many2one(
'res.company',
string='Company',
related='student_id.company_id',
store=True,
index=True
)
@api.depends('document_file')
def _compute_file_metadata(self):
for rec in self:
if rec.document_file:
try:
decoded = base64.b64decode(rec.document_file)
rec.file_size = len(decoded)
except Exception:
rec.file_size = 0
else:
rec.file_size = 0
@api.constrains('document_file', 'file_name')
def _check_file_validity(self):
for rec in self:
if rec.document_file:
decoded = base64.b64decode(rec.document_file)
if len(decoded) > MAX_FILE_SIZE_BYTES:
raise ValidationError(f"File size exceeds the maximum allowed limit of 5 MB ({len(decoded)} bytes).")
if rec.file_name:
ext = rec.file_name.split('.')[-1].lower() if '.' in rec.file_name else ''
if ext not in ['pdf', 'jpg', 'jpeg', 'png']:
raise ValidationError("Invalid file type. Allowed formats: PDF, JPG, JPEG, PNG.")
def action_verify(self):
for rec in self:
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
raise ValidationError("Only authorized staff can verify student documents.")
rec.write({
'verification_status': 'verified',
'verified_by': self.env.user.id,
'verification_date': fields.Datetime.now(),
'rejection_reason': False,
})
def action_reject(self, reason="Document details could not be verified."):
for rec in self:
if not (self.env.is_admin() or self.env.user.has_group('education_student.group_education_student_user')):
raise ValidationError("Only authorized staff can reject student documents.")
rec.write({
'verification_status': 'rejected',
'verified_by': self.env.user.id,
'verification_date': fields.Datetime.now(),
'rejection_reason': reason,
})

View File

@ -8,3 +8,9 @@ access_education_semester_user,education.semester user,model_education_semester,
access_education_semester_manager,education.semester manager,model_education_semester,group_education_student_manager,1,1,1,1
access_education_section_user,education.section user,model_education_section,base.group_user,1,0,0,0
access_education_section_manager,education.section manager,model_education_section,group_education_student_manager,1,1,1,1
access_education_student_document_user,education.student.document user,model_education_student_document,group_education_student_user,1,1,1,0
access_education_student_document_manager,education.student.document manager,model_education_student_document,group_education_student_manager,1,1,1,1
access_education_student_document_admin,education.student.document admin,model_education_student_document,group_education_student_admin,1,1,1,1
access_education_student_certificate_user,education.student.certificate user,model_education_student_certificate,group_education_student_user,1,1,1,0
access_education_student_certificate_manager,education.student.certificate manager,model_education_student_certificate,group_education_student_manager,1,1,1,1
access_education_student_certificate_admin,education.student.certificate admin,model_education_student_certificate,group_education_student_admin,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
8 access_education_semester_manager education.semester manager model_education_semester group_education_student_manager 1 1 1 1
9 access_education_section_user education.section user model_education_section base.group_user 1 0 0 0
10 access_education_section_manager education.section manager model_education_section group_education_student_manager 1 1 1 1
11 access_education_student_document_user education.student.document user model_education_student_document group_education_student_user 1 1 1 0
12 access_education_student_document_manager education.student.document manager model_education_student_document group_education_student_manager 1 1 1 1
13 access_education_student_document_admin education.student.document admin model_education_student_document group_education_student_admin 1 1 1 1
14 access_education_student_certificate_user education.student.certificate user model_education_student_certificate group_education_student_user 1 1 1 0
15 access_education_student_certificate_manager education.student.certificate manager model_education_student_certificate group_education_student_manager 1 1 1 1
16 access_education_student_certificate_admin education.student.certificate admin model_education_student_certificate group_education_student_admin 1 1 1 1

View File

@ -24,4 +24,16 @@
<field name="model_id" ref="model_education_section"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
</record>
<record id="rule_education_student_document_company" model="ir.rule">
<field name="name">Education Student Document Company Rule</field>
<field name="model_id" ref="model_education_student_document"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
</record>
<record id="rule_education_student_certificate_company" model="ir.rule">
<field name="name">Education Student Certificate Company Rule</field>
<field name="model_id" ref="model_education_student_certificate"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', '=', user.company_id.id)]</field>
</record>
</odoo>

View File

@ -1 +1,2 @@
from . import test_student
from . import test_student_profile_security

View File

@ -49,13 +49,13 @@ class TestEducationStudent(TransactionCase):
'company_id': self.company.id,
})
self.batch = self.env['education.batch'].create({
'name': 'Batch 2025', 'code': 'B2025',
'name': 'Batch 2025 Test', 'code': 'B2025T',
})
self.semester = self.env['education.semester'].create({
'name': 'Semester 1', 'code': 'SEM1',
'name': 'Semester 1 Test', 'code': 'SEM1T',
})
self.section = self.env['education.section'].create({
'name': 'Section A', 'code': 'SEC-A',
'name': 'Section A Test', 'code': 'SEC-AT',
})
def test_student_create(self):

View File

@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
import base64
from odoo import fields
from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError, AccessError
class TestStudentProfileAndDocumentsSecurity(TransactionCase):
def setUp(self):
super().setUp()
self.company = self.env.company
self.campus = self.env['education.campus'].create({
'name': 'Main Campus',
'code': 'MC',
'company_id': self.company.id,
})
self.level = self.env['education.academic.level'].create({
'name': 'Undergraduate',
'code': 'UG',
'company_id': self.company.id,
})
self.department = self.env['education.department'].create({
'name': 'Computer Science',
'code': 'CS',
'campus_id': self.campus.id,
})
self.academic_year = self.env['education.academic.year'].create({
'name': '2025-2026',
'code': 'AY2025',
'start_date': '2025-06-01',
'end_date': '2026-05-31',
'company_id': self.company.id,
'state': 'draft',
})
self.academic_term = self.env['education.academic.term'].create({
'name': 'Semester 1',
'code': 'SEM1',
'academic_year_id': self.academic_year.id,
'start_date': '2025-06-01',
'end_date': '2025-09-30',
'sequence': 1,
'company_id': self.company.id,
})
self.program = self.env['education.program'].create({
'name': 'B.Tech CS',
'code': 'BT-CS',
'level_id': self.level.id,
'department_id': self.department.id,
'duration': 4,
'duration_unit': 'years',
'company_id': self.company.id,
})
self.batch = self.env['education.batch'].create({
'name': 'Sec Batch 2025',
'code': 'SB25',
})
self.semester = self.env['education.semester'].create({
'name': 'Sec Semester 1',
'code': 'SS1',
})
self.section = self.env['education.section'].create({
'name': 'Sec Section A',
'code': 'SSEC-A',
})
# Student A
self.student_a = self.env['education.student'].create({
'first_name': 'Alice',
'last_name': 'Smith',
'email': 'alice.smith@example.com',
'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.academic_term.id,
'batch_id': self.batch.id,
'semester_id': self.semester.id,
'section_id': self.section.id,
'status': 'active',
})
# Student B
self.student_b = self.env['education.student'].create({
'first_name': 'Bob',
'last_name': 'Jones',
'email': 'bob.jones@example.com',
'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.academic_term.id,
'batch_id': self.batch.id,
'semester_id': self.semester.id,
'section_id': self.section.id,
'status': 'draft',
})
# Staff user
self.staff_user = self.env['res.users'].create({
'name': 'Academic Officer',
'login': 'academic.officer@test.com',
'email': 'academic.officer@test.com',
'group_ids': [(6, 0, [
self.env.ref('base.group_user').id,
self.env.ref('education_student.group_education_student_user').id,
])]
})
# Portal user (student without internal staff group)
self.portal_user = self.env['res.users'].create({
'name': 'Portal User Student',
'login': 'portal.student@test.com',
'email': 'portal.student@test.com',
'group_ids': [(6, 0, [
self.env.ref('base.group_portal').id,
])]
})
def test_profile_completion_computation(self):
"""Test calculation of profile completion percentage."""
initial_completion = self.student_a.profile_completion
self.assertGreater(initial_completion, 0)
self.assertLessEqual(initial_completion, 100)
# Update several profile fields and verify increase
self.student_a.write({
'mobile': '+919876543210',
'gender': 'female',
'date_of_birth': '2004-05-15',
'blood_group': 'a+',
'street': '456 Campus Ave',
'city': 'Chennai',
'emergency_contact': 'Jane Smith',
'emergency_phone': '+919876543219',
})
updated_completion = self.student_a.profile_completion
self.assertGreater(updated_completion, initial_completion)
def test_update_student_profile_allowed_fields(self):
"""Test updating allowed self-service fields via update_student_profile."""
res = self.student_a.update_student_profile({
'mobile': '+919876543210',
'blood_group': 'o+',
'emergency_contact': 'Parent Contact',
'emergency_phone': '+919876543211',
'street': '123 College Road',
'city': 'Coimbatore',
'zip': '641001',
})
self.assertTrue(res)
self.assertEqual(self.student_a.mobile, '+919876543210')
self.assertEqual(self.student_a.blood_group, 'o+')
self.assertEqual(self.student_a.emergency_contact, 'Parent Contact')
self.assertEqual(self.student_a.city, 'Coimbatore')
def test_update_student_profile_forbidden_fields(self):
"""Test that modifying protected academic or administrative fields raises ValidationError."""
forbidden_attempts = [
{'student_id': 'NEW-STU-001'},
{'admission_number': 'NEW-ADM-001'},
{'registration_number': 'REG-001'},
{'roll_number': 'ROLL-001'},
{'status': 'graduated'},
{'program_id': self.program.id},
{'academic_year_id': self.academic_year.id},
]
for payload in forbidden_attempts:
with self.subTest(payload=payload):
with self.assertRaises(ValidationError):
self.student_a.update_student_profile(payload)
def test_update_student_profile_format_validation(self):
"""Test email and phone format validation in profile updates."""
with self.assertRaises(ValidationError):
self.student_a.update_student_profile({'email': 'invalid-email-address'})
with self.assertRaises(ValidationError):
self.student_a.update_student_profile({'mobile': 'invalid-mobile-letters-xyz'})
def test_document_creation_and_constraints(self):
"""Test student document upload validation."""
valid_pdf = base64.b64encode(b'%PDF-1.4 test document').decode('utf-8')
doc = self.env['education.student.document'].create({
'name': '10th Marksheet',
'student_id': self.student_a.id,
'category': 'academic',
'document_file': valid_pdf,
'file_name': 'marksheet_10th.pdf',
})
self.assertEqual(doc.verification_status, 'submitted')
self.assertGreater(doc.file_size, 0)
# Invalid file extension
with self.assertRaises(ValidationError):
self.env['education.student.document'].create({
'name': 'Executable Script',
'student_id': self.student_a.id,
'category': 'other',
'document_file': valid_pdf,
'file_name': 'payload.exe',
})
# Oversized file (>5MB)
oversized_data = base64.b64encode(b'A' * (6 * 1024 * 1024)).decode('utf-8')
with self.assertRaises(ValidationError):
self.env['education.student.document'].create({
'name': 'Huge Document',
'student_id': self.student_a.id,
'category': 'academic',
'document_file': oversized_data,
'file_name': 'huge.pdf',
})
def test_document_verification_workflow_and_security(self):
"""Test document verification is restricted to authorized staff."""
valid_pdf = base64.b64encode(b'%PDF-1.4 marksheet').decode('utf-8')
doc = self.env['education.student.document'].create({
'name': 'Transfer Certificate',
'student_id': self.student_a.id,
'category': 'transfer',
'document_file': valid_pdf,
'file_name': 'tc.pdf',
})
# Non-staff portal user cannot verify
with self.assertRaises(ValidationError):
doc.with_user(self.portal_user).action_verify()
# Staff user can verify
doc.with_user(self.staff_user).action_verify()
self.assertEqual(doc.verification_status, 'verified')
self.assertEqual(doc.verified_by, self.staff_user)
self.assertTrue(doc.verification_date)
# Staff user can reject with reason
doc.with_user(self.staff_user).action_reject(reason="Incomplete stamp on certificate.")
self.assertEqual(doc.verification_status, 'rejected')
self.assertEqual(doc.rejection_reason, "Incomplete stamp on certificate.")
def test_certificate_request_and_issuance_workflow(self):
"""Test certificate request by eligible student, staff approval and issuance."""
# Active student requests bonafide certificate
cert = self.env['education.student.certificate'].create({
'student_id': self.student_a.id,
'certificate_type': 'bonafide',
'remarks': 'For passport application',
})
cert.action_request()
self.assertEqual(cert.state, 'requested')
# Inactive / draft student cannot request certificate
draft_cert = self.env['education.student.certificate'].create({
'student_id': self.student_b.id,
'certificate_type': 'bonafide',
})
with self.assertRaises(ValidationError):
draft_cert.action_request()
# Non-staff cannot approve
with self.assertRaises(ValidationError):
cert.with_user(self.portal_user).action_approve()
# Staff approves and issues certificate
cert.with_user(self.staff_user).action_approve()
self.assertEqual(cert.state, 'approved')
cert.with_user(self.staff_user).action_issue()
self.assertEqual(cert.state, 'issued')
self.assertTrue(cert.name)
self.assertTrue(cert.name.startswith('CERT/'))
self.assertEqual(cert.issue_date, fields.Date.today())
self.assertEqual(cert.issued_by, self.staff_user)

View File

@ -0,0 +1,188 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================== STUDENT DOCUMENTS ==================== -->
<record id="view_education_student_document_tree" model="ir.ui.view">
<field name="name">education.student.document.tree</field>
<field name="model">education.student.document</field>
<field name="arch" type="xml">
<list string="Student Documents" decoration-info="verification_status == 'submitted'" decoration-success="verification_status == 'verified'" decoration-danger="verification_status == 'rejected'">
<field name="name"/>
<field name="student_id"/>
<field name="category"/>
<field name="file_name"/>
<field name="file_size" widget="integer"/>
<field name="upload_date"/>
<field name="verification_status" widget="badge" decoration-success="verification_status == 'verified'" decoration-danger="verification_status == 'rejected'" decoration-warning="verification_status == 'submitted'"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_education_student_document_form" model="ir.ui.view">
<field name="name">education.student.document.form</field>
<field name="model">education.student.document</field>
<field name="arch" type="xml">
<form string="Student Document">
<header>
<button name="action_verify" string="Verify" type="object" class="oe_highlight" invisible="verification_status == 'verified'" groups="education_student.group_education_student_user"/>
<button name="action_reject" string="Reject" type="object" invisible="verification_status == 'rejected'" groups="education_student.group_education_student_user"/>
<field name="verification_status" widget="statusbar" statusbar_visible="submitted,verified,rejected"/>
</header>
<sheet>
<group>
<group string="Document Details">
<field name="name"/>
<field name="student_id"/>
<field name="category"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group string="File Info">
<field name="document_file" filename="file_name"/>
<field name="file_name"/>
<field name="file_size"/>
<field name="mime_type"/>
<field name="upload_date"/>
</group>
</group>
<group string="Verification Details" invisible="verification_status == 'submitted'">
<group>
<field name="verified_by"/>
<field name="verification_date"/>
</group>
<group>
<field name="rejection_reason" invisible="verification_status != 'rejected'"/>
</group>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_education_student_document_search" model="ir.ui.view">
<field name="name">education.student.document.search</field>
<field name="model">education.student.document</field>
<field name="arch" type="xml">
<search string="Search Student Documents">
<field name="name" string="Document Title"/>
<field name="student_id"/>
<field name="category"/>
<filter string="Pending Verification" name="filter_submitted" domain="[('verification_status', '=', 'submitted')]"/>
<filter string="Verified" name="filter_verified" domain="[('verification_status', '=', 'verified')]"/>
<filter string="Rejected" name="filter_rejected" domain="[('verification_status', '=', 'rejected')]"/>
<group>
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
<filter string="Category" name="group_category" context="{'group_by': 'category'}"/>
<filter string="Status" name="group_status" context="{'group_by': 'verification_status'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_student_document" model="ir.actions.act_window">
<field name="name">Student Documents</field>
<field name="res_model">education.student.document</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_education_student_document_search"/>
<field name="context">{'search_default_filter_submitted': 1}</field>
</record>
<menuitem id="menu_education_student_document"
name="Student Documents"
parent="education_base.menu_education_base_root"
action="action_education_student_document"
sequence="15"
groups="education_student.group_education_student_user"/>
<!-- ==================== STUDENT CERTIFICATES ==================== -->
<record id="view_education_student_certificate_tree" model="ir.ui.view">
<field name="name">education.student.certificate.tree</field>
<field name="model">education.student.certificate</field>
<field name="arch" type="xml">
<list string="Student Certificates" decoration-info="state == 'requested'" decoration-warning="state == 'approved'" decoration-success="state == 'issued'" decoration-danger="state == 'rejected'">
<field name="name"/>
<field name="student_id"/>
<field name="certificate_type"/>
<field name="request_date"/>
<field name="issue_date"/>
<field name="state" widget="badge" decoration-success="state == 'issued'" decoration-danger="state == 'rejected'" decoration-warning="state == 'approved'" decoration-info="state == 'requested'"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_education_student_certificate_form" model="ir.ui.view">
<field name="name">education.student.certificate.form</field>
<field name="model">education.student.certificate</field>
<field name="arch" type="xml">
<form string="Student Certificate">
<header>
<button name="action_approve" string="Approve" type="object" class="oe_highlight" invisible="state != 'requested'" groups="education_student.group_education_student_user"/>
<button name="action_issue" string="Issue Official Certificate" type="object" class="btn-success" invisible="state not in ['draft', 'approved']" groups="education_student.group_education_student_user"/>
<button name="action_reject" string="Reject" type="object" invisible="state in ['issued', 'rejected']" groups="education_student.group_education_student_user"/>
<field name="state" widget="statusbar" statusbar_visible="draft,requested,approved,issued"/>
</header>
<sheet>
<group>
<group string="Certificate Info">
<field name="name"/>
<field name="student_id"/>
<field name="certificate_type"/>
<field name="remarks"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group string="Dates &amp; Issuance">
<field name="request_date"/>
<field name="issue_date"/>
<field name="expiry_date"/>
<field name="issued_by"/>
</group>
</group>
<group string="Generated Document" invisible="not document_file">
<field name="document_file" filename="file_name"/>
<field name="file_name" invisible="1"/>
</group>
<group string="Rejection Details" invisible="state != 'rejected'">
<field name="rejection_reason"/>
</group>
</sheet>
<chatter/>
</form>
</field>
</record>
<record id="view_education_student_certificate_search" model="ir.ui.view">
<field name="name">education.student.certificate.search</field>
<field name="model">education.student.certificate</field>
<field name="arch" type="xml">
<search string="Search Certificates">
<field name="name" string="Certificate Number"/>
<field name="student_id"/>
<field name="certificate_type"/>
<filter string="Requests Pending Approval" name="filter_requested" domain="[('state', '=', 'requested')]"/>
<filter string="Approved (Ready to Issue)" name="filter_approved" domain="[('state', '=', 'approved')]"/>
<filter string="Issued" name="filter_issued" domain="[('state', '=', 'issued')]"/>
<group>
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
<filter string="Type" name="group_type" context="{'group_by': 'certificate_type'}"/>
<filter string="Status" name="group_status" context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<record id="action_education_student_certificate" model="ir.actions.act_window">
<field name="name">Student Certificates</field>
<field name="res_model">education.student.certificate</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_education_student_certificate_search"/>
<field name="context">{'search_default_filter_requested': 1}</field>
</record>
<menuitem id="menu_education_student_certificate"
name="Certificates &amp; ID Cards"
parent="education_base.menu_education_base_root"
action="action_education_student_certificate"
sequence="16"
groups="education_student.group_education_student_user"/>
</odoo>

View File

@ -142,6 +142,7 @@
</group>
<group>
<field name="company_id" groups="base.group_multi_company"/>
<field name="profile_completion" widget="progressbar"/>
</group>
</group>
@ -251,24 +252,34 @@
<!-- Documents Tab -->
<page string="Documents" name="documents">
<group>
<group string="Identity Documents">
<field name="doc_passport" filename="doc_passport_name"/>
<field name="doc_passport_name" invisible="1"/>
<field name="doc_aadhaar" filename="doc_aadhaar_name"/>
<field name="doc_aadhaar_name" invisible="1"/>
</group>
<group string="Certificates">
<field name="doc_birth_cert" filename="doc_birth_cert_name"/>
<field name="doc_birth_cert_name" invisible="1"/>
<field name="doc_transfer_cert" filename="doc_transfer_cert_name"/>
<field name="doc_transfer_cert_name" invisible="1"/>
<field name="doc_community_cert" filename="doc_community_cert_name"/>
<field name="doc_community_cert_name" invisible="1"/>
<field name="doc_mark_sheets" filename="doc_mark_sheets_name"/>
<field name="doc_mark_sheets_name" invisible="1"/>
</group>
</group>
<field name="document_ids">
<list string="Uploaded Documents" decoration-info="verification_status == 'submitted'" decoration-success="verification_status == 'verified'" decoration-danger="verification_status == 'rejected'">
<field name="name"/>
<field name="category"/>
<field name="file_name"/>
<field name="file_size"/>
<field name="upload_date"/>
<field name="verification_status" widget="badge" decoration-success="verification_status == 'verified'" decoration-danger="verification_status == 'rejected'" decoration-warning="verification_status == 'submitted'"/>
<button name="action_verify" type="object" string="Verify" invisible="verification_status == 'verified'" class="btn-primary"/>
<button name="action_reject" type="object" string="Reject" invisible="verification_status == 'rejected'"/>
</list>
</field>
</page>
<!-- Certificates Tab -->
<page string="Certificates" name="certificates">
<field name="certificate_ids">
<list string="Certificates &amp; ID Cards" decoration-info="state == 'requested'" decoration-warning="state == 'approved'" decoration-success="state == 'issued'" decoration-danger="state == 'rejected'">
<field name="name"/>
<field name="certificate_type"/>
<field name="request_date"/>
<field name="issue_date"/>
<field name="state" widget="badge" decoration-success="state == 'issued'" decoration-danger="state == 'rejected'" decoration-warning="state == 'approved'" decoration-info="state == 'requested'"/>
<button name="action_approve" type="object" string="Approve" invisible="state != 'requested'" class="btn-primary"/>
<button name="action_issue" type="object" string="Issue" invisible="state not in ['draft', 'approved']" class="btn-success"/>
<button name="action_reject" type="object" string="Reject" invisible="state in ['issued', 'rejected']"/>
</list>
</field>
</page>
<!-- Notes Tab -->

View File

@ -113,6 +113,21 @@ class ApiClient {
}
}
Future<dynamic> patch(
String path, {
dynamic data,
}) async {
try {
final response = await _dio.patch(
path,
data: data,
);
return _handleResponse(response);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
dynamic _handleResponse(Response response) {
final data = response.data;
if (data is Map<String, dynamic>) {

View File

@ -21,4 +21,12 @@ class ApiEndpoints {
static const String feeInvoices = '/api/mobile/v1/student/fees/invoices';
static String feeInvoiceDetail(int id) => '/api/mobile/v1/student/fees/invoices/$id';
static const String feePayments = '/api/mobile/v1/student/fees/payments';
// Documents & Certificates
static const String documents = '/api/mobile/v1/student/documents';
static String documentDetail(int id) => '/api/mobile/v1/student/documents/$id';
static String documentDownload(int id) => '/api/mobile/v1/student/documents/$id/download';
static const String certificates = '/api/mobile/v1/student/certificates';
static const String certificateRequest = '/api/mobile/v1/student/certificates/request';
static String certificateDownload(int id) => '/api/mobile/v1/student/certificates/$id/download';
}

File diff suppressed because it is too large Load Diff

View File

@ -51,13 +51,30 @@ class StudentProfile {
final int id;
final String studentId;
final String admissionNumber;
final String? registrationNumber;
final String? rollNumber;
final String fullName;
final String? email;
final String? mobile;
final String? phone;
final String? gender;
final String? dateOfBirth;
final String? bloodGroup;
final String? emergencyContact;
final String? emergencyPhone;
final String? street;
final String? street2;
final String? city;
final String? zip;
final NamedRecord? state;
final NamedRecord? country;
final String? guardianName;
final String? guardianRelation;
final String? guardianPhone;
final String? guardianEmail;
final String status;
final int profileCompletion;
final bool hasPhoto;
final NamedRecord? campus;
final NamedRecord? department;
@ -69,17 +86,37 @@ class StudentProfile {
final NamedRecord? semester;
final NamedRecord? section;
final List<String> editableFields;
final List<String> protectedFields;
const StudentProfile({
required this.id,
required this.studentId,
required this.admissionNumber,
this.registrationNumber,
this.rollNumber,
required this.fullName,
this.email,
this.mobile,
this.phone,
this.gender,
this.dateOfBirth,
this.bloodGroup,
this.emergencyContact,
this.emergencyPhone,
this.street,
this.street2,
this.city,
this.zip,
this.state,
this.country,
this.guardianName,
this.guardianRelation,
this.guardianPhone,
this.guardianEmail,
required this.status,
this.profileCompletion = 0,
this.hasPhoto = false,
this.campus,
this.department,
this.program,
@ -89,29 +126,169 @@ class StudentProfile {
this.batch,
this.semester,
this.section,
this.editableFields = const [],
this.protectedFields = const [],
});
factory StudentProfile.fromJson(Map<String, dynamic> json) {
// If the data is nested under 'profile', unpack it
final data = json.containsKey('profile') && json['profile'] is Map<String, dynamic>
? json['profile'] as Map<String, dynamic>
: json;
final guardianMap = SafeConvert.toNullableMap(data['guardian']);
final editable = (data['editable_fields'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
const [];
final protected = (data['protected_fields'] as List<dynamic>?)
?.map((e) => e.toString())
.toList() ??
const [];
return StudentProfile(
id: SafeConvert.toSafeInt(json['id']),
studentId: SafeConvert.toSafeString(json['student_id']),
admissionNumber: SafeConvert.toSafeString(json['admission_number']),
rollNumber: SafeConvert.toNullableString(json['roll_number']),
fullName: SafeConvert.toSafeString(json['full_name']),
email: SafeConvert.toNullableString(json['email']),
mobile: SafeConvert.toNullableString(json['mobile']),
gender: SafeConvert.toNullableString(json['gender']),
dateOfBirth: SafeConvert.toNullableString(json['date_of_birth']),
status: SafeConvert.toSafeString(json['status'], 'draft'),
campus: NamedRecord.fromNullableJson(json['campus']),
department: NamedRecord.fromNullableJson(json['department']),
program: NamedRecord.fromNullableJson(json['program']),
level: NamedRecord.fromNullableJson(json['level']),
academicYear: NamedRecord.fromNullableJson(json['academic_year']),
academicTerm: NamedRecord.fromNullableJson(json['academic_term']),
batch: NamedRecord.fromNullableJson(json['batch']),
semester: NamedRecord.fromNullableJson(json['semester']),
section: NamedRecord.fromNullableJson(json['section']),
id: SafeConvert.toSafeInt(data['id']),
studentId: SafeConvert.toSafeString(data['student_id']),
admissionNumber: SafeConvert.toSafeString(data['admission_number']),
registrationNumber: SafeConvert.toNullableString(data['registration_number']),
rollNumber: SafeConvert.toNullableString(data['roll_number']),
fullName: SafeConvert.toSafeString(data['full_name']),
email: SafeConvert.toNullableString(data['email']),
mobile: SafeConvert.toNullableString(data['mobile']),
phone: SafeConvert.toNullableString(data['phone']),
gender: SafeConvert.toNullableString(data['gender']),
dateOfBirth: SafeConvert.toNullableString(data['date_of_birth']),
bloodGroup: SafeConvert.toNullableString(data['blood_group']),
emergencyContact: SafeConvert.toNullableString(data['emergency_contact']),
emergencyPhone: SafeConvert.toNullableString(data['emergency_phone']),
street: SafeConvert.toNullableString(data['street']),
street2: SafeConvert.toNullableString(data['street2']),
city: SafeConvert.toNullableString(data['city']),
zip: SafeConvert.toNullableString(data['zip']),
state: NamedRecord.fromNullableJson(data['state']),
country: NamedRecord.fromNullableJson(data['country']),
guardianName: guardianMap != null ? SafeConvert.toNullableString(guardianMap['name']) : null,
guardianRelation: guardianMap != null ? SafeConvert.toNullableString(guardianMap['relation']) : null,
guardianPhone: guardianMap != null ? SafeConvert.toNullableString(guardianMap['phone']) : null,
guardianEmail: guardianMap != null ? SafeConvert.toNullableString(guardianMap['email']) : null,
status: SafeConvert.toSafeString(data['status'], 'draft'),
profileCompletion: SafeConvert.toSafeInt(data['profile_completion']),
hasPhoto: SafeConvert.toSafeBool(data['has_photo']),
campus: NamedRecord.fromNullableJson(data['campus']),
department: NamedRecord.fromNullableJson(data['department']),
program: NamedRecord.fromNullableJson(data['program']),
level: NamedRecord.fromNullableJson(data['level']),
academicYear: NamedRecord.fromNullableJson(data['academic_year']),
academicTerm: NamedRecord.fromNullableJson(data['academic_term']),
batch: NamedRecord.fromNullableJson(data['batch']),
semester: NamedRecord.fromNullableJson(data['semester']),
section: NamedRecord.fromNullableJson(data['section']),
editableFields: editable,
protectedFields: protected,
);
}
}
class StudentDocument {
final int id;
final String name;
final String category;
final String categoryLabel;
final String? fileName;
final int fileSize;
final String? mimeType;
final String verificationStatus; // 'submitted', 'verified', 'rejected'
final String uploadDate;
final String? verificationDate;
final String? rejectionReason;
const StudentDocument({
required this.id,
required this.name,
required this.category,
required this.categoryLabel,
this.fileName,
this.fileSize = 0,
this.mimeType,
required this.verificationStatus,
required this.uploadDate,
this.verificationDate,
this.rejectionReason,
});
bool get isVerified => verificationStatus == 'verified';
bool get isRejected => verificationStatus == 'rejected';
bool get isSubmitted => verificationStatus == 'submitted';
factory StudentDocument.fromJson(Map<String, dynamic> json) {
return StudentDocument(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name']),
category: SafeConvert.toSafeString(json['category'], 'academic'),
categoryLabel: SafeConvert.toSafeString(json['category_label'], json['category'] ?? 'Academic'),
fileName: SafeConvert.toNullableString(json['file_name']),
fileSize: SafeConvert.toSafeInt(json['file_size']),
mimeType: SafeConvert.toNullableString(json['mime_type']),
verificationStatus: SafeConvert.toSafeString(json['verification_status'], 'submitted'),
uploadDate: SafeConvert.toSafeString(json['upload_date']),
verificationDate: SafeConvert.toNullableString(json['verification_date']),
rejectionReason: SafeConvert.toNullableString(json['rejection_reason']),
);
}
}
class StudentCertificate {
final int id;
final String name;
final String certificateType;
final String certificateTypeLabel;
final String state; // 'draft', 'requested', 'approved', 'issued', 'rejected'
final String requestDate;
final String? issueDate;
final String? expiryDate;
final String? fileName;
final String? remarks;
final String? rejectionReason;
final bool hasFile;
const StudentCertificate({
required this.id,
required this.name,
required this.certificateType,
required this.certificateTypeLabel,
required this.state,
required this.requestDate,
this.issueDate,
this.expiryDate,
this.fileName,
this.remarks,
this.rejectionReason,
this.hasFile = false,
});
bool get isIssued => state == 'issued';
bool get isApproved => state == 'approved';
bool get isRequested => state == 'requested';
bool get isRejected => state == 'rejected';
factory StudentCertificate.fromJson(Map<String, dynamic> json) {
return StudentCertificate(
id: SafeConvert.toSafeInt(json['id']),
name: SafeConvert.toSafeString(json['name']),
certificateType: SafeConvert.toSafeString(json['certificate_type']),
certificateTypeLabel: SafeConvert.toSafeString(
json['certificate_type_label'],
json['certificate_type'] ?? 'Certificate',
),
state: SafeConvert.toSafeString(json['state'], 'draft'),
requestDate: SafeConvert.toSafeString(json['request_date']),
issueDate: SafeConvert.toNullableString(json['issue_date']),
expiryDate: SafeConvert.toNullableString(json['expiry_date']),
fileName: SafeConvert.toNullableString(json['file_name']),
remarks: SafeConvert.toNullableString(json['remarks']),
rejectionReason: SafeConvert.toNullableString(json['rejection_reason']),
hasFile: SafeConvert.toSafeBool(json['has_file']),
);
}
}

View File

@ -0,0 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/student.dart';
import 'dashboard_provider.dart';
final studentDocumentsProvider = FutureProvider.autoDispose<List<StudentDocument>>((ref) async {
final repo = ref.watch(studentRepositoryProvider);
return await repo.getDocuments();
});
final studentCertificatesProvider = FutureProvider.autoDispose<List<StudentCertificate>>((ref) async {
final repo = ref.watch(studentRepositoryProvider);
return await repo.getCertificates();
});

View File

@ -14,8 +14,67 @@ class StudentRepository {
return StudentProfile.fromJson(SafeConvert.toNullableMap(data) ?? {});
}
Future<StudentProfile> updateProfile(Map<String, dynamic> fields) async {
final data = await _apiClient.patch(ApiEndpoints.profile, data: fields);
return StudentProfile.fromJson(SafeConvert.toNullableMap(data) ?? {});
}
Future<DashboardData> getDashboard() async {
final data = await _apiClient.get(ApiEndpoints.dashboard);
return DashboardData.fromJson(SafeConvert.toNullableMap(data) ?? {});
}
Future<List<StudentDocument>> getDocuments() async {
final data = await _apiClient.get(ApiEndpoints.documents);
final map = SafeConvert.toNullableMap(data) ?? {};
final list = map['documents'] as List<dynamic>? ?? [];
return list
.map((e) => StudentDocument.fromJson(SafeConvert.toNullableMap(e) ?? {}))
.toList();
}
Future<StudentDocument> uploadDocument({
required String name,
required String category,
required String fileContentBase64,
required String fileName,
}) async {
final data = await _apiClient.post(
ApiEndpoints.documents,
data: {
'name': name,
'category': category,
'file': fileContentBase64,
'file_name': fileName,
},
);
final map = SafeConvert.toNullableMap(data) ?? {};
final docData = SafeConvert.toNullableMap(map['document']) ?? map;
return StudentDocument.fromJson(docData);
}
Future<List<StudentCertificate>> getCertificates() async {
final data = await _apiClient.get(ApiEndpoints.certificates);
final map = SafeConvert.toNullableMap(data) ?? {};
final list = map['certificates'] as List<dynamic>? ?? [];
return list
.map((e) => StudentCertificate.fromJson(SafeConvert.toNullableMap(e) ?? {}))
.toList();
}
Future<StudentCertificate> requestCertificate({
required String certificateType,
String? remarks,
}) async {
final data = await _apiClient.post(
ApiEndpoints.certificateRequest,
data: {
'certificate_type': certificateType,
if (remarks != null && remarks.isNotEmpty) 'remarks': remarks,
},
);
final map = SafeConvert.toNullableMap(data) ?? {};
final certData = SafeConvert.toNullableMap(map['certificate']) ?? map;
return StudentCertificate.fromJson(certData);
}
}

View File

@ -188,7 +188,58 @@ void main() {
stdout.writeln('--> Fee Payments verified: ${paymentsRes.payments.length} payments found (first: ${firstPay.receiptNumber} - amount: ${firstPay.amount})');
});
test('11. POST /api/mobile/v1/auth/logout ends session', () async {
test('11. Phase 10: Profile Update, Document Upload, and Certificate Request', () async {
// 1. Profile Update
final updatedProfile = await studentRepo.updateProfile({
'mobile': '+1-555-0199',
'blood_group': 'B+',
'emergency_contact': 'Jane Student',
'emergency_phone': '+1-555-0198',
'city': 'Metropolis',
});
expect(updatedProfile.mobile, '+1-555-0199');
expect(updatedProfile.bloodGroup?.toUpperCase(), 'B+');
expect(updatedProfile.emergencyContact, 'Jane Student');
expect(updatedProfile.city, 'Metropolis');
expect(updatedProfile.profileCompletion, greaterThanOrEqualTo(50));
stdout.writeln('--> Profile updated successfully. Completion: ${updatedProfile.profileCompletion}%');
// 2. Document Upload
final uploadedDoc = await studentRepo.uploadDocument(
name: 'Birth Certificate',
category: 'identity',
fileContentBase64: 'JVBERi0xLjQKJcTl8uXrCg==', // Dummy base64 PDF header
fileName: 'birth_cert.pdf',
);
expect(uploadedDoc.id, isPositive);
expect(uploadedDoc.name, 'Birth Certificate');
expect(uploadedDoc.verificationStatus, 'submitted');
stdout.writeln('--> Document uploaded: #${uploadedDoc.id} "${uploadedDoc.name}" [${uploadedDoc.verificationStatus}]');
// 3. Document Listing
final docs = await studentRepo.getDocuments();
expect(docs, isNotEmpty);
expect(docs.any((d) => d.id == uploadedDoc.id), isTrue);
stdout.writeln('--> Document list verified: ${docs.length} documents found');
// 4. Certificate Request
final requestedCert = await studentRepo.requestCertificate(
certificateType: 'bonafide',
remarks: 'Applying for bus pass',
);
expect(requestedCert.id, isPositive);
expect(requestedCert.certificateType, 'bonafide');
expect(requestedCert.state, 'requested');
stdout.writeln('--> Certificate requested: #${requestedCert.id} "${requestedCert.name}" [${requestedCert.state}]');
// 5. Certificate Listing
final certs = await studentRepo.getCertificates();
expect(certs, isNotEmpty);
expect(certs.any((c) => c.id == requestedCert.id), isTrue);
stdout.writeln('--> Certificate list verified: ${certs.length} certificates found');
});
test('12. POST /api/mobile/v1/auth/logout ends session', () async {
await authRepo.logout();
final cookie = await storage.getSessionCookie();
expect(cookie, isNull);

View File

@ -597,5 +597,129 @@ void main() {
expect(dashboard.fees?.currencySymbol, '₹');
expect(dashboard.fees?.nextDueDate, '2026-10-23');
});
test('parses extended StudentProfile with contact, guardian, and completion fields', () {
final json = {
'id': 4,
'student_id': 'STU-9999',
'admission_number': 'ADM-9999',
'registration_number': 'REG-2026-001',
'roll_number': 'ROLL-01',
'full_name': 'Demo Student',
'first_name': 'Demo',
'last_name': 'Student',
'email': 'demo.student@test.edu',
'mobile': '+919876543210',
'phone': '044-12345678',
'gender': 'male',
'date_of_birth': '2004-01-01',
'blood_group': 'o+',
'emergency_contact': 'Jane Doe',
'emergency_phone': '+919876543219',
'street': '123 College Road',
'street2': 'Apt 4B',
'city': 'Chennai',
'zip': '600001',
'state': {'id': 1, 'name': 'Tamil Nadu'},
'country': {'id': 2, 'name': 'India'},
'guardian': {
'name': 'John Doe',
'relation': 'Father',
'phone': '+919876543211',
'email': 'john.doe@test.com'
},
'status': 'active',
'profile_completion': 85,
'has_photo': true,
'campus': {'id': 11, 'name': 'Test Campus'},
'department': {'id': 11, 'name': 'Test Dept'},
'program': {'id': 11, 'name': 'Test Program'},
'editable_fields': ['email', 'mobile', 'phone', 'blood_group'],
'protected_fields': ['student_id', 'admission_number', 'status']
};
final profile = StudentProfile.fromJson(json);
expect(profile.id, 4);
expect(profile.studentId, 'STU-9999');
expect(profile.admissionNumber, 'ADM-9999');
expect(profile.registrationNumber, 'REG-2026-001');
expect(profile.rollNumber, 'ROLL-01');
expect(profile.fullName, 'Demo Student');
expect(profile.email, 'demo.student@test.edu');
expect(profile.mobile, '+919876543210');
expect(profile.phone, '044-12345678');
expect(profile.bloodGroup, 'o+');
expect(profile.emergencyContact, 'Jane Doe');
expect(profile.emergencyPhone, '+919876543219');
expect(profile.street, '123 College Road');
expect(profile.city, 'Chennai');
expect(profile.zip, '600001');
expect(profile.state?.name, 'Tamil Nadu');
expect(profile.guardianName, 'John Doe');
expect(profile.guardianRelation, 'Father');
expect(profile.profileCompletion, 85);
expect(profile.hasPhoto, isTrue);
expect(profile.editableFields, contains('mobile'));
expect(profile.protectedFields, contains('student_id'));
});
test('parses StudentDocument with status flags and category labels', () {
final json = {
'id': 1,
'name': 'Previous Marksheet',
'category': 'academic',
'category_label': 'Academic Certificate / Marksheet',
'file_name': 'marksheet.pdf',
'file_size': 204800,
'mime_type': 'application/pdf',
'verification_status': 'verified',
'upload_date': '2026-10-10 14:30:00',
'verification_date': '2026-10-11 10:00:00',
'rejection_reason': null
};
final doc = StudentDocument.fromJson(json);
expect(doc.id, 1);
expect(doc.name, 'Previous Marksheet');
expect(doc.category, 'academic');
expect(doc.categoryLabel, 'Academic Certificate / Marksheet');
expect(doc.fileName, 'marksheet.pdf');
expect(doc.fileSize, 204800);
expect(doc.mimeType, 'application/pdf');
expect(doc.verificationStatus, 'verified');
expect(doc.isVerified, isTrue);
expect(doc.isRejected, isFalse);
expect(doc.isSubmitted, isFalse);
expect(doc.uploadDate, '2026-10-10 14:30:00');
});
test('parses StudentCertificate with state helpers and types', () {
final json = {
'id': 1,
'name': 'CERT/2026/00001',
'certificate_type': 'bonafide',
'certificate_type_label': 'Bonafide Certificate',
'state': 'issued',
'request_date': '2026-10-10',
'issue_date': '2026-10-11',
'expiry_date': null,
'file_name': 'CERT_2026_00001.pdf',
'remarks': 'For passport application',
'rejection_reason': null,
'has_file': true
};
final cert = StudentCertificate.fromJson(json);
expect(cert.id, 1);
expect(cert.name, 'CERT/2026/00001');
expect(cert.certificateType, 'bonafide');
expect(cert.certificateTypeLabel, 'Bonafide Certificate');
expect(cert.state, 'issued');
expect(cert.isIssued, isTrue);
expect(cert.isApproved, isFalse);
expect(cert.isRequested, isFalse);
expect(cert.hasFile, isTrue);
expect(cert.remarks, 'For passport application');
});
});
}

View File

@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:metatron_student_erp/features/profile/profile_screen.dart';
import 'package:metatron_student_erp/models/student.dart';
import 'package:metatron_student_erp/providers/dashboard_provider.dart';
import 'package:metatron_student_erp/providers/student_provider.dart';
void main() {
const dummyProfile = StudentProfile(
id: 4,
studentId: 'STU-9999',
admissionNumber: 'ADM-9999',
registrationNumber: 'REG-001',
rollNumber: 'ROLL-01',
fullName: 'Demo Student',
email: 'demo.student@test.edu',
mobile: '+919876543210',
gender: 'male',
dateOfBirth: '2004-01-01',
bloodGroup: 'o+',
emergencyContact: 'Jane Doe',
emergencyPhone: '+919876543219',
city: 'Chennai',
street: '123 College Road',
zip: '600001',
status: 'active',
profileCompletion: 75,
hasPhoto: false,
campus: NamedRecord(id: 11, name: 'Test Campus'),
department: NamedRecord(id: 11, name: 'Computer Science'),
program: NamedRecord(id: 11, name: 'B.Tech CS'),
);
const dummyDoc = StudentDocument(
id: 1,
name: '10th Marksheet',
category: 'academic',
categoryLabel: 'Academic Certificate / Marksheet',
fileName: 'marksheet.pdf',
fileSize: 1024,
verificationStatus: 'verified',
uploadDate: '2026-10-10',
);
const dummyCert = StudentCertificate(
id: 1,
name: 'CERT/2026/00001',
certificateType: 'bonafide',
certificateTypeLabel: 'Bonafide Certificate',
state: 'issued',
requestDate: '2026-10-10',
issueDate: '2026-10-11',
remarks: 'Passport requirement',
hasFile: true,
);
Widget buildTestableScreen([List<Override>? overrides]) {
return ProviderScope(
overrides: overrides ??
[
studentProfileProvider.overrideWith((ref) async => dummyProfile),
studentDocumentsProvider.overrideWith((ref) async => [dummyDoc]),
studentCertificatesProvider.overrideWith((ref) async => [dummyCert]),
],
child: const MaterialApp(
home: ProfileScreen(),
),
);
}
group('ProfileScreen Widget Tests', () {
testWidgets('renders all 3 tabs: Profile, Documents, Certificates', (tester) async {
await tester.pumpWidget(buildTestableScreen());
await tester.pumpAndSettle();
expect(find.text('Profile'), findsOneWidget);
expect(find.text('Documents'), findsOneWidget);
expect(find.text('Certificates'), findsOneWidget);
});
testWidgets('renders profile academic and contact cards on Profile tab', (tester) async {
await tester.pumpWidget(buildTestableScreen());
await tester.pumpAndSettle();
expect(find.text('Demo Student'), findsOneWidget);
expect(find.text('ID: STU-9999'), findsOneWidget);
expect(find.text('75%'), findsOneWidget);
expect(find.text('Academic Details (Institutional)'), findsOneWidget);
expect(find.text('ADM-9999'), findsOneWidget);
expect(find.text('B.Tech CS'), findsOneWidget);
await tester.drag(find.byType(ListView).first, const Offset(0, -350));
await tester.pumpAndSettle();
expect(find.text('Contact & Emergency'), findsOneWidget);
expect(find.text('demo.student@test.edu'), findsOneWidget);
expect(find.text('+919876543210'), findsOneWidget);
expect(find.text('Jane Doe'), findsOneWidget);
});
testWidgets('tapping Edit Contact Info opens edit dialog with pre-filled fields', (tester) async {
await tester.pumpWidget(buildTestableScreen());
await tester.pumpAndSettle();
final editButton = find.text('Edit Contact Info');
expect(editButton, findsOneWidget);
await tester.tap(editButton);
await tester.pumpAndSettle();
expect(find.text('Edit Contact Info'), findsNWidgets(2)); // dialog title + button
expect(find.text('Save Changes'), findsOneWidget);
expect(find.text('Cancel'), findsOneWidget);
// Dismiss dialog
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Save Changes'), findsNothing);
});
testWidgets('Documents tab renders documents list and verification badge', (tester) async {
await tester.pumpWidget(buildTestableScreen());
await tester.pumpAndSettle();
// Switch to Documents tab
await tester.tap(find.text('Documents'));
await tester.pumpAndSettle();
expect(find.text('10th Marksheet'), findsOneWidget);
expect(find.text('VERIFIED'), findsOneWidget);
expect(find.text('Academic Certificate / Marksheet'), findsOneWidget);
expect(find.text('Upload Document'), findsOneWidget);
});
testWidgets('Documents tab renders empty state when list is empty', (tester) async {
await tester.pumpWidget(buildTestableScreen([
studentProfileProvider.overrideWith((ref) async => dummyProfile),
studentDocumentsProvider.overrideWith((ref) async => []),
studentCertificatesProvider.overrideWith((ref) async => [dummyCert]),
]));
await tester.pumpAndSettle();
await tester.tap(find.text('Documents'));
await tester.pumpAndSettle();
expect(find.text('No documents uploaded yet'), findsOneWidget);
});
testWidgets('Certificates tab renders certificates list and state badge', (tester) async {
await tester.pumpWidget(buildTestableScreen());
await tester.pumpAndSettle();
// Switch to Certificates tab
await tester.tap(find.text('Certificates'));
await tester.pumpAndSettle();
expect(find.text('Bonafide Certificate'), findsOneWidget);
expect(find.text('ISSUED'), findsOneWidget);
expect(find.text('Cert No: CERT/2026/00001'), findsOneWidget);
expect(find.text('Request Certificate'), findsOneWidget);
});
testWidgets('Certificates tab renders empty state when list is empty', (tester) async {
await tester.pumpWidget(buildTestableScreen([
studentProfileProvider.overrideWith((ref) async => dummyProfile),
studentDocumentsProvider.overrideWith((ref) async => [dummyDoc]),
studentCertificatesProvider.overrideWith((ref) async => []),
]));
await tester.pumpAndSettle();
await tester.tap(find.text('Certificates'));
await tester.pumpAndSettle();
expect(find.text('No certificates requested yet'), findsOneWidget);
});
testWidgets('renders loading state initially', (tester) async {
await tester.pumpWidget(buildTestableScreen([
studentProfileProvider.overrideWith(
(ref) => Future.delayed(const Duration(seconds: 2), () => dummyProfile),
),
studentDocumentsProvider.overrideWith((ref) async => [dummyDoc]),
studentCertificatesProvider.overrideWith((ref) async => [dummyCert]),
]));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pumpAndSettle();
});
testWidgets('renders error and retry button on profile failure', (tester) async {
await tester.pumpWidget(buildTestableScreen([
studentProfileProvider.overrideWith(
(ref) => Future.error('Network failure'),
),
studentDocumentsProvider.overrideWith((ref) async => [dummyDoc]),
studentCertificatesProvider.overrideWith((ref) async => [dummyCert]),
]));
await tester.pumpAndSettle();
expect(find.text('Failed to load profile details'), findsOneWidget);
expect(find.text('Retry'), findsOneWidget);
});
});
}