Source code for camcops_server.cc_modules.cc_taskfilter

#!/usr/bin/env python
# camcops_server/cc_modules/cc_taskfilter.py

"""
===============================================================================

    Copyright (C) 2012-2018 Rudolf Cardinal (rudolf@pobox.com).

    This file is part of CamCOPS.

    CamCOPS 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.

    CamCOPS 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 CamCOPS. If not, see <http://www.gnu.org/licenses/>.

===============================================================================
"""

from enum import Enum
import logging
from typing import Dict, List, Optional, Type, TYPE_CHECKING

from cardinal_pythonlib.logs import BraceStyleAdapter
from cardinal_pythonlib.reprfunc import auto_repr
from cardinal_pythonlib.sqlalchemy.list_types import (
    IntListType,
    StringListType,
)
from pendulum import Date
from sqlalchemy.orm import reconstructor
from sqlalchemy.sql.functions import func
from sqlalchemy.sql.expression import and_, or_
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import Boolean, Date, Integer
from sqlalchemy.orm import Query

from .cc_cache import cache_region_static, fkg
from .cc_device import Device
from .cc_group import Group
from .cc_patient import Patient
from .cc_patientidnum import PatientIdNum
from .cc_request import CamcopsRequest
from .cc_simpleobjects import IdNumReference
from .cc_sqla_coltypes import (
    PendulumDateTimeAsIsoTextColType,
    IdNumReferenceListColType,
    PatientNameColType,
    SexColType,
)
from .cc_sqlalchemy import Base
from .cc_task import Task
from .cc_user import User

if TYPE_CHECKING:
    from sqlalchemy.sql.elements import ColumnElement

log = BraceStyleAdapter(logging.getLogger(__name__))


# =============================================================================
# Sorting helpers
# =============================================================================

