· 5 years ago · Aug 28, 2020, 07:10 PM
1from __future__ import absolute_import
2import string
3import random
4from decimal import Decimal as _d
5from collections import defaultdict
6from django.db.models import Q
7from django.utils import timezone
8
9from accounting.constants.currency import CAD
10from api.catalogue.logic.catalogue_processor_diagnostic import CatalogueProcessorDiagnostic
11from books.constants import MTC_ONLY, MTC_INCLUSIVE_ACCESS, MTC_STORE_SPECIFIC, STANDARD
12from books.models import Book
13from books import constants as book_constants
14from books.utils import is_valid_isbn as book_utils_is_valid_isbn
15from accounts.models import Organization
16from api.models import BooklogPrice, ThirdPartyPriceSchedule
17import redshelf.logging_utils as logging_utils
18from redshelf.logic.importer.product_sales_rights_logic import ProductSalesRightsLogic
19from redshelf.utils import mtc_vfi
20from api.utils.catalogue import mtc_calc_digest, mtc_is_valid_isbn, is_pub_in_chill_period
21
22from api.mtc.pricing_logic import MTCPricingLogic
23
24
25from six import itervalues, iteritems, text_type
26from six.moves import range
27
28logger = logging_utils.get_redshelf_logger(__name__)
29
30CHARS = string.digits
31PRICE_DIFFERENCE_TOLERANCE = _d(0.01) # How much a price can differ before we regenerate a BooklogPrice
32
33DIGITAL_ISBN_TYPES = [
34 book_constants.EISBN13,
35]
36
37PRINT_ISBN_TYPES = [
38 book_constants.ISBN13,
39 book_constants.PAPER_13,
40 book_constants.LLF_13,
41 book_constants.HARD_13,
42 book_constants.PACKAGE_13,
43 book_constants.RISBN13
44]
45
46BP_FIELDS = ['book__id', 'is_active', 'pricing_type', 'currency_code', 'limit_days', 'deactivation_date',
47 'price', 'wholesale_price']
48
49RECIPIENT_ORG_FIELD = 'recipient_org__id'
50PRICING_FIELD = 'pricing_option__id'
51PRICING_HASH_FIELD = 'pricing_option__hash_id'
52PRICING_STATUS_FIELD = 'pricing_option__is_active'
53PRICING_TYPE = 'pricing_option__pricing_type'
54CURRENCY_CODE = 'pricing_option__currency_code'
55DIGEST_FIELD = 'digest'
56PRODUCT_POS_CONFIG_FIELD = 'pricing_option__product_pos_configuration_id'
57BLP_FIELDS = ['id', 'api_id', 'limit_days', 'price', 'is_active', 'digital_isbn', 'print_isbn', 'duration_id',
58 RECIPIENT_ORG_FIELD, PRICING_FIELD, PRICING_STATUS_FIELD, DIGEST_FIELD, PRODUCT_POS_CONFIG_FIELD,
59 PRICING_HASH_FIELD, PRICING_TYPE, CURRENCY_CODE, 'pricing_identifier']
60
61BOOK_FIELDS = ['is_active', 'is_public', 'is_published', 'in_bdp_program', 'is_deleted', 'distribution_type__code',
62 'org__id']
63CREATED = "created"
64UPDATED = "updated"
65DEACTIVATED = "deactivated"
66FAILED = "failed"
67TOTAL = "total"
68
69
70class BooklogPriceBookProcessor(object):
71
72 def generate_id(self, size=15):
73 """
74 Generates a random ID
75 :param size: Number of characters for the length of the random ID
76 :return: String
77 """
78 return ''.join(random.choice(CHARS) for _ in range(size))
79
80 def generate_duration_id(self):
81 return 1000000000 + int(self.generate_id(size=9)) # duration_id is a 32bit signed int, 2147483648 max
82
83 def generate_pricing_identifier(self, book_pricing, recipient_org):
84 if recipient_org and recipient_org.is_follett_org:
85 if book_pricing.pricing_type == MTC_ONLY:
86 return '{}{}'.format(book_pricing.book.hash_id, book_pricing.hash_id)
87 return mtc_vfi(book_pricing.book.hash_id, book_pricing.limit_days, book_pricing.currency_code)
88 return None
89
90 def get_wholesale_orgs(self):
91 """
92 Get a list of Wholesale Organizations
93 :return: [org_id, ...]
94 """
95 return (Organization.objects.filter(
96 orgpricing__type__category='GLOBAL',
97 orgpricing__type__code='ACCOUNT_TYPE',
98 orgpricing__field_char='WHOLESALE',
99 is_active=True).distinct())
100
101 @classmethod
102 def get_skip_orgs(cls):
103 """
104 Get a list of Orgs which not to process
105 :return: [org_id, ...]
106 """
107 return Organization.objects.filter(
108 orgpricing__type__category='GLOBAL',
109 orgpricing__type__code='HOLD_CATALOGUE_UPDATES',
110 orgpricing__field_bool=True)
111
112 def create_booklog_price(self, bp, digital_isbn, print_isbn, duration_id=None, recipient_org=None, price=None,
113 previous_api_id=None, digest=None, suggested_retail_price=None, pricing_identifier=None):
114 """
115 Creates BooklogPrice objects for pricing option that is missing an active BooklogPrice
116 :param bp: BookPricing object
117 :param digital_isbn: str, result of self.get_digital_isbn()
118 :param print_isbn: str, result of self.get_print_isbn()
119 :param duration_id: if None inherits bp.id
120 :param recipient_org: None in general case, recipient Organization object in case of wholesale api pricing
121 :param price: None in general case, wholesale api price in case of wholesale api pricing
122 :param previous_api_id: previous BooklogPrice object api_id
123 :param digest: hash of isbn list for MTC
124 :param pricing_identifier: pricing identifier
125 :return: BooklogPrice object
126 """
127 if self.dry_run:
128 return
129
130 api_id = self.generate_id()
131 if duration_id is None:
132 duration_id = self.generate_duration_id()
133
134 if not pricing_identifier:
135 pricing_identifier = self.generate_pricing_identifier(bp, recipient_org=recipient_org)
136
137 srp = suggested_retail_price if suggested_retail_price else _d('0.00')
138 price = price if price is not None else bp.price
139
140 kwargs = dict(
141 pricing_identifier=pricing_identifier,
142 duration_id=duration_id,
143 recipient_org=recipient_org,
144 api_id=api_id,
145 digest=digest,
146 suggested_retail_price=srp,
147 pricing_option=bp,
148 digital_isbn=digital_isbn,
149 print_isbn=print_isbn,
150 price=price
151 )
152
153 if previous_api_id:
154 kwargs['previous_api_id'] = previous_api_id
155
156 if bp.limit_days:
157 kwargs['limit_days'] = bp.limit_days
158
159 elif bp.deactivation_date:
160 kwargs['deactivation_date'] = bp.deactivation_date
161
162 return BooklogPrice.objects.create(**kwargs)
163
164 def blp_changed(self, blp, current_price, digital_isbn, print_isbn):
165 """ check if BLP values changed """
166 return (abs(blp['price'] - current_price) > PRICE_DIFFERENCE_TOLERANCE or
167 blp['digital_isbn'] != digital_isbn or blp['print_isbn'] != print_isbn)
168
169 def is_book_pricing_eligible_for_redshelf_catalog_file(self, bp):
170 if not bp.is_active or bp.pricing_type != 'STANDARD' or bp.currency_code != 'USD' \
171 or not bp.book.is_eligible_for_booklog_price(allow_private=False):
172 return False
173 return True
174
175 def is_book_pricing_eligible_for_mtc_file(self, bp):
176 if not bp.is_active or bp.pricing_type not in ['STANDARD', 'MTC_ONLY', 'MTC_IA', 'MTC_SS'] \
177 or bp.currency_code not in ('USD', 'CAD'):
178 self.mtc_diagnostic.set_error(bp, self.mtc_diagnostic.ERROR_MTC_INELIGIBLE_PRICING_OR_CURRENCY)
179 return False
180
181 if not bp.book.is_eligible_for_booklog_price(allow_private=True, is_custom=True):
182 self.mtc_diagnostic.set_error(bp, self.mtc_diagnostic.ERROR_MTC_INELIGIBLE_INVALID_PRODUCT_VALUES)
183 return False
184
185 return True
186
187 @property
188 def blps(self):
189 return self._blps
190
191 @property
192 def book(self):
193 return self._book
194
195 @property
196 def is_book_wholesale(self):
197 if self._book_is_wholesale is None:
198 self._book_is_wholesale = self._book.is_wholesale()
199 self.mtc_diagnostic.global_log(f"Book is wholesale: {self._book_is_wholesale}")
200
201 return self._book_is_wholesale
202
203 def get_digital_isbn(self, bp=None, is_valid_isbn=book_utils_is_valid_isbn):
204 if bp and bp.pricing_type == MTC_ONLY:
205 custom_isbn = (bp.billing_isbn or '').strip().replace('-', '')
206 if not custom_isbn:
207 logger.warning(
208 "Missing or invalid custom/billing_isbn for MTC_ONLY",
209 context_dict={
210 'offending_book_id': self._book.id,
211 'offending_bp_id': bp.id,
212 'billing_isbn': bp.billing_isbn,
213 }
214 )
215 return ''
216 return custom_isbn
217
218 isbns = dict(self._book.book_identifier.filter(id_type__in=DIGITAL_ISBN_TYPES)
219 .values_list('id_type', 'id_value'))
220 for id_type in DIGITAL_ISBN_TYPES:
221 if id_type in isbns:
222 isbn = isbns[id_type]
223 if is_valid_isbn(isbn):
224 return isbn.replace('-', '')
225
226 if is_valid_isbn(self._book.eisbn13):
227 logger.warning(
228 "Missing or invalid EISBN13 BookIdentifier, falling back to Book.eisbn13",
229 context_dict={
230 'offending_book_id': self._book.id,
231 'returned_digital_isbns': isbns,
232 }
233 )
234 return self._book.eisbn13.strip().replace('-', '')
235
236 logger.warning(
237 "Missing or invalid EISBN13 BookIdentifier and Book.eisbn13",
238 context_dict={
239 'offending_book_id': self._book.id,
240 'returned_digital_isbns': isbns,
241 'book_eisbn13': self._book.eisbn13,
242 }
243 )
244 return ""
245
246 def get_print_isbn(self, is_valid_isbn=book_utils_is_valid_isbn):
247 isbns = dict(self._book.book_identifier.filter(id_type__in=PRINT_ISBN_TYPES)
248 .values_list('id_type', 'id_value'))
249
250 for id_type in PRINT_ISBN_TYPES:
251 if id_type in isbns:
252 isbn = isbns[id_type]
253 existing_book = Book.objects.with_isbn_of_types(
254 isbn, [book_constants.ISBN13]
255 ).exclude(id=self._book.id).exclude(is_deleted=True).exclude(is_active=False).first()
256
257 if existing_book:
258 logger.warning(
259 "ISBN13 collision in catalogue_processor. Processing book's ISBN of type '{}' matches "
260 "an ISBN13 BookIdentifier associated with an existing book!".format(id_type),
261 context_dict={
262 'offending_book_id': self._book.id,
263 'existing_book_id': existing_book.id,
264 'duplicate_isbn': isbn,
265 'isbn_type': id_type,
266 }
267 )
268 continue
269
270 if is_valid_isbn(isbn):
271 return isbn.replace('-', '')
272
273 return ""
274
275 def get_active_book_pricing(self):
276 return self._book.bookpricing_set.filter(is_active=True,
277 pricing_type__in=('STANDARD', 'MTC_ONLY', 'MTC_IA', 'MTC_SS'),
278 currency_code__in=['USD', 'CAD']).only(*BP_FIELDS)
279
280 def get_active_booklog_price(self, recipient_org=None):
281 self._blps = defaultdict(lambda: defaultdict(lambda: dict()))
282 blps = BooklogPrice.objects.filter(pricing_option__book=self._book, is_active=True)
283 blps = blps.filter(recipient_org=recipient_org)
284 blps = blps.order_by('id').values(*BLP_FIELDS)
285
286 for blp in blps:
287 if blp[PRICING_TYPE] in {MTC_ONLY, MTC_INCLUSIVE_ACCESS, MTC_STORE_SPECIFIC}:
288 # custom mtc pricing get indexed by BookPricing.hash_id
289 self._blps[blp[RECIPIENT_ORG_FIELD]][blp[PRICING_HASH_FIELD]][blp[PRICING_FIELD]] = blp
290 else:
291 self._blps[blp[RECIPIENT_ORG_FIELD]][blp[CURRENCY_CODE]+str(blp['limit_days'])][blp[PRICING_FIELD]] = blp
292 return self._blps
293
294 def chill_pricing(self, blp_id=None, blps=None):
295 if blps is None:
296 blps = BooklogPrice.objects.filter(id=blp_id)
297 chill_blps = blps.filter(is_active=True, chill_date=None)
298 self.blp_counters[UPDATED] += self.run_query(chill_blps, lambda x: x.update(chill_date=timezone.now()))
299
300 def deactivate_ineligible_booklog_price(self):
301 """
302 Deactivate any BooklogPrice which doesn't have eligible BookPricing
303 :return: None
304 """
305 unclaimed_blps = []
306 for ro_id, ro_blps in iteritems(self._blps):
307 for d_blps in itervalues(ro_blps):
308 for blp in itervalues(d_blps):
309 if not blp[PRICING_STATUS_FIELD] and 'claimed' not in blp:
310 unclaimed_blps.append(blp['id'])
311 elif self._mtc_org and ro_id == self._mtc_org.id and 'claimed' not in blp:
312 # if MTC duration changed, blp would not get claimed, and bp might still be active,
313 # so no active check for MTC
314 unclaimed_blps.append(blp['id'])
315
316 if unclaimed_blps:
317 blps = BooklogPrice.objects.filter(id__in=set(unclaimed_blps), is_active=True)
318 if self._pub_is_in_chill_period:
319 # if pub is in chill period, mark National pricing BLP (recipient_org=None) as chilled
320 self.chill_pricing(blps=blps.filter(recipient_org=None))
321 blps = blps.exclude(recipient_org=None)
322
323 count = self.run_query(blps, lambda x: x.update(is_active=False, last_modified=timezone.now()))
324 self.blp_counters[DEACTIVATED] += count
325
326 def run_query(self, queryset, operation):
327 if self.dry_run:
328 return queryset.count()
329
330 return operation(queryset)
331
332 def process_book_pricing(self, bp, recipient_org=None, current_price=None, is_valid_isbn=book_utils_is_valid_isbn,
333 digest=None, skip_if_missing_eisbn13=False, is_custom_mtc_pricing=False):
334 force_blp_regen = False
335 digital_isbn = self.get_digital_isbn(bp=bp, is_valid_isbn=is_valid_isbn)
336 print_isbn = self.get_print_isbn(is_valid_isbn=is_valid_isbn)
337 if skip_if_missing_eisbn13 and not digital_isbn:
338 logger.warning('BooklogPriceBookProcessor.process_book_pricing: missing ISBN data', context_dict={
339 'book_pricing': bp.id, 'book': bp.book_id, 'recipient_org': recipient_org.id if recipient_org else None,
340 'digital_isbn': digital_isbn, 'print_isbn': print_isbn})
341 return
342 if current_price is None:
343 current_price = bp.price
344
345 if is_custom_mtc_pricing:
346 if self.pricing_logic.is_valid_custom_mtc_pricing(bp):
347 blps = self._blps[recipient_org.id if recipient_org else None][bp.hash_id]
348 else:
349 return
350 else:
351 blps = self._blps[recipient_org.id if recipient_org else None][bp.currency_code+str(bp.limit_days)]
352
353 if not blps:
354 blp = None
355 elif bp.id in blps:
356 # old BLP is for this BP
357 blp = blps[bp.id]
358 else:
359 # we are assuming BP was disabled and re-created instead of modified, old BLP.duration_id has to be passed to new BLP
360 # if there are multiple old BLPs with same duration as this BP, find a free and unclaimed and just randomly assign
361 force_blp_regen = True # even if the pricing values are the same, we have to re-create BLP, so it points to new BP
362 for blp in itervalues(blps):
363 if not blp[PRICING_STATUS_FIELD] and 'claimed' not in blp:
364 break
365 else:
366 blp = None
367
368 if blp:
369 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_BLP_EXISTS)
370 # if booklogprice exists, keep it if unchanged, or regenerate new
371 blp_id = blp['id']
372 blp['claimed'] = True
373 if force_blp_regen or self.blp_changed(blp, current_price=current_price, digital_isbn=digital_isbn, print_isbn=print_isbn):
374 # significant pricing change happened, recreate BLP (deactivate old BLP and create a new updated one)
375 if self._pub_is_in_chill_period and recipient_org is None:
376 # if pub is in chill period, for National pricing BLP (recipient_org=None), mark BLP as chilled
377 self.chill_pricing(blp_id=blp_id)
378 return
379 # if booklogprice exists, but key field changed, regenerate
380 changed_blps = BooklogPrice.objects.filter(id=blp_id, is_active=True)
381 self.blp_counters[DEACTIVATED] += self.run_query(
382 changed_blps,
383 lambda x: x.update(is_active=False, last_modified=timezone.now())
384 )
385
386 self.create_booklog_price(bp, digital_isbn=digital_isbn, print_isbn=print_isbn, duration_id=blp['duration_id'],
387 recipient_org=recipient_org, price=current_price, previous_api_id=blp['api_id'],
388 digest=digest, suggested_retail_price=bp.book.base_price,
389 pricing_identifier=blp['pricing_identifier'])
390 self.mtc_diagnostic.set_result(bp, self.mtc_diagnostic.RESULT_RECREATED)
391
392 self.blp_counters[CREATED] += 1
393 elif digest is not None and blp[DIGEST_FIELD] != digest:
394 # digest changed, update BLP.last_modified
395 # - digest stores hash of isbns related to pricing, to allow us to detect when significant isbns change
396 # - updating BLP.last_modified allow us to show the BLP in delta file without a recreate
397 # TODO: Currently this behavior is limited to MTC file only. This will in a later tickets be used
398 # for other catalogue files. eg. RISBN files to detect/show up when RISBN changes.
399 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_BLP_DIGEST_CHANGED)
400 if self._pub_is_in_chill_period and recipient_org is None:
401 # if pub is in chill period, for National pricing BLP (recipient_org=None) only
402 # mark BLP as chilled instead of updating last_modified
403 self.chill_pricing(blp_id=blp_id)
404 return
405 # for MTC catalogue, if isbn list changes, which is hashed to digest, then update the blp digest and
406 # last modified date, so no new SKU is generated, but the change should show up in the delta file
407 digest_changed_blps = BooklogPrice.objects.filter(id=blp_id, is_active=True)
408 self.blp_counters[UPDATED] += self.run_query(
409 digest_changed_blps,
410 lambda x: x.update(digest=digest, last_modified=timezone.now())
411 )
412 self.mtc_diagnostic.set_result(bp, self.mtc_diagnostic.RESULT_UPDATED)
413 else:
414 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_BLP_HAS_NO_CHANGES)
415 self.mtc_diagnostic.set_result(bp, self.mtc_diagnostic.RESULT_NO_CHANGE)
416 else:
417 # if booklogprice doesn't exist, create it
418 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_BLP_DOES_NOT_EXIST)
419 self.create_booklog_price(bp, recipient_org=recipient_org, price=current_price,
420 digital_isbn=digital_isbn, print_isbn=print_isbn, digest=digest,
421 suggested_retail_price=bp.book.base_price)
422 self.mtc_diagnostic.set_result(bp, self.mtc_diagnostic.RESULT_CREATED)
423 self.blp_counters[CREATED] += 1
424
425 def mtc_is_valid_isbn(self, isbn):
426 """ MTC specific ISBN validation (per BI-3923) """
427 if mtc_is_valid_isbn(isbn):
428 return book_utils_is_valid_isbn(isbn)
429 return None
430
431 def has_valid_distribution_rights(self, book_pricing, is_custom):
432 if is_custom:
433 bp_pos_config = book_pricing.product_pos_configuration
434 pub_type = bp_pos_config.mtc_catalogue_pub_type or ''
435 else:
436 pub_type = self._mtc_pub_type or ''
437 is_follett_contract = pub_type.upper().startswith(('W', 'A'))
438 distribution_rights = ProductSalesRightsLogic.get_mtc_sales_rights_for_book(
439 book_pricing.book,
440 is_follett_contract
441 )
442 return distribution_rights
443
444 def process_mtc_book_pricing(self, bp, is_custom=False, discount_srp=None, discount_list=None, wholesale_pct=None):
445 if not self._is_mtc_eligible:
446 # if mtc_org is not setup or publisher is not setup for MTC then this will be blank/None
447 return
448
449 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_PROCESSING_MTC_INV_PRICING)
450
451 distribution_rights = self.has_valid_distribution_rights(bp, is_custom=is_custom)
452 if distribution_rights is None:
453 self.mtc_diagnostic.set_error(bp, self.mtc_diagnostic.ERROR_MTC_INELIGIBLE_DISTRIBUTION_RIGHTS)
454 return None
455
456 if is_custom:
457 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_PROCESSING_MTC_CUSTOM_INV_PRICING)
458
459 digest = mtc_calc_digest(book=bp.book,
460 custom_data_str=text_type('{}{}'.format(bp.billing_isbn, bp.store_numbers)))
461 current_price = bp.price
462 else:
463 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_PROCESSING_MTC_NON_CUSTOM_INV_PRICING)
464 # validate book? territory, isbn
465 digest = mtc_calc_digest(book=bp.book)
466 current_price = self.pricing_logic.mtc_calc_book_price(
467 book_pricing=bp,
468 follett_pub_type=self._mtc_pub_type,
469 is_rs_agency_pub=self.is_book_wholesale is False,
470 is_list_pub=self._mtc_is_list_pub,
471 org_publisher_percentage=self._bdp_pct,
472 pct_off_list=(
473 discount_list
474 if discount_list is not None else
475 self._mtc_pct_off_list), # fallback for MTC_CATALOGUE_DURATION w/o ThirdPartyPricingSchedule
476 discount_srp=discount_srp,
477 wholesale_pct=wholesale_pct,
478 )
479 self.mtc_diagnostic.log(bp, f"MTC Book Pricing: {current_price}")
480
481 if not current_price:
482 # None/blank and 0.00/free prices are invalid per BI-3923
483 self.mtc_diagnostic.set_error(bp, self.mtc_diagnostic.ERROR_MTC_INELIGIBLE_INVALID_PRICE)
484 return None
485 self.process_book_pricing(bp, recipient_org=self._mtc_org, current_price=current_price,
486 is_valid_isbn=self.mtc_is_valid_isbn, digest=digest, skip_if_missing_eisbn13=True,
487 is_custom_mtc_pricing=is_custom)
488
489 def start(self):
490 """
491 Start processing book
492 update + create booklog prices for a given book for base + all wholesale api orgs
493 :return: True if processing passed else False
494 """
495 self.blp_counters = dict().fromkeys([CREATED, UPDATED, DEACTIVATED, TOTAL], 0)
496 # get booklogprices by bookprice and recipient_org
497 self.get_active_booklog_price()
498 # process book pricing
499 book_pricings = self.get_active_book_pricing()
500 for bp in book_pricings:
501 has_eligible_pricing = False
502 if self.is_book_pricing_eligible_for_redshelf_catalog_file(bp):
503 # process National and Wholesale API BLPs
504 has_eligible_pricing = True
505 self.process_book_pricing(bp)
506
507 if not has_eligible_pricing:
508 blps = BooklogPrice.objects.filter(pricing_option=bp, is_active=True)
509 if self._pub_is_in_chill_period:
510 # if pub is in chill period, mark National pricing BLP (recipient_org=None) as chilled
511 self.chill_pricing(blps=blps.filter(recipient_org=None))
512 else:
513 blps = blps.filter(recipient_org=None)
514 self.blp_counters[DEACTIVATED] += self.run_query(
515 blps, lambda x: x.update(is_active=False, last_modified=timezone.now()))
516
517 # disable any booklogs that no longer have active pricing
518 self.deactivate_ineligible_booklog_price()
519
520 Book.objects.filter(id=self._book.id).update(last_catalogue_update=timezone.now())
521 self.blp_counters[TOTAL] = sum(self.blp_counters.values())
522 return self.blp_counters
523
524 def _process_bp_according_to_contract(self, bp):
525 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_PROCESSING_STANDARD_MTC)
526 has_eligible_pricing = False
527 if self.is_book_pricing_eligible_for_mtc_file(bp):
528 # process MTC BLPs
529 if bp.is_custom_mtc_book_pricing:
530 # process Custom MTC BLPs
531 if not self.pricing_logic.is_valid_custom_mtc_pricing(bp):
532 self.mtc_diagnostic.set_error(bp, self.mtc_diagnostic.ERROR_MTC_CUSTOM_INVALID)
533 return
534 has_eligible_pricing = True
535
536 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_PROCESSING_MTC_CUSTOM_INV_PRICING)
537 self.process_mtc_book_pricing(bp, is_custom=True)
538 elif self._book.is_public and self._is_mtc_eligible:
539 # process Standard MTC BLPs
540 if self._mtc_pub_type == 'R':
541 # process RedShelf Contract Standard MTC BLPs
542 has_eligible_pricing = True
543 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_PROCESSING_REDSHELF_CONTRACT_STANDARD_MTC)
544 self.process_mtc_book_pricing(bp)
545 else:
546 # process Follett Contract Standard MTC BLPs using ThirdPartyPricingSchedule values
547 kwargs = self.pricing_logic.get_kwargs_from_pricing_schedule(
548 bp, kwargs=self._mtc_pub_data, pricing_schedules=self._pricing_schedules)
549 if kwargs.get('schedule_key'):
550 # there is a valid ThirdPartyPricingSchedule, allow this duration and use it's values
551 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_PROCESSING_FOLLETT_CONTRACT_STANDARD_MTC)
552
553 has_eligible_pricing = True
554 self.process_mtc_book_pricing(
555 bp,
556 discount_srp=kwargs['discount_srp'],
557 discount_list=kwargs['pct_off_l'],
558 wholesale_pct=kwargs['wholesale_pct'],
559 )
560 elif self._mtc_duration == bp.limit_days:
561 # (fallback) process Follett Contract Standard MTC BLPs using
562 # OrgPricing MTC_CATALOGUE_DURATION and MTC_CATALOGUE_PCT_OFF_LIST values
563 # Note: this only should happen if we didn't setup the Follett Contract org correctly
564 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_PROCESSING_FOLLETT_CONTRACT_FALLBACK)
565 has_eligible_pricing = True
566 logger.warning(
567 "CatProc: Follett Contract Publisher missing ThirdPartyPricingSchedule for MTC_CATALOGUE_DURATION. "
568 "Using OrgPricing values (duration, pct_off_list) as a fallback.",
569 context_dict={
570 'org': self._book.org.short_name,
571 'org_account': self._book.org.account.email,
572 'imprint_org_id': self._book.imprint_org.short_name if self._book.imprint_org_id else None,
573 'book_hash_id': self._book.hash_id,
574 'limit_days': bp.limit_days,
575 'discount_code': self._book.discount_code,
576 'pricing_schedules': self._pricing_schedules,
577 'mtc_settings_src': self._mtc_settings_src,
578 }
579 )
580 self.process_mtc_book_pricing(bp)
581 else:
582 self.mtc_diagnostic.set_error(bp, self.mtc_diagnostic.ERROR_MTC_INELIGIBLE_FOLLETT)
583 else:
584 self.mtc_diagnostic.set_error(bp, self.mtc_diagnostic.ERROR_MTC_INELIGIBLE_SETTINGS)
585 else:
586 self.mtc_diagnostic.set_error(bp, self.mtc_diagnostic.ERROR_MTC_INELIGIBLE)
587
588 if not has_eligible_pricing:
589 self.mtc_diagnostic.log(bp, self.mtc_diagnostic.LOG_PRICING_NOT_ELIGIBLE)
590 blps = BooklogPrice.objects.filter(recipient_org=self._mtc_org, pricing_option=bp, is_active=True)
591 self.blp_counters[DEACTIVATED] += self.run_query(
592 blps,
593 lambda x: x.update(is_active=False, last_modified=timezone.now())
594 )
595
596 def mtc_start(self):
597 self.blp_counters = dict().fromkeys([CREATED, UPDATED, DEACTIVATED, TOTAL], 0)
598 self.get_active_booklog_price(recipient_org=self._mtc_org)
599 self._pricing_schedules = self.pricing_logic.get_pricing_schedules(book=self._book)
600
601 if self._pricing: # Running for single pricing
602 self._process_bp_according_to_contract(self._pricing)
603 return
604
605 # process book pricing
606 book_pricings = self.get_active_book_pricing()
607 for bp in book_pricings:
608 self._process_bp_according_to_contract(bp)
609
610 # disable any booklogs that no longer have active pricing
611 self.deactivate_ineligible_booklog_price()
612
613 self.run_query(
614 Book.objects.filter(id=self._book.id),
615 lambda x: x.update(last_mtc_catalogue_update=timezone.now())
616 )
617 self.blp_counters[TOTAL] = sum(self.blp_counters.values())
618 return self.blp_counters
619
620 @classmethod
621 def diagnose_mtc_pricing(cls, pricing, **kwargs):
622 gen = cls(pricing.book, pricing=pricing, dry_run=True, **kwargs)
623 gen.mtc_start()
624 diagnostic = gen.mtc_diagnostic
625 return diagnostic
626
627 @classmethod
628 def diagnose_mtc_product(cls, product, **kwargs):
629 gen = cls(product, dry_run=True, **kwargs)
630 gen.mtc_start()
631 diagnostic = gen.mtc_diagnostic
632 return diagnostic
633
634 def __init__(self, book, mtc_pub_data=None, pub_is_in_chill_period=None, pricing_logic=None, dry_run=False,
635 pricing=None):
636 """
637 BooklogPriceBookProcessor init
638 set initial variables for the processor
639
640 :param book: Book
641 :return: None
642 """
643 self._book = book
644 self._pricing = pricing
645 self._blps = None
646 self._pricing_schedules = None
647 self._book_is_wholesale = None
648 self._mtc_pub_code = None
649 self._mtc_duration = None
650 self._mtc_is_list_pub = None
651 self._mtc_pct_off_list = None
652 self._mtc_pub_type = None
653 self._bdp_pct = None
654 self._is_mtc_eligible = False
655 self.pricing_logic = pricing_logic or MTCPricingLogic()
656 self._pub_is_in_chill_period = \
657 is_pub_in_chill_period(book.org_id) if pub_is_in_chill_period is None else pub_is_in_chill_period
658 self.blp_counters = dict().fromkeys([CREATED, UPDATED, DEACTIVATED, TOTAL], 0)
659 self.mtc_diagnostic = CatalogueProcessorDiagnostic()
660
661 if not mtc_pub_data:
662 mtc_pub_data = self.pricing_logic.get_pub_mtc_data(book)
663 self._mtc_org = self.pricing_logic.get_mtc_org()
664 self._mtc_settings_src = mtc_pub_data.get('settings_src')
665 if self._mtc_org and self._mtc_settings_src:
666 self.mtc_diagnostic.log_settings(self.mtc_diagnostic.MTC_SETTINGS, mtc_pub_data)
667 # if book publisher is setup for MTC, populate the values
668 self._mtc_pub_data = mtc_pub_data
669 self._mtc_pub_code = mtc_pub_data['pub_code']
670 self._mtc_duration = mtc_pub_data['duration']
671 self._mtc_is_list_pub = mtc_pub_data['net_or_list'] == 'L'
672 self._mtc_pct_off_list = mtc_pub_data['pct_off_l']
673 self._mtc_pub_type = mtc_pub_data['pub_type']
674 self._bdp_pct = mtc_pub_data['bdp_pct']
675 self._is_mtc_eligible = True
676 else:
677 self.mtc_diagnostic.global_log("MTC Settings invalid for this product")
678 self._mtc_pub_data = None
679 self._is_mtc_eligible = False
680
681 self.mtc_diagnostic.log_settings('is_mtc_eligible', self._is_mtc_eligible)
682
683 self.dry_run = dry_run
684