source: main/waeup.ikoba/trunk/src/waeup/ikoba/customers/customer.py @ 12032

Last change on this file since 12032 was 12032, checked in by Henrik Bettermann, 10 years ago

Implement translated_state property correctly so that we can more easily customized customer registration and document verification state names.

  • Property svn:keywords set to Id
File size: 15.1 KB
Line 
1## $Id: customer.py 12032 2014-11-21 18:52:43Z henrik $
2##
3## Copyright (C) 2014 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"""
19Container for the various objects owned by customers.
20"""
21import os
22import re
23import shutil
24import grok
25from datetime import datetime, timedelta
26from hurry.workflow.interfaces import IWorkflowState, IWorkflowInfo
27from zope.password.interfaces import IPasswordManager
28from zope.component import getUtility, createObject
29from zope.component.interfaces import IFactory
30from zope.interface import implementedBy
31from zope.securitypolicy.interfaces import IPrincipalRoleManager
32from zope.schema.interfaces import ConstraintNotSatisfied
33
34from waeup.ikoba.image import IkobaImageFile
35from waeup.ikoba.imagestorage import DefaultFileStoreHandler
36from waeup.ikoba.interfaces import (
37    IObjectHistory, IUserAccount, IFileStoreNameChooser, IFileStoreHandler,
38    IIkobaUtils, IExtFileStore,
39    CREATED, REQUESTED, APPROVED)
40from waeup.ikoba.customers.export import EXPORTER_NAMES
41from waeup.ikoba.customers.interfaces import (
42    ICustomer, ICustomerNavigation, ICSVCustomerExporter,
43    ICustomersUtils)
44from waeup.ikoba.customers.utils import generate_customer_id
45from waeup.ikoba.customers.documents import CustomerDocumentsContainer
46from waeup.ikoba.utils.helpers import attrs_to_fields, now, copy_filesystem_tree
47
48RE_CUSTID_NON_NUM = re.compile('[^\d]+')
49
50
51class Customer(grok.Container):
52    """This is a customer container for the various objects
53    owned by customers.
54    """
55    grok.implements(ICustomer, ICustomerNavigation)
56    grok.provides(ICustomer)
57
58    temp_password_minutes = 10
59
60    def __init__(self):
61        super(Customer, self).__init__()
62        # The site doesn't exist in unit tests
63        try:
64            self.customer_id = generate_customer_id()
65        except TypeError:
66            self.customer_id = u'Z654321'
67        self.password = None
68        self.temp_password = None
69        return
70
71    def setTempPassword(self, user, password):
72        """Set a temporary password (LDAP-compatible) SSHA encoded for
73        officers.
74
75        """
76        passwordmanager = getUtility(IPasswordManager, 'SSHA')
77        self.temp_password = {}
78        self.temp_password[
79            'password'] = passwordmanager.encodePassword(password)
80        self.temp_password['user'] = user
81        self.temp_password['timestamp'] = datetime.utcnow()  # offset-naive datetime
82
83    def getTempPassword(self):
84        """Check if a temporary password has been set and if it
85        is not expired.
86
87        Return the temporary password if valid,
88        None otherwise. Unset the temporary password if expired.
89        """
90        temp_password_dict = getattr(self, 'temp_password', None)
91        if temp_password_dict is not None:
92            delta = timedelta(minutes=self.temp_password_minutes)
93            now = datetime.utcnow()
94            if now < temp_password_dict.get('timestamp') + delta:
95                return temp_password_dict.get('password')
96            else:
97                # Unset temporary password if expired
98                self.temp_password = None
99        return None
100
101    def writeLogMessage(self, view, message):
102        ob_class = view.__implemented__.__name__.replace('waeup.ikoba.','')
103        self.__parent__.logger.info(
104            '%s - %s - %s' % (ob_class, self.__name__, message))
105        return
106
107    @property
108    def display_fullname(self):
109        middlename = getattr(self, 'middlename', None)
110        ikoba_utils = getUtility(IIkobaUtils)
111        return ikoba_utils.fullname(self.firstname, self.lastname, middlename)
112
113    @property
114    def fullname(self):
115        middlename = getattr(self, 'middlename', None)
116        if middlename:
117            return '%s-%s-%s' % (self.firstname.lower(),
118                middlename.lower(), self.lastname.lower())
119        else:
120            return '%s-%s' % (self.firstname.lower(), self.lastname.lower())
121
122    @property
123    def state(self):
124        state = IWorkflowState(self).getState()
125        return state
126
127    @property
128    def translated_state(self):
129        try:
130            TRANSLATED_STATES = getUtility(ICustomersUtils).TRANSLATED_STATES
131            ts = TRANSLATED_STATES[self.state]
132            return ts
133        except KeyError:
134            return
135
136    @property
137    def history(self):
138        history = IObjectHistory(self)
139        return history
140
141    @property
142    def customer(self):
143        return self
144
145    @property
146    def personal_data_expired(self):
147        return False
148
149# Set all attributes of Customer required in ICustomer as field
150# properties. Doing this, we do not have to set initial attributes
151# ourselves and as a bonus we get free validation when an attribute is
152# set.
153Customer = attrs_to_fields(Customer)
154
155
156class CustomerFactory(grok.GlobalUtility):
157    """A factory for customers.
158    """
159    grok.implements(IFactory)
160    grok.name(u'waeup.Customer')
161    title = u"Create a new customer.",
162    description = u"This factory instantiates new customer instances."
163
164    def __call__(self, *args, **kw):
165        return Customer()
166
167    def getInterfaces(self):
168        return implementedBy(Customer)
169
170
171@grok.subscribe(ICustomer, grok.IObjectAddedEvent)
172def handle_customer_added(customer, event):
173    """If a customer is added all subcontainers are automatically added
174    and the transition create is fired. The latter produces a logging
175    message.
176    """
177    # Assign global customer role for new customer
178    account = IUserAccount(customer)
179    account.roles = ['waeup.Customer']
180    # Assign local CustomerRecordOwner role
181    role_manager = IPrincipalRoleManager(customer)
182    role_manager.assignRoleToPrincipal(
183        'waeup.local.CustomerRecordOwner', customer.customer_id)
184    if customer.state is None:
185        IWorkflowInfo(customer).fireTransition('create')
186    documents = CustomerDocumentsContainer()
187    customer['documents'] = documents
188    return
189
190
191def path_from_custid(customer_id):
192    """Convert a customer_id into a predictable relative folder path.
193
194    Used for storing files.
195
196    Returns the name of folder in which files for a particular customer
197    should be stored. This is a relative path, relative to any general
198    customers folder with 5 zero-padded digits (except when customer_id
199    is overlong).
200
201    We normally map 1,000 different customer ids into one single
202    path. For instance ``K1000000`` will give ``01000/K1000000``,
203    ``K1234567`` will give ``0123/K1234567`` and ``K12345678`` will
204    result in ``1234/K12345678``.
205
206    For lower numbers < 10**6 we return the same path for up to 10,000
207    customer_ids. So for instance ``KM123456`` will result in
208    ``00120/KM123456`` (there will be no path starting with
209    ``00123``).
210
211    Works also with overlong number: here the leading zeros will be
212    missing but ``K123456789`` will give reliably
213    ``12345/K123456789`` as expected.
214    """
215    # remove all non numeric characters and turn this into an int.
216    num = int(RE_CUSTID_NON_NUM.sub('', customer_id))
217    if num < 10**6:
218        # store max. of 10000 custs per folder and correct num for 5 digits
219        num = num / 10000 * 10
220    else:
221        # store max. of 1000 custs per folder
222        num = num / 1000
223    # format folder name to have 5 zero-padded digits
224    folder_name = u'%05d' % num
225    folder_name = os.path.join(folder_name, customer_id)
226    return folder_name
227
228
229def move_customer_files(customer, del_dir):
230    """Move files belonging to `customer` to `del_dir`.
231
232    `del_dir` is expected to be the path to the site-wide directory
233    for storing backup data.
234
235    The current files of the customer are removed after backup.
236
237    If the customer has no associated files stored, nothing is done.
238    """
239    cust_id = customer.customer_id
240
241    src = getUtility(IExtFileStore).root
242    src = os.path.join(src, 'customers', path_from_custid(cust_id))
243
244    dst = os.path.join(
245        del_dir, 'media', 'customers', path_from_custid(cust_id))
246
247    if not os.path.isdir(src):
248        # Do not copy if no files were stored.
249        return
250    if not os.path.exists(dst):
251        os.makedirs(dst, 0755)
252    copy_filesystem_tree(src, dst)
253    shutil.rmtree(src)
254    return
255
256
257def update_customer_deletion_csvs(customer, del_dir):
258    """Update deletion CSV files with data from customer.
259
260    `del_dir` is expected to be the path to the site-wide directory
261    for storing backup data.
262
263    Each exporter available for customers (and their many subobjects)
264    is called in order to export CSV data of the given customer to csv
265    files in the site-wide backup directory for object data (see
266    DataCenter).
267
268    Each exported row is appended a column giving the deletion date
269    (column `del_date`) as a UTC timestamp.
270    """
271    for name in EXPORTER_NAMES:
272        exporter = getUtility(ICSVCustomerExporter, name=name)
273        csv_data = exporter.export_customer(customer)
274        csv_data = csv_data.split('\r\n')
275
276        # append a deletion timestamp on each data row
277        timestamp = str(now().replace(microsecond=0))  # store UTC timestamp
278        for num, row in enumerate(csv_data[1:-1]):
279            csv_data[num+1] = csv_data[num+1] + ',' + timestamp
280        csv_path = os.path.join(del_dir, '%s.csv' % name)
281
282        # write data to CSV file
283        if not os.path.exists(csv_path):
284            # create new CSV file (including header line)
285            csv_data[0] = csv_data[0] + ',del_date'
286            open(csv_path, 'wb').write('\r\n'.join(csv_data))
287        else:
288            # append existing CSV file (omitting headerline)
289            open(csv_path, 'a').write('\r\n'.join(csv_data[1:]))
290    return
291
292
293@grok.subscribe(ICustomer, grok.IObjectRemovedEvent)
294def handle_customer_removed(customer, event):
295    """If a customer is removed a message is logged and data is put
296       into a backup location.
297
298    The data of the removed customer is appended to CSV files in local
299    datacenter and any existing external files (passport images, etc.)
300    are copied over to this location as well.
301
302    Documents in the file storage refering to the given customer are
303    removed afterwards (if they exist). Please make no assumptions
304    about how the deletion takes place. Files might be deleted
305    individually (leaving the customers file directory intact) or the
306    whole customer directory might be deleted completely.
307
308    All CSV rows created/appended contain a timestamp with the
309    datetime of removal in an additional `del_date` column.
310
311    XXX: blocking of used customer_ids yet not implemented.
312    """
313    comment = 'Customer record removed'
314    target = customer.customer_id
315    try:
316        site = grok.getSite()
317        site['customers'].logger.info('%s - %s' % (
318            target, comment))
319    except KeyError:
320        # If we delete an entire university instance there won't be
321        # a customers subcontainer
322        return
323
324    del_dir = site['datacenter'].deleted_path
325
326    # save files of the customer
327    move_customer_files(customer, del_dir)
328
329    # update CSV files
330    update_customer_deletion_csvs(customer, del_dir)
331    return
332
333#: The file id marker for customer files
334CUSTOMER_FILE_STORE_NAME = 'file-customer'
335
336
337class CustomerFileNameChooser(grok.Adapter):
338    """A file id chooser for :class:`Customer` objects.
339
340    `context` is an :class:`Customer` instance.
341
342    The :class:`CustomerImageNameChooser` can build/check file ids for
343    :class:`Customer` objects suitable for use with
344    :class:`ExtFileStore` instances. The delivered file_id contains
345    the file id marker for :class:`Customer` object and the customer id
346    of the context customer.
347
348    This chooser is registered as an adapter providing
349    :class:`waeup.ikoba.interfaces.IFileStoreNameChooser`.
350
351    File store name choosers like this one are only convenience
352    components to ease the task of creating file ids for customer
353    objects. You are nevertheless encouraged to use them instead of
354    manually setting up filenames for customers.
355
356    .. seealso:: :mod:`waeup.ikoba.imagestorage`
357
358    """
359    grok.context(ICustomer)
360    grok.implements(IFileStoreNameChooser)
361
362    def checkName(self, name=None, attr=None):
363        """Check whether the given name is a valid file id for the context.
364
365        Returns ``True`` only if `name` equals the result of
366        :meth:`chooseName`.
367
368        """
369        return name == self.chooseName()
370
371    def chooseName(self, attr, name=None):
372        """Get a valid file id for customer context.
373
374        *Example:*
375
376        For a customer with customer id ``'A123456'`` and
377        with attr ``'nice_image.jpeg'`` stored in
378        the customers container this chooser would create:
379
380          ``'__file-customer__customers/A/A123456/nice_image_A123456.jpeg'``
381
382        meaning that the nice image of this applicant would be
383        stored in the site-wide file storage in path:
384
385          ``customers/A/A123456/nice_image_A123456.jpeg``
386
387        """
388        basename, ext = os.path.splitext(attr)
389        cust_id = self.context.customer_id
390        marked_filename = '__%s__%s/%s_%s%s' % (
391            CUSTOMER_FILE_STORE_NAME, path_from_custid(cust_id), basename,
392            cust_id, ext)
393        return marked_filename
394
395
396class CustomerFileStoreHandler(DefaultFileStoreHandler, grok.GlobalUtility):
397    """Customer specific file handling.
398
399    This handler knows in which path in a filestore to store customer
400    files and how to turn this kind of data into some (browsable)
401    file object.
402
403    It is called from the global file storage, when it wants to
404    get/store a file with a file id starting with
405    ``__file-customer__`` (the marker string for customer files).
406
407    Like each other file store handler it does not handle the files
408    really (this is done by the global file store) but only computes
409    paths and things like this.
410    """
411    grok.implements(IFileStoreHandler)
412    grok.name(CUSTOMER_FILE_STORE_NAME)
413
414    def pathFromFileID(self, store, root, file_id):
415        """All customer files are put in directory ``customers``.
416        """
417        marker, filename, basename, ext = store.extractMarker(file_id)
418        sub_root = os.path.join(root, 'customers')
419        return super(CustomerFileStoreHandler, self).pathFromFileID(
420            store, sub_root, basename)
421
422    def createFile(self, store, root, filename, file_id, file):
423        """Create a browsable file-like object.
424        """
425        # call super method to ensure that any old files with
426        # different filename extension are deleted.
427        file, path, file_obj = super(
428            CustomerFileStoreHandler, self).createFile(
429            store, root,  filename, file_id, file)
430        return file, path, IkobaImageFile(
431            file_obj.filename, file_obj.data)
Note: See TracBrowser for help on using the repository browser.