source: rattail/rattail/batch/handlers.py @ 72d1b07

Last change on this file since 72d1b07 was 72d1b07, checked in by Lance Edgar <lance@…>, 8 months ago

Add basic start date support for "future" pricing batch

  • Property mode set to 100644
File size: 37.5 KB
Line 
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"""
24Data Batch Handlers
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import os
30import shutil
31import datetime
32import warnings
33import logging
34
35import json
36import six
37from sqlalchemy import orm
38
39from rattail.db import model, api
40from rattail.core import Object
41from rattail.gpc import GPC
42from rattail.barcodes import upce_to_upca
43from rattail.db.cache import cache_model
44from rattail.time import localtime, make_utc
45from rattail.util import progress_loop, load_object
46
47
48log = logging.getLogger(__name__)
49
50
51class 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
942def 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
962def 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)
Note: See TracBrowser for help on using the repository browser.