##// END OF EJS Templates
email: send comment and pullrequest mails with the author's name in 'From'...
Thomas De Schampheleire -
r5455:c935bcaf default
parent child Browse files
Show More
@@ -1,74 +1,79 b''
1 1 .. _email:
2 2
3 3 ==============
4 4 Email settings
5 5 ==============
6 6
7 7 The Kallithea configuration file has several email related settings. When
8 8 these contain correct values, Kallithea will send email in the situations
9 9 described below. If the email configuration is not correct so that emails
10 10 cannot be sent, all mails will show up in the log output.
11 11
12 12 Before any email can be sent, an SMTP server has to be configured using the
13 13 configuration file setting ``smtp_server``. If required for that server, specify
14 14 a username (``smtp_username``) and password (``smtp_password``), a non-standard
15 15 port (``smtp_port``), encryption settings (``smtp_use_tls`` or ``smtp_use_ssl``)
16 16 and/or specific authentication parameters (``smtp_auth``).
17 17
18 18
19 19 Application emails
20 20 ------------------
21 21
22 22 Kallithea sends an email to `users` on several occasions:
23 23
24 24 - when comments are given on one of their changesets
25 25 - when comments are given on changesets they are reviewer on or on which they
26 26 commented regardless
27 27 - when they are invited as reviewer in pull requests
28 28 - when they request a password reset
29 29
30 30 Kallithea sends an email to all `administrators` upon new account registration.
31 31 Administrators are users with the ``Admin`` flag set on the *Admin > Users*
32 32 page.
33 33
34 34 When Kallithea wants to send an email but due to an error cannot correctly
35 35 determine the intended recipients, the administrators and the addresses
36 36 specified in ``email_to`` in the configuration file are used as fallback.
37 37
38 38 Recipients will see these emails originating from the sender specified in the
39 39 ``app_email_from`` setting in the configuration file. This setting can either
40 40 contain only an email address, like `kallithea-noreply@example.com`, or both
41 41 a name and an address in the following format: `Kallithea
42 <kallithea-noreply@example.com>`. The subject of these emails can
43 optionally be prefixed with the value of ``email_prefix`` in the configuration
44 file.
42 <kallithea-noreply@example.com>`. However, if the email is sent due to an
43 action of a particular user, for example when a comment is given or a pull
44 request created, the name of that user will be combined with the email address
45 specified in ``app_email_from`` to form the sender (and any name part in that
46 configuration setting disregarded).
47
48 The subject of these emails can optionally be prefixed with the value of
49 ``email_prefix`` in the configuration file.
45 50
46 51
47 52 Error emails
48 53 ------------
49 54
50 55 When an exception occurs in Kallithea -- and unless interactive debugging is
51 56 enabled using ``set debug = true`` in the ``[app:main]`` section of the
52 57 configuration file -- an email with exception details is sent by WebError_'s
53 58 ``ErrorMiddleware`` to the addresses specified in ``email_to`` in the
54 59 configuration file.
55 60
56 61 Recipients will see these emails originating from the sender specified in the
57 62 ``error_email_from`` setting in the configuration file. This setting can either
58 63 contain only an email address, like `kallithea-noreply@example.com`, or both
59 64 a name and an address in the following format: `Kallithea Errors
60 65 <kallithea-noreply@example.com>`.
61 66
62 67 *Note:* The WebError_ package does not respect ``smtp_port`` and assumes the
63 68 standard SMTP port (25). If you have a remote SMTP server with a different port,
64 69 you could set up a local forwarding SMTP server on port 25.
65 70
66 71
67 72 References
68 73 ----------
69 74
70 75 - `Error Middleware (Pylons documentation) <http://pylons-webframework.readthedocs.org/en/latest/debugging.html#error-middleware>`_
71 76 - `ErrorHandler (Pylons modules documentation) <http://pylons-webframework.readthedocs.org/en/latest/modules/middleware.html#pylons.middleware.ErrorHandler>`_
72 77
73 78
74 79 .. _WebError: https://pypi.python.org/pypi/WebError
@@ -1,510 +1,530 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.lib.celerylib.tasks
16 16 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
17 17
18 18 Kallithea task modules, containing all task that suppose to be run
19 19 by celery daemon
20 20
21 21 This file was forked by the Kallithea project in July 2014.
22 22 Original author and date, and relevant copyright and licensing information is below:
23 23 :created_on: Oct 6, 2010
24 24 :author: marcink
25 25 :copyright: (c) 2013 RhodeCode GmbH, and others.
26 26 :license: GPLv3, see LICENSE.md for more details.
27 27 """
28 28
29 29 from celery.decorators import task
30 30
31 31 import os
32 32 import traceback
33 33 import logging
34 import rfc822
34 35 from os.path import join as jn
35 36
36 37 from time import mktime
37 38 from operator import itemgetter
38 39 from string import lower
39 40
40 41 from pylons import config
41 42
42 43 from kallithea import CELERY_ON
43 44 from kallithea.lib.celerylib import run_task, locked_task, dbsession, \
44 45 str2bool, __get_lockkey, LockHeld, DaemonLock, get_session
45 46 from kallithea.lib.helpers import person
46 47 from kallithea.lib.rcmail.smtp_mailer import SmtpMailer
47 48 from kallithea.lib.utils import add_cache, action_logger
49 from kallithea.lib.vcs.utils import author_email
48 50 from kallithea.lib.compat import json, OrderedDict
49 51 from kallithea.lib.hooks import log_create_repository
50 52
51 53 from kallithea.model.db import Statistics, Repository, User
52 54
53 55
54 56 add_cache(config) # pragma: no cover
55 57
56 58 __all__ = ['whoosh_index', 'get_commits_stats', 'send_email']
57 59
58 60
59 61 def get_logger(cls):
60 62 if CELERY_ON:
61 63 try:
62 64 return cls.get_logger()
63 65 except AttributeError:
64 66 pass
65 67 return logging.getLogger(__name__)
66 68
67 69
68 70 @task(ignore_result=True)
69 71 @locked_task
70 72 @dbsession
71 73 def whoosh_index(repo_location, full_index):
72 74 from kallithea.lib.indexers.daemon import WhooshIndexingDaemon
73 75 DBS = get_session()
74 76
75 77 index_location = config['index_dir']
76 78 WhooshIndexingDaemon(index_location=index_location,
77 79 repo_location=repo_location, sa=DBS)\
78 80 .run(full_index=full_index)
79 81
80 82
81 83 @task(ignore_result=True)
82 84 @dbsession
83 85 def get_commits_stats(repo_name, ts_min_y, ts_max_y, recurse_limit=100):
84 86 log = get_logger(get_commits_stats)
85 87 DBS = get_session()
86 88 lockkey = __get_lockkey('get_commits_stats', repo_name, ts_min_y,
87 89 ts_max_y)
88 90 lockkey_path = config['app_conf']['cache_dir']
89 91
90 92 log.info('running task with lockkey %s', lockkey)
91 93
92 94 try:
93 95 lock = l = DaemonLock(file_=jn(lockkey_path, lockkey))
94 96
95 97 # for js data compatibility cleans the key for person from '
96 98 akc = lambda k: person(k).replace('"', "")
97 99
98 100 co_day_auth_aggr = {}
99 101 commits_by_day_aggregate = {}
100 102 repo = Repository.get_by_repo_name(repo_name)
101 103 if repo is None:
102 104 return True
103 105
104 106 repo = repo.scm_instance
105 107 repo_size = repo.count()
106 108 # return if repo have no revisions
107 109 if repo_size < 1:
108 110 lock.release()
109 111 return True
110 112
111 113 skip_date_limit = True
112 114 parse_limit = int(config['app_conf'].get('commit_parse_limit'))
113 115 last_rev = None
114 116 last_cs = None
115 117 timegetter = itemgetter('time')
116 118
117 119 dbrepo = DBS.query(Repository)\
118 120 .filter(Repository.repo_name == repo_name).scalar()
119 121 cur_stats = DBS.query(Statistics)\
120 122 .filter(Statistics.repository == dbrepo).scalar()
121 123
122 124 if cur_stats is not None:
123 125 last_rev = cur_stats.stat_on_revision
124 126
125 127 if last_rev == repo.get_changeset().revision and repo_size > 1:
126 128 # pass silently without any work if we're not on first revision or
127 129 # current state of parsing revision(from db marker) is the
128 130 # last revision
129 131 lock.release()
130 132 return True
131 133
132 134 if cur_stats:
133 135 commits_by_day_aggregate = OrderedDict(json.loads(
134 136 cur_stats.commit_activity_combined))
135 137 co_day_auth_aggr = json.loads(cur_stats.commit_activity)
136 138
137 139 log.debug('starting parsing %s', parse_limit)
138 140 lmktime = mktime
139 141
140 142 last_rev = last_rev + 1 if last_rev >= 0 else 0
141 143 log.debug('Getting revisions from %s to %s',
142 144 last_rev, last_rev + parse_limit
143 145 )
144 146 for cs in repo[last_rev:last_rev + parse_limit]:
145 147 log.debug('parsing %s', cs)
146 148 last_cs = cs # remember last parsed changeset
147 149 k = lmktime([cs.date.timetuple()[0], cs.date.timetuple()[1],
148 150 cs.date.timetuple()[2], 0, 0, 0, 0, 0, 0])
149 151
150 152 if akc(cs.author) in co_day_auth_aggr:
151 153 try:
152 154 l = [timegetter(x) for x in
153 155 co_day_auth_aggr[akc(cs.author)]['data']]
154 156 time_pos = l.index(k)
155 157 except ValueError:
156 158 time_pos = None
157 159
158 160 if time_pos >= 0 and time_pos is not None:
159 161
160 162 datadict = \
161 163 co_day_auth_aggr[akc(cs.author)]['data'][time_pos]
162 164
163 165 datadict["commits"] += 1
164 166 datadict["added"] += len(cs.added)
165 167 datadict["changed"] += len(cs.changed)
166 168 datadict["removed"] += len(cs.removed)
167 169
168 170 else:
169 171 if k >= ts_min_y and k <= ts_max_y or skip_date_limit:
170 172
171 173 datadict = {"time": k,
172 174 "commits": 1,
173 175 "added": len(cs.added),
174 176 "changed": len(cs.changed),
175 177 "removed": len(cs.removed),
176 178 }
177 179 co_day_auth_aggr[akc(cs.author)]['data']\
178 180 .append(datadict)
179 181
180 182 else:
181 183 if k >= ts_min_y and k <= ts_max_y or skip_date_limit:
182 184 co_day_auth_aggr[akc(cs.author)] = {
183 185 "label": akc(cs.author),
184 186 "data": [{"time":k,
185 187 "commits":1,
186 188 "added":len(cs.added),
187 189 "changed":len(cs.changed),
188 190 "removed":len(cs.removed),
189 191 }],
190 192 "schema": ["commits"],
191 193 }
192 194
193 195 #gather all data by day
194 196 if k in commits_by_day_aggregate:
195 197 commits_by_day_aggregate[k] += 1
196 198 else:
197 199 commits_by_day_aggregate[k] = 1
198 200
199 201 overview_data = sorted(commits_by_day_aggregate.items(),
200 202 key=itemgetter(0))
201 203
202 204 if not co_day_auth_aggr:
203 205 co_day_auth_aggr[akc(repo.contact)] = {
204 206 "label": akc(repo.contact),
205 207 "data": [0, 1],
206 208 "schema": ["commits"],
207 209 }
208 210
209 211 stats = cur_stats if cur_stats else Statistics()
210 212 stats.commit_activity = json.dumps(co_day_auth_aggr)
211 213 stats.commit_activity_combined = json.dumps(overview_data)
212 214
213 215 log.debug('last revision %s', last_rev)
214 216 leftovers = len(repo.revisions[last_rev:])
215 217 log.debug('revisions to parse %s', leftovers)
216 218
217 219 if last_rev == 0 or leftovers < parse_limit:
218 220 log.debug('getting code trending stats')
219 221 stats.languages = json.dumps(__get_codes_stats(repo_name))
220 222
221 223 try:
222 224 stats.repository = dbrepo
223 225 stats.stat_on_revision = last_cs.revision if last_cs else 0
224 226 DBS.add(stats)
225 227 DBS.commit()
226 228 except:
227 229 log.error(traceback.format_exc())
228 230 DBS.rollback()
229 231 lock.release()
230 232 return False
231 233
232 234 # final release
233 235 lock.release()
234 236
235 237 # execute another task if celery is enabled
236 238 if len(repo.revisions) > 1 and CELERY_ON and recurse_limit > 0:
237 239 recurse_limit -= 1
238 240 run_task(get_commits_stats, repo_name, ts_min_y, ts_max_y,
239 241 recurse_limit)
240 242 if recurse_limit <= 0:
241 243 log.debug('Breaking recursive mode due to reach of recurse limit')
242 244 return True
243 245 except LockHeld:
244 246 log.info('LockHeld')
245 247 return 'Task with key %s already running' % lockkey
246 248
247 249
248 250 @task(ignore_result=True)
249 251 @dbsession
250 def send_email(recipients, subject, body='', html_body='', headers=None):
252 def send_email(recipients, subject, body='', html_body='', headers=None, author=None):
251 253 """
252 254 Sends an email with defined parameters from the .ini files.
253 255
254 256 :param recipients: list of recipients, if this is None, the defined email
255 257 address from field 'email_to' and all admins is used instead
256 258 :param subject: subject of the mail
257 259 :param body: body of the mail
258 260 :param html_body: html version of body
261 :param headers: dictionary of prepopulated e-mail headers
262 :param author: User object of the author of this mail, if known and relevant
259 263 """
260 264 log = get_logger(send_email)
261 265 assert isinstance(recipients, list), recipients
266 if headers is None:
267 headers = {}
268 else:
269 # do not modify the original headers object passed by the caller
270 headers = headers.copy()
262 271
263 272 email_config = config
264 273 email_prefix = email_config.get('email_prefix', '')
265 274 if email_prefix:
266 275 subject = "%s %s" % (email_prefix, subject)
267 276
268 277 if not recipients:
269 278 # if recipients are not defined we send to email_config + all admins
270 279 recipients = [u.email for u in User.query()
271 280 .filter(User.admin == True).all()]
272 281 if email_config.get('email_to') is not None:
273 282 recipients += [email_config.get('email_to')]
274 283
275 284 # If there are still no recipients, there are no admins and no address
276 285 # configured in email_to, so return.
277 286 if not recipients:
278 287 log.error("No recipients specified and no fallback available.")
279 288 return False
280 289
281 290 log.warning("No recipients specified for '%s' - sending to admins %s", subject, ' '.join(recipients))
282 291
283 mail_from = email_config.get('app_email_from', 'Kallithea')
292 # SMTP sender
293 envelope_from = email_config.get('app_email_from', 'Kallithea')
294 # 'From' header
295 if author is not None:
296 # set From header based on author but with a generic e-mail address
297 # In case app_email_from is in "Some Name <e-mail>" format, we first
298 # extract the e-mail address.
299 envelope_addr = author_email(envelope_from)
300 headers['From'] = '"%s" <%s>' % (
301 rfc822.quote('%s (no-reply)' % author.full_name_or_username),
302 envelope_addr)
303
284 304 user = email_config.get('smtp_username')
285 305 passwd = email_config.get('smtp_password')
286 306 mail_server = email_config.get('smtp_server')
287 307 mail_port = email_config.get('smtp_port')
288 308 tls = str2bool(email_config.get('smtp_use_tls'))
289 309 ssl = str2bool(email_config.get('smtp_use_ssl'))
290 310 debug = str2bool(email_config.get('debug'))
291 311 smtp_auth = email_config.get('smtp_auth')
292 312
293 313 logmsg = ("Mail details:\n"
294 314 "recipients: %s\n"
295 315 "headers: %s\n"
296 316 "subject: %s\n"
297 317 "body:\n%s\n"
298 318 "html:\n%s\n"
299 319 % (' '.join(recipients), headers, subject, body, html_body))
300 320
301 321 if mail_server:
302 322 log.debug("Sending e-mail. " + logmsg)
303 323 else:
304 324 log.error("SMTP mail server not configured - cannot send e-mail.")
305 325 log.warning(logmsg)
306 326 return False
307 327
308 328 try:
309 m = SmtpMailer(mail_from, user, passwd, mail_server, smtp_auth,
329 m = SmtpMailer(envelope_from, user, passwd, mail_server, smtp_auth,
310 330 mail_port, ssl, tls, debug=debug)
311 331 m.send(recipients, subject, body, html_body, headers=headers)
312 332 except:
313 333 log.error('Mail sending failed')
314 334 log.error(traceback.format_exc())
315 335 return False
316 336 return True
317 337
318 338 @task(ignore_result=False)
319 339 @dbsession
320 340 def create_repo(form_data, cur_user):
321 341 from kallithea.model.repo import RepoModel
322 342 from kallithea.model.user import UserModel
323 343 from kallithea.model.db import Setting
324 344
325 345 log = get_logger(create_repo)
326 346 DBS = get_session()
327 347
328 348 cur_user = UserModel(DBS)._get_user(cur_user)
329 349
330 350 owner = cur_user
331 351 repo_name = form_data['repo_name']
332 352 repo_name_full = form_data['repo_name_full']
333 353 repo_type = form_data['repo_type']
334 354 description = form_data['repo_description']
335 355 private = form_data['repo_private']
336 356 clone_uri = form_data.get('clone_uri')
337 357 repo_group = form_data['repo_group']
338 358 landing_rev = form_data['repo_landing_rev']
339 359 copy_fork_permissions = form_data.get('copy_permissions')
340 360 copy_group_permissions = form_data.get('repo_copy_permissions')
341 361 fork_of = form_data.get('fork_parent_id')
342 362 state = form_data.get('repo_state', Repository.STATE_PENDING)
343 363
344 364 # repo creation defaults, private and repo_type are filled in form
345 365 defs = Setting.get_default_repo_settings(strip_prefix=True)
346 366 enable_statistics = defs.get('repo_enable_statistics')
347 367 enable_locking = defs.get('repo_enable_locking')
348 368 enable_downloads = defs.get('repo_enable_downloads')
349 369
350 370 try:
351 371 repo = RepoModel(DBS)._create_repo(
352 372 repo_name=repo_name_full,
353 373 repo_type=repo_type,
354 374 description=description,
355 375 owner=owner,
356 376 private=private,
357 377 clone_uri=clone_uri,
358 378 repo_group=repo_group,
359 379 landing_rev=landing_rev,
360 380 fork_of=fork_of,
361 381 copy_fork_permissions=copy_fork_permissions,
362 382 copy_group_permissions=copy_group_permissions,
363 383 enable_statistics=enable_statistics,
364 384 enable_locking=enable_locking,
365 385 enable_downloads=enable_downloads,
366 386 state=state
367 387 )
368 388
369 389 action_logger(cur_user, 'user_created_repo',
370 390 form_data['repo_name_full'], '', DBS)
371 391
372 392 DBS.commit()
373 393 # now create this repo on Filesystem
374 394 RepoModel(DBS)._create_filesystem_repo(
375 395 repo_name=repo_name,
376 396 repo_type=repo_type,
377 397 repo_group=RepoModel(DBS)._get_repo_group(repo_group),
378 398 clone_uri=clone_uri,
379 399 )
380 400 repo = Repository.get_by_repo_name(repo_name_full)
381 401 log_create_repository(repo.get_dict(), created_by=owner.username)
382 402
383 403 # update repo changeset caches initially
384 404 repo.update_changeset_cache()
385 405
386 406 # set new created state
387 407 repo.set_state(Repository.STATE_CREATED)
388 408 DBS.commit()
389 409 except Exception as e:
390 410 log.warning('Exception %s occurred when forking repository, '
391 411 'doing cleanup...' % e)
392 412 # rollback things manually !
393 413 repo = Repository.get_by_repo_name(repo_name_full)
394 414 if repo:
395 415 Repository.delete(repo.repo_id)
396 416 DBS.commit()
397 417 RepoModel(DBS)._delete_filesystem_repo(repo)
398 418 raise
399 419
400 420 # it's an odd fix to make celery fail task when exception occurs
401 421 def on_failure(self, *args, **kwargs):
402 422 pass
403 423
404 424 return True
405 425
406 426
407 427 @task(ignore_result=False)
408 428 @dbsession
409 429 def create_repo_fork(form_data, cur_user):
410 430 """
411 431 Creates a fork of repository using interval VCS methods
412 432
413 433 :param form_data:
414 434 :param cur_user:
415 435 """
416 436 from kallithea.model.repo import RepoModel
417 437 from kallithea.model.user import UserModel
418 438
419 439 log = get_logger(create_repo_fork)
420 440 DBS = get_session()
421 441
422 442 base_path = Repository.base_path()
423 443 cur_user = UserModel(DBS)._get_user(cur_user)
424 444
425 445 repo_name = form_data['repo_name'] # fork in this case
426 446 repo_name_full = form_data['repo_name_full']
427 447
428 448 repo_type = form_data['repo_type']
429 449 owner = cur_user
430 450 private = form_data['private']
431 451 clone_uri = form_data.get('clone_uri')
432 452 repo_group = form_data['repo_group']
433 453 landing_rev = form_data['landing_rev']
434 454 copy_fork_permissions = form_data.get('copy_permissions')
435 455
436 456 try:
437 457 fork_of = RepoModel(DBS)._get_repo(form_data.get('fork_parent_id'))
438 458
439 459 RepoModel(DBS)._create_repo(
440 460 repo_name=repo_name_full,
441 461 repo_type=repo_type,
442 462 description=form_data['description'],
443 463 owner=owner,
444 464 private=private,
445 465 clone_uri=clone_uri,
446 466 repo_group=repo_group,
447 467 landing_rev=landing_rev,
448 468 fork_of=fork_of,
449 469 copy_fork_permissions=copy_fork_permissions
450 470 )
451 471 action_logger(cur_user, 'user_forked_repo:%s' % repo_name_full,
452 472 fork_of.repo_name, '', DBS)
453 473 DBS.commit()
454 474
455 475 update_after_clone = form_data['update_after_clone'] # FIXME - unused!
456 476 source_repo_path = os.path.join(base_path, fork_of.repo_name)
457 477
458 478 # now create this repo on Filesystem
459 479 RepoModel(DBS)._create_filesystem_repo(
460 480 repo_name=repo_name,
461 481 repo_type=repo_type,
462 482 repo_group=RepoModel(DBS)._get_repo_group(repo_group),
463 483 clone_uri=source_repo_path,
464 484 )
465 485 repo = Repository.get_by_repo_name(repo_name_full)
466 486 log_create_repository(repo.get_dict(), created_by=owner.username)
467 487
468 488 # update repo changeset caches initially
469 489 repo.update_changeset_cache()
470 490
471 491 # set new created state
472 492 repo.set_state(Repository.STATE_CREATED)
473 493 DBS.commit()
474 494 except Exception as e:
475 495 log.warning('Exception %s occurred when forking repository, '
476 496 'doing cleanup...' % e)
477 497 #rollback things manually !
478 498 repo = Repository.get_by_repo_name(repo_name_full)
479 499 if repo:
480 500 Repository.delete(repo.repo_id)
481 501 DBS.commit()
482 502 RepoModel(DBS)._delete_filesystem_repo(repo)
483 503 raise
484 504
485 505 # it's an odd fix to make celery fail task when exception occurs
486 506 def on_failure(self, *args, **kwargs):
487 507 pass
488 508
489 509 return True
490 510
491 511
492 512 def __get_codes_stats(repo_name):
493 513 from kallithea.config.conf import LANGUAGES_EXTENSIONS_MAP
494 514 repo = Repository.get_by_repo_name(repo_name).scm_instance
495 515
496 516 tip = repo.get_changeset()
497 517 code_stats = {}
498 518
499 519 def aggregate(cs):
500 520 for f in cs[2]:
501 521 ext = lower(f.extension)
502 522 if ext in LANGUAGES_EXTENSIONS_MAP.keys() and not f.is_binary:
503 523 if ext in code_stats:
504 524 code_stats[ext] += 1
505 525 else:
506 526 code_stats[ext] = 1
507 527
508 528 map(aggregate, tip.walk('/'))
509 529
510 530 return code_stats or {}
@@ -1,342 +1,342 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.model.notification
16 16 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
17 17
18 18 Model for notifications
19 19
20 20
21 21 This file was forked by the Kallithea project in July 2014.
22 22 Original author and date, and relevant copyright and licensing information is below:
23 23 :created_on: Nov 20, 2011
24 24 :author: marcink
25 25 :copyright: (c) 2013 RhodeCode GmbH, and others.
26 26 :license: GPLv3, see LICENSE.md for more details.
27 27 """
28 28
29 29 import logging
30 30 import traceback
31 31
32 32 from pylons import tmpl_context as c
33 33 from pylons.i18n.translation import _
34 34 from sqlalchemy.orm import joinedload, subqueryload
35 35
36 36 import kallithea
37 37 from kallithea.lib import helpers as h
38 38 from kallithea.lib.utils2 import safe_unicode
39 39 from kallithea.model import BaseModel
40 40 from kallithea.model.db import Notification, User, UserNotification
41 41 from kallithea.model.meta import Session
42 42
43 43 log = logging.getLogger(__name__)
44 44
45 45
46 46 class NotificationModel(BaseModel):
47 47
48 48 cls = Notification
49 49
50 50 def __get_notification(self, notification):
51 51 if isinstance(notification, Notification):
52 52 return notification
53 53 elif isinstance(notification, (int, long)):
54 54 return Notification.get(notification)
55 55 else:
56 56 if notification is not None:
57 57 raise Exception('notification must be int, long or Instance'
58 58 ' of Notification got %s' % type(notification))
59 59
60 60 def create(self, created_by, subject, body, recipients=None,
61 61 type_=Notification.TYPE_MESSAGE, with_email=True,
62 62 email_kwargs={}):
63 63 """
64 64
65 65 Creates notification of given type
66 66
67 67 :param created_by: int, str or User instance. User who created this
68 68 notification
69 69 :param subject:
70 70 :param body:
71 71 :param recipients: list of int, str or User objects, when None
72 72 is given send to all admins
73 73 :param type_: type of notification
74 74 :param with_email: send email with this notification
75 75 :param email_kwargs: additional dict to pass as args to email template
76 76 """
77 77 from kallithea.lib.celerylib import tasks, run_task
78 78
79 79 if recipients and not getattr(recipients, '__iter__', False):
80 80 raise Exception('recipients must be a list or iterable')
81 81
82 82 created_by_obj = self._get_user(created_by)
83 83
84 84 recipients_objs = []
85 85 if recipients:
86 86 for u in recipients:
87 87 obj = self._get_user(u)
88 88 if obj is not None:
89 89 recipients_objs.append(obj)
90 90 else:
91 91 # TODO: inform user that requested operation couldn't be completed
92 92 log.error('cannot email unknown user %r', u)
93 93 recipients_objs = set(recipients_objs)
94 94 log.debug('sending notifications %s to %s',
95 95 type_, recipients_objs
96 96 )
97 97 elif recipients is None:
98 98 # empty recipients means to all admins
99 99 recipients_objs = User.query().filter(User.admin == True).all()
100 100 log.debug('sending notifications %s to admins: %s',
101 101 type_, recipients_objs
102 102 )
103 103 #else: silently skip notification mails?
104 104
105 105 # TODO: inform user who are notified
106 106 notif = Notification.create(
107 107 created_by=created_by_obj, subject=subject,
108 108 body=body, recipients=recipients_objs, type_=type_
109 109 )
110 110
111 111 if not with_email:
112 112 return notif
113 113
114 114 #don't send email to person who created this comment
115 115 rec_objs = set(recipients_objs).difference(set([created_by_obj]))
116 116
117 117 headers = None
118 118 if 'threading' in email_kwargs:
119 119 headers = {'References': ' '.join('<%s>' % x for x in email_kwargs['threading'])}
120 120
121 121 # send email with notification to all other participants
122 122 for rec in rec_objs:
123 123 ## this is passed into template
124 124 html_kwargs = {
125 125 'subject': subject,
126 126 'body': h.rst_w_mentions(body),
127 127 'when': h.fmt_date(notif.created_on),
128 128 'user': notif.created_by_user.username,
129 129 }
130 130
131 131 txt_kwargs = {
132 132 'subject': subject,
133 133 'body': body,
134 134 'when': h.fmt_date(notif.created_on),
135 135 'user': notif.created_by_user.username,
136 136 }
137 137
138 138 html_kwargs.update(email_kwargs)
139 139 txt_kwargs.update(email_kwargs)
140 140 email_subject = EmailNotificationModel()\
141 141 .get_email_description(type_, **txt_kwargs)
142 142 email_txt_body = EmailNotificationModel()\
143 143 .get_email_tmpl(type_, 'txt', **txt_kwargs)
144 144 email_html_body = EmailNotificationModel()\
145 145 .get_email_tmpl(type_, 'html', **html_kwargs)
146 146
147 147 run_task(tasks.send_email, [rec.email], email_subject, email_txt_body,
148 email_html_body, headers)
148 email_html_body, headers, author=created_by_obj)
149 149
150 150 return notif
151 151
152 152 def delete(self, user, notification):
153 153 # we don't want to remove actual notification just the assignment
154 154 try:
155 155 notification = self.__get_notification(notification)
156 156 user = self._get_user(user)
157 157 if notification and user:
158 158 obj = UserNotification.query()\
159 159 .filter(UserNotification.user == user)\
160 160 .filter(UserNotification.notification
161 161 == notification)\
162 162 .one()
163 163 Session().delete(obj)
164 164 return True
165 165 except Exception:
166 166 log.error(traceback.format_exc())
167 167 raise
168 168
169 169 def get_for_user(self, user, filter_=None):
170 170 """
171 171 Get notifications for given user, filter them if filter dict is given
172 172
173 173 :param user:
174 174 :param filter:
175 175 """
176 176 user = self._get_user(user)
177 177
178 178 q = UserNotification.query()\
179 179 .filter(UserNotification.user == user)\
180 180 .join((Notification, UserNotification.notification_id ==
181 181 Notification.notification_id))\
182 182 .options(joinedload('notification'))\
183 183 .options(subqueryload('notification.created_by_user'))\
184 184 .order_by(Notification.created_on.desc())
185 185
186 186 if filter_:
187 187 q = q.filter(Notification.type_.in_(filter_))
188 188
189 189 return q.all()
190 190
191 191 def mark_read(self, user, notification):
192 192 try:
193 193 notification = self.__get_notification(notification)
194 194 user = self._get_user(user)
195 195 if notification and user:
196 196 obj = UserNotification.query()\
197 197 .filter(UserNotification.user == user)\
198 198 .filter(UserNotification.notification
199 199 == notification)\
200 200 .one()
201 201 obj.read = True
202 202 Session().add(obj)
203 203 return True
204 204 except Exception:
205 205 log.error(traceback.format_exc())
206 206 raise
207 207
208 208 def mark_all_read_for_user(self, user, filter_=None):
209 209 user = self._get_user(user)
210 210 q = UserNotification.query()\
211 211 .filter(UserNotification.user == user)\
212 212 .filter(UserNotification.read == False)\
213 213 .join((Notification, UserNotification.notification_id ==
214 214 Notification.notification_id))
215 215 if filter_:
216 216 q = q.filter(Notification.type_.in_(filter_))
217 217
218 218 # this is a little inefficient but sqlalchemy doesn't support
219 219 # update on joined tables :(
220 220 for obj in q.all():
221 221 obj.read = True
222 222 Session().add(obj)
223 223
224 224 def get_unread_cnt_for_user(self, user):
225 225 user = self._get_user(user)
226 226 return UserNotification.query()\
227 227 .filter(UserNotification.read == False)\
228 228 .filter(UserNotification.user == user).count()
229 229
230 230 def get_unread_for_user(self, user):
231 231 user = self._get_user(user)
232 232 return [x.notification for x in UserNotification.query()\
233 233 .filter(UserNotification.read == False)\
234 234 .filter(UserNotification.user == user).all()]
235 235
236 236 def get_user_notification(self, user, notification):
237 237 user = self._get_user(user)
238 238 notification = self.__get_notification(notification)
239 239
240 240 return UserNotification.query()\
241 241 .filter(UserNotification.notification == notification)\
242 242 .filter(UserNotification.user == user).scalar()
243 243
244 244 def make_description(self, notification, show_age=True):
245 245 """
246 246 Creates a human readable description based on properties
247 247 of notification object
248 248 """
249 249 #alias
250 250 _n = notification
251 251
252 252 if show_age:
253 253 return {
254 254 _n.TYPE_CHANGESET_COMMENT: _('%(user)s commented on changeset %(age)s'),
255 255 _n.TYPE_MESSAGE: _('%(user)s sent message %(age)s'),
256 256 _n.TYPE_MENTION: _('%(user)s mentioned you %(age)s'),
257 257 _n.TYPE_REGISTRATION: _('%(user)s registered in Kallithea %(age)s'),
258 258 _n.TYPE_PULL_REQUEST: _('%(user)s opened new pull request %(age)s'),
259 259 _n.TYPE_PULL_REQUEST_COMMENT: _('%(user)s commented on pull request %(age)s'),
260 260 }[notification.type_] % dict(
261 261 user=notification.created_by_user.username,
262 262 age=h.age(notification.created_on),
263 263 )
264 264 else:
265 265 return {
266 266 _n.TYPE_CHANGESET_COMMENT: _('%(user)s commented on changeset at %(when)s'),
267 267 _n.TYPE_MESSAGE: _('%(user)s sent message at %(when)s'),
268 268 _n.TYPE_MENTION: _('%(user)s mentioned you at %(when)s'),
269 269 _n.TYPE_REGISTRATION: _('%(user)s registered in Kallithea at %(when)s'),
270 270 _n.TYPE_PULL_REQUEST: _('%(user)s opened new pull request at %(when)s'),
271 271 _n.TYPE_PULL_REQUEST_COMMENT: _('%(user)s commented on pull request at %(when)s'),
272 272 }[notification.type_] % dict(
273 273 user=notification.created_by_user.username,
274 274 when=h.fmt_date(notification.created_on),
275 275 )
276 276
277 277
278 278 class EmailNotificationModel(BaseModel):
279 279
280 280 TYPE_CHANGESET_COMMENT = Notification.TYPE_CHANGESET_COMMENT
281 281 TYPE_MESSAGE = Notification.TYPE_MESSAGE # only used for testing
282 282 # Notification.TYPE_MENTION is not used
283 283 TYPE_PASSWORD_RESET = 'password_link'
284 284 TYPE_REGISTRATION = Notification.TYPE_REGISTRATION
285 285 TYPE_PULL_REQUEST = Notification.TYPE_PULL_REQUEST
286 286 TYPE_PULL_REQUEST_COMMENT = Notification.TYPE_PULL_REQUEST_COMMENT
287 287 TYPE_DEFAULT = 'default'
288 288
289 289 def __init__(self):
290 290 super(EmailNotificationModel, self).__init__()
291 291 self._template_root = kallithea.CONFIG['pylons.paths']['templates'][0]
292 292 self._tmpl_lookup = kallithea.CONFIG['pylons.app_globals'].mako_lookup
293 293 self.email_types = {
294 294 self.TYPE_CHANGESET_COMMENT: 'changeset_comment',
295 295 self.TYPE_PASSWORD_RESET: 'password_reset',
296 296 self.TYPE_REGISTRATION: 'registration',
297 297 self.TYPE_DEFAULT: 'default',
298 298 self.TYPE_PULL_REQUEST: 'pull_request',
299 299 self.TYPE_PULL_REQUEST_COMMENT: 'pull_request_comment',
300 300 }
301 301 self._subj_map = {
302 302 self.TYPE_CHANGESET_COMMENT: _('[Comment from %(comment_username)s] %(repo_name)s changeset %(short_id)s on %(branch)s'),
303 303 self.TYPE_MESSAGE: 'Test Message',
304 304 # self.TYPE_PASSWORD_RESET
305 305 self.TYPE_REGISTRATION: _('New user %(new_username)s registered'),
306 306 # self.TYPE_DEFAULT
307 307 self.TYPE_PULL_REQUEST: _('[Added by %(pr_username)s] %(repo_name)s pull request %(pr_nice_id)s from %(ref)s'),
308 308 self.TYPE_PULL_REQUEST_COMMENT: _('[Comment from %(comment_username)s] %(repo_name)s pull request %(pr_nice_id)s from %(ref)s'),
309 309 }
310 310
311 311 def get_email_description(self, type_, **kwargs):
312 312 """
313 313 return subject for email based on given type
314 314 """
315 315 tmpl = self._subj_map[type_]
316 316 try:
317 317 subj = tmpl % kwargs
318 318 except KeyError as e:
319 319 log.error('error generating email subject for %r from %s: %s', type_, ','.join(self._subj_map.keys()), e)
320 320 raise
321 321 l = [safe_unicode(x) for x in [kwargs.get('status_change'), kwargs.get('closing_pr') and _('Closing')] if x]
322 322 if l:
323 323 if subj.startswith('['):
324 324 subj = '[' + ', '.join(l) + ': ' + subj[1:]
325 325 else:
326 326 subj = '[' + ', '.join(l) + '] ' + subj
327 327 return subj
328 328
329 329 def get_email_tmpl(self, type_, content_type, **kwargs):
330 330 """
331 331 return generated template for email based on given type
332 332 """
333 333
334 334 base = 'email_templates/' + self.email_types.get(type_, self.email_types[self.TYPE_DEFAULT]) + '.' + content_type
335 335 email_template = self._tmpl_lookup.get_template(base)
336 336 # translator and helpers inject
337 337 _kwargs = {'_': _,
338 338 'h': h,
339 339 'c': c}
340 340 _kwargs.update(kwargs)
341 341 log.debug('rendering tmpl %s with kwargs %s', base, _kwargs)
342 342 return email_template.render(**_kwargs)
@@ -1,91 +1,167 b''
1 1 import mock
2 2
3 3 import kallithea
4 4 from kallithea.tests import *
5 from kallithea.model.db import User
5 6
6 7 class smtplib_mock(object):
7 8
8 9 @classmethod
9 10 def SMTP(cls, server, port, local_hostname):
10 11 return smtplib_mock()
11 12
12 13 def ehlo(self):
13 14 pass
14 15 def quit(self):
15 16 pass
16 17 def sendmail(self, sender, dest, msg):
17 18 smtplib_mock.lastsender = sender
18 19 smtplib_mock.lastdest = dest
19 20 smtplib_mock.lastmsg = msg
20 21 pass
21 22
22 23 @mock.patch('kallithea.lib.rcmail.smtp_mailer.smtplib', smtplib_mock)
23 24 class TestMail(BaseTestCase):
24 25
25 26 def test_send_mail_trivial(self):
26 27 mailserver = 'smtp.mailserver.org'
27 28 recipients = ['rcpt1', 'rcpt2']
28 29 envelope_from = 'noreply@mailserver.org'
29 30 subject = 'subject'
30 31 body = 'body'
31 32 html_body = 'html_body'
32 33
33 34 config_mock = {
34 35 'smtp_server': mailserver,
35 36 'app_email_from': envelope_from,
36 37 }
37 38 with mock.patch('kallithea.lib.celerylib.tasks.config', config_mock):
38 39 kallithea.lib.celerylib.tasks.send_email(recipients, subject, body, html_body)
39 40
40 41 self.assertSetEqual(smtplib_mock.lastdest, set(recipients))
41 42 self.assertEqual(smtplib_mock.lastsender, envelope_from)
42 43 self.assertIn('From: %s' % envelope_from, smtplib_mock.lastmsg)
43 44 self.assertIn('Subject: %s' % subject, smtplib_mock.lastmsg)
44 45 self.assertIn(body, smtplib_mock.lastmsg)
45 46 self.assertIn(html_body, smtplib_mock.lastmsg)
46 47
47 48 def test_send_mail_no_recipients(self):
48 49 mailserver = 'smtp.mailserver.org'
49 50 recipients = []
50 51 envelope_from = 'noreply@mailserver.org'
51 52 email_to = 'admin@mailserver.org'
52 53 subject = 'subject'
53 54 body = 'body'
54 55 html_body = 'html_body'
55 56
56 57 config_mock = {
57 58 'smtp_server': mailserver,
58 59 'app_email_from': envelope_from,
59 60 'email_to': email_to,
60 61 }
61 62 with mock.patch('kallithea.lib.celerylib.tasks.config', config_mock):
62 63 kallithea.lib.celerylib.tasks.send_email(recipients, subject, body, html_body)
63 64
64 65 self.assertSetEqual(smtplib_mock.lastdest, set([TEST_USER_ADMIN_EMAIL, email_to]))
65 66 self.assertEqual(smtplib_mock.lastsender, envelope_from)
66 67 self.assertIn('From: %s' % envelope_from, smtplib_mock.lastmsg)
67 68 self.assertIn('Subject: %s' % subject, smtplib_mock.lastmsg)
68 69 self.assertIn(body, smtplib_mock.lastmsg)
69 70 self.assertIn(html_body, smtplib_mock.lastmsg)
70 71
71 72 def test_send_mail_no_recipients_no_email_to(self):
72 73 mailserver = 'smtp.mailserver.org'
73 74 recipients = []
74 75 envelope_from = 'noreply@mailserver.org'
75 76 subject = 'subject'
76 77 body = 'body'
77 78 html_body = 'html_body'
78 79
79 80 config_mock = {
80 81 'smtp_server': mailserver,
81 82 'app_email_from': envelope_from,
82 83 }
83 84 with mock.patch('kallithea.lib.celerylib.tasks.config', config_mock):
84 85 kallithea.lib.celerylib.tasks.send_email(recipients, subject, body, html_body)
85 86
86 87 self.assertSetEqual(smtplib_mock.lastdest, set([TEST_USER_ADMIN_EMAIL]))
87 88 self.assertEqual(smtplib_mock.lastsender, envelope_from)
88 89 self.assertIn('From: %s' % envelope_from, smtplib_mock.lastmsg)
89 90 self.assertIn('Subject: %s' % subject, smtplib_mock.lastmsg)
90 91 self.assertIn(body, smtplib_mock.lastmsg)
91 92 self.assertIn(html_body, smtplib_mock.lastmsg)
93
94 def test_send_mail_with_author(self):
95 mailserver = 'smtp.mailserver.org'
96 recipients = ['rcpt1', 'rcpt2']
97 envelope_from = 'noreply@mailserver.org'
98 subject = 'subject'
99 body = 'body'
100 html_body = 'html_body'
101 author = User.get_by_username(TEST_USER_REGULAR_LOGIN)
102
103 config_mock = {
104 'smtp_server': mailserver,
105 'app_email_from': envelope_from,
106 }
107 with mock.patch('kallithea.lib.celerylib.tasks.config', config_mock):
108 kallithea.lib.celerylib.tasks.send_email(recipients, subject, body, html_body, author=author)
109
110 self.assertSetEqual(smtplib_mock.lastdest, set(recipients))
111 self.assertEqual(smtplib_mock.lastsender, envelope_from)
112 self.assertIn('From: "Kallithea Admin (no-reply)" <%s>' % envelope_from, smtplib_mock.lastmsg)
113 self.assertIn('Subject: %s' % subject, smtplib_mock.lastmsg)
114 self.assertIn(body, smtplib_mock.lastmsg)
115 self.assertIn(html_body, smtplib_mock.lastmsg)
116
117 def test_send_mail_with_author_full_mail_from(self):
118 mailserver = 'smtp.mailserver.org'
119 recipients = ['rcpt1', 'rcpt2']
120 envelope_addr = 'noreply@mailserver.org'
121 envelope_from = 'Some Name <%s>' % envelope_addr
122 subject = 'subject'
123 body = 'body'
124 html_body = 'html_body'
125 author = User.get_by_username(TEST_USER_REGULAR_LOGIN)
126
127 config_mock = {
128 'smtp_server': mailserver,
129 'app_email_from': envelope_from,
130 }
131 with mock.patch('kallithea.lib.celerylib.tasks.config', config_mock):
132 kallithea.lib.celerylib.tasks.send_email(recipients, subject, body, html_body, author=author)
133
134 self.assertSetEqual(smtplib_mock.lastdest, set(recipients))
135 self.assertEqual(smtplib_mock.lastsender, envelope_from)
136 self.assertIn('From: "Kallithea Admin (no-reply)" <%s>' % envelope_addr, smtplib_mock.lastmsg)
137 self.assertIn('Subject: %s' % subject, smtplib_mock.lastmsg)
138 self.assertIn(body, smtplib_mock.lastmsg)
139 self.assertIn(html_body, smtplib_mock.lastmsg)
140
141 def test_send_mail_extra_headers(self):
142 mailserver = 'smtp.mailserver.org'
143 recipients = ['rcpt1', 'rcpt2']
144 envelope_from = 'noreply@mailserver.org'
145 subject = 'subject'
146 body = 'body'
147 html_body = 'html_body'
148 author = User(name='foo', lastname='(fubar) "baz"')
149 headers = {'extra': 'yes'}
150
151 config_mock = {
152 'smtp_server': mailserver,
153 'app_email_from': envelope_from,
154 }
155 with mock.patch('kallithea.lib.celerylib.tasks.config', config_mock):
156 kallithea.lib.celerylib.tasks.send_email(recipients, subject, body, html_body,
157 author=author, headers=headers)
158
159 self.assertSetEqual(smtplib_mock.lastdest, set(recipients))
160 self.assertEqual(smtplib_mock.lastsender, envelope_from)
161 self.assertIn(r'From: "foo (fubar) \"baz\" (no-reply)" <%s>' % envelope_from, smtplib_mock.lastmsg)
162 self.assertIn('Subject: %s' % subject, smtplib_mock.lastmsg)
163 self.assertIn(body, smtplib_mock.lastmsg)
164 self.assertIn(html_body, smtplib_mock.lastmsg)
165 self.assertIn('Extra: yes', smtplib_mock.lastmsg)
166 # verify that headers dict hasn't mutated by send_email
167 self.assertDictEqual(headers, {'extra': 'yes'})
General Comments 0
You need to be logged in to leave comments. Login now