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 |