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

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

propset svn:keywords "Id"

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