models.py 33 KB

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