1#!/usr/bin/env python3
  2
  3"""Implementation of Role, User and RoleManager.
  4
  5Role hierarchies (where a role can belong to another role) are not supported, although
  6the basic data structures allow it. This might be a useful feature to add but was left out
  7for now to keep the initial implementation simpler. It's not clear how useful this would be
  8anyway.
  9"""
 10
 11import re
 12import logging
 13import sys
 14
 15from fnmatch import fnmatch
 16from datetime import timedelta
 17from datetime import datetime
 18from typing import Optional
 19
 20import django
 21from django.template import Template
 22from django.template import Context
 23
 24from chart import settings
 25from chart.common.xml import XMLElement
 26from chart.common.sendmail import Email
 27from chart.common.sendmail import sendmail_attachment
 28from chart.reports.archive import get_report_name
 29from chart.reports.archive import get_report_url
 30from chart.backend.activity import Activity
 31from chart.backend.job import JobStatus
 32from chart.common.util import ensure_dir_exists
 33from chart.project import SID
 34from chart.reportviewer.views import get_report_abspath
 35from chart.reportviewer.views import zip_to_pdf
 36
 37# Base: Has username, subscriptions
 38# User: name, email, subscriptions
 39
 40# Everything is a role
 41# But role has a critical is_user attribute
 42# If is_user, an have name + email
 43
 44ELEM_USER = 'user'
 45ELEM_ROLE = 'role'
 46ELEM_EVENT_NOTIFICATION = 'event-notification'
 47ELEM_REPORT_NOTIFICATION = 'report-notification'
 48ELEM_JOB_NOTIFICATION = 'job-notification'
 49ELEM_DAEMON_NOTIFICATION = 'daemon-notification'
 50ELEM_MEMBERS = 'members'
 51ELEM_NAME = 'name'
 52ELEM_DESCRIPTION = 'description'
 53ELEM_USERNAME = 'username'
 54ELEM_EMAIL = 'email'
 55ELEM_ATTACH_CONVERTED_PDF_INSTEAD_OF_LINK = 'attach-converted-pdf-instead-of-link'
 56
 57# Templates for event notification
 58# Subject template is expanded using python string expansion with
 59# {'prefix': prefix=settings.EMAIL_SUBJECT_PREFIX.rstrip(), 'classname': event.event_class.name}
 60EMAIL_EVENT_SUBJECT_TEMPLATE = '{prefix} event: {classname}'
 61# Body template is expanded using Event.render_with_template
 62EMAIL_EVENT_BODY_TEMPLATE = """{{settings.APPNAME}} has raised an event of type {{classname}}.
 63
 64Source: {{sid}}
 65Start: {{start_time}}
 66{%if stop_time%}Stop: {{stop_time}}
 67{%endif%}{%if properties%}Properties:{%for p in properties.items%}{%if forloop.first%}
 68{%endif%}  {{p.0}}: {{p.1}}
 69{%endfor%}{%endif%}"""
 70
 71# Templates for daemon state change
 72EMAIL_DAEMON_SUBJECT_TEMPLATE = '{prefix} process {daemon} {state} event'
 73EMAIL_DAEMON_BODY_TEMPLATE = """Process {{daemon}} has changed to state {{state}} from {{old_state}}
 74"""
 75
 76# Templates for activity state change
 77EMAIL_JOB_SUBJECT_TEMPLATE = '{prefix} job {activity} entered state {state.name}'
 78EMAIL_JOB_BODY_TEMPLATE = """{{settings.APPNAME}} job {{job_id}} for {{sid}} start {{start}}
 79stop {{stop}} completed with state {{state.name}}. Previous jobs had state {{old_state.name}}."""
 80
 81# Templates for report notification
 82EMAIL_REPORT_SUBJECT_TEMPLATE = '{prefix} report: {activity}'
 83EMAIL_REPORT_BODY_TEMPLATE = """{{settings.APPNAME}} has generated a {{activity}} report for {{sid}}
 84from {{start}} to {{stop}}
 85
 86View online {{report_url}}"""
 87EMAIL_PDF_BODY = 'See attached PDF report'
 88
 89# Standard indentation level
 90INDENT = '  '
 91
 92# Detect name and email strings
 93NAME_EMAIL_MATCH = re.compile(r'\s*(?P<name>.*?)\s*<\s*(?P<email>.*?)>\s*')
 94
 95logger = logging.getLogger()
 96user_logger = logging.getLogger('user')
 97
 98# Eventually we might want fine grained control like only notify for certain
 99# SIDs or certain event properties, certain job statuses.
