models.py 17 KB

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