Exemple #1
0
 def test_only_obs_fields_passed(self):
     fields = [
         nh_obs_fields.Selection(),
         nh_obs_fields.Selection(),
         nh_obs_fields.Selection()
     ]
     obs_fields = self.field_utils.get_obs_fields_from_fields(fields)
     self.assertEqual(3, len(obs_fields))
Exemple #2
0
 def test_obs_field_returns_true(self):
     field = nh_obs_fields.Selection()
     is_obs_field = self.field_utils.is_obs_field(field)
     self.assertTrue(is_obs_field)
class NhClinicalPatientObservationNeurological(models.Model):

    _name = 'nh.clinical.patient.observation.neurological'
    _inherit = 'nh.clinical.patient.observation.gcs'

    _pupil_size_selection = [
        ['8', '8mm'],
        ['7', '7mm'],
        ['6', '6mm'],
        ['5', '5mm'],
        ['4', '4mm'],
        ['3', '3mm'],
        ['2', '2mm'],
        ['1', '1mm'],
        ['NO', 'Not Observable']
    ]
    _pupil_reaction_selection = [
        ('+', '+'),
        ('-', '-'),
        ('NT', 'Not Testable')
    ]
    _limb_movement_selection = [
        ('NP', 'Normal Power'),
        ('MW', 'Mild Weakness'),
        ('SW', 'Severe Weakness'),
        ('SF', 'Spastic Flexion'),
        ('EX', 'Extension'),
        ('NR', 'No Response'),
        ('NO', 'Not Observable')
    ]

    _description = "Neurological"
    # TODO Remove when EOBS-982 complete.
    # Also decides the order fields are displayed in the mobile view.
    _required = [
        'eyes', 'verbal', 'motor', 'pupil_right_size', 'pupil_right_reaction',
        'pupil_left_size', 'pupil_left_reaction',
        'limb_movement_left_arm', 'limb_movement_right_arm',
        'limb_movement_left_leg', 'limb_movement_right_leg'
    ]

    pupil_right_size = obs_fields.Selection(
        _pupil_size_selection, 'Pupil Right - Size')
    pupil_right_reaction = obs_fields.Selection(
        _pupil_reaction_selection, 'Pupil Right - Reaction'
    )
    pupil_left_size = obs_fields.Selection(
        _pupil_size_selection, 'Pupil Left - Size')
    pupil_left_reaction = obs_fields.Selection(
        _pupil_reaction_selection, 'Pupil Left - Reaction'
    )
    limb_movement_left_arm = obs_fields.Selection(
        _limb_movement_selection, 'Limb Movement - Left Arm'
    )
    limb_movement_right_arm = obs_fields.Selection(
        _limb_movement_selection, 'Limb Movement - Right Arm'
    )
    limb_movement_left_leg = obs_fields.Selection(
        _limb_movement_selection, 'Limb Movement - Left Leg'
    )
    limb_movement_right_leg = obs_fields.Selection(
        _limb_movement_selection, 'Limb Movement - Right Leg'
    )

    @api.model
    def get_form_description(self, patient_id):
        """
        Returns a list of dicts that represent the form description used by
        the mobile

        :param patient_id: ID for the patient
        :return: list of dicts
        """
        form_description = super(NhClinicalPatientObservationNeurological,
                                 self).get_form_description(patient_id)
        for item in form_description:
            if item.get('type') == 'meta':
                item['partial_flow'] = 'score'
        return form_description

    @classmethod
    def get_data_visualisation_resource(cls):
        """
        Returns URL of JS file to plot data visualisation so can be loaded on
        mobile and desktop

        :return: URL of JS file to plot graph
        :rtype: str
        """
        return '/nh_neurological/static/src/js/chart.js'

    def get_submission_message(self):
        """
        Override of `nh.clinical.patient.observation` method.

        :return:
        """
        score = self.score
        message = 'The Coma Scale score for this observation is {}'\
            .format(score)
        return message
