models.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062
  1. # -*- coding: utf-8 -*-
  2. """
  3. flaskbb.forum.models
  4. ~~~~~~~~~~~~~~~~~~~~
  5. It provides the models for the forum
  6. :copyright: (c) 2014 by the FlaskBB Team.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. from datetime import timedelta
  10. from flask import url_for, abort
  11. from sqlalchemy.orm import aliased
  12. from flaskbb.extensions import db
  13. from flaskbb.utils.helpers import (slugify, get_categories_and_forums,
  14. get_forums, time_utcnow, topic_is_unread)
  15. from flaskbb.utils.database import CRUDMixin, UTCDateTime
  16. from flaskbb.utils.settings import flaskbb_config
  17. moderators = db.Table(
  18. 'moderators',
  19. db.Column('user_id', db.Integer(), db.ForeignKey('users.id'),
  20. nullable=False),
  21. db.Column('forum_id', db.Integer(),
  22. db.ForeignKey('forums.id', use_alter=True, name="fk_forum_id"),
  23. nullable=False))
  24. topictracker = db.Table(
  25. 'topictracker',
  26. db.Column('user_id', db.Integer(), db.ForeignKey('users.id'),
  27. nullable=False),
  28. db.Column('topic_id', db.Integer(),
  29. db.ForeignKey('topics.id',
  30. use_alter=True, name="fk_tracker_topic_id"),
  31. nullable=False))
  32. # m2m table for group-forum permission mapping
  33. forumgroups = db.Table(
  34. 'forumgroups',
  35. db.Column(
  36. 'group_id',
  37. db.Integer(),
  38. db.ForeignKey('groups.id'),
  39. nullable=False
  40. ),
  41. db.Column(
  42. 'forum_id',
  43. db.Integer(),
  44. db.ForeignKey('forums.id', use_alter=True, name="fk_forum_id"),
  45. nullable=False
  46. )
  47. )
  48. class TopicsRead(db.Model, CRUDMixin):
  49. __tablename__ = "topicsread"
  50. user_id = db.Column(db.Integer, db.ForeignKey("users.id"),
  51. primary_key=True)
  52. topic_id = db.Column(db.Integer,
  53. db.ForeignKey("topics.id", use_alter=True,
  54. name="fk_tr_topic_id"),
  55. primary_key=True)
  56. forum_id = db.Column(db.Integer,
  57. db.ForeignKey("forums.id", use_alter=True,
  58. name="fk_tr_forum_id"),
  59. primary_key=True)
  60. last_read = db.Column(UTCDateTime(timezone=True), default=time_utcnow)
  61. class ForumsRead(db.Model, CRUDMixin):
  62. __tablename__ = "forumsread"
  63. user_id = db.Column(db.Integer, db.ForeignKey("users.id"),
  64. primary_key=True)
  65. forum_id = db.Column(db.Integer,
  66. db.ForeignKey("forums.id", use_alter=True,
  67. name="fk_fr_forum_id"),
  68. primary_key=True)
  69. last_read = db.Column(UTCDateTime(timezone=True), default=time_utcnow)
  70. cleared = db.Column(UTCDateTime(timezone=True))
  71. class Report(db.Model, CRUDMixin):
  72. __tablename__ = "reports"
  73. id = db.Column(db.Integer, primary_key=True)
  74. reporter_id = db.Column(db.Integer, db.ForeignKey("users.id"),
  75. nullable=False)
  76. reported = db.Column(UTCDateTime(timezone=True), default=time_utcnow)
  77. post_id = db.Column(db.Integer, db.ForeignKey("posts.id"), nullable=False)
  78. zapped = db.Column(UTCDateTime(timezone=True))
  79. zapped_by = db.Column(db.Integer, db.ForeignKey("users.id"))
  80. reason = db.Column(db.Text)
  81. post = db.relationship("Post", backref="report", lazy="joined")
  82. reporter = db.relationship("User", lazy="joined",
  83. foreign_keys=[reporter_id])
  84. zapper = db.relationship("User", lazy="joined", foreign_keys=[zapped_by])
  85. def __repr__(self):
  86. return "<{} {}>".format(self.__class__.__name__, self.id)
  87. def save(self, post=None, user=None):
  88. """Saves a report.
  89. :param post: The post that should be reported
  90. :param user: The user who has reported the post
  91. :param reason: The reason why the user has reported the post
  92. """
  93. if self.id:
  94. db.session.add(self)
  95. db.session.commit()
  96. return self
  97. if post and user:
  98. self.reporter_id = user.id
  99. self.reported = time_utcnow()
  100. self.post_id = post.id
  101. db.session.add(self)
  102. db.session.commit()
  103. return self
  104. class Post(db.Model, CRUDMixin):
  105. __tablename__ = "posts"
  106. id = db.Column(db.Integer, primary_key=True)
  107. topic_id = db.Column(db.Integer,
  108. db.ForeignKey("topics.id",
  109. use_alter=True,
  110. name="fk_post_topic_id",
  111. ondelete="CASCADE"))
  112. user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
  113. username = db.Column(db.String(200), nullable=False)
  114. content = db.Column(db.Text, nullable=False)
  115. date_created = db.Column(UTCDateTime(timezone=True), default=time_utcnow)
  116. date_modified = db.Column(UTCDateTime(timezone=True))
  117. modified_by = db.Column(db.String(200))
  118. # Properties
  119. @property
  120. def url(self):
  121. """Returns the url for the post."""
  122. return url_for("forum.view_post", post_id=self.id)
  123. # Methods
  124. def __init__(self, content=None, user=None, topic=None):
  125. """Creates a post object with some initial values.
  126. :param content: The content of the post.
  127. :param user: The user of the post.
  128. :param topic: Can either be the topic_id or the topic object.
  129. """
  130. if content:
  131. self.content = content
  132. if user:
  133. self.user_id = user.id
  134. self.username = user.username
  135. if topic:
  136. self.topic_id = topic if isinstance(topic, int) else topic.id
  137. self.date_created = time_utcnow()
  138. def __repr__(self):
  139. """Set to a unique key specific to the object in the database.
  140. Required for cache.memoize() to work across requests.
  141. """
  142. return "<{} {}>".format(self.__class__.__name__, self.id)
  143. def save(self, user=None, topic=None):
  144. """Saves a new post. If no parameters are passed we assume that
  145. you will just update an existing post. It returns the object after the
  146. operation was successful.
  147. :param user: The user who has created the post
  148. :param topic: The topic in which the post was created
  149. """
  150. # update/edit the post
  151. if self.id:
  152. db.session.add(self)
  153. db.session.commit()
  154. return self
  155. # Adding a new post
  156. if user and topic:
  157. created = time_utcnow()
  158. self.user_id = user.id
  159. self.username = user.username
  160. self.topic_id = topic.id
  161. self.date_created = created
  162. topic.last_updated = created
  163. # This needs to be done before the last_post_id gets updated.
  164. db.session.add(self)
  165. db.session.commit()
  166. # Now lets update the last post id
  167. topic.last_post_id = self.id
  168. # Update the last post info for the forum
  169. topic.forum.last_post_id = self.id
  170. topic.forum.last_post_title = topic.title
  171. topic.forum.last_post_user_id = user.id
  172. topic.forum.last_post_username = user.username
  173. topic.forum.last_post_created = created
  174. # Update the post counts
  175. user.post_count += 1
  176. topic.post_count += 1
  177. topic.forum.post_count += 1
  178. # And commit it!
  179. db.session.add(topic)
  180. db.session.commit()
  181. return self
  182. def delete(self):
  183. """Deletes a post and returns self."""
  184. # This will delete the whole topic
  185. if self.topic.first_post_id == self.id:
  186. self.topic.delete()
  187. return self
  188. # Delete the last post
  189. if self.topic.last_post_id == self.id:
  190. # update the last post in the forum
  191. if self.topic.last_post_id == self.topic.forum.last_post_id:
  192. # We need the second last post in the forum here,
  193. # because the last post will be deleted
  194. second_last_post = Post.query.\
  195. filter(Post.topic_id == Topic.id,
  196. Topic.forum_id == self.topic.forum.id).\
  197. order_by(Post.id.desc()).limit(2).offset(0).\
  198. all()
  199. second_last_post = second_last_post[1]
  200. self.topic.forum.last_post_id = second_last_post.id
  201. # check if there is a second last post, else it is the first post
  202. if self.topic.second_last_post:
  203. # Now the second last post will be the last post
  204. self.topic.last_post_id = self.topic.second_last_post
  205. # there is no second last post, now the last post is also the
  206. # first post
  207. else:
  208. self.topic.last_post_id = self.topic.first_post_id
  209. # Update the post counts
  210. self.user.post_count -= 1
  211. self.topic.post_count -= 1
  212. self.topic.forum.post_count -= 1
  213. db.session.commit()
  214. db.session.delete(self)
  215. db.session.commit()
  216. return self
  217. class Topic(db.Model, CRUDMixin):
  218. __tablename__ = "topics"
  219. id = db.Column(db.Integer, primary_key=True)
  220. forum_id = db.Column(db.Integer,
  221. db.ForeignKey("forums.id",
  222. use_alter=True,
  223. name="fk_topic_forum_id"),
  224. nullable=False)
  225. title = db.Column(db.String(255), nullable=False)
  226. user_id = db.Column(db.Integer, db.ForeignKey("users.id"))
  227. username = db.Column(db.String(200), nullable=False)
  228. date_created = db.Column(UTCDateTime(timezone=True), default=time_utcnow)
  229. last_updated = db.Column(UTCDateTime(timezone=True), default=time_utcnow)
  230. locked = db.Column(db.Boolean, default=False)
  231. important = db.Column(db.Boolean, default=False)
  232. views = db.Column(db.Integer, default=0)
  233. post_count = db.Column(db.Integer, default=0)
  234. # One-to-one (uselist=False) relationship between first_post and topic
  235. first_post_id = db.Column(db.Integer, db.ForeignKey("posts.id",
  236. ondelete="CASCADE"))
  237. first_post = db.relationship("Post", backref="first_post", uselist=False,
  238. foreign_keys=[first_post_id])
  239. # One-to-one
  240. last_post_id = db.Column(db.Integer, db.ForeignKey("posts.id"))
  241. last_post = db.relationship("Post", backref="last_post", uselist=False,
  242. foreign_keys=[last_post_id])
  243. # One-to-many
  244. posts = db.relationship("Post", backref="topic", lazy="dynamic",
  245. primaryjoin="Post.topic_id == Topic.id",
  246. cascade="all, delete-orphan", post_update=True)
  247. # Properties
  248. @property
  249. def second_last_post(self):
  250. """Returns the second last post."""
  251. return self.posts[-2].id
  252. @property
  253. def slug(self):
  254. """Returns a slugified version of the topic title."""
  255. return slugify(self.title)
  256. @property
  257. def url(self):
  258. """Returns the slugified url for the topic."""
  259. return url_for("forum.view_topic", topic_id=self.id, slug=self.slug)
  260. def first_unread(self, topicsread, user, forumsread=None):
  261. """Returns the url to the first unread post if any else to the topic
  262. itself.
  263. :param topicsread: The topicsread object for the topic
  264. :param user: The user who should be checked if he has read the last post
  265. in the topic
  266. :param forumsread: The forumsread object in which the topic is. If you
  267. also want to check if the user has marked the forum as
  268. read, than you will also need to pass an forumsread
  269. object.
  270. """
  271. # If the topic is unread try to get the first unread post
  272. if topic_is_unread(self, topicsread, user, forumsread):
  273. #
  274. query = Post.query.filter(Post.topic_id == self.id)
  275. if topicsread is not None:
  276. query = query.filter(Post.date_created > topicsread.last_read)
  277. post = query.order_by(Post.id.asc()).first()
  278. if post is not None:
  279. return post.url
  280. return self.url
  281. # Methods
  282. def __init__(self, title=None, user=None):
  283. """Creates a topic object with some initial values.
  284. :param title: The title of the topic.
  285. :param user: The user of the post.
  286. """
  287. if title:
  288. self.title = title
  289. if user:
  290. self.user_id = user.id
  291. self.username = user.username
  292. self.date_created = self.last_updated = time_utcnow()
  293. def __repr__(self):
  294. """Set to a unique key specific to the object in the database.
  295. Required for cache.memoize() to work across requests.
  296. """
  297. return "<{} {}>".format(self.__class__.__name__, self.id)
  298. @classmethod
  299. def get_topic(cls, topic_id, user):
  300. topic = Topic.query.filter_by(id=topic_id).first_or_404()
  301. return topic
  302. def tracker_needs_update(self, forumsread, topicsread):
  303. """Returns True if the topicsread tracker needs an update.
  304. Also, if the ``TRACKER_LENGTH`` is configured, it will just recognize
  305. topics that are newer than the ``TRACKER_LENGTH`` (in days) as unread.
  306. :param forumsread: The ForumsRead object is needed because we also
  307. need to check if the forum has been cleared
  308. sometime ago.
  309. :param topicsread: The topicsread object is used to check if there is
  310. a new post in the topic.
  311. """
  312. read_cutoff = None
  313. if flaskbb_config['TRACKER_LENGTH'] > 0:
  314. read_cutoff = time_utcnow() - timedelta(
  315. days=flaskbb_config['TRACKER_LENGTH'])
  316. # The tracker is disabled - abort
  317. if read_cutoff is None:
  318. return False
  319. # Else the topic is still below the read_cutoff
  320. elif read_cutoff > self.last_post.date_created:
  321. return False
  322. # Can be None (cleared) if the user has never marked the forum as read.
  323. # If this condition is false - we need to update the tracker
  324. if forumsread and forumsread.cleared is not None and \
  325. forumsread.cleared >= self.last_post.date_created:
  326. return False
  327. if topicsread and topicsread.last_read >= self.last_post.date_created:
  328. return False
  329. return True
  330. def update_read(self, user, forum, forumsread):
  331. """Updates the topicsread and forumsread tracker for a specified user,
  332. if the topic contains new posts or the user hasn't read the topic.
  333. Returns True if the tracker has been updated.
  334. :param user: The user for whom the readstracker should be updated.
  335. :param forum: The forum in which the topic is.
  336. :param forumsread: The forumsread object. It is used to check if there
  337. is a new post since the forum has been marked as
  338. read.
  339. """
  340. # User is not logged in - abort
  341. if not user.is_authenticated:
  342. return False
  343. topicsread = TopicsRead.query.\
  344. filter(TopicsRead.user_id == user.id,
  345. TopicsRead.topic_id == self.id).first()
  346. if not self.tracker_needs_update(forumsread, topicsread):
  347. return False
  348. # Because we return True/False if the trackers have been
  349. # updated, we need to store the status in a temporary variable
  350. updated = False
  351. # A new post has been submitted that the user hasn't read.
  352. # Updating...
  353. if topicsread:
  354. topicsread.last_read = time_utcnow()
  355. topicsread.save()
  356. updated = True
  357. # The user has not visited the topic before. Inserting him in
  358. # the TopicsRead model.
  359. elif not topicsread:
  360. topicsread = TopicsRead()
  361. topicsread.user_id = user.id
  362. topicsread.topic_id = self.id
  363. topicsread.forum_id = self.forum_id
  364. topicsread.last_read = time_utcnow()
  365. topicsread.save()
  366. updated = True
  367. # No unread posts
  368. else:
  369. updated = False
  370. # Save True/False if the forums tracker has been updated.
  371. updated = forum.update_read(user, forumsread, topicsread)
  372. return updated
  373. def recalculate(self):
  374. """Recalculates the post count in the topic."""
  375. post_count = Post.query.filter_by(topic_id=self.id).count()
  376. self.post_count = post_count
  377. self.save()
  378. return self
  379. def move(self, new_forum):
  380. """Moves a topic to the given forum.
  381. Returns True if it could successfully move the topic to forum.
  382. :param new_forum: The new forum for the topic
  383. """
  384. # if the target forum is the current forum, abort
  385. if self.forum_id == new_forum.id:
  386. return False
  387. old_forum = self.forum
  388. self.forum.post_count -= self.post_count
  389. self.forum.topic_count -= 1
  390. self.forum_id = new_forum.id
  391. new_forum.post_count += self.post_count
  392. new_forum.topic_count += 1
  393. db.session.commit()
  394. new_forum.update_last_post()
  395. old_forum.update_last_post()
  396. TopicsRead.query.filter_by(topic_id=self.id).delete()
  397. return True
  398. def save(self, user=None, forum=None, post=None):
  399. """Saves a topic and returns the topic object. If no parameters are
  400. given, it will only update the topic.
  401. :param user: The user who has created the topic
  402. :param forum: The forum where the topic is stored
  403. :param post: The post object which is connected to the topic
  404. """
  405. # Updates the topic
  406. if self.id:
  407. db.session.add(self)
  408. db.session.commit()
  409. return self
  410. # Set the forum and user id
  411. self.forum_id = forum.id
  412. self.user_id = user.id
  413. self.username = user.username
  414. # Set the last_updated time. Needed for the readstracker
  415. created = time_utcnow()
  416. self.date_created = self.last_updated = created
  417. # Insert and commit the topic
  418. db.session.add(self)
  419. db.session.commit()
  420. # Create the topic post
  421. post.save(user, self)
  422. # Update the first post id
  423. self.first_post_id = post.id
  424. # Update the topic count
  425. forum.topic_count += 1
  426. db.session.commit()
  427. return self
  428. def delete(self, users=None):
  429. """Deletes a topic with the corresponding posts. If a list with
  430. user objects is passed it will also update their post counts
  431. :param users: A list with user objects
  432. """
  433. # Grab the second last topic in the forum + parents/childs
  434. topic = Topic.query.\
  435. filter_by(forum_id=self.forum_id).\
  436. order_by(Topic.last_post_id.desc()).limit(2).offset(0).all()
  437. # do we want to delete the topic with the last post in the forum?
  438. if topic and topic[0].id == self.id:
  439. try:
  440. # Now the second last post will be the last post
  441. self.forum.last_post_id = topic[1].last_post_id
  442. self.forum.last_post_title = topic[1].title
  443. self.forum.last_post_user_id = topic[1].user_id
  444. self.forum.last_post_username = topic[1].username
  445. self.forum.last_post_created = topic[1].last_updated
  446. # Catch an IndexError when you delete the last topic in the forum
  447. # There is no second last post
  448. except IndexError:
  449. self.forum.last_post_id = None
  450. self.forum.last_post_title = None
  451. self.forum.last_post_user_id = None
  452. self.forum.last_post_username = None
  453. self.forum.last_post_created = None
  454. # Commit the changes
  455. db.session.commit()
  456. # These things needs to be stored in a variable before they are deleted
  457. forum = self.forum
  458. TopicsRead.query.filter_by(topic_id=self.id).delete()
  459. # Delete the topic
  460. db.session.delete(self)
  461. db.session.commit()
  462. # Update the post counts
  463. if users:
  464. for user in users:
  465. user.post_count = Post.query.filter_by(user_id=user.id).count()
  466. db.session.commit()
  467. forum.topic_count = Topic.query.\
  468. filter_by(forum_id=self.forum_id).\
  469. count()
  470. forum.post_count = Post.query.\
  471. filter(Post.topic_id == Topic.id,
  472. Topic.forum_id == self.forum_id).\
  473. count()
  474. db.session.commit()
  475. return self
  476. class Forum(db.Model, CRUDMixin):
  477. __tablename__ = "forums"
  478. id = db.Column(db.Integer, primary_key=True)
  479. category_id = db.Column(db.Integer, db.ForeignKey("categories.id"),
  480. nullable=False)
  481. title = db.Column(db.String(255), nullable=False)
  482. description = db.Column(db.Text)
  483. position = db.Column(db.Integer, default=1, nullable=False)
  484. locked = db.Column(db.Boolean, default=False, nullable=False)
  485. show_moderators = db.Column(db.Boolean, default=False, nullable=False)
  486. external = db.Column(db.String(200))
  487. post_count = db.Column(db.Integer, default=0, nullable=False)
  488. topic_count = db.Column(db.Integer, default=0, nullable=False)
  489. # One-to-one
  490. last_post_id = db.Column(db.Integer, db.ForeignKey("posts.id"))
  491. last_post = db.relationship("Post", backref="last_post_forum",
  492. uselist=False, foreign_keys=[last_post_id])
  493. # Not nice, but needed to improve the performance
  494. last_post_title = db.Column(db.String(255))
  495. last_post_user_id = db.Column(db.Integer, db.ForeignKey("users.id"))
  496. last_post_username = db.Column(db.String(255))
  497. last_post_created = db.Column(UTCDateTime(timezone=True),
  498. default=time_utcnow)
  499. # One-to-many
  500. topics = db.relationship(
  501. "Topic",
  502. backref="forum",
  503. lazy="dynamic",
  504. cascade="all, delete-orphan"
  505. )
  506. # Many-to-many
  507. moderators = db.relationship(
  508. "User",
  509. secondary=moderators,
  510. primaryjoin=(moderators.c.forum_id == id),
  511. backref=db.backref("forummoderator", lazy="dynamic"),
  512. lazy="joined"
  513. )
  514. groups = db.relationship(
  515. "Group",
  516. secondary=forumgroups,
  517. primaryjoin=(forumgroups.c.forum_id == id),
  518. backref="forumgroups",
  519. lazy="joined",
  520. )
  521. # Properties
  522. @property
  523. def slug(self):
  524. """Returns a slugified version from the forum title"""
  525. return slugify(self.title)
  526. @property
  527. def url(self):
  528. """Returns the slugified url for the forum"""
  529. if self.external:
  530. return self.external
  531. return url_for("forum.view_forum", forum_id=self.id, slug=self.slug)
  532. @property
  533. def last_post_url(self):
  534. """Returns the url for the last post in the forum"""
  535. return url_for("forum.view_post", post_id=self.last_post_id)
  536. # Methods
  537. def __repr__(self):
  538. """Set to a unique key specific to the object in the database.
  539. Required for cache.memoize() to work across requests.
  540. """
  541. return "<{} {}>".format(self.__class__.__name__, self.id)
  542. def update_last_post(self):
  543. """Updates the last post in the forum."""
  544. last_post = Post.query.\
  545. filter(Post.topic_id == Topic.id,
  546. Topic.forum_id == self.id).\
  547. order_by(Post.date_created.desc()).\
  548. first()
  549. # Last post is none when there are no topics in the forum
  550. if last_post is not None:
  551. # a new last post was found in the forum
  552. if not last_post.id == self.last_post_id:
  553. self.last_post_id = last_post.id
  554. self.last_post_title = last_post.topic.title
  555. self.last_post_user_id = last_post.user_id
  556. self.last_post_username = last_post.username
  557. self.last_post_created = last_post.date_created
  558. # No post found..
  559. else:
  560. self.last_post_id = None
  561. self.last_post_title = None
  562. self.last_post_user_id = None
  563. self.last_post_username = None
  564. self.last_post_created = None
  565. db.session.commit()
  566. def update_read(self, user, forumsread, topicsread):
  567. """Updates the ForumsRead status for the user. In order to work
  568. correctly, be sure that `topicsread is **not** `None`.
  569. :param user: The user for whom we should check if he has read the
  570. forum.
  571. :param forumsread: The forumsread object. It is needed to check if
  572. if the forum is unread. If `forumsread` is `None`
  573. and the forum is unread, it will create a new entry
  574. in the `ForumsRead` relation, else (and the forum
  575. is still unread) we are just going to update the
  576. entry in the `ForumsRead` relation.
  577. :param topicsread: The topicsread object is used in combination
  578. with the forumsread object to check if the
  579. forumsread relation should be updated and
  580. therefore is unread.
  581. """
  582. if not user.is_authenticated or topicsread is None:
  583. return False
  584. read_cutoff = None
  585. if flaskbb_config['TRACKER_LENGTH'] > 0:
  586. read_cutoff = time_utcnow() - timedelta(
  587. days=flaskbb_config['TRACKER_LENGTH'])
  588. # fetch the unread posts in the forum
  589. unread_count = Topic.query.\
  590. outerjoin(TopicsRead,
  591. db.and_(TopicsRead.topic_id == Topic.id,
  592. TopicsRead.user_id == user.id)).\
  593. outerjoin(ForumsRead,
  594. db.and_(ForumsRead.forum_id == Topic.forum_id,
  595. ForumsRead.user_id == user.id)).\
  596. filter(Topic.forum_id == self.id,
  597. Topic.last_updated > read_cutoff,
  598. db.or_(TopicsRead.last_read == None,
  599. TopicsRead.last_read < Topic.last_updated)).\
  600. count()
  601. # No unread topics available - trying to mark the forum as read
  602. if unread_count == 0:
  603. if forumsread and forumsread.last_read > topicsread.last_read:
  604. return False
  605. # ForumRead Entry exists - Updating it because a new topic/post
  606. # has been submitted and has read everything (obviously, else the
  607. # unread_count would be useless).
  608. elif forumsread:
  609. forumsread.last_read = time_utcnow()
  610. forumsread.save()
  611. return True
  612. # No ForumRead Entry existing - creating one.
  613. forumsread = ForumsRead()
  614. forumsread.user_id = user.id
  615. forumsread.forum_id = self.id
  616. forumsread.last_read = time_utcnow()
  617. forumsread.save()
  618. return True
  619. # Nothing updated, because there are still more than 0 unread
  620. # topicsread
  621. return False
  622. def recalculate(self, last_post=False):
  623. """Recalculates the post_count and topic_count in the forum.
  624. Returns the forum with the recounted stats.
  625. :param last_post: If set to ``True`` it will also try to update
  626. the last post columns in the forum.
  627. """
  628. topic_count = Topic.query.filter_by(forum_id=self.id).count()
  629. post_count = Post.query.\
  630. filter(Post.topic_id == Topic.id,
  631. Topic.forum_id == self.id).\
  632. count()
  633. self.topic_count = topic_count
  634. self.post_count = post_count
  635. if last_post:
  636. self.update_last_post()
  637. self.save()
  638. return self
  639. def save(self, groups=None):
  640. """Saves a forum
  641. :param moderators: If given, it will update the moderators in this
  642. forum with the given iterable of user objects.
  643. :param groups: A list with group objects.
  644. """
  645. if self.id:
  646. db.session.merge(self)
  647. else:
  648. if groups is None:
  649. # importing here because of circular dependencies
  650. from flaskbb.user.models import Group
  651. self.groups = Group.query.order_by(Group.name.asc()).all()
  652. db.session.add(self)
  653. db.session.commit()
  654. return self
  655. def delete(self, users=None):
  656. """Deletes forum. If a list with involved user objects is passed,
  657. it will also update their post counts
  658. :param users: A list with user objects
  659. """
  660. # Delete the forum
  661. db.session.delete(self)
  662. db.session.commit()
  663. # Delete the entries for the forum in the ForumsRead and TopicsRead
  664. # relation
  665. ForumsRead.query.filter_by(forum_id=self.id).delete()
  666. TopicsRead.query.filter_by(forum_id=self.id).delete()
  667. # Update the users post count
  668. if users:
  669. users_list = []
  670. for user in users:
  671. user.post_count = Post.query.filter_by(user_id=user.id).count()
  672. users_list.append(user)
  673. db.session.add_all(users_list)
  674. db.session.commit()
  675. return self
  676. def move_topics_to(self, topics):
  677. """Moves a bunch a topics to the forum. Returns ``True`` if all
  678. topics were moved successfully to the forum.
  679. :param topics: A iterable with topic objects.
  680. """
  681. status = False
  682. for topic in topics:
  683. status = topic.move(self)
  684. return status
  685. # Classmethods
  686. @classmethod
  687. def get_forum(cls, forum_id, user):
  688. """Returns the forum and forumsread object as a tuple for the user.
  689. :param forum_id: The forum id
  690. :param user: The user object is needed to check if we also need their
  691. forumsread object.
  692. """
  693. if user.is_authenticated:
  694. forum, forumsread = Forum.query.\
  695. filter(Forum.id == forum_id).\
  696. options(db.joinedload("category")).\
  697. outerjoin(ForumsRead,
  698. db.and_(ForumsRead.forum_id == Forum.id,
  699. ForumsRead.user_id == user.id)).\
  700. add_entity(ForumsRead).\
  701. first_or_404()
  702. else:
  703. forum = Forum.query.filter(Forum.id == forum_id).first_or_404()
  704. forumsread = None
  705. return forum, forumsread
  706. @classmethod
  707. def get_topics(cls, forum_id, user, page=1, per_page=20):
  708. """Get the topics for the forum. If the user is logged in,
  709. it will perform an outerjoin for the topics with the topicsread and
  710. forumsread relation to check if it is read or unread.
  711. :param forum_id: The forum id
  712. :param user: The user object
  713. :param page: The page whom should be loaded
  714. :param per_page: How many topics per page should be shown
  715. """
  716. if user.is_authenticated:
  717. topics = Topic.query.filter_by(forum_id=forum_id).\
  718. outerjoin(TopicsRead,
  719. db.and_(TopicsRead.topic_id == Topic.id,
  720. TopicsRead.user_id == user.id)).\
  721. add_entity(TopicsRead).\
  722. order_by(Topic.important.desc(), Topic.last_updated.desc()).\
  723. paginate(page, per_page, True)
  724. else:
  725. topics = Topic.query.filter_by(forum_id=forum_id).\
  726. order_by(Topic.important.desc(), Topic.last_updated.desc()).\
  727. paginate(page, per_page, True)
  728. topics.items = [(topic, None) for topic in topics.items]
  729. return topics
  730. class Category(db.Model, CRUDMixin):
  731. __tablename__ = "categories"
  732. id = db.Column(db.Integer, primary_key=True)
  733. title = db.Column(db.String(255), nullable=False)
  734. description = db.Column(db.Text)
  735. position = db.Column(db.Integer, default=1, nullable=False)
  736. # One-to-many
  737. forums = db.relationship("Forum", backref="category", lazy="dynamic",
  738. primaryjoin='Forum.category_id == Category.id',
  739. order_by='asc(Forum.position)',
  740. cascade="all, delete-orphan")
  741. # Properties
  742. @property
  743. def slug(self):
  744. """Returns a slugified version from the category title"""
  745. return slugify(self.title)
  746. @property
  747. def url(self):
  748. """Returns the slugified url for the category"""
  749. return url_for("forum.view_category", category_id=self.id,
  750. slug=self.slug)
  751. # Methods
  752. def __repr__(self):
  753. """Set to a unique key specific to the object in the database.
  754. Required for cache.memoize() to work across requests.
  755. """
  756. return "<{} {}>".format(self.__class__.__name__, self.id)
  757. def delete(self, users=None):
  758. """Deletes a category. If a list with involved user objects is passed,
  759. it will also update their post counts
  760. :param users: A list with user objects
  761. """
  762. # and finally delete the category itself
  763. db.session.delete(self)
  764. db.session.commit()
  765. # Update the users post count
  766. if users:
  767. for user in users:
  768. user.post_count = Post.query.filter_by(user_id=user.id).count()
  769. db.session.commit()
  770. return self
  771. # Classmethods
  772. @classmethod
  773. def get_all(cls, user):
  774. """Get all categories with all associated forums.
  775. It returns a list with tuples. Those tuples are containing the category
  776. and their associated forums (whose are stored in a list).
  777. For example::
  778. [(<Category 1>, [(<Forum 2>, <ForumsRead>), (<Forum 1>, None)]),
  779. (<Category 2>, [(<Forum 3>, None), (<Forum 4>, None)])]
  780. :param user: The user object is needed to check if we also need their
  781. forumsread object.
  782. """
  783. # import Group model locally to avoid cicular imports
  784. from flaskbb.user.models import Group
  785. if user.is_authenticated:
  786. # get list of user group ids
  787. user_groups = [gr.id for gr in user.groups]
  788. # filter forums by user groups
  789. user_forums = Forum.query.\
  790. filter(Forum.groups.any(Group.id.in_(user_groups))).\
  791. subquery()
  792. forum_alias = aliased(Forum, user_forums)
  793. # get all
  794. forums = cls.query.\
  795. join(forum_alias, cls.id == forum_alias.category_id).\
  796. outerjoin(ForumsRead,
  797. db.and_(ForumsRead.forum_id == forum_alias.id,
  798. ForumsRead.user_id == user.id)).\
  799. add_entity(forum_alias).\
  800. add_entity(ForumsRead).\
  801. order_by(Category.position, Category.id,
  802. forum_alias.position).\
  803. all()
  804. else:
  805. guest_group = Group.get_guest_group()
  806. # filter forums by guest groups
  807. guest_forums = Forum.query.\
  808. filter(Forum.groups.any(Group.id == guest_group.id)).\
  809. subquery()
  810. forum_alias = aliased(Forum, guest_forums)
  811. forums = cls.query.\
  812. join(forum_alias, cls.id == forum_alias.category_id).\
  813. add_entity(forum_alias).\
  814. order_by(Category.position, Category.id,
  815. forum_alias.position).\
  816. all()
  817. return get_categories_and_forums(forums, user)
  818. @classmethod
  819. def get_forums(cls, category_id, user):
  820. """Get the forums for the category.
  821. It returns a tuple with the category and the forums with their
  822. forumsread object are stored in a list.
  823. A return value can look like this for a category with two forums::
  824. (<Category 1>, [(<Forum 1>, None), (<Forum 2>, None)])
  825. :param category_id: The category id
  826. :param user: The user object is needed to check if we also need their
  827. forumsread object.
  828. """
  829. from flaskbb.user.models import Group
  830. if user.is_authenticated:
  831. # get list of user group ids
  832. user_groups = [gr.id for gr in user.groups]
  833. # filter forums by user groups
  834. user_forums = Forum.query.\
  835. filter(Forum.groups.any(Group.id.in_(user_groups))).\
  836. subquery()
  837. forum_alias = aliased(Forum, user_forums)
  838. forums = cls.query.\
  839. filter(cls.id == category_id).\
  840. join(forum_alias, cls.id == forum_alias.category_id).\
  841. outerjoin(ForumsRead,
  842. db.and_(ForumsRead.forum_id == forum_alias.id,
  843. ForumsRead.user_id == user.id)).\
  844. add_entity(forum_alias).\
  845. add_entity(ForumsRead).\
  846. order_by(forum_alias.position).\
  847. all()
  848. else:
  849. guest_group = Group.get_guest_group()
  850. # filter forums by guest groups
  851. guest_forums = Forum.query.\
  852. filter(Forum.groups.any(Group.id == guest_group.id)).\
  853. subquery()
  854. forum_alias = aliased(Forum, guest_forums)
  855. forums = cls.query.\
  856. filter(cls.id == category_id).\
  857. join(forum_alias, cls.id == forum_alias.category_id).\
  858. add_entity(forum_alias).\
  859. order_by(forum_alias.position).\
  860. all()
  861. if not forums:
  862. abort(404)
  863. return get_forums(forums, user)