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

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

Adjust customer workflow.

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