~launchpad-pqm/launchpad/devel

14538.2.49 by Curtis Hovey
Updated copyright.
1
# Copyright 2009-2011 Canonical Ltd.  This software is licensed under the
8687.15.17 by Karl Fogel
Add the copyright header block to the rest of the files under lib/lp/.
2
# GNU Affero General Public License version 3 (see the file LICENSE).
4536.1.3 by Brad Crittenden
re-adding files that somehow disappeared.
3
4
"""Import entitlements from Salesforce.
5
6
Provide a class that allows the writing and reading of entitlement exchange
7
files and a class to create and update entitlements in Launchpad.
8
"""
9
10
__metaclass__ = type
11
__all__ = [
12
    'EntitlementExchange',
13
    'EntitlementImporter',
14
    'InvalidFormat',
15
    'NoSuchEntitlement',
16
    'UnsupportedVersion',
17
    ]
18
19
import cStringIO
20
import csv
5821.6.21 by James Henstridge
Fix entitlement tests.
21
import datetime
4536.1.3 by Brad Crittenden
re-adding files that somehow disappeared.
22
import re
5821.6.21 by James Henstridge
Fix entitlement tests.
23
import time
24
25
import pytz
26
from zope.component import getUtility
4536.1.3 by Brad Crittenden
re-adding files that somehow disappeared.
27
11270.1.3 by Tim Penhey
Changed NotFoundError imports - gee there were a lot of them.
28
from lp.app.errors import NotFoundError
7675.110.3 by Curtis Hovey
Ran the migration script to move registry code to lp.registry.
29
from lp.registry.interfaces.entitlement import (
11403.1.4 by Henning Eggers
Reformatted imports using format-imports script r32.
30
    EntitlementState,
31
    EntitlementType,
32
    IEntitlementSet,
33
    )
7675.110.3 by Curtis Hovey
Ran the migration script to move registry code to lp.registry.
34
from lp.registry.interfaces.person import IPersonSet
14550.1.1 by Steve Kowalik
Run format-imports over lib/lp and lib/canonical/launchpad
35
from lp.services.unicode_csv import (
36
    UnicodeDictReader,
37
    UnicodeDictWriter,
38
    )
11403.1.4 by Henning Eggers
Reformatted imports using format-imports script r32.
39
4536.1.3 by Brad Crittenden
re-adding files that somehow disappeared.
40
41
COMMENT = '#'
42
COMMA = ','
43
44
45
class NoSuchEntitlement(Exception):
46
    """Used if a non-existent entitlement is specified."""
47
48
49
class UnsupportedVersion(Exception):
50
    """Used if the version is not supported."""
51
52
53
class InvalidFormat(Exception):
54
    """Used if the file format is not as expected."""
55
56
57
class RequiredValueMissing(Exception):
58
    """Used if a required value was not provided."""
59
60
61
class EntitlementExchange:
62
    """Define the exchange format for entitlement data.
63
64
    Writers of entitlement data should use the 'writerFactory' method to
65
    obtain a writer object.  Readers should use the 'readerFactory'.  They
66
    return a UnicodeDictWriter and UnicodeDictReader respectively.
67
68
    Any changes to the list of fieldnames or their order will require an
69
    increment in the version value.
70
    """
71
72
    file_header = "%s Entitlement exchange format version" % COMMENT
73
    version = 1
74
    version_re = re.compile(
75
        "^%s (\d+)" % file_header)
76
77
    fieldnames = [
78
        'id', 'ext_id', 'person_name', 'entitlement_type', 'quota',
79
        'amount_used', 'date_starts', 'date_expires', 'date_created',
80
        'registrant_name', 'approved_by_name', 'state', 'whiteboard',
81
        ]
82
83
    @staticmethod
84
    def _preprocessData(in_file):
85
        """Verify the version and remove comments."""
86
        version_line = in_file.readline()
87
        match = EntitlementExchange.version_re.search(version_line)
88
        if not match:
89
            raise InvalidFormat(
90
                "The first line does not have valid version information.")
91
        read_version = int(match.group(1))
4536.1.4 by Brad Crittenden
Changes due to Tim's review comments
92
        if EntitlementExchange.version != read_version:
4536.1.3 by Brad Crittenden
re-adding files that somehow disappeared.
93
            raise UnsupportedVersion(
94
                "Version %d of the file format is not supported." %
95
                read_version)
96
        lines= [line for line in in_file.readlines()
97
                if not line.lstrip().startswith(COMMENT)]
