]> git.ipfire.org Git - ipfire.org.git/blame - src/backend/blog.py
blog: Show unpublished posts
[ipfire.org.git] / src / backend / blog.py
CommitLineData
0a6875dc
MT
1#!/usr/bin/python
2
541c952b 3import datetime
c70a7c29 4import feedparser
aee57270 5import html2text
e9c6d581 6import markdown
023cdd01
MT
7import markdown.extensions
8import markdown.preprocessors
c70a7c29 9import re
7e64f6a3 10import textile
c70a7c29 11import unicodedata
7e64f6a3 12
0a6875dc 13from . import misc
a3a850a4 14from .decorators import *
0a6875dc
MT
15
16class Blog(misc.Object):
17 def _get_post(self, query, *args):
18 res = self.db.get(query, *args)
19
20 if res:
21 return Post(self.backend, res.id, data=res)
22
23 def _get_posts(self, query, *args):
24 res = self.db.query(query, *args)
25
26 for row in res:
27 yield Post(self.backend, row.id, data=row)
28
487417ad
MT
29 def get_by_id(self, id):
30 return self._get_post("SELECT * FROM blog \
31 WHERE id = %s", id)
32
a3ba761a 33 def get_by_slug(self, slug):
0a6875dc 34 return self._get_post("SELECT * FROM blog \
df157ede
MT
35 WHERE slug = %s", slug)
36
0a6875dc 37 def get_newest(self, limit=None):
0daae935 38 posts = self._get_posts("SELECT * FROM blog \
0a6875dc
MT
39 WHERE published_at IS NOT NULL \
40 AND published_at <= NOW() \
41 ORDER BY published_at DESC LIMIT %s", limit)
42
0daae935
RH
43 return list(posts)
44
0a6875dc
MT
45 def get_by_tag(self, tag, limit=None):
46 return self._get_posts("SELECT * FROM blog \
47 WHERE published_at IS NOT NULL \
48 AND published_at <= NOW() \
49 AND %s = ANY(tags) \
4bde7f18 50 ORDER BY published_at DESC LIMIT %s", tag, limit)
0a6875dc 51
cdf85ee7 52 def get_by_author(self, author, limit=None):
0a6875dc 53 return self._get_posts("SELECT * FROM blog \
cdf85ee7 54 WHERE (author = %s OR author_uid = %s) \
0a6875dc
MT
55 AND published_at IS NOT NULL \
56 AND published_at <= NOW() \
cdf85ee7
MT
57 ORDER BY published_at DESC LIMIT %s",
58 author.name, author.uid, limit)
0a6875dc 59
7e64f6a3
MT
60 def get_by_year(self, year):
61 return self._get_posts("SELECT * FROM blog \
62 WHERE EXTRACT(year FROM published_at) = %s \
63 AND published_at IS NOT NULL \
64 AND published_at <= NOW() \
65 ORDER BY published_at DESC", year)
66
0b342a05
MT
67 def get_drafts(self, author=None, limit=None):
68 if author:
69 return self._get_posts("SELECT * FROM blog \
70 WHERE author_uid = %s \
71 AND (published_at IS NULL OR published_at > NOW()) \
72 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s",
73 author.uid, limit)
74
75 return self._get_posts("SELECT * FROM blog \
76 WHERE (published_at IS NULL OR published_at > NOW()) \
77 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s", limit)
78
0a6875dc 79 def search(self, query, limit=None):
d8710471 80 posts = self._get_posts("SELECT blog.* FROM blog \
0a6875dc 81 LEFT JOIN blog_search_index search_index ON blog.id = search_index.post_id \
22e56c4a
MT
82 WHERE search_index.document @@ websearch_to_tsquery('english', %s) \
83 ORDER BY ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC \
0a6875dc
MT
84 LIMIT %s", query, query, limit)
85
d8710471
RH
86 return list(posts)
87
9268f3c3
MT
88 def has_had_recent_activity(self, **kwargs):
89 t = datetime.timedelta(**kwargs)
611adbfb
MT
90
91 res = self.db.get("SELECT COUNT(*) AS count FROM blog \
ae1da47f 92 WHERE published_at IS NOT NULL AND published_at BETWEEN NOW() - %s AND NOW()", t)
611adbfb
MT
93
94 if res and res.count > 0:
95 return True
96
97 return False
98
694c4f08 99 def create_post(self, title, text, author, tags=[], lang="markdown"):
541c952b
MT
100 """
101 Creates a new post and returns the resulting Post object
102 """
694c4f08
MT
103 # Pre-render HTML
104 html = self._render_text(text, lang=lang)
105
106 return self._get_post("INSERT INTO blog(title, slug, text, html, lang, author_uid, tags) \
107 VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", title, self._make_slug(title), text,
108 html, lang, author.uid, list(tags))
541c952b 109
c70a7c29
MT
110 def _make_slug(self, s):
111 # Remove any non-ASCII characters
112 try:
113 s = unicodedata.normalize("NFKD", s)
114 except TypeError:
115 pass
116
117 # Remove excessive whitespace
118 s = re.sub(r"[^\w]+", " ", s)
119
120 slug = "-".join(s.split()).lower()
121
122 while True:
123 e = self.db.get("SELECT 1 FROM blog WHERE slug = %s", slug)
124 if not e:
125 break
126
127 slug += "-"
128
129 return slug
130
2de5ad8a
MT
131 def _render_text(self, text, lang="markdown"):
132 if lang == "markdown":
023cdd01 133 return markdown.markdown(text,
e9c6d581 134 extensions=[
023cdd01 135 PrettyLinksExtension(),
e9c6d581
MT
136 "codehilite",
137 "fenced_code",
e0ef6d39 138 "footnotes",
e9c6d581
MT
139 "nl2br",
140 "sane_lists",
e0ef6d39 141 "tables",
e9c6d581 142 "toc",
e0ef6d39 143 ])
2de5ad8a
MT
144
145 elif lang == "textile":
146 return textile.textile(text)
147
148 return text
149
0a6875dc
MT
150 def refresh(self):
151 """
152 Needs to be called after a post has been changed
153 and updates the search index.
154 """
155 self.db.execute("REFRESH MATERIALIZED VIEW blog_search_index")
156
7e64f6a3
MT
157 @property
158 def years(self):
159 res = self.db.query("SELECT DISTINCT EXTRACT(year FROM published_at)::integer AS year \
160 FROM blog WHERE published_at IS NOT NULL AND published_at <= NOW() \
161 ORDER BY year DESC")
162
163 for row in res:
164 yield row.year
165
feb245e0
MT
166 @property
167 def authors(self):
168 res = self.db.query("""
169 SELECT
170 author_uid,
171 MAX(published_at) AS published_at
172 FROM
173 blog
174 WHERE
175 author_uid IS NOT NULL
176 AND
177 published_at IS NOT NULL
178 AND
179 published_at <= NOW()
180 GROUP BY
181 author_uid
182 ORDER BY
183 published_at DESC
184 """,
185 )
186
187 return [self.backend.accounts.get_by_uid(row.author_uid) for row in res]
188
aee57270
MT
189 async def announce(self):
190 posts = self._get_posts("SELECT * FROM blog \
191 WHERE (published_at IS NOT NULL AND published_at <= NOW()) \
192 AND announced_at IS NULL")
193
194 for post in posts:
195 await post.announce()
196
9fdf4fb7 197 async def update_feeds(self):
c70a7c29
MT
198 """
199 Updates all enabled feeds
200 """
201 for feed in self.db.query("SELECT * FROM blog_feeds WHERE enabled IS TRUE"):
202 try:
203 f = feedparser.parse(feed.url)
204 except Exception as e:
205 raise e
206
207 with self.db.transaction():
208 # Update name
209 self.db.execute("UPDATE blog_feeds SET name = %s \
210 WHERE id = %s", f.feed.title, feed.id)
211
212 # Walk through all entries
213 for entry in f.entries:
214 # Skip everything without the "blog.ipfire.org" tag
215 try:
216 tags = list((t.term for t in entry.tags))
217
218 if not "blog.ipfire.org" in tags:
219 continue
220 except AttributeError:
221 continue
222
223 # Get link to the posting site
224 link = entry.links[0].href
225
226 # Check if the entry has already been imported
227 res = self.db.get("SELECT id, (updated_at < %s) AS needs_update \
228 FROM blog WHERE feed_id = %s AND foreign_id = %s",
229 entry.updated, feed.id, entry.id)
230 if res:
231 # If the post needs to be updated, we do so
232 if res.needs_update:
233 self.db.execute("UPDATE blog SET title = %s, author = %s, \
234 published_at = %s, updated_at = %s, html = %s, link = %s, \
235 tags = %s WHERE id = %s", entry.title, entry.author,
236 entry.published, entry.updated, entry.summary, link,
237 feed.tags + tags, res.id)
238
239 # Done here
240 continue
241
242 # Insert the new post
243 self.db.execute("INSERT INTO blog(title, slug, author, \
244 published_at, html, link, tags, updated_at, feed_id, foreign_id) \
245 VALUES(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
246 entry.title, self._make_slug(entry.title), entry.author,
247 entry.published, entry.summary, link, feed.tags + tags,
248 entry.updated, feed.id, entry.id)
249
20277bf5
MT
250 # Mark feed as updated
251 self.db.execute("UPDATE blog_feeds SET last_updated_at = CURRENT_TIMESTAMP \
252 WHERE id = %s" % feed.id)
253
c70a7c29
MT
254 # Refresh the search index
255 with self.db.transaction():
256 self.refresh()
257
0a6875dc
MT
258
259class Post(misc.Object):
260 def init(self, id, data=None):
261 self.id = id
262 self.data = data
263
541c952b
MT
264 # Title
265
93725180
MT
266 @property
267 def title(self):
0a6875dc
MT
268 return self.data.title
269
270 @property
271 def slug(self):
272 return self.data.slug
273
a3a850a4 274 @lazy_property
0a6875dc
MT
275 def author(self):
276 if self.data.author_uid:
277 return self.backend.accounts.get_by_uid(self.data.author_uid)
278
cdf85ee7
MT
279 return self.data.author
280
0a6875dc
MT
281 @property
282 def created_at(self):
283 return self.data.created_at
284
2de5ad8a
MT
285 @property
286 def lang(self):
287 return self.data.lang
288
541c952b
MT
289 # Published?
290
0a6875dc
MT
291 @property
292 def published_at(self):
293 return self.data.published_at
294
541c952b
MT
295 def is_published(self):
296 """
297 Returns True if the post is already published
298 """
299 return self.published_at and self.published_at <= datetime.datetime.now()
300
9ea64cef 301 def publish(self, when=None):
541c952b
MT
302 if self.is_published():
303 return
304
9ea64cef
MT
305 self.db.execute("UPDATE blog SET published_at = COALESCE(%s, CURRENT_TIMESTAMP) \
306 WHERE id = %s", when, self.id)
541c952b
MT
307
308 # Update search indices
309 self.backend.blog.refresh()
310
311 # Updated?
312
7e64f6a3 313 @property
541c952b
MT
314 def updated_at(self):
315 return self.data.updated_at
316
541c952b
MT
317 # Text
318
93725180
MT
319 @property
320 def text(self):
541c952b
MT
321 return self.data.text
322
541c952b 323 # HTML
7e64f6a3 324
a3a850a4 325 @lazy_property
0a6875dc
MT
326 def html(self):
327 """
328 Returns this post as rendered HTML
329 """
2de5ad8a 330 return self.data.html or self.backend.blog._render_text(self.text, lang=self.lang)
8ebc98d4 331
aee57270
MT
332 @lazy_property
333 def plaintext(self):
334 h = html2text.HTML2Text()
335 h.ignore_links = True
336
337 return h.handle(self.html)
338
339 # Excerpt
340
341 @property
342 def excerpt(self):
343 paragraphs = self.plaintext.split("\n\n")
344
345 excerpt = []
346
347 for paragraph in paragraphs:
348 excerpt.append(paragraph)
349
350 # Add another paragraph if we encountered a headline
351 if paragraph.startswith("#"):
352 continue
353
354 # End if this paragraph was long enough
355 if len(paragraph) >= 40:
356 break
357
358 return "\n\n".join(excerpt)
359
541c952b
MT
360 # Tags
361
93725180
MT
362 @property
363 def tags(self):
8ebc98d4 364 return self.data.tags
1e76fec4 365
93725180 366 # Link
541c952b 367
1e76fec4
MT
368 @property
369 def link(self):
370 return self.data.link
984e4e7b 371
a3a850a4 372 @lazy_property
984e4e7b
MT
373 def release(self):
374 return self.backend.releases._get_release("SELECT * FROM releases \
375 WHERE published IS NOT NULL AND published <= NOW() AND blog_id = %s", self.id)
e8a81a70 376
4d657f4f
MT
377 def is_editable(self, user):
378 # Anonymous users cannot do anything
379 if not user:
380 return False
381
382 # Admins can edit anything
383 if user.is_admin():
384 return True
385
386 # User must have permission for the blog
387 if not user.is_blog_author():
388 return False
389
e8a81a70 390 # Authors can edit their own posts
4d657f4f 391 return self.author == user
93725180
MT
392
393 def update(self, title, text, tags=[]):
394 """
395 Called to update the content of this post
396 """
397 # Update slug when post isn't published yet
baa294fb
MT
398 slug = self.backend.blog._make_slug(title) \
399 if not self.is_published() and not self.title == title else self.slug
93725180 400
694c4f08
MT
401 # Render and cache HTML
402 html = self.backend.blog._render_text(text, lang=self.lang)
93725180 403
694c4f08 404 self.db.execute("UPDATE blog SET title = %s, slug = %s, text = %s, html = %s, \
93725180 405 tags = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
694c4f08 406 title, slug, text, html, list(tags), self.id)
93725180
MT
407
408 # Update cache
409 self.data.update({
410 "title" : title,
411 "slug" : slug,
412 "text" : text,
d73edae7 413 "html" : html,
93725180
MT
414 "tags" : tags,
415 })
416
417 # Update search index if post is published
418 if self.is_published():
419 self.backend.blog.refresh()
914238a5
MT
420
421 def delete(self):
422 self.db.execute("DELETE FROM blog WHERE id = %s", self.id)
423
424 # Update search indices
425 self.backend.blog.refresh()
023cdd01 426
aee57270
MT
427 async def announce(self):
428 # Get people who should receive this message
429 group = self.backend.groups.get_by_gid("promotional-consent")
430 if not group:
431 return
432
433 with self.db.transaction():
434 # Generate an email for everybody in this group
435 for account in group:
436 self.backend.messages.send_template("blog/messages/announcement",
437 account=account, post=self)
438
439 # Mark this post as announced
440 self.db.execute("UPDATE blog SET announced_at = CURRENT_TIMESTAMP \
441 WHERE id = %s", self.id)
442
023cdd01
MT
443
444class PrettyLinksExtension(markdown.extensions.Extension):
445 def extendMarkdown(self, md):
446 md.preprocessors.register(BugzillaLinksPreprocessor(md), "bugzilla", 10)
447 md.preprocessors.register(CVELinksPreprocessor(md), "cve", 10)
448
449
450class BugzillaLinksPreprocessor(markdown.preprocessors.Preprocessor):
a77a9c0a 451 regex = re.compile(r"(?:#(\d{5,}))", re.I)
023cdd01
MT
452
453 def run(self, lines):
454 for line in lines:
455 yield self.regex.sub(r"[#\1](https://bugzilla.ipfire.org/show_bug.cgi?id=\1)", line)
456
457
458class CVELinksPreprocessor(markdown.preprocessors.Preprocessor):
459 regex = re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)")
460
461 def run(self, lines):
462 for line in lines:
463 yield self.regex.sub(r"[CVE-\1](https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1)", line)