## $Id: studylevel.py 16810 2022-02-16 07:48:07Z henrik $
##
## Copyright (C) 2012 Uli Fouquet & Henrik Bettermann
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program; if not, write to the Free Software
## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
##
"""
Container which holds the data of a student study level
and contains the course tickets.
"""
import grok
import pytz
from datetime import datetime
from zope.component.interfaces import IFactory
from zope.component import createObject
from zope.interface import implementedBy
from waeup.kofa.utils.helpers import attrs_to_fields
from waeup.kofa.interfaces import RETURNING, VALIDATED, REGISTERED, PAID
from waeup.kofa.students.browser import TicketError
from waeup.kofa.students.studylevel import (
    StudentStudyLevel, CourseTicket,
    CourseTicketFactory, StudentStudyLevelFactory)
from waeup.kofa.students.interfaces import IStudentNavigation, ICourseTicket
from waeup.aaue.students.interfaces import (
    ICustomStudentStudyLevel, ICustomCourseTicket)
from waeup.aaue.students.utils import MINIMUM_UNITS_THRESHOLD
from waeup.aaue.interfaces import MessageFactory as _


class CustomStudentStudyLevel(StudentStudyLevel):
    """This is a container for course tickets.
    """
    grok.implements(ICustomStudentStudyLevel, IStudentNavigation)
    grok.provides(ICustomStudentStudyLevel)

    @property
    def total_credits_s1(self):
        total = 0
        for ticket in self.values():
            if ticket.semester == 1 and not ticket.outstanding:
                total += ticket.credits
        return total

    @property
    def total_credits_s2(self):
        total = 0
        for ticket in self.values():
            if ticket.semester == 2 and not ticket.outstanding:
                total += ticket.credits
        return total

    @property
    def gpa_params(self):
        """Calculate gpa parameters for this level.
        """
        credits_weighted = 0.0
        credits_counted = 0
        level_gpa = 0.0
        for ticket in self.values():
            if ticket.total_score is not None:
                credits_counted += ticket.credits
                credits_weighted += ticket.credits * ticket.weight
        if credits_counted:
            level_gpa = credits_weighted/credits_counted
        # Override level_gpa if value has been imported
        imported_gpa = getattr(self, 'imported_gpa', None)
        if imported_gpa:
            level_gpa = imported_gpa
        return level_gpa, credits_counted, credits_weighted

    @property
    def gpa_params_rectified(self):
        return self.gpa_params

    @property
    def passed_params(self):
        """Determine the number and credits of passed and failed courses.
        This method is used for level reports.
        """
        passed = failed = 0
        courses_failed = ''
        credits_failed = 0
        credits_passed = 0
        courses_not_taken = ''
        courses_passed = ''
        for ticket in self.values():
            if ticket.total_score is not None:
                if ticket.total_score < ticket.passmark:
                    failed += 1
                    credits_failed += ticket.credits
                    if ticket.mandatory or ticket.course_category == 'C':
                        courses_failed += 'm_%s_m ' % ticket.code
                    else:
                        courses_failed += '%s ' % ticket.code
                else:
                    passed += 1
                    credits_passed += ticket.credits
                    courses_passed += '%s ' % ticket.code
            else:
                courses_not_taken += '%s ' % ticket.code
        if not len(courses_failed):
            courses_failed = 'Nil'
        if not len(courses_not_taken):
            courses_not_taken = 'Nil'
        return (passed, failed, credits_passed,
                credits_failed, courses_failed,
                courses_not_taken, courses_passed)

    @property
    def course_registration_forbidden(self):
        #fac_dep_paid = True
        #if self.student.entry_session >= 2016:
        #    fac_dep_paid = False
        #    for ticket in self.student['payments'].values():
        #        if ticket.p_category == 'fac_dep' and \
        #            ticket.p_session == self.level_session and \
        #            ticket.p_state == 'paid':
        #                fac_dep_paid = True
        #                continue
        #if not fac_dep_paid:
        #    return _("Please pay faculty and departmental dues first.")


        ######################################################
        # Temporarily disable ug_ft course registration
        #if self.student.current_mode == 'ug_ft':
        #    return _("Course registration has been disabled.")
        ######################################################


        restitution_paid = True
        if self.student.current_session == 2016 \
            and self.student.current_mode in ('ug_ft', 'dp_ft') \
            and not self.student.is_fresh:
            restitution_paid = False
            for ticket in self.student['payments'].values():
                if ticket.p_category == 'restitution' and \
                    ticket.p_session == self.level_session and \
                    ticket.p_state == 'paid':
                        restitution_paid = True
                        continue
        if not restitution_paid:
            return _("Please pay restitution fee first.")
        #if self.student.is_fresh:
        #    return
        try:
            academic_session = grok.getSite()['configuration'][
                str(self.level_session)]
            if self.student.is_postgrad:
                deadline = academic_session.coursereg_deadline_pg
            elif self.student.current_mode.startswith('dp'):
                deadline = academic_session.coursereg_deadline_dp
            elif self.student.current_mode in (
                'ug_pt', 'de_pt', 'de_dsh', 'ug_dsh'):
                deadline = academic_session.coursereg_deadline_pt
            elif self.student.current_mode == 'found':
                deadline = academic_session.coursereg_deadline_found
            elif self.student.current_mode == 'bridge':
                deadline = academic_session.coursereg_deadline_bridge
            else:
                deadline = academic_session.coursereg_deadline
        except (TypeError, KeyError):
            return
        if not deadline or deadline > datetime.now(pytz.utc):
            return
        if self.student.is_postgrad:
            lcrfee = academic_session.late_pg_registration_fee
        else:
            lcrfee = academic_session.late_registration_fee
        if not lcrfee:
            return _("Course registration has been disabled.")
        if len(self.student['payments']):
            for ticket in self.student['payments'].values():
                if ticket.p_category == 'late_registration' and \
                    ticket.p_session == self.level_session and \
                    ticket.p_state == 'paid':
                        return
        return _("Course registration has ended. "
                 "Please pay the late registration fee.")

    # only AAUE
    @property
    def remark(self):
        certificate = getattr(self.__parent__,'certificate',None)
        end_level = getattr(certificate, 'end_level', None)
        study_mode = getattr(certificate, 'study_mode', None)
        is_dp = False
        if study_mode and study_mode.startswith('dp'):
            is_dp = True
        failed_limit = 1.5
        if self.student.entry_session < 2013:
            failed_limit = 1.0
        # final level student remark
        if end_level and self.level >= end_level:
            if self.level > end_level:
                # spill-over level
                if self.gpa_params[1] == 0:
                    # no credits taken
                    return 'NER'
            elif self.gpa_params[1] < MINIMUM_UNITS_THRESHOLD:
                # credits taken below limit
                return 'NER'
            if self.level_verdict in ('FRNS', 'NER', 'NYV'):
                return self.level_verdict
            if '_m' in self.passed_params[4]:
                return 'FRNS'
            if not self.cumulative_params[0]:
                return 'FRNS'
            if len(self.passed_params[5]) \
                and not self.passed_params[5] == 'Nil':
                return 'FRNS'
            if self.cumulative_params[1] < 60:
                return 'FRNS'
            if self.cumulative_params[0] < failed_limit:
                return 'Fail'
            dummy, repeat = divmod(self.level, 100)
            if self.cumulative_params[0] < 5.1 and repeat == 20:
                # Irrespective of the CGPA of a student, if the He/She has
                # 3rd Extension, such student will be graduated with a "Pass".
                return 'Pass'
            if self.cumulative_params[0] < 1.5:
                if is_dp:
                    return 'Fail'
                return 'Pass'
            if self.cumulative_params[0] < 2.4:
                if is_dp:
                    return 'Pass'
                return '3s_rd_s'
            if self.cumulative_params[0] < 3.5:
                if is_dp:
                    return 'Merit'
                return '2s_2_s'
            if self.cumulative_params[0] < 4.5:
                if is_dp:
                    return 'Credit'
                return '2s_1_s'
            if self.cumulative_params[0] < 5.1:
                if is_dp:
                    return 'Distinction'
                return '1s_st_s'
            return 'N/A'
        # returning student remark
        if self.level_verdict in ('FRNS', 'NER', 'NYV'):
            return 'Probation'
        if self.level_verdict == 'D':
            return 'Withdrawn'
        if self.gpa_params[1] == 0:
            # no credits taken
            return 'NER'
        if self.gpa_params[1] < MINIMUM_UNITS_THRESHOLD:
            # credits taken below limit
            return 'Probation'
        if self.cumulative_params[0] < failed_limit:
            return 'Probation'
        if self.cumulative_params[0] < 5.1:
            return 'Proceed'
        return 'N/A'

    def _schoolfeePaymentMade(self):
        if len(self.student['payments']):
            for ticket in self.student['payments'].values():
                if ticket.p_state == 'paid' and \
                    ticket.p_category in (
                        'schoolfee', 'schoolfee_incl', 'schoolfee_2',)  and \
                    ticket.p_session == self.student[
                        'studycourse'].current_session:
                    return True
        return False

    def _coursePaymentsMade(self, course):
        if self.level_session < 2016:
            return True
        if not course.code[:3] in ('GST', 'ENT'):
            return True
        if len(self.student['payments']):
            paid_cats = list()
            for pticket in self.student['payments'].values():
                if pticket.p_state == 'paid':
                    paid_cats.append(pticket.p_category)
            if course.code in ('GST101', 'GST102', 'GST111', 'GST112') and \
                not 'gst_registration_1' in paid_cats:
                return False
            if course.code in ('GST222',) and \
                not 'gst_registration_2' in paid_cats:
                return False
            if course.code in ('GST101', 'GST102') and \
                not 'gst_text_book_1' in paid_cats and \
                not 'gst_text_book_0' in paid_cats:
                return False
            if course.code in ('GST111', 'GST112') and \
                not 'gst_text_book_2' in paid_cats and \
                not 'gst_text_book_0' in paid_cats:
                return False
            if course.code in ('GST222',) and \
                not 'gst_text_book_3' in paid_cats:
                return False
            if course.code in ('ENT201',) and \
                not 'ent_registration_1' in paid_cats and \
                not 'ent_registration_0' in paid_cats and \
                not 'ent_combined' in paid_cats:
                return False
            if course.code in ('ENT211',) and \
                not 'ent_registration_2' in paid_cats and \
                not 'ent_registration_0' in paid_cats and \
                not 'ent_combined' in paid_cats:
                return False
            if course.code in ('ENT201',) and \
                not 'ent_text_book_1' in paid_cats and \
                not 'ent_text_book_0' in paid_cats and \
                not 'ent_combined' in paid_cats:
                return False
            if course.code in ('ENT211',) and \
                not 'ent_text_book_2' in paid_cats and \
                not 'ent_text_book_0' in paid_cats and \
                not 'ent_combined' in paid_cats:
                return False
            return True
        return False

    def addCourseTicket(self, ticket, course):
        """Add a course ticket object.
        """
        if not ICourseTicket.providedBy(ticket):
            raise TypeError(
                'StudentStudyLeves contain only ICourseTicket instances')
        # Raise TicketError if course is in 2nd semester but
        # schoolfee has not yet been fully paid.
        if course.semester == 2 and not self._schoolfeePaymentMade():
            raise TicketError(
                _('%s is a 2nd semester course which can only be added '
                  'if school fees have been fully paid.' % course.code))
        # Raise TicketError if registration fee or text
        # book fee haven't been paid.
        if not self._coursePaymentsMade(course):
            raise TicketError(
                _('%s can only be added if both registration fee and text '
                  'book fee have been paid.'
                  % course.code))
        # We check if there exists a certificate course in the certificate
        # container which refers to the course. If such an object does
        # not exist, students and managers will be prevented from registering
        # the corresponding course.
        # If a certificate course is found, the course_category will be used
        # for the course ticket.
        cert = self.__parent__.certificate
        ticket_allowed = False
        for val in cert.values():
            if val.course == course:
                ticket_allowed = True
                course_category = val.course_category
                break
        if not ticket_allowed:
            raise TicketError(
                _('%s is not part of the %s curriculum.'
                  % (course.code, cert.code)))
        ticket.code = course.code
        ticket.title = course.title
        ticket.fcode = course.__parent__.__parent__.__parent__.code
        ticket.dcode = course.__parent__.__parent__.code
        ticket.credits = course.credits
        ticket.course_category = course_category
        if self.student.entry_session < 2013:
            ticket.passmark = course.passmark - 5
        else:
            ticket.passmark = course.passmark
        ticket.semester = course.semester
        self[ticket.code] = ticket
        return

    def addCertCourseTickets(self, cert):
        """Collect all certificate courses and create course
        tickets automatically.
        """
        if cert is not None:
            for key, val in cert.items():
                if val.level != self.level:
                    continue
                ticket = createObject(u'waeup.CourseTicket')
                ticket.automatic = True
                ticket.mandatory = val.mandatory
                ticket.carry_over = False
                #ticket.course_category = val.course_category
                try:
                    self.addCourseTicket(ticket, val.course)
                except TicketError:
                    pass
        return

