models.py 45 KB

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