helpers.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. # -*- coding: utf-8 -*-
  2. """
  3. flaskbb.utils.helpers
  4. ~~~~~~~~~~~~~~~~~~~~~
  5. A few helpers that are used by flaskbb
  6. :copyright: (c) 2014 by the FlaskBB Team.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. import re
  10. import time
  11. import itertools
  12. import operator
  13. from datetime import datetime, timedelta
  14. from pytz import UTC
  15. from PIL import ImageFile
  16. import requests
  17. import unidecode
  18. from flask import session, url_for, flash, redirect, request
  19. from jinja2 import Markup
  20. from babel.dates import format_timedelta as babel_format_timedelta
  21. from flask_babelplus import lazy_gettext as _
  22. from flask_themes2 import render_theme_template
  23. from flask_login import current_user
  24. from flaskbb._compat import range_method, text_type
  25. from flaskbb.extensions import redis_store
  26. from flaskbb.utils.settings import flaskbb_config
  27. from flaskbb.utils.markup import markdown
  28. from flask_allows import Permission
  29. _punct_re = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+')
  30. def slugify(text, delim=u'-'):
  31. """Generates an slightly worse ASCII-only slug.
  32. Taken from the Flask Snippets page.
  33. :param text: The text which should be slugified
  34. :param delim: Default "-". The delimeter for whitespace
  35. """
  36. text = unidecode.unidecode(text)
  37. result = []
  38. for word in _punct_re.split(text.lower()):
  39. if word:
  40. result.append(word)
  41. return text_type(delim.join(result))
  42. def redirect_or_next(endpoint, **kwargs):
  43. """Redirects the user back to the page they were viewing or to a specified
  44. endpoint. Wraps Flasks :func:`Flask.redirect` function.
  45. :param endpoint: The fallback endpoint.
  46. """
  47. return redirect(
  48. request.args.get('next') or endpoint, **kwargs
  49. )
  50. def render_template(template, **context): # pragma: no cover
  51. """A helper function that uses the `render_theme_template` function
  52. without needing to edit all the views
  53. """
  54. if current_user.is_authenticated and current_user.theme:
  55. theme = current_user.theme
  56. else:
  57. theme = session.get('theme', flaskbb_config['DEFAULT_THEME'])
  58. return render_theme_template(theme, template, **context)
  59. def do_topic_action(topics, user, action, reverse):
  60. """Executes a specific action for topics. Returns a list with the modified
  61. topic objects.
  62. :param topics: A iterable with ``Topic`` objects.
  63. :param user: The user object which wants to perform the action.
  64. :param action: One of the following actions: locked, important and delete.
  65. :param reverse: If the action should be done in a reversed way.
  66. For example, to unlock a topic, ``reverse`` should be
  67. set to ``True``.
  68. """
  69. from flaskbb.utils.requirements import (IsAtleastModeratorInForum,
  70. CanDeleteTopic)
  71. from flaskbb.user.models import User
  72. from flaskbb.forum.models import Post
  73. if not Permission(IsAtleastModeratorInForum(forum=topics[0].forum)):
  74. flash(_("You do not have the permissions to execute this "
  75. "action."), "danger")
  76. return False
  77. modified_topics = 0
  78. if action != "delete":
  79. for topic in topics:
  80. if getattr(topic, action) and not reverse:
  81. continue
  82. setattr(topic, action, not reverse)
  83. modified_topics += 1
  84. topic.save()
  85. elif action == "delete":
  86. for topic in topics:
  87. if not Permission(CanDeleteTopic):
  88. flash(_("You do not have the permissions to delete this "
  89. "topic."), "danger")
  90. return False
  91. involved_users = User.query.filter(Post.topic_id == topic.id,
  92. User.id == Post.user_id).all()
  93. modified_topics += 1
  94. topic.delete(involved_users)
  95. return modified_topics
  96. def get_categories_and_forums(query_result, user):
  97. """Returns a list with categories. Every category has a list for all
  98. their associated forums.
  99. The structure looks like this::
  100. [(<Category 1>,
  101. [(<Forum 1>, None),
  102. (<Forum 2>, <flaskbb.forum.models.ForumsRead at 0x38fdb50>)]),
  103. (<Category 2>,
  104. [(<Forum 3>, None),
  105. (<Forum 4>, None)])]
  106. and to unpack the values you can do this::
  107. In [110]: for category, forums in x:
  108. .....: print category
  109. .....: for forum, forumsread in forums:
  110. .....: print "\t", forum, forumsread
  111. This will print something like this:
  112. <Category 1>
  113. <Forum 1> None
  114. <Forum 2> <flaskbb.forum.models.ForumsRead object at 0x38fdb50>
  115. <Category 2>
  116. <Forum 3> None
  117. <Forum 4> None
  118. :param query_result: A tuple (KeyedTuple) with all categories and forums
  119. :param user: The user object is needed because a signed out user does not
  120. have the ForumsRead relation joined.
  121. """
  122. it = itertools.groupby(query_result, operator.itemgetter(0))
  123. forums = []
  124. if user.is_authenticated:
  125. for key, value in it:
  126. forums.append((key, [(item[1], item[2]) for item in value]))
  127. else:
  128. for key, value in it:
  129. forums.append((key, [(item[1], None) for item in value]))
  130. return forums
  131. def get_forums(query_result, user):
  132. """Returns a tuple which contains the category and the forums as list.
  133. This is the counterpart for get_categories_and_forums and especially
  134. usefull when you just need the forums for one category.
  135. For example::
  136. (<Category 2>,
  137. [(<Forum 3>, None),
  138. (<Forum 4>, None)])
  139. :param query_result: A tuple (KeyedTuple) with all categories and forums
  140. :param user: The user object is needed because a signed out user does not
  141. have the ForumsRead relation joined.
  142. """
  143. it = itertools.groupby(query_result, operator.itemgetter(0))
  144. if user.is_authenticated:
  145. for key, value in it:
  146. forums = key, [(item[1], item[2]) for item in value]
  147. else:
  148. for key, value in it:
  149. forums = key, [(item[1], None) for item in value]
  150. return forums
  151. def forum_is_unread(forum, forumsread, user):
  152. """Checks if a forum is unread
  153. :param forum: The forum that should be checked if it is unread
  154. :param forumsread: The forumsread object for the forum
  155. :param user: The user who should be checked if he has read the forum
  156. """
  157. # If the user is not signed in, every forum is marked as read
  158. if not user.is_authenticated:
  159. return False
  160. read_cutoff = time_utcnow() - timedelta(
  161. days=flaskbb_config["TRACKER_LENGTH"])
  162. # disable tracker if TRACKER_LENGTH is set to 0
  163. if flaskbb_config["TRACKER_LENGTH"] == 0:
  164. return False
  165. # If there are no topics in the forum, mark it as read
  166. if forum and forum.topic_count == 0:
  167. return False
  168. # check if the last post is newer than the tracker length
  169. if forum.last_post_created < read_cutoff:
  170. return False
  171. # If the user hasn't visited a topic in the forum - therefore,
  172. # forumsread is None and we need to check if it is still unread
  173. if forum and not forumsread:
  174. return forum.last_post_created > read_cutoff
  175. try:
  176. # check if the forum has been cleared and if there is a new post
  177. # since it have been cleared
  178. if forum.last_post_created > forumsread.cleared:
  179. if forum.last_post_created < forumsread.last_read:
  180. return False
  181. except TypeError:
  182. pass
  183. # else just check if the user has read the last post
  184. return forum.last_post_created > forumsread.last_read
  185. def topic_is_unread(topic, topicsread, user, forumsread=None):
  186. """Checks if a topic is unread.
  187. :param topic: The topic that should be checked if it is unread
  188. :param topicsread: The topicsread object for the topic
  189. :param user: The user who should be checked if he has read the last post
  190. in the topic
  191. :param forumsread: The forumsread object in which the topic is. If you
  192. also want to check if the user has marked the forum as
  193. read, than you will also need to pass an forumsread
  194. object.
  195. """
  196. if not user.is_authenticated:
  197. return False
  198. read_cutoff = time_utcnow() - timedelta(
  199. days=flaskbb_config["TRACKER_LENGTH"])
  200. # disable tracker if read_cutoff is set to 0
  201. if flaskbb_config["TRACKER_LENGTH"] == 0:
  202. return False
  203. # check read_cutoff
  204. if topic.last_post.date_created < read_cutoff:
  205. return False
  206. # topicsread is none if the user has marked the forum as read
  207. # or if he hasn't visited the topic yet
  208. if topicsread is None:
  209. # user has cleared the forum - check if there is a new post
  210. if forumsread and forumsread.cleared is not None:
  211. return forumsread.cleared < topic.last_post.date_created
  212. # user hasn't read the topic yet, or there is a new post since the user
  213. # has marked the forum as read
  214. return True
  215. # check if there is a new post since the user's last topic visit
  216. return topicsread.last_read < topic.last_post.date_created
  217. def mark_online(user_id, guest=False): # pragma: no cover
  218. """Marks a user as online
  219. :param user_id: The id from the user who should be marked as online
  220. :param guest: If set to True, it will add the user to the guest activity
  221. instead of the user activity.
  222. Ref: http://flask.pocoo.org/snippets/71/
  223. """
  224. now = int(time.time())
  225. expires = now + (flaskbb_config['ONLINE_LAST_MINUTES'] * 60) + 10
  226. if guest:
  227. all_users_key = 'online-guests/%d' % (now // 60)
  228. user_key = 'guest-activity/%s' % user_id
  229. else:
  230. all_users_key = 'online-users/%d' % (now // 60)
  231. user_key = 'user-activity/%s' % user_id
  232. p = redis_store.pipeline()
  233. p.sadd(all_users_key, user_id)
  234. p.set(user_key, now)
  235. p.expireat(all_users_key, expires)
  236. p.expireat(user_key, expires)
  237. p.execute()
  238. def get_online_users(guest=False): # pragma: no cover
  239. """Returns all online users within a specified time range
  240. :param guest: If True, it will return the online guests
  241. """
  242. current = int(time.time()) // 60
  243. minutes = range_method(flaskbb_config['ONLINE_LAST_MINUTES'])
  244. if guest:
  245. return redis_store.sunion(['online-guests/%d' % (current - x)
  246. for x in minutes])
  247. return redis_store.sunion(['online-users/%d' % (current - x)
  248. for x in minutes])
  249. def crop_title(title, length=None, suffix="..."):
  250. """Crops the title to a specified length
  251. :param title: The title that should be cropped
  252. :param suffix: The suffix which should be appended at the
  253. end of the title.
  254. """
  255. length = flaskbb_config['TITLE_LENGTH'] if length is None else length
  256. if len(title) <= length:
  257. return title
  258. return title[:length].rsplit(' ', 1)[0] + suffix
  259. def render_markup(text):
  260. """Renders the given text as markdown
  261. :param text: The text that should be rendered as markdown
  262. """
  263. return Markup(markdown.render(text))
  264. def is_online(user):
  265. """A simple check to see if the user was online within a specified
  266. time range
  267. :param user: The user who needs to be checked
  268. """
  269. return user.lastseen >= time_diff()
  270. def time_utcnow():
  271. """Returns a timezone aware utc timestamp."""
  272. return datetime.now(UTC)
  273. def time_diff():
  274. """Calculates the time difference between now and the ONLINE_LAST_MINUTES
  275. variable from the configuration.
  276. """
  277. now = time_utcnow()
  278. diff = now - timedelta(minutes=flaskbb_config['ONLINE_LAST_MINUTES'])
  279. return diff
  280. def format_date(value, format='%Y-%m-%d'):
  281. """Returns a formatted time string
  282. :param value: The datetime object that should be formatted
  283. :param format: How the result should look like. A full list of available
  284. directives is here: http://goo.gl/gNxMHE
  285. """
  286. return value.strftime(format)
  287. def format_timedelta(delta, **kwargs):
  288. """Wrapper around babel's format_timedelta to make it user language
  289. aware.
  290. """
  291. locale = flaskbb_config.get("DEFAULT_LANGUAGE", "en")
  292. if current_user.is_authenticated and current_user.language is not None:
  293. locale = current_user.language
  294. return babel_format_timedelta(delta, locale=locale, **kwargs)
  295. def time_since(time): # pragma: no cover
  296. """Returns a string representing time since e.g.
  297. 3 days ago, 5 hours ago.
  298. :param time: A datetime object
  299. """
  300. delta = time - time_utcnow()
  301. return format_timedelta(delta, add_direction=True)
  302. def format_quote(username, content):
  303. """Returns a formatted quote depending on the markup language.
  304. :param username: The username of a user.
  305. :param content: The content of the quote
  306. """
  307. profile_url = url_for('user.profile', username=username)
  308. content = "\n> ".join(content.strip().split('\n'))
  309. quote = u"**[{username}]({profile_url}) wrote:**\n> {content}\n".\
  310. format(username=username, profile_url=profile_url, content=content)
  311. return quote
  312. def get_image_info(url):
  313. """Returns the content-type, image size (kb), height and width of
  314. an image without fully downloading it.
  315. :param url: The URL of the image.
  316. """
  317. r = requests.get(url, stream=True)
  318. image_size = r.headers.get("content-length")
  319. image_size = float(image_size) / 1000 # in kilobyte
  320. image_max_size = 10000
  321. image_data = {
  322. "content_type": "",
  323. "size": image_size,
  324. "width": 0,
  325. "height": 0
  326. }
  327. # lets set a hard limit of 10MB
  328. if image_size > image_max_size:
  329. return image_data
  330. data = None
  331. parser = ImageFile.Parser()
  332. while True:
  333. data = r.raw.read(1024)
  334. if not data:
  335. break
  336. parser.feed(data)
  337. if parser.image:
  338. image_data["content_type"] = parser.image.format
  339. image_data["width"] = parser.image.size[0]
  340. image_data["height"] = parser.image.size[1]
  341. break
  342. return image_data
  343. def check_image(url):
  344. """A little wrapper for the :func:`get_image_info` function.
  345. If the image doesn't match the ``flaskbb_config`` settings it will
  346. return a tuple with a the first value is the custom error message and
  347. the second value ``False`` for not passing the check.
  348. If the check is successful, it will return ``None`` for the error message
  349. and ``True`` for the passed check.
  350. :param url: The image url to be checked.
  351. """
  352. img_info = get_image_info(url)
  353. error = None
  354. if img_info["size"] > flaskbb_config["AVATAR_SIZE"]:
  355. error = "Image is too big! {}kb are allowed.".format(
  356. flaskbb_config["AVATAR_SIZE"]
  357. )
  358. return error, False
  359. if not img_info["content_type"] in flaskbb_config["AVATAR_TYPES"]:
  360. error = "Image type {} is not allowed. Allowed types are: {}".format(
  361. img_info["content_type"],
  362. ", ".join(flaskbb_config["AVATAR_TYPES"])
  363. )
  364. return error, False
  365. if img_info["width"] > flaskbb_config["AVATAR_WIDTH"]:
  366. error = "Image is too wide! {}px width is allowed.".format(
  367. flaskbb_config["AVATAR_WIDTH"]
  368. )
  369. return error, False
  370. if img_info["height"] > flaskbb_config["AVATAR_HEIGHT"]:
  371. error = "Image is too high! {}px height is allowed.".format(
  372. flaskbb_config["AVATAR_HEIGHT"]
  373. )
  374. return error, False
  375. return error, True