1 | # -*- coding: utf-8; -*- |
---|
2 | ################################################################################ |
---|
3 | # |
---|
4 | # Rattail -- Retail Software Framework |
---|
5 | # Copyright © 2010-2022 Lance Edgar |
---|
6 | # |
---|
7 | # This file is part of Rattail. |
---|
8 | # |
---|
9 | # Rattail is free software: you can redistribute it and/or modify it under the |
---|
10 | # terms of the GNU General Public License as published by the Free Software |
---|
11 | # Foundation, either version 3 of the License, or (at your option) any later |
---|
12 | # version. |
---|
13 | # |
---|
14 | # Rattail is distributed in the hope that it will be useful, but WITHOUT ANY |
---|
15 | # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS |
---|
16 | # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more |
---|
17 | # details. |
---|
18 | # |
---|
19 | # You should have received a copy of the GNU General Public License along with |
---|
20 | # Rattail. If not, see <http://www.gnu.org/licenses/>. |
---|
21 | # |
---|
22 | ################################################################################ |
---|
23 | """ |
---|
24 | Data Batch Handlers |
---|
25 | """ |
---|
26 | |
---|
27 | from __future__ import unicode_literals, absolute_import |
---|
28 | |
---|
29 | import os |
---|
30 | import shutil |
---|
31 | import datetime |
---|
32 | import warnings |
---|
33 | import logging |
---|
34 | |
---|
35 | import json |
---|
36 | import six |
---|
37 | from sqlalchemy import orm |
---|
38 | |
---|
39 | from rattail.db import model, api |
---|
40 | from rattail.core import Object |
---|
41 | from rattail.gpc import GPC |
---|
42 | from rattail.barcodes import upce_to_upca |
---|
43 | from rattail.db.cache import cache_model |
---|
44 | from rattail.time import localtime, make_utc |
---|
45 | from rattail.util import progress_loop, load_object |
---|
46 | |
---|
47 | |
---|
48 | log = logging.getLogger(__name__) |
---|
49 | |
---|
50 | |
---|
51 | class BatchHandler(object): |
---|
52 | """ |
---|
53 | Base class and partial default implementation for batch handlers. It is |
---|
54 | expected that all batch handlers will ultimately inherit from this base |
---|
55 | class, therefore it defines the implementation "interface" loosely |
---|
56 | speaking. Custom batch handlers are welcome to supplement or override this |
---|
57 | as needed, and in fact must do so for certain aspects. |
---|
58 | """ |
---|
59 | populate_batches = False |
---|
60 | populate_with_versioning = True |
---|
61 | |
---|
62 | refresh_with_versioning = True |
---|
63 | repopulate_when_refresh = False |
---|
64 | |
---|
65 | execute_with_versioning = True |
---|
66 | delete_with_versioning = True |
---|
67 | |
---|
68 | def __init__(self, config): |
---|
69 | self.config = config |
---|
70 | self.app = self.config.get_app() |
---|
71 | self.enum = config.get_enum() |
---|
72 | self.model = config.get_model() |
---|
73 | |
---|
74 | @property |
---|
75 | def batch_model_class(self): |
---|
76 | """ |
---|
77 | Reference to the data model class of the batch type for which this |
---|
78 | handler is responsible, |
---|
79 | e.g. :class:`~rattail.db.model.batch.labels.LabelBatch`. Each handler |
---|
80 | must define this (or inherit from one that does). |
---|
81 | """ |
---|
82 | raise NotImplementedError("You must set the 'batch_model_class' attribute " |
---|
83 | "for class '{}'".format(self.__class__.__name__)) |
---|
84 | |
---|
85 | @property |
---|
86 | def batch_key(self): |
---|
87 | """ |
---|
88 | The "batch type key" for the handler, e.g. ``'labels'``. This is not |
---|
89 | meant to uniquely identify the handler itself, but rather the *type* of |
---|
90 | batch which it handles. Therefore multiple handlers may be defined |
---|
91 | which share the same ``batch_key`` - although in practice usually each |
---|
92 | app will need only one handler per batch type. |
---|
93 | |
---|
94 | If the handler doesn't define this, it will be obtained from the |
---|
95 | ``batch_key`` attribute of the :attr:`batch_model_class` attribute. |
---|
96 | """ |
---|
97 | return self.batch_model_class.batch_key |
---|
98 | |
---|
99 | @classmethod |
---|
100 | def get_spec(cls): |
---|
101 | return '{}:{}'.format(cls.__module__, cls.__name__) |
---|
102 | |
---|
103 | def get_model_title(self): |
---|
104 | return self.batch_model_class.get_model_title() |
---|
105 | |
---|
106 | def allow_versioning(self, action): |
---|
107 | if action == 'populate': |
---|
108 | return self.populate_with_versioning |
---|
109 | if action == 'refresh': |
---|
110 | return self.refresh_with_versioning |
---|
111 | if action == 'execute': |
---|
112 | return self.execute_with_versioning |
---|
113 | if action == 'delete': |
---|
114 | return self.delete_with_versioning |
---|
115 | raise NotImplementedError("unknown batch action: {}".format(action)) |
---|
116 | |
---|
117 | def is_mutable(self, batch): |
---|
118 | """ |
---|
119 | Returns a boolean indicating whether or not *any* modifications are to |
---|
120 | be allowed for the given batch. By default this returns ``False`` if |
---|
121 | the batch is already executed, or has been marked complete; otherwise |
---|
122 | returns ``True``. |
---|
123 | """ |
---|
124 | if batch.executed: |
---|
125 | return False |
---|
126 | if batch.complete: |
---|
127 | return False |
---|
128 | return True |
---|
129 | |
---|
130 | def consume_batch_id(self, session, as_str=False): |
---|
131 | """ |
---|
132 | Consumes a new batch ID from the generator, and returns it. |
---|
133 | |
---|
134 | :param session: Current session for Rattail DB. |
---|
135 | |
---|
136 | :param as_str: Flag indicating whether the return value should be a |
---|
137 | string, as opposed to the default of integer. |
---|
138 | |
---|
139 | :returns: Batch ID as integer, or zero-padded string of 8 chars. |
---|
140 | """ |
---|
141 | batch_id = self.app.next_counter_value(session, 'batch_id') |
---|
142 | if as_str: |
---|
143 | return '{:08d}'.format(batch_id) |
---|
144 | return batch_id |
---|
145 | |
---|
146 | def make_basic_batch(self, session, user=None, progress=None, **kwargs): |
---|
147 | """ |
---|
148 | Make a new "basic" batch, with no customization beyond what is provided |
---|
149 | by ``kwargs``, which are passed directly to the batch class constructor. |
---|
150 | |
---|
151 | Note that the new batch instance will be added to the provided session, |
---|
152 | which will also be flushed. |
---|
153 | |
---|
154 | Callers should use :meth:`make_batch()` instead of this method. |
---|
155 | """ |
---|
156 | kwargs.setdefault('rowcount', 0) |
---|
157 | kwargs.setdefault('complete', False) |
---|
158 | |
---|
159 | # we used just let the postgres sequence auto-generate the id, |
---|
160 | # but now we are trying to support more than just postgres, |
---|
161 | # and so we consume a batch id using shared logic which can |
---|
162 | # accommodate more than just postgres |
---|
163 | if 'id' not in kwargs: |
---|
164 | kwargs['id'] = self.consume_batch_id(session) |
---|
165 | |
---|
166 | # try to provide default creator |
---|
167 | if user and 'created_by' not in kwargs and 'created_by_uuid' not in kwargs: |
---|
168 | kwargs['created_by'] = user |
---|
169 | |
---|
170 | batch = self.batch_model_class(**kwargs) |
---|
171 | session.add(batch) |
---|
172 | session.flush() |
---|
173 | return batch |
---|
174 | |
---|
175 | def make_batch(self, session, progress=None, **kwargs): |
---|
176 | """ |
---|
177 | Make and return a new batch instance. |
---|
178 | |
---|
179 | This is the method which callers should use. It invokes |
---|
180 | :meth:`make_basic_batch()` to actually create the new batch instance, |
---|
181 | and then :meth:`init_batch()` to perform any extra initialization for |
---|
182 | it. Note that the batch returned will *not* yet be fully populated. |
---|
183 | """ |
---|
184 | batch = self.make_basic_batch(session, progress=progress, **kwargs) |
---|
185 | kwargs['session'] = session |
---|
186 | self.init_batch(batch, progress=progress, **kwargs) |
---|
187 | return batch |
---|
188 | |
---|
189 | def init_batch(self, batch, progress=None, **kwargs): |
---|
190 | """ |
---|
191 | Perform extra initialization for the given batch, in whatever way might |
---|
192 | make sense. Default of course is to do nothing here; handlers are free |
---|
193 | to override as needed. |
---|
194 | |
---|
195 | Note that initial population of a batch should *not* happen here; see |
---|
196 | :meth:`populate()` for a place to define that logic. |
---|
197 | """ |
---|
198 | |
---|
199 | def make_row(self, **kwargs): |
---|
200 | """ |
---|
201 | Make a new row for the batch. Note however that the row will **not** |
---|
202 | be added to the batch; that should be done with :meth:`add_row()`. |
---|
203 | |
---|
204 | :returns: A new row object, which does *not* yet belong to any batch. |
---|
205 | """ |
---|
206 | return self.batch_model_class.row_class(**kwargs) |
---|
207 | |
---|
208 | def add_row(self, batch, row): |
---|
209 | """ |
---|
210 | Add the given row to the given batch. This assumes it is a *new* row |
---|
211 | which does not yet belong to any batch. This logic performs the |
---|
212 | following steps: |
---|
213 | |
---|
214 | The row is officially added to the batch, and is immediately |
---|
215 | "refreshed" via :meth:`refresh_row()`. |
---|
216 | |
---|
217 | The row is then examined to see if it has been marked as "removed" by |
---|
218 | the refresh. If it was *not* removed then the batch's cached |
---|
219 | ``rowcount`` is incremented, and the :meth:`after_add_row()` hook is |
---|
220 | invoked. |
---|
221 | """ |
---|
222 | session = orm.object_session(batch) |
---|
223 | with session.no_autoflush: |
---|
224 | batch.data_rows.append(row) |
---|
225 | self.refresh_row(row) |
---|
226 | if not row.removed: |
---|
227 | batch.rowcount = (batch.rowcount or 0) + 1 |
---|
228 | self.after_add_row(batch, row) |
---|
229 | |
---|
230 | def after_add_row(self, batch, row): |
---|
231 | """ |
---|
232 | Event hook, called immediately after the given row has been "properly" |
---|
233 | added to the batch. This is a good place to update totals for the |
---|
234 | batch, to account for the new row, etc. |
---|
235 | """ |
---|
236 | |
---|
237 | def purge_batches(self, session, before=None, before_days=90, |
---|
238 | dry_run=False, delete_all_data=None, |
---|
239 | progress=None, **kwargs): |
---|
240 | """ |
---|
241 | Purge all batches which were executed prior to a given date. |
---|
242 | |
---|
243 | :param before: If provided, must be a timezone-aware datetime object. |
---|
244 | If not provided, it will be calculated from the current date, using |
---|
245 | ``before_days``. |
---|
246 | |
---|
247 | :param before_days: Number of days before the current date, to be used |
---|
248 | as the cutoff date if ``before`` is not specified. |
---|
249 | |
---|
250 | :param dry_run: Flag indicating that this is a "dry run" and all logic |
---|
251 | involved should be (made) aware of that fact. |
---|
252 | |
---|
253 | :param delete_all_data: Flag indicating whether *all* data should be |
---|
254 | deleted for each batch being purged. This flag is passed along to |
---|
255 | :meth:`delete()`; see that for more info. NOTE: This flag should |
---|
256 | probably be deprecated, but so far has not been...but ``dry_run`` |
---|
257 | should be preferred for readability etc. |
---|
258 | |
---|
259 | :returns: Integer indicating the number of batches purged. |
---|
260 | """ |
---|
261 | if delete_all_data and dry_run: |
---|
262 | raise ValueError("You can enable (n)either of `dry_run` or " |
---|
263 | "`delete_all_data` but both cannot be True") |
---|
264 | delete_all_data = not dry_run |
---|
265 | |
---|
266 | if not before: |
---|
267 | before = localtime(self.config).date() - datetime.timedelta(days=before_days) |
---|
268 | before = datetime.datetime.combine(before, datetime.time(0)) |
---|
269 | before = localtime(self.config, before) |
---|
270 | |
---|
271 | log.info("will purge '%s' batches, executed before %s", |
---|
272 | self.batch_key, before.date()) |
---|
273 | |
---|
274 | old_batches = session.query(self.batch_model_class)\ |
---|
275 | .filter(self.batch_model_class.executed < before)\ |
---|
276 | .options(orm.joinedload(self.batch_model_class.data_rows))\ |
---|
277 | .all() |
---|
278 | log.info("found %s batches to purge", len(old_batches)) |
---|
279 | result = Object() |
---|
280 | result.purged = 0 |
---|
281 | |
---|
282 | def purge(batch, i): |
---|
283 | self.do_delete(batch, dry_run=dry_run) |
---|
284 | result.purged += 1 |
---|
285 | if i % 5 == 0: |
---|
286 | session.flush() |
---|
287 | |
---|
288 | self.progress_loop(purge, old_batches, progress, |
---|
289 | message="Purging old batches") |
---|
290 | |
---|
291 | session.flush() |
---|
292 | if old_batches: |
---|
293 | log.info("%spurged %s '%s' batches", |
---|
294 | "(would have) " if dry_run else "", |
---|
295 | result.purged, self.batch_key) |
---|
296 | return result.purged |
---|
297 | |
---|
298 | @property |
---|
299 | def root_datadir(self): |
---|
300 | """ |
---|
301 | The absolute path of the root folder in which data for this particular |
---|
302 | type of batch is stored. The structure of this path is as follows: |
---|
303 | |
---|
304 | .. code-block:: none |
---|
305 | |
---|
306 | /{root_batch_data_dir}/{batch_type_key} |
---|
307 | |
---|
308 | * ``{root_batch_data_dir}`` - Value of the 'batch.files' option in the |
---|
309 | [rattail] section of config file. |
---|
310 | * ``{batch_type_key}`` - Unique key for the type of batch it is. |
---|
311 | |
---|
312 | .. note:: |
---|
313 | While it is likely that the data folder returned by this method |
---|
314 | already exists, this method does not guarantee it. |
---|
315 | """ |
---|
316 | return self.config.batch_filedir(self.batch_key) |
---|
317 | |
---|
318 | def datadir(self, batch): |
---|
319 | """ |
---|
320 | Returns the absolute path of the folder in which the batch's source |
---|
321 | data file(s) resides. Note that the batch must already have been |
---|
322 | persisted to the database. The structure of the path returned is as |
---|
323 | follows: |
---|
324 | |
---|
325 | .. code-block:: none |
---|
326 | |
---|
327 | /{root_datadir}/{uuid[:2]}/{uuid[2:]} |
---|
328 | |
---|
329 | * ``{root_datadir}`` - Value returned by :meth:`root_datadir()`. |
---|
330 | * ``{uuid[:2]}`` - First two characters of batch UUID. |
---|
331 | * ``{uuid[2:]}`` - All batch UUID characters *after* the first two. |
---|
332 | |
---|
333 | .. note:: |
---|
334 | While it is likely that the data folder returned by this method |
---|
335 | already exists, this method does not guarantee any such thing. It |
---|
336 | is typically assumed that the path will have been created by a |
---|
337 | previous call to :meth:`make_batch()` however. |
---|
338 | """ |
---|
339 | return os.path.join(self.root_datadir, batch.uuid[:2], batch.uuid[2:]) |
---|
340 | |
---|
341 | def make_datadir(self, batch): |
---|
342 | """ |
---|
343 | Returns the data folder specific to the given batch, creating if necessary. |
---|
344 | """ |
---|
345 | datadir = self.datadir(batch) |
---|
346 | if not os.path.exists(datadir): |
---|
347 | os.makedirs(datadir) |
---|
348 | return datadir |
---|
349 | |
---|
350 | # TODO: remove default attr? |
---|
351 | def set_input_file(self, batch, path, attr='filename'): |
---|
352 | """ |
---|
353 | Assign the data file found at ``path`` to the batch. This overwrites |
---|
354 | the given attribute (``attr``) of the batch and places a copy of the |
---|
355 | data file in the batch's data folder. |
---|
356 | """ |
---|
357 | datadir = self.make_datadir(batch) |
---|
358 | filename = os.path.basename(path) |
---|
359 | shutil.copyfile(path, os.path.join(datadir, filename)) |
---|
360 | setattr(batch, attr, filename) |
---|
361 | |
---|
362 | def should_populate(self, batch): |
---|
363 | """ |
---|
364 | Must return a boolean indicating whether the given batch should be |
---|
365 | populated from an initial data source, i.e. at time of batch creation. |
---|
366 | Override this method if you need to inspect the batch in order to |
---|
367 | determine whether the populate step is needed. Default behavior is to |
---|
368 | simply return the value of :attr:`populate_batches`. |
---|
369 | """ |
---|
370 | return self.populate_batches |
---|
371 | |
---|
372 | def setup_populate(self, batch, progress=None): |
---|
373 | """ |
---|
374 | Perform any setup (caching etc.) necessary for populating a batch. |
---|
375 | """ |
---|
376 | |
---|
377 | def teardown_populate(self, batch, progress=None): |
---|
378 | """ |
---|
379 | Perform any teardown (cleanup etc.) necessary after populating a batch. |
---|
380 | """ |
---|
381 | |
---|
382 | def do_populate(self, batch, user, progress=None): |
---|
383 | """ |
---|
384 | Perform initial population for the batch, i.e. fill it with data rows. |
---|
385 | Where the handler obtains the data to do this, will vary greatly. |
---|
386 | |
---|
387 | Note that callers *should* use this method, but custom batch handlers |
---|
388 | should *not* override this method. Conversely, custom handlers |
---|
389 | *should* override the :meth:`~populate()` method, but callers should |
---|
390 | *not* use that one directly. |
---|
391 | """ |
---|
392 | self.setup_populate(batch, progress=progress) |
---|
393 | self.populate(batch, progress=progress) |
---|
394 | self.teardown_populate(batch, progress=progress) |
---|
395 | self.refresh_batch_status(batch) |
---|
396 | return True |
---|
397 | |
---|
398 | def populate(self, batch, progress=None): |
---|
399 | """ |
---|
400 | Populate the batch with initial data rows. It is assumed that the data |
---|
401 | source to be used will be known by inspecting various properties of the |
---|
402 | batch itself. |
---|
403 | |
---|
404 | Note that callers should *not* use this method, but custom batch |
---|
405 | handlers *should* override this method. Conversely, custom handlers |
---|
406 | should *not* override the :meth:`~do_populate()` method, but callers |
---|
407 | *should* use that one directly. |
---|
408 | """ |
---|
409 | raise NotImplementedError("Please implement `{}.populate()` method".format(self.__class__.__name__)) |
---|
410 | |
---|
411 | def refreshable(self, batch): |
---|
412 | """ |
---|
413 | This method should return a boolean indicating whether or not the |
---|
414 | handler supports a "refresh" operation for the batch, given its current |
---|
415 | condition. The default assumes a refresh is allowed unless the batch |
---|
416 | has already been executed. |
---|
417 | """ |
---|
418 | if batch.executed: |
---|
419 | return False |
---|
420 | return True |
---|
421 | |
---|
422 | def progress_loop(self, *args, **kwargs): |
---|
423 | return progress_loop(*args, **kwargs) |
---|
424 | |
---|
425 | def setup_refresh(self, batch, progress=None): |
---|
426 | """ |
---|
427 | Perform any setup (caching etc.) necessary for refreshing a batch. |
---|
428 | """ |
---|
429 | |
---|
430 | def teardown_refresh(self, batch, progress=None): |
---|
431 | """ |
---|
432 | Perform any teardown (cleanup etc.) necessary after refreshing a batch. |
---|
433 | """ |
---|
434 | |
---|
435 | def do_refresh(self, batch, user, progress=None): |
---|
436 | """ |
---|
437 | Perform a full data refresh for the batch, i.e. update any data which |
---|
438 | may have become stale, etc. |
---|
439 | |
---|
440 | Note that callers *should* use this method, but custom batch handlers |
---|
441 | should *not* override this method. Conversely, custom handlers |
---|
442 | *should* override the :meth:`~refresh()` method, but callers should |
---|
443 | *not* use that one directly. |
---|
444 | """ |
---|
445 | self.refresh(batch, progress=progress) |
---|
446 | return True |
---|
447 | |
---|
448 | def refresh(self, batch, progress=None): |
---|
449 | """ |
---|
450 | Perform a full data refresh for the batch. What exactly this means will |
---|
451 | depend on the type of batch, and specific handler logic. |
---|
452 | |
---|
453 | Generally speaking this refresh is meant to use queries etc. to obtain |
---|
454 | "fresh" data for the batch (header) and all its rows. In most cases |
---|
455 | certain data is expected to be "core" to the batch and/or rows, and |
---|
456 | such data will be left intact, with all *other* data values being |
---|
457 | re-calculated and/or reset etc. |
---|
458 | |
---|
459 | Note that callers should *not* use this method, but custom batch |
---|
460 | handlers *should* override this method. Conversely, custom handlers |
---|
461 | should *not* override the :meth:`~do_refresh()` method, but callers |
---|
462 | *should* use that one directly. |
---|
463 | """ |
---|
464 | session = orm.object_session(batch) |
---|
465 | self.setup_refresh(batch, progress=progress) |
---|
466 | if self.repopulate_when_refresh: |
---|
467 | del batch.data_rows[:] |
---|
468 | batch.rowcount = 0 |
---|
469 | session.flush() |
---|
470 | self.populate(batch, progress=progress) |
---|
471 | else: |
---|
472 | batch.rowcount = 0 |
---|
473 | |
---|
474 | def refresh(row, i): |
---|
475 | with session.no_autoflush: |
---|
476 | self.refresh_row(row) |
---|
477 | if not row.removed: |
---|
478 | batch.rowcount += 1 |
---|
479 | |
---|
480 | self.progress_loop(refresh, batch.active_rows(), progress, |
---|
481 | message="Refreshing batch data rows") |
---|
482 | self.refresh_batch_status(batch) |
---|
483 | self.teardown_refresh(batch, progress=progress) |
---|
484 | return True |
---|
485 | |
---|
486 | def refresh_many(self, batches, user=None, progress=None): |
---|
487 | """ |
---|
488 | Refresh a set of batches, with given progress. Default behavior is to |
---|
489 | simply refresh each batch in succession. Any batches which are already |
---|
490 | executed are skipped. |
---|
491 | |
---|
492 | Handlers may have to override this method if "grouping" or other |
---|
493 | special behavior is needed. |
---|
494 | """ |
---|
495 | needs_refresh = [batch for batch in batches |
---|
496 | if not batch.executed] |
---|
497 | if not needs_refresh: |
---|
498 | return |
---|
499 | |
---|
500 | # TODO: should perhaps try to make the progress indicator reflect the |
---|
501 | # "total" number of rows across all batches being refreshed? seems |
---|
502 | # like that might be more accurate, for the user. but also harder. |
---|
503 | |
---|
504 | for batch in needs_refresh: |
---|
505 | self.do_refresh(batch, user, progress=progress) |
---|
506 | |
---|
507 | def refresh_row(self, row): |
---|
508 | """ |
---|
509 | This method will be passed a row object which has already been properly |
---|
510 | added to a batch, and which has basic required fields already |
---|
511 | populated. This method is then responsible for further populating all |
---|
512 | applicable fields for the row, based on current data within the |
---|
513 | appropriate system(s). |
---|
514 | |
---|
515 | Note that in some cases this method may be called multiple times for |
---|
516 | the same row, e.g. once when first creating the batch and then later |
---|
517 | when a user explicitly refreshes the batch. The method logic must |
---|
518 | account for this possibility. |
---|
519 | """ |
---|
520 | |
---|
521 | def refresh_product_basics(self, row): |
---|
522 | """ |
---|
523 | This method will refresh the "basic" product info for a row. It |
---|
524 | assumes that the row is derived from |
---|
525 | :class:`~rattail.db.model.batch.core.ProductBatchRowMixin` and that |
---|
526 | ``row.product`` is already set to a valid product. |
---|
527 | """ |
---|
528 | product = getattr(row, 'product', None) |
---|
529 | if not product: |
---|
530 | return |
---|
531 | |
---|
532 | row.item_id = product.item_id |
---|
533 | row.upc = product.upc |
---|
534 | row.brand_name = six.text_type(product.brand or "") |
---|
535 | row.description = product.description |
---|
536 | row.size = product.size |
---|
537 | |
---|
538 | department = product.department |
---|
539 | row.department_number = department.number if department else None |
---|
540 | row.department_name = department.name if department else None |
---|
541 | |
---|
542 | subdepartment = product.subdepartment |
---|
543 | row.subdepartment_number = subdepartment.number if subdepartment else None |
---|
544 | row.subdepartment_name = subdepartment.name if subdepartment else None |
---|
545 | |
---|
546 | def quick_entry(self, session, batch, entry): |
---|
547 | """ |
---|
548 | Handle a "quick entry" value, e.g. from user input. Most frequently this |
---|
549 | value would represent a UPC or similar "ID" value for e.g. a product record, |
---|
550 | and the handler's duty would be to either locate a corresponding row within |
---|
551 | the batch (if one exists), or else add a new row to the batch. |
---|
552 | |
---|
553 | In any event this method can be customized and in fact has no default |
---|
554 | behavior, so must be defined by a handler. |
---|
555 | |
---|
556 | :param session: Database sesssion. |
---|
557 | |
---|
558 | :param batch: Batch for which the quick entry is to be handled. Note that |
---|
559 | this batch is assumed to belong to the given ``session``. |
---|
560 | |
---|
561 | :param entry: String value to be handled. This is generally assumed to |
---|
562 | be from user input (e.g. UPC scan field) but may not always be. |
---|
563 | |
---|
564 | :returns: New or existing "row" object, for the batch. |
---|
565 | """ |
---|
566 | raise NotImplementedError |
---|
567 | |
---|
568 | def locate_product_for_entry(self, session, entry, **kwargs): |
---|
569 | """ |
---|
570 | TODO: Deprecated; use ProductsHandler method instead. |
---|
571 | """ |
---|
572 | # don't bother if we're given empty "entry" value |
---|
573 | if not entry: |
---|
574 | return |
---|
575 | |
---|
576 | products_handler = self.app.get_products_handler() |
---|
577 | return products_handler.locate_product_for_entry(session, entry, |
---|
578 | **kwargs) |
---|
579 | |
---|
580 | def remove_row(self, row): |
---|
581 | """ |
---|
582 | Remove the given row from its batch, and update the batch accordingly. |
---|
583 | How exactly the row is "removed" is up to this method. Default is to |
---|
584 | set the row's ``removed`` flag, then invoke the |
---|
585 | :meth:`refresh_batch_status()` method. |
---|
586 | |
---|
587 | Note that callers should *not* use this method, but custom batch |
---|
588 | handlers *should* override this method. Conversely, custom handlers |
---|
589 | should *not* override the :meth:`do_remove_row()` method, but callers |
---|
590 | *should* use that one directly. |
---|
591 | """ |
---|
592 | batch = row.batch |
---|
593 | row.removed = True |
---|
594 | self.refresh_batch_status(batch) |
---|
595 | |
---|
596 | def do_remove_row(self, row): |
---|
597 | """ |
---|
598 | Remove the given row from its batch, and update the batch accordingly. |
---|
599 | Uses the following logic: |
---|
600 | |
---|
601 | If the row's ``removed`` flag is already set, does nothing and returns |
---|
602 | immediately. |
---|
603 | |
---|
604 | Otherwise, it invokes :meth:`remove_row()` and then decrements the |
---|
605 | batch ``rowcount`` attribute. |
---|
606 | |
---|
607 | Note that callers *should* use this method, but custom batch handlers |
---|
608 | should *not* override this method. Conversely, custom handlers |
---|
609 | *should* override the :meth:`remove_row()` method, but callers should |
---|
610 | *not* use that one directly. |
---|
611 | """ |
---|
612 | if row.removed: |
---|
613 | return |
---|
614 | self.remove_row(row) |
---|
615 | batch = row.batch |
---|
616 | if batch.rowcount is not None: |
---|
617 | batch.rowcount -= 1 |
---|
618 | |
---|
619 | def refresh_batch_status(self, batch): |
---|
620 | """ |
---|
621 | Update the batch status, as needed. This method does nothing by |
---|
622 | default, but may be overridden if the overall batch status needs to be |
---|
623 | updated according to the status of its rows. This method may be |
---|
624 | invoked whenever rows are added, removed, updated etc. |
---|
625 | """ |
---|
626 | |
---|
627 | def write_worksheet(self, batch, progress=None): |
---|
628 | """ |
---|
629 | Write a worksheet file, to be downloaded by the user. Must return the |
---|
630 | file path. |
---|
631 | """ |
---|
632 | raise NotImplementedError("Please define logic for `{}.write_worksheet()`".format( |
---|
633 | self.__class__.__name__)) |
---|
634 | |
---|
635 | def update_from_worksheet(self, batch, path, progress=None): |
---|
636 | """ |
---|
637 | Save the given file to a batch-specific location, then update the |
---|
638 | batch data from the file contents. |
---|
639 | """ |
---|
640 | raise NotImplementedError("Please define logic for `{}.update_from_worksheet()`".format( |
---|
641 | self.__class__.__name__)) |
---|
642 | |
---|
643 | def mark_complete(self, batch, progress=None): |
---|
644 | """ |
---|
645 | Mark the given batch as "complete". This usually is just a matter of |
---|
646 | setting the :attr:`~rattail.db.model.batch.BatchMixin.complete` flag |
---|
647 | for the batch, with the idea that this should "freeze" the batch so |
---|
648 | that another user can verify its state before finally executing it. |
---|
649 | |
---|
650 | Each handler is of course free to expound on this idea, or to add extra |
---|
651 | logic to this "event" of marking a batch complete. |
---|
652 | """ |
---|
653 | batch.complete = True |
---|
654 | |
---|
655 | def mark_incomplete(self, batch, progress=None): |
---|
656 | """ |
---|
657 | Mark the given batch as "incomplete" (aka. pending). This usually is |
---|
658 | just a matter of clearing the |
---|
659 | :attr:`~rattail.db.model.batch.BatchMixin.complete` flag for the batch, |
---|
660 | with the idea that this should "thaw" the batch so that it may be |
---|
661 | further updated, i.e. it's not yet ready to execute. |
---|
662 | |
---|
663 | Each handler is of course free to expound on this idea, or to add extra |
---|
664 | logic to this "event" of marking a batch incomplete. |
---|
665 | """ |
---|
666 | batch.complete = False |
---|
667 | |
---|
668 | def why_not_execute(self, batch): |
---|
669 | """ |
---|
670 | This method should inspect the given batch and, if there is a reason |
---|
671 | that execution should *not* be allowed for it, the method should return |
---|
672 | a text string indicating that reason. It should return ``None`` if no |
---|
673 | such reason could be identified, and execution should be allowed. |
---|
674 | |
---|
675 | Note that it is assumed the batch has not already been executed, since |
---|
676 | execution is globally prevented for such batches. In other words you |
---|
677 | needn't check for that as a possible reason not to execute. |
---|
678 | """ |
---|
679 | |
---|
680 | def executable(self, batch): |
---|
681 | """ |
---|
682 | This method should return a boolean indicating whether or not execution |
---|
683 | should be allowed for the batch, given its current condition. |
---|
684 | |
---|
685 | While you may override this method, you are encouraged to override |
---|
686 | :meth:`why_not_execute()` instead. Default logic for this method is as |
---|
687 | follows: |
---|
688 | |
---|
689 | If the batch is ``None`` then the caller simply wants to know if "any" |
---|
690 | batch may be executed, so we return ``True``. |
---|
691 | |
---|
692 | If the batch has already been executed then we return ``False``. |
---|
693 | |
---|
694 | If the :meth:`why_not_execute()` method returns a value, then we assume |
---|
695 | execution is not allowed and return ``False``. |
---|
696 | |
---|
697 | Finally we will return ``True`` if none of the above rules matched. |
---|
698 | """ |
---|
699 | if batch is None: |
---|
700 | return True |
---|
701 | if batch.executed: |
---|
702 | return False |
---|
703 | if self.why_not_execute(batch): |
---|
704 | return False |
---|
705 | return True |
---|
706 | |
---|
707 | def auto_executable(self, batch): |
---|
708 | """ |
---|
709 | Must return a boolean indicating whether the given bath is eligible for |
---|
710 | "automatic" execution, i.e. immediately after batch is created. |
---|
711 | """ |
---|
712 | return False |
---|
713 | |
---|
714 | def describe_execution(self, batch, **kwargs): |
---|
715 | """ |
---|
716 | This method should essentially return some text describing briefly what |
---|
717 | will happen when the given batch is executed. |
---|
718 | |
---|
719 | :param batch: The batch in question, which is a candidate for |
---|
720 | execution. |
---|
721 | |
---|
722 | :returns: String value describing the batch execution. |
---|
723 | """ |
---|
724 | |
---|
725 | def do_execute(self, batch, user, progress=None, **kwargs): |
---|
726 | """ |
---|
727 | Perform final execution for the batch. What that means for any given |
---|
728 | batch, will vary greatly. |
---|
729 | |
---|
730 | Note that callers *should* use this method, but custom batch handlers |
---|
731 | should *not* override this method. Conversely, custom handlers |
---|
732 | *should* override the :meth:`~execute()` method, but callers should |
---|
733 | *not* use that one directly. |
---|
734 | """ |
---|
735 | # make sure we declare who's responsible, if we can |
---|
736 | # TODO: seems like if caller already knows user, they should |
---|
737 | # have already done this. and probably bad form to do it here |
---|
738 | session = self.app.get_session(batch) |
---|
739 | session.set_continuum_user(user) |
---|
740 | |
---|
741 | result = self.execute(batch, user=user, progress=progress, **kwargs) |
---|
742 | if not result: |
---|
743 | return False |
---|
744 | |
---|
745 | batch.executed = make_utc() |
---|
746 | batch.executed_by = user |
---|
747 | |
---|
748 | # record the execution kwargs within batch params, if there |
---|
749 | # were any. this is mostly for troubleshooting after the fact |
---|
750 | if kwargs: |
---|
751 | try: |
---|
752 | # first make sure kwargs are JSON-safe |
---|
753 | json.dumps(kwargs) |
---|
754 | except: |
---|
755 | # TODO: may need to lower log level if this is common, |
---|
756 | # although underlying causes are hopefully easy to fix |
---|
757 | log.exception("kwargs are not JSON-safe: %s", kwargs) |
---|
758 | else: |
---|
759 | batch.set_param('_executed_with_kwargs_', kwargs) |
---|
760 | |
---|
761 | return result |
---|
762 | |
---|
763 | def get_effective_rows(self, batch): |
---|
764 | """ |
---|
765 | Should return the set of rows from the given batch which are |
---|
766 | considered "effective" - i.e. when the batch is executed, |
---|
767 | these rows should be processed whereas the remainder should |
---|
768 | not. |
---|
769 | |
---|
770 | :param batch: A |
---|
771 | :class:`~rattail.db.model.batch.vendorcatalog.VendorCatalogBatch` |
---|
772 | instance. |
---|
773 | |
---|
774 | :returns: List of |
---|
775 | :class:`~rattail.db.model.batch.vendorcatalog.VendorCatalogBatchRow` |
---|
776 | instances. |
---|
777 | """ |
---|
778 | return batch.active_rows() |
---|
779 | |
---|
780 | def execute(self, batch, progress=None, **kwargs): |
---|
781 | """ |
---|
782 | Execute the given batch, according to the given kwargs. This is really |
---|
783 | where the magic happens, although each handler must define that magic, |
---|
784 | since the default logic does nothing at all. |
---|
785 | |
---|
786 | Note that callers should *not* use this method, but custom batch |
---|
787 | handlers *should* override this method. Conversely, custom handlers |
---|
788 | should *not* override the :meth:`~do_execute()` method, but callers |
---|
789 | *should* use that one directly. |
---|
790 | """ |
---|
791 | |
---|
792 | def execute_many(self, batches, progress=None, **kwargs): |
---|
793 | """ |
---|
794 | Execute a set of batches, with given progress and kwargs. Default |
---|
795 | behavior is to simply execute each batch in succession. Any batches |
---|
796 | which are already executed are skipped. |
---|
797 | |
---|
798 | Handlers may have to override this method if "grouping" or other |
---|
799 | special behavior is needed. |
---|
800 | """ |
---|
801 | now = make_utc() |
---|
802 | for batch in batches: |
---|
803 | if not batch.executed: |
---|
804 | self.execute(batch, progress=progress, **kwargs) |
---|
805 | batch.executed = now |
---|
806 | batch.executed_by = kwargs['user'] |
---|
807 | return True |
---|
808 | |
---|
809 | def do_delete(self, batch, dry_run=False, progress=None, **kwargs): |
---|
810 | """ |
---|
811 | Totally delete the given batch. This includes deleting the batch |
---|
812 | itself, any rows and "extra" data such as files. |
---|
813 | |
---|
814 | Note that callers *should* use this method, but custom batch handlers |
---|
815 | should *not* override this method. Conversely, custom handlers |
---|
816 | *should* override the :meth:`~delete()` method, but callers should |
---|
817 | *not* use that one directly. |
---|
818 | """ |
---|
819 | session = orm.object_session(batch) |
---|
820 | |
---|
821 | if 'delete_all_data' in kwargs: |
---|
822 | warnings.warn("The 'delete_all_data' kwarg is not supported for " |
---|
823 | "this method; please use 'dry_run' instead", |
---|
824 | DeprecationWarning) |
---|
825 | kwargs['delete_all_data'] = not dry_run |
---|
826 | |
---|
827 | self.delete(batch, progress=progress, **kwargs) |
---|
828 | session.delete(batch) |
---|
829 | |
---|
830 | def delete(self, batch, delete_all_data=True, progress=None, **kwargs): |
---|
831 | """ |
---|
832 | Delete all data for the batch, including any related (e.g. row) |
---|
833 | records, as well as files on disk etc. This method should *not* delete |
---|
834 | the batch itself however. |
---|
835 | |
---|
836 | Note that callers should *not* use this method, but custom batch |
---|
837 | handlers *should* override this method. Conversely, custom handlers |
---|
838 | should *not* override the :meth:`~do_delete()` method, but callers |
---|
839 | *should* use that one directly. |
---|
840 | |
---|
841 | :param delete_all_data: Flag indicating whether *all* data should be |
---|
842 | deleted. You should probably set this to ``False`` if in dry-run |
---|
843 | mode, since deleting *all* data often implies deleting files from |
---|
844 | disk, which is not transactional and therefore can't be rolled back. |
---|
845 | """ |
---|
846 | if delete_all_data: |
---|
847 | self.delete_extra_data(batch, progress=progress) |
---|
848 | |
---|
849 | # delete all rows from batch, one by one. maybe would be nicer if we |
---|
850 | # could delete all in one fell swoop, but sometimes "extension" row |
---|
851 | # records might exist, and can get FK constraint errors |
---|
852 | # TODO: in other words i don't even know why this is necessary. seems |
---|
853 | # to me that one fell swoop should not incur FK errors |
---|
854 | if hasattr(batch, 'data_rows'): |
---|
855 | session = orm.object_session(batch) |
---|
856 | |
---|
857 | def delete(row, i): |
---|
858 | session.delete(row) |
---|
859 | if i % 200 == 0: |
---|
860 | session.flush() |
---|
861 | |
---|
862 | self.progress_loop(delete, batch.data_rows, progress, |
---|
863 | message="Deleting rows from batch") |
---|
864 | session.flush() |
---|
865 | |
---|
866 | # even though we just deleted all rows, we must also "remove" all |
---|
867 | # rows explicitly from the batch; otherwise when the batch itself |
---|
868 | # is deleted, SQLAlchemy may complain about an unexpected number of |
---|
869 | # rows being deleted |
---|
870 | del batch.data_rows[:] |
---|
871 | |
---|
872 | def delete_extra_data(self, batch, progress=None, **kwargs): |
---|
873 | """ |
---|
874 | Delete all "extra" data for the batch. This method should *not* bother |
---|
875 | trying to delete the batch itself, or rows thereof. It typically is |
---|
876 | only concerned with deleting extra files on disk, related to the batch. |
---|
877 | """ |
---|
878 | path = self.config.batch_filepath(self.batch_key, batch.uuid) |
---|
879 | if os.path.exists(path): |
---|
880 | shutil.rmtree(path) |
---|
881 | |
---|
882 | def setup_clone(self, oldbatch, progress=None): |
---|
883 | """ |
---|
884 | Perform any setup (caching etc.) necessary for cloning batch. Note |
---|
885 | that the ``oldbatch`` arg is the "old" batch, i.e. the one from which a |
---|
886 | clone is to be created. |
---|
887 | """ |
---|
888 | |
---|
889 | def teardown_clone(self, newbatch, progress=None): |
---|
890 | """ |
---|
891 | Perform any teardown (cleanup etc.) necessary after cloning a batch. |
---|
892 | Note that the ``newbatch`` arg is the "new" batch, i.e. the one which |
---|
893 | was just created by cloning the old batch. |
---|
894 | """ |
---|
895 | |
---|
896 | def clone(self, oldbatch, created_by, progress=None): |
---|
897 | """ |
---|
898 | Clone the given batch as a new batch, and return the new batch. |
---|
899 | """ |
---|
900 | self.setup_clone(oldbatch, progress=progress) |
---|
901 | batch_class = self.batch_model_class |
---|
902 | batch_mapper = orm.class_mapper(batch_class) |
---|
903 | |
---|
904 | newbatch = batch_class() |
---|
905 | newbatch.created_by = created_by |
---|
906 | newbatch.rowcount = 0 |
---|
907 | for name in batch_mapper.columns.keys(): |
---|
908 | if name not in ('uuid', 'id', 'created', 'created_by_uuid', 'rowcount', 'executed', 'executed_by_uuid'): |
---|
909 | setattr(newbatch, name, getattr(oldbatch, name)) |
---|
910 | |
---|
911 | session = orm.object_session(oldbatch) |
---|
912 | session.add(newbatch) |
---|
913 | session.flush() |
---|
914 | |
---|
915 | row_class = newbatch.row_class |
---|
916 | row_mapper = orm.class_mapper(row_class) |
---|
917 | |
---|
918 | def clone_row(oldrow, i): |
---|
919 | newrow = self.clone_row(oldrow) |
---|
920 | self.add_row(newbatch, newrow) |
---|
921 | |
---|
922 | self.progress_loop(clone_row, oldbatch.data_rows, progress, |
---|
923 | message="Cloning data rows for new batch") |
---|
924 | |
---|
925 | self.refresh_batch_status(newbatch) |
---|
926 | self.teardown_clone(newbatch, progress=progress) |
---|
927 | return newbatch |
---|
928 | |
---|
929 | def clone_row(self, oldrow): |
---|
930 | row_class = self.batch_model_class.row_class |
---|
931 | row_mapper = orm.class_mapper(row_class) |
---|
932 | newrow = row_class() |
---|
933 | for name in row_mapper.columns.keys(): |
---|
934 | if name not in ('uuid', 'batch_uuid', 'sequence'): |
---|
935 | setattr(newrow, name, getattr(oldrow, name)) |
---|
936 | return newrow |
---|
937 | |
---|
938 | def cache_model(self, session, model, **kwargs): |
---|
939 | return cache_model(session, model, **kwargs) |
---|
940 | |
---|
941 | |
---|
942 | def get_batch_types(config): |
---|
943 | """ |
---|
944 | Returns the list of available batch type keys. |
---|
945 | """ |
---|
946 | model = config.get_model() |
---|
947 | |
---|
948 | keys = [] |
---|
949 | for name in dir(model): |
---|
950 | if name == 'BatchMixin': |
---|
951 | continue |
---|
952 | obj = getattr(model, name) |
---|
953 | if isinstance(obj, type): |
---|
954 | if issubclass(obj, model.Base): |
---|
955 | if issubclass(obj, model.BatchMixin): |
---|
956 | keys.append(obj.batch_key) |
---|
957 | |
---|
958 | keys.sort() |
---|
959 | return keys |
---|
960 | |
---|
961 | |
---|
962 | def get_batch_handler(config, batch_key, default=None, error=True): # pragma: no cover |
---|
963 | warnings.warn("function is deprecated; please use " |
---|
964 | "`app.get_batch_handler() instead", |
---|
965 | DeprecationWarning) |
---|
966 | app = config.get_app() |
---|
967 | return app.get_batch_handler(batch_key, default=default, error=error) |
---|