class nh_clinical_patient_observation_gcs(models.Model):
    """
    Represents an Glasgow Coma Scale
    :class:`observation<observations.nh_clinical_patient_observation>`
    which stores three parameters that are used as a way to communicate
    about the level of consciousness of
    :class:`patients<base.nh_clinical_patient>` with acute brain injury.

    The basis of the scale system are the following parameters:
    Eye response: spontaneous, to sound, to pressure, none.
    Verbal response: orientated, confused, words, sounds, none.
    Motor response: obey commands, localising, normal flexion, abnormal
    flexion, extension, none.
    """
    _name = 'nh.clinical.patient.observation.gcs'
    _inherit = ['nh.clinical.patient.observation_scored']

    # Also decides the order fields are displayed in the mobile view.
    _required = ['eyes', 'verbal', 'motor']
    _scored = ['eyes', 'verbal', 'motor']
    _description = "GCS"
    _eyes_selection = [('SP', 'Spontaneous'), ('TS', 'To Sound'),
                       ('TP', 'To Pressure'), ('NN', 'None'),
                       ('NT', 'Not Testable')]
    _verbal_selection = [('OR', 'Orientated'), ('CO', 'Confused'),
                         ('WO', 'Words'), ('SO', 'Sounds'), ('NN', 'None'),
                         ('NT', 'Not Testable')]
    _motor_selection = [('OC', 'Obey Commands'), ('LO', 'Localising'),
                        ('NF', 'Normal Flexion'), ('AF', 'Abnormal Flexion'),
                        ('EX', 'Extension'), ('NN', 'None'),
                        ('NT', 'Not Testable')]
    """
    Default GCS policy has 5 different scenarios:
        case 0: 30 min frequency
        case 1: 1 hour frequency
        case 2: 2 hour frequency
        case 3: 4 hour frequency
        case 4: 12 hour frequency (no clinical risk)
    """
    _POLICY = {
        'ranges': [5, 9, 13, 14],
        'case': '01234',
        'frequencies': [30, 60, 120, 240, 720],
        'notifications': [[], [], [], [], []]
    }

    eyes = obs_fields.Selection(_eyes_selection, 'Eyes Open', required=True)
    verbal = obs_fields.Selection(_verbal_selection,
                                  'Best Verbal Response',
                                  required=True)
    motor = obs_fields.Selection(_motor_selection,
                                 'Best Motor Response',
                                 required=True)

    @api.model
    def calculate_score(self, obs_data, return_dictionary=True):
        is_dict = isinstance(obs_data, dict)
        obs_data_dict = {
            'eyes': obs_data['eyes'] if is_dict else obs_data.eyes,
            'verbal': obs_data['verbal'] if is_dict else obs_data.verbal,
            'motor': obs_data['motor'] if is_dict else obs_data.motor
        }
        return super(nh_clinical_patient_observation_gcs, self)\
            .calculate_score(obs_data_dict,
                             return_dictionary=return_dictionary)

    def complete(self, cr, uid, activity_id, context=None):
        """
        It determines which acuity case the current observation is in
        with the stored data and responds to the different policy
        triggers accordingly defined on the ``_POLICY`` dictionary.

        :returns: ``True``
        :rtype: bool
        """
        activity_pool = self.pool['nh.activity']
        api_pool = self.pool['nh.clinical.api']
        groups_pool = self.pool['res.groups']
        activity = activity_pool.browse(cr, uid, activity_id, context=context)
        case = int(self._POLICY['case'][bisect.bisect_left(
            self._POLICY['ranges'], activity.data_ref.score)])
        hcagroup_ids = groups_pool.search(
            cr, uid, [('users', 'in', [uid]),
                      ('name', '=', 'NH Clinical HCA Group')])
        nursegroup_ids = groups_pool.search(
            cr, uid, [('users', 'in', [uid]),
                      ('name', '=', 'NH Clinical Nurse Group')])
        group = nursegroup_ids and 'nurse' or hcagroup_ids and 'hca' or False

        # TRIGGER NOTIFICATIONS
        api_pool.trigger_notifications(
            cr,
            uid, {
                'notifications': self._POLICY['notifications'][case],
                'parent_id': activity.parent_id.id,
                'creator_id': activity_id,
                'patient_id': activity.data_ref.patient_id.id,
                'model': self._name,
                'group': group
            },
            context=context)

        return super(nh_clinical_patient_observation_gcs,
                     self).complete(cr, uid, activity_id, context)

    def create_activity(self,
                        cr,
                        uid,
                        vals_activity=None,
                        vals_data=None,
                        context=None):
        """
        When creating a new activity of this type, an exception will be
        raised if the :class:`spell<base.nh_clinical_spell>` already has
        an open GCS.

        :returns: :class:`activity<activity.nh_activity>` id.
        :rtype: int
        """
        if not vals_activity:
            vals_activity = {}
        if not vals_data:
            vals_data = {}
        assert vals_data.get('patient_id'), "patient_id is a required field!"
        activity_pool = self.pool['nh.activity']
        domain = [['patient_id', '=', vals_data['patient_id']],
                  ['data_model', '=', self._name],
                  ['state', 'in', ['new', 'started', 'scheduled']]]
        ids = activity_pool.search(cr, SUPERUSER_ID, domain)
        if len(ids):
            raise osv.except_osv(
                "GCS Create Error!",
                "Having more than one activity of type '%s' "
                "is restricted. Terminate activities with "
                "ids=%s first" % (self._name, str(ids)))
        return super(nh_clinical_patient_observation_gcs,
                     self).create_activity(cr, uid, vals_activity, vals_data,
                                           context)
