models.py 36 KB

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