#!/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):
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 \
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
"""
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
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)