models.py 45 KB

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