user.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. from hashlib import md5
  2. from django.contrib.auth.models import AnonymousUser as DjangoAnonymousUser
  3. from django.contrib.auth.models import UserManager as BaseUserManager
  4. from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
  5. from django.contrib.auth.password_validation import validate_password
  6. from django.contrib.postgres.fields import ArrayField, HStoreField, JSONField
  7. from django.core.mail import send_mail
  8. from django.db import IntegrityError, models, transaction
  9. from django.urls import reverse
  10. from django.utils import timezone
  11. from django.utils.translation import gettext_lazy as _
  12. from misago.acl.models import Role
  13. from misago.conf import settings
  14. from misago.core.pgutils import PgPartialIndex
  15. from misago.core.utils import slugify
  16. from misago.users import avatars
  17. from misago.users.audittrail import create_user_audit_trail
  18. from misago.users.signatures import is_user_signature_valid
  19. from misago.users.utils import hash_email
  20. from .rank import Rank
  21. class UserManager(BaseUserManager):
  22. @transaction.atomic
  23. def create_user(
  24. self, username, email, password=None, create_audit_trail=False,
  25. joined_from_ip=None, set_default_avatar=False, **extra_fields):
  26. from misago.users.validators import validate_email, validate_username
  27. email = self.normalize_email(email)
  28. username = self.model.normalize_username(username)
  29. if not email:
  30. raise ValueError(_("User must have an email address."))
  31. WATCH_DICT = {
  32. 'no': self.model.SUBSCRIBE_NONE,
  33. 'watch': self.model.SUBSCRIBE_NOTIFY,
  34. 'watch_email': self.model.SUBSCRIBE_ALL,
  35. }
  36. if not 'subscribe_to_started_threads' in extra_fields:
  37. new_value = WATCH_DICT[settings.subscribe_start]
  38. extra_fields['subscribe_to_started_threads'] = new_value
  39. if not 'subscribe_to_replied_threads' in extra_fields:
  40. new_value = WATCH_DICT[settings.subscribe_reply]
  41. extra_fields['subscribe_to_replied_threads'] = new_value
  42. extra_fields.update({'is_staff': False, 'is_superuser': False})
  43. now = timezone.now()
  44. user = self.model(
  45. last_login=now,
  46. joined_on=now,
  47. joined_from_ip=joined_from_ip,
  48. **extra_fields
  49. )
  50. user.set_username(username)
  51. user.set_email(email)
  52. user.set_password(password)
  53. validate_username(username)
  54. validate_email(email)
  55. if not 'rank' in extra_fields:
  56. user.rank = Rank.objects.get_default()
  57. user.save(using=self._db)
  58. if set_default_avatar:
  59. avatars.set_default_avatar(
  60. user, settings.default_avatar, settings.default_gravatar_fallback
  61. )
  62. else:
  63. # just for test purposes
  64. user.avatars = [{'size': 400, 'url': 'http://placekitten.com/400/400'}]
  65. authenticated_role = Role.objects.get(special_role='authenticated')
  66. if authenticated_role not in user.roles.all():
  67. user.roles.add(authenticated_role)
  68. user.update_acl_key()
  69. user.save(update_fields=['avatars', 'acl_key'])
  70. if create_audit_trail:
  71. create_user_audit_trail(user, user.joined_from_ip, user)
  72. # populate online tracker with default value
  73. Online.objects.create(user=user, last_click=now)
  74. return user
  75. @transaction.atomic
  76. def create_superuser(self, username, email, password, set_default_avatar=False):
  77. user = self.create_user(
  78. username,
  79. email,
  80. password=password,
  81. set_default_avatar=set_default_avatar,
  82. )
  83. try:
  84. user.rank = Rank.objects.get(name=_("Forum team"))
  85. user.update_acl_key()
  86. except Rank.DoesNotExist:
  87. pass
  88. user.is_staff = True
  89. user.is_superuser = True
  90. updated_fields = ('rank', 'acl_key', 'is_staff', 'is_superuser')
  91. user.save(update_fields=updated_fields, using=self._db)
  92. return user
  93. def get_by_username(self, username):
  94. return self.get(slug=slugify(username))
  95. def get_by_email(self, email):
  96. return self.get(email_hash=hash_email(email))
  97. def get_by_username_or_email(self, login):
  98. if '@' in login:
  99. return self.get(email_hash=hash_email(login))
  100. return self.get(slug=slugify(login))
  101. class User(AbstractBaseUser, PermissionsMixin):
  102. ACTIVATION_NONE = 0
  103. ACTIVATION_USER = 1
  104. ACTIVATION_ADMIN = 2
  105. SUBSCRIBE_NONE = 0
  106. SUBSCRIBE_NOTIFY = 1
  107. SUBSCRIBE_ALL = 2
  108. SUBSCRIBE_CHOICES = [
  109. (SUBSCRIBE_NONE, _("No")),
  110. (SUBSCRIBE_NOTIFY, _("Notify")),
  111. (SUBSCRIBE_ALL, _("Notify with e-mail")),
  112. ]
  113. LIMIT_INVITES_TO_NONE = 0
  114. LIMIT_INVITES_TO_FOLLOWED = 1
  115. LIMIT_INVITES_TO_NOBODY = 2
  116. LIMIT_INVITES_TO_CHOICES = [
  117. (LIMIT_INVITES_TO_NONE, _("Everybody")),
  118. (LIMIT_INVITES_TO_FOLLOWED, _("Users I follow")),
  119. (LIMIT_INVITES_TO_NOBODY, _("Nobody")),
  120. ]
  121. # Note that "username" field is purely for shows.
  122. # When searching users by their names, always use lowercased string
  123. # and slug field instead that is normalized around DB engines
  124. # differences in case handling.
  125. username = models.CharField(max_length=30)
  126. slug = models.CharField(max_length=30, unique=True)
  127. # Misago stores user email in two fields:
  128. # "email" holds normalized email address
  129. # "email_hash" is lowercase hash of email address used to identify account
  130. # as well as enforcing on database level that no more than one user can be
  131. # using one email address
  132. email = models.EmailField(max_length=255, db_index=True)
  133. email_hash = models.CharField(max_length=32, unique=True)
  134. joined_on = models.DateTimeField(_('joined on'), default=timezone.now)
  135. joined_from_ip = models.GenericIPAddressField(null=True, blank=True)
  136. is_hiding_presence = models.BooleanField(default=False)
  137. rank = models.ForeignKey(
  138. 'Rank',
  139. null=True,
  140. blank=True,
  141. on_delete=models.deletion.PROTECT,
  142. )
  143. title = models.CharField(max_length=255, null=True, blank=True)
  144. requires_activation = models.PositiveIntegerField(default=ACTIVATION_NONE)
  145. is_staff = models.BooleanField(
  146. _('staff status'),
  147. default=False,
  148. help_text=_('Designates whether the user can log into admin sites.'),
  149. )
  150. roles = models.ManyToManyField('misago_acl.Role')
  151. acl_key = models.CharField(max_length=12, null=True, blank=True)
  152. is_active = models.BooleanField(
  153. _('active'),
  154. db_index=True,
  155. default=True,
  156. help_text=_(
  157. "Designates whether this user should be treated as active. "
  158. "Unselect this instead of deleting accounts."
  159. ),
  160. )
  161. is_active_staff_message = models.TextField(null=True, blank=True)
  162. is_deleting_account = models.BooleanField(default=False)
  163. avatar_tmp = models.ImageField(
  164. max_length=255,
  165. upload_to=avatars.store.upload_to,
  166. null=True,
  167. blank=True,
  168. )
  169. avatar_src = models.ImageField(
  170. max_length=255,
  171. upload_to=avatars.store.upload_to,
  172. null=True,
  173. blank=True,
  174. )
  175. avatar_crop = models.CharField(max_length=255, null=True, blank=True)
  176. avatars = JSONField(null=True, blank=True)
  177. is_avatar_locked = models.BooleanField(default=False)
  178. avatar_lock_user_message = models.TextField(null=True, blank=True)
  179. avatar_lock_staff_message = models.TextField(null=True, blank=True)
  180. signature = models.TextField(null=True, blank=True)
  181. signature_parsed = models.TextField(null=True, blank=True)
  182. signature_checksum = models.CharField(max_length=64, null=True, blank=True)
  183. is_signature_locked = models.BooleanField(default=False)
  184. signature_lock_user_message = models.TextField(null=True, blank=True)
  185. signature_lock_staff_message = models.TextField(null=True, blank=True)
  186. followers = models.PositiveIntegerField(default=0)
  187. following = models.PositiveIntegerField(default=0)
  188. follows = models.ManyToManyField(
  189. 'self',
  190. related_name='followed_by',
  191. symmetrical=False,
  192. )
  193. blocks = models.ManyToManyField(
  194. 'self',
  195. related_name='blocked_by',
  196. symmetrical=False,
  197. )
  198. limits_private_thread_invites_to = models.PositiveIntegerField(
  199. default=LIMIT_INVITES_TO_NONE,
  200. choices=LIMIT_INVITES_TO_CHOICES,
  201. )
  202. unread_private_threads = models.PositiveIntegerField(default=0)
  203. sync_unread_private_threads = models.BooleanField(default=False)
  204. subscribe_to_started_threads = models.PositiveIntegerField(
  205. default=SUBSCRIBE_NONE,
  206. choices=SUBSCRIBE_CHOICES,
  207. )
  208. subscribe_to_replied_threads = models.PositiveIntegerField(
  209. default=SUBSCRIBE_NONE,
  210. choices=SUBSCRIBE_CHOICES,
  211. )
  212. threads = models.PositiveIntegerField(default=0)
  213. posts = models.PositiveIntegerField(default=0, db_index=True)
  214. last_posted_on = models.DateTimeField(null=True, blank=True)
  215. profile_fields = HStoreField(default=dict)
  216. agreements = ArrayField(models.PositiveIntegerField(), default=list)
  217. USERNAME_FIELD = 'slug'
  218. REQUIRED_FIELDS = ['email']
  219. objects = UserManager()
  220. class Meta:
  221. indexes = [
  222. PgPartialIndex(
  223. fields=['is_staff'],
  224. where={'is_staff': True},
  225. ),
  226. PgPartialIndex(
  227. fields=['requires_activation'],
  228. where={'requires_activation__gt': 0},
  229. ),
  230. PgPartialIndex(
  231. fields=['is_deleting_account'],
  232. where={'is_deleting_account': True},
  233. ),
  234. ]
  235. def clean(self):
  236. self.username = self.normalize_username(self.username)
  237. self.email = UserManager.normalize_email(self.email)
  238. def lock(self):
  239. """locks user in DB, shortcut for locking user model in views"""
  240. return User.objects.select_for_update().get(pk=self.pk)
  241. def delete(self, *args, **kwargs):
  242. if kwargs.pop('delete_content', False):
  243. self.delete_content()
  244. self.anonymize_data()
  245. avatars.delete_avatar(self)
  246. return super().delete(*args, **kwargs)
  247. def delete_content(self):
  248. from misago.users.signals import delete_user_content
  249. delete_user_content.send(sender=self)
  250. def mark_for_delete(self):
  251. self.is_active = False
  252. self.is_deleting_account = True
  253. self.save(update_fields=['is_active', 'is_deleting_account'])
  254. def anonymize_data(self):
  255. """Replaces username with anonymized one, then send anonymization signal.
  256. Items associated with this user then anonymize their user-specific data
  257. like username or IP addresses.
  258. """
  259. self.username = settings.MISAGO_ANONYMOUS_USERNAME
  260. self.slug = slugify(self.username)
  261. from misago.users.signals import anonymize_user_data
  262. anonymize_user_data.send(sender=self)
  263. @property
  264. def requires_activation_by_admin(self):
  265. return self.requires_activation == self.ACTIVATION_ADMIN
  266. @property
  267. def requires_activation_by_user(self):
  268. return self.requires_activation == self.ACTIVATION_USER
  269. @property
  270. def can_be_messaged_by_everyone(self):
  271. preference = self.limits_private_thread_invites_to
  272. return preference == self.LIMIT_INVITES_TO_NONE
  273. @property
  274. def can_be_messaged_by_followed(self):
  275. preference = self.limits_private_thread_invites_to
  276. return preference == self.LIMIT_INVITES_TO_FOLLOWED
  277. @property
  278. def can_be_messaged_by_nobody(self):
  279. preference = self.limits_private_thread_invites_to
  280. return preference == self.LIMIT_INVITES_TO_NOBODY
  281. @property
  282. def has_valid_signature(self):
  283. return is_user_signature_valid(self)
  284. def get_absolute_url(self):
  285. return reverse(
  286. 'misago:user', kwargs={
  287. 'slug': self.slug,
  288. 'pk': self.pk,
  289. }
  290. )
  291. def get_username(self):
  292. """dirty hack: return real username instead of normalized slug"""
  293. return self.username
  294. def get_full_name(self):
  295. return self.username
  296. def get_short_name(self):
  297. return self.username
  298. def get_real_name(self):
  299. return self.profile_fields.get('real_name')
  300. def set_username(self, new_username, changed_by=None):
  301. new_username = self.normalize_username(new_username)
  302. if new_username != self.username:
  303. old_username = self.username
  304. self.username = new_username
  305. self.slug = slugify(new_username)
  306. if self.pk:
  307. changed_by = changed_by or self
  308. namechange = self.record_name_change(
  309. changed_by, new_username, old_username
  310. )
  311. from misago.users.signals import username_changed
  312. username_changed.send(sender=self)
  313. return namechange
  314. def record_name_change(self, changed_by, new_username, old_username):
  315. return self.namechanges.create(
  316. new_username=new_username,
  317. old_username=old_username,
  318. changed_by=changed_by,
  319. changed_by_username=changed_by.username,
  320. )
  321. def set_email(self, new_email):
  322. self.email = UserManager.normalize_email(new_email)
  323. self.email_hash = hash_email(new_email)
  324. def get_any_title(self):
  325. return self.title or self.rank.title or self.rank.name
  326. def get_roles(self):
  327. roles_pks = []
  328. roles_dict = {}
  329. for role in self.roles.all():
  330. roles_pks.append(role.pk)
  331. role.origin = self
  332. roles_dict[role.pk] = role
  333. if self.rank:
  334. for role in self.rank.roles.all():
  335. if role.pk not in roles_pks:
  336. role.origin = self.rank
  337. roles_pks.append(role.pk)
  338. roles_dict[role.pk] = role
  339. return [roles_dict[r] for r in sorted(roles_pks)]
  340. def update_acl_key(self):
  341. roles_pks = []
  342. for role in self.get_roles():
  343. if role.origin == 'self':
  344. roles_pks.append('u%s' % role.pk)
  345. else:
  346. roles_pks.append('%s:%s' % (self.rank.pk, role.pk))
  347. self.acl_key = md5(','.join(roles_pks).encode()).hexdigest()[:12]
  348. def email_user(self, subject, message, from_email=None, **kwargs):
  349. """sends an email to this user (for compat with Django)"""
  350. send_mail(subject, message, from_email, [self.email], **kwargs)
  351. def is_following(self, user_or_id):
  352. try:
  353. user_id = user_or_id.id
  354. except AttributeError:
  355. user_id = user_or_id
  356. try:
  357. self.follows.get(id=user_id)
  358. return True
  359. except User.DoesNotExist:
  360. return False
  361. def is_blocking(self, user_or_id):
  362. try:
  363. user_id = user_or_id.id
  364. except AttributeError:
  365. user_id = user_or_id
  366. try:
  367. self.blocks.get(id=user_id)
  368. return True
  369. except User.DoesNotExist:
  370. return False
  371. class Online(models.Model):
  372. user = models.OneToOneField(
  373. settings.AUTH_USER_MODEL,
  374. primary_key=True,
  375. related_name='online_tracker',
  376. on_delete=models.CASCADE,
  377. )
  378. last_click = models.DateTimeField(default=timezone.now)
  379. def save(self, *args, **kwargs):
  380. try:
  381. super().save(*args, **kwargs)
  382. except IntegrityError:
  383. pass # first come is first serve in online tracker
  384. class UsernameChange(models.Model):
  385. user = models.ForeignKey(
  386. settings.AUTH_USER_MODEL,
  387. related_name='namechanges',
  388. on_delete=models.CASCADE,
  389. )
  390. changed_by = models.ForeignKey(
  391. settings.AUTH_USER_MODEL,
  392. null=True,
  393. blank=True,
  394. related_name='user_renames',
  395. on_delete=models.SET_NULL,
  396. )
  397. changed_by_username = models.CharField(max_length=30)
  398. changed_on = models.DateTimeField(default=timezone.now)
  399. new_username = models.CharField(max_length=255)
  400. old_username = models.CharField(max_length=255)
  401. class Meta:
  402. get_latest_by = "changed_on"
  403. def set_change_author(self, user):
  404. self.changed_by = user
  405. self.changed_by_username = user.username
  406. class AnonymousUser(DjangoAnonymousUser):
  407. acl_key = 'anonymous'
  408. @property
  409. def acl_cache(self):
  410. raise Exception("AnonymousUser.acl_cache has been removed")
  411. @acl_cache.setter
  412. def acl_cache(self, value):
  413. raise TypeError("AnonymousUser instances can't be made ACL aware")
  414. def get_roles(self):
  415. try:
  416. return [Role.objects.get(special_role="anonymous")]
  417. except Role.DoesNotExist:
  418. raise RuntimeError("Anonymous user role not found.")
  419. def update_acl_key(self):
  420. raise TypeError("Can't update ACL key on anonymous users")