from datetime import datetime, timedelta from dateutil.parser import isoparse import logging from .umbrellas import Umbrellas from .jform import JForm from .exceptions import * from .config import config from .utils import CST """A set of routine methods, run at an interval (somewhere from ten minutes to one hour), to: - sync JImbrella's databse against data pulled from jForm - check if any umbrella is now overdue - send an SMS where applicable, to a user or an admin """ def chronological_merge(*sheet_lists) -> list: """Merges all lists answer sheets passed in, in chronological order, into a single list. All lists of sheets in sheet_lists MUST be already in chronological order. By "chronological", we mean sorting by the value under key "date". """ chronicle = [] while any(sheet_lists): # at least one list in `sheet_lists` is non-empty # for each list of sheets, read date from its first element, then find the earliest # is an instance of datetime.datetime # only pass non-empty lists to min(): earliest_date = min( [sheet_list[0]["date"] for sheet_list in filter(None, sheet_lists)] ) for idx, sheet_list in enumerate(sheet_lists): if not sheet_list: # is empty continue if sheet_list[0]["date"] == earliest_date: # remove the first element and append it to `chronicle`, if it is the earliest chronicle.append(sheet_lists[idx].pop(0)) # we do not directly break here because there exists a tiny chance # two answer sheets were submitted at the exact same millisecond return chronicle def sync_jform(takeaway: JForm, giveback: JForm, db: Umbrellas): takeaway_unread = takeaway.get_unread() giveback_unread = giveback.get_unread() logging.info( "Sync jForm: found %d unread takeaway sheet(s) and %d unread giveback sheet(s)", len(takeaway_unread), len(giveback_unread), ) unread = chronological_merge(takeaway_unread, giveback_unread) # NOTE: beyond this point `takeaway_unread` and `giveback_unread` are empty # because chronological_merge popped all their elements for sheet in unread: sheet["date_str"] = sheet["date"].isoformat(timespec="milliseconds") tenant_identity = "{name} (ID: {id}, phone: {phone})".format(**sheet) if sheet["jform_name"] == "takeaway": try: db.take_away( sheet["key"], sheet["date"], sheet["name"], sheet["id"], sheet["phone"], ) except (UmbrellaStatusError, UmbrellaNotFoundError): logging.warning( tenant_identity + " attempted to borrow umbrella #{key} at {date_str}".format( **sheet ) ) elif sheet["jform_name"] == "giveback": try: db.give_back(sheet["key"], sheet["date"], sheet["name"], sheet["id"]) logging.info( tenant_identity + " returned umbrella #{key} at {date_str}".format(**sheet) ) except (UmbrellaStatusError, UmbrellaNotFoundError): logging.warning( tenant_identity + " attempted to return umbrella #{key} at {date_str}".format( **sheet ) ) except TenantIdentityError as e: logging.warning( tenant_identity + " attempted to return umbrella #{key} at {date_str}, expecting tenant {expected}".format( expected=e.expected, **sheet ) ) def process_overdue(db: Umbrellas): """mark and log umbrellas that were not, but just became overdue""" umbrellas = db.read() now = datetime.now().astimezone(CST) for umb in umbrellas: if umb["status"] == "lent": try: lent_at = isoparse(umb["lent_at"]) except ValueError: logging.error( "Invalid lent_at in umbrella #%d: %s", umb["id"], umb["lent_at"] ) continue if now - lent_at > timedelta(hours=config.getint("general", "due_hours")): db.mark_overdue(umb["id"]) logging.warning( "Umbrella #{id} is now overdue, tenant: {tenant_name} (ID: {tenant_id}, phone: {tenant_phone})".format( **umb ) ) if __name__ == "__main__": takeaway = JForm( "takeaway", config.get("jform", "takeaway_url"), config.get("jform", "bookmark_dir"), ) giveback = JForm( "giveback", config.get("jform", "giveback_url"), config.get("jform", "bookmark_dir"), ) db = Umbrellas(config.get("general", "db_path")) sync_jform(takeaway, giveback, db) process_overdue(db)