[docs]class TaskClassSortMethod(Enum): NONE = 0 TABLENAME = 1 SHORTNAME = 2 LONGNAME = 3
def sort_task_classes_in_place(classlist: List[Type[Task]], sortmethod: TaskClassSortMethod) -> None: if sortmethod == TaskClassSortMethod.TABLENAME: classlist.sort(key=lambda c: c.tablename) elif sortmethod == TaskClassSortMethod.SHORTNAME: classlist.sort(key=lambda c: c.shortname) elif sortmethod == TaskClassSortMethod.LONGNAME: classlist.sort(key=lambda c: c.longname) # ============================================================================= # Cache task class mapping # ============================================================================= # Function, staticmethod, classmethod? # https://stackoverflow.com/questions/8108688/in-python-when-should-i-use-a-function-instead-of-a-method # noqa # https://stackoverflow.com/questions/11788195/module-function-vs-staticmethod-vs-classmethod-vs-no-decorators-which-idiom-is # noqa # https://stackoverflow.com/questions/15017734/using-static-methods-in-python-best-practice # noqa @cache_region_static.cache_on_arguments(function_key_generator=fkg) def tablename_to_task_class_dict() -> Dict[str, Type[Task]]: d = {} # type: Dict[str, Type[Task]] for cls in Task.gen_all_subclasses(): d[cls.tablename] = cls return d
[docs]def task_classes_from_table_names( tablenames: List[str], sortmethod: TaskClassSortMethod = TaskClassSortMethod.NONE) \ -> List[Type[Task]]: """ May raise KeyError. """ d = tablename_to_task_class_dict() classes = [] # type: List[Type[Task]] for tablename in tablenames: cls = d[tablename] classes.append(cls) sort_task_classes_in_place(classes, sortmethod) return classes
@cache_region_static.cache_on_arguments(function_key_generator=fkg) def all_tracker_task_classes() -> List[Type[Task]]: return [cls for cls in Task.all_subclasses_by_shortname() if cls.provides_trackers] # ============================================================================= # Define a filter to apply to tasks # =============================================================================
[docs]class TaskFilter(Base): __tablename__ = "_task_filters" # Lots of these could be changed into lists; for example, filtering to # multiple devices, multiple users, multiple text patterns. For # AND-joining, there is little clear benefit (one could always AND-join # multiple filters with SQL). For OR-joining, this is more useful. # - surname: use ID numbers instead; not very likely to have >1 surname # - forename: ditto # - DOB: ditto # - sex: just eliminate the filter if you don't care about sex # - task_types: needs a list # - device_id: might as well make it a list # - user_id: might as well make it a list # - group_id: might as well make it a list # - start_datetime: single only # - end_datetime: single only # - text_contents: might as well make it a list # - ID numbers: a list, joined with OR. id = Column( "id", Integer, primary_key=True, autoincrement=True, index=True, comment="Task filter ID (arbitrary integer)" ) # Task type filters task_types = Column( "task_types", StringListType, comment="Task filter: task type(s), as CSV list of table names" ) tasks_offering_trackers_only = Column( "tasks_offering_trackers_only", Boolean, comment="Task filter: restrict to tasks offering trackers only?" ) tasks_with_patient_only = Column( "tasks_with_patient_only", Boolean, comment="Task filter: restrict to tasks with a patient (non-anonymous " "tasks) only?" ) # Patient-related filters surname = Column( "surname", PatientNameColType, comment="Task filter: surname" ) forename = Column( "forename", PatientNameColType, comment="Task filter: forename" ) dob = Column( "dob", Date, comment="Task filter: DOB" ) sex = Column( "sex", SexColType, comment="Task filter: sex" ) idnum_criteria = Column( # new in v2.0.1 "idnum_criteria", IdNumReferenceListColType, comment="ID filters as JSON; the ID number definitions are joined " "with OR" ) # Other filters device_ids = Column( "device_ids", IntListType, comment="Task filter: source device ID(s), as CSV" ) adding_user_ids = Column( "user_ids", IntListType, comment="Task filter: adding (uploading) user ID(s), as CSV" ) group_ids = Column( "group_ids", IntListType, comment="Task filter: group ID(s), as CSV" ) start_datetime = Column( "start_datetime_iso8601", PendulumDateTimeAsIsoTextColType, comment="Task filter: start date/time (UTC as ISO8601)" ) end_datetime = Column( "end_datetime_iso8601", PendulumDateTimeAsIsoTextColType, comment="Task filter: end date/time (UTC as ISO8601)" ) text_contents = Column( "text_contents", StringListType, comment="Task filter: filter text fields" ) # Implemented on the Python side complete_only = Column( "complete_only", Boolean, comment="Task filter: task complete?" ) def __init__(self) -> None: # We need to initialize these explicitly, because if we create an # instance via "x = TaskFilter()", they will be initialized to None, # without any recourse to our database to-and-fro conversion code for # each fieldtype. # (If we load from a database, things will be fine.) self.idnum_criteria = [] # type: List[IdNumReference] self.device_ids = [] # type: List[int] self.adding_user_ids = [] # type: List[int] self.group_ids = [] # type: List[int] self.text_contents = [] # type: List[str] # ANYTHING YOU ADD BELOW HERE MUST ALSO BE IN init_on_load(). # Or call it, of course, but we like to keep on the happy side of the # PyCharm type checker. # Python-only (non-database) filtering attributes: self.era = None # type: str self.patient_ids = [] # type: List[int] # Other Python-only attributes self.sort_method = TaskClassSortMethod.NONE self._task_classes = None # type: List[Type[Task]] @reconstructor def init_on_load(self): self.era = None # type: str self.patient_ids = [] # type: List[int] self.sort_method = TaskClassSortMethod.NONE self._task_classes = None # type: List[Type[Task]] def __repr__(self) -> str: return auto_repr(self, with_addr=True) def set_sort_method(self, sort_method: TaskClassSortMethod) -> None: self.sort_method = sort_method @property def task_classes(self) -> List[Type[Task]]: # Cached, since the filter will be called repeatedly if self._task_classes is None: self._task_classes = [] # type: List[Type[Task]] if self.task_types: starting_classes = task_classes_from_table_names( self.task_types) else: starting_classes = Task.all_subclasses_by_shortname() for cls in starting_classes: if (self.tasks_offering_trackers_only and not cls.provides_trackers): continue if self.tasks_with_patient_only and not cls.has_patient: continue self._task_classes.append(cls) sort_task_classes_in_place(self._task_classes, self.sort_method) return self._task_classes @property def task_tablename_list(self) -> List[str]: return [cls.__tablename__ for cls in self.task_classes]
[docs] def any_patient_filtering(self) -> bool: """Is there some sort of patient filtering being applied?""" return ( bool(self.surname) or bool(self.forename) or (self.dob is not None) or bool(self.sex) or bool(self.idnum_criteria) or bool(self.patient_ids) )
[docs] def any_specific_patient_filtering(self) -> bool: """Are there filters that would restrict to one or a few patients?""" # differs from any_patient_filtering w.r.t. sex return ( bool(self.surname) or bool(self.forename) or self.dob is not None or bool(self.idnum_criteria) or bool(self.patient_ids) )
def get_only_iddef(self) -> Optional[IdNumReference]: if len(self.idnum_criteria) != 1: return None return self.idnum_criteria[0] def get_group_names(self, req: CamcopsRequest) -> List[str]: names = [] # type: List[str] dbsession = req.dbsession for group_id in self.group_ids: group = dbsession.query(Group).filter(Group.id == group_id).first() names.append(group.name if group and group.name else "") return names def get_user_names(self, req: CamcopsRequest) -> List[str]: names = [] # type: List[str] dbsession = req.dbsession for user_id in self.adding_user_ids: user = dbsession.query(User).filter(User.id == user_id).first() names.append(user.username if user and user.username else "") return names def get_device_names(self, req: CamcopsRequest) -> List[str]: names = [] # type: List[str] dbsession = req.dbsession for dev_id in self.device_ids: dev = dbsession.query(Device).filter(Device.id == dev_id).first() names.append(dev.name if dev and dev.name else "") return names def task_query_restricted_by_filter(self, req: CamcopsRequest, q: Query, cls: Type[Task]) -> Optional[Query]: user = req.user if self.group_ids: permitted_group_ids = self.group_ids.copy() else: permitted_group_ids = None # unrestricted if (self.start_datetime and self.end_datetime and self.end_datetime < self.start_datetime): # Inconsistent return None if cls not in self.task_classes: # We don't want this task return None if cls.is_anonymous: if self.any_patient_filtering(): # If we're restricting by patient in any way, we don't # want this task class at all. return None else: # Not anonymous. if not self.any_specific_patient_filtering(): if user.may_view_all_patients_when_unfiltered: pass elif user.may_view_no_patients_when_unfiltered: # (a) User not permitted to view any patients when # unfiltered. (b) Not filtered to a level that would # reasonably restrict to one or a small number of # patients. Skip the task class. return None else: liberal_group_ids = user.group_ids_that_nonsuperuser_may_see_when_unfiltered() # noqa if not permitted_group_ids: # was unrestricted permitted_group_ids = liberal_group_ids else: # was restricted; restrict further permitted_group_ids = [ gid for gid in permitted_group_ids if gid in liberal_group_ids ] if not permitted_group_ids: return None # down to zero; no point continuing # Patient filtering if self.any_patient_filtering(): # q = q.join(Patient) # fails q = q.join(cls.patient) # use explicitly configured relationship # noqa if self.surname: q = q.filter(func.upper(Patient.surname) == self.surname.upper()) if self.forename: q = q.filter(func.upper(Patient.forename) == self.forename.upper()) if self.dob is not None: q = q.filter(Patient.dob == self.dob) if self.sex: q = q.filter(func.upper(Patient.sex) == self.sex.upper()) if self.idnum_criteria: # q = q.join(PatientIdNum) # fails q = q.join(Patient.idnums) id_filter_parts = [] # type: List[ColumnElement] for iddef in self.idnum_criteria: id_filter_parts.append( and_( PatientIdNum.which_idnum == iddef.which_idnum, PatientIdNum.idnum_value == iddef.idnum_value ) ) # Use OR (disjunction) of the specified values: q = q.filter(or_(*id_filter_parts)) if self.patient_ids: q = q.filter(cls.patient_id.in_(self.patient_ids)) # Patient-independent filtering if self.device_ids: # noinspection PyProtectedMember q = q.filter(cls._device_id.in_(self.device_ids)) if self.era: # noinspection PyProtectedMember q = q.filter(cls._era == self.era) if self.adding_user_ids: # noinspection PyProtectedMember q = q.filter(cls._adding_user_id.in_(self.adding_user_ids)) if permitted_group_ids: # noinspection PyProtectedMember q = q.filter(cls._group_id.in_(permitted_group_ids)) if self.start_datetime is not None: q = q.filter(cls.when_created >= self.start_datetime) if self.end_datetime is not None: q = q.filter(cls.when_created <= self.end_datetime) if self.text_contents: textcols = [col for _, col in cls.gen_text_filter_columns()] if not textcols: # Text filtering requested, but there are no text columns, so # by definition the filter must fail. return None clauses_over_text_phrases = [] # type: List[ColumnElement] for textfilter in self.text_contents: tf_lower = textfilter.lower() clauses_over_columns = [] # type: List[ColumnElement] for textcol in textcols: # Case-insensitive comparison: # https://groups.google.com/forum/#!topic/sqlalchemy/331XoToT4lk # https://bitbucket.org/zzzeek/sqlalchemy/wiki/UsageRecipes/StringComparisonFilter # noqa clauses_over_columns.append( func.lower(textcol).contains(tf_lower, autoescape='/') ) clauses_over_text_phrases.append( or_(*clauses_over_columns) ) q = q.filter(and_(*clauses_over_text_phrases)) return q def has_python_parts_to_filter(self) -> bool: return self.complete_only def task_matches_python_parts_of_filter(self, task: Task) -> bool: # "Is task complete" filter if self.complete_only: if not task.is_complete(): return False return True def clear(self) -> None: self.task_types = [] # type: List[str] self.surname = None self.forename = None self.dob = None self.sex = None self.idnum_criteria = [] # type: List[IdNumReference] self.device_ids = [] # type: List[int] self.adding_user_ids = [] # type: List[int] self.group_ids = [] # type: List[int] self.start_datetime = None self.end_datetime = None self.text_contents = [] # type: List[str] self.complete_only = None