source: main/kofacustom.iuokada/trunk/src/kofacustom/iuokada/students/utils.py @ 18135

Last change on this file since 18135 was 18135, checked in by Henrik Bettermann, 14 hours ago

Minor modifications requested in #252

  • Property svn:keywords set to Id
File size: 25.2 KB
RevLine 
[10765]1## $Id: utils.py 18135 2025-07-21 15:18:07Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
[15802]18import grok
[10765]19from time import time
[16136]20from copy import deepcopy
[10765]21from zope.component import createObject, getUtility
22from waeup.kofa.interfaces import (IKofaUtils,
[16091]23    ADMITTED, CLEARANCE, REQUESTED, CLEARED, RETURNING, PAID,
24    REGISTERED, VALIDATED)
[10765]25from kofacustom.nigeria.students.utils import NigeriaStudentsUtils
[15563]26from kofacustom.iuokada.interfaces import MessageFactory as _
[10765]27
28class CustomStudentsUtils(NigeriaStudentsUtils):
29    """A collection of customized methods.
30
31    """
32
[17661]33    @property
34    def STUDENT_ID_PREFIX(self):
35        if grok.getSite().__name__ == 'iuokada-cdl':
36            return u'F'
37        return u'I'
[15645]38
[17661]39
[15649]40    SKIP_UPLOAD_VIEWLETS = (
[15654]41        'acceptanceletterupload', 'certificateupload'
[15649]42        )
[15653]43    # Maximum size of upload files in kB
44    MAX_KB = 500
[15649]45
[15656]46    #: A tuple containing the names of registration states in which changing of
47    #: passport pictures is allowed.
[16136]48
[15657]49    PORTRAIT_CHANGE_STATES = (ADMITTED, CLEARANCE,)
[15656]50
[18034]51    REQUIRED_PAYMENTS_FRESH_SCIENCE = {
[17900]52        'registration_fresh': 'Registration Fee (Fresh)',
[16136]53        'book': 'Book Deposit',
54        'develop': 'Development Fee',
55        'parentsconsult': 'Parents Consultative Forum (PCF) Fee',
[16223]56        'municipal_fresh': 'Fresh Students Municipal Fee',
[18034]57        'matric': 'Matriculation Fee',
58        'waecneco': 'WAEC/NECO Verification',
59        'jambver': 'JAMB Verification Fee',
60        'health_insurance': 'Student Health Insurance',
61        'id_card': 'I.D. Card',
62        'medical_screening': 'Medical Screening Fees',
63        'science': 'Science Bench Fee',
[16136]64        }
65
[18034]66    REQUIRED_PAYMENTS_FRESH_NON_SCIENCE = {
67        'registration_fresh': 'Registration Fee (Fresh)',
68        'book': 'Book Deposit',
69        'develop': 'Development Fee',
70        'parentsconsult': 'Parents Consultative Forum (PCF) Fee',
71        'municipal_fresh': 'Fresh Students Municipal Fee',
72        'matric': 'Matriculation Fee',
73        'waecneco': 'WAEC/NECO Verification',
74        'jambver': 'JAMB Verification Fee',
75        'health_insurance': 'Student Health Insurance',
76        'id_card': 'I.D. Card',
77        'medical_screening': 'Medical Screening Fees',
78        }
79
80    # all students (except PHM) returning
[16223]81    REQUIRED_PAYMENTS_RETURNING = {
[17900]82        'registration_return': 'Registration Fee (Returning)',
[16223]83        'book': 'Book Deposit',
84        'develop': 'Development Fee',
85        'parentsconsult': 'Parents Consultative Forum (PCF) Fee',
86        'municipal_returning': 'Returning Students Municipal Fee',
[18034]87        'health_insurance': 'Student Health Insurance',
[16223]88        }
89
[18034]90
91    # all stdents (except PHM) final year
92    REQUIRED_PAYMENTS_FINAL = {
93        'registration_return': 'Registration Fee (Returning)',
94        'book': 'Book Deposit',
95        'develop': 'Development Fee',
96        'parentsconsult': 'Parents Consultative Forum (PCF) Fee',
97        'municipal_returning': 'Returning Students Municipal Fee',
98        'health_insurance': 'Student Health Insurance',
99        'alumni': 'Alumni Fees',
100        'conv': 'Convocation Fees',
[18037]101        'grad_clearance': 'Clearance Fees',
[18034]102        }
103
104    # PHM returning students
105    REQUIRED_PAYMENTS_RETURNING_PHARMACY = {
106        'registration_return': 'Registration Fee (Returning)',
107        'book': 'Book Deposit',
108        'develop': 'Development Fee',
109        'parentsconsult': 'Parents Consultative Forum (PCF) Fee',
110        'municipal_returning': 'Returning Students Municipal Fee',
111        'health_insurance': 'Student Health Insurance',
112        'lab_support': 'Lab Support',
113        }
114
115    # PHM students final year
116    REQUIRED_PAYMENTS_FINAL_PHARMACY = {
117        'registration_return': 'Registration Fee (Returning)',
118        'book': 'Book Deposit',
119        'develop': 'Development Fee',
120        'parentsconsult': 'Parents Consultative Forum (PCF) Fee',
121        'municipal_returning': 'Returning Students Municipal Fee',
122        'health_insurance': 'Student Health Insurance',
123        'alumni': 'Alumni Fees',
124        'conv': 'Convocation Fees',
[18037]125        'grad_clearance': 'Clearance Fees',
[18034]126        'lab_support': 'Lab Support',
127        }
128
[16233]129    REQUIRED_PAYMENTS_PG = {
130        'pg_other': 'PG Other Charges',
131        }
132
[15660]133    def warnCreditsOOR(self, studylevel, course=None):
134        """Return message if credits are out of range. In the base
135        package only maximum credits is set.
136        """
[16377]137        max_credits = 60
138        end_level = getattr(studylevel.__parent__.certificate, 'end_level', None)
139        if end_level and studylevel.level >= end_level:
140            max_credits = 80
[17947]141        #if studylevel.certcode == 'LLB':
142        #    max_credits = 50
143        if studylevel.certcode == 'MBBSMED' and studylevel.level == 200:
144            max_credits = 100
[16377]145        if course and studylevel.total_credits + course.credits > max_credits:
[15660]146            return _('Maximum credits exceeded.')
[16377]147        elif studylevel.total_credits > max_credits:
[15660]148            return _('Maximum credits exceeded.')
149        return
150
[18034]151    def _is_payment_for_final(self, student):
152        studycourse = student['studycourse']
153        certificate = getattr(studycourse,'certificate',None)
154        current_level = studycourse.current_level
155        if None in (current_level, certificate):
156            return False
157        end_level = certificate.end_level
158        if current_level >= end_level-100:
159            return True
160        return False
[16257]161
[18034]162    def _collect_required_payment_items(self, student):
[16233]163        if student.is_postgrad:
164            rp = self.REQUIRED_PAYMENTS_PG
[18034]165        elif student.is_fresh and student.faccode in ('ENG', 'HSC', 'NAS', 'PHM'):
166            rp = self.REQUIRED_PAYMENTS_FRESH_SCIENCE
[16233]167        elif student.is_fresh:
[18034]168            rp = self.REQUIRED_PAYMENTS_FRESH_NON_SCIENCE
169        elif student.faccode == 'PHM' and self._is_payment_for_final(student):
170            rp = self.REQUIRED_PAYMENTS_FINAL_PHARMACY
171        elif student.faccode == 'PHM':
172            rp = self.REQUIRED_PAYMENTS_RETURNING_PHARMACY
173        elif self._is_payment_for_final(student):
174            rp = self.REQUIRED_PAYMENTS_FINAL
[16223]175        else:
[18036]176            rp = self.REQUIRED_PAYMENTS_RETURNING
[18034]177        return rp
178
179    def _requiredPaymentsMissing(self, student, session):
[18050]180        # disabled on 03/04/25
[18108]181        # reenabled on 08/04/25
182        if session < 2025:
183            return
[18037]184        # Part time, jupeb, medical, and PG students do not pay sundry.
185        if student.is_jupeb or student.current_mode.endswith('_pt') \
186            or student.is_postgrad \
187            or student.certcode in ('MBBSMED',):
188            return
[18043]189        # Only in states, which allow to pay school fees, sundry payments
190        # are required
191        if student.state not in (CLEARANCE, REQUESTED, CLEARED, RETURNING):
192            return
[18135]193           
[18034]194        # Has the required combi payment been made?
[16223]195        for ticket in student['payments'].values():
196            if ticket.p_category == 'required_combi'and \
197                ticket.p_session == session and \
198                ticket.p_state == 'paid':
199                return
[18135]200
201        # If not, check single payments (no longer possible)
[18034]202        rp = self._collect_required_payment_items(student)
[16223]203        cats_missing = deepcopy(rp)
[18132]204        combi_cats = getUtility(IKofaUtils).PAYMENT_CATEGORIES
[16136]205        if len(student['payments']):
[16223]206            for category in rp.keys():
[16136]207                for ticket in student['payments'].values():
208                    if ticket.p_state == 'paid' and \
[16223]209                        ticket.p_category == category and \
[16136]210                        ticket.p_session == session:
211                        del cats_missing[category]
[18131]212                    elif ticket.p_state == 'paid' and \
213                        ticket.p_category == 'combi' and \
214                        combi_cats[category] in ticket.p_item and \
215                        ticket.p_session == session:
216                        del cats_missing[category]
[16222]217                if not cats_missing:
218                    return
[18135]219        #return "%s must be paid before Tution Fee." % ', '.join(
220        #    cats_missing.values())
[16136]221
[18135]222        return "Sundry charges must be paid before tuition."
223
[18034]224    @property
225    def _all_required_payments(self):
226        return set(
227            self.REQUIRED_PAYMENTS_PG.keys()
228            + self.REQUIRED_PAYMENTS_FRESH_SCIENCE.keys()
229            + self.REQUIRED_PAYMENTS_FRESH_NON_SCIENCE.keys()
230            + self.REQUIRED_PAYMENTS_FINAL_PHARMACY.keys()
231            + self.REQUIRED_PAYMENTS_RETURNING_PHARMACY.keys()
232            + self.REQUIRED_PAYMENTS_FINAL.keys()
233            + self.REQUIRED_PAYMENTS_RETURNING.keys()
234            )
235
[16583]236    def samePaymentMade(self, student, category, p_item, p_session):
237        if category.startswith('resit'):
238            return False
[17889]239        if category == 'combi':
240            return False
[16583]241        for key in student['payments'].keys():
242            ticket = student['payments'][key]
243            if ticket.p_state == 'paid' and\
244               ticket.p_category == category and \
245               ticket.p_item != 'Balance' and \
246               ticket.p_item == p_item and \
247               ticket.p_session == p_session:
248                  return True
249        return False
250
[15645]251    def setPaymentDetails(self, category, student,
[16136]252            previous_session=None, previous_level=None, combi=[]):
[15645]253        """Create a payment ticket and set the payment data of a
254        student for the payment category specified.
255        """
[17661]256        if grok.getSite().__name__ == 'iuokada-cdl':
257            return self.setCDLPaymentDetails(category, student,
258                previous_session, previous_level, combi)
[15645]259        p_item = u''
260        amount = 0.0
261        if previous_session:
262            if previous_session < student['studycourse'].entry_session:
263                return _('The previous session must not fall below '
264                         'your entry session.'), None
265            if category == 'schoolfee':
266                # School fee is always paid for the following session
267                if previous_session > student['studycourse'].current_session:
268                    return _('This is not a previous session.'), None
269            else:
270                if previous_session > student['studycourse'].current_session - 1:
271                    return _('This is not a previous session.'), None
272            p_session = previous_session
273            p_level = previous_level
274            p_current = False
275        else:
276            p_session = student['studycourse'].current_session
277            p_level = student['studycourse'].current_level
278            p_current = True
[18034]279            if category in list(self._all_required_payments) + ['required_combi',] \
[16136]280                and student.state == RETURNING:
281                # In case of school fee or required sundry fee payments the
282                # payment session is always next session if students are in
283                # state returning.
284                p_session, p_level = self.getReturningData(student)
[15645]285        academic_session = self._getSessionConfiguration(p_session)
286        if academic_session == None:
287            return _(u'Session configuration object is not available.'), None
288        # Determine fee.
[16138]289        if category in ('schoolfee', 'schoolfee40', 'secondinstal'):
[16136]290            rpm = self._requiredPaymentsMissing(student, p_session)
291            if rpm:
292                return rpm, None
[15645]293            try:
294                certificate = student['studycourse'].certificate
295                p_item = certificate.code
296            except (AttributeError, TypeError):
297                return _('Study course data are incomplete.'), None
298            if previous_session:
299                # Students can pay for previous sessions in all
300                # workflow states.  Fresh students are excluded by the
301                # update method of the PreviousPaymentAddFormPage.
302                if previous_level == 100:
303                    amount = getattr(certificate, 'school_fee_1', 0.0)
304                else:
305                    amount = getattr(certificate, 'school_fee_2', 0.0)
[15780]306                if category == 'schoolfee40':
[15773]307                    amount = 0.4*amount
[15780]308                elif category == 'secondinstal':
[15773]309                    amount = 0.6*amount
[15780]310            else:
311                if category == 'secondinstal':
312                    if student.is_fresh:
313                        amount = 0.6 * getattr(certificate, 'school_fee_1', 0.0)
314                    else:
315                        amount = 0.6 * getattr(certificate, 'school_fee_2', 0.0)
316                else:
[16091]317                    if student.state in (CLEARANCE, REQUESTED, CLEARED):
[15780]318                        amount = getattr(certificate, 'school_fee_1', 0.0)
319                    elif student.state == RETURNING:
320                        amount = getattr(certificate, 'school_fee_2', 0.0)
321                    elif student.is_postgrad and student.state == PAID:
322                        # Returning postgraduate students also pay for the
323                        # next session but their level always remains the
324                        # same.
325                        p_session += 1
326                        academic_session = self._getSessionConfiguration(p_session)
327                        if academic_session == None:
328                            return _(
329                                u'Session configuration object is not available.'
330                                ), None
331                        amount = getattr(certificate, 'school_fee_2', 0.0)
332                    if amount and category == 'schoolfee40':
333                        amount = 0.4*amount
[15645]334        elif category == 'clearance':
335            try:
336                p_item = student['studycourse'].certificate.code
337            except (AttributeError, TypeError):
338                return _('Study course data are incomplete.'), None
339            amount = academic_session.clearance_fee
[16464]340            if student.is_postgrad:
341                amount *= 0.5
[18118]342            if student.certcode in ('DPHARM','MBBSMED','BSCNNUR','LLB'):
[18111]343                amount += 200000
[15937]344        elif category.startswith('resit'):
345            amount = academic_session.resit_fee
346            number = int(category.strip('resit'))
[16571]347            amount *= number
[15661]348        #elif category == 'bed_allocation':
349        #    p_item = self.getAccommodationDetails(student)['bt']
350        #    amount = academic_session.booking_fee
351        #elif category == 'hostel_maintenance':
352        #    amount = 0.0
353        #    bedticket = student['accommodation'].get(
354        #        str(student.current_session), None)
355        #    if bedticket is not None and bedticket.bed is not None:
356        #        p_item = bedticket.bed_coordinates
357        #        if bedticket.bed.__parent__.maint_fee > 0:
358        #            amount = bedticket.bed.__parent__.maint_fee
359        #        else:
360        #            # fallback
361        #            amount = academic_session.maint_fee
362        #    else:
363        #        return _(u'No bed allocated.'), None
[15676]364        elif category == 'combi' and combi:
365            categories = getUtility(IKofaUtils).COMBI_PAYMENT_CATEGORIES
366            for cat in combi:
367                fee_name = cat + '_fee'
368                cat_amount = getattr(academic_session, fee_name, 0.0)
369                if not cat_amount:
370                    return _('%s undefined.' % categories[cat]), None
371                amount += cat_amount
372                p_item += u'%s + ' % categories[cat]
373            p_item = p_item.strip(' + ')
[18034]374
[16222]375        elif category == 'required_combi':
[18034]376            rp = self._collect_required_payment_items(student)
377            for cat in rp.keys():
[16222]378                fee_name = cat + '_fee'
379                cat_amount = getattr(academic_session, fee_name, 0.0)
380                if not cat_amount:
381                    return _('%s undefined.' % rp[cat]), None
382                amount += cat_amount
383                p_item += u'%s + ' % rp[cat]
384            p_item = p_item.strip(' + ')
[18034]385
[15645]386        else:
387            fee_name = category + '_fee'
388            amount = getattr(academic_session, fee_name, 0.0)
389        if amount in (0.0, None):
390            return _('Amount could not be determined.'), None
391        if self.samePaymentMade(student, category, p_item, p_session):
392            return _('This type of payment has already been made.'), None
393        if self._isPaymentDisabled(p_session, category, student):
394            return _('This category of payments has been disabled.'), None
395        payment = createObject(u'waeup.StudentOnlinePayment')
396        timestamp = ("%d" % int(time()*10000))[1:]
[16270]397        if category in (
[16396]398            'registration', 'required_combi', 'pg_other', 'jupeb_reg'):
[16270]399            payment.provider_amt = 5000.0
400        if category in (
401            'schoolfee', 'schoolfee40') and student.is_jupeb:
402            payment.provider_amt = 5000.0
[15645]403        payment.p_id = "p%s" % timestamp
404        payment.p_category = category
405        payment.p_item = p_item
406        payment.p_session = p_session
407        payment.p_level = p_level
408        payment.p_current = p_current
409        payment.amount_auth = amount
[15686]410        payment.p_combi = combi
[15802]411        return None, payment
412
[17661]413    def setCDLPaymentDetails(self, category, student,
414            previous_session=None, previous_level=None, combi=[]):
415        """Create a payment ticket and set the payment data of a
416        student for the payment category specified.
417        """
418        p_item = u''
419        amount = 0.0
420        if previous_session:
421            if previous_session < student['studycourse'].entry_session:
422                return _('The previous session must not fall below '
423                         'your entry session.'), None
424            if category == 'schoolfee':
425                # School fee is always paid for the following session
426                if previous_session > student['studycourse'].current_session:
427                    return _('This is not a previous session.'), None
428            else:
429                if previous_session > student['studycourse'].current_session - 1:
430                    return _('This is not a previous session.'), None
431            p_session = previous_session
432            p_level = previous_level
433            p_current = False
434        else:
435            p_session = student['studycourse'].current_session
436            p_level = student['studycourse'].current_level
437            p_current = True
438            if category in self.REQUIRED_PAYMENTS_FRESH.keys() \
439                + self.REQUIRED_PAYMENTS_RETURNING.keys() \
440                + ['schoolfee','schoolfee40','secondinstal'] \
441                and student.state == RETURNING:
442                # In case of school fee or required sundry fee payments the
443                # payment session is always next session if students are in
444                # state returning.
445                p_session, p_level = self.getReturningData(student)
446        academic_session = self._getSessionConfiguration(p_session)
447        if academic_session == None:
448            return _(u'Session configuration object is not available.'), None
449        # Determine fee.
450        if category in ('schoolfee', 'schoolfee40', 'secondinstal'):
451            rpm = self._requiredPaymentsMissing(student, p_session)
452            if rpm:
453                return rpm, None
454            try:
455                certificate = student['studycourse'].certificate
456                p_item = certificate.code
457            except (AttributeError, TypeError):
458                return _('Study course data are incomplete.'), None
459            if previous_session:
460                # Students can pay for previous sessions in all
461                # workflow states.  Fresh students are excluded by the
462                # update method of the PreviousPaymentAddFormPage.
463                if previous_level == 100:
464                    amount = getattr(certificate, 'school_fee_1', 0.0)
465                else:
466                    amount = getattr(certificate, 'school_fee_2', 0.0)
467                if category == 'schoolfee40':
468                    amount = 0.4*amount
469                elif category == 'secondinstal':
470                    amount = 0.6*amount
471            else:
472                if category == 'secondinstal':
473                    if student.is_fresh:
474                        amount = 0.6 * getattr(certificate, 'school_fee_1', 0.0)
475                    else:
476                        amount = 0.6 * getattr(certificate, 'school_fee_2', 0.0)
477                else:
478                    if student.state in (CLEARANCE, REQUESTED, CLEARED):
479                        amount = getattr(certificate, 'school_fee_1', 0.0)
480                    elif student.state == RETURNING:
481                        amount = getattr(certificate, 'school_fee_2', 0.0)
482                    elif student.is_postgrad and student.state == PAID:
483                        # Returning postgraduate students also pay for the
484                        # next session but their level always remains the
485                        # same.
486                        p_session += 1
487                        academic_session = self._getSessionConfiguration(p_session)
488                        if academic_session == None:
489                            return _(
490                                u'Session configuration object is not available.'
491                                ), None
492                        amount = getattr(certificate, 'school_fee_2', 0.0)
493                    if amount and category == 'schoolfee40':
494                        amount = 0.4*amount
495        elif category == 'clearance':
496            try:
497                p_item = student['studycourse'].certificate.code
498            except (AttributeError, TypeError):
499                return _('Study course data are incomplete.'), None
500            amount = academic_session.clearance_fee
501        elif category.startswith('cdlcourse'):
502            amount = academic_session.course_fee
503            number = int(category.strip('cdlcourse'))
504            amount *= number
505        elif category == 'combi' and combi:
[17664]506            categories = getUtility(IKofaUtils).COMBI_PAYMENT_CATEGORIES
[17661]507            for cat in combi:
508                fee_name = cat + '_fee'
509                cat_amount = getattr(academic_session, fee_name, 0.0)
510                if not cat_amount:
511                    return _('%s undefined.' % categories[cat]), None
512                amount += cat_amount
513                p_item += u'%s + ' % categories[cat]
514            p_item = p_item.strip(' + ')
515        else:
516            fee_name = category + '_fee'
517            amount = getattr(academic_session, fee_name, 0.0)
518        if amount in (0.0, None):
519            return _('Amount could not be determined.'), None
520        if self.samePaymentMade(student, category, p_item, p_session):
521            return _('This type of payment has already been made.'), None
522        if self._isPaymentDisabled(p_session, category, student):
523            return _('This category of payments has been disabled.'), None
524        payment = createObject(u'waeup.StudentOnlinePayment')
525        timestamp = ("%d" % int(time()*10000))[1:]
526        if category in (
527            'registration', 'required_combi', 'pg_other', 'jupeb_reg'):
528            payment.provider_amt = 5000.0
529        if category in (
530            'schoolfee', 'schoolfee40') and student.is_jupeb:
531            payment.provider_amt = 5000.0
532        payment.p_id = "p%s" % timestamp
533        payment.p_category = category
534        payment.p_item = p_item
535        payment.p_session = p_session
536        payment.p_level = p_level
537        payment.p_current = p_current
538        payment.amount_auth = amount
539        payment.p_combi = combi
540        return None, payment
541
[16138]542    def setBalanceDetails(self, category, student,
543            balance_session, balance_level, balance_amount):
544        """Create a balance payment ticket and set the payment data
545        as selected by the student.
546        """
[16139]547        if category in ('schoolfee', 'schoolfee40', 'secondinstal') \
548            and balance_session > 2019:
[16138]549            rpm = self._requiredPaymentsMissing(student, balance_session)
550            if rpm:
551                return rpm, None
552        return super(
553            CustomStudentsUtils, self).setBalanceDetails(category, student,
554            balance_session, balance_level, balance_amount)
555
[15802]556    def constructMatricNumber(self, student):
557        """Fetch the matric number counter which fits the student and
558        construct the new matric number of the student.
559        """
560        next_integer = grok.getSite()['configuration'].next_matric_integer
561        if next_integer == 0:
562            return _('Matriculation number cannot be set.'), None
[15810]563        if not student.state in (
564            RETURNING, CLEARED, PAID, REGISTERED, VALIDATED):
[15805]565            return _('Matriculation number cannot be set.'), None
[15802]566        year = unicode(student.entry_session)[2:]
[17818]567        if grok.getSite().__name__ == 'iuokada-cdl':
568            return None, "%s/%04d/%s/CDL" % (year, next_integer, student.faccode)
[16294]569        return None, "%s/%06d/%s" % (year, next_integer, student.faccode)
Note: See TracBrowser for help on using the repository browser.