models.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. # -*- coding: utf-8 -*-
  2. """
  3. flaskbb.user.models
  4. ~~~~~~~~~~~~~~~~~~~~
  5. This module provides the models for the user.
  6. :copyright: (c) 2014 by the FlaskBB Team.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. import os
  10. from datetime import datetime, timedelta
  11. from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
  12. from itsdangerous import SignatureExpired
  13. from werkzeug.security import generate_password_hash, check_password_hash
  14. from flask import current_app, url_for
  15. from flask_login import UserMixin, AnonymousUserMixin
  16. from flaskbb._compat import max_integer
  17. from flaskbb.extensions import db, cache
  18. from flaskbb.exceptions import AuthenticationError, LoginAttemptsExceeded
  19. from flaskbb.utils.settings import flaskbb_config
  20. from flaskbb.utils.database import CRUDMixin
  21. from flaskbb.forum.models import (Post, Topic, topictracker, TopicsRead,
  22. ForumsRead)
  23. from flaskbb.message.models import Conversation
  24. groups_users = db.Table(
  25. 'groups_users',
  26. db.Column('user_id', db.Integer(), db.ForeignKey('users.id')),
  27. db.Column('group_id', db.Integer(), db.ForeignKey('groups.id')))
  28. class Group(db.Model, CRUDMixin):
  29. __tablename__ = "groups"
  30. id = db.Column(db.Integer, primary_key=True)
  31. name = db.Column(db.String(255), unique=True, nullable=False)
  32. description = db.Column(db.Text)
  33. # Group types
  34. admin = db.Column(db.Boolean, default=False, nullable=False)
  35. super_mod = db.Column(db.Boolean, default=False, nullable=False)
  36. mod = db.Column(db.Boolean, default=False, nullable=False)
  37. guest = db.Column(db.Boolean, default=False, nullable=False)
  38. banned = db.Column(db.Boolean, default=False, nullable=False)
  39. # Moderator permissions (only available when the user a moderator)
  40. mod_edituser = db.Column(db.Boolean, default=False, nullable=False)
  41. mod_banuser = db.Column(db.Boolean, default=False, nullable=False)
  42. # User permissions
  43. editpost = db.Column(db.Boolean, default=True, nullable=False)
  44. deletepost = db.Column(db.Boolean, default=False, nullable=False)
  45. deletetopic = db.Column(db.Boolean, default=False, nullable=False)
  46. posttopic = db.Column(db.Boolean, default=True, nullable=False)
  47. postreply = db.Column(db.Boolean, default=True, nullable=False)
  48. # Methods
  49. def __repr__(self):
  50. """Set to a unique key specific to the object in the database.
  51. Required for cache.memoize() to work across requests.
  52. """
  53. return "<{} {} {}>".format(self.__class__.__name__, self.id, self.name)
  54. @classmethod
  55. def selectable_groups_choices(cls):
  56. return Group.query.order_by(Group.name.asc()).with_entities(
  57. Group.id, Group.name
  58. ).all()
  59. @classmethod
  60. def get_guest_group(cls):
  61. return cls.query.filter(cls.guest == True).first()
  62. class User(db.Model, UserMixin, CRUDMixin):
  63. __tablename__ = "users"
  64. __searchable__ = ['username', 'email']
  65. id = db.Column(db.Integer, primary_key=True)
  66. username = db.Column(db.String(200), unique=True, nullable=False)
  67. email = db.Column(db.String(200), unique=True, nullable=False)
  68. _password = db.Column('password', db.String(120), nullable=False)
  69. date_joined = db.Column(db.DateTime, default=datetime.utcnow())
  70. lastseen = db.Column(db.DateTime, default=datetime.utcnow())
  71. birthday = db.Column(db.DateTime)
  72. gender = db.Column(db.String(10))
  73. website = db.Column(db.String(200))
  74. location = db.Column(db.String(100))
  75. signature = db.Column(db.Text)
  76. avatar = db.Column(db.String(200))
  77. notes = db.Column(db.Text)
  78. last_failed_login = db.Column(db.DateTime)
  79. login_attempts = db.Column(db.Integer, default=0)
  80. activated = db.Column(db.DateTime)
  81. theme = db.Column(db.String(15))
  82. language = db.Column(db.String(15), default="en")
  83. posts = db.relationship("Post", backref="user", lazy="dynamic")
  84. topics = db.relationship("Topic", backref="user", lazy="dynamic")
  85. post_count = db.Column(db.Integer, default=0)
  86. primary_group_id = db.Column(db.Integer, db.ForeignKey('groups.id'),
  87. nullable=False)
  88. primary_group = db.relationship('Group', lazy="joined",
  89. backref="user_group", uselist=False,
  90. foreign_keys=[primary_group_id])
  91. secondary_groups = \
  92. db.relationship('Group',
  93. secondary=groups_users,
  94. primaryjoin=(groups_users.c.user_id == id),
  95. backref=db.backref('users', lazy='dynamic'),
  96. lazy='dynamic')
  97. tracked_topics = \
  98. db.relationship("Topic", secondary=topictracker,
  99. primaryjoin=(topictracker.c.user_id == id),
  100. backref=db.backref("topicstracked", lazy="dynamic"),
  101. lazy="dynamic")
  102. # Properties
  103. @property
  104. def is_active(self):
  105. if flaskbb_config["ACTIVATE_ACCOUNT"] and self.activated is not None:
  106. return True
  107. return False
  108. @property
  109. def last_post(self):
  110. """Returns the latest post from the user."""
  111. return Post.query.filter(Post.user_id == self.id).\
  112. order_by(Post.date_created.desc()).first()
  113. @property
  114. def url(self):
  115. """Returns the url for the user."""
  116. return url_for("user.profile", username=self.username)
  117. @property
  118. def permissions(self):
  119. """Returns the permissions for the user."""
  120. return self.get_permissions()
  121. @property
  122. def groups(self):
  123. """Returns the user groups."""
  124. return self.get_groups()
  125. @property
  126. def unread_messages(self):
  127. """Returns the unread messages for the user."""
  128. return self.get_unread_messages()
  129. @property
  130. def unread_count(self):
  131. """Returns the unread message count for the user."""
  132. return len(self.unread_messages)
  133. @property
  134. def days_registered(self):
  135. """Returns the amount of days the user is registered."""
  136. days_registered = (datetime.utcnow() - self.date_joined).days
  137. if not days_registered:
  138. return 1
  139. return days_registered
  140. @property
  141. def topic_count(self):
  142. """Returns the thread count."""
  143. return Topic.query.filter(Topic.user_id == self.id).count()
  144. @property
  145. def posts_per_day(self):
  146. """Returns the posts per day count."""
  147. return round((float(self.post_count) / float(self.days_registered)), 1)
  148. @property
  149. def topics_per_day(self):
  150. """Returns the topics per day count."""
  151. return round(
  152. (float(self.topic_count) / float(self.days_registered)), 1
  153. )
  154. # Methods
  155. def __repr__(self):
  156. """Set to a unique key specific to the object in the database.
  157. Required for cache.memoize() to work across requests.
  158. """
  159. return "<{} {}>".format(self.__class__.__name__, self.username)
  160. def _get_password(self):
  161. """Returns the hashed password."""
  162. return self._password
  163. def _set_password(self, password):
  164. """Generates a password hash for the provided password."""
  165. if not password:
  166. return
  167. self._password = generate_password_hash(password)
  168. # Hide password encryption by exposing password field only.
  169. password = db.synonym('_password',
  170. descriptor=property(_get_password,
  171. _set_password))
  172. def check_password(self, password):
  173. """Check passwords. If passwords match it returns true, else false."""
  174. if self.password is None:
  175. return False
  176. return check_password_hash(self.password, password)
  177. @classmethod
  178. def authenticate(cls, login, password):
  179. """A classmethod for authenticating users.
  180. It returns the user object if the user/password combination is ok.
  181. If the user has entered too often a wrong password, he will be locked
  182. out of his account for a specified time.
  183. :param login: This can be either a username or a email address.
  184. :param password: The password that is connected to username and email.
  185. """
  186. user = cls.query.filter(db.or_(User.username == login,
  187. User.email == login)).first()
  188. if user:
  189. # check for the login attempts first
  190. login_timeout = datetime.utcnow() - timedelta(
  191. minutes=flaskbb_config["LOGIN_TIMEOUT"]
  192. )
  193. if user.login_attempts >= flaskbb_config["LOGIN_ATTEMPTS"] and \
  194. user.last_failed_login > login_timeout:
  195. raise LoginAttemptsExceeded
  196. if user.check_password(password):
  197. if user.login_attempts >= flaskbb_config["LOGIN_ATTEMPTS"]:
  198. # reset them after a successful login attempt
  199. user.login_attempts = 0
  200. user.save()
  201. return user
  202. # user exists, wrong password
  203. user.login_attempts += 1
  204. user.last_failed_login = datetime.utcnow()
  205. user.save()
  206. # protection against account enumeration timing attacks
  207. dummy_password = os.urandom(15).encode("base-64")
  208. check_password_hash(dummy_password, password)
  209. raise AuthenticationError
  210. def recalculate(self):
  211. """Recalculates the post count from the user."""
  212. post_count = Post.query.filter_by(user_id=self.id).count()
  213. self.post_count = post_count
  214. self.save()
  215. return self
  216. def all_topics(self, page):
  217. """Returns a paginated result with all topics the user has created."""
  218. return Topic.query.filter(Topic.user_id == self.id).\
  219. filter(Post.topic_id == Topic.id).\
  220. order_by(Post.id.desc()).\
  221. paginate(page, flaskbb_config['TOPICS_PER_PAGE'], False)
  222. def all_posts(self, page):
  223. """Returns a paginated result with all posts the user has created."""
  224. return Post.query.filter(Post.user_id == self.id).\
  225. paginate(page, flaskbb_config['TOPICS_PER_PAGE'], False)
  226. def track_topic(self, topic):
  227. """Tracks the specified topic.
  228. :param topic: The topic which should be added to the topic tracker.
  229. """
  230. if not self.is_tracking_topic(topic):
  231. self.tracked_topics.append(topic)
  232. return self
  233. def untrack_topic(self, topic):
  234. """Untracks the specified topic.
  235. :param topic: The topic which should be removed from the
  236. topic tracker.
  237. """
  238. if self.is_tracking_topic(topic):
  239. self.tracked_topics.remove(topic)
  240. return self
  241. def is_tracking_topic(self, topic):
  242. """Checks if the user is already tracking this topic.
  243. :param topic: The topic which should be checked.
  244. """
  245. return self.tracked_topics.filter(
  246. topictracker.c.topic_id == topic.id).count() > 0
  247. def add_to_group(self, group):
  248. """Adds the user to the `group` if he isn't in it.
  249. :param group: The group which should be added to the user.
  250. """
  251. if not self.in_group(group):
  252. self.secondary_groups.append(group)
  253. return self
  254. def remove_from_group(self, group):
  255. """Removes the user from the `group` if he is in it.
  256. :param group: The group which should be removed from the user.
  257. """
  258. if self.in_group(group):
  259. self.secondary_groups.remove(group)
  260. return self
  261. def in_group(self, group):
  262. """Returns True if the user is in the specified group.
  263. :param group: The group which should be checked.
  264. """
  265. return self.secondary_groups.filter(
  266. groups_users.c.group_id == group.id).count() > 0
  267. @cache.memoize(timeout=max_integer)
  268. def get_groups(self):
  269. """Returns all the groups the user is in."""
  270. return [self.primary_group] + list(self.secondary_groups)
  271. @cache.memoize(timeout=max_integer)
  272. def get_permissions(self, exclude=None):
  273. """Returns a dictionary with all permissions the user has"""
  274. if exclude:
  275. exclude = set(exclude)
  276. else:
  277. exclude = set()
  278. exclude.update(['id', 'name', 'description'])
  279. perms = {}
  280. # Get the Guest group
  281. for group in self.groups:
  282. columns = set(group.__table__.columns.keys()) - set(exclude)
  283. for c in columns:
  284. perms[c] = getattr(group, c) or perms.get(c, False)
  285. return perms
  286. @cache.memoize(timeout=max_integer)
  287. def get_unread_messages(self):
  288. """Returns all unread messages for the user."""
  289. unread_messages = Conversation.query.\
  290. filter(Conversation.unread, Conversation.user_id == self.id).all()
  291. return unread_messages
  292. def invalidate_cache(self, permissions=True, messages=True):
  293. """Invalidates this objects cached metadata.
  294. :param permissions_only: If set to ``True`` it will only invalidate
  295. the permissions cache. Otherwise it will
  296. also invalidate the user's unread message
  297. cache.
  298. """
  299. if messages:
  300. cache.delete_memoized(self.get_unread_messages, self)
  301. if permissions:
  302. cache.delete_memoized(self.get_permissions, self)
  303. cache.delete_memoized(self.get_groups, self)
  304. def ban(self):
  305. """Bans the user. Returns True upon success."""
  306. if not self.get_permissions()['banned']:
  307. banned_group = Group.query.filter(
  308. Group.banned == True
  309. ).first()
  310. self.primary_group_id = banned_group.id
  311. self.save()
  312. self.invalidate_cache()
  313. return True
  314. return False
  315. def unban(self):
  316. """Unbans the user. Returns True upon success."""
  317. if self.get_permissions()['banned']:
  318. member_group = Group.query.filter(
  319. Group.admin == False,
  320. Group.super_mod == False,
  321. Group.mod == False,
  322. Group.guest == False,
  323. Group.banned == False
  324. ).first()
  325. self.primary_group_id = member_group.id
  326. self.save()
  327. self.invalidate_cache()
  328. return True
  329. return False
  330. def save(self, groups=None):
  331. """Saves a user. If a list with groups is provided, it will add those
  332. to the secondary groups from the user.
  333. :param groups: A list with groups that should be added to the
  334. secondary groups from user.
  335. """
  336. if groups is not None:
  337. # TODO: Only remove/add groups that are selected
  338. secondary_groups = self.secondary_groups.all()
  339. for group in secondary_groups:
  340. self.remove_from_group(group)
  341. db.session.commit()
  342. for group in groups:
  343. # Do not add the primary group to the secondary groups
  344. if group.id == self.primary_group_id:
  345. continue
  346. self.add_to_group(group)
  347. self.invalidate_cache()
  348. db.session.add(self)
  349. db.session.commit()
  350. return self
  351. def delete(self):
  352. """Deletes the User."""
  353. # This isn't done automatically...
  354. Conversation.query.filter_by(user_id=self.id).delete()
  355. ForumsRead.query.filter_by(user_id=self.id).delete()
  356. TopicsRead.query.filter_by(user_id=self.id).delete()
  357. # This should actually be handeld by the dbms.. but dunno why it doesnt
  358. # work here
  359. from flaskbb.forum.models import Forum
  360. last_post_forums = Forum.query.\
  361. filter_by(last_post_user_id=self.id).all()
  362. for forum in last_post_forums:
  363. forum.last_post_user_id = None
  364. forum.save()
  365. db.session.delete(self)
  366. db.session.commit()
  367. return self
  368. class Guest(AnonymousUserMixin):
  369. @property
  370. def permissions(self):
  371. return self.get_permissions()
  372. @property
  373. def groups(self):
  374. return self.get_groups()
  375. @cache.memoize(timeout=max_integer)
  376. def get_groups(self):
  377. return Group.query.filter(Group.guest == True).all()
  378. @cache.memoize(timeout=max_integer)
  379. def get_permissions(self, exclude=None):
  380. """Returns a dictionary with all permissions the user has"""
  381. if exclude:
  382. exclude = set(exclude)
  383. else:
  384. exclude = set()
  385. exclude.update(['id', 'name', 'description'])
  386. perms = {}
  387. # Get the Guest group
  388. for group in self.groups:
  389. columns = set(group.__table__.columns.keys()) - set(exclude)
  390. for c in columns:
  391. perms[c] = getattr(group, c) or perms.get(c, False)
  392. return perms
  393. @classmethod
  394. def invalidate_cache(cls):
  395. """Invalidates this objects cached metadata."""
  396. cache.delete_memoized(cls.get_permissions, cls)