1#!/usr/bin/env python3
  2
  3"""Insert entries to the NOTIFICATIONS table.
  4
  5This is part of the old email system and substantially replaced by the new User/Roles
  6system. Few if any of these notifications are still needed and the major exception -
  7CHART-EPS AMSUA Unique Mode notifications - should be an Event instead.
  8The job status change algorithm called by worker.py would be better handled inside
  9role.py not here."""
 10
 11import logging
 12from collections import OrderedDict
 13
 14from chart.db.connection import db_connect
 15from chart.project import settings
 16from chart.common import sendmail
 17from chart.common.util import ensure_dir_exists
 18from chart.common.util import nvl
 19from chart.common.traits import name_of_thing
 20from chart.project import SID
 21from chart.web.user import User
 22
 23# connection also used for users, auth_user tables
 24db_conn = db_connect('USERS')
 25
 26logger = logging.getLogger()
 27user_logger = logging.getLogger('user')
 28
 29
 30# We define all the available notification classes here.
 31# Each notification must be an instance of one class.
 32
 33notification_classes = OrderedDict([
 34    ('NO_INGESTION', {'description': 'Long gap in ingestion',
 35                      'has_state': True,
 36                      'has_parameter': True}),
 37    # ('DISK_FULL', {}),
 38    ('AOCS_SCAO17', {'description': 'Record problems in the AOCS SCAO17 algorithm',
 39                     'has_sid': True}),
 40    ('SSR_POWER', {'description': 'Record problems in the SSR_POWER algorithm',
 41                   'has_sid': True}),
 42    ('HOURLY_NOTIFICATION', {'description': 'Test notification',
 43                             'email': False}),
 44    ('TEST_NOTIFICATION_1', {'description': 'Test notification'}),
 45    ('TEST_NOTIFICATION_2', {'description': 'Test notification',
 46                             'has_sid': True,
 47                             'has_parameter': True}),
 48    ('TEST_NOTIFICATION_3', {'description': 'Test notification',
 49                             'has_state': True,
 50                             'has_sid': True,
 51                             'has_parameter': True}),
 52    # ('DATABASE_UNAVAILABLE', {'description': 'Cannot connect to database',
 53    # 'has_state': True},
 54    ('ACTIVITY_FAILED', {'description': 'An activity returned status of FAILED',
 55                         'has_sid': True,
 56                         'has_parameter': True,
 57                         'has_state': True}),
 58    ('AMSUA_UNIQUE', {'description': 'AMSUA Unique mode data has been received',
 59                      'has_sid': True,
 60                      'has_state': True}),
 61    ('SCHEDULER_STARTUP', {'description': 'Send when the scheduler starts to confirm it is '
 62                           'able to send notifications',
 63                           'has_sid': False,
 64                           'has_state': False}),
 65    # ('GOME_TIMELINE_SEARCH', {'description': 'Debug messagesend after gome tl scan',
 66                              # 'has_sid': True}),
 67    ('GOME_TIMELINE_ERROR', {'description': 'Debug messagesend after gome tl scan',
 68                              'has_sid': True}),
 69        ])
 70
 71
 72def statefile_name(notification, sid, param):
 73    """Compute statefile name for this notification."""
 74    dirname = settings.STATEFILE_DIR.child('notifications')
 75    ensure_dir_exists(dirname)
 76    if param is None:
 77        param_part = ''
 78
 79    else:
 80        param_part = '_{param}'.format(param=name_of_thing(param))
 81
 82    if notification_classes[notification].get('has_sid'):
 83        # if sid is None:
 84            # logger.warning('No SID set for notification which allows one')
 85
 86        return dirname.child('{classname}_{sid}{param}'.format(
 87            classname=notification,
 88            sid=SID.get_reportname_part(sid),
 89            param=param_part))
 90
 91    else:
 92        if sid is not None:
 93            logger.warning('Attempt to set SID for a notification which doesn\'t allow it')
 94
 95        return dirname.child('{classname}_{param}'.format(
 96            classname=notification,
 97            param=param_part))
 98
 99
