Source code for nexusLIMS.db.session_handler

#  NIST Public License - 2020
#
#  This software was developed by employees of the National Institute of
#  Standards and Technology (NIST), an agency of the Federal Government
#  and is being made available as a public service. Pursuant to title 17
#  United States Code Section 105, works of NIST employees are not subject
#  to copyright protection in the United States.  This software may be
#  subject to foreign copyright.  Permission in the United States and in
#  foreign countries, to the extent that NIST may hold copyright, to use,
#  copy, modify, create derivative works, and distribute this software and
#  its documentation without fee is hereby granted on a non-exclusive basis,
#  provided that this notice and disclaimer of warranty appears in all copies.
#
#  THE SOFTWARE IS PROVIDED 'AS IS' WITHOUT ANY WARRANTY OF ANY KIND,
#  EITHER EXPRESSED, IMPLIED, OR STATUTORY, INCLUDING, BUT NOT LIMITED
#  TO, ANY WARRANTY THAT THE SOFTWARE WILL CONFORM TO SPECIFICATIONS, ANY
#  IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
#  AND FREEDOM FROM INFRINGEMENT, AND ANY WARRANTY THAT THE DOCUMENTATION
#  WILL CONFORM TO THE SOFTWARE, OR ANY WARRANTY THAT THE SOFTWARE WILL BE
#  ERROR FREE.  IN NO EVENT SHALL NIST BE LIABLE FOR ANY DAMAGES, INCLUDING,
#  BUT NOT LIMITED TO, DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES,
#  ARISING OUT OF, RESULTING FROM, OR IN ANY WAY CONNECTED WITH THIS SOFTWARE,
#  WHETHER OR NOT BASED UPON WARRANTY, CONTRACT, TORT, OR OTHERWISE, WHETHER
#  OR NOT INJURY WAS SUSTAINED BY PERSONS OR PROPERTY OR OTHERWISE, AND
#  WHETHER OR NOT LOSS WAS SUSTAINED FROM, OR AROSE OUT OF THE RESULTS OF,
#  OR USE OF, THE SOFTWARE OR SERVICES PROVIDED HEREUNDER.
#
"""Classes and methods to interact with sessions from the NexusLIMS database."""
import contextlib
import logging
import os
import sqlite3
from datetime import datetime as dt
from typing import List, Optional, Tuple

from nexusLIMS.instruments import Instrument, instrument_db
from nexusLIMS.utils import current_system_tz

logger = logging.getLogger(__name__)


