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

Last change on this file since 13139 was 12741, checked in by uli, 10 years ago

Merge changes from uli-payments back into trunk.

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