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

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

pep8

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