]> git.ipfire.org Git - ipfire.org.git/blob - src/backend/blog.py
de2b8b69f55251a4a3f815ca4b30779c0ca05730
[ipfire.org.git] / src / backend / blog.py
1 #!/usr/bin/python
2
3 import datetime
4 import feedparser
5 import markdown2
6 import re
7 import textile
8 import tornado.gen
9 import unicodedata
10
11 from . import misc
12 from . import util
13 from .decorators import *
14
15 # Used to automatically link some things
16 link_patterns = (
17 # Find bug reports
18 (re.compile(r"(?:#(\d+))", re.I), r"https://bugzilla.ipfire.org/show_bug.cgi?id=\1"),
19
20 # Email Addresses
21 (re.compile(r"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)"), r"mailto:\1"),
22
23 # CVE Numbers
24 (re.compile(r"(?:CVE)[\s\-](\d{4}\-\d+)"), r"https://cve.mitre.org/cgi-bin/cvename.cgi?name=\1"),
25 )
26
27 class Blog(misc.Object):
28 def _get_post(self, query, *args):
29 res = self.db.get(query, *args)
30
31 if res:
32 return Post(self.backend, res.id, data=res)
33
34 def _get_posts(self, query, *args):
35 res = self.db.query(query, *args)
36
37 for row in res:
38 yield Post(self.backend, row.id, data=row)
39
40 def get_by_id(self, id):
41 return self._get_post("SELECT * FROM blog \
42 WHERE id = %s", id)
43
44 def get_by_slug(self, slug, published=True):
45 if published:
46 return self._get_post("SELECT * FROM blog \
47 WHERE slug = %s AND published_at <= NOW()", slug)
48
49 return self._get_post("SELECT * FROM blog \
50 WHERE slug = %s", slug)
51
52 def get_newest(self, limit=None):
53 return self._get_posts("SELECT * FROM blog \
54 WHERE published_at IS NOT NULL \
55 AND published_at <= NOW() \
56 ORDER BY published_at DESC LIMIT %s", limit)
57
58 def get_by_tag(self, tag, limit=None):
59 return self._get_posts("SELECT * FROM blog \
60 WHERE published_at IS NOT NULL \
61 AND published_at <= NOW() \
62 AND %s = ANY(tags) \
63 ORDER BY published_at DESC LIMIT %s", tag, limit)
64
65 def get_by_author(self, author, limit=None):
66 return self._get_posts("SELECT * FROM blog \
67 WHERE (author = %s OR author_uid = %s) \
68 AND published_at IS NOT NULL \
69 AND published_at <= NOW() \
70 ORDER BY published_at DESC LIMIT %s",
71 author.name, author.uid, limit)
72
73 def get_by_year(self, year):
74 return self._get_posts("SELECT * FROM blog \
75 WHERE EXTRACT(year FROM published_at) = %s \
76 AND published_at IS NOT NULL \
77 AND published_at <= NOW() \
78 ORDER BY published_at DESC", year)
79
80 def get_drafts(self, author=None, limit=None):
81 if author:
82 return self._get_posts("SELECT * FROM blog \
83 WHERE author_uid = %s \
84 AND (published_at IS NULL OR published_at > NOW()) \
85 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s",
86 author.uid, limit)
87
88 return self._get_posts("SELECT * FROM blog \
89 WHERE (published_at IS NULL OR published_at > NOW()) \
90 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s", limit)
91
92 def search(self, query, limit=None):
93 query = util.parse_search_query(query)
94
95 return self._get_posts("SELECT blog.* FROM blog \
96 LEFT JOIN blog_search_index search_index ON blog.id = search_index.post_id \
97 WHERE search_index.document @@ to_tsquery('english', %s) \
98 ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC \
99 LIMIT %s", query, query, limit)
100
101 def create_post(self, title, text, author, tags=[], lang="markdown"):
102 """
103 Creates a new post and returns the resulting Post object
104 """
105 # Pre-render HTML
106 html = self._render_text(text, lang=lang)
107
108 return self._get_post("INSERT INTO blog(title, slug, text, html, lang, author_uid, tags) \
109 VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", title, self._make_slug(title), text,
110 html, lang, author.uid, list(tags))
111
112 def _make_slug(self, s):
113 # Remove any non-ASCII characters
114 try:
115 s = unicodedata.normalize("NFKD", s)
116 except TypeError:
117 pass
118
119 # Remove excessive whitespace
120 s = re.sub(r"[^\w]+", " ", s)
121
122 slug = "-".join(s.split()).lower()
123
124 while True:
125 e = self.db.get("SELECT 1 FROM blog WHERE slug = %s", slug)
126 if not e:
127 break
128
129 slug += "-"
130
131 return slug
132
133 def _render_text(self, text, lang="markdown"):
134 if lang == "markdown":
135 return markdown2.markdown(text, link_patterns=link_patterns,
136 extras=[
137 "code-friendly",
138 "cuddled-lists",
139 "fenced-code-blocks",
140 "footnotes",
141 "header-ids",
142 "link-patterns",
143 "tables",
144 ])
145
146 elif lang == "textile":
147 return textile.textile(text)
148
149 return text
150
151 def refresh(self):
152 """
153 Needs to be called after a post has been changed
154 and updates the search index.
155 """
156 self.db.execute("REFRESH MATERIALIZED VIEW blog_search_index")
157
158 @property
159 def years(self):
160 res = self.db.query("SELECT DISTINCT EXTRACT(year FROM published_at)::integer AS year \
161 FROM blog WHERE published_at IS NOT NULL AND published_at <= NOW() \
162 ORDER BY year DESC")
163
164 for row in res:
165 yield row.year
166
167 @tornado.gen.coroutine
168 def update_feeds(self):
169 """
170 Updates all enabled feeds
171 """
172 for feed in self.db.query("SELECT * FROM blog_feeds WHERE enabled IS TRUE"):
173 try:
174 f = feedparser.parse(feed.url)
175 except Exception as e:
176 raise e
177
178 with self.db.transaction():
179 # Update name
180 self.db.execute("UPDATE blog_feeds SET name = %s \
181 WHERE id = %s", f.feed.title, feed.id)
182
183 # Walk through all entries
184 for entry in f.entries:
185 # Skip everything without the "blog.ipfire.org" tag
186 try:
187 tags = list((t.term for t in entry.tags))
188
189 if not "blog.ipfire.org" in tags:
190 continue
191 except AttributeError:
192 continue
193
194 # Get link to the posting site
195 link = entry.links[0].href
196
197 # Check if the entry has already been imported
198 res = self.db.get("SELECT id, (updated_at < %s) AS needs_update \
199 FROM blog WHERE feed_id = %s AND foreign_id = %s",
200 entry.updated, feed.id, entry.id)
201 if res:
202 # If the post needs to be updated, we do so
203 if res.needs_update:
204 self.db.execute("UPDATE blog SET title = %s, author = %s, \
205 published_at = %s, updated_at = %s, html = %s, link = %s, \
206 tags = %s WHERE id = %s", entry.title, entry.author,
207 entry.published, entry.updated, entry.summary, link,
208 feed.tags + tags, res.id)
209
210 # Done here
211 continue
212
213 # Insert the new post
214 self.db.execute("INSERT INTO blog(title, slug, author, \
215 published_at, html, link, tags, updated_at, feed_id, foreign_id) \
216 VALUES(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
217 entry.title, self._make_slug(entry.title), entry.author,
218 entry.published, entry.summary, link, feed.tags + tags,
219 entry.updated, feed.id, entry.id)
220
221 # Mark feed as updated
222 self.db.execute("UPDATE blog_feeds SET last_updated_at = CURRENT_TIMESTAMP \
223 WHERE id = %s" % feed.id)
224
225 # Refresh the search index
226 with self.db.transaction():
227 self.refresh()
228
229
230 class Post(misc.Object):
231 def init(self, id, data=None):
232 self.id = id
233 self.data = data
234
235 # Title
236
237 @property
238 def title(self):
239 return self.data.title
240
241 @property
242 def slug(self):
243 return self.data.slug
244
245 @lazy_property
246 def author(self):
247 if self.data.author_uid:
248 return self.backend.accounts.get_by_uid(self.data.author_uid)
249
250 return self.data.author
251
252 @property
253 def created_at(self):
254 return self.data.created_at
255
256 @property
257 def lang(self):
258 return self.data.lang
259
260 # Published?
261
262 @property
263 def published_at(self):
264 return self.data.published_at
265
266 def is_published(self):
267 """
268 Returns True if the post is already published
269 """
270 return self.published_at and self.published_at <= datetime.datetime.now()
271
272 def publish(self, when=None):
273 if self.is_published():
274 return
275
276 self.db.execute("UPDATE blog SET published_at = COALESCE(%s, CURRENT_TIMESTAMP) \
277 WHERE id = %s", when, self.id)
278
279 # Update search indices
280 self.backend.blog.refresh()
281
282 # Updated?
283
284 @property
285 def updated_at(self):
286 return self.data.updated_at
287
288 # Text
289
290 @property
291 def text(self):
292 return self.data.text
293
294 # HTML
295
296 @lazy_property
297 def html(self):
298 """
299 Returns this post as rendered HTML
300 """
301 return self.data.html or self.backend.blog._render_text(self.text, lang=self.lang)
302
303 # Tags
304
305 @property
306 def tags(self):
307 return self.data.tags
308
309 # Link
310
311 @property
312 def link(self):
313 return self.data.link
314
315 @lazy_property
316 def release(self):
317 return self.backend.releases._get_release("SELECT * FROM releases \
318 WHERE published IS NOT NULL AND published <= NOW() AND blog_id = %s", self.id)
319
320 def is_editable(self, editor):
321 # Authors can edit their own posts
322 return self.author == editor
323
324 def update(self, title, text, tags=[]):
325 """
326 Called to update the content of this post
327 """
328 # Update slug when post isn't published yet
329 slug = self.backend.blog._make_slug(title) \
330 if not self.is_published() and not self.title == title else self.slug
331
332 # Render and cache HTML
333 html = self.backend.blog._render_text(text, lang=self.lang)
334
335 self.db.execute("UPDATE blog SET title = %s, slug = %s, text = %s, html = %s, \
336 tags = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
337 title, slug, text, html, list(tags), self.id)
338
339 # Update cache
340 self.data.update({
341 "title" : title,
342 "slug" : slug,
343 "text" : text,
344 "html" : html,
345 "tags" : tags,
346 })
347
348 # Update search index if post is published
349 if self.is_published():
350 self.backend.blog.refresh()
351
352 def delete(self):
353 self.db.execute("DELETE FROM blog WHERE id = %s", self.id)
354
355 # Update search indices
356 self.backend.blog.refresh()