1037 lines
47 KiB
Python

import base64
import json
import logging
import mimetypes
import re
from datetime import datetime, date
# pyrefly: ignore [missing-import]
from odoo import http
# pyrefly: ignore [missing-import]
from odoo.http import request
_logger = logging.getLogger(__name__)
def json_response(success, data=None, error_code=None, error_message=None, status=200):
if success:
body = {
"success": True,
"data": data or {}
}
else:
body = {
"success": False,
"error": {
"code": error_code or "ERROR",
"message": error_message or "An error occurred"
}
}
return request.make_response(
json.dumps(body, default=str),
headers=[('Content-Type', 'application/json')],
status=status
)
def require_student(func):
def wrapper(self, *args, **kwargs):
if request.env.user._is_public():
return json_response(False, error_code="AUTH_REQUIRED", error_message="Authentication required", status=401)
student = request.env.user.sudo().student_id
if not student:
return json_response(False, error_code="STUDENT_NOT_LINKED", error_message="No student profile is linked to this account", status=403)
return func(self, student, *args, **kwargs)
return wrapper
def format_float_time(float_time):
if float_time is None or float_time is False:
return ""
hours = int(float_time)
minutes = int(round((float_time - hours) * 60))
if minutes == 60:
minutes = 0
hours += 1
hours = hours % 24
am_pm = 'AM' if hours < 12 else 'PM'
display_hours = hours % 12
if display_hours == 0:
display_hours = 12
return f"{display_hours:02d}:{minutes:02d} {am_pm}"
class MobileStudent(http.Controller):
def _strip_html(self, text):
if not text:
return ""
clean = re.sub(r'<[^>]+>', ' ', str(text))
return " ".join(clean.split()).strip()
def _format_announcement(self, a):
title = a.title or getattr(a, 'name', '') or 'Announcement'
raw_content = a.content or ''
summary = self._strip_html(raw_content)[:140]
return {
"id": a.id,
"title": title,
"name": title,
"date": str(a.date_published or ''),
"date_published": str(a.date_published or ''),
"expiry_date": str(a.expiry_date) if a.expiry_date else None,
"summary": summary,
"content": raw_content,
"type": a.announcement_type or 'notice',
"priority": a.priority or '0',
"author": a.author_id.name if a.author_id else None,
"has_attachment": bool(a.circular_file),
"circular_filename": a.circular_filename or None,
}
def _get_rel(self, record):
if record:
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:
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)
def _get_attendance_summary(self, student, from_date=None, to_date=None):
domain = [('student_id', '=', student.id)]
if from_date:
domain.append(('date', '>=', from_date))
if to_date:
domain.append(('date', '<=', to_date))
records = request.env['education.attendance'].search(domain)
total_sessions = len(records)
present = len([a for a in records if a.status == 'present'])
absent = len([a for a in records if a.status == 'absent'])
late = len([a for a in records if a.status == 'late'])
medical_leave = len([a for a in records if a.status == 'medical_leave'])
on_duty = len([a for a in records if a.status == 'on_duty'])
attended = present + on_duty + late + medical_leave
percentage = round((attended / total_sessions * 100.0), 1) if total_sessions > 0 else 100.0
return {
"percentage": percentage,
"present": present,
"absent": absent,
"late": late,
"medical_leave": medical_leave,
"on_duty": on_duty
}
def _get_timetable_slots(self, student, date_filter=None, from_date=None, to_date=None):
if not student.program_id or not student.academic_year_id:
return []
domain = [
('program_id', '=', student.program_id.id),
('academic_year_id', '=', student.academic_year_id.id),
('active', '=', True),
('state', '!=', 'cancelled'),
]
if student.company_id:
domain.append(('company_id', '=', student.company_id.id))
if student.academic_term_id:
domain += ['|', ('term_id', '=', False), ('term_id', '=', student.academic_term_id.id)]
else:
domain += [('term_id', '=', False)]
if student.batch_id:
domain += ['|', ('batch_id', '=', False), ('batch_id', '=', student.batch_id.id)]
else:
domain += [('batch_id', '=', False)]
if hasattr(student, 'semester_id') and student.semester_id:
domain += ['|', ('semester_id', '=', False), ('semester_id', '=', student.semester_id.id)]
else:
domain += [('semester_id', '=', False)]
if hasattr(student, 'section_id') and student.section_id:
domain += ['|', ('section_id', '=', False), ('section_id', '=', student.section_id.id)]
else:
domain += [('section_id', '=', False)]
if date_filter:
domain += [('date', '=', date_filter)]
if from_date:
domain += [('date', '>=', from_date)]
if to_date:
domain += [('date', '<=', to_date)]
env = student.env if student else request.env
slots = env['education.timetable.slot'].sudo().search(domain, order="date asc, start_time asc")
return [{
"id": s.id,
"date": s.date,
"day": s.date.strftime('%A') if s.date else '',
"start_time": format_float_time(s.start_time),
"end_time": format_float_time(s.end_time),
"subject": self._get_rel(s.subject_id),
"faculty": self._get_rel(s.faculty_id),
"room": self._get_rel(s.room_id)
} for s in slots]
@http.route('/api/mobile/v1/student/dashboard', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_dashboard(self, student, **kwargs):
try:
today = date.today()
# Upcoming Exams
exams = request.env['education.exam'].sudo().search_read([
('state', 'in', ['scheduled', 'ongoing']),
('program_id', '=', student.program_id.id),
('academic_year_id', '=', student.academic_year_id.id),
], fields=['id', 'name', 'state', 'start_date'], order='start_date asc', limit=5)
# Announcements (filtered by audience, state, and publication window)
ann_records, _ = request.env['education.announcement'].get_announcements_for_student(student, limit=5)
announcements = [self._format_announcement(a) for a in ann_records]
# Recent Results
results = request.env['education.exam.result'].sudo().search([
('student_id', '=', student.id),
('published', '=', True)
], order='id desc', limit=5)
results_data = []
for r in results:
results_data.append({
"id": r.id,
"exam": self._get_rel(r.exam_id),
"percentage": r.percentage,
"grade": r.grade_id.name if r.grade_id else None,
"status": r.result_status
})
data = {
"student": {
"id": student.id,
"full_name": student.full_name,
"program": self._get_rel(student.program_id)
},
"attendance": self._get_attendance_summary(student),
"today_timetable": self._get_timetable_slots(student, date_filter=today),
"upcoming_exams": exams,
"recent_results": results_data,
"announcements": announcements,
"fees": {
"total_fees": float(student.total_fees),
"total_paid": float(student.total_paid),
"outstanding_balance": float(student.outstanding_amount),
"overdue_amount": float(student.overdue_amount),
"payment_status": student.payment_status or "no_dues",
"next_due_date": str(student.next_due_date) if student.next_due_date else None,
"currency_symbol": student.currency_id.symbol or student.currency_id.name or '₹'
}
}
return json_response(True, data=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/attendance', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_attendance(self, student, **kwargs):
try:
from_date_str = kwargs.get('from_date')
to_date_str = kwargs.get('to_date')
from_date = None
to_date = None
if from_date_str:
from_date = datetime.strptime(from_date_str, '%Y-%m-%d').date()
if to_date_str:
to_date = datetime.strptime(to_date_str, '%Y-%m-%d').date()
if from_date and to_date and from_date > to_date:
return json_response(False, error_code="INVALID_DATE_RANGE", error_message="'from_date' cannot be later than 'to_date'", status=400)
summary = self._get_attendance_summary(student, from_date, to_date)
domain = [('student_id', '=', student.id)]
if from_date:
domain.append(('date', '>=', from_date))
if to_date:
domain.append(('date', '<=', to_date))
records = request.env['education.attendance'].search_read(
domain,
fields=['id', 'date', 'status', 'remarks'],
order='date desc, id desc',
limit=100
)
formatted_records = []
for r in records:
formatted_records.append({
"id": r["id"],
"date": str(r["date"]),
"status": r["status"],
"remarks": r.get("remarks") or ""
})
return json_response(True, data={
"summary": summary,
"records": formatted_records
})
except ValueError:
return json_response(False, error_code="INVALID_DATE", error_message="Dates must be in YYYY-MM-DD format", status=400)
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/timetable', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_timetable(self, student, **kwargs):
try:
date_filter = kwargs.get('date')
from_date = kwargs.get('from_date')
to_date = kwargs.get('to_date')
if date_filter:
try:
datetime.strptime(date_filter, '%Y-%m-%d')
except ValueError:
return json_response(False, error_code="INVALID_DATE", error_message="Date must be in YYYY-MM-DD format", status=400)
if from_date:
try:
datetime.strptime(from_date, '%Y-%m-%d')
except ValueError:
return json_response(False, error_code="INVALID_DATE", error_message="from_date must be in YYYY-MM-DD format", status=400)
if to_date:
try:
datetime.strptime(to_date, '%Y-%m-%d')
except ValueError:
return json_response(False, error_code="INVALID_DATE", error_message="to_date must be in YYYY-MM-DD format", status=400)
if from_date and to_date and from_date > to_date:
return json_response(False, error_code="INVALID_DATE_RANGE", error_message="from_date must be before or equal to to_date", status=400)
slots = self._get_timetable_slots(student, date_filter=date_filter, from_date=from_date, to_date=to_date)
return json_response(True, data={"slots": slots})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/exams', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_exams(self, student, **kwargs):
try:
exams = request.env['education.exam'].sudo().search([
('program_id', '=', student.program_id.id),
('academic_year_id', '=', student.academic_year_id.id),
('state', 'in', ['scheduled', 'ongoing', 'completed', 'results_published']),
], order='start_date desc')
exams_data = [{
"id": exam.id,
"name": exam.name,
"state": exam.state,
"start_date": exam.start_date,
"end_date": exam.end_date,
"program": self._get_rel(exam.program_id),
"academic_year": self._get_rel(exam.academic_year_id)
} for exam in exams]
return json_response(True, data={"exams": exams_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/results', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_results(self, student, **kwargs):
try:
results = request.env['education.exam.result'].sudo().search([
('student_id', '=', student.id),
('published', '=', True)
], order='id desc')
results_data = []
for r in results:
lines = []
for line in r.line_ids:
lines.append({
"subject": self._get_rel(line.subject_id),
"max_marks": line.max_marks,
"pass_marks": line.pass_marks,
"obtained_marks": line.obtained_marks,
"percentage": line.percentage,
"grade": line.grade_id.name if line.grade_id else None,
"status": line.status
})
results_data.append({
"id": r.id,
"exam": self._get_rel(r.exam_id),
"total_max_marks": r.total_max_marks,
"total_obtained_marks": r.total_obtained_marks,
"percentage": r.percentage,
"grade": r.grade_id.name if r.grade_id else None,
"status": r.result_status,
"published_date": r.published_date,
"lines": lines
})
return json_response(True, data={"results": results_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/announcements', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_announcements(self, student, **kwargs):
try:
page = int(kwargs.get('page', 1) or 1)
limit = int(kwargs.get('limit', 20) or 20)
if page < 1:
page = 1
if limit < 1 or limit > 100:
limit = 20
offset = (page - 1) * limit
ann_type = kwargs.get('type')
if ann_type and ann_type not in ['notice', 'circular', 'event']:
ann_type = None
records, total_count = request.env['education.announcement'].get_announcements_for_student(
student,
limit=limit,
offset=offset,
announcement_type=ann_type
)
announcements_data = [self._format_announcement(a) for a in records]
return json_response(True, data={
"announcements": announcements_data,
"total_count": total_count,
"page": page,
"limit": limit
})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/announcements/<int:announcement_id>', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_announcement_detail(self, student, announcement_id, **kwargs):
try:
ann = request.env['education.announcement'].sudo().browse(announcement_id)
if not ann.exists() or not ann.active or ann.state != 'published':
return json_response(False, error_code="NOT_FOUND", error_message="Announcement not found or unavailable", status=404)
# Date window validation
today = date.today()
if ann.date_published > today:
return json_response(False, error_code="NOT_FOUND", error_message="Announcement not found or unavailable", status=404)
if ann.expiry_date and ann.expiry_date < today:
return json_response(False, error_code="NOT_FOUND", error_message="Announcement has expired", status=404)
# Multi-company isolation
if student.company_id and ann.company_id and ann.company_id.id != student.company_id.id:
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this announcement", status=403)
# Audience and cohort targeting
if not ann.check_student_audience(student):
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this announcement", status=403)
return json_response(True, data={
"announcement": self._format_announcement(ann)
})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/fees/summary', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_fee_summary(self, student, **kwargs):
try:
curr = student.currency_id or student.company_id.currency_id
data = {
"total_fees": float(student.total_fees),
"total_paid": float(student.total_paid),
"outstanding_balance": float(student.outstanding_amount),
"overdue_amount": float(student.overdue_amount),
"payment_status": student.payment_status or "no_dues",
"next_due_date": str(student.next_due_date) if student.next_due_date else None,
"last_payment_date": str(student.last_payment_date) if student.last_payment_date else None,
"last_receipt_number": student.last_receipt_number or None,
"currency": {
"id": curr.id if curr else 1,
"name": curr.name if curr else "INR",
"symbol": curr.symbol if curr else "₹"
}
}
return json_response(True, data=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/fees/invoices', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_fee_invoices(self, student, **kwargs):
try:
status_filter = kwargs.get('status')
page = int(kwargs.get('page', 1) or 1)
limit = int(kwargs.get('limit', 20) or 20)
if page < 1:
page = 1
if limit < 1 or limit > 100:
limit = 20
offset = (page - 1) * limit
domain = [
('student_id', '=', student.id),
('active', '=', True),
]
if student.company_id:
domain.append(('company_id', '=', student.company_id.id))
if status_filter in ['unpaid', 'partial', 'paid', 'overdue']:
domain.append(('payment_status', '=', status_filter))
total_count = request.env['education.fee.assignment'].sudo().search_count(domain)
assignments = request.env['education.fee.assignment'].sudo().search(
domain,
order='due_date asc, id desc',
limit=limit,
offset=offset
)
curr = student.currency_id or student.company_id.currency_id
curr_dict = {
"id": curr.id if curr else 1,
"name": curr.name if curr else "INR",
"symbol": curr.symbol if curr else "₹"
}
invoices = []
for a in assignments:
invoices.append({
"id": a.id,
"invoice_number": a.name or f"INV/{a.id:05d}",
"title": a.fee_structure_id.name or "Semester Fees",
"academic_year": self._get_rel(a.academic_year_id),
"academic_term": self._get_rel(a.academic_term_id),
"semester": self._get_rel(a.semester_id),
"assignment_date": str(a.assignment_date or ''),
"due_date": str(a.due_date or ''),
"amount": float(a.amount),
"amount_paid": float(a.amount_paid),
"amount_due": float(a.amount_due),
"payment_status": a.payment_status,
"state": a.state,
"currency": curr_dict
})
return json_response(True, data={
"invoices": invoices,
"total_count": total_count,
"page": page,
"limit": limit
})
except Exception as e:
return json_response(False, error_code="SERVER_ERROR", error_message=str(e), status=500)
@http.route('/api/mobile/v1/student/fees/invoices/<int:invoice_id>', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_fee_invoice_detail(self, student, invoice_id, **kwargs):
try:
assignment = request.env['education.fee.assignment'].sudo().browse(invoice_id)
if not assignment.exists() or not assignment.active:
return json_response(False, error_code="NOT_FOUND", error_message="Invoice not found", status=404)
# Security isolation: must match authenticated student
if assignment.student_id.id != student.id:
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this invoice", status=403)
# Company isolation
if student.company_id and assignment.company_id and assignment.company_id.id != student.company_id.id:
return json_response(False, error_code="ACCESS_DENIED", error_message="You do not have access to this invoice", status=403)
curr = assignment.currency_id or student.currency_id or student.company_id.currency_id
curr_dict = {
"id": curr.id if curr else 1,
"name": curr.name if curr else "INR",
"symbol": curr.symbol if curr else "₹"
}
lines = []
if assignment.fee_structure_id and assignment.fee_structure_id.line_ids:
for line in assignment.fee_structure_id.line_ids:
lines.append({
"id": line.id,
"name": line.fee_type_id.name,
"category": line.fee_type_id.category_id.name if line.fee_type_id.category_id else None,
"amount": float(line.amount),
"mandatory": line.mandatory
})
payments = []
for p in assignment.payment_ids.filtered(lambda x: x.status == 'confirmed'):
payments.append({
"id": p.id,
"receipt_number": p.receipt_number,
"payment_date": str(p.payment_date or ''),
"payment_method": p.payment_method,
"payment_method_label": dict(p._fields['payment_method'].selection).get(p.payment_method, p.payment_method),
"amount": float(p.amount),
"reference_number": p.reference_number
})
data = {
"invoice": {
"id": assignment.id,
"invoice_number": assignment.name or f"INV/{assignment.id:05d}",
"title": assignment.fee_structure_id.name or "Semester Fees",
"academic_year": self._get_rel(assignment.academic_year_id),
"academic_term": self._get_rel(assignment.academic_term_id),
"semester": self._get_rel(assignment.semester_id),
"assignment_date": str(assignment.assignment_date or ''),
"due_date": str(assignment.due_date or ''),
"amount": float(assignment.amount),
"amount_paid": float(assignment.amount_paid),
"amount_due": float(assignment.amount_due),
"payment_status": assignment.payment_status,
"state": assignment.state,
"currency": curr_dict,
"lines": lines,
"payments": payments
}
}
return json_response(True, data=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/fees/payments', type='http', auth='public', methods=['GET'], cors='*')
@require_student
def get_fee_payments(self, student, **kwargs):
try:
page = int(kwargs.get('page', 1) or 1)
limit = int(kwargs.get('limit', 20) or 20)
if page < 1:
page = 1
if limit < 1 or limit > 100:
limit = 20
offset = (page - 1) * limit
domain = [
('student_id', '=', student.id),
('status', '=', 'confirmed'),
]
if student.company_id:
domain.append(('company_id', '=', student.company_id.id))
total_count = request.env['education.student.payment'].sudo().search_count(domain)
payments = request.env['education.student.payment'].sudo().search(
domain,
order='payment_date desc, id desc',
limit=limit,
offset=offset
)
curr = student.currency_id or student.company_id.currency_id
curr_dict = {
"id": curr.id if curr else 1,
"name": curr.name if curr else "INR",
"symbol": curr.symbol if curr else "₹"
}
payments_data = []
for p in payments:
payments_data.append({
"id": p.id,
"receipt_number": p.receipt_number,
"payment_date": str(p.payment_date or ''),
"payment_method": p.payment_method,
"payment_method_label": dict(p._fields['payment_method'].selection).get(p.payment_method, p.payment_method),
"amount": float(p.amount),
"reference_number": p.reference_number,
"remarks": p.remarks,
"status": p.status,
"invoice": {
"id": p.fee_assignment_id.id,
"invoice_number": p.fee_assignment_id.name
} if p.fee_assignment_id else None,
"currency": curr_dict
})
return json_response(True, data={
"payments": payments_data,
"total_count": total_count,
"page": page,
"limit": limit
})
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', str(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_ref = None
if cert.certificate_type == 'bonafide':
report_ref = 'education_reports.action_report_bonafide_certificate'
elif cert.certificate_type == 'id_card':
report_ref = 'education_reports.action_report_student_id_card'
if report_ref and request.env.ref(report_ref, raise_if_not_found=False):
try:
pdf_content, _ = request.env['ir.actions.report'].sudo()._render_qweb_pdf(report_ref, 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', str(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)