PageRenderTime 37ms CodeModel.GetById 9ms RepoModel.GetById 1ms app.codeStats 0ms

/kitsune/users/models.py

https://gitlab.com/Guy1394/kitsune
Python | 641 lines | 624 code | 14 blank | 3 comment | 2 complexity | 33f29410e635a327f265150ce4461b42 MD5 | raw file
  1. import hashlib
  2. import logging
  3. import random
  4. import re
  5. import time
  6. from datetime import datetime, timedelta
  7. from django.conf import settings
  8. from django.contrib.auth.models import User, Group
  9. from django.contrib.sites.models import Site
  10. from django.db import models
  11. from django.utils.translation import ugettext as _, ugettext_lazy as _lazy
  12. from celery.task import task
  13. from statsd import statsd
  14. from timezones.fields import TimeZoneField
  15. from kitsune.lib.countries import COUNTRIES
  16. from kitsune.search.es_utils import UnindexMeBro
  17. from kitsune.search.models import (
  18. SearchMappingType, SearchMixin, register_for_indexing,
  19. register_mapping_type)
  20. from kitsune.sumo import email_utils
  21. from kitsune.sumo.models import ModelBase, LocaleField
  22. from kitsune.sumo.urlresolvers import reverse
  23. from kitsune.sumo.utils import auto_delete_files, chunked
  24. from kitsune.users.validators import TwitterValidator
  25. log = logging.getLogger('k.users')
  26. SHA1_RE = re.compile('^[a-f0-9]{40}$')
  27. CONTRIBUTOR_GROUP = 'Registered as contributor'
  28. @auto_delete_files
  29. class Profile(ModelBase, SearchMixin):
  30. """Profile model for django users."""
  31. user = models.OneToOneField(User, primary_key=True,
  32. verbose_name=_lazy(u'User'))
  33. name = models.CharField(max_length=255, null=True, blank=True,
  34. verbose_name=_lazy(u'Display name'))
  35. public_email = models.BooleanField( # show/hide email
  36. default=False, verbose_name=_lazy(u'Make my email public'))
  37. avatar = models.ImageField(upload_to=settings.USER_AVATAR_PATH, null=True,
  38. blank=True, verbose_name=_lazy(u'Avatar'),
  39. max_length=settings.MAX_FILEPATH_LENGTH)
  40. bio = models.TextField(null=True, blank=True,
  41. verbose_name=_lazy(u'Biography'))
  42. website = models.URLField(max_length=255, null=True, blank=True,
  43. verbose_name=_lazy(u'Website'))
  44. twitter = models.CharField(max_length=15, null=True, blank=True, validators=[TwitterValidator],
  45. verbose_name=_lazy(u'Twitter Username'))
  46. facebook = models.URLField(max_length=255, null=True, blank=True,
  47. verbose_name=_lazy(u'Facebook URL'))
  48. mozillians = models.CharField(max_length=255, null=True, blank=True,
  49. verbose_name=_lazy(u'Mozillians Username'))
  50. irc_handle = models.CharField(max_length=255, null=True, blank=True,
  51. verbose_name=_lazy(u'IRC nickname'))
  52. timezone = TimeZoneField(null=True, blank=True,
  53. verbose_name=_lazy(u'Timezone'))
  54. country = models.CharField(max_length=2, choices=COUNTRIES, null=True,
  55. blank=True, verbose_name=_lazy(u'Country'))
  56. # No city validation
  57. city = models.CharField(max_length=255, null=True, blank=True,
  58. verbose_name=_lazy(u'City'))
  59. locale = LocaleField(default=settings.LANGUAGE_CODE,
  60. verbose_name=_lazy(u'Preferred language'))
  61. first_answer_email_sent = models.BooleanField(
  62. default=False, help_text=_lazy(u'Has been sent a first answer contribution email.'))
  63. first_l10n_email_sent = models.BooleanField(
  64. default=False, help_text=_lazy(u'Has been sent a first revision contribution email.'))
  65. involved_from = models.DateField(null=True, blank=True,
  66. verbose_name=_lazy(u'Involved with Mozilla from'))
  67. class Meta(object):
  68. permissions = (('view_karma_points', 'Can view karma points'),
  69. ('deactivate_users', 'Can deactivate users'),
  70. ('screen_share', 'Can screen share'),)
  71. def __unicode__(self):
  72. try:
  73. return unicode(self.user)
  74. except Exception as exc:
  75. return unicode('%d (%r)' % (self.pk, exc))
  76. def get_absolute_url(self):
  77. return reverse('users.profile', args=[self.user_id])
  78. def clear(self):
  79. """Clears out the users profile"""
  80. self.name = ''
  81. self.public_email = False
  82. self.avatar = None
  83. self.bio = ''
  84. self.website = ''
  85. self.twitter = ''
  86. self.facebook = ''
  87. self.mozillians = ''
  88. self.irc_handle = ''
  89. self.city = ''
  90. @property
  91. def display_name(self):
  92. return self.name if self.name else self.user.username
  93. @property
  94. def twitter_usernames(self):
  95. from kitsune.customercare.models import Reply
  96. return list(
  97. Reply.objects.filter(user=self.user)
  98. .values_list('twitter_username', flat=True)
  99. .distinct())
  100. @classmethod
  101. def get_mapping_type(cls):
  102. return UserMappingType
  103. @classmethod
  104. def get_serializer(cls, serializer_type='full'):
  105. # Avoid circular import
  106. from kitsune.users import api
  107. if serializer_type == 'full':
  108. return api.ProfileSerializer
  109. elif serializer_type == 'fk':
  110. return api.ProfileFKSerializer
  111. else:
  112. raise ValueError('Unknown serializer type "{}".'.format(serializer_type))
  113. @property
  114. def last_contribution_date(self):
  115. """Get the date of the user's last contribution."""
  116. from kitsune.customercare.models import Reply
  117. from kitsune.questions.models import Answer
  118. from kitsune.wiki.models import Revision
  119. dates = []
  120. # Latest Army of Awesome reply:
  121. try:
  122. aoa_reply = Reply.objects.filter(
  123. user=self.user).latest('created')
  124. dates.append(aoa_reply.created)
  125. except Reply.DoesNotExist:
  126. pass
  127. # Latest Support Forum answer:
  128. try:
  129. answer = Answer.objects.filter(
  130. creator=self.user).latest('created')
  131. dates.append(answer.created)
  132. except Answer.DoesNotExist:
  133. pass
  134. # Latest KB Revision edited:
  135. try:
  136. revision = Revision.objects.filter(
  137. creator=self.user).latest('created')
  138. dates.append(revision.created)
  139. except Revision.DoesNotExist:
  140. pass
  141. # Latest KB Revision reviewed:
  142. try:
  143. revision = Revision.objects.filter(
  144. reviewer=self.user).latest('reviewed')
  145. # Old revisions don't have the reviewed date.
  146. dates.append(revision.reviewed or revision.created)
  147. except Revision.DoesNotExist:
  148. pass
  149. if len(dates) == 0:
  150. return None
  151. return max(dates)
  152. @property
  153. def settings(self):
  154. return self.user.settings
  155. @property
  156. def answer_helpfulness(self):
  157. # Avoid circular import
  158. from kitsune.questions.models import AnswerVote
  159. return AnswerVote.objects.filter(answer__creator=self.user, helpful=True).count()
  160. @register_mapping_type
  161. class UserMappingType(SearchMappingType):
  162. list_keys = [
  163. 'twitter_usernames',
  164. 'itwitter_usernames',
  165. ]
  166. @classmethod
  167. def get_model(cls):
  168. return Profile
  169. @classmethod
  170. def get_index_group(cls):
  171. return 'non-critical'
  172. @classmethod
  173. def get_mapping(cls):
  174. return {
  175. 'properties': {
  176. 'id': {'type': 'long'},
  177. 'model': {'type': 'string', 'index': 'not_analyzed'},
  178. 'url': {'type': 'string', 'index': 'not_analyzed'},
  179. 'indexed_on': {'type': 'integer'},
  180. 'username': {'type': 'string', 'index': 'not_analyzed'},
  181. 'display_name': {'type': 'string', 'index': 'not_analyzed'},
  182. 'twitter_usernames': {
  183. 'type': 'string',
  184. 'index': 'not_analyzed'
  185. },
  186. 'last_contribution_date': {'type': 'date'},
  187. # lower-cased versions for querying:
  188. 'iusername': {'type': 'string', 'index': 'not_analyzed'},
  189. 'idisplay_name': {'type': 'string', 'analyzer': 'whitespace'},
  190. 'itwitter_usernames': {
  191. 'type': 'string',
  192. 'index': 'not_analyzed'
  193. },
  194. 'avatar': {'type': 'string', 'index': 'not_analyzed'},
  195. 'suggest': {
  196. 'type': 'completion',
  197. 'index_analyzer': 'whitespace',
  198. 'search_analyzer': 'whitespace',
  199. 'payloads': True,
  200. }
  201. }
  202. }
  203. @classmethod
  204. def extract_document(cls, obj_id, obj=None):
  205. """Extracts interesting thing from a Thread and its Posts"""
  206. if obj is None:
  207. model = cls.get_model()
  208. obj = model.objects.select_related('user').get(pk=obj_id)
  209. if not obj.user.is_active:
  210. raise UnindexMeBro()
  211. d = {}
  212. d['id'] = obj.pk
  213. d['model'] = cls.get_mapping_type_name()
  214. d['url'] = obj.get_absolute_url()
  215. d['indexed_on'] = int(time.time())
  216. d['username'] = obj.user.username
  217. d['display_name'] = obj.display_name
  218. d['twitter_usernames'] = obj.twitter_usernames
  219. d['last_contribution_date'] = obj.last_contribution_date
  220. d['iusername'] = obj.user.username.lower()
  221. d['idisplay_name'] = obj.display_name.lower()
  222. d['itwitter_usernames'] = [u.lower() for u in obj.twitter_usernames]
  223. from kitsune.users.helpers import profile_avatar
  224. d['avatar'] = profile_avatar(obj.user, size=120)
  225. d['suggest'] = {
  226. 'input': [
  227. d['iusername'],
  228. d['idisplay_name']
  229. ],
  230. 'output': _(u'{displayname} ({username})').format(
  231. displayname=d['display_name'], username=d['username']),
  232. 'payload': {'user_id': d['id']},
  233. }
  234. return d
  235. @classmethod
  236. def suggest_completions(cls, text):
  237. """Suggest completions for the text provided."""
  238. USER_SUGGEST = 'user-suggest'
  239. es = UserMappingType.search().get_es()
  240. results = es.suggest(index=cls.get_index(), body={
  241. USER_SUGGEST: {
  242. 'text': text.lower(),
  243. 'completion': {
  244. 'field': 'suggest'
  245. }
  246. }
  247. })
  248. if results[USER_SUGGEST][0]['length'] > 0:
  249. return results[USER_SUGGEST][0]['options']
  250. return []
  251. register_for_indexing('users', Profile)
  252. def get_profile(u):
  253. try:
  254. return Profile.objects.get(user=u)
  255. except Profile.DoesNotExist:
  256. return None
  257. register_for_indexing(
  258. 'users',
  259. User,
  260. instance_to_indexee=get_profile)
  261. class Setting(ModelBase):
  262. """User specific value per setting"""
  263. user = models.ForeignKey(User, verbose_name=_lazy(u'User'),
  264. related_name='settings')
  265. name = models.CharField(max_length=100)
  266. value = models.CharField(blank=True, max_length=60,
  267. verbose_name=_lazy(u'Value'))
  268. class Meta(object):
  269. unique_together = (('user', 'name'),)
  270. def __unicode__(self):
  271. return u'%s %s:%s' % (self.user, self.name, self.value or u'[none]')
  272. @classmethod
  273. def get_for_user(cls, user, name):
  274. from kitsune.users.forms import SettingsForm
  275. form = SettingsForm()
  276. if name not in form.fields.keys():
  277. raise KeyError(("'{name}' is not a field in "
  278. "user.forms.SettingsFrom()").format(name=name))
  279. try:
  280. setting = Setting.objects.get(user=user, name=name)
  281. except Setting.DoesNotExist:
  282. value = form.fields[name].initial or ''
  283. setting = Setting.objects.create(user=user, name=name, value=value)
  284. # Cast to the field's Python type.
  285. return form.fields[name].to_python(setting.value)
  286. # Activation model and manager:
  287. # (based on http://bitbucket.org/ubernostrum/django-registration)
  288. class ConfirmationManager(models.Manager):
  289. """
  290. Custom manager for confirming keys sent by email.
  291. The methods defined here provide shortcuts for creation of instances
  292. and sending email confirmations.
  293. Activation should be done in specific managers.
  294. """
  295. def _send_email(self, confirmation_profile, url,
  296. subject, text_template, html_template,
  297. send_to, **kwargs):
  298. """
  299. Send an email using a passed in confirmation profile.
  300. Use specified url, subject, text_template, html_template and
  301. email to send_to.
  302. """
  303. current_site = Site.objects.get_current()
  304. email_kwargs = {'activation_key': confirmation_profile.activation_key,
  305. 'domain': current_site.domain,
  306. 'activate_url': url,
  307. 'login_url': reverse('users.login'),
  308. 'reg': 'main'}
  309. email_kwargs.update(kwargs)
  310. # RegistrationProfile doesn't have a locale attribute. So if
  311. # we get one of those, then we have to get the real profile
  312. # from the user.
  313. if hasattr(confirmation_profile, 'locale'):
  314. locale = confirmation_profile.locale
  315. else:
  316. locale = confirmation_profile.user.profile.locale
  317. @email_utils.safe_translation
  318. def _make_mail(locale):
  319. mail = email_utils.make_mail(
  320. subject=subject,
  321. text_template=text_template,
  322. html_template=html_template,
  323. context_vars=email_kwargs,
  324. from_email=settings.DEFAULT_FROM_EMAIL,
  325. to_email=send_to)
  326. return mail
  327. email_utils.send_messages([_make_mail(locale)])
  328. def send_confirmation_email(self, *args, **kwargs):
  329. """This is meant to be overwritten."""
  330. raise NotImplementedError
  331. def create_profile(self, user, *args, **kwargs):
  332. """
  333. Create an instance of this manager's object class for a given
  334. ``User``, and return it.
  335. The activation key will be a SHA1 hash, generated from a combination
  336. of the ``User``'s username and a random salt.
  337. """
  338. salt = hashlib.sha1(str(random.random())).hexdigest()[:5]
  339. activation_key = hashlib.sha1(salt + user.username).hexdigest()
  340. return self.create(user=user, activation_key=activation_key, **kwargs)
  341. class RegistrationManager(ConfirmationManager):
  342. def get_user(self, activation_key):
  343. """Get the user for the specified activation_key."""
  344. try:
  345. profile = self.get(activation_key=activation_key)
  346. return profile.user
  347. except self.model.DoesNotExist:
  348. return None
  349. def activate_user(self, activation_key, request=None):
  350. """
  351. Validate an activation key and activate the corresponding
  352. ``User`` if valid.
  353. If the key is valid and has not expired, return the ``User``
  354. after activating.
  355. If the key is not valid or has expired, return ``False``.
  356. """
  357. # Make sure the key we're trying conforms to the pattern of a
  358. # SHA1 hash; if it doesn't, no point trying to look it up in
  359. # the database.
  360. if SHA1_RE.search(activation_key):
  361. try:
  362. profile = self.get(activation_key=activation_key)
  363. except self.model.DoesNotExist:
  364. profile = None
  365. statsd.incr('user.activate-error.does-not-exist')
  366. reason = 'key not found'
  367. if profile:
  368. if not profile.activation_key_expired():
  369. user = profile.user
  370. user.is_active = True
  371. user.save()
  372. # We don't need the RegistrationProfile anymore, delete it.
  373. profile.delete()
  374. # If user registered as contributor, send them the
  375. # welcome email.
  376. if user.groups.filter(name=CONTRIBUTOR_GROUP):
  377. self._send_email(
  378. confirmation_profile=profile,
  379. url=None,
  380. subject=_('Welcome to SUMO!'),
  381. text_template='users/email/contributor.ltxt',
  382. html_template='users/email/contributor.html',
  383. send_to=user.email,
  384. contributor=user)
  385. return user
  386. else:
  387. statsd.incr('user.activate-error.expired')
  388. reason = 'key expired'
  389. else:
  390. statsd.incr('user.activate-error.invalid-key')
  391. reason = 'invalid key'
  392. log.warning(u'User activation failure ({r}): {k}'.format(
  393. r=reason, k=activation_key))
  394. return False
  395. def create_inactive_user(self, username, password, email,
  396. locale=settings.LANGUAGE_CODE,
  397. text_template=None, html_template=None,
  398. subject=None, email_data=None,
  399. volunteer_interest=False, **kwargs):
  400. """
  401. Create a new, inactive ``User`` and ``Profile``, generates a
  402. ``RegistrationProfile`` and email its activation key to the
  403. ``User``, returning the new ``User``.
  404. """
  405. new_user = User.objects.create_user(username, email, password)
  406. new_user.is_active = False
  407. new_user.save()
  408. Profile.objects.create(user=new_user, locale=locale)
  409. registration_profile = self.create_profile(new_user)
  410. self.send_confirmation_email(
  411. registration_profile,
  412. text_template,
  413. html_template,
  414. subject,
  415. email_data,
  416. **kwargs)
  417. if volunteer_interest:
  418. statsd.incr('user.registered-as-contributor')
  419. group = Group.objects.get(name=CONTRIBUTOR_GROUP)
  420. new_user.groups.add(group)
  421. return new_user
  422. def send_confirmation_email(self, registration_profile,
  423. text_template=None, html_template=None,
  424. subject=None, email_data=None, **kwargs):
  425. """Send the user confirmation email."""
  426. user_id = registration_profile.user.id
  427. key = registration_profile.activation_key
  428. self._send_email(
  429. confirmation_profile=registration_profile,
  430. url=reverse('users.activate', args=[user_id, key]),
  431. subject=subject or _('Please confirm your email address'),
  432. text_template=text_template or 'users/email/activate.ltxt',
  433. html_template=html_template or 'users/email/activate.html',
  434. send_to=registration_profile.user.email,
  435. expiration_days=settings.ACCOUNT_ACTIVATION_DAYS,
  436. username=registration_profile.user.username,
  437. email_data=email_data,
  438. **kwargs)
  439. def delete_expired_users(self):
  440. """
  441. Remove expired instances of this manager's object class.
  442. Accounts to be deleted are identified by searching for
  443. instances of this manager's object class with expired activation
  444. keys, and then checking to see if their associated ``User``
  445. instances have the field ``is_active`` set to ``False``; any
  446. ``User`` who is both inactive and has an expired activation
  447. key will be deleted.
  448. """
  449. days_valid = settings.ACCOUNT_ACTIVATION_DAYS
  450. expired = datetime.now() - timedelta(days=days_valid)
  451. prof_ids = self.filter(user__date_joined__lt=expired)
  452. prof_ids = prof_ids.values_list('id', flat=True)
  453. for chunk in chunked(prof_ids, 1000):
  454. _delete_registration_profiles_chunk.apply_async(args=[chunk])
  455. @task
  456. def _delete_registration_profiles_chunk(data):
  457. log_msg = u'Deleting {num} expired registration profiles.'
  458. log.info(log_msg.format(num=len(data)))
  459. qs = RegistrationProfile.objects.filter(id__in=data)
  460. for profile in qs.select_related('user'):
  461. user = profile.user
  462. profile.delete()
  463. if user and not user.is_active:
  464. user.delete()
  465. class EmailChangeManager(ConfirmationManager):
  466. def send_confirmation_email(self, email_change, new_email):
  467. """Ask for confirmation before changing a user's email."""
  468. self._send_email(
  469. confirmation_profile=email_change,
  470. url=reverse('users.confirm_email',
  471. args=[email_change.activation_key]),
  472. subject=_('Please confirm your email address'),
  473. text_template='users/email/confirm_email.ltxt',
  474. html_template='users/email/confirm_email.html',
  475. send_to=new_email)
  476. class RegistrationProfile(models.Model):
  477. """
  478. A simple profile which stores an activation key used for
  479. user account registration.
  480. Generally, you will not want to interact directly with instances
  481. of this model; the provided manager includes methods
  482. for creating and activating new accounts.
  483. """
  484. user = models.ForeignKey(User, unique=True, verbose_name=_lazy(u'user'))
  485. activation_key = models.CharField(verbose_name=_lazy(u'activation key'),
  486. max_length=40)
  487. objects = RegistrationManager()
  488. class Meta:
  489. verbose_name = _lazy(u'registration profile')
  490. verbose_name_plural = _lazy(u'registration profiles')
  491. def __unicode__(self):
  492. return u'Registration information for %s' % self.user
  493. def activation_key_expired(self):
  494. """
  495. Determine whether this ``RegistrationProfile``'s activation
  496. key has expired, returning a boolean -- ``True`` if the key
  497. has expired.
  498. Key expiration is determined by:
  499. 1. The date the user signed up is incremented by
  500. the number of days specified in the setting
  501. ``ACCOUNT_ACTIVATION_DAYS`` (which should be the number of
  502. days after signup during which a user is allowed to
  503. activate their account); if the result is less than or
  504. equal to the current date, the key has expired and this
  505. method returns ``True``.
  506. """
  507. exp_date = timedelta(days=settings.ACCOUNT_ACTIVATION_DAYS)
  508. return self.user.date_joined + exp_date <= datetime.now()
  509. activation_key_expired.boolean = True
  510. class EmailChange(models.Model):
  511. """Stores email with activation key when user requests a change."""
  512. ACTIVATED = u"ALREADY_ACTIVATED"
  513. user = models.ForeignKey(User, unique=True, verbose_name=_lazy(u'user'))
  514. activation_key = models.CharField(verbose_name=_lazy(u'activation key'),
  515. max_length=40)
  516. email = models.EmailField(db_index=True, null=True)
  517. objects = EmailChangeManager()
  518. def __unicode__(self):
  519. return u'Change email request to %s for %s' % (self.email, self.user)
  520. class Deactivation(models.Model):
  521. """Stores user deactivation logs."""
  522. user = models.ForeignKey(User, verbose_name=_lazy(u'user'),
  523. related_name='+')
  524. moderator = models.ForeignKey(User, verbose_name=_lazy(u'moderator'),
  525. related_name='deactivations')
  526. date = models.DateTimeField(default=datetime.now)
  527. def __unicode__(self):
  528. return u'%s was deactivated by %s on %s' % (self.user, self.moderator,
  529. self.date)