models.py 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314
  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. import logging
  11. from flask import abort, current_app, url_for
  12. from sqlalchemy.orm import aliased
  13. from flaskbb.extensions import db
  14. from flaskbb.utils.database import (CRUDMixin, HideableCRUDMixin, UTCDateTime,
  15. make_comparable)
  16. from flaskbb.utils.helpers import (get_categories_and_forums, get_forums,
  17. slugify, time_utcnow, topic_is_unread)
  18. from flaskbb.utils.settings import flaskbb_config
  19. logger = logging.getLogger(__name__)
  20. moderators = db.Table(
  21. 'moderators',
  22. db.Column('user_id', db.Integer(),
  23. db.ForeignKey('users.id', ondelete="CASCADE"),
  24. nullable=False),
  25. db.Column('forum_id', db.Integer(),
  26. db.ForeignKey('forums.id', ondelete="CASCADE"),
  27. nullable=False))
  28. topictracker = db.Table(
  29. 'topictracker',
  30. db.Column('user_id', db.Integer(),
  31. db.ForeignKey('users.id', ondelete="CASCADE"),
  32. nullable=False),
  33. db.Column('topic_id', db.Integer(),
  34. db.ForeignKey('topics.id', ondelete="CASCADE"),
  35. nullable=False))
  36. forumgroups = db.Table(
  37. 'forumgroups',
  38. db.Column('group_id', db.Integer(),
  39. db.ForeignKey('groups.id', ondelete="CASCADE"),
  40. nullable=False),
  41. db.Column('forum_id', db.Integer(),
  42. db.ForeignKey('forums.id', ondelete="CASCADE"),
  43. nullable=False))
  44. class TopicsRead(db.Model, CRUDMixin):
  45. __tablename__ = "topicsread"
  46. user_id = db.Column(db.Integer,
  47. db.ForeignKey("users.id", ondelete="CASCADE"),
  48. primary_key=True)
  49. user = db.relationship('User', uselist=False, foreign_keys=[user_id])
  50. topic_id = db.Column(db.Integer,
  51. db.ForeignKey("topics.id", ondelete="CASCADE"),
  52. primary_key=True)
  53. topic = db.relationship('Topic', uselist=False, foreign_keys=[topic_id])
  54. forum_id = db.Column(db.Integer,
  55. db.ForeignKey("forums.id", ondelete="CASCADE"),
  56. primary_key=True)
  57. forum = db.relationship('Forum', uselist=False, foreign_keys=[forum_id])
  58. last_read = db.Column(UTCDateTime(timezone=True), default=time_utcnow,
  59. nullable=False)
  60. class ForumsRead(db.Model, CRUDMixin):
  61. __tablename__ = "forumsread"
  62. user_id = db.Column(db.Integer,
  63. db.ForeignKey("users.id", ondelete="CASCADE"),
  64. primary_key=True)
  65. user = db.relationship('User', uselist=False, foreign_keys=[user_id])
  66. forum_id = db.Column(db.Integer,
  67. db.ForeignKey("forums.id", ondelete="CASCADE"),
  68. primary_key=True)
  69. forum = db.relationship('Forum', uselist=False, foreign_keys=[forum_id])
  70. last_read = db.Column(UTCDateTime(timezone=True), default=time_utcnow,
  71. nullable=False)
  72. cleared = db.Column(UTCDateTime(timezone=True), nullable=True)
  73. @make_comparable
  74. class Report(db.Model, CRUDMixin):
  75. __tablename__ = "reports"
  76. # TODO: Store in addition to the info below topic title and username
  77. # as well. So that in case a user or post gets deleted, we can
  78. # still view the report
  79. id = db.Column(db.Integer, primary_key=True)
  80. reporter_id = db.Column(db.Integer, db.ForeignKey("users.id"),
  81. nullable=True)
  82. reported = db.Column(UTCDateTime(timezone=True), default=time_utcnow,
  83. nullable=False)
  84. post_id = db.Column(db.Integer, db.ForeignKey("posts.id"), nullable=True)
  85. zapped = db.Column(UTCDateTime(timezone=True), nullable=True)
  86. zapped_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
  87. reason = db.Column(db.Text, nullable=True)
  88. post = db.relationship(
  89. "Post", lazy="joined",
  90. backref=db.backref('report', cascade='all, delete-orphan')
  91. )
  92. reporter = db.relationship("User", lazy="joined",
  93. foreign_keys=[reporter_id])
  94. zapper = db.relationship("User", lazy="joined", foreign_keys=[zapped_by])
  95. def __repr__(self):
  96. return "<{} {}>".format(self.__class__.__name__, self.id)
  97. def save(self, post=None, user=None):
  98. """Saves a report.
  99. :param post: The post that should be reported
  100. :param user: The user who has reported the post
  101. :param reason: The reason why the user has reported the post
  102. """
  103. if self.id:
  104. db.session.add(self)
  105. db.session.commit()
  106. return self
  107. if post and user:
  108. self.reporter = user
  109. self.reported = time_utcnow()
  110. self.post = post
  111. db.session.add(self)
  112. db.session.commit()
  113. return self
  114. @make_comparable
  115. class Post(HideableCRUDMixin, db.Model):
  116. __tablename__ = "posts"
  117. id = db.Column(db.Integer, primary_key=True)
  118. topic_id = db.Column(db.Integer,
  119. db.ForeignKey("topics.id", ondelete="CASCADE",
  120. use_alter=True),
  121. nullable=True)
  122. user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
  123. username = db.Column(db.String(200), nullable=False)
  124. content = db.Column(db.Text, nullable=False)
  125. date_created = db.Column(UTCDateTime(timezone=True), default=time_utcnow,
  126. nullable=False)
  127. date_modified = db.Column(UTCDateTime(timezone=True), nullable=True)
  128. modified_by = db.Column(db.String(200), nullable=True)
  129. # Properties
  130. @property
  131. def url(self):
  132. """Returns the url for the post."""
  133. return url_for("forum.view_post", post_id=self.id)
  134. # Methods
  135. def __init__(self, content=None, user=None, topic=None):
  136. """Creates a post object with some initial values.
  137. :param content: The content of the post.
  138. :param user: The user of the post.
  139. :param topic: Can either be the topic_id or the topic object.
  140. """
  141. if content:
  142. self.content = content
  143. if user:
  144. # setting user here -- even with setting the user id explicitly
  145. # breaks the bulk insert for some reason
  146. self.user_id = user.id
  147. self.username = user.username
  148. if topic:
  149. self.topic_id = topic if isinstance(topic, int) else topic.id
  150. self.date_created = time_utcnow()
  151. def __repr__(self):
  152. """Set to a unique key specific to the object in the database.
  153. Required for cache.memoize() to work across requests.
  154. """
  155. return "<{} {}>".format(self.__class__.__name__, self.id)
  156. def save(self, user=None, topic=None):
  157. """Saves a new post. If no parameters are passed we assume that
  158. you will just update an existing post. It returns the object after the
  159. operation was successful.
  160. :param user: The user who has created the post
  161. :param topic: The topic in which the post was created
  162. """
  163. current_app.pluggy.hook.flaskbb_event_post_save_before(post=self)
  164. # update/edit the post
  165. if self.id:
  166. db.session.add(self)
  167. db.session.commit()
  168. current_app.pluggy.hook.flaskbb_event_post_save_after(post=self,
  169. is_new=False)
  170. return self
  171. # Adding a new post
  172. if user and topic:
  173. created = time_utcnow()
  174. self.user = user
  175. self.username = user.username
  176. self.topic = topic
  177. self.date_created = created
  178. if not topic.hidden:
  179. topic.last_updated = created
  180. topic.last_post = self
  181. # Update the last post info for the forum
  182. topic.forum.last_post = self
  183. topic.forum.last_post_user = self.user
  184. topic.forum.last_post_title = topic.title
  185. topic.forum.last_post_username = user.username
  186. topic.forum.last_post_created = created
  187. # Update the post counts
  188. user.post_count += 1
  189. topic.post_count += 1
  190. topic.forum.post_count += 1
  191. # And commit it!
  192. db.session.add(topic)
  193. db.session.commit()
  194. current_app.pluggy.hook.flaskbb_event_post_save_after(post=self,
  195. is_new=True)
  196. return self
  197. def delete(self):
  198. """Deletes a post and returns self."""
  199. # This will delete the whole topic
  200. if self.topic.first_post == self:
  201. self.topic.delete()
  202. return self
  203. db.session.delete(self)
  204. self._deal_with_last_post()
  205. self._update_counts()
  206. db.session.commit()
  207. return self
  208. def hide(self, user):
  209. if self.hidden:
  210. return
  211. if self.topic.first_post == self:
  212. self.topic.hide(user)
  213. return self
  214. super(Post, self).hide(user)
  215. self._deal_with_last_post()
  216. self._update_counts()
  217. db.session.commit()
  218. return self
  219. def unhide(self):
  220. if not self.hidden:
  221. return
  222. if self.topic.first_post == self:
  223. self.topic.unhide()
  224. return self
  225. self._restore_post_to_topic()
  226. super(Post, self).unhide()
  227. self._update_counts()
  228. db.session.commit()
  229. return self
  230. def _deal_with_last_post(self):
  231. if self.topic.last_post == self:
  232. # update the last post in the forum
  233. if self.topic.last_post == self.topic.forum.last_post:
  234. # We need the second last post in the forum here,
  235. # because the last post will be deleted
  236. second_last_post = Post.query.filter(
  237. Post.topic_id == Topic.id,
  238. Topic.forum_id == self.topic.forum.id,
  239. Post.hidden != True,
  240. Post.id != self.id
  241. ).order_by(
  242. Post.id.desc()
  243. ).limit(1).first()
  244. if second_last_post:
  245. # now lets update the second last post to the last post
  246. self.topic.forum.last_post = second_last_post
  247. self.topic.forum.last_post_title = second_last_post.topic.title # noqa
  248. self.topic.forum.last_post_user = second_last_post.user
  249. self.topic.forum.last_post_username = second_last_post.username # noqa
  250. self.topic.forum.last_post_created = second_last_post.date_created # noqa
  251. else:
  252. self.topic.forum.last_post = None
  253. self.topic.forum.last_post_title = None
  254. self.topic.forum.last_post_user = None
  255. self.topic.forum.last_post_username = None
  256. self.topic.forum.last_post_created = None
  257. # check if there is a second last post in this topic
  258. if self.topic.second_last_post is not None:
  259. # Now the second last post will be the last post
  260. self.topic.last_post_id = self.topic.second_last_post
  261. # there is no second last post, now the last post is also the
  262. # first post
  263. else:
  264. self.topic.last_post = self.topic.first_post
  265. self.topic.last_updated = self.topic.last_post.date_created
  266. def _update_counts(self):
  267. if self.hidden:
  268. clauses = [Post.hidden != True, Post.id != self.id]
  269. else:
  270. clauses = [db.or_(Post.hidden != True, Post.id == self.id)]
  271. user_post_clauses = clauses + [
  272. Post.user_id == self.user.id,
  273. Topic.id == Post.topic_id,
  274. Topic.hidden != True,
  275. ]
  276. # Update the post counts
  277. self.user.post_count = Post.query.filter(*user_post_clauses).count()
  278. if self.topic.hidden:
  279. self.topic.post_count = 0
  280. else:
  281. topic_post_clauses = clauses + [
  282. Post.topic_id == self.topic.id,
  283. ]
  284. self.topic.post_count = Post.query.filter(
  285. *topic_post_clauses
  286. ).count()
  287. forum_post_clauses = clauses + [
  288. Post.topic_id == Topic.id,
  289. Topic.forum_id == self.topic.forum.id,
  290. Topic.hidden != True,
  291. ]
  292. self.topic.forum.post_count = Post.query.filter(
  293. *forum_post_clauses
  294. ).count()
  295. def _restore_post_to_topic(self):
  296. last_unhidden_post = Post.query.filter(
  297. Post.topic_id == self.topic_id,
  298. Post.id != self.id,
  299. Post.hidden != True,
  300. ).limit(1).first()
  301. # should never be None, but deal with it anyways to be safe
  302. if (
  303. last_unhidden_post and
  304. self.date_created > last_unhidden_post.date_created
  305. ):
  306. self.topic.last_post = self
  307. self.second_last_post = last_unhidden_post
  308. # if we're the newest in the topic again, we might be the newest
  309. # in the forum again only set if our parent topic isn't hidden
  310. if (
  311. not self.topic.hidden and
  312. (
  313. not self.topic.forum.last_post or
  314. self.date_created > self.topic.forum.last_post.date_created
  315. )
  316. ):
  317. self.topic.forum.last_post = self
  318. self.topic.forum.last_post_title = self.topic.title
  319. self.topic.forum.last_post_user = self.user
  320. self.topic.forum.last_post_username = self.username
  321. self.topic.forum.last_post_created = self.date_created
  322. @make_comparable
  323. class Topic(HideableCRUDMixin, db.Model):
  324. __tablename__ = "topics"
  325. id = db.Column(db.Integer, primary_key=True)
  326. forum_id = db.Column(db.Integer,
  327. db.ForeignKey("forums.id", ondelete="CASCADE"),
  328. nullable=False)
  329. title = db.Column(db.String(255), nullable=False)
  330. user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
  331. username = db.Column(db.String(200), nullable=False)
  332. date_created = db.Column(UTCDateTime(timezone=True), default=time_utcnow,
  333. nullable=False)
  334. last_updated = db.Column(UTCDateTime(timezone=True), default=time_utcnow,
  335. nullable=False)
  336. locked = db.Column(db.Boolean, default=False, nullable=False)
  337. important = db.Column(db.Boolean, default=False, nullable=False)
  338. views = db.Column(db.Integer, default=0, nullable=False)
  339. post_count = db.Column(db.Integer, default=0, nullable=False)
  340. # One-to-one (uselist=False) relationship between first_post and topic
  341. first_post_id = db.Column(db.Integer,
  342. db.ForeignKey("posts.id", ondelete="CASCADE"),
  343. nullable=True)
  344. first_post = db.relationship("Post", backref="first_post", uselist=False,
  345. foreign_keys=[first_post_id])
  346. # One-to-one
  347. last_post_id = db.Column(db.Integer, db.ForeignKey("posts.id"),
  348. nullable=True)
  349. last_post = db.relationship("Post", backref="last_post", uselist=False,
  350. foreign_keys=[last_post_id])
  351. # One-to-many
  352. posts = db.relationship("Post", backref="topic", lazy="dynamic",
  353. primaryjoin="Post.topic_id == Topic.id",
  354. cascade="all, delete-orphan",
  355. post_update=True)
  356. # Properties
  357. @property
  358. def second_last_post(self):
  359. """Returns the second last post or None."""
  360. try:
  361. return self.posts[-2].id
  362. except IndexError:
  363. return None
  364. @property
  365. def slug(self):
  366. """Returns a slugified version of the topic title."""
  367. return slugify(self.title)
  368. @property
  369. def url(self):
  370. """Returns the slugified url for the topic."""
  371. return url_for("forum.view_topic", topic_id=self.id, slug=self.slug)
  372. def first_unread(self, topicsread, user, forumsread=None):
  373. """Returns the url to the first unread post. If no unread posts exist
  374. it will return the url to the topic.
  375. :param topicsread: The topicsread object for the topic
  376. :param user: The user who should be checked if he has read the
  377. last post in the topic
  378. :param forumsread: The forumsread object in which the topic is. If you
  379. also want to check if the user has marked the forum as
  380. read, than you will also need to pass an forumsread
  381. object.
  382. """
  383. # If the topic is unread try to get the first unread post
  384. if topic_is_unread(self, topicsread, user, forumsread):
  385. query = Post.query.filter(Post.topic_id == self.id)
  386. if topicsread is not None:
  387. query = query.filter(Post.date_created > topicsread.last_read)
  388. post = query.order_by(Post.id.asc()).first()
  389. if post is not None:
  390. return post.url
  391. return self.url
  392. # Methods
  393. def __init__(self, title=None, user=None):
  394. """Creates a topic object with some initial values.
  395. :param title: The title of the topic.
  396. :param user: The user of the post.
  397. """
  398. if title:
  399. self.title = title
  400. if user:
  401. # setting the user here, even with setting the id, breaks the bulk
  402. # insert stuff as they use the session.bulk_save_objects which does
  403. # not trigger relationships
  404. self.user_id = user.id
  405. self.username = user.username
  406. self.date_created = self.last_updated = time_utcnow()
  407. def __repr__(self):
  408. """Set to a unique key specific to the object in the database.
  409. Required for cache.memoize() to work across requests.
  410. """
  411. return "<{} {}>".format(self.__class__.__name__, self.id)
  412. @classmethod
  413. def get_topic(cls, topic_id, user):
  414. topic = Topic.query.filter_by(id=topic_id).first_or_404()
  415. return topic
  416. def tracker_needs_update(self, forumsread, topicsread):
  417. """Returns True if the topicsread tracker needs an update.
  418. Also, if the ``TRACKER_LENGTH`` is configured, it will just recognize
  419. topics that are newer than the ``TRACKER_LENGTH`` (in days) as unread.
  420. :param forumsread: The ForumsRead object is needed because we also
  421. need to check if the forum has been cleared
  422. sometime ago.
  423. :param topicsread: The topicsread object is used to check if there is
  424. a new post in the topic.
  425. """
  426. read_cutoff = None
  427. if flaskbb_config['TRACKER_LENGTH'] > 0:
  428. read_cutoff = time_utcnow() - timedelta(
  429. days=flaskbb_config['TRACKER_LENGTH'])
  430. # The tracker is disabled - abort
  431. if read_cutoff is None:
  432. logger.debug("Readtracker is disabled.")
  433. return False
  434. # Else the topic is still below the read_cutoff
  435. elif read_cutoff > self.last_post.date_created:
  436. logger.debug("Topic is below the read_cutoff (too old).")
  437. return False
  438. # Can be None (cleared) if the user has never marked the forum as read.
  439. # If this condition is false - we need to update the tracker
  440. if forumsread and forumsread.cleared is not None and \
  441. forumsread.cleared >= self.last_post.date_created:
  442. logger.debug("User has marked the forum as read. No new posts "
  443. "since then.")
  444. return False
  445. if topicsread and topicsread.last_read >= self.last_post.date_created:
  446. logger.debug("The last post in this topic has already been read.")
  447. return False
  448. logger.debug("Topic is unread.")
  449. return True
  450. def update_read(self, user, forum, forumsread):
  451. """Updates the topicsread and forumsread tracker for a specified user,
  452. if the topic contains new posts or the user hasn't read the topic.
  453. Returns True if the tracker has been updated.
  454. :param user: The user for whom the readstracker should be updated.
  455. :param forum: The forum in which the topic is.
  456. :param forumsread: The forumsread object. It is used to check if there
  457. is a new post since the forum has been marked as
  458. read.
  459. """
  460. # User is not logged in - abort
  461. if not user.is_authenticated:
  462. return False
  463. topicsread = TopicsRead.query.\
  464. filter(TopicsRead.user_id == user.id,
  465. TopicsRead.topic_id == self.id).first()
  466. if not self.tracker_needs_update(forumsread, topicsread):
  467. return False
  468. # Because we return True/False if the trackers have been
  469. # updated, we need to store the status in a temporary variable
  470. updated = False
  471. # A new post has been submitted that the user hasn't read.
  472. # Updating...
  473. if topicsread:
  474. logger.debug("Updating existing TopicsRead '{}' object."
  475. .format(topicsread))
  476. topicsread.last_read = time_utcnow()
  477. topicsread.save()
  478. updated = True
  479. # The user has not visited the topic before. Inserting him in
  480. # the TopicsRead model.
  481. elif not topicsread:
  482. logger.debug("Creating new TopicsRead object.")
  483. topicsread = TopicsRead()
  484. topicsread.user = user
  485. topicsread.topic = self
  486. topicsread.forum = self.forum
  487. topicsread.last_read = time_utcnow()
  488. topicsread.save()
  489. updated = True
  490. # No unread posts
  491. else:
  492. updated = False
  493. # Save True/False if the forums tracker has been updated.
  494. updated = forum.update_read(user, forumsread, topicsread)
  495. return updated
  496. def recalculate(self):
  497. """Recalculates the post count in the topic."""
  498. post_count = Post.query.filter_by(topic_id=self.id).count()
  499. self.post_count = post_count
  500. self.save()
  501. return self
  502. def move(self, new_forum):
  503. """Moves a topic to the given forum.
  504. Returns True if it could successfully move the topic to forum.
  505. :param new_forum: The new forum for the topic
  506. """
  507. # if the target forum is the current forum, abort
  508. if self.forum == new_forum:
  509. return False
  510. old_forum = self.forum
  511. self.forum.post_count -= self.post_count
  512. self.forum.topic_count -= 1
  513. self.forum = new_forum
  514. new_forum.post_count += self.post_count
  515. new_forum.topic_count += 1
  516. db.session.commit()
  517. new_forum.update_last_post()
  518. old_forum.update_last_post()
  519. TopicsRead.query.filter_by(topic_id=self.id).delete()
  520. return True
  521. def save(self, user=None, forum=None, post=None):
  522. """Saves a topic and returns the topic object. If no parameters are
  523. given, it will only update the topic.
  524. :param user: The user who has created the topic
  525. :param forum: The forum where the topic is stored
  526. :param post: The post object which is connected to the topic
  527. """
  528. current_app.pluggy.hook.flaskbb_event_topic_save_before(topic=self)
  529. # Updates the topic
  530. if self.id:
  531. db.session.add(self)
  532. db.session.commit()
  533. current_app.pluggy.hook.flaskbb_event_topic_save_after(
  534. topic=self, is_new=False
  535. )
  536. return self
  537. # Set the forum and user id
  538. self.forum = forum
  539. self.user = user
  540. self.username = user.username
  541. # Set the last_updated time. Needed for the readstracker
  542. self.date_created = self.last_updated = time_utcnow()
  543. # Insert and commit the topic
  544. db.session.add(self)
  545. db.session.commit()
  546. # Create the topic post
  547. post.save(user, self)
  548. # Update the first and last post id
  549. self.last_post = self.first_post = post
  550. # Update the topic count
  551. forum.topic_count += 1
  552. db.session.commit()
  553. current_app.pluggy.hook.flaskbb_event_topic_save_after(topic=self,
  554. is_new=True)
  555. return self
  556. def delete(self, users=None):
  557. """Deletes a topic with the corresponding posts. If a list with
  558. user objects is passed it will also update their post counts
  559. :param users: A list with user objects
  560. """
  561. forum = self.forum
  562. db.session.delete(self)
  563. self._fix_user_post_counts(users or self.involved_users().all())
  564. self._fix_post_counts(forum)
  565. # forum.last_post_id shouldn't usually be none
  566. if forum.last_post_id is None or \
  567. self.last_post_id == forum.last_post_id:
  568. forum.update_last_post(commit=False)
  569. db.session.commit()
  570. return self
  571. def hide(self, user, users=None):
  572. """Soft deletes a topic from a forum
  573. """
  574. if self.hidden:
  575. return
  576. self._remove_topic_from_forum()
  577. super(Topic, self).hide(user)
  578. self._handle_first_post()
  579. self._fix_user_post_counts(users or self.involved_users().all())
  580. self._fix_post_counts(self.forum)
  581. db.session.commit()
  582. return self
  583. def unhide(self, users=None):
  584. """Restores a hidden topic to a forum
  585. """
  586. if not self.hidden:
  587. return
  588. super(Topic, self).unhide()
  589. self._handle_first_post()
  590. self._restore_topic_to_forum()
  591. self._fix_user_post_counts(users or self.involved_users().all())
  592. self.forum.recalculate()
  593. db.session.commit()
  594. return self
  595. def _remove_topic_from_forum(self):
  596. # Grab the second last topic in the forum + parents/childs
  597. topics = Topic.query.filter(
  598. Topic.forum_id == self.forum_id,
  599. Topic.hidden != True
  600. ).order_by(
  601. Topic.last_post_id.desc()
  602. ).limit(2).offset(0).all()
  603. # do we want to replace the topic with the last post in the forum?
  604. if len(topics) > 1:
  605. if topics[0] == self:
  606. # Now the second last post will be the last post
  607. self.forum.last_post = topics[1].last_post
  608. self.forum.last_post_title = topics[1].title
  609. self.forum.last_post_user = topics[1].user
  610. self.forum.last_post_username = topics[1].username
  611. self.forum.last_post_created = topics[1].last_updated
  612. else:
  613. self.forum.last_post = None
  614. self.forum.last_post_title = None
  615. self.forum.last_post_user = None
  616. self.forum.last_post_username = None
  617. self.forum.last_post_created = None
  618. def _fix_user_post_counts(self, users=None):
  619. # Update the post counts
  620. if users:
  621. for user in users:
  622. user.post_count = Post.query.filter(
  623. Post.user_id == user.id,
  624. Topic.id == Post.topic_id,
  625. Topic.hidden != True,
  626. Post.hidden != True
  627. ).count()
  628. def _fix_post_counts(self, forum):
  629. clauses = [
  630. Topic.forum_id == forum.id
  631. ]
  632. if self.hidden:
  633. clauses.extend([
  634. Topic.id != self.id,
  635. Topic.hidden != True,
  636. ])
  637. else:
  638. clauses.append(db.or_(Topic.id == self.id, Topic.hidden != True))
  639. forum.topic_count = Topic.query.filter(*clauses).count()
  640. post_count = clauses + [
  641. Post.topic_id == Topic.id,
  642. ]
  643. if self.hidden:
  644. post_count.append(Post.hidden != True)
  645. else:
  646. post_count.append(
  647. db.or_(Post.hidden != True, Post.id == self.first_post.id)
  648. )
  649. forum.post_count = Post.query.distinct().filter(*post_count).count()
  650. def _restore_topic_to_forum(self):
  651. if (
  652. self.forum.last_post is None or
  653. self.forum.last_post_created < self.last_updated
  654. ):
  655. self.forum.last_post = self.last_post
  656. self.forum.last_post_title = self.title
  657. self.forum.last_post_user = self.user
  658. self.forum.last_post_username = self.username
  659. self.forum.last_post_created = self.last_updated
  660. def _handle_first_post(self):
  661. # have to do this specially because otherwise we start recurisve calls
  662. self.first_post.hidden = self.hidden
  663. self.first_post.hidden_by = self.hidden_by
  664. self.first_post.hidden_at = self.hidden_at
  665. def involved_users(self):
  666. """
  667. Returns a query of all users involved in the topic
  668. """
  669. # todo: Find circular import and break it
  670. from flaskbb.user.models import User
  671. return User.query.distinct().filter(
  672. Post.topic_id == self.id, User.id == Post.user_id
  673. )
  674. @make_comparable
  675. class Forum(db.Model, CRUDMixin):
  676. __tablename__ = "forums"
  677. id = db.Column(db.Integer, primary_key=True)
  678. category_id = db.Column(db.Integer,
  679. db.ForeignKey("categories.id", ondelete="CASCADE"),
  680. nullable=False)
  681. title = db.Column(db.String(255), nullable=False)
  682. description = db.Column(db.Text, nullable=True)
  683. position = db.Column(db.Integer, default=1, nullable=False)
  684. locked = db.Column(db.Boolean, default=False, nullable=False)
  685. show_moderators = db.Column(db.Boolean, default=False, nullable=False)
  686. external = db.Column(db.String(200), nullable=True)
  687. post_count = db.Column(db.Integer, default=0, nullable=False)
  688. topic_count = db.Column(db.Integer, default=0, nullable=False)
  689. # One-to-one
  690. last_post_id = db.Column(db.Integer, db.ForeignKey("posts.id"),
  691. nullable=True) # we handle this case ourselfs
  692. last_post = db.relationship("Post", backref="last_post_forum",
  693. uselist=False, foreign_keys=[last_post_id])
  694. # set to null if the user got deleted
  695. last_post_user_id = db.Column(db.Integer,
  696. db.ForeignKey("users.id",
  697. ondelete="SET NULL"),
  698. nullable=True)
  699. last_post_user = db.relationship("User", uselist=False,
  700. foreign_keys=[last_post_user_id])
  701. # Not nice, but needed to improve the performance; can be set to NULL
  702. # if the forum has no posts
  703. last_post_title = db.Column(db.String(255), nullable=True)
  704. last_post_username = db.Column(db.String(255), nullable=True)
  705. last_post_created = db.Column(UTCDateTime(timezone=True),
  706. default=time_utcnow, nullable=True)
  707. # One-to-many
  708. topics = db.relationship("Topic", backref="forum", lazy="dynamic",
  709. cascade="all, delete-orphan")
  710. # Many-to-many
  711. moderators = db.relationship(
  712. "User",
  713. secondary=moderators,
  714. primaryjoin=(moderators.c.forum_id == id),
  715. backref=db.backref("forummoderator", lazy="dynamic"),
  716. lazy="joined"
  717. )
  718. groups = db.relationship(
  719. "Group",
  720. secondary=forumgroups,
  721. primaryjoin=(forumgroups.c.forum_id == id),
  722. backref="forumgroups",
  723. lazy="joined",
  724. )
  725. # Properties
  726. @property
  727. def slug(self):
  728. """Returns a slugified version from the forum title"""
  729. return slugify(self.title)
  730. @property
  731. def url(self):
  732. """Returns the slugified url for the forum"""
  733. if self.external:
  734. return self.external
  735. return url_for("forum.view_forum", forum_id=self.id, slug=self.slug)
  736. @property
  737. def last_post_url(self):
  738. """Returns the url for the last post in the forum"""
  739. return url_for("forum.view_post", post_id=self.last_post_id)
  740. # Methods
  741. def __repr__(self):
  742. """Set to a unique key specific to the object in the database.
  743. Required for cache.memoize() to work across requests.
  744. """
  745. return "<{} {}>".format(self.__class__.__name__, self.id)
  746. def update_last_post(self, commit=True):
  747. """Updates the last post in the forum."""
  748. last_post = Post.query.\
  749. filter(Post.topic_id == Topic.id,
  750. Topic.forum_id == self.id).\
  751. order_by(Post.date_created.desc()).\
  752. limit(1)\
  753. .first()
  754. # Last post is none when there are no topics in the forum
  755. if last_post is not None:
  756. # a new last post was found in the forum
  757. if last_post != self.last_post:
  758. self.last_post = last_post
  759. self.last_post_title = last_post.topic.title
  760. self.last_post_user_id = last_post.user_id
  761. self.last_post_username = last_post.username
  762. self.last_post_created = last_post.date_created
  763. # No post found..
  764. else:
  765. self.last_post = None
  766. self.last_post_title = None
  767. self.last_post_user = None
  768. self.last_post_username = None
  769. self.last_post_created = None
  770. if commit:
  771. db.session.commit()
  772. def update_read(self, user, forumsread, topicsread):
  773. """Updates the ForumsRead status for the user. In order to work
  774. correctly, be sure that `topicsread is **not** `None`.
  775. :param user: The user for whom we should check if he has read the
  776. forum.
  777. :param forumsread: The forumsread object. It is needed to check if
  778. if the forum is unread. If `forumsread` is `None`
  779. and the forum is unread, it will create a new entry
  780. in the `ForumsRead` relation, else (and the forum
  781. is still unread) we are just going to update the
  782. entry in the `ForumsRead` relation.
  783. :param topicsread: The topicsread object is used in combination
  784. with the forumsread object to check if the
  785. forumsread relation should be updated and
  786. therefore is unread.
  787. """
  788. if not user.is_authenticated or topicsread is None:
  789. return False
  790. read_cutoff = None
  791. if flaskbb_config['TRACKER_LENGTH'] > 0:
  792. read_cutoff = time_utcnow() - timedelta(
  793. days=flaskbb_config['TRACKER_LENGTH'])
  794. # fetch the unread posts in the forum
  795. unread_count = Topic.query.\
  796. outerjoin(TopicsRead,
  797. db.and_(TopicsRead.topic_id == Topic.id,
  798. TopicsRead.user_id == user.id)).\
  799. outerjoin(ForumsRead,
  800. db.and_(ForumsRead.forum_id == Topic.forum_id,
  801. ForumsRead.user_id == user.id)).\
  802. filter(Topic.forum_id == self.id,
  803. Topic.last_updated > read_cutoff,
  804. db.or_(TopicsRead.last_read == None, # noqa: E711
  805. TopicsRead.last_read < Topic.last_updated),
  806. db.or_(ForumsRead.last_read == None, # noqa: E711
  807. ForumsRead.last_read < Topic.last_updated)).\
  808. count()
  809. # No unread topics available - trying to mark the forum as read
  810. if unread_count == 0:
  811. logger.debug("No unread topics. Trying to mark the forum as read.")
  812. if forumsread and forumsread.last_read > topicsread.last_read:
  813. logger.debug("forumsread.last_read is newer than "
  814. "topicsread.last_read. Everything is read.")
  815. return False
  816. # ForumRead Entry exists - Updating it because a new topic/post
  817. # has been submitted and has read everything (obviously, else the
  818. # unread_count would be useless).
  819. elif forumsread:
  820. logger.debug("Updating existing ForumsRead '{}' object."
  821. .format(forumsread))
  822. forumsread.last_read = time_utcnow()
  823. forumsread.save()
  824. return True
  825. # No ForumRead Entry existing - creating one.
  826. logger.debug("Creating new ForumsRead object.")
  827. forumsread = ForumsRead()
  828. forumsread.user = user
  829. forumsread.forum = self
  830. forumsread.last_read = time_utcnow()
  831. forumsread.save()
  832. return True
  833. # Nothing updated, because there are still more than 0 unread
  834. # topicsread
  835. logger.debug("No ForumsRead object updated - there are still {} "
  836. "unread topics.".format(unread_count))
  837. return False
  838. def recalculate(self, last_post=False):
  839. """Recalculates the post_count and topic_count in the forum.
  840. Returns the forum with the recounted stats.
  841. :param last_post: If set to ``True`` it will also try to update
  842. the last post columns in the forum.
  843. """
  844. topic_count = Topic.query.filter(
  845. Topic.forum_id == self.id, Topic.hidden != True
  846. ).count()
  847. post_count = Post.query.filter(
  848. Post.topic_id == Topic.id,
  849. Topic.forum_id == self.id,
  850. Post.hidden != True,
  851. Topic.hidden != True
  852. ).count()
  853. self.topic_count = topic_count
  854. self.post_count = post_count
  855. if last_post:
  856. self.update_last_post()
  857. self.save()
  858. return self
  859. def save(self, groups=None):
  860. """Saves a forum
  861. :param moderators: If given, it will update the moderators in this
  862. forum with the given iterable of user objects.
  863. :param groups: A list with group objects.
  864. """
  865. if self.id:
  866. db.session.merge(self)
  867. else:
  868. if groups is None:
  869. # importing here because of circular dependencies
  870. from flaskbb.user.models import Group
  871. self.groups = Group.query.order_by(Group.name.asc()).all()
  872. db.session.add(self)
  873. db.session.commit()
  874. return self
  875. def delete(self, users=None):
  876. """Deletes forum. If a list with involved user objects is passed,
  877. it will also update their post counts
  878. :param users: A list with user objects
  879. """
  880. # Delete the forum
  881. db.session.delete(self)
  882. db.session.commit()
  883. # Update the users post count
  884. if users:
  885. users_list = []
  886. for user in users:
  887. user.post_count = Post.query.filter_by(user_id=user.id).count()
  888. users_list.append(user)
  889. db.session.add_all(users_list)
  890. db.session.commit()
  891. return self
  892. def move_topics_to(self, topics):
  893. """Moves a bunch a topics to the forum. Returns ``True`` if all
  894. topics were moved successfully to the forum.
  895. :param topics: A iterable with topic objects.
  896. """
  897. status = False
  898. for topic in topics:
  899. status = topic.move(self)
  900. return status
  901. # Classmethods
  902. @classmethod
  903. def get_forum(cls, forum_id, user):
  904. """Returns the forum and forumsread object as a tuple for the user.
  905. :param forum_id: The forum id
  906. :param user: The user object is needed to check if we also need their
  907. forumsread object.
  908. """
  909. if user.is_authenticated:
  910. forum, forumsread = Forum.query.\
  911. filter(Forum.id == forum_id).\
  912. options(db.joinedload("category")).\
  913. outerjoin(ForumsRead,
  914. db.and_(ForumsRead.forum_id == Forum.id,
  915. ForumsRead.user_id == user.id)).\
  916. add_entity(ForumsRead).\
  917. first_or_404()
  918. else:
  919. forum = Forum.query.filter(Forum.id == forum_id).first_or_404()
  920. forumsread = None
  921. return forum, forumsread
  922. @classmethod
  923. def get_topics(cls, forum_id, user, page=1, per_page=20):
  924. """Get the topics for the forum. If the user is logged in,
  925. it will perform an outerjoin for the topics with the topicsread and
  926. forumsread relation to check if it is read or unread.
  927. :param forum_id: The forum id
  928. :param user: The user object
  929. :param page: The page whom should be loaded
  930. :param per_page: How many topics per page should be shown
  931. """
  932. if user.is_authenticated:
  933. # Now thats intersting - if i don't do the add_entity(Post)
  934. # the n+1 still exists when trying to access 'topic.last_post'
  935. # but without it it will fire another query.
  936. # This way I don't have to use the last_post object when I
  937. # iterate over the result set.
  938. topics = Topic.query.filter_by(forum_id=forum_id).\
  939. outerjoin(TopicsRead,
  940. db.and_(TopicsRead.topic_id == Topic.id,
  941. TopicsRead.user_id == user.id)).\
  942. outerjoin(Post, Topic.last_post_id == Post.id).\
  943. add_entity(Post).\
  944. add_entity(TopicsRead).\
  945. order_by(Topic.important.desc(), Topic.last_updated.desc()).\
  946. paginate(page, per_page, True)
  947. else:
  948. topics = Topic.query.filter_by(forum_id=forum_id).\
  949. outerjoin(Post, Topic.last_post_id == Post.id).\
  950. add_entity(Post).\
  951. order_by(Topic.important.desc(), Topic.last_updated.desc()).\
  952. paginate(page, per_page, True)
  953. topics.items = [(topic, last_post, None)
  954. for topic, last_post, in topics.items]
  955. return topics
  956. @make_comparable
  957. class Category(db.Model, CRUDMixin):
  958. __tablename__ = "categories"
  959. id = db.Column(db.Integer, primary_key=True)
  960. title = db.Column(db.String(255), nullable=False)
  961. description = db.Column(db.Text, nullable=True)
  962. position = db.Column(db.Integer, default=1, nullable=False)
  963. # One-to-many
  964. forums = db.relationship("Forum", backref="category", lazy="dynamic",
  965. primaryjoin='Forum.category_id == Category.id',
  966. order_by='asc(Forum.position)',
  967. cascade="all, delete-orphan")
  968. # Properties
  969. @property
  970. def slug(self):
  971. """Returns a slugified version from the category title"""
  972. return slugify(self.title)
  973. @property
  974. def url(self):
  975. """Returns the slugified url for the category"""
  976. return url_for("forum.view_category", category_id=self.id,
  977. slug=self.slug)
  978. # Methods
  979. def __repr__(self):
  980. """Set to a unique key specific to the object in the database.
  981. Required for cache.memoize() to work across requests.
  982. """
  983. return "<{} {}>".format(self.__class__.__name__, self.id)
  984. def delete(self, users=None):
  985. """Deletes a category. If a list with involved user objects is passed,
  986. it will also update their post counts
  987. :param users: A list with user objects
  988. """
  989. # and finally delete the category itself
  990. db.session.delete(self)
  991. db.session.commit()
  992. # Update the users post count
  993. if users:
  994. for user in users:
  995. user.post_count = Post.query.filter_by(user_id=user.id).count()
  996. db.session.commit()
  997. return self
  998. # Classmethods
  999. @classmethod
  1000. def get_all(cls, user):
  1001. """Get all categories with all associated forums.
  1002. It returns a list with tuples. Those tuples are containing the category
  1003. and their associated forums (whose are stored in a list).
  1004. For example::
  1005. [(<Category 1>, [(<Forum 2>, <ForumsRead>), (<Forum 1>, None)]),
  1006. (<Category 2>, [(<Forum 3>, None), (<Forum 4>, None)])]
  1007. :param user: The user object is needed to check if we also need their
  1008. forumsread object.
  1009. """
  1010. # import Group model locally to avoid cicular imports
  1011. from flaskbb.user.models import Group
  1012. if user.is_authenticated:
  1013. # get list of user group ids
  1014. user_groups = [gr.id for gr in user.groups]
  1015. # filter forums by user groups
  1016. user_forums = Forum.query.\
  1017. filter(Forum.groups.any(Group.id.in_(user_groups))).\
  1018. subquery()
  1019. forum_alias = aliased(Forum, user_forums)
  1020. # get all
  1021. forums = cls.query.\
  1022. join(forum_alias, cls.id == forum_alias.category_id).\
  1023. outerjoin(ForumsRead,
  1024. db.and_(ForumsRead.forum_id == forum_alias.id,
  1025. ForumsRead.user_id == user.id)).\
  1026. add_entity(forum_alias).\
  1027. add_entity(ForumsRead).\
  1028. order_by(Category.position, Category.id,
  1029. forum_alias.position).\
  1030. all()
  1031. else:
  1032. guest_group = Group.get_guest_group()
  1033. # filter forums by guest groups
  1034. guest_forums = Forum.query.\
  1035. filter(Forum.groups.any(Group.id == guest_group.id)).\
  1036. subquery()
  1037. forum_alias = aliased(Forum, guest_forums)
  1038. forums = cls.query.\
  1039. join(forum_alias, cls.id == forum_alias.category_id).\
  1040. add_entity(forum_alias).\
  1041. order_by(Category.position, Category.id,
  1042. forum_alias.position).\
  1043. all()
  1044. return get_categories_and_forums(forums, user)
  1045. @classmethod
  1046. def get_forums(cls, category_id, user):
  1047. """Get the forums for the category.
  1048. It returns a tuple with the category and the forums with their
  1049. forumsread object are stored in a list.
  1050. A return value can look like this for a category with two forums::
  1051. (<Category 1>, [(<Forum 1>, None), (<Forum 2>, None)])
  1052. :param category_id: The category id
  1053. :param user: The user object is needed to check if we also need their
  1054. forumsread object.
  1055. """
  1056. from flaskbb.user.models import Group
  1057. if user.is_authenticated:
  1058. # get list of user group ids
  1059. user_groups = [gr.id for gr in user.groups]
  1060. # filter forums by user groups
  1061. user_forums = Forum.query.\
  1062. filter(Forum.groups.any(Group.id.in_(user_groups))).\
  1063. subquery()
  1064. forum_alias = aliased(Forum, user_forums)
  1065. forums = cls.query.\
  1066. filter(cls.id == category_id).\
  1067. join(forum_alias, cls.id == forum_alias.category_id).\
  1068. outerjoin(ForumsRead,
  1069. db.and_(ForumsRead.forum_id == forum_alias.id,
  1070. ForumsRead.user_id == user.id)).\
  1071. add_entity(forum_alias).\
  1072. add_entity(ForumsRead).\
  1073. order_by(forum_alias.position).\
  1074. all()
  1075. else:
  1076. guest_group = Group.get_guest_group()
  1077. # filter forums by guest groups
  1078. guest_forums = Forum.query.\
  1079. filter(Forum.groups.any(Group.id == guest_group.id)).\
  1080. subquery()
  1081. forum_alias = aliased(Forum, guest_forums)
  1082. forums = cls.query.\
  1083. filter(cls.id == category_id).\
  1084. join(forum_alias, cls.id == forum_alias.category_id).\
  1085. add_entity(forum_alias).\
  1086. order_by(forum_alias.position).\
  1087. all()
  1088. if not forums:
  1089. abort(404)
  1090. return get_forums(forums, user)