98
        return "".join(lines)
99
100
    @staticmethod
101
    def readerFactory(in_file):
102
        """Return a properly provisioned reader.
103
104
        Assumes data in the file is UTF-8 encoded.
105
        """
106
107
        filedata = EntitlementExchange._preprocessData(in_file)
108
        return UnicodeDictReader(cStringIO.StringIO(filedata),
109
                                 EntitlementExchange.fieldnames,
110
                                 skipinitialspace=True,
111
                                 quoting=csv.QUOTE_ALL)
112
113
    @staticmethod
114
    def writerFactory(filedescriptor):
115
        """Return a properly provisioned writer.
116
117
        Data in the file will be UTF-8 encoded.
118
        """
119
120
        filedescriptor.write(
121
            "%s %d\n" % (EntitlementExchange.file_header,
122
                         EntitlementExchange.version))
123
        filedescriptor.write(
124
            "%s %s\n" % (COMMENT,
125
                        COMMA.join(EntitlementExchange.fieldnames)))
126
        writer = UnicodeDictWriter(filedescriptor,
127
                                   EntitlementExchange.fieldnames,
128
                                   skipinitialspace=True,
129
                                   quoting=csv.QUOTE_ALL)
130
        return writer
131
132
133
class EntitlementImporter:
134
    """Class for writing and updating entitlement data.
135
136
    Methods create_entitlements and update_entitlements are called with a list
137
    of dictionaries representing entitlement data.
138
    """
139
    def __init__(self, logger):
140
        self.logger = logger
141
142
    def _replacePersonName(self, entitlement, old_key, new_key,
143
                           row_no, required=False):
144
        """Replace a person's name with a Person object in the entitlement.
145
146
        Raise RequiredValueMissing if the old_key is not found in the
147
        entitlement dictionary and required is True.
148
        Raise NotFoundError if no matching person can be found.
149
        """
150
        person_name = entitlement.get(old_key, '')
151
        del entitlement[old_key]
152
        if person_name == '':
153
            if required:
154
                raise RequiredValueMissing(
155
                    "'person_name' not supplied in row %d." % row_no)
156
            else:
157
                return entitlement
158
        person = getUtility(IPersonSet).getByName(person_name)
159
        if person is None:
160
            self.logger.error(
161
                "[E%d] Person '%s' is not found." % (
162
                row_no, person_name))
163
            raise NotFoundError(
164
                "Person '%s' not supplied in row %d." % (
165
                person_name, row_no))
166
        entitlement[new_key] = person
167
        return entitlement
168
169
    def _normalizeEntitlement(
170
        self, entitlement, row_no, person_required=True):
171
        """Normalize a dictionary representing an entitlement.
172
173
        Convert names of people and teams to database objects and
174
        convert string representations of numerics to the correct type.
175
        Remove any keys in the dictionary that do not correspond to attributes
176
        on an Entitlement.
177
        """
178
        entitlement = self._replacePersonName(
179
            entitlement, 'person_name', 'person', row_no, person_required)
180
        entitlement = self._replacePersonName(
181
            entitlement, 'registrant_name', 'registrant', row_no)
182
        entitlement = self._replacePersonName(
183
            entitlement, 'approved_by_name', 'approved_by', row_no)
184
185
        # Remove the 'ext_id' since it is not part of the Launchpad data.
186
        del entitlement['ext_id']
187
188
        # Convert numeric data from string to int.
189
        for field in ['id', 'quota', 'entitlement_type', 'state', 'amount_used']:
190
            if entitlement[field]:
191
                entitlement[field] = int(entitlement[field])
192
5821.6.21 by James Henstridge
Fix entitlement tests.
193
        # Convert strings to dates.
194
        for field in ['date_starts', 'date_expires', 'date_created']:
195
            if entitlement[field]:
196
                date_string = entitlement[field]
197
                if len(date_string) == len('YYYY-mm-dd'):
198
                    year, month, day, hour, minute, second = time.strptime(
199
                        date_string, '%Y-%m-%d')[:6]
200
                elif len(date_string) == len('YYYY-mm-dd HH:MM:SS'):
201
                    year, month, day, hour, minute, second = time.strptime(
202
                        date_string, '%Y-%m-%d %H:%M:%S')[:6]
203
                else:
204
                    raise AssertionError(
205
                        'Unknown date format: %s' % date_string)
206
                entitlement[field] = datetime.datetime(
207
                    year, month, day, hour, minute, second,
208
                    tzinfo=pytz.timezone('UTC'))
