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. logger = logging.getLogger(__name__)
  20. groups_users = db.Table(
  21. 'groups_users',
  22. db.Column('user_id', db.Integer,
  23. db.ForeignKey('users.id', ondelete="CASCADE"),
  24. nullable=False),
  25. db.Column('group_id', db.Integer,
  26. db.ForeignKey('groups.id', ondelete="CASCADE"),
  27. nullable=False)
  28. )
  29. @make_comparable
  30. class Group(db.Model, CRUDMixin):
  31. __tablename__ = "groups"
  32. id = db.Column(db.Integer, primary_key=True)
  33. name = db.Column(db.String(255), unique=True, nullable=False)
  34. description = db.Column(db.Text, nullable=True)
  35. # Group types
  36. admin = db.Column(db.Boolean, default=False, nullable=False)
  37. super_mod = db.Column(db.Boolean, default=False, nullable=False)
  38. mod = db.Column(db.Boolean, default=False, nullable=False)
  39. guest = db.Column(db.Boolean, default=False, nullable=False)
  40. banned = db.Column(db.Boolean, default=False, nullable=False)
  41. # Moderator permissions (only available when the user a moderator)
  42. mod_edituser = db.Column(db.Boolean, default=False, nullable=False)
  43. mod_banuser = db.Column(db.Boolean, default=False, nullable=False)
  44. # User permissions
  45. editpost = db.Column(db.Boolean, default=True, nullable=False)
  46. deletepost = db.Column(db.Boolean, default=False, nullable=False)
  47. deletetopic = db.Column(db.Boolean, default=False, nullable=False)
  48. posttopic = db.Column(db.Boolean, default=True, nullable=False)
  49. postreply = db.Column(db.Boolean, default=True, nullable=False)
  50. viewhidden = db.Column(db.Boolean, default=False, nullable=False)
  51. makehidden = db.Column(db.Boolean, default=False, nullable=False)
  52. # Methods
  53. def __repr__(self):
  54. """Set to a unique key specific to the object in the database.
  55. Required for cache.memoize() to work across requests.
  56. """
  57. return "<{} {} {}>".format(self.__class__.__name__, self.id, self.name)
  58. @classmethod
  59. def selectable_groups_choices(cls):
  60. return Group.query.order_by(Group.name.asc()).with_entities(
  61. Group.id, Group.name
  62. ).all()
  63. @classmethod
  64. def get_guest_group(cls):
  65. return cls.query.filter(cls.guest == True).first()
  66. @classmethod
  67. def get_member_group(cls):
  68. """Returns the first member group."""
  69. # This feels ugly..
  70. return cls.query.filter(cls.admin == False, cls.super_mod == False,
  71. cls.mod == False, cls.guest == False,
  72. cls.banned == False).first()
  73. class User(db.Model, UserMixin, CRUDMixin):
  74. __tablename__ = "users"
  75. id = db.Column(db.Integer, primary_key=True)
  76. username = db.Column(db.String(200), unique=True, nullable=False)
  77. email = db.Column(db.String(200), unique=True, nullable=False)
  78. _password = db.Column('password', db.String(120), nullable=False)
  79. date_joined = db.Column(UTCDateTime(timezone=True), default=time_utcnow,
  80. nullable=False)
  81. lastseen = db.Column(UTCDateTime(timezone=True), default=time_utcnow,
  82. nullable=True)
  83. birthday = db.Column(db.DateTime, nullable=True)
  84. gender = db.Column(db.String(10), nullable=True)
  85. website = db.Column(db.String(200), nullable=True)
  86. location = db.Column(db.String(100), nullable=True)
  87. signature = db.Column(db.Text, nullable=True)
  88. avatar = db.Column(db.String(200), nullable=True)
  89. notes = db.Column(db.Text, nullable=True)
  90. last_failed_login = db.Column(UTCDateTime(timezone=True), nullable=True)
  91. login_attempts = db.Column(db.Integer, default=0, nullable=False)
  92. activated = db.Column(db.Boolean, default=False, nullable=False)
  93. theme = db.Column(db.String(15), nullable=True)
  94. language = db.Column(db.String(15), default="en", nullable=True)
  95. post_count = db.Column(db.Integer, default=0)
  96. primary_group_id = db.Column(db.Integer, db.ForeignKey('groups.id'),
  97. nullable=False)
  98. posts = db.relationship(
  99. "Post",
  100. backref="user",
  101. primaryjoin="User.id == Post.user_id",
  102. lazy="dynamic"
  103. )
  104. topics = db.relationship(
  105. "Topic",
  106. backref="user",
  107. primaryjoin="User.id == Topic.user_id",
  108. lazy="dynamic"
  109. )
  110. primary_group = db.relationship(
  111. "Group",
  112. backref="user_group",
  113. uselist=False,
  114. lazy="joined",
  115. foreign_keys=[primary_group_id]
  116. )
  117. secondary_groups = db.relationship(
  118. "Group",
  119. secondary=groups_users,
  120. primaryjoin=(groups_users.c.user_id == id),
  121. backref=db.backref("users", lazy="dynamic"),
  122. lazy="dynamic"
  123. )
  124. tracked_topics = db.relationship(
  125. "Topic",
  126. secondary=topictracker,
  127. primaryjoin=(topictracker.c.user_id == id),
  128. backref=db.backref("topicstracked", lazy="dynamic"),
  129. lazy="dynamic",
  130. cascade="all, delete-orphan",
  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. def authenticate(cls, login, password):
  209. """A classmethod for authenticating users.
  210. It returns the user object if the user/password combination is ok.
  211. If the user has entered too often a wrong password, he will be locked
  212. out of his account for a specified time.
  213. :param login: This can be either a username or a email address.
  214. :param password: The password that is connected to username and email.
  215. """
  216. user = cls.query.filter(db.or_(User.username == login,
  217. User.email == login)).first()
  218. if user is not None:
  219. if user.check_password(password):
  220. # reset them after a successful login attempt
  221. user.login_attempts = 0
  222. user.save()
  223. return user
  224. # user exists, wrong password
  225. # never had a bad login before
  226. if user.login_attempts is None:
  227. user.login_attempts = 1
  228. else:
  229. user.login_attempts += 1
  230. user.last_failed_login = time_utcnow()
  231. user.save()
  232. # protection against account enumeration timing attacks
  233. check_password_hash("dummy password", password)
  234. raise AuthenticationError
  235. def recalculate(self):
  236. """Recalculates the post count from the user."""
  237. post_count = Post.query.filter_by(user_id=self.id).count()
  238. self.post_count = post_count
  239. self.save()
  240. return self
  241. def all_topics(self, page, viewer):
  242. """Returns a paginated result with all topics the user has created.
  243. :param page: The page which should be displayed.
  244. :param viewer: The user who is viewing this user. It will return a
  245. list with topics that the *viewer* has access to and
  246. thus it will not display all topics from
  247. the requested user.
  248. """
  249. group_ids = [g.id for g in viewer.groups]
  250. topics = Topic.query.\
  251. filter(Topic.user_id == self.id,
  252. Forum.id == Topic.forum_id,
  253. Forum.groups.any(Group.id.in_(group_ids))).\
  254. paginate(page, flaskbb_config['TOPICS_PER_PAGE'], False)
  255. return topics
  256. def all_posts(self, page, viewer):
  257. """Returns a paginated result with all posts the user has created.
  258. :param page: The page which should be displayed.
  259. :param viewer: The user who is viewing this user. It will return a
  260. list with posts that the *viewer* has access to and
  261. thus it will not display all posts from
  262. the requested user.
  263. """
  264. group_ids = [g.id for g in viewer.groups]
  265. posts = Post.query.\
  266. filter(Post.user_id == self.id,
  267. Post.topic_id == Topic.id,
  268. Topic.forum_id == Forum.id,
  269. Forum.groups.any(Group.id.in_(group_ids))).\
  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)