]> git.ipfire.org Git - ipfire.org.git/blobdiff - src/backend/blog.py
docs: Implement our own Markdown renderer based on the blog
[ipfire.org.git] / src / backend / blog.py
index 3b02acf18e6fb641b22d6bcbd6c9d657d31642f5..aed8311b1c40edfc87108180759e5d8733c998a0 100644 (file)
@@ -1,6 +1,16 @@
 #!/usr/bin/python
 
+import datetime
+import feedparser
+import html2text
+import markdown
+import re
+import textile
+import unicodedata
+
 from . import misc
+from . import wiki
+from .decorators import *
 
 class Blog(misc.Object):
        def _get_post(self, query, *args):
@@ -21,14 +31,16 @@ class Blog(misc.Object):
 
        def get_by_slug(self, slug):
                return self._get_post("SELECT * FROM blog \
-                       WHERE slug = %s AND published_at <= NOW()", slug)
+                       WHERE slug = %s", slug)
 
        def get_newest(self, limit=None):
-               return self._get_posts("SELECT * FROM blog \
+               posts = self._get_posts("SELECT * FROM blog \
                        WHERE published_at IS NOT NULL \
                                AND published_at <= NOW() \
                        ORDER BY published_at DESC LIMIT %s", limit)
 
+               return list(posts)
+
        def get_by_tag(self, tag, limit=None):
                return self._get_posts("SELECT * FROM blog \
                        WHERE published_at IS NOT NULL \
@@ -36,20 +48,92 @@ class Blog(misc.Object):
                                AND %s = ANY(tags) \
                        ORDER BY published_at DESC LIMIT %s", tag, limit)
 
-       def get_by_author(self, uid, limit=None):
+       def get_by_year(self, year):
                return self._get_posts("SELECT * FROM blog \
-                       WHERE author_uid = %s \
+                       WHERE EXTRACT(year FROM published_at) = %s \
                                AND published_at IS NOT NULL \
                                AND published_at <= NOW() \
-                       ORDER BY published_at DESC LIMIT %s", uid, limit)
+                       ORDER BY published_at DESC", year)
+
+       def get_drafts(self, author, limit=None):
+               return self._get_posts("SELECT * FROM blog \
+                       WHERE author_uid = %s \
+                               AND (published_at IS NULL OR published_at > NOW()) \
+                       ORDER BY COALESCE(updated_at, created_at) DESC LIMIT %s",
+                       author.uid, limit)
 
        def search(self, query, limit=None):
-               return self._get_posts("SELECT blog.* FROM blog \
+               posts = self._get_posts("SELECT blog.* FROM blog \
                        LEFT JOIN blog_search_index search_index ON blog.id = search_index.post_id \
-                       WHERE search_index.document @@ to_tsquery('english', %s) \
-                               ORDER BY ts_rank(search_index.document, to_tsquery('english', %s)) DESC \
+                       WHERE search_index.document @@ websearch_to_tsquery('english', %s) \
+                               ORDER BY ts_rank(search_index.document, websearch_to_tsquery('english', %s)) DESC \
                        LIMIT %s", query, query, limit)
 
+               return list(posts)
+
+       def has_had_recent_activity(self, **kwargs):
+               t = datetime.timedelta(**kwargs)
+
+               res = self.db.get("SELECT COUNT(*) AS count FROM blog \
+                       WHERE published_at IS NOT NULL AND published_at BETWEEN NOW() - %s AND NOW()", t)
+
+               if res and res.count > 0:
+                       return True
+
+               return False
+
+       def create_post(self, title, text, author, tags=[], lang="markdown"):
+               """
+                       Creates a new post and returns the resulting Post object
+               """
+               # Pre-render HTML
+               html = self._render_text(text, lang=lang)
+
+               return self._get_post("INSERT INTO blog(title, slug, text, html, lang, author_uid, tags) \
+                       VALUES(%s, %s, %s, %s, %s, %s, %s) RETURNING *", title, self._make_slug(title), text,
+                       html, lang, author.uid, list(tags))
+
+       def _make_slug(self, s):
+               # Remove any non-ASCII characters
+               try:
+                       s = unicodedata.normalize("NFKD", s)
+               except TypeError:
+                       pass
+
+               # Remove excessive whitespace
+               s = re.sub(r"[^\w]+", " ", s)
+
+               slug = "-".join(s.split()).lower()
+
+               while True:
+                       e = self.db.get("SELECT 1 FROM blog WHERE slug = %s", slug)
+                       if not e:
+                               break
+
+                       slug += "-"
+
+               return slug
+
+       def _render_text(self, text, lang="markdown"):
+               if lang == "markdown":
+                       return markdown.markdown(text,
+                               extensions=[
+                                       wiki.PrettyLinksExtension(),
+                                       "codehilite",
+                                       "fenced_code",
+                                       "footnotes",
+                                       "nl2br",
+                                       "sane_lists",
+                                       "tables",
+                                       "toc",
+                               ])
+
+               elif lang == "textile":
+                       return textile.textile(text)
+
+               else:
+                       return text
+
        def refresh(self):
                """
                        Needs to be called after a post has been changed
@@ -57,12 +141,92 @@ class Blog(misc.Object):
                """
                self.db.execute("REFRESH MATERIALIZED VIEW blog_search_index")
 
+       @property
+       def years(self):
+               res = self.db.query("SELECT DISTINCT EXTRACT(year FROM published_at)::integer AS year \
+                       FROM blog WHERE published_at IS NOT NULL AND published_at <= NOW() \
+                       ORDER BY year DESC")
+
+               for row in res:
+                       yield row.year
+
+       async def announce(self):
+               posts = self._get_posts("SELECT * FROM blog \
+                       WHERE (published_at IS NOT NULL AND published_at <= NOW()) \
+                               AND announced_at IS NULL")
+
+               for post in posts:
+                       await post.announce()
+
+       async def update_feeds(self):
+               """
+                       Updates all enabled feeds
+               """
+               for feed in self.db.query("SELECT * FROM blog_feeds WHERE enabled IS TRUE"):
+                       try:
+                               f = feedparser.parse(feed.url)
+                       except Exception as e:
+                               raise e
+
+                       with self.db.transaction():
+                               # Update name
+                               self.db.execute("UPDATE blog_feeds SET name = %s \
+                                       WHERE id = %s", f.feed.title, feed.id)
+
+                               # Walk through all entries
+                               for entry in f.entries:
+                                       # Skip everything without the "blog.ipfire.org" tag
+                                       try:
+                                               tags = list((t.term for t in entry.tags))
+
+                                               if not "blog.ipfire.org" in tags:
+                                                       continue
+                                       except AttributeError:
+                                               continue
+
+                                       # Get link to the posting site
+                                       link = entry.links[0].href
+
+                                       # Check if the entry has already been imported
+                                       res = self.db.get("SELECT id, (updated_at < %s) AS needs_update \
+                                                       FROM blog WHERE feed_id = %s AND foreign_id = %s",
+                                                       entry.updated, feed.id, entry.id)
+                                       if res:
+                                               # If the post needs to be updated, we do so
+                                               if res.needs_update:
+                                                       self.db.execute("UPDATE blog SET title = %s, author = %s, \
+                                                               published_at = %s, updated_at = %s, html = %s, link = %s, \
+                                                               tags = %s WHERE id = %s", entry.title, entry.author,
+                                                               entry.published, entry.updated, entry.summary, link,
+                                                               feed.tags + tags, res.id)
+
+                                               # Done here
+                                               continue
+
+                                       # Insert the new post
+                                       self.db.execute("INSERT INTO blog(title, slug, author, \
+                                               published_at, html, link, tags, updated_at, feed_id, foreign_id) \
+                                               VALUES(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
+                                               entry.title, self._make_slug(entry.title), entry.author,
+                                               entry.published, entry.summary, link, feed.tags + tags,
+                                               entry.updated, feed.id, entry.id)
+
+                               # Mark feed as updated
+                               self.db.execute("UPDATE blog_feeds SET last_updated_at = CURRENT_TIMESTAMP \
+                                       WHERE id = %s" % feed.id)
+
+               # Refresh the search index
+               with self.db.transaction():
+                       self.refresh()
+
 
 class Post(misc.Object):
        def init(self, id, data=None):
                self.id   = id
                self.data = data
 
+       # Title
+
        @property
        def title(self):
                return self.data.title
@@ -71,31 +235,171 @@ class Post(misc.Object):
        def slug(self):
                return self.data.slug
 
-       # XXX needs caching
-       @property
+       @lazy_property
        def author(self):
                if self.data.author_uid:
                        return self.backend.accounts.get_by_uid(self.data.author_uid)
 
+               return self.data.author
+
        @property
        def created_at(self):
                return self.data.created_at
 
+       @property
+       def lang(self):
+               return self.data.lang
+
+       # Published?
+
        @property
        def published_at(self):
                return self.data.published_at
 
+       def is_published(self):
+               """
+                       Returns True if the post is already published
+               """
+               return self.published_at and self.published_at <= datetime.datetime.now()
+
+       def publish(self, when=None):
+               if self.is_published():
+                       return
+
+               self.db.execute("UPDATE blog SET published_at = COALESCE(%s, CURRENT_TIMESTAMP) \
+                       WHERE id = %s", when, self.id)
+
+               # Update search indices
+               self.backend.blog.refresh()
+
+       # Updated?
+
        @property
+       def updated_at(self):
+               return self.data.updated_at
+
+       # Text
+
+       @property
+       def text(self):
+               return self.data.text
+
+       # HTML
+
+       @lazy_property
        def html(self):
                """
                        Returns this post as rendered HTML
                """
-               return self.data.html
+               return self.data.html or self.backend.blog._render_text(self.text, lang=self.lang)
+
+       @lazy_property
+       def plaintext(self):
+               h = html2text.HTML2Text()
+               h.ignore_links = True
+
+               return h.handle(self.html)
+
+       # Excerpt
+
+       @property
+       def excerpt(self):
+               paragraphs = self.plaintext.split("\n\n")
+
+               excerpt = []
+
+               for paragraph in paragraphs:
+                       excerpt.append(paragraph)
+
+                       # Add another paragraph if we encountered a headline
+                       if paragraph.startswith("#"):
+                               continue
+
+                       # End if this paragraph was long enough
+                       if len(paragraph) >= 40:
+                               break
+
+               return "\n\n".join(excerpt)
+
+       # Tags
 
        @property
        def tags(self):
                return self.data.tags
 
+       # Link
+
        @property
        def link(self):
                return self.data.link
+
+       @lazy_property
+       def release(self):
+               return self.backend.releases._get_release("SELECT * FROM releases \
+                       WHERE published IS NOT NULL AND published <= NOW() AND blog_id = %s", self.id)
+
+       def is_editable(self, user):
+               # Anonymous users cannot do anything
+               if not user:
+                       return False
+
+               # Admins can edit anything
+               if user.is_admin():
+                       return True
+
+               # User must have permission for the blog
+               if not user.is_blog_author():
+                       return False
+
+               # Authors can edit their own posts
+               return self.author == user
+
+       def update(self, title, text, tags=[]):
+               """
+                       Called to update the content of this post
+               """
+               # Update slug when post isn't published yet
+               slug = self.backend.blog._make_slug(title) \
+                       if not self.is_published() and not self.title == title else self.slug
+
+               # Render and cache HTML
+               html = self.backend.blog._render_text(text, lang=self.lang)
+
+               self.db.execute("UPDATE blog SET title = %s, slug = %s, text = %s, html = %s, \
+                       tags = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s",
+                       title, slug, text, html, list(tags), self.id)
+
+               # Update cache
+               self.data.update({
+                       "title" : title,
+                       "slug"  : slug,
+                       "text"  : text,
+                       "html"  : html,
+                       "tags"  : tags,
+               })
+
+               # Update search index if post is published
+               if self.is_published():
+                       self.backend.blog.refresh()
+
+       def delete(self):
+               self.db.execute("DELETE FROM blog WHERE id = %s", self.id)
+
+               # Update search indices
+               self.backend.blog.refresh()
+
+       async def announce(self):
+               # Get people who should receive this message
+               group = self.backend.groups.get_by_gid("promotional-consent")
+               if not group:
+                       return
+
+               with self.db.transaction():
+                       # Generate an email for everybody in this group
+                       for account in group:
+                               self.backend.messages.send_template("blog/messages/announcement",
+                                       account=account, post=self)
+
+                       # Mark this post as announced
+                       self.db.execute("UPDATE blog SET announced_at = CURRENT_TIMESTAMP \
+                               WHERE id = %s", self.id)