"""Model and functions to work with task state data.""" import datetime from oto import response from oto import status as response_status from sqlalchemy import and_ from sqlalchemy import Column from sqlalchemy import DateTime from sqlalchemy import Enum from sqlalchemy import ForeignKey from sqlalchemy.dialects import mysql from sqlalchemy.ext.hybrid import hybrid_property from availability import config from availability.connectors import sql from availability.constants import error from availability.constants import field_const from availability.constants import models from availability.models import error_handlers from availability.models import product_in_store from availability.models import store STATUS_TIMEOUT_MAPPING = { models.TASK_STATUS_PROCESSING: config.TASK_PROCESSING_THRESHOLD_MINUTES, models.TASK_STATUS_IN_QUEUE: config.TASK_IN_QUEUE_THRESHOLD_MINUTES, } class Task(sql.BaseModel): """Class representing the task table. A task is used for saving a specific product polling status. This table stores FK to the product_in_store that is being polled, last change date of the task and its status. """ __tablename__ = models.TASK_TABLE task_id = Column( 'id', mysql.INTEGER(unsigned=True), primary_key=True, autoincrement=True) product_in_store_id = Column( mysql.INTEGER(unsigned=True), ForeignKey('product_in_store.id'), nullable=False) status = Column( Enum(*models.TASK_STATUSES_ENUM), default=models.TASK_STATUS_IN_QUEUE) _last_change_date = Column( 'last_change_date', DateTime, nullable=False, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow) @hybrid_property def last_change_date(self): """Getter for private attribute _last_change_date.""" return self._last_change_date def as_dict(self): """Return dictionary representation of particular instance of Task. Returns: dict: task fields. """ task_data = { field_const.TASK_ID: self.task_id, field_const.PRODUCT_IN_STORE_ID: self.product_in_store_id, field_const.STATUS: self.status, field_const.LAST_CHANGE_DATE: self.last_change_date } return task_data @error_handlers.sqlalchemy_error_handler @error_handlers.integrity_error_handler( error_code=error.ERROR_CODE_MODEL_VALIDATION) def create_task(*, product_in_store_id, status): """Create a new record in the task table. Args: product_in_store_id (int): ProductInStore PK. status (int): Task internal status, must be one of models.TASK_STATUSES_ENUM. Returns: response.Response: .message with Task.as_dict() on success, .errors on failure. """ task_obj = Task(product_in_store_id=product_in_store_id, status=status) with sql.session_scope() as session: session.add(task_obj) return response.Response(task_obj.as_dict()) @error_handlers.sqlalchemy_error_handler def change_status(product_in_store_id, status): """Change status for latest task with given product_in_store_id. Args: product_in_store_id (int): ProductInStore PK. status (str): Task internal status, must be one of models.TASK_STATUSES_ENUM. Returns: response.Response: empty body on success, .errors on failure. """ with sql.session_scope() as session: task = ( session.query(Task).with_for_update() .filter_by(product_in_store_id=product_in_store_id) .order_by(Task.last_change_date.desc()).first()) if not task: return response.create_not_found_response( error.ERROR_TASK_NOT_FOUND) task.status = status session.add(task) return response.Response(status=response_status.NO_CONTENT) @error_handlers.sqlalchemy_error_handler def get_stuck_tasks(*, task_status, store_id=None): """Get Tasks that are stuck in a certain state. Get all tasks for the given store that are in the given state for more than configured threshold. Args: task_status (str): one of constants.models.TASK_STATUSES_ENUM. store_id (int): Internal ID of the store. Returns: response.Response: message with a list of tasks as SQLAlchemy result rows with the following attributes: task_id, product_in_store_id. """ threshold = STATUS_TIMEOUT_MAPPING[task_status] now = datetime.datetime.utcnow() latest_valid_time = now - datetime.timedelta(minutes=threshold) with sql.session_scope() as session: is_stuck = and_( Task.status == task_status, Task.last_change_date <= latest_valid_time ) query = session.query(Task.task_id, Task.product_in_store_id) if store_id: query = query.join(product_in_store.ProductInStore) query = query.join(store.Store) query = query.filter(store.Store.store_id == store_id) query = query.filter(is_stuck) return response.Response(query.all()) @error_handlers.sqlalchemy_error_handler def get_failed_tasks(store_id, **kwargs): """Get Tasks for currently polled products that are in failed state. Any extra kwargs are passed to get_products_to_poll_query(). Args: store_id (int): Internal ID of the store. Returns: response.Response: message with a list of tasks as SQLAlchemy result rows with the following attributes: task_id, product_in_store_id. """ is_failed = (Task.status == models.TASK_STATUS_FAILED) query = product_in_store.get_products_to_poll_query( store_id, any_task_status=True, **kwargs) query = query.filter(is_failed) return response.Response(query.all())