source: main/waeup.kofa/trunk/src/waeup/kofa/utils/utils.py @ 17902

Last change on this file since 17902 was 17823, checked in by Henrik Bettermann, 6 months ago

Make _set_exporter_values customizable.

  • Property svn:keywords set to Id
File size: 18.7 KB
Line 
1## $Id: utils.py 17823 2024-06-27 09:18:42Z 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##
18"""General helper utilities for Kofa.
19"""
20import grok
21import psutil
22import string
23import pytz
24import decimal
25from copy import deepcopy
26from random import SystemRandom as r
27from zope.i18n import translate
28from zope.security import checkPermission
29from waeup.kofa.interfaces import IKofaUtils
30from waeup.kofa.interfaces import MessageFactory as _
31from waeup.kofa.smtp import send_mail as send_mail_internally
32from waeup.kofa.utils.helpers import get_sorted_preferred
33from waeup.kofa.utils.degrees import DEGREES_DICT
34
35
36def send_mail(from_name, from_addr,
37              rcpt_name, rcpt_addr,
38              subject, body, config,
39              cc=None, bcc=None):
40    """Wrapper for the real SMTP functionality in :mod:`waeup.kofa.smtp`.
41
42    Merely here to stay compatible with lots of calls to this place.
43    """
44    mail_id = send_mail_internally(
45        from_name, from_addr, rcpt_name, rcpt_addr,
46        subject, body, config, cc, bcc)
47    return True
48
49
50#: A list of phone prefixes (order num, country, prefix).
51#: Items with same order num will be sorted alphabetically.
52#: The lower the order num, the higher the precedence.
53INT_PHONE_PREFIXES = [
54    (99, _('Germany'), '49'),
55    (1, _('Nigeria'), '234'),
56    (99, _('U.S.'), '1'),
57    ]
58
59
60def sorted_phone_prefixes(data=INT_PHONE_PREFIXES, request=None):
61    """Sorted tuples of phone prefixes.
62
63    Ordered as shown above and formatted for use in select boxes.
64
65    If request is given, we'll try to translate all country names in
66    order to sort alphabetically correctly.
67
68    XXX: This is a function (and not a constant) as different
69    languages might give different orders. This is not tested yet.
70
71    XXX: If we really want to use alphabetic ordering here, we might
72    think about caching results of translations.
73    """
74    if request is not None:
75        data = [
76            (x, translate(y, context=request), z)
77            for x, y, z in data]
78    return tuple([
79        ('%s (+%s)' % (x[1], x[2]), '+%s' % x[2])
80        for x in sorted(data)
81        ])
82
83
84class KofaUtils(grok.GlobalUtility):
85    """A collection of parameters and methods subject to customization.
86    """
87    grok.implements(IKofaUtils)
88
89    #: This the only place where we define the portal language
90    #: which is used for the translation of system messages
91    #: (e.g. object histories) pdf slips.
92    PORTAL_LANGUAGE = 'en'
93
94    DEGREES_DICT = DEGREES_DICT
95
96    PREFERRED_LANGUAGES_DICT = {
97        'en': (1, u'English'),
98        'fr': (2, u'Français'),
99        'de': (3, u'Deutsch'),
100        'ha': (4, u'Hausa'),
101        'yo': (5, u'Yoruba'),
102        'ig': (6, u'Igbo'),
103        }
104
105    MONTHS_DICT = {
106        '1': _('January'),
107        '2': _('February'),
108        '3': _('March'),
109        '4': _('April'),
110        '5': _('May'),
111        '6': _('June'),
112        '7': _('July'),
113        '8': _('August'),
114        '9': _('September'),
115        '10': _('October'),
116        '11': _('November'),
117        '12': _('December'),
118        }
119
120    #: A function to return
121    @classmethod
122    def sorted_phone_prefixes(cls, data=INT_PHONE_PREFIXES, request=None):
123        return sorted_phone_prefixes(data, request)
124
125    EXAM_SUBJECTS_DICT = {
126        'math': 'Mathematics',
127        'computer_science': 'Computer Science',
128        }
129
130    #: Exam grades. The tuple is sorted as it should be displayed in
131    #: select boxes.
132    EXAM_GRADES = (
133        ('A', 'Best'),
134        ('B', 'Better'),
135        ('C', 'Good'),
136        )
137
138    INST_TYPES_DICT = {
139        'none': '',
140        'faculty': 'Faculty of',
141        'department': 'Department of',
142        'school': 'School of',
143        'office': 'Office for',
144        'centre': 'Centre for',
145        'centre_of': 'Centre of',
146        'institute': 'Institute of',
147        'school_for': 'School for',
148        'college': 'College of',
149        'directorate': 'Directorate of',
150        }
151
152    STUDY_MODES_DICT = {
153        'transfer': 'Transfer',
154        'transferred': 'Transferred',
155        'ug_ft': 'Undergraduate Full-Time',
156        'ug_pt': 'Undergraduate Part-Time',
157        'pg_ft': 'Postgraduate Full-Time',
158        'pg_pt': 'Postgraduate Part-Time',
159        }
160
161    DISABLE_PAYMENT_GROUP_DICT = {
162        'sf_all': 'School Fee - All Students',
163        }
164
165    APP_CATS_DICT = {
166        'basic': 'Basic Application',
167        'no': 'no application',
168        'pg': 'Postgraduate',
169        'sandwich': 'Sandwich',
170        'cest': 'Part-Time, Diploma, Certificate'
171        }
172
173    SEMESTER_DICT = {
174        1: '1st Semester',
175        2: '2nd Semester',
176        3: 'Combined',
177        4: '1st Term',
178        5: '2nd Term',
179        6: '3rd Term',
180        9: 'N/A',
181        11: 'Module I',
182        12: 'Module II',
183        13: 'Module III',
184        }
185
186    COURSE_CATEGORY_DICT = {
187        }
188
189    SPECIAL_HANDLING_DICT = {
190        'regular': 'Regular Hostel',
191        'blocked': 'Blocked Hostel',
192        'pg': 'Postgraduate Hostel'
193        }
194
195    SPECIAL_APP_DICT = {
196        'transcript': 'Transcript Fee Payment',
197        'clearance': 'Acceptance Fee',
198        }
199
200    PAYMENT_CATEGORIES = {
201        'schoolfee': 'School Fee',
202        'clearance': 'Acceptance Fee',
203        'bed_allocation': 'Bed Allocation Fee',
204        'hostel_maintenance': 'Hostel Maintenance Fee',
205        'transfer': 'Transfer Fee',
206        'gown': 'Gown Hire Fee',
207        'application': 'Application Fee',
208        'app_balance': 'Application Fee Balance',
209        'transcript': 'Transcript Fee',
210        'late_registration': 'Late Course Registration Fee',
211        'combi': 'Combi Payment',
212        'donation': 'Donation',
213        }
214
215    #: If PAYMENT_OPTIONS is empty, payment option fields won't show up.
216    PAYMENT_OPTIONS = {
217        #'credit_card': 'Credit Card',
218        #'debit_card': 'Debit Card',
219        }
220
221    def selectable_payment_categories(self, student):
222        return self.PAYMENT_CATEGORIES
223
224    def selectable_payment_options(self, student):
225        return self.PAYMENT_OPTIONS
226
227    PREVIOUS_PAYMENT_CATEGORIES = deepcopy(PAYMENT_CATEGORIES)
228
229    REPORTABLE_PAYMENT_CATEGORIES = {
230        'schoolfee': 'School Fee',
231        'clearance': 'Acceptance Fee',
232        'hostel_maintenance': 'Hostel Maintenance Fee',
233        'gown': 'Gown Hire Fee',
234        }
235
236    BALANCE_PAYMENT_CATEGORIES = {
237        'schoolfee': 'School Fee',
238        }
239
240    APPLICANT_BALANCE_PAYMENT_CATEGORIES = {
241        'donation': 'Donation',
242        }
243
244    COMBI_PAYMENT_CATEGORIES = {
245        'gown': 'Gown Hire Fee',
246        'transcript': 'Transcript Fee',
247        'late_registration': 'Late Course Registration Fee',
248        }
249
250    MODE_GROUPS = {
251        'All': ('all',),
252        'Undergraduate Full-Time': ('ug_ft',),
253        'Undergraduate Part-Time': ('ug_pt',),
254        'Postgraduate Full-Time': ('pg_ft',),
255        'Postgraduate Part-Time': ('pg_pt',),
256        }
257
258    VERDICTS_DICT = {
259        '0': _('(not yet)'),
260        'A': 'Successful student',
261        'B': 'Student with carryover courses',
262        'C': 'Student on probation',
263        }
264
265    #: Set positive number for allowed max, negative for required min
266    #: avail.
267    #: Use integer for bytes value, float for percent
268    #: value. `cpu-load`, of course, accepts float values only.
269    #: `swap-mem` = Swap Memory, `virt-mem` = Virtual Memory,
270    #: `cpu-load` = CPU load in percent.
271    SYSTEM_MAX_LOAD = {
272        'swap-mem': None,
273        'virt-mem': None,
274        'cpu-load': 100.0,
275        }
276
277    #: Maximum number of files listed in `finished` subfolder
278    MAX_FILES = 100
279
280    #: Maximum size in Bytes of passport images in the applicants and
281    #: students section
282    MAX_PASSPORT_SIZE = 50 * 1024
283
284    #: Temporary passwords and parents password validity period
285    TEMP_PASSWORD_MINUTES = 10
286
287    def sortCertificates(self, context, resultset):
288        """Sort already filtered certificates in `CertificateSource`.
289        """
290        return sorted(resultset, key=lambda value: value.code)
291
292    def getCertTitle(self, context, value):
293        """Compose the titles in `CertificateSource.
294        """
295        return "%s - %s" % (value.code, value.title)
296
297    def getCertLongTitle(self, value):
298        """Compose certificate's longtitle.
299        """
300        return "%s (%s)" % (value.title, value.code)
301
302    def sendContactForm(self, from_name, from_addr, rcpt_name, rcpt_addr,
303                        from_username, usertype, portal, body, subject,
304                        bcc_to=None):
305        """Send an email with data provided by forms.
306        """
307        config = grok.getSite()['configuration']
308        text = _(u"""${e}
309
310---
311${a} (id: ${b})
312${d}
313""")
314        text = _(text, mapping={
315            'a': from_name,
316            'b': from_username,
317            'c': usertype,
318            'd': portal,
319            'e': body})
320        body = translate(text, 'waeup.kofa',
321            target_language=self.PORTAL_LANGUAGE)
322        if not (from_addr and rcpt_addr):
323            return False
324        return send_mail(
325            from_name, from_addr, rcpt_name, rcpt_addr,
326            subject, body, config, None, bcc_to)
327
328    def getUsers(self):
329        users = sorted(
330            grok.getSite()['users'].items(), key=lambda x: x[1].title)
331        for key, val in users:
332            yield(dict(name=key, val="%s (%s)" % (val.title, val.name)))
333
334    @property
335    def tzinfo(self):
336        """Time zone of the university.
337        """
338        # For Nigeria: pytz.timezone('Africa/Lagos')
339        # For Germany: pytz.timezone('Europe/Berlin')
340        return pytz.utc
341
342    def fullname(self, firstname, lastname, middlename=None):
343        """Construct fullname.
344        """
345        # We do not necessarily have the middlename attribute
346        if middlename:
347            name = '%s %s %s' % (firstname, middlename, lastname)
348        else:
349            name = '%s %s' % (firstname, lastname)
350        if '<' in name:
351            return 'XXX'
352        return string.capwords(
353            name.replace('-', ' - ')).replace(' - ', '-')
354
355    def genPassword(self, length=4, chars=string.letters + string.digits):
356        """Generate a random password.
357        """
358        return ''.join([
359            r().choice(string.uppercase) +
360            r().choice(string.lowercase) +
361            r().choice(string.digits) for i in range(length)])
362
363    def sendCredentials(self, user, password=None, url_info=None, msg=None):
364        """Send credentials as email. Input is the user for which credentials
365        are sent and the password. Method returns True or False to indicate
366        successful operation.
367        """
368        subject = _('Your Kofa credentials')
369        text = _(u"""Dear ${a},
370
371${b}
372Student Registration and Information Portal of
373${c}.
374
375Your user name: ${d}
376Your password: ${e}
377${f}
378
379Please remember your user name and keep
380your password secret!
381
382Please also note that passwords are case-sensitive.
383
384Regards
385""")
386        config = grok.getSite()['configuration']
387        from_name = config.name_admin
388        from_addr = config.email_admin
389        rcpt_name = user.title
390        rcpt_addr = user.email
391        text = _(text, mapping={
392            'a': rcpt_name,
393            'b': msg,
394            'c': config.name,
395            'd': user.name,
396            'e': password,
397            'f': url_info})
398
399        body = translate(text, 'waeup.kofa',
400            target_language=self.PORTAL_LANGUAGE)
401        return send_mail(
402            from_name, from_addr, rcpt_name, rcpt_addr,
403            subject, body, config)
404
405    def informNewStudent(self, user, pw, login_url, rpw_url):
406        """Inform student that a new student account has been created.
407        """
408        subject = _('Your new Kofa student account')
409        text = _(u"""Dear ${a},
410
411Your student record of the Student Registration and Information Portal of
412${b} has been created for you.
413
414Your user name: ${c}
415Your password: ${d}
416Login: ${e}
417
418Or request a new secure password here: ${f}
419
420Regards
421""")
422        config = grok.getSite()['configuration']
423        from_name = config.name_admin
424        from_addr = config.email_admin
425        rcpt_name = user.title
426        rcpt_addr = user.email
427
428        text = _(text, mapping={
429            'a': rcpt_name,
430            'b': config.name,
431            'c': user.name,
432            'd': pw,
433            'e': login_url,
434            'f': rpw_url
435            })
436        body = translate(text, 'waeup.kofa',
437            target_language=self.PORTAL_LANGUAGE)
438        return send_mail(
439            from_name, from_addr, rcpt_name, rcpt_addr,
440            subject, body, config)
441
442
443    def informApplicant(self, applicant):
444        """Inform applicant that the application form was successfully
445        submitted.
446        """
447        if not getattr(applicant.__parent__, 'send_email', False):
448            return
449        subject = 'Your application form was successfully submitted'
450        text = _(u"""Dear ${a},
451
452Your application ${b} has been successfully submitted to ${c}.
453
454Regards
455""")
456        config = grok.getSite()['configuration']
457        from_name = config.name_admin
458        from_addr = config.email_admin
459        rcpt_name = applicant.display_fullname
460        rcpt_addr = applicant.email
461        session = '%s/%s' % (
462            applicant.__parent__.year, applicant.__parent__.year+1)
463        text = _(text, mapping={
464            'a': rcpt_name,
465            'b': applicant.applicant_id,
466            'c': config.name,
467            })
468        body = translate(text, 'waeup.kofa',
469            target_language=self.PORTAL_LANGUAGE)
470        return send_mail(
471            from_name, from_addr, rcpt_name, rcpt_addr,
472            subject, body, config)
473
474    def inviteReferee(self, referee, applicant, url_info=None):
475        """Send invitation email to referee.
476        """
477        config = grok.getSite()['configuration']
478        subject = 'Request for referee report from %s' % config.name
479        text = _(u"""Dear ${a},
480
481The candidate with Id ${b} and name ${c} applied to
482the ${d} to study ${e} for the ${f} session.
483The candidate has listed you as referee. You are, therefore, required to,
484kindly, provide your referral remarks on or before ${g}. Please use the
485following form:
486
487${h}
488
489Thank You
490
491The Secretary
492School of Postgraduate Studies
493${d}
494""")
495        from_name = config.name_admin
496        from_addr = config.email_admin
497        rcpt_name = referee.name
498        rcpt_addr = referee.email
499        session = '%s/%s' % (
500            applicant.__parent__.year, applicant.__parent__.year+1)
501        text = _(text, mapping={
502            'a': rcpt_name,
503            'b': applicant.applicant_id,
504            'c': applicant.display_fullname,
505            'd': config.name,
506            'e': applicant.course1.title,
507            'f': session,
508            'g': applicant.__parent__.enddate,
509            'h': url_info,
510            })
511
512        body = translate(text, 'waeup.kofa',
513            target_language=self.PORTAL_LANGUAGE)
514        return send_mail(
515            from_name, from_addr, rcpt_name, rcpt_addr,
516            subject, body, config)
517
518    def getPaymentItem(self, payment):
519        """Return payment item. This method can be used to customize the
520        `display_item` property attribute, e.g. in order to hide bed coordinates
521        if maintenance fee is not paid.
522        """
523        return payment.p_item
524
525    def expensive_actions_allowed(self, type=None, request=None):
526        """Tell, whether expensive actions are currently allowed.
527        Check system load/health (or other external circumstances) and
528        locally set values to see, whether expensive actions should be
529        allowed (`True`) or better avoided (`False`).
530        Use this to allow or forbid exports, report generations, or
531        similar actions.
532        """
533        max_values = self.SYSTEM_MAX_LOAD
534        for (key, func) in (
535            ('swap-mem', psutil.swap_memory),
536            ('virt-mem', psutil.virtual_memory),
537            ):
538            max_val = max_values.get(key, None)
539            if max_val is None:
540                continue
541            mem_val = func()
542            if isinstance(max_val, float):
543                # percents
544                if max_val < 0.0:
545                    max_val = 100.0 + max_val
546                if mem_val.percent > max_val:
547                    return False
548            else:
549                # number of bytes
550                if max_val < 0:
551                    max_val = mem_val.total + max_val
552                if mem_val.used > max_val:
553                    return False
554        return True
555
556    def export_disabled_message(self):
557        export_disabled_message = grok.getSite()[
558            'configuration'].export_disabled_message
559        if export_disabled_message:
560            return export_disabled_message
561        return None
562
563    def format_float(self, value, prec):
564        # >>> 4.6 * 100
565        # 459.99999999999994
566        value = decimal.Decimal(str(value))
567        # cut floating point value
568        value = int(pow(10, prec)*value) / (1.0*pow(10, prec))
569        return '{:{width}.{prec}f}'.format(value, width=0, prec=prec)
570
571    def collect_exporters(self, context):
572        # Used for `ExportJobContainerJobConfig`.
573        # We provide all student exporters, nothing else, yet.
574        # Bursary, Department or Accommodation Officers don't
575        # have the general exportData
576        # permission and are only allowed to export bursary, payments
577        # overview or accommodation data respectively.
578        # This is the only place where waeup.exportAccommodationData,
579        # waeup.exportBursaryData and waeup.exportPaymentsOverview
580        # are used.
581        exporters = []
582        if not checkPermission('waeup.exportData', context):
583            if checkPermission('waeup.exportBursaryData', context):
584                exporters += [('Bursary Data', 'bursary')]
585            if checkPermission('waeup.exportPaymentsOverview', context):
586                exporters += [('School Fee Payments Overview',
587                               'sfpaymentsoverview'),
588                              ('Session Payments Overview',
589                               'sessionpaymentsoverview')]
590            if checkPermission('waeup.exportAccommodationData', context):
591                exporters += [('Bed Tickets', 'bedtickets'),
592                              ('Accommodation Payments',
593                               'accommodationpayments')]
594            return exporters
Note: See TracBrowser for help on using the repository browser.