class NHClinicalFoodAndFluid(models.Model):

    _name = 'nh.clinical.patient.observation.food_fluid'
    _inherit = 'nh.clinical.patient.observation'

    _required = ['passed_urine', 'bowels_open']
    _description = 'Food and Fluid'

    _passed_urine_options = [
        ('measured', 'Yes (Measured)'),
        ('not_measured', 'Yes (Not Measured)'),
        ('no', 'No'),
        ('unknown', 'Unknown')
    ]

    _bowels_open_options = [
        ('no', 'No'),
        ('unknown', 'Unknown'),
        ('type_1', 'Type 1'),
        ('type_2', 'Type 2'),
        ('type_3', 'Type 3'),
        ('type_4', 'Type 4'),
        ('type_5', 'Type 5'),
        ('type_6', 'Type 6'),
        ('type_7', 'Type 7')
    ]

    recorded_concerns = obs_fields.Many2Many(
        comodel_name='nh.clinical.recorded_concern',
        relation="recorded_concern_rel",
        string='Recorded Concern', necessary=False
    )
    dietary_needs = obs_fields.Many2Many(
        comodel_name='nh.clinical.dietary_need',
        relation="dietary_need_rel",
        string='Consider Special Dietary Needs', necessary=False
    )
    fluid_taken = obs_fields.Integer('Fluid Taken (ml) - Include IV / NG',
                                     necessary=False)
    fluid_description = obs_fields.Text('Fluid Description')
    food_taken = obs_fields.Text('Food Taken')
    food_fluid_rejected = obs_fields.Text(
        'Food and Fluid Offered but Rejected', necessary=False
    )
    passed_urine = obs_fields.Selection(_passed_urine_options, 'Passed Urine',
                                        required=True)
    bowels_open = obs_fields.Selection(_bowels_open_options, 'Bowels Open',
                                       required=True)
    fluid_output = obs_fields.Integer('Fluid Output (ml)', necessary=False)

    @api.constrains('fluid_output')
    def _in_min_max_range(self):
        form_description = self.get_form_description(None)
        fluid_output_field = [field for field in form_description
                              if field['name'] == 'fluid_output'][0]
        validate.in_min_max_range(fluid_output_field['min'],
                                  fluid_output_field['max'],
                                  self.fluid_output)

    @classmethod
    def get_description(cls, append_observation=True):
        description = super(NHClinicalFoodAndFluid, cls).get_description(
            append_observation=append_observation
        )
        if not append_observation:
            description = "Daily {}".format(description)
        return description

    @classmethod
    def get_data_visualisation_resource(cls):
        """
        Returns URL of JS file to plot data visualisation so can be loaded on
        mobile and desktop

        :return: URL of JS file to plot graph
        :rtype: str
        """
        return '/nh_food_and_fluid/static/src/js/chart.js'

    @api.model
    def get_form_description(self, patient_id):
        """
        Returns a description in dictionary format of the input fields
        that would be required in the user gui to submit this
        observation.

        Adds the lists of recorded concerns and dietary needs to the
        form description as these are stored in separate models to allow
        for multi select

        :param patient_id: :class:`patient<base.nh_clinical_patient>` id
        :type patient_id: int
        :returns: a list of dictionaries
        :rtype: list
        """
        form_desc = copy.deepcopy(self._form_description)
        recorded_concern_model = self.env['nh.clinical.recorded_concern']
        dietary_need_model = self.env['nh.clinical.dietary_need']
        recorded_concerns = recorded_concern_model.search([])
        dietary_needs = dietary_need_model.search([])
        form_desc[0]['selection'] = \
            [(rec.id, rec.name) for rec in recorded_concerns]
        form_desc[1]['selection'] = \
            [(rec.id, rec.name) for rec in dietary_needs]
        return form_desc

    _form_description = [
        {
            'name': 'recorded_concerns',
            'type': 'multiselect',
            'label': 'Recorded Concern',
            'selection': [],
            'initially_hidden': False,
            'necessary': 'false'
        },
        {
            'name': 'dietary_needs',
            'type': 'multiselect',
            'label': 'Consider Special Dietary Needs',
            'selection': [],
            'initially_hidden': False,
            'necessary': 'false'
        },
        {
            'name': 'fluid_taken',
            'type': 'integer',
            'min': 1,
            'max': 5000,
            'label': 'Fluid Taken (ml) - Include IV / NG',
            'initially_hidden': False,
            'reference': {
                'type': 'iframe',
                'url': '/nh_food_and_fluid/static/src/html/fluid_taken.html',
                'title': 'Fluid Taken Guidance',
                'label': 'Fluid Taken Guidance'
            },
            'necessary': 'false'
        },
        {
            'name': 'fluid_description',
            'type': 'text',
            'label': 'Fluid Description',
            'initially_hidden': False,
            'necessary': 'false'
        },
        {
            'name': 'food_taken',
            'type': 'text',
            'label': 'Food Taken',
            'initially_hidden': False,
            'necessary': 'false'
        },
        {
            'name': 'food_fluid_rejected',
            'type': 'text',
            'label': 'Food and Fluid Offered but Rejected',
            'initially_hidden': False,
            'necessary': 'false'
        },
        {
            'name': 'passed_urine',
            'type': 'selection',
            'label': 'Passed Urine',
            'selection': _passed_urine_options,
            'initially_hidden': False,
            'required': True,
            'necessary': 'true',
            'on_change': [
                {
                    'fields': ['fluid_output'],
                    'condition': [
                        ['passed_urine', '==', 'measured']],
                    'action': 'show',
                    'type': 'value'
                },
                {
                    'fields': ['fluid_output'],
                    'condition': [
                        ['passed_urine', '==', 'measured']],
                    'action': 'require',
                    'type': 'value'
                },
                {
                    'fields': ['fluid_output'],
                    'condition': [
                        ['passed_urine', '!=', 'measured']],
                    'action': 'hide',
                    'type': 'value'
                },
                {
                    'fields': ['fluid_output'],
                    'condition': [
                        ['passed_urine', '!=', 'measured']],
                    'action': 'unrequire',
                    'type': 'value'
                }
            ],
        },
        {
            'name': 'fluid_output',
            'type': 'integer',
            'label': 'Fluid Output (ml)',
            'min': 1,
            'max': 999,
            'initially_hidden': True,
            'necessary': False
        },
        {
            'name': 'bowels_open',
            'type': 'selection',
            'label': 'Bowels Open',
            'selection': _bowels_open_options,
            'initially_hidden': False,
            'reference': {
                'type': 'image',
                'url': '/nh_stools/static/src/img/bristol_stools.png',
                'title': 'Bristol Stools Type Chart',
                'label': 'Bristol Stools Type Chart'
            },
            'required': True,
            'necessary': 'true'
        }
    ]

    def calculate_total_fluid_intake(self, spell_activity_id, date_time):
        """
        Returns the sum of all the `fluid_taken` values from all the food and
        fluid observations completed in a particular period.

        The period to calculate for is determined by the `date_time` argument.
        The `date_time` argument can be any time. Whichever period the
        `date_time` is a part of will be the period used for the calculation.

        :param spell_activity_id:
        :type spell_activity_id: int
        :param date_time:
        :type: str or datetime
        :return: Total fluid intake.
        :rtype: int
        """
        f_and_f_obs_activities = self.get_obs_activities_for_period(
            spell_activity_id, date_time)
        fluid_intake_total = \
            self._calculate_total_fluid_intake_from_obs_activities(
                f_and_f_obs_activities)
        return fluid_intake_total

    @staticmethod
    def _calculate_total_fluid_intake_from_obs_activities(obs_activities):
        """
        Calculates total fluid intake from all the passed obs activities.
        It is assumed that the caller has narrowed the list of obs activities
        down to the time period they want to investigate.

        If no obs activities are passed then `0` is returned because the
        assumption is that all fluid intake is controlled, and so if no
        measurements are taken, it is because no fluid was given to the
        patient, and having to constantly record the fact that a patient wasn't
        given any fluid would be silly.

        :param obs_activities:
        :type obs_activities: List of records.
        :return:
        """
        fluid_intake_values = [activity.data_ref.fluid_taken for activity
                               in obs_activities]
        # Sum of empty list will return 0.
        fluid_intake_total = sum(fluid_intake_values)
        return fluid_intake_total

    @staticmethod
    def _calculate_total_fluid_output_from_obs_activities(obs_activities):
        """
        Calculates total fluid output from all the passed obs activities.
        It is assumed that the caller has narrowed the list of obs activities
        down to the time period they want to investigate.

        If no obs activities are passed then None is returned because fluid
        output is not controlled by clinical staff, and so in the absence of
        any fluid output measurements there is no confidence about the
        quantity of fluid output, and `None` is more appropriate than `0`.

        :param obs_activities:
        :type obs_activities: List of records.
        :return:
        """
        fluid_output_values = [activity.data_ref.fluid_output for activity
                               in obs_activities]
        if not any(fluid_output_values):
            return None
        fluid_output_total = sum(fluid_output_values)
        return fluid_output_total

    @staticmethod
    def calculate_period_score(fluid_intake_total):
        if fluid_intake_total <= 600:
            score = 3
        elif 600 < fluid_intake_total < 1200:
            score = 2
        elif 1200 <= fluid_intake_total < 1500:
            score = 1
        elif fluid_intake_total >= 1500:
            score = 0
        return score

    def calculate_fluid_balance(self, spell_activity_id, date_time):
        """
        Calculates the fluid balance for supplied date_time (which is an Odoo
        string representation of a datetime)
        :param spell_activity_id: ID of the patient's spell activity
        :param date_time: Odoo string representation of a datetime
        :type date_time: str
        :return: Fluid Balance
        :rtype: int
        """
        f_and_f_obs_activities = self.get_obs_activities_for_period(
            spell_activity_id, date_time)

        fluid_intake_total = \
            self._calculate_total_fluid_intake_from_obs_activities(
                f_and_f_obs_activities)

        fluid_output_total = \
            self._calculate_total_fluid_output_from_obs_activities(
                f_and_f_obs_activities)

        # If no intake or output measurements, return 0.
        if fluid_intake_total is 0 and fluid_output_total is None:
            # See docstrings of _calculate* methods for explanation of why
            # fluid intake is 0 and fluid output is None.
            return None
        if fluid_output_total is None:
            fluid_output_total = 0

        fluid_balance = fluid_intake_total - fluid_output_total
        return fluid_balance

    @staticmethod
    def format_fluid_balance_for_frontend(fluid_balance):
        if fluid_balance is None:
            return '-'
        else:
            return '{}ml'.format(fluid_balance)

    def get_obs_activities_for_period(self, spell_activity_id, date_time):
        """
        Get a list of food and fluid observation activities for the date_time
        passed in
        :param spell_activity_id: ID of the patient's spell activity
        :param date_time: Odoo string representation of a date_time
        :type date_time: str
        :return: list of food and fluid observation activities
        :rtype: list
        """
        activity_model = self.env['nh.activity']
        period_domain = self.get_period_domain(date_time)
        domain = [
            ('data_model', '=', self._name),
            ('spell_activity_id', '=', spell_activity_id)
        ]
        domain.extend(period_domain)
        f_and_f_obs_activities = activity_model.search(domain)
        return f_and_f_obs_activities

    def get_period_domain(self, date_time):
        """
        The period to produce domain parameters for is determined by the
        `date_time` argument. The `date_time` argument can be any time.
        Whichever period the `date_time` is a part of will be the period used
        for the calculation.

        :param date_time:
        :type date_time: datetime or str
        :return: Domain parameters that will limit results to a 24 hour period.
        :rtype: list
        """
        date_time = self.env['datetime_utils'].validate_and_convert(date_time)

        period_start_datetime_str = self.get_period_start_datetime(date_time)
        period_end_datetime_str = self.get_period_end_datetime(date_time)
        domain = [
            ('date_terminated', '>=', period_start_datetime_str),
            ('date_terminated', '<', period_end_datetime_str)
        ]
        return domain

    def get_period_start_datetime(self, date_time):
        """
        Get the datetime representing the beginning of the period that the
        passed datetime occurs in.

        :param date_time:
        :type date_time: datetime or str
        :return:
        :rtype: str
        """
        date_time = self.env['datetime_utils'].validate_and_convert(date_time)
        period_start_hour = 7

        period_start_datetime = datetime(
            date_time.year, date_time.month, day=date_time.day,
            hour=period_start_hour
        )
        if self.before_seven_am(date_time):
            period_start_datetime = period_start_datetime - timedelta(days=1)

        period_start_datetime_str = period_start_datetime.strftime(DTF)
        return period_start_datetime_str

    def get_period_end_datetime(self, date_time):
        """
        Get the datetime representing the first microsecond of the period
        after the one that the passed date_time is a part of.

        :param date_time:
        :type date_time: datetime or str
        :return:
        :rtype: str
        """
        date_time = self.env['datetime_utils'].validate_and_convert(date_time)
        period_start_hour = 7

        period_end_datetime = datetime(
            date_time.year, date_time.month, day=date_time.day,
            hour=period_start_hour
        )
        if not self.before_seven_am(date_time):
            period_end_datetime = period_end_datetime + timedelta(days=1)

        period_end_datetime_str = period_end_datetime.strftime(DTF)
        return period_end_datetime_str

    @classmethod
    def before_seven_am(cls, date_time):
        """
        True if the passed date_time is before 07:00 in the morning.

        :param date_time:
        :type date_time: datetime
        :return:
        :rtype: bool
        """
        return date_time.hour < 7

    def get_submission_message(self):
        """
        Override of `nh.clinical.patient.observation` method.

        :return:
        """
        activity_model = self.env['nh.activity']

        data_ref = self.convert_record_to_data_ref()
        domain = [
            ('data_ref', '=', data_ref)
        ]
        obs_activity = activity_model.search(domain)
        obs_activity.ensure_one()
        if obs_activity.state != 'completed':
            raise ValueError(
                "Cannot get the submission message for an observation that is "
                "not completed."
            )

        observation_completion_datetime = obs_activity.date_terminated
        fluid_intake_total = self.calculate_total_fluid_intake(
            obs_activity.spell_activity_id.id, observation_completion_datetime
        )

        period_start_datetime = \
            self.get_period_start_datetime(observation_completion_datetime)
        datetime_utils = self.env['datetime_utils']
        period_start_datetime = \
            datetime_utils.reformat_server_datetime_for_frontend(
                period_start_datetime, two_character_year=True
            )

        spell_activity_id = obs_activity.spell_activity_id.id
        fluid_balance = self.calculate_fluid_balance(
            spell_activity_id, observation_completion_datetime)
        fluid_balance = self.format_fluid_balance_for_frontend(fluid_balance)

        message = 'The patient has had {fluid_intake_total}ml of fluid in ' \
                  'the current 24 hour period (starting on ' \
                  '{period_start_datetime}).' \
                  '<br/>Current Fluid Balance: {fluid_balance}'
        message = message.format(fluid_intake_total=fluid_intake_total,
                                 period_start_datetime=period_start_datetime,
                                 fluid_balance=fluid_balance)
        return message

    def get_all_completed_food_and_fluid_observation_activities(
            self, spell_activity_id):
        activity_model = self.env['nh.activity']
        domain = [
            ('data_model', '=', 'nh.clinical.patient.observation.food_fluid'),
            ('state', '=', 'completed'),
            ('spell_activity_id', '=', spell_activity_id)
        ]
        obs_activities = activity_model.search(domain,
                                               order='date_terminated asc')
        return obs_activities

    @api.multi
    def get_formatted_obs(self, convert_datetimes_to_client_timezone=False):
        """
        Override of `nh.clinical.patient.observation`.

        :return:
        :rtype: dict
        """
        convert = convert_datetimes_to_client_timezone
        obs = super(NHClinicalFoodAndFluid, self).get_formatted_obs(
            convert_datetimes_to_client_timezone=convert)
        periods = self.get_period_dictionaries(obs, include_units=True)
        self.format_period_datetimes(periods)
        return periods

    def get_period_dictionaries(self, food_and_fluid_observations,
                                include_units=False):
        """
        Get a list of dictionaries, each one representing a 24 hour
        observation period. Each dictionary contains data about the period as
        well as a nested list of data for the observations.

        :param food_and_fluid_observations:
        :param include_units: Include measurements with units where applicable.
        :type include_units: bool
        :return:
        """
        if not food_and_fluid_observations:
            raise ValueError(
                "Passed observations argument is falsey, expected a list "
                "of dictionaries. Cannot create period dictionaries without "
                "observations."
            )

        food_and_fluid_model = \
            self.env['nh.clinical.patient.observation.food_fluid']
        an_obs = food_and_fluid_observations[0]
        if an_obs.get('spell_activity_id'):
            spell_activity_id = \
                self._get_id_from_tuple(an_obs['spell_activity_id'])
        else:
            spell_model = self.env['nh.clinical.spell']
            spell_activity = \
                spell_model.get_spell_activity_by_patient_id(
                    an_obs['patient_id'][0]
                )
            spell_activity_id = spell_activity.id

        period_dictionaries = []
        period_start_datetime_current = None
        period_obs = None
        # Iterate through all the observations and gradually build a list of
        # periods containing their observations.
        for obs in food_and_fluid_observations:
            date_terminated = obs['date_terminated']
            period_start_datetime = \
                food_and_fluid_model.get_period_start_datetime(date_terminated)
            # If this observation is the first in a new period,
            # add the new period to the list.
            if period_start_datetime != period_start_datetime_current:
                period_start_datetime_current = period_start_datetime
                period_dictionary = \
                    self._create_new_period_dictionary(
                        obs, spell_activity_id
                    )
                period_dictionaries.append(period_dictionary)
                period_obs = period_dictionaries[-1]['observations']
            # If this observation is in a period we've already come across
            # then add it.
            period_obs.append(obs)

        if include_units:
            self._add_units_to_period_dictionaries(period_dictionaries)
        return period_dictionaries

    def _create_new_period_dictionary(self, obs, spell_activity_id,
                                      include_units=False):
        """
        Encapsulates logic for initialising a period dictionary,
        further population is required for the dictionary to be complete.

        :param obs:
        :param spell_activity_id:
        :param include_units: Include measurements with units where applicable.
        :type include_units: bool
        :return:
        :rtype: dict
        """
        food_and_fluid_model = \
            self.env['nh.clinical.patient.observation.food_fluid']
        period = {}

        # Set period start and end datetimes.
        date_terminated = obs['date_terminated']
        period['period_start_datetime'] = \
            food_and_fluid_model.get_period_start_datetime(date_terminated)
        period['period_end_datetime'] = \
            food_and_fluid_model.get_period_end_datetime(date_terminated)

        # Set fluid intake.
        total_fluid_intake = \
            food_and_fluid_model.calculate_total_fluid_intake(
                spell_activity_id, date_terminated
            )
        if include_units:
            total_fluid_intake = "{}ml".format(total_fluid_intake)
        period['total_fluid_intake'] = total_fluid_intake

        # Set fluid balance.
        fluid_balance = self.calculate_fluid_balance(spell_activity_id,
                                                     date_terminated)
        if include_units:
            fluid_balance = \
                self.format_fluid_balance_for_frontend(fluid_balance)
        period['fluid_balance'] = fluid_balance

        # Set score.
        score = \
            food_and_fluid_model.calculate_period_score(total_fluid_intake)
        period['score'] = score

        period['observations'] = []

        # Set current period.
        period_end_datetime = datetime.strptime(
            period['period_end_datetime'], DTF
        )
        if datetime.now() < period_end_datetime:
            period['current_period'] = True
        return period

    def _add_units_to_period_dictionaries(self, period_dictionaries):
        """
        Add units to values in period dictionaries - expects dictionary to
        represent a food and fluid observation, not an nh.activity
        :param period_dictionaries: list of food and fluid observation dicts
        :return: list of food and fluid observation dicts
        """
        for period in period_dictionaries:
            period['total_fluid_intake'] = self._add_ml(
                period['total_fluid_intake'])

            if period['fluid_balance'] is None:
                period['fluid_balance'] = '-'
            else:
                period['fluid_balance'] = self._add_ml(period['fluid_balance'])

            for obs in period['observations']:
                if 'values' in obs:
                    obs = obs['values']  # Handles report style dict.
                obs['fluid_taken'] = self._add_ml(obs['fluid_taken'])

                yes_measured = self._fields['passed_urine'].selection[0][1]
                if obs['passed_urine'] == yes_measured:
                    obs['passed_urine'] = 'Yes ({}ml)'.format(
                        obs['fluid_output'])

    @staticmethod
    def _add_ml(obj):
        return '{}ml'.format(obj)

    def format_period_datetimes(self, periods):
        """
        Format the datetimes in the passed period dictionaries to be more
        user-friendly.

        :param periods:
        :return:
        """
        datetime_utils = self.env['datetime_utils']
        datetime_format = \
            datetime_utils.datetime_format_front_end_two_character_year
        for period in periods:
            period_start_datetime = datetime.strptime(
                period['period_start_datetime'], DTF
            )
            period['period_start_datetime'] = \
                period_start_datetime.strftime(datetime_format)
            period_end_datetime = datetime.strptime(
                period['period_end_datetime'], DTF
            )
            period['period_end_datetime'] = \
                period_end_datetime.strftime(datetime_format)

    def active_food_fluid_period(self, spell_activity_id):
        """
        Check to see if any food and fluid observations have been submitted in
        this period
        :param spell_activity_id: ID of patient's spell activity
        :return: True if food and fluid observation have been submitted in the
        current period
        :rtype: bool
        """
        dateutils_model = self.env['datetime_utils']
        current_time = dateutils_model.get_current_time(as_string=True)
        obs_for_period = self.get_obs_activities_for_period(
            spell_activity_id, current_time)
        return any(obs_for_period)