# -*- coding: utf-8 -*- import json import logging 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 @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) 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 } 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), ], 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/', 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)