models.py 39 KB

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