209
4536.1.3 by Brad Crittenden
re-adding files that somehow disappeared.
210
        # Convert the entitlement_type and state to the corresponding
211
        # database objects.
212
        if entitlement['entitlement_type']:
213
            entitlement_type = entitlement['entitlement_type']
214
            entitlement['entitlement_type'] = (
215
                EntitlementType.items.mapping[entitlement_type])
216
217
        if entitlement['state']:
218
            state = entitlement['state']
219
            entitlement['state'] = (
220
                EntitlementState.items.mapping[state])
221
222
        # Remove the entries from the dictionary that only have placeholder
223
        # data.
224
        for key, value in entitlement.items():
225
            if value == '':
226
                del entitlement[key]
227
        return entitlement
228
229
    def _checkRequired(self, entitlement, required, row_no):
230
        """Check to see that all required keys are in the entitlement."""
231
        for key in required:
232
            val = entitlement.get(key, '')
233
            # Test for None or ''.  No boolean variable are expected.
234
            if val == '':
235
                self.logger.error(
236
                    "[E%d] A required key is missing: %s." % (row_no, key))
237
                return False
238
        return True
239
240
    def createEntitlements(self, entitlements):
241
        """Create a new entitlement for each in the list.
242
243
        Return a list of sparsely populated dictionaries suitable for writing
244
        as a return CSV file.
245
        """
246
247
        required = ['ext_id', 'person_name', 'quota', 'entitlement_type',
248
                    'state']
249
        entitlement_set = getUtility(IEntitlementSet)
250
        new_entitlements = []
251
        for row_no, entitlement in enumerate(entitlements):
252
            if self._checkRequired(entitlement, required, row_no) is False:
253
                continue
254
            ext_id = entitlement.get('ext_id')
255
            try:
256
                normalized_entitlement = self._normalizeEntitlement(
257
                    entitlement, row_no)
258
            except NotFoundError:
259
                continue
260
            except RequiredValueMissing:
261
                continue
262
263
            new_entitlement = entitlement_set.new(**normalized_entitlement)
264
265
            if new_entitlement is not None:
266
                # Add a dictionary with id and ext_id to the list of
267
                # new entitlements.
268
                new_entitlements.append(dict(id=str(new_entitlement.id),
269
                                             ext_id=ext_id))
270
        return new_entitlements
271
272
    def updateEntitlements(self, entitlements):
273
        """Update an existing entitlement.
274
275
        The entitlement must already exist.  A list of dictionaries with the
276
        ids of the entitlments that were modified is returned.
277
        """
278
279
        modified = []
280
        required = ['id']
281
        for row_no, upd_entitlement in enumerate(entitlements):
282
            if not self._checkRequired(upd_entitlement, required, row_no):
283
                continue
284
            # The ext_id must be cached before the data is normalized.
285
            ext_id = upd_entitlement.get('ext_id')
286
287
            try:
288
                norm_entitlement = self._normalizeEntitlement(
289
                    upd_entitlement, row_no, person_required=False)
290
            except NotFoundError:
291
                continue
292
            except RequiredValueMissing:
293
                continue
294
295
            lp_id = norm_entitlement.get('id')
296
            entitlement_set = getUtility(IEntitlementSet)
297
298
            existing = entitlement_set.get(lp_id)
299
            if existing is None:
300
                self.logger.error(
301
                    "[E%d] Invalid entitlement id: %d" % (row_no,
302
                                                          lp_id))
303
                continue
304
305
            succeeded = True
306
            for (key, val) in norm_entitlement.items():
307
                if key == 'id':
308
                    pass
309
                elif key == 'person':
310
                    self.logger.info(
311
                        "[E%d] You may not change the person for the "
312
                        "entitlement." % (row_no))
313
                    succeeded = False
314
                    break
315
                elif key == 'whiteboard':
316
                    # Append the whiteboard value rather than replacing it.
317
                    existing.whiteboard = "%s\n%s" % (existing.whiteboard,
318
                                                      val)
319
                elif key in ['entitlement_type', 'quota', 'amount_used',
320
                             'date_starts', 'date_expires', 'date_created',
321
                             'registrant', 'approved_by', 'state']:
322
                    setattr(existing, key, val)
323
                else:
324
                    self.logger.error(
325
                        "[E%d] Unrecognized key: %s." % (row_no, key))
326
                    succeeded = False
327
                    break
328
            if succeeded:
329
                modified.append(dict(id=str(lp_id)))
330
        return modified