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