feat(education_student): add secure profile and document management
This commit is contained in:
parent
ff2d7903b6
commit
1277f7af43
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
@ -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': [
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -1,2 +1,4 @@
|
||||
from . import student
|
||||
from . import academic_config
|
||||
from . import student_document
|
||||
from . import student_certificate
|
||||
|
||||
@ -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:
|
||||
|
||||
105
addons/education_student/models/student_certificate.py
Normal file
105
addons/education_student/models/student_certificate.py
Normal 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
|
||||
102
addons/education_student/models/student_document.py
Normal file
102
addons/education_student/models/student_document.py
Normal 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,
|
||||
})
|
||||
@ -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
|
||||
|
||||
|
@ -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>
|
||||
|
||||
@ -1 +1,2 @@
|
||||
from . import test_student
|
||||
from . import test_student_profile_security
|
||||
|
||||
@ -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):
|
||||
|
||||
275
addons/education_student/tests/test_student_profile_security.py
Normal file
275
addons/education_student/tests/test_student_profile_security.py
Normal 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)
|
||||
188
addons/education_student/views/student_document_views.xml
Normal file
188
addons/education_student/views/student_document_views.xml
Normal 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 & 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 & ID Cards"
|
||||
parent="education_base.menu_education_base_root"
|
||||
action="action_education_student_certificate"
|
||||
sequence="16"
|
||||
groups="education_student.group_education_student_user"/>
|
||||
</odoo>
|
||||
@ -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 & 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 -->
|
||||
|
||||
@ -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>) {
|
||||
|
||||
@ -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
@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
13
flutter_app/lib/providers/student_provider.dart
Normal file
13
flutter_app/lib/providers/student_provider.dart
Normal 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();
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
204
flutter_app/test/profile_widget_test.dart
Normal file
204
flutter_app/test/profile_widget_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user