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 flask import url_for
  11. from flask_login import AnonymousUserMixin, UserMixin
  12. from werkzeug.security import check_password_hash, generate_password_hash
  13. from flaskbb.deprecation import deprecated
  14. from flaskbb.exceptions import AuthenticationError
  15. from flaskbb.extensions import cache, db
  16. from flaskbb.forum.models import Forum, Post, Topic, topictracker
  17. from flaskbb.utils.database import CRUDMixin, UTCDateTime, make_comparable
  18. from flaskbb.utils.helpers import time_utcnow
  19. from flaskbb.utils.settings import flaskbb_config
  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. self.post_count = Post.query.filter_by(user_id=self.id).count()
  239. self.save()
  240. return self
  241. def all_topics(self, page, viewer):
  242. """Topics made by a given user, most recent first.
  243. :param page: The page which should be displayed.
  244. :param viewer: The user who is viewing the page. Only posts
  245. accessible to the viewer will be returned.
  246. :rtype: flask_sqlalchemy.Pagination
  247. """
  248. group_ids = [g.id for g in viewer.groups]
  249. topics = Topic.query.\
  250. filter(Topic.user_id == self.id,
  251. Forum.id == Topic.forum_id,
  252. Forum.groups.any(Group.id.in_(group_ids))).\
  253. order_by(Topic.id.desc()).\
  254. paginate(page, flaskbb_config['TOPICS_PER_PAGE'], False)
  255. return topics
  256. def all_posts(self, page, viewer):
  257. """Posts made by a given user, most recent first.
  258. :param page: The page which should be displayed.
  259. :param viewer: The user who is viewing the page. Only posts
  260. accessible to the viewer will be returned.
  261. :rtype: flask_sqlalchemy.Pagination
  262. """
  263. group_ids = [g.id for g in viewer.groups]
  264. posts = Post.query.\
  265. filter(Post.user_id == self.id,
  266. Post.topic_id == Topic.id,
  267. Topic.forum_id == Forum.id,
  268. Forum.groups.any(Group.id.in_(group_ids))).\
  269. order_by(Post.id.desc()).\
  270. paginate(page, flaskbb_config['TOPICS_PER_PAGE'], False)
  271. return posts
  272. def track_topic(self, topic):
  273. """Tracks the specified topic.
  274. :param topic: The topic which should be added to the topic tracker.
  275. """
  276. if not self.is_tracking_topic(topic):
  277. self.tracked_topics.append(topic)
  278. return self
  279. def untrack_topic(self, topic):
  280. """Untracks the specified topic.
  281. :param topic: The topic which should be removed from the
  282. topic tracker.
  283. """
  284. if self.is_tracking_topic(topic):
  285. self.tracked_topics.remove(topic)
  286. return self
  287. def is_tracking_topic(self, topic):
  288. """Checks if the user is already tracking this topic.
  289. :param topic: The topic which should be checked.
  290. """
  291. return self.tracked_topics.filter(
  292. topictracker.c.topic_id == topic.id).count() > 0
  293. def add_to_group(self, group):
  294. """Adds the user to the `group` if he isn't in it.
  295. :param group: The group which should be added to the user.
  296. """
  297. if not self.in_group(group):
  298. self.secondary_groups.append(group)
  299. return self
  300. def remove_from_group(self, group):
  301. """Removes the user from the `group` if he is in it.
  302. :param group: The group which should be removed from the user.
  303. """
  304. if self.in_group(group):
  305. self.secondary_groups.remove(group)
  306. return self
  307. def in_group(self, group):
  308. """Returns True if the user is in the specified group.
  309. :param group: The group which should be checked.
  310. """
  311. return self.secondary_groups.filter(
  312. groups_users.c.group_id == group.id).count() > 0
  313. @cache.memoize()
  314. def get_groups(self):
  315. """Returns all the groups the user is in."""
  316. return [self.primary_group] + list(self.secondary_groups)
  317. @cache.memoize()
  318. def get_permissions(self, exclude=None):
  319. """Returns a dictionary with all permissions the user has"""
  320. if exclude:
  321. exclude = set(exclude)
  322. else:
  323. exclude = set()
  324. exclude.update(['id', 'name', 'description'])
  325. perms = {}
  326. # Get the Guest group
  327. for group in self.groups:
  328. columns = set(group.__table__.columns.keys()) - set(exclude)
  329. for c in columns:
  330. perms[c] = getattr(group, c) or perms.get(c, False)
  331. return perms
  332. def invalidate_cache(self):
  333. """Invalidates this objects cached metadata."""
  334. cache.delete_memoized(self.get_permissions, self)
  335. cache.delete_memoized(self.get_groups, self)
  336. def ban(self):
  337. """Bans the user. Returns True upon success."""
  338. if not self.get_permissions()['banned']:
  339. banned_group = Group.query.filter(
  340. Group.banned == True
  341. ).first()
  342. self.primary_group = banned_group
  343. self.save()
  344. self.invalidate_cache()
  345. return True
  346. return False
  347. def unban(self):
  348. """Unbans the user. Returns True upon success."""
  349. if self.get_permissions()['banned']:
  350. member_group = Group.query.filter(
  351. Group.admin == False,
  352. Group.super_mod == False,
  353. Group.mod == False,
  354. Group.guest == False,
  355. Group.banned == False
  356. ).first()
  357. self.primary_group = member_group
  358. self.save()
  359. self.invalidate_cache()
  360. return True
  361. return False
  362. def save(self, groups=None):
  363. """Saves a user. If a list with groups is provided, it will add those
  364. to the secondary groups from the user.
  365. :param groups: A list with groups that should be added to the
  366. secondary groups from user.
  367. """
  368. if groups is not None:
  369. # TODO: Only remove/add groups that are selected
  370. secondary_groups = self.secondary_groups.all()
  371. for group in secondary_groups:
  372. self.remove_from_group(group)
  373. db.session.commit()
  374. for group in groups:
  375. # Do not add the primary group to the secondary groups
  376. if group == self.primary_group:
  377. continue
  378. self.add_to_group(group)
  379. self.invalidate_cache()
  380. db.session.add(self)
  381. db.session.commit()
  382. return self
  383. def delete(self):
  384. """Deletes the User."""
  385. db.session.delete(self)
  386. db.session.commit()
  387. return self
  388. class Guest(AnonymousUserMixin):
  389. @property
  390. def permissions(self):
  391. return self.get_permissions()
  392. @property
  393. def groups(self):
  394. return self.get_groups()
  395. @cache.memoize()
  396. def get_groups(self):
  397. return Group.query.filter(Group.guest == True).all()
  398. @cache.memoize()
  399. def get_permissions(self, exclude=None):
  400. """Returns a dictionary with all permissions the user has"""
  401. if exclude:
  402. exclude = set(exclude)
  403. else:
  404. exclude = set()
  405. exclude.update(['id', 'name', 'description'])
  406. perms = {}
  407. # Get the Guest group
  408. for group in self.groups:
  409. columns = set(group.__table__.columns.keys()) - set(exclude)
  410. for c in columns:
  411. perms[c] = getattr(group, c) or perms.get(c, False)
  412. return perms
  413. @classmethod
  414. def invalidate_cache(cls):
  415. """Invalidates this objects cached metadata."""
  416. cache.delete_memoized(cls.get_permissions, cls)