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