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