[docs]def db_query(query, args=None): """Make a query on the NexusLIMS database.""" if args is None: args = () success = False with contextlib.closing( # noqa: SIM117 sqlite3.connect(os.environ["nexusLIMS_db_path"]), ) as conn: with conn: # auto-commits with contextlib.closing(conn.cursor()) as cursor: results = cursor.execute(query, args).fetchall() success = True return success, results
[docs]class SessionLog: """ A log of the start or end of a Session. A simple mapping of one row in the ``session_log`` table of the NexusLIMS database (all values are strings). Parameters ---------- session_identifier A unique string that is consistent among a single record's `"START"`, `"END"`, and `"RECORD_GENERATION"` events (often a UUID, but is not required to be so) instrument The instrument associated with this session (foreign key reference to the ``instruments`` table) timestamp The ISO format timestamp representing the date and time of the logged event event_type The type of log for this session (either `"START"`, `"END"`, or `"RECORD_GENERATION"`) user The username associated with this session (if known) record_status The status to use for this record (defaults to ``'TO_BE_BUILT'``) """ def __init__( # pylint: disable=too-many-arguments # noqa: PLR0913 self, session_identifier: str, instrument: str, timestamp: str, event_type: str, user: str, record_status: Optional[str] = None, ): self.session_identifier = session_identifier self.instrument = instrument self.timestamp = timestamp self.event_type = event_type self.user = user self.record_status = record_status if record_status else "TO_BE_BUILT" def __repr__(self): """Return custom representation of a SessionLog.""" return ( f"SessionLog (id={self.session_identifier}, " f"instrument={self.instrument}, " f"timestamp={self.timestamp}, " f"event_type={self.event_type}, " f"user={self.user}, " f"record_status={self.record_status})" )
[docs] def insert_log(self) -> bool: """ Insert this log into the NexusLIMS database. Inserts a log into the database with the information contained within this SessionLog's attributes (used primarily for NEMO ``usage_event`` integration). It will check for the presence of a matching record first and warn without inserting anything if it finds one. Returns ------- success : bool Whether or not the session log row was inserted successfully """ query = ( "SELECT * FROM session_log " "WHERE session_identifier=? AND " "instrument=? AND " "timestamp=? AND " "event_type=? AND " "record_status=? AND " "user=?" ) args = ( self.session_identifier, self.instrument, self.timestamp, self.event_type, self.record_status, self.user, ) success, results = db_query(query, args) if results: # There was a matching record in the DB, so warn and return success logger.warning( "SessionLog already existed in DB, so no row was added: %s", self, ) return success query = ( "INSERT INTO session_log(session_identifier, instrument, " "timestamp, event_type, record_status, user) VALUES " "(?, ?, ?, ?, ?, ?)" ) success, results = db_query(query, args) return success
[docs]class Session: """ A representation of a session in the NexusLIMS database. A record of an individual session as read from the Nexus Microscopy facility session database. Created by combining two :py:class:`~nexusLIMS.db.session_handler.SessionLog` objects with status ``"TO_BE_BUILT"``. Parameters ---------- session_identifier The unique identifier for an individual session on an instrument instrument An object representing the instrument associated with this session dt_range A tuple of two :py:class:`~datetime.datetime` objects representing the start and end of this session )in that order user : str The username associated with this session (may not be trustworthy) """ def __init__( self, session_identifier: str, instrument: Instrument, dt_range: Tuple[dt, dt], user: str, ): self.session_identifier = session_identifier self.instrument = instrument self.dt_from, self.dt_to = dt_range self.user = user def __repr__(self): """Return custom representation of a Session.""" return ( f"{self.dt_from.isoformat()} to {self.dt_to.isoformat()} on " f"{self.instrument.name}" )
[docs] def update_session_status(self, status): """ Update the status of this Session in the NexusLIMS database. Specifically, update the ``record_status`` in any session logs for this :py:class:`~nexusLIMS.db.session_handler.Session`. Parameters ---------- status : str One of ``"COMPLETED"``, ``"WAITING_FOR_END"``, ``"TO_BE_BUILT"``, ``"ERROR"``, ``"NO_FILES_FOUND"``, ``"NO_CONSENT"``, or ``"NO_RESERVATION"`` (the allowed values in the NexusLIMS database). Status value will be validated by a check constraint in the database Returns ------- success : bool Whether the update operation was successful """ update_query = ( "UPDATE session_log SET record_status = ? WHERE session_identifier = ?" ) success = False # use contextlib to auto-close the connection and database cursors with contextlib.closing( # noqa: SIM117 sqlite3.connect(os.environ["nexusLIMS_db_path"]), ) as conn: with conn: # auto-commits with contextlib.closing(conn.cursor()) as cursor: # auto-closes _ = cursor.execute(update_query, (status, self.session_identifier)) success = True return success
[docs] def insert_record_generation_event(self) -> dict: """ Insert record generation event to session log. Insert a log for this session into the session database with ``event_type`` `"RECORD_GENERATION"` and the current time (with local system timezone) as the timestamp. Returns ------- res : dict A dictionary containing the results from the query to check that a RECORD_GENERATION event was added """ logger.debug("Logging RECORD_GENERATION for %s", self.session_identifier) insert_query = ( "INSERT INTO session_log " "(instrument, event_type, session_identifier, " "user, timestamp) VALUES (?, ?, ?, ?, ?)" ) args = ( self.instrument.name, "RECORD_GENERATION", self.session_identifier, os.getenv("nexusLIMS_user"), dt.now(tz=current_system_tz()), ) # use contextlib to auto-close the connection and database cursors with contextlib.closing( # noqa: SIM117 sqlite3.connect(os.environ["nexusLIMS_db_path"]), ) as conn: with conn: # auto-commits with contextlib.closing(conn.cursor()) as cursor: # auto-closes results = cursor.execute(insert_query, args) check_query = ( "SELECT id_session_log, event_type, " "session_identifier, timestamp FROM session_log " "WHERE instrument = ?" "AND event_type = ?" "ORDER BY timestamp DESC LIMIT 1" ) args = (self.instrument.name, "RECORD_GENERATION") # use contextlib to auto-close the connection and database cursors with contextlib.closing( sqlite3.connect(os.environ["nexusLIMS_db_path"]), ) as conn: conn.row_factory = sqlite3.Row with conn: # auto-commits # noqa: SIM117 with contextlib.closing(conn.cursor()) as cursor: # auto-closes results = cursor.execute(check_query, args) res = results.fetchone() event_match = res["event_type"] == "RECORD_GENERATION" id_match = res["session_identifier"] == self.session_identifier if event_match and id_match: logger.debug( "Confirmed RECORD_GENERATION insertion for %s", self.session_identifier, ) return dict(res)
[docs]def get_sessions_to_build() -> List[Session]: """ Get list of sessions that need to be built from the NexusLIMS database. Query the NexusLIMS database for pairs of logs with status ``'TO_BE_BUILT'`` and return the information needed to build a record for that session. Returns ------- sessions : List[Session] A list of :py:class:`~nexusLIMS.db.session_handler.Session` objects containing the sessions that the need their record built. Will be an empty list if there's nothing to do. """ sessions = [] query_string = ( "SELECT session_identifier, instrument, timestamp, " "event_type, user " "FROM session_log WHERE record_status == 'TO_BE_BUILT'" ) # use contextlib to auto-close the connection and database cursors with contextlib.closing( # noqa: SIM117 sqlite3.connect(os.environ["nexusLIMS_db_path"]), ) as conn: with conn: # auto-commits with contextlib.closing(conn.cursor()) as cursor: # auto-closes results = cursor.execute(query_string).fetchall() session_logs = [SessionLog(*i) for i in results] start_logs = [sl for sl in session_logs if sl.event_type == "START"] end_logs = [sl for sl in session_logs if sl.event_type == "END"] for start_l in start_logs: # for every log that has a 'START', there should be one corresponding # log with 'END' that has the same session identifier. If not, # the database is in an inconsistent state and we should know about it el_list = [ el for el in end_logs if el.session_identifier == start_l.session_identifier ] if len(el_list) != 1: msg = ( "There was not exactly one 'END' log for this 'START' log; " f"len(el_list) was {len(el_list)}; sl was {start_l}; el_list " f"was {el_list}" ) raise ValueError(msg) end_l = el_list[0] dt_from = dt.fromisoformat(start_l.timestamp) dt_to = dt.fromisoformat(end_l.timestamp) session = Session( session_identifier=start_l.session_identifier, instrument=instrument_db[start_l.instrument], dt_range=(dt_from, dt_to), user=start_l.user, ) sessions.append(session) logger.info("Found %i new sessions to build", len(sessions)) return sessions