models.py 39 KB

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