]>
Commit | Line | Data |
---|---|---|
0a6875dc MT |
1 | #!/usr/bin/python |
2 | ||
541c952b | 3 | import datetime |
c70a7c29 | 4 | import feedparser |
aee57270 | 5 | import html2text |
e9c6d581 | 6 | import markdown |
c70a7c29 | 7 | import re |
7e64f6a3 | 8 | import textile |
c70a7c29 | 9 | import unicodedata |
7e64f6a3 | 10 | |
0a6875dc | 11 | from . import misc |
245a2e36 | 12 | from . import wiki |
a3a850a4 | 13 | from .decorators import * |
0a6875dc MT |
14 | |
15 | class 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 | |
223 | class 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) |