source: main/waeup.kofa/trunk/src/waeup/kofa/applicants/applicant.py @ 16594

Last change on this file since 16594 was 16551, checked in by Henrik Bettermann, 3 years ago

Send email to student after single record creation.

  • Property svn:keywords set to Id
File size: 19.8 KB
RevLine 
[7192]1## $Id: applicant.py 16551 2021-07-14 11:39:09Z henrik $
[5272]2##
[7192]3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
[5272]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.
[7192]8##
[5272]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.
[7192]13##
[5272]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##
[7063]18import os
[5272]19import grok
[7391]20from cStringIO import StringIO
[6115]21from grok import index
[8404]22from hurry.query import Eq, Text
23from hurry.query.query import Query
[16551]24from urllib import urlencode
[7438]25from zope.component import getUtility, createObject, getAdapter
[5318]26from zope.component.interfaces import IFactory
[7421]27from zope.event import notify
[7240]28from zope.securitypolicy.interfaces import IPrincipalRoleManager
[5479]29from zope.interface import implementedBy
[16278]30from zope.schema.interfaces import (
31    RequiredMissing, ConstraintNotSatisfied, WrongType)
[6296]32from hurry.workflow.interfaces import IWorkflowInfo, IWorkflowState
[7819]33from waeup.kofa.image import KofaImageFile
[14256]34from reportlab.platypus.doctemplate import LayoutError
[7811]35from waeup.kofa.imagestorage import DefaultFileStoreHandler
36from waeup.kofa.interfaces import (
[7819]37    IObjectHistory, IFileStoreHandler, IFileStoreNameChooser, IKofaUtils,
[9217]38    IExtFileStore, IPDF, IUserAccount, IUniversity)
[8311]39from waeup.kofa.interfaces import MessageFactory as _
[7811]40from waeup.kofa.students.vocabularies import RegNumNotInSource
[8843]41from waeup.kofa.students.studycourse import StudentStudyCourse
[7811]42from waeup.kofa.utils.helpers import attrs_to_fields
[10845]43from waeup.kofa.applicants.interfaces import (
[15941]44    IApplicant, IApplicantEdit, ISpecialApplicant, IApplicantsUtils)
[7811]45from waeup.kofa.applicants.workflow import application_states_dict
[13968]46from waeup.kofa.applicants.payment import ApplicantOnlinePayment
[13974]47from waeup.kofa.applicants.refereereport import ApplicantRefereeReport
[5272]48
[8404]49def search(query=None, searchtype=None, view=None):
50    if searchtype in ('fullname',):
51        results = Query().searchResults(
52            Text(('applicants_catalog', searchtype), query))
53    else:
54        results = Query().searchResults(
55            Eq(('applicants_catalog', searchtype), query))
56    return results
57
[7250]58class Applicant(grok.Container):
[10845]59    grok.implements(IApplicant, IApplicantEdit, ISpecialApplicant)
[5483]60    grok.provides(IApplicant)
[5272]61
[11615]62    applicant_student_mapping = [
[11614]63        ('firstname', 'firstname'),
64        ('middlename', 'middlename'),
65        ('lastname', 'lastname'),
66        ('sex', 'sex'),
67        ('date_of_birth', 'date_of_birth'),
68        ('email', 'email'),
69        ('phone', 'phone'),
[11615]70        ]
[8667]71
[16228]72    applicant_graduated_mapping = [
73        ('firstname', 'firstname'),
74        ('middlename', 'middlename'),
75        ('lastname', 'lastname'),
76        ('sex', 'sex'),
77        ('date_of_birth', 'date_of_birth'),
78        ('email', 'email'),
79        ('phone', 'phone'),
80        ]
81
[7260]82    def __init__(self):
[6296]83        super(Applicant, self).__init__()
[7240]84        self.password = None
[6476]85        self.application_date = None
[7260]86        self.applicant_id = None
[6331]87        return
[6296]88
[13968]89    @property
90    def payments(self):
91        payments = [value for value in self.values()
92            if isinstance(value, ApplicantOnlinePayment)]
93        return payments
94
[13974]95    @property
96    def refereereports(self):
97        reports = [value for value in self.values()
98            if isinstance(value, ApplicantRefereeReport)]
99        return reports
100
[8742]101    def writeLogMessage(self, view, message):
102        ob_class = view.__implemented__.__name__.replace('waeup.kofa.','')
103        self.__parent__.__parent__.logger.info(
104            '%s - %s - %s' % (ob_class, self.applicant_id, message))
105        return
[6475]106
[6324]107    @property
[8286]108    def state(self):
[7686]109        return IWorkflowState(self).getState()
[6324]110
[6339]111    @property
[8377]112    def container_code(self):
[13080]113        try:
114            code = self.__parent__.code
115        except AttributeError:  # in unit tests
116            return
[13216]117        if (self.password,
118            self.firstname,
119            self.lastname,
120            self.email) == (None, None, None, None):
121            return code + '-'
122        return code + '+'
[8377]123
124    @property
[7686]125    def translated_state(self):
[13080]126        try:
127            state = application_states_dict[self.state]
128        except LookupError:  # in unit tests
129            return
130        return state
[7686]131
132    @property
[6339]133    def history(self):
134        history = IObjectHistory(self)
135        return history
136
[7240]137    @property
[10831]138    def special(self):
[13080]139        try:
140            special = self.__parent__.prefix.startswith('special')
141        except AttributeError:  # in unit tests
142            return
143        return special
[10831]144
145    @property
[7240]146    def application_number(self):
[7260]147        try:
148            return self.applicant_id.split('_')[1]
149        except AttributeError:
150            return None
[7240]151
152    @property
[7364]153    def display_fullname(self):
[7356]154        middlename = getattr(self, 'middlename', None)
[7819]155        kofa_utils = getUtility(IKofaUtils)
[7811]156        return kofa_utils.fullname(self.firstname, self.lastname, middlename)
[7240]157
[9480]158    def _setStudyCourseAttributes(self, studycourse):
159        studycourse.entry_mode = self.course_admitted.study_mode
160        studycourse.current_level = self.course_admitted.start_level
161        studycourse.certificate = self.course_admitted
162        studycourse.entry_session = self.__parent__.year
163        studycourse.current_session = self.__parent__.year
164        return
165
[16228]166    def _setGraduatedStudyCourseAttributes(self, studycourse):
167        studycourse.entry_mode = self.course_studied.study_mode
168        studycourse.current_level = self.course_studied.start_level
169        studycourse.certificate = self.course_studied
170        studycourse.entry_session = self.__parent__.year
171        studycourse.current_session = self.__parent__.year
172        return
173
[16551]174    def createStudent(self, view=None, graduated=False, send_email=False):
[15941]175        """Create a student, fill with base data, create an application slip,
[16551]176        copy applicant data and files, send an email (optional).
[7338]177        """
[15932]178        site = grok.getSite()
[7344]179        # Is applicant in the correct state?
[16228]180        if graduated:
181            if self.state != 'processed':
182                return False, _('Applicant has not yet been processed.')
183            certificate = getattr(self, 'course_studied', None)
184            mapping = self.applicant_graduated_mapping
185        else:
186            if self.state != 'admitted':
187                return False, _('Applicant has not yet been admitted.')
188            certificate = getattr(self, 'course_admitted', None)
189            mapping = self.applicant_student_mapping
[7344]190        # Does registration number exist?
[7338]191        student = createObject(u'waeup.Student')
[7341]192        try:
193            student.reg_number = self.reg_number
194        except RegNumNotInSource:
[15932]195            # Reset _curr_stud_id
196            site['students']._curr_stud_id -= 1
[8311]197            return False, _('Registration Number exists.')
[16228]198        # Has the course_admitted/course_studied field been properly filled?
199        if certificate is None:
[15932]200            # Reset _curr_stud_id
201            site['students']._curr_stud_id -= 1
[16228]202            return False, _('No study course provided.')
[8843]203        # Set student attributes
[8635]204        try:
[16228]205            for item in mapping:
[16279]206                if item[0] == 'email':
[16282]207                    # The field type in the application section might be
208                    # TextLineChoice to guarantee uniqueness of email addresses.
[16279]209                    setattr(student, item[1], str(getattr(self, item[0], None)))
210                else:
211                    setattr(student, item[1], getattr(self, item[0], None))
[8635]212        except RequiredMissing, err:
[15932]213            site['students']._curr_stud_id -= 1
[8855]214            return False, 'RequiredMissing: %s' % err
[16278]215        except WrongType, err:
[16279]216            site['students']._curr_stud_id -= 1
217            return False, 'WrongType: %s' % err
[8843]218        except:
[15932]219            site['students']._curr_stud_id -= 1
[9391]220            return False, 'Unknown Error'
[12395]221        # Prove if the certificate still exists
[8843]222        try:
[16228]223            StudentStudyCourse().certificate = certificate
[8843]224        except ConstraintNotSatisfied, err:
[15932]225            # Reset _curr_stud_id
226            site['students']._curr_stud_id -= 1
[16228]227            return False, 'ConstraintNotSatisfied: %s' % certificate.code
[12395]228        # Finally prove if an application slip can be created
229        try:
[12565]230            test_applicant_slip = getAdapter(self, IPDF, name='application_slip')(
[12395]231                view=view)
232        except IOError:
[15932]233            site['students']._curr_stud_id -= 1
[12395]234            return False, _('IOError: Application Slip could not be created.')
[14256]235        except LayoutError, err:
[15932]236            site['students']._curr_stud_id -= 1
[14256]237            return False, _('Reportlab LayoutError: %s' % err)
[15930]238        except AttributeError, err:
[15932]239            site['students']._curr_stud_id -= 1
[15930]240            return False, _('Reportlab AttributeError: ${a}', mapping = {'a':err})
[14263]241        except:
[15932]242            site['students']._curr_stud_id -= 1
[15930]243            return False, _('Unknown Reportlab error')
[8843]244        # Add student
[7406]245        site['students'].addStudent(student)
[9121]246        # Save student_id
247        self.student_id = student.student_id
[16228]248        if graduated:
249            # Set state
250            IWorkflowState(student).setState('graduated')
251            # Save the certificate and set study course attributes
252            self._setGraduatedStudyCourseAttributes(student['studycourse'])
253        else:
254            # Fire transitions
255            IWorkflowInfo(self).fireTransition('create')
256            IWorkflowInfo(student).fireTransition('admit')
257            # Save the certificate and set study course attributes
258            self._setStudyCourseAttributes(student['studycourse'])
[7406]259        # Set password
[7409]260        IUserAccount(student).setPassword(self.application_number)
[7388]261        self._copyPassportImage(student)
[15941]262        self._copyFiles(student)
[7421]263        # Update the catalog
264        notify(grok.ObjectModifiedEvent(student))
[7391]265        # Save application slip
[12565]266        applicant_slip = getAdapter(self, IPDF, name='application_slip')(
267            view=view)
[12395]268        self._saveApplicationPDF(student, applicant_slip, view=view)
[16551]269        if view and send_email:
270            kofa_utils = getUtility(IKofaUtils)
271            pw = self.application_number
272            args = {'login':student.student_id, 'password':pw}
273            login_url = view.url(grok.getSite()) + '/login?%s' % urlencode(args)
274            rpw_url = view.url(grok.getSite(), 'changepw')
275            kofa_utils.informNewStudent(
276                IUserAccount(student), pw, login_url, rpw_url)
[12395]277        return True, _('Student ${a} created', mapping = {'a':student.student_id})
[7355]278
[12395]279    def _saveApplicationPDF(self, student, applicant_slip, view=None):
[7391]280        """Create an application slip as PDF and store it in student folder.
281        """
282        file_store = getUtility(IExtFileStore)
283        file_id = IFileStoreNameChooser(student).chooseName(
284            attr="application_slip.pdf")
285        file_store.createFile(file_id, StringIO(applicant_slip))
286        return
287
[7388]288    def _copyPassportImage(self, student):
289        """Copy any passport image over to student location.
290        """
291        file_store = getUtility(IExtFileStore)
292        appl_file = file_store.getFileByContext(self)
[7417]293        if appl_file is None:
294            return
[7388]295        stud_file_id = IFileStoreNameChooser(student).chooseName(
296            attr="passport.jpg")
[7417]297        file_store.createFile(stud_file_id, appl_file)
[7388]298        return
299
[15941]300    def _copyFiles(self, student):
301        """Copy all other files over to student location. Not
302        used in base package but tested with fake file.
303        """
304        file_store = getUtility(IExtFileStore)
[15943]305        filenames = getUtility(IApplicantsUtils).ADDITIONAL_FILES
[15941]306        for filename in filenames:
[15943]307            appl_file = file_store.getFileByContext(self, attr=filename[1])
[15941]308            if appl_file is None:
[16016]309                continue
[16545]310            ext = os.path.splitext(appl_file.name)[1]
[15941]311            stud_file_id = IFileStoreNameChooser(student).chooseName(
[16545]312                attr=filename[1] + ext)
[15941]313            file_store.createFile(stud_file_id, appl_file)
314        return
315
[5983]316# Set all attributes of Applicant required in IApplicant as field
317# properties. Doing this, we do not have to set initial attributes
318# ourselves and as a bonus we get free validation when an attribute is
319# set.
[6115]320Applicant = attrs_to_fields(Applicant)
[5272]321
[8404]322class ApplicantsCatalog(grok.Indexes):
[6115]323    """A catalog indexing :class:`Applicant` instances in the ZODB.
324    """
[9217]325    grok.site(IUniversity)
[6115]326    grok.name('applicants_catalog')
327    grok.context(IApplicant)
328
[8404]329    fullname = index.Text(attribute='display_fullname')
[7240]330    applicant_id = index.Field(attribute='applicant_id')
[7270]331    reg_number = index.Field(attribute='reg_number')
[8039]332    email = index.Field(attribute='email')
[8635]333    state = index.Field(attribute='state')
[13216]334    container_code = index.Field(attribute='container_code')
[6115]335
[5318]336class ApplicantFactory(grok.GlobalUtility):
[6620]337    """A factory for applicants.
[5318]338    """
339    grok.implements(IFactory)
340    grok.name(u'waeup.Applicant')
341    title = u"Create a new applicant.",
342    description = u"This factory instantiates new applicant instances."
343
344    def __call__(self, *args, **kw):
[7260]345        return Applicant()
[5318]346
347    def getInterfaces(self):
[5479]348        return implementedBy(Applicant)
[7063]349
350
351#: The file id marker for applicant passport images
352APPLICANT_IMAGE_STORE_NAME = 'img-applicant'
353
354class ApplicantImageNameChooser(grok.Adapter):
355    """A file id chooser for :class:`Applicant` objects.
356
357    `context` is an :class:`Applicant` instance.
358
359    The :class:`ApplicantImageNameChooser` can build/check file ids
360    for :class:`Applicant` objects suitable for use with
361    :class:`ExtFileStore` instances. The delivered file_id contains
362    the file id marker for :class:`Applicant` object and the
363    registration number or access code of the context applicant. Also
364    the name of the connected applicant container will be part of the
365    generated file id.
366
367    This chooser is registered as an adapter providing
[7811]368    :class:`waeup.kofa.interfaces.IFileStoreNameChooser`.
[7063]369
370    File store name choosers like this one are only convenience
371    components to ease the task of creating file ids for applicant
372    objects. You are nevertheless encouraged to use them instead of
373    manually setting up filenames for applicants.
374
[7811]375    .. seealso:: :mod:`waeup.kofa.imagestorage`
[7063]376
377    """
378    grok.context(IApplicant)
379    grok.implements(IFileStoreNameChooser)
380
[7067]381    def checkName(self, name=None, attr=None):
[7063]382        """Check whether the given name is a valid file id for the context.
383
384        Returns ``True`` only if `name` equals the result of
385        :meth:`chooseName`.
[7067]386
387        The `attr` parameter is not taken into account for
388        :class:`Applicant` context as the single passport image is the
389        only file we store for applicants.
[7063]390        """
391        return name == self.chooseName()
392
[7105]393    def chooseName(self, name=None, attr=None):
[7063]394        """Get a valid file id for applicant context.
395
396        *Example:*
397
[7240]398        For an applicant with applicant_id. ``'app2001_1234'``
[7063]399        and stored in an applicants container called
400        ``'mycontainer'``, this chooser would create:
401
[7240]402          ``'__img-applicant__mycontainer/app2001_1234.jpg'``
[7063]403
404        meaning that the passport image of this applicant would be
405        stored in the site-wide file storage in path:
406
[7240]407          ``mycontainer/app2001_1234.jpg``
[7063]408
409        If the context applicant has no parent, ``'_default'`` is used
410        as parent name.
[7067]411
[10089]412        In the beginning the `attr` parameter was not taken into account for
413        :class:`Applicant` context as the single passport image was the
[15941]414        only file we store for applicants. Meanwhile many universities require
[10089]415        uploads of other documents too. Now we store passport image
416        files without attribute but all other documents with.
[7067]417
[7063]418        """
419        parent_name = getattr(
420            getattr(self.context, '__parent__', None),
421            '__name__', '_default')
[10089]422        if attr is None or attr == 'passport.jpg':
423            marked_filename = '__%s__%s/%s.jpg' % (
424                APPLICANT_IMAGE_STORE_NAME,
425                parent_name, self.context.applicant_id)
426        else:
427            basename, ext = os.path.splitext(attr)
428            marked_filename = '__%s__%s/%s_%s%s' % (
429                APPLICANT_IMAGE_STORE_NAME,
[10092]430                parent_name, basename, self.context.applicant_id, ext)
[7063]431        return marked_filename
432
433
434class ApplicantImageStoreHandler(DefaultFileStoreHandler, grok.GlobalUtility):
435    """Applicant specific image handling.
436
437    This handler knows in which path in a filestore to store applicant
438    images and how to turn this kind of data into some (browsable)
439    file object.
440
441    It is called from the global file storage, when it wants to
442    get/store a file with a file id starting with
443    ``__img-applicant__`` (the marker string for applicant images).
444
445    Like each other file store handler it does not handle the files
446    really (this is done by the global file store) but only computes
447    paths and things like this.
448    """
449    grok.implements(IFileStoreHandler)
450    grok.name(APPLICANT_IMAGE_STORE_NAME)
451
452    def pathFromFileID(self, store, root, file_id):
453        """All applicants images are filed in directory ``applicants``.
454        """
455        marker, filename, basename, ext = store.extractMarker(file_id)
[7121]456        sub_root = os.path.join(root, 'applicants')
457        return super(ApplicantImageStoreHandler, self).pathFromFileID(
458            store, sub_root, basename)
[7063]459
460    def createFile(self, store, root, filename, file_id, file):
461        """Create a browsable file-like object.
462        """
[7121]463        # call super method to ensure that any old files with
464        # different filename extension are deleted.
465        file, path, file_obj =  super(
466            ApplicantImageStoreHandler, self).createFile(
467            store, root,  filename, file_id, file)
[7819]468        return file, path, KofaImageFile(
[7121]469            file_obj.filename, file_obj.data)
[7240]470
471@grok.subscribe(IApplicant, grok.IObjectAddedEvent)
472def handle_applicant_added(applicant, event):
473    """If an applicant is added local and site roles are assigned.
474    """
475    role_manager = IPrincipalRoleManager(applicant)
476    role_manager.assignRoleToPrincipal(
477        'waeup.local.ApplicationOwner', applicant.applicant_id)
478    # Assign current principal the global Applicant role
479    role_manager = IPrincipalRoleManager(grok.getSite())
480    role_manager.assignRoleToPrincipal(
481        'waeup.Applicant', applicant.applicant_id)
[8375]482    if applicant.state is None:
483        IWorkflowInfo(applicant).fireTransition('init')
[7240]484
485    # Assign global applicant role for new applicant (alternative way)
486    #account = IUserAccount(applicant)
487    #account.roles = ['waeup.Applicant']
488
489    return
490
491@grok.subscribe(IApplicant, grok.IObjectRemovedEvent)
492def handle_applicant_removed(applicant, event):
[13224]493    """If an applicant is removed a message is logged, passport images are
494    deleted and the global role is unset.
[7240]495    """
[13073]496    comment = 'Application record removed'
[7240]497    target = applicant.applicant_id
498    try:
[7652]499        grok.getSite()['applicants'].logger.info('%s - %s' % (
500            target, comment))
[7240]501    except KeyError:
502        # If we delete an entire university instance there won't be
503        # an applicants subcontainer
504        return
[13224]505    # Remove any passport image.
[8285]506    file_store = getUtility(IExtFileStore)
507    file_store.deleteFileByContext(applicant)
[15941]508    # Remove all other files too
[15943]509    filenames = getUtility(IApplicantsUtils).ADDITIONAL_FILES
510    for filename in filenames:
511        file_store.deleteFileByContext(applicant, attr=filename[1])
[13224]512    # Remove global role
513    role_manager = IPrincipalRoleManager(grok.getSite())
514    role_manager.unsetRoleForPrincipal(
515        'waeup.Applicant', applicant.applicant_id)
[7240]516    return
Note: See TracBrowser for help on using the repository browser.