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