100# class ConfiguredNotification
101# def test(self, notification)
102# Generic tests
103# class ConfiguredDaemonNotification(ConfiguredNotification)
104# def test(self, daemon_notification):
105    # super().test(notification)
106    # Daemon specific tests like old or new status
107
108class RoleManager:
109    """Manage all Users or Roles.
110
111    All Users in the system are in the top level self.users list.
112    Roles are stored in a heirarchy with self.roles as the top list,
113    and members sublists containing users or roles.
114    Roles can have sub-roles but that would be complicated to configure.
115    """
116
117    # We don't usually use singleton pattern in CHART but I think future features may use
118    # multiple role contexts with different configuration files / database tables
119    _instance = None
120
121    # Accept a report, event, job state change, daemon event
122    # List of users
123    # List of top level roles
124    def __init__(self, path=None):
125        self.users = []
126        self.roles = []
127        if path is None:
128            path = settings.ROLES_FILE
129
130        if path is None or not path.is_file():
131            # raise ValueError('Cannot open config file - check value of settings.ROLES_FILE')
132            return
133
134        # logger.debug('opening {p}'.format(p=path))
135        root_elem = XMLElement(filename=path)
136
137        # The system must begin with an XML file since there's no UI
138        # to set up role-based notifications
139        # Users have no heirarchy just a flat list
140        for user_elem in root_elem.findall(ELEM_USER):
141            # logger.debug('Instantiate user')
142            self.users.append(User(self, user_elem))
143        # Roles do have a heirarchy stored in the members and member_of
144        # members but here we list them all as a flat list
145        for role_elem in root_elem.findall(ELEM_ROLE):
146            # logger.debug('Instantiate role')
147            self.roles.append(Role(self, role_elem))
148
149        for role in self.roles:
150            # print('preresolve')
151            role.resolve()
152            # print('postresolve')
153        # print('dun all resolve')
154
155    @staticmethod
156    def instance():
157        """Implement singleton pattern."""
158        if RoleManager._instance is None:
159            RoleManager._instance = RoleManager()
160
161        return RoleManager._instance
162
163    def find_user_or_role(self, username=None, name=None, email=None, create=False, nodes=None):
164        """Identify the user or role refered to in an element.
165
166        Users or roles can be identified by username, full name or email address.
167
168        Roles can implicitly create member Users just by specifying an <email> element.
169
170        Args:
171            - username: Username or short name to match
172            - name: Long name to match
173            - email: Email address to match. Could be either "a.b@c.com" or "A M <a.b@c.com>"
174                notation
175            - create: If an email address is specified, do we create the user if not found?
176            - nodes: For recursive searching, the list of nodes to search through if not top node.
177
178        Returns:
179            User instance or Role instance
180        """
181        # For a top level search, check for an existing user
182        if nodes is None:
183            for u in self.users:
184                if u.match_criteria(username=username, name=name, email=email):
185                    return u
186
187            # Otherwise check for an existing role in the tree, or create a user
188            nodes = self.roles
189
190        # Look for a role
191        for role_or_user in nodes:
192            if role_or_user.match_criteria(username=username, name=name, email=email):
193                return role_or_user
194            # Role heirarchy is not really supported yet, but recursive searching
195            # would go here if it was
196
197        # Create a user implicitly from email address
198        if create and email:
199            # the constructor will decode "name <email>" if present
200            new_user = User(self, email=email)
201            self.users.append(new_user)
202            return new_user
203
204        raise ValueError('Cannot find user or role matching username:{u} name:{n} email:{e}'.format(
205            u=username, n=name, e=email))
206
207    def list_users(self, target=sys.stdout):
208        """List all users, whether explicitly or implicitly defined."""
209        target.write('Users:\n')
210        for user in self.users:
211            target.write('{i}User {usr}\n'.format(i=INDENT, usr=str(user)))
212
213    def list_roles(self, target=sys.stdout, indent='', nodes=None):
214        """List all roles in a tree view with lists of notifications."""
215        if len(indent) == 0:
216            target.write('Roles:\n')
217            nodes = self.roles
218
219        # print('  showing', len(nodes), 'childs')
220
221        for role_or_user in nodes:
222            # Show basic information if it's a user
223            if isinstance(role_or_user, User):
224                target.write('{i}User {u}\n'.format(i=indent + INDENT, u=str(role_or_user)))
225
226            # Show basic information and subscriptions if it's a role
227            elif isinstance(role_or_user, Role):
228                target.write('{i}Role {r}\n'.format(i=indent + INDENT, r=role_or_user.name))
229                self.list_roles(target, indent + INDENT, role_or_user.members)
230                for notify_type in ('event', 'report', 'daemon', 'job'):
231                    for notify in getattr(role_or_user, '{type}_notifications'.format(
232                            type=notify_type)):
233                        target.write('{i}{type} notification {n}\n'.format(
234                            type=notify_type.capitalize(),
235                            i=indent + INDENT * 2,
236                            n=notify))
237
238
239    # def show_user_or_role(self, username=None, name=None, email=None):
240    # """Show detailed information about a single user or role."""
241        # pass
242
243    def handle_event(self,
244                     event,
245                     sendmails=False,
246                     log=True,
247                     sendmail_filter=None):
248        """Examine a single event and raise alerts.
249
250        Args:
251            - event: Event to test
252            - sendmails: Set to True to actually send out emails
253            - log: Set to True to write to the user.log file
254            - sendmail_filter: If a string, use as a wildcard filter against email addresess
255                and only send mails to those who match
256
257        This function would be a good place to implement email throttling -
258        after sending THRESHOLD events of a certain event class in a THRESHOLD_DURATION period,
259        just send a single alert saying additional events will not be sent, click here to see in
260        event viewer.
261        """
262        # Don't send emails when reprocessing old events
263        if (settings.EMAIL_CUTOFF is not None) and\
264           (event.start_time < (datetime.utcnow() - settings.EMAIL_CUTOFF)):
265            return
266
267        # Remember on Roles can have subscriptions not Users
268        # And we're not supporting hierarchical roles which makes it nice and simple
269        recipients = set()
270        # Building the email could be expensive so only do it once, and only do it if there is a
271        # match
272        email = None
273        for role in self.roles:
274            # logger.debug('Testing against role {r}'.format(r=role))
275            if role.match_event(event):
276                logger.debug('Matched role {r}'.format(r=role))
277                if email is None:
278                    email = Email(
279                        sender=settings.EMAIL_SENDER,
280                        subject=EMAIL_EVENT_SUBJECT_TEMPLATE.format(
281                            prefix=settings.EMAIL_SUBJECT_PREFIX.rstrip(),
282                            classname=event.event_class.name),
283                        body=event.render_with_template(EMAIL_EVENT_BODY_TEMPLATE))
284                    logger.debug('Prepared email {e}'.format(e=email))
285
286                for person in role.members:
287                    if isinstance(person, User):
288                        recipients.add(person)
289
290        self.send_notifications(recipients, email, sendmails, sendmail_filter, log)
291
292    def send_notifications(self, recipients, email, sendmails, sendmail_filter, log):
293        """Actual send of prepared email to prepared list.
294
295        Allows filtering which is used with the role --test options to send test emails
296        to selected users only."""
297        # print('send notifications', recipients, email, sendmails, sendmail_filter, log)
298        for recipient in recipients:
299            if sendmails:
300                if sendmail_filter is None or recipient.match_criteria(general=sendmail_filter):
301                    # logger.info('Sending to {rec} {email}'.format(rec=recipient, email=email))
302                    email.send(recipient)
303
304                else:
305                    logger.info('Sendmail check failed for {rec}'.format(rec=recipient))
306
307            if log:
308                user_logger.info('Sending {rec} {email}'.format(rec=recipient, email=email))
309
310        if not sendmails:
311            logger.info('Sending of emails disabled in this tool')
312
313    def handle_report(self,
314                      activity: Activity,
315                      sid: 'SID',
316                      start: datetime,
317                      stop: datetime,
318                      sendmails: bool = False,
319                      log: bool = False,
320                      sendmail_filter: Optional[str] = None):
321        """Respond to a report ingestion."""
322        if settings.EMAIL_CUTOFF is not None and stop < (datetime.utcnow() - settings.EMAIL_CUTOFF):
323            return
324
325        # Remember on Roles can have subscriptions not Users
326        # And we're not supporting hierarchical roles which makes it nice and simple
327        recipients = set()
328
329        # Nasty little hack to support the pdf send function - should be a set instead
330        # people might get multiple emails depending on roles.xml configuration
331        # recipients = []
332
333        # building the email could be expensive so only do it when there is a match
334        email = None
335        for role in self.roles:
336            # logger.debug('Testing against role {r}'.format(r=role))
337            if role.match_report(activity, sid, start, stop):
338                # TBD this is bad design since we use different mechanisms to
339                # build emails with PDF attachments
340                if role.attach_converted_pdf_instead_of_link:
341                    # this means we convert the pdf each time for each receiver
342                    # it's horrible but we're basically out of time to fix it
343                    report_name = get_report_name(activity, sid, start)
344                    report_filename = get_report_abspath(report_name)
345                    pdf = zip_to_pdf(report_filename)
346                    for person in role.members:
347                        if isinstance(person, User):
348                            # these are not the addresses anyone wants to see
349                            # and if this was any other part of the code this should be sorted out
350                            # properly. But it's only for emailing PDF attachments so we leave it
351                            # TBD read the strings for the email subject and body from project settings
352                            # where they are present anyway
353                            # TBD This is extremely inefficient because the email is built
354                            # for each recipient and not sent to multiple recipients
355                            # TBD unfortuntely calling sendmail_* directly here instead of via the
356                            # send_notifications function means we'll send notification emails
357                            # during backlog reprocesssing and when disabled by command line
358                            # arguments
359                            sendmail_attachment(
360                                from_address=settings.EMAIL_SENDER,
361                                to_addresses=[(person.email, person.email)],
362                                subject=EMAIL_REPORT_SUBJECT_TEMPLATE.format(
363                                    prefix=settings.EMAIL_SUBJECT_PREFIX.rstrip(),
364                                    activity=activity.name),
365                                attachment_name=report_name + '.pdf',
366                                attachment=pdf,
367                                body=EMAIL_PDF_BODY)
368
369                    continue
370
371                logger.debug('Matched role {r}'.format(r=role))
372                if email is None:
373                    report_name = get_report_name(activity, sid, start)
374                    # report_url = reverse('reportviewer:report/index',
375                                         # kwargs={'report_name': report_name})
376                    # TBD: implement User <external> attribute
377                    report_url = get_report_url(activity, sid, start, external=False)
378
379                    # Prevent possible "Apps aren't loaded yet" error with some Django versions
380                    django.setup()
381
382                    context = Context({'settings': settings,
383                                       'activity': activity.name,
384                                       'sid': sid,
385                                       'start': start.date() if start is not None else None,
386                                       'stop': stop.date() if stop is not None else None,
387                                       'report_url': report_url,
388                                       'report_name': report_name})
389                    body = Template(EMAIL_REPORT_BODY_TEMPLATE).render(context)
390                    email = Email(
391                        sender=settings.EMAIL_SENDER,
392                        subject=EMAIL_REPORT_SUBJECT_TEMPLATE.format(
393                            prefix=settings.EMAIL_SUBJECT_PREFIX.rstrip(),
394                            activity=activity.name),
395                        body=body)
396                    logger.debug('Prepared email {e}'.format(e=email))
397
398                for person in role.members:
399                    if isinstance(person, User):
400                        logger.debug('Added reciever {r}'.format(r=person))
401                        recipients.add(person)
402
403        self.send_notifications(recipients, email, sendmails, sendmail_filter, log)
404
405    def handle_job_state(self, job, sendmails, log, sendmail_filter=None):
406        """Respond to a job, first testing if the status has changed."""
407        # location for statefiles for trackingchanges
408        if settings.STATEFILE_DIR is None:
409            logger.info('Cannot track job state changes as settings.STATEFILE_DIR is not set')
410            return
411
412        STATE_DIR = settings.STATEFILE_DIR.joinpath('activitystate')
413        ensure_dir_exists(STATE_DIR)
414        statefile_name = STATE_DIR.joinpath('{activity}_{sid}.state'.format(
415            activity=job.activity.name,
416            sid=SID.get_reportname_part(job. sid)))
417        if job.status is JobStatus.COMPLETED and not statefile_name.exists():
418            # logger.debug('New and old state are both completed')
419            # we've optimised the normal case
420            return
421
422        if statefile_name.exists():
423            try:
424                old_state = JobStatus[statefile_name.open('r').read()]
425            except (OSError, KeyError) as e:
426                logger.error('Error writing to state file {fn}: {e}'.format(
427                    fn=statefile_name, e=e))
428                return
429
430        else:
431            old_state = JobStatus.COMPLETED
432
433        # logger.debug('New state {new} old state {old}'.format(new=job.status, old=old_state))
434
435        if old_state is not job.status:
436            self.handle_job_state_change(activity=job.activity,
437                                         sid=job.sid,
438                                         start=job.sensing_start,
439                                         stop=job.sensing_stop,
440                                         state=job.status,
441                                         old_state=old_state,
442                                         job_id=job.job_id,
443                                         sendmails=sendmails,
444                                         log=log,
445                                         sendmail_filter=sendmail_filter)
446
447            if job.status is JobStatus.COMPLETED:
448                try:
449                    statefile_name.unlink()
450                except FileNotFoundError:
451                    # Statefiles can be deleted by other processes
452                    logger.debug('File {path} already deleted'.format(path=statefile_name))
453
454                logger.debug('Removed {path}'.format(path=statefile_name))
455
456            else:
457                statefile_name.open('w').write(job.status.name)
458                logger.debug('Created {path}'.format(path=statefile_name))
459
460
461    def handle_job_state_change(self,
462                                activity,
463                                sid,
464                                start,
465                                stop,
466                                state,
467                                old_state,
468                                job_id,
469                                sendmails=False,
470                                log=False,
471                                sendmail_filter=None):
472        """Respond to a job with a different state to the last time the activity was run."""
473        # ingestion jobs will not have start time set at this point which means notification
474        # emails will be send for reprocessing historical jobs
475        # It should probably be fixed, and also set up so we only send emails if category is
476        # SCHEDULER
477        if settings.EMAIL_CUTOFF is not None and start is not None and \
478           start < (datetime.utcnow() - settings.EMAIL_CUTOFF):
479            return
480
481        # Remember on Roles can have subscriptions not Users
482        # And we're not supporting hierarchical roles which makes it nice and simple
483        recipients = set()
484        # Building the email could be expensive so only do it when there is a match
485        email = None
486        for role in self.roles:
487            # logger.debug('Testing against role {r}'.format(r=role))
488            if role.match_job(activity):
489                logger.debug('Matched activity {r}'.format(r=role))
490                if email is None:
491                    # Prevent possible "Apps aren't loaded yet" error with some Django versions
492                    django.setup()
493
494                    context = Context({'settings': settings,
495                                       'activity': activity.name,
496                                       'sid': sid,
497                                       'start': start.date() if start is not None else None,
498                                       'stop': stop.date() if stop is not None else None,
499                                       'job_id': job_id,
500                                       'state': state,
501                                       'old_state': old_state})
502                    body = Template(EMAIL_JOB_BODY_TEMPLATE).render(context)
503                    email = Email(
504                        sender=settings.EMAIL_SENDER,
505                        subject=EMAIL_JOB_SUBJECT_TEMPLATE.format(
506                            prefix=settings.EMAIL_SUBJECT_PREFIX.rstrip(),
507                            activity=activity.name,
508                            state=state),
509                        body=body)
510                    logger.debug('Prepared email {e}'.format(e=email))
511
512                for person in role.members:
513                    if isinstance(person, User):
514                        logger.debug('Added reciever {r}'.format(r=person))
515                        recipients.add(person)
516
517        self.send_notifications(recipients, email, sendmails, sendmail_filter, log)
518
519    def handle_daemon_event(self, daemon, state, old_state, sendmails=False, sendmail_filter=None):
520        """Respond to a supervisor daemon changing state."""
521        # Remember on Roles can have subscriptions not Users
522        # And we're not supporting hierarchical roles which makes it nice and simple
523        recipients = set()
524        # Building the email could be expensive so only do it when there is a match
525        email = None
526        for role in self.roles:
527            # logger.debug('Testing against role {r}'.format(r=role))
528            if role.match_daemon(daemon):
529                logger.debug('Matched daemon {r}'.format(r=role))
530                if email is None:
531                    # Prevent possible "Apps aren't loaded yet" error with some Django versions
532                    django.setup()
533
534                    context = Context({'settings': settings,
535                                       'daemon': daemon,
536                                       'state': state,
537                                       'old_state': old_state})
538                    body = Template(EMAIL_DAEMON_BODY_TEMPLATE).render(context)
539                    email = Email(
540                        sender=settings.EMAIL_SENDER,
541                        subject=EMAIL_DAEMON_SUBJECT_TEMPLATE.format(
542                            prefix=settings.EMAIL_SUBJECT_PREFIX.rstrip(),
543                            daemon=daemon,
544                            state=state),
545                        body=body)
546                    logger.debug('Prepared email {e}'.format(e=email))
547
548                for person in role.members:
549                    if isinstance(person, User):
550                        logger.debug('Added reciever {r}'.format(r=person))
551                        recipients.add(person)
552
553        self.send_notifications(recipients, email, sendmails, sendmail_filter, log=False)
554
555
556class Role:
557    """representation of a user or role."""
558    # read from xml
559    def __init__(self, manager, elem):
560        self.manager = manager
561        self.name = None
562        self.description = None
563        # To be filled in by the resolve() function later
564        self.member_of = []
565        self.members = []
566        self.event_notifications = []
567        self.report_notifications = []
568        self.daemon_notifications = []
569        self.job_notifications = []
570        self.attach_converted_pdf_instead_of_link = False
571
572        if elem is not None:
573            # Can only be instantiated by XML file since we have no
574            self.name = elem.parse_str(ELEM_NAME, None)
575            self.description = elem.parse_str(ELEM_DESCRIPTION, None)
576            # Users (or later, roles) who are members of this role
577            self.members = elem.find(ELEM_MEMBERS)
578            # Emails when an event is raised
579            self.event_notifications = elem.parse_strs(ELEM_EVENT_NOTIFICATION)
580            # Emails sent when a report is generated
581            self.report_notifications = elem.parse_strs(ELEM_REPORT_NOTIFICATION)
582            # Activity notifications - we could allow for alerts when a type of job runs
583            # very basic uses though - i.e. monitoring for weird file ingestions I guess
584            # supervisord state changes. Interface using role tool
585            self.daemon_notifications = elem.parse_strs(ELEM_DAEMON_NOTIFICATION)
586            # Subscribe to job state changes only - when jobs for a sid change state
587            self.job_notifications = elem.parse_strs(ELEM_JOB_NOTIFICATION)
588            self.attach_converted_pdf_instead_of_link = elem.parse_bool(
589                ELEM_ATTACH_CONVERTED_PDF_INSTEAD_OF_LINK, False)
590
591    def __str__(self):
592        return 'Role({name})'.format(name=self.name)
593
594    def match_event(self, event):
595        """Test if we send notifications on `event`."""
596        for notify in self.event_notifications:
597            if fnmatch(event.event_class.name.lower(), notify.lower()):
598                return True
599
600        return False
601
602    def match_report(self, activity, sid, start, stop):
603        """Test if we match a report."""
604        for notify in self.report_notifications:
605            if fnmatch(activity.name.lower(), notify.lower()):
606                return True
607
608        return False
609
610    def match_job(self, activity):
611        """Test if we match a job activity."""
612        for notify in self.job_notifications:
613            if fnmatch(activity.name.lower(), notify.lower()):
614                return True
615
616        return False
617
618    def match_daemon(self, daemon):
619        """Test if we match the daemon whose state has changed."""
620        for notify in self.daemon_notifications:
621            if fnmatch(daemon.lower(), notify.lower()):
622                return True
623
624        return False
625
626    def resolve(self):
627        """Convert string references to User or Role objects.
628
629        Called as a second pass after the basic objects have been created."""
630        # logger.debug('Resolving {n}'.format(n=self.name))
631        if isinstance(self.members, list):
632            # already done
633            logger.debug('  already dun')
634            return
635
636        resolved = []
637        if self.members is not None:
638            for member in self.members.findall():
639                # print('found child',member.tag,'text',member.text)
640                # print('found child',member.tag)
641                if member.tag == ELEM_EMAIL:
642                    new_member = self.manager.find_user_or_role(email=member.text, create=True)
643
644                elif member.tag == ELEM_NAME:
645                    new_member = self.manager.find_user_or_role(name=member.text)
646
647                elif member.tag == ELEM_USERNAME:
648                    new_member = self.manager.find_user_or_role(username=member.text)
649
650                else:
651                    raise ValueError('Cannot resolve tag {t} text {tt} inside role {r}'.format(
652                        t=member.tag, tt=member.text, r=self.name))
653
654                # print('  resolved as', new_member)
655                resolved.append(new_member)
656
657        self.members = resolved
658
659    def match_criteria(self, general=None, username=None, name=None, email=None):
660        """See if our username, name or email match supplied.
661
662        If given `general` can match against any attribute and use wildcards."""
663        if general is not None:
664            if self.name is not None and fnmatch(self.name.lower(), general.lower()):
665                return True
666
667        if name is None:
668            return False
669
670        return self.name.lower() == name.lower()
671
672class User(Role):
673    """Representation of a person who can receive email alerts.
674
675    Can be created explicitly with a <user> top level element in the roles file.
676    Or implicitly by refering to an <email> address in a role subscription.
677    """
678    # Adds username and email support
679    # Can be created explicitly by a roles.xml <user> element
680    # or implicitly by being mentioned in a role
681    # It's like a Role but adds username and email
682    # And can't have members
683    # Email is optional you can have users that just get notifications written
684    # to email.log
685    def __init__(self, manager, elem=None, email=None):
686        super().__init__(manager, elem)
687        if email is None:
688            self.email = email
689
690        elif '<' in email or '>' in email:
691            # Look for "A B <a.b@c.com>" notation and store name and email separately
692            match = NAME_EMAIL_MATCH.match(email)
693            if match is None:
694                raise ValueError('Cannot decode email "{e}"'.format(e=email))
695
696            groups = match.groupdict()
697            self.name = groups['name']
698            self.email = groups['email']
699
700        else:
701            # Assume `email` is a simple address
702            self.email = email
703
704        if self.email is not None and '@' not in self.email:
705            logger.warning('Suspicious email address: {e}'.format(e=self.email))
706
707        self.username = None
708
709        if elem is not None:
710            self.username = elem.parse_str(ELEM_USERNAME, None)
711            self.email = elem.parse_str(ELEM_EMAIL, None)  # this is just the email address
712        # If specified as "Name <email>" if gets instantiated as
713        # name and email members
714
715    # def show(self, target=sys.stdout):
716        # pass
717
718    def __str__(self):
719        result = []
720        if self.username is not None:
721            result.append(self.username)
722
723        if self.name is not None:
724            result.append('"{n}"'.format(n=self.name))
725
726        if self.email is not None:
727            result.append('<{e}>'.format(e=self.email))
728
729        return ' '.join(result)
730
731    def match_criteria(self, general=None, username=None, name=None, email=None):
732        """Test if we match against any of the criteria.
733
734        If `general` is set test as a wildcard against all attributes."""
735        if general is not None:
736            if self.username is not None and fnmatch(self.username.lower(), general.lower()):
737                return True
738
739            if self.name is not None and fnmatch(self.name.lower(), general.lower()):
740                return True
741
742            if self.email is not None and fnmatch(self.email.lower(), general.lower()):
743                return True
744
745        if username is not None:
746            if self.username is None:
747                return False
748
749            return username.lower() == self.username.lower()
750
751        if name is not None:
752            if self.name is None:
753                return False
754
755            return name.lower() == self.name.lower()
756
757        if email is not None:
758            if self.email is None:
759                return False
760
761            return email.lower() == self.email.lower()
762
763        return False