CustomStudentStudyLevel = attrs_to_fields(
    CustomStudentStudyLevel, omit=[
    'total_credits', 'total_credits_s1', 'total_credits_s2', 'gpa'])

class CustomStudentStudyLevelFactory(StudentStudyLevelFactory):
    """A factory for student study levels.
    """

    def __call__(self, *args, **kw):
        return CustomStudentStudyLevel()

    def getInterfaces(self):
        return implementedBy(CustomStudentStudyLevel)

class CustomCourseTicket(CourseTicket):
    """This is a course ticket which allows the
    student to attend the course. Lecturers will enter scores and more at
    the end of the term.

    A course ticket contains a copy of the original course and
    course referrer data. If the courses and/or their referrers are removed, the
    corresponding tickets remain unchanged. So we do not need any event
    triggered actions on course tickets.
    """
    grok.implements(ICustomCourseTicket, IStudentNavigation)
    grok.provides(ICustomCourseTicket)

    @property
    def _getGradeWeightFromScore(self):
        """AAUE Course Grading System
        """
        if self.score == -1:
            return ('-',0) # core course and result not yet available (used by AAUE)
        if self.total_score is None:
            return (None, None)
        if self.total_score >= 70:
            return ('A',5)
        if self.total_score >= 60:
            return ('B',4)
        if self.total_score >= 50:
            return ('C',3)
        if self.total_score >= 45:
            return ('D',2)
        if self.total_score >= self.passmark: # passmark changed in 2013 from 40 to 45
            return ('E',1)
        return ('F',0)

    @property
    def total_score(self):
        """Returns ca + score or imported total score.
        """
        if self.outstanding:
            return None
        # Override total_score if value has been imported
        if getattr(self, 'imported_ts', None):
            return self.imported_ts
        if self.score == -1:
            return 0
        if not None in (self.score, self.ca):
            return self.score + self.ca
        return None

    @property
    def removable_by_student(self):
        """True if student is allowed to remove the ticket.
        """
        if self.mandatory:
            return False
        if self.score:
            return False
        #if self.course_category == 'C':
        #    return False
        return True

    @property
    def editable_by_lecturer(self):
        """True if lecturer is allowed to edit the ticket.
        """
        try:
            cas = grok.getSite()[
                'configuration'].current_academic_session
            # Temporarily we allow students to pay for next session, so their
            # current_session might have increased
            if self.student.state in (
                VALIDATED, REGISTERED, PAID, RETURNING) and \
                self.student.current_session in (cas, cas+1):
                return True
        except (AttributeError, TypeError): # in unit tests
            pass
        return False

CustomCourseTicket = attrs_to_fields(CustomCourseTicket)

class CustomCourseTicketFactory(CourseTicketFactory):
    """A factory for student study levels.
    """

    def __call__(self, *args, **kw):
        return CustomCourseTicket()

    def getInterfaces(self):
        return implementedBy(CustomCourseTicket)
