# -*- coding: utf-8; -*-
################################################################################
#
# WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023-2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Batch Handlers
"""
import datetime
import os
import shutil
from wuttjamaican.app import GenericHandler
[docs]
class BatchHandler(GenericHandler):
"""
Base class and *partial* default implementation for :term:`batch
handlers <batch handler>`.
This handler class "works as-is" but does not actually do
anything. Subclass must implement logic for various things as
needed, e.g.:
* :attr:`model_class`
* :meth:`init_batch()`
* :meth:`should_populate()`
* :meth:`populate()`
* :meth:`refresh_row()`
"""
@property
def model_class(self):
"""
Reference to the batch :term:`data model` class which this
batch handler is meant to work with.
This is expected to be a subclass of
:class:`~wuttjamaican.db.model.batch.BatchMixin` (among other
classes).
Subclass must define this; default is not implemented.
"""
raise NotImplementedError("You must set the 'model_class' attribute "
f"for class '{self.__class__.__name__}'")
@property
def batch_type(self):
"""
Convenience property to return the :term:`batch type` which
the current handler is meant to process.
This is effectively an alias to
:attr:`~wuttjamaican.db.model.batch.BatchMixin.batch_type`.
"""
return self.model_class.batch_type
[docs]
def make_batch(self, session, progress=None, **kwargs):
"""
Make and return a new batch (:attr:`model_class`) instance.
This will create the new batch, and auto-assign its
:attr:`~wuttjamaican.db.model.batch.BatchMixin.id` value
(unless caller specifies it) by calling
:meth:`consume_batch_id()`.
It then will call :meth:`init_batch()` to perform any custom
initialization needed.
Therefore callers should use this ``make_batch()`` method, but
subclass should override :meth:`init_batch()` instead (if
needed).
:param session: Current :term:`db session`.
:param progress: Optional progress indicator factory.
:param \**kwargs: Additional kwargs to pass to the batch
constructor.
:returns: New batch; instance of :attr:`model_class`.
"""
# generate new ID unless caller specifies
if 'id' not in kwargs:
kwargs['id'] = self.consume_batch_id(session)
# make batch
batch = self.model_class(**kwargs)
self.init_batch(batch, session=session, progress=progress, **kwargs)
return batch
[docs]
def consume_batch_id(self, session, as_str=False):
"""
Fetch a new batch ID from the counter, and return it.
This may be called automatically from :meth:`make_batch()`.
:param session: Current :term:`db session`.
:param as_str: Indicates the return value should be a string
instead of integer.
:returns: Batch ID as integer, or zero-padded 8-char string.
"""
db = self.app.get_db_handler()
batch_id = db.next_counter_value(session, 'batch_id')
if as_str:
return f'{batch_id:08d}'
return batch_id
[docs]
def init_batch(self, batch, session=None, progress=None, **kwargs):
"""
Initialize a new batch.
This is called automatically from :meth:`make_batch()`.
Default logic does nothing; subclass should override if needed.
.. note::
*Population* of the new batch should **not** happen here;
see instead :meth:`populate()`.
"""
[docs]
def get_data_path(self, batch=None, filename=None, makedirs=False):
"""
Returns a path to batch data file(s).
This can be used to return any of the following, depending on
how it's called:
* path to root data dir for handler's :attr:`batch_type`
* path to data dir for specific batch
* path to specific filename, for specific batch
For instance::
# nb. assuming batch_type = 'inventory'
batch = handler.make_batch(session, created_by=user)
handler.get_data_path()
# => env/app/data/batch/inventory
handler.get_data_path(batch)
# => env/app/data/batch/inventory/03/7721fe56c811ef9223743af49773a4
handler.get_data_path(batch, 'counts.csv')
# => env/app/data/batch/inventory/03/7721fe56c811ef9223743af49773a4/counts.csv
:param batch: Optional batch instance. If specified, will
return path for this batch in particular. Otherwise will
return the "generic" path for handler's batch type.
:param filename: Optional filename, in context of the batch.
If set, the returned path will include this filename. Only
relevant if ``batch`` is also specified.
:param makedirs: Whether the folder(s) should be created, if
not already present.
:returns: Path to root data dir for handler's batch type.
"""
# get root storage path
rootdir = self.config.get(f'{self.config.appname}.batch.storage_path')
if not rootdir:
appdir = self.app.get_appdir()
rootdir = os.path.join(appdir, 'data', 'batch')
# get path for this batch type
path = os.path.join(rootdir, self.batch_type)
# give more precise path, if batch was specified
if batch:
uuid = batch.uuid.hex
# nb. we use *last 2 chars* for first part of batch uuid
# path. this is because uuid7 is mostly sequential, so
# first 2 chars do not vary enough.
path = os.path.join(path, uuid[-2:], uuid[:-2])
# maybe create data dir
if makedirs and not os.path.exists(path):
os.makedirs(path)
# append filename if applicable
if batch and filename:
path = os.path.join(path, filename)
return path
[docs]
def should_populate(self, batch):
"""
Must return true or false, indicating whether the given batch
should be populated from initial data source(s).
So, true means fill the batch with data up front - by calling
:meth:`do_populate()` - and false means the batch will start
empty.
Default logic here always return false; subclass should
override if needed.
"""
return False
[docs]
def do_populate(self, batch, progress=None):
"""
Populate the batch from initial data source(s).
This method is a convenience wrapper, which ultimately will
call :meth:`populate()` for the implementation logic.
Therefore callers should use this ``do_populate()`` method,
but subclass should override :meth:`populate()` instead (if
needed).
See also :meth:`should_populate()` - you should check that
before calling ``do_populate()``.
"""
self.populate(batch, progress=progress)
[docs]
def populate(self, batch, progress=None):
"""
Populate the batch from initial data source(s).
It is assumed that the data source(s) to be used will be known
by inspecting various properties of the batch itself.
Subclass should override this method to provide the
implementation logic. It may populate some batches
differently based on the batch attributes, or it may populate
them all the same. Whatever is needed.
Callers should always use :meth:`do_populate()` instead of
calling ``populate()`` directly.
"""
[docs]
def make_row(self, **kwargs):
"""
Make a new row for the batch. This will be an instance of
:attr:`~wuttjamaican.db.model.batch.BatchMixin.__row_class__`.
Note that the row will **not** be added to the batch; that
should be done with :meth:`add_row()`.
:returns: A new row object, which does *not* yet belong to any batch.
"""
return self.model_class.__row_class__(**kwargs)
[docs]
def add_row(self, batch, row):
"""
Add the given row to the given batch.
This assumes a *new* row which does not yet belong to a batch,
as returned by :meth:`make_row()`.
It will add it to batch
:attr:`~wuttjamaican.db.model.batch.BatchMixin.rows`, call
:meth:`refresh_row()` for it, and update the
:attr:`~wuttjamaican.db.model.batch.BatchMixin.row_count`.
"""
session = self.app.get_session(batch)
with session.no_autoflush:
batch.rows.append(row)
self.refresh_row(row)
batch.row_count = (batch.row_count or 0) + 1
[docs]
def refresh_row(self, row):
"""
Update the given batch row as needed, to reflect latest data.
This method is a bit of a catch-all in that it could be used
to do any of the following (etc.):
* fetch latest "live" data for comparison with batch input data
* (re-)calculate row values based on latest data
* set row status based on other row attributes
This method is called when the row is first added to the batch
via :meth:`add_row()` - but may be called multiple times after
that depending on the workflow.
"""
[docs]
def do_remove_row(self, row):
"""
Remove a row from its batch. This will:
* call :meth:`remove_row()`
* decrement the batch
:attr:`~wuttjamaican.db.model.batch.BatchMixin.row_count`
* call :meth:`refresh_batch_status()`
So, callers should use ``do_remove_row()``, but subclass
should (usually) override :meth:`remove_row()` etc.
"""
batch = row.batch
session = self.app.get_session(batch)
self.remove_row(row)
if batch.row_count is not None:
batch.row_count -= 1
self.refresh_batch_status(batch)
session.flush()
[docs]
def remove_row(self, row):
"""
Remove a row from its batch.
Callers should use :meth:`do_remove_row()` instead, which
calls this method automatically.
Subclass can override this method; the default logic just
deletes the row.
"""
session = self.app.get_session(row)
batch = row.batch
batch.rows.remove(row)
session.delete(row)
[docs]
def refresh_batch_status(self, batch):
"""
Update the batch status as needed.
This method is called when some row data has changed for the
batch, e.g. from :meth:`do_remove_row()`.
It does nothing by default; subclass may override to set these
attributes on the batch:
* :attr:`~wuttjamaican.db.model.batch.BatchMixin.status_code`
* :attr:`~wuttjamaican.db.model.batch.BatchMixin.status_text`
"""
[docs]
def why_not_execute(self, batch, user=None, **kwargs):
"""
Returns text indicating the reason (if any) that a given batch
should *not* be executed.
By default the only reason a batch cannot be executed, is if
it has already been executed. But in some cases it should be
more restrictive; hence this method.
A "brief but descriptive" message should be returned, which
may be displayed to the user e.g. so they understand why the
execute feature is not allowed for the batch. (There is no
need to check if batch is already executed since other logic
handles that.)
If no text is returned, the assumption will be made that this
batch is safe to execute.
:param batch: The batch in question; potentially eligible for
execution.
:param user: :class:`~wuttjamaican.db.model.auth.User` who
might choose to execute the batch.
:param \**kwargs: Execution kwargs for the batch, if known.
Should be similar to those for :meth:`execute()`.
:returns: Text reason to prevent execution, or ``None``.
The user interface should normally check this and if it
returns anything, that should be shown and the user should be
prevented from executing the batch.
However :meth:`do_execute()` will also call this method, and
raise a ``RuntimeError`` if text was returned. This is done
out of safety, to avoid relying on the user interface.
"""
[docs]
def describe_execution(self, batch, user=None, **kwargs):
"""
This should return some text which briefly describes what will
happen when the given batch is executed.
Note that Markdown is supported here, e.g.::
def describe_execution(self, batch, **kwargs):
return \"""
This batch does some crazy things!
**you cannot possibly fathom it**
here are a few of them:
- first
- second
- third
\"""
Nothing is returned by default; subclass should define.
:param batch: The batch in question; eligible for execution.
:param user: Reference to current user who might choose to
execute the batch.
:param \**kwargs: Execution kwargs for the batch; should be
similar to those for :meth:`execute()`.
:returns: Markdown text describing batch execution.
"""
[docs]
def get_effective_rows(self, batch):
"""
This should return a list of "effective" rows for the batch.
In other words, which rows should be "acted upon" when the
batch is executed.
The default logic returns the full list of batch
:attr:`~wuttjamaican.db.model.batch.BatchMixin.rows`, but
subclass may need to filter by status code etc.
"""
return batch.rows
[docs]
def do_execute(self, batch, user, progress=None, **kwargs):
"""
Perform the execution steps for a batch.
This first calls :meth:`why_not_execute()` to make sure this
is even allowed.
If so, it calls :meth:`execute()` and then updates
:attr:`~wuttjamaican.db.model.batch.BatchMixin.executed` and
:attr:`~wuttjamaican.db.model.batch.BatchMixin.executed_by` on
the batch, to reflect current time+user.
So, callers should use ``do_execute()``, and subclass should
override :meth:`execute()`.
:param batch: The :term:`batch` to execute; instance of
:class:`~wuttjamaican.db.model.batch.BatchMixin` (among
other classes).
:param user: :class:`~wuttjamaican.db.model.auth.User` who is
executing the batch.
:param progress: Optional progress indicator factory.
:param \**kwargs: Additional kwargs as needed. These are
passed as-is to :meth:`why_not_execute()` and
:meth:`execute()`.
:returns: Whatever was returned from :meth:`execute()` - often
``None``.
"""
if batch.executed:
raise ValueError(f"batch has already been executed: {batch}")
reason = self.why_not_execute(batch, user=user, **kwargs)
if reason:
raise RuntimeError(f"batch execution not allowed: {reason}")
result = self.execute(batch, user=user, progress=progress, **kwargs)
batch.executed = datetime.datetime.now()
batch.executed_by = user
return result
[docs]
def execute(self, batch, user=None, progress=None, **kwargs):
"""
Execute the given batch.
Callers should use :meth:`do_execute()` instead, which calls
this method automatically.
This does nothing by default; subclass must define logic.
:param batch: A :term:`batch`; instance of
:class:`~wuttjamaican.db.model.batch.BatchMixin` (among
other classes).
:param user: :class:`~wuttjamaican.db.model.auth.User` who is
executing the batch.
:param progress: Optional progress indicator factory.
:param \**kwargs: Additional kwargs which may affect the batch
execution behavior. There are none by default, but some
handlers may declare/use them.
:returns: ``None`` by default, but subclass can return
whatever it likes, in which case that will be also returned
to the caller from :meth:`do_execute()`.
"""
[docs]
def do_delete(self, batch, user, dry_run=False, progress=None, **kwargs):
"""
Delete the given batch entirely.
This will delete the batch proper, all data rows, and any
files which may be associated with it.
"""
session = self.app.get_session(batch)
# remove data files
path = self.get_data_path(batch)
if os.path.exists(path) and not dry_run:
shutil.rmtree(path)
# remove batch proper
session.delete(batch)