100def set_statefile(notification, sid, param):  # , not_id):
101    """Create notification statefile if it doesn't exist."""
102    path = statefile_name(notification, sid, param)
103    if not path.exists():
104        logging.debug('Creating statefile {path}'.format(path=path))
105        with open(str(path), 'w') as handle:
106            handle.write(' ')  # str(not_id))
107
108    else:
109        logging.debug('Not creating statefile {path}'.format(path=path))
110
111
112def clear_statefile(notification, sid, param):
113    """Remove notification statefile if it exists."""
114    path = statefile_name(notification, sid, param)
115    if path.exists():
116        logging.debug('Removing statefile {path}'.format(path=path))
117        path.unlink()
118
119    else:
120        logging.debug('No statefile {path}'.format(path=path))
121
122
123def test_statefile(notification, sid, param):
124    """Check if notification statefile exists."""
125    path = statefile_name(notification, sid, param)
126    res = path.exists()
127    if not res:
128        # logger.debug('Notification statefile {name} does not exist'.format(name=path.name))
129        return False
130
131    else:
132        logger.debug('Notification statefile {name} exists'.format(name=path.name))
133        # not_id = int(open(str(path), 'r').read())
134        # logger.debug('Notification statefile {name} has id {id}'.format(
135            # name=path.name, id=not_id))
136        # return not_id
137        return True
138
139
140def notify(notification_classname,
141           message='No message set',
142           sid=None,
143           parameter=None):
144    """Create a notification.
145    Emails are sent to subscribers and a statefile is created
146    if the notification class has the 'has_state' attribute.
147    `message` :: Appears in the body of the email message. It is purely for information.
148    `sid` :: Should be None for system jobs, otherwise the relevant SID
149    `parameter` :: Can be a short string. It appears in the email subject.
150    For notifications with a state, the same `parameter` must be passed to the `denotify`
151    call to end the notification.
152    Statefull notifications do not have to have a parameter.
153
154    Notifications are just logged if settings.DEBUG is set.
155    """
156
157    # Rules for sending notifications:
158    #  - We want notifications for NRT data on the OPE or VAL systems.
159    #  - We don't want notifications from from TEST or DEV (?)
160    #  - No notifications if ingester, dispatcher, jobcontrol etc. are being run from the command
161    #    line.
162
163    notification_class = notification_classes.get(notification_classname)
164    if notification_class is None:
165        logger.error('notify() called with unknown class {cls}'.format(cls=notification_classname))
166        return
167
168    # check for required function arguments
169    if notification_class.get('has_parameter', False) and parameter is None:
170        logger.error('Attempt to raise notification {cls} with no parameter set'.format(
171            cls=notification_classname))
172        return
173
174    # if notification_class.get('has_sid', False) and sid is None:
175        # logger.error('Attempt to raise notification {cls} with no SID set'.format(
176            # cls=notification_classname))
177        # return
178
179    if settings.DEBUG:
180        logger.info('Not emailing as settings.DEBUG is set notification {cls} {sid} {param} '
181                    '{msg}'.format(cls=notification_classname,
182                                   sid=sid,
183                                   param=parameter,
184                                   msg=message))
185        return
186
187    # statefull notification, see if already set
188    if notification_class.get('has_state'):
189        if test_statefile(notification_classname, sid, parameter):
190            logger.info('Not sending email as notification statefile file exists')
191            return
192
193        # record the notification ID in a statefile
194        set_statefile(notification_classname, sid, parameter)
195
196    user_logger.info('Notification {msg}'.format(msg=message))
197
198    if settings.DEBUG is True:
199        # don't send out emails in debug mode
200        logger.debug('Not sending email because settings.DEBUG is true')
201        return
202
203    if not notification_class.get('email', True):
204        # don't send emails if {'email':False} is set
205        logger.debug('Not sending email because this notification has the `email` '
206                      'attribute set to false')
207        return
208
209    subject = notification_classname
210    if parameter is not None:
211        subject += ' ' + parameter
212
213    if sid is not None:
214        subject += ' for {sid}'.format(sid=sid)
215
216    notification_email(
217        subject=subject,
218        html="""Class: <b>{classname}</b><br>
219SID: {sid}<br>
220Parameter: {param}<br>
221Message:<br>
222{mess}<br>
223<br>
224{suffix}""".format(classname=notification_classname,
225                   sid='n/a' if sid is None else '<b>{sid}</b>'.format(sid=sid),
226                   param='n/a' if parameter is None else '<b>' + parameter + '</b>',
227                   mess=message,
228                   suffix=settings.EMAIL_MESSAGE_SUFFIX))
229
230
231def denotify(notification_classname, parameter=None, sid=None):
232    """Show that an error state, previously created by a call to notify()
233    with a `statefile` parameter, has ended."""
234    notification_class = notification_classes.get(notification_classname)
235    if notification_class is None:
236        logger.error('Denotify called with unknown class {cls}'.format(cls=notification_classname))
237        return
238
239    if not test_statefile(notification_classname, sid, parameter):
240        return
241
242    clear_statefile(notification_classname, sid, parameter)
243
244    logger.info('End of notification state {cls} {param}'.format(
245        cls=notification_classname, param=parameter))
246
247    if settings.DEBUG:
248        logger.info('Not sending denotification email as settings.DEBUG is set')
249        return
250
251    if not notification_class.get('email', True):
252        # don't send emails if {'email':False} is set
253        logger.debug('Not sending email because this notification has the `email` '
254                      'attribute set to false')
255        return
256
257    notification_email(subject='End of state {cls}'.format(cls=notification_classname),
258                              html="""A notification state has ended.
259Class {classname}
260SID: {sid}
261Parameter: {param}
262{suffix}""".format(  # start='not implemented',
263    classname=notification_classname,
264    sid=sid,
265    param=parameter,
266    suffix=settings.EMAIL_MESSAGE_SUFFIX))
267
268    # db_conn.query('UPDATE notifications SET stop_time=:stop_time WHERE id=:id',
269             # id=not_id,
270             # stop_time=datetime.utcnow())
271    # db_conn.commit()
272
273
274def notification_email(subject,
275                       message=None,
276                       html=None):
277    """Internal function to this module.
278    After a notification has been entered into the NOTIFICATIONS table
279    test for any emails that should be sent.
280    """
281
282    # Subject: first message
283    # This message was sent at {local} local time.
284    # The following notifications have been raised:
285    # The following notifications have been closed:
286    # best regards, CHART
287
288    # body = None
289
290    if settings.EMAIL_HOST is None:
291        logger.info('Not sending email as EMAIL_HOST is None')
292        return
293
294    for target in (settings.ADMINS if settings.SYSADMINS is None else settings.SYSADMINS):
295        if isinstance(target, str):
296            # just email address
297            name = email = target
298
299        else:
300            # (full name, email address)
301            name, email = target
302
303        logger.info('Emailing {name} <{email}>'.format(
304                     name=name,
305                     email=email))
306
307        from_address = (settings.EMAIL_NAME.format('notify'),
308                        settings.EMAIL_ADDRESS.format('notify'))
309
310        if message is not None:
311            sendmail.sendmail(
312                from_address=from_address,
313                to_addresses=((name, email),),
314                subject=settings.EMAIL_SUBJECT_PREFIX + subject,
315                message=message)
316
317        else:
318            sendmail.sendmail_html(
319                from_address=from_address,
320                to_addresses=((name, email),),
321                subject=settings.EMAIL_SUBJECT_PREFIX + subject,
322                html=html)
323
324
325def main():
326    """Command line entry point."""
327
328    from chart.common.args import ArgumentParser
329    parser = ArgumentParser()
330    parser.add_argument('--db', '--conn', '-d',
331                        metavar='CONN',
332                        help='Use database connection CONNECTION')
333    parser.add_argument('--notify-1',
334                        help='Create a test notification',
335                        action='store_true')
336    parser.add_argument('--notify-2',
337                        help='Create a test notification with parameters',
338                        action='store_true')
339    parser.add_argument('--notify-3',
340                        help='Create a test notification with duration',
341                        action='store_true')
342    parser.add_argument('--denotify',
343                        help='Create a test denotification',
344                        action='store_true')
345    parser.add_argument('--unique',
346                        action='store_true')
347    parser.add_argument('--non-unique',
348                        action='store_true')
349    args = parser.parse_args()
350
351    if args.db:
352        settings.set_db_name(args.db)
353
354    if args.unique:
355        notify('AMSUA_UNIQUE',
356                             message='AMSU-A unique mode data found',
357                             sid=SID('M02'))
358        parser.exit()
359
360    if args.non_unique:
361        denotify('AMSUA_UNIQUE', sid=SID('M02'))
362        parser.exit()
363
364    if args.notify_1:
365        logger.info('Sending notification 1')
366        notify('TEST_NOTIFICATION_1',
367               message='This is a test notification with no parameters')
368
369    elif args.notify_2:
370        logger.info('Sending notification 2')
371        notify('TEST_NOTIFICATION_2',
372               sid=SID('M02'),
373               parameter='TESTPARAM',
374               message='This is a test notification with SID and a parameter')
375
376    elif args.notify_3:
377        logger.info('Sending notification 3')
378        notify('TEST_NOTIFICATION_3',
379               sid=SID('M02'),
380               parameter='TESTPARAM',
381               message='This is a test notification with a duration')
382
383    elif args.denotify:
384        logger.info('Sending denotification 3')
385        denotify('TEST_NOTIFICATION_3',
386               sid=SID('M02'),
387               parameter='TESTPARAM')
388
389    else:
390        parser.error('No actions specified')
391
392if __name__ == '__main__':
393    main()