From 1a32a7e36c7e1d732c72acb30b8a6a6dc2fc7651 Mon Sep 17 00:00:00 2001 From: "James E. Blair" Date: Sun, 26 Jul 2009 19:26:45 -0700 Subject: Initial checkin (TG 2.0). --- quoins/__init__.py | 0 quoins/blog_controllers.py | 8 + quoins/blog_model.py | 274 +++++++++ quoins/command.py | 103 ++++ quoins/controllers.py | 878 +++++++++++++++++++++++++++++ quoins/linkback.py | 184 ++++++ quoins/model/__init__.py | 62 ++ quoins/model/blog.py | 261 +++++++++ quoins/public/images/feed-icon-20x20.png | Bin 0 -> 1104 bytes quoins/public/images/openid_small_logo.png | Bin 0 -> 916 bytes quoins/templates/__init__.py | 0 quoins/templates/blog-master.html | 83 +++ quoins/templates/delete_comment.html | 60 ++ quoins/templates/delete_post.html | 89 +++ quoins/templates/index.html | 71 +++ quoins/templates/master.html | 24 + quoins/templates/new_comment.html | 44 ++ quoins/templates/new_post.html | 46 ++ quoins/templates/post.html | 85 +++ quoins/templates/save_post.html | 79 +++ quoins/templates/sitetemplate.html | 18 + quoins/templates/unapproved_comments.html | 78 +++ setup.py | 49 ++ 23 files changed, 2496 insertions(+) create mode 100644 quoins/__init__.py create mode 100644 quoins/blog_controllers.py create mode 100644 quoins/blog_model.py create mode 100644 quoins/command.py create mode 100644 quoins/controllers.py create mode 100644 quoins/linkback.py create mode 100644 quoins/model/__init__.py create mode 100644 quoins/model/blog.py create mode 100644 quoins/public/images/feed-icon-20x20.png create mode 100644 quoins/public/images/openid_small_logo.png create mode 100644 quoins/templates/__init__.py create mode 100644 quoins/templates/blog-master.html create mode 100644 quoins/templates/delete_comment.html create mode 100644 quoins/templates/delete_post.html create mode 100644 quoins/templates/index.html create mode 100644 quoins/templates/master.html create mode 100644 quoins/templates/new_comment.html create mode 100644 quoins/templates/new_post.html create mode 100644 quoins/templates/post.html create mode 100644 quoins/templates/save_post.html create mode 100644 quoins/templates/sitetemplate.html create mode 100644 quoins/templates/unapproved_comments.html create mode 100644 setup.py diff --git a/quoins/__init__.py b/quoins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quoins/blog_controllers.py b/quoins/blog_controllers.py new file mode 100644 index 0000000..40c27ae --- /dev/null +++ b/quoins/blog_controllers.py @@ -0,0 +1,8 @@ +from tg import TGController, tmpl_context +from tg import expose, flash, require, url, request, redirect + +class BlogController(TGController): + @expose(template="genshi:quoinstemplates.test1") + def test(self): + return dict() + diff --git a/quoins/blog_model.py b/quoins/blog_model.py new file mode 100644 index 0000000..9a84010 --- /dev/null +++ b/quoins/blog_model.py @@ -0,0 +1,274 @@ +# Quoins - A TurboGears blogging system. +# Copyright (C) 2008-2009 James E. Blair +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from sqlalchemy import * +from sqlalchemy.orm import mapper, relation +from sqlalchemy import Table, ForeignKey, Column +from sqlalchemy.types import Integer, Unicode +#from sqlalchemy.orm import relation, backref +import tg +from datetime import datetime + +#from bonglonglong.model import DeclarativeBase, metadata, DBSession +metadata = tg.config['model'].metadata +DBSession = tg.config['model'].DBSession + +TGUser = tg.config['sa_auth']['user_class'] +tguser_table = TGUser.__table__ + +# Blog schema + +blog_table = Table('blog', metadata, + Column('id', Integer, primary_key=True), + Column('title', Unicode(255)), + Column('subtitle', Unicode(255)), + Column('allow_comments', Boolean, default=True, nullable=False), +) + +post_table = Table('post', metadata, + Column('id', Integer, primary_key=True), + Column('blog_id', Integer, ForeignKey('blog.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('user_id', Integer, ForeignKey(tguser_table.c.user_id, + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('title', Unicode(255)), + Column('teaser', TEXT), + Column('body', TEXT), + Column('created', DateTime, nullable=False, default=datetime.now), + Column('allow_comments', Boolean, nullable=False), + Column('published', Boolean, nullable=False, default=False, index=True), +) + +media_table = Table('media', metadata, + Column('id', Integer, primary_key=True), + Column('post_id', Integer, ForeignKey('post.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('name', String(255), nullable=False, index=True), + Column('mimetype', String(255)), + Column('data', BLOB), + UniqueConstraint('post_id', 'name'), +) + +tag_table = Table('tag', metadata, + Column('id', Integer, primary_key=True), + Column('name', Unicode(255)), +) + +post_tag_table = Table('post_tag', metadata, + Column('id', Integer, primary_key=True), + Column('post_id', Integer, ForeignKey('post.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('tag_id', Integer, ForeignKey('tag.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), +) + +comment_table = Table('comment', metadata, + Column('id', Integer, primary_key=True), + Column('parent_comment_id', Integer, ForeignKey('comment.id', + onupdate="CASCADE", ondelete="CASCADE"), index=True), + Column('post_id', Integer, ForeignKey('post.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('user_id', Integer, ForeignKey(tguser_table.c.user_id, + onupdate="CASCADE"), index=True), + Column('name', Unicode(255)), + Column('openid', String(255)), + Column('url', String(255)), + Column('title', Unicode(255)), + Column('body', TEXT), + Column('created', DateTime, nullable=False, default=datetime.now), + Column('approved', Boolean, nullable=False, default=False, index=True), +) + +linkback_table = Table('linkback', metadata, + Column('id', Integer, primary_key=True), + Column('post_id', Integer, ForeignKey('post.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('url', String(255)), + Column('title', Unicode(255)), + Column('body', Unicode(255)), + Column('name', Unicode(255)), + Column('created', DateTime, nullable=False, default=datetime.now), +) + +class Blog(object): + def get_tags(self): + # XXX this assumes that only one blog exists in the schema + return DBSession.query(Tag).all() + tags = property(get_tags) + + def get_users(self): + return [user for user in TGUser.query.all() if 'blog-post' in [p.permission_name for p in user.permissions]] + authors = property(get_users) + + def getPostsByTag(self, tagname): + posts = Post.query.filter(and_(post_table.c.blog_id==self.id, + post_table.c.id==post_tag_table.c.post_id, + post_tag_table.c.tag_id==tag_table.c.id, + tag_table.c.name==tagname, + post_table.c.published==True)).all() + return posts + + def getYears(self): + years = {} + for p in self.published_posts: + x = years.get(p.created.year, 0) + years[p.created.year] = x+1 + years = years.items() + years.sort(lambda a,b: cmp(a[0],b[0])) + years.reverse() + return years + + def getPostsByDate(self, year=None, month=None, day=None): + posts = [] + for p in self.published_posts: + if year and p.created.year!=year: + continue + if month and p.created.month!=month: + continue + if day and p.created.day!=day: + continue + posts.append(p) + return posts + + def getPostsByAuthor(self, name): + posts = [] + for p in self.published_posts: + if p.author.user_name==name: + posts.append(p) + return posts + + +class Post(object): + def get_teaser_or_body(self): + if self.teaser: return self.teaser + return self.body + short_body = property(get_teaser_or_body) + + def get_teaser_and_body(self): + if self.teaser: + return self.teaser + self.body + return self.body + long_body = property(get_teaser_and_body) + + def tag(self, name): + t = Tag.query.filter_by(name=name).first() + if not t: + t = Tag() + t.name = name + self.tags.append(t) + + def untag(self, name): + t = Tag.query.filter_by(name=name).first() + if len(t.posts)<2: + session.delete(t) + self.tags.remove(t) + + def get_comments_and_linkbacks(self, trackbacks=1, pingbacks=0): + objects = self.approved_comments[:] + for x in self.linkbacks: + if (trackbacks and x.body) or (pingbacks and not x.body): + objects.append(x) + objects.sort(lambda a,b: cmp(a.created, b.created)) + return objects + comments_and_links = property(get_comments_and_linkbacks) + + +class Media(object): + pass + +class Tag(object): + pass + +class BaseComment(object): + def get_author_name(self): + if hasattr(self, 'author') and self.author: + return self.author.display_name + if self.name: + return self.name + return 'Anonymous' + author_name = property(get_author_name) + + def get_author_url(self): + if hasattr(self, 'author') and self.author: + return self.author.url + if self.url: + return self.url + return None + author_url = property(get_author_url) + +class Comment(BaseComment): + pass + +class LinkBack(BaseComment): + pass + +mapper(Blog, blog_table, + properties=dict(posts=relation(Post, + order_by=desc(post_table.c.created)), + published_posts=relation(Post, + primaryjoin=and_(post_table.c.blog_id==blog_table.c.id, + post_table.c.published==True), + order_by=desc(post_table.c.created)), + unpublished_posts=relation(Post, + primaryjoin=and_(post_table.c.blog_id==blog_table.c.id, + post_table.c.published==False), + order_by=desc(post_table.c.created)), + unapproved_comments=relation(Comment, secondary=post_table, + primaryjoin=post_table.c.blog_id==blog_table.c.id, + secondaryjoin=and_(comment_table.c.post_id==post_table.c.id, + comment_table.c.approved==False)))) + +mapper(Tag, tag_table, + order_by=desc(tag_table.c.name)) + +mapper(Media, media_table, + properties=dict(post=relation(Post))) + +mapper(Post, post_table, + order_by=desc(post_table.c.created), + properties=dict(blog=relation(Blog), + author=relation(TGUser), + tags=relation(Tag, secondary=post_tag_table, backref='posts'), + comments=relation(Comment, cascade="all, delete-orphan"), + approved_comments=relation(Comment, + primaryjoin=and_(comment_table.c.post_id==post_table.c.id, + comment_table.c.approved==True)), + unapproved_comments=relation(Comment, + primaryjoin=and_(comment_table.c.post_id==post_table.c.id, + comment_table.c.approved==False)), + media=relation(Media), + linkbacks=relation(LinkBack))) + +mapper(Comment, comment_table, + order_by=comment_table.c.created, + properties=dict(post=relation(Post), + author=relation(TGUser))) + +mapper(LinkBack, linkback_table, + properties=dict(post=relation(Post))) + +def init_model(engine): + """Call me before using any of the tables or classes in the model.""" + + maker = sessionmaker(autoflush=True, autocommit=False, + extension=ZopeTransactionExtension()) + DBSession = scoped_session(maker) + + DeclarativeBase = declarative_base() + metadata = DeclarativeBase.metadata + + DBSession.configure(bind=engine) + return DBSession diff --git a/quoins/command.py b/quoins/command.py new file mode 100644 index 0000000..2e6411a --- /dev/null +++ b/quoins/command.py @@ -0,0 +1,103 @@ +import os +from paste.script import command +from paste.deploy import appconfig + +def get_config(self, config_spec): + section = self.options.section_name + if section is None: + if '#' in config_spec: + config_spec, section = config_spec.split('#', 1) + else: + section = 'main' + if not ':' in section: + plain_section = section + section = 'app:'+section + else: + plain_section = section.split(':', 1)[0] + if not config_spec.startswith('config:'): + config_spec = 'config:' + config_spec + if plain_section != 'main': + config_spec += '#' + plain_section + config_file = config_spec[len('config:'):].split('#', 1)[0] + config_file = os.path.join(os.getcwd(), config_file) + self.logging_file_config(config_file) + conf = appconfig(config_spec, relative_to=os.getcwd()) + return conf + +class OpenIDCommand(command.Command): + max_args = 1 + min_args = 1 + + usage = "CONFIG_FILE" + summary = "Setup Quoins OpenID tables" + group_name = "Quoins" + + parser = command.Command.standard_parser(verbose=True) + parser.add_option('--name', + action='store', + dest='section_name', + default=None, + help='The name of the section to set up (default: app:main)') + + def command(self): + config_file = self.args[0] + if self.verbose: + print "Using config file: %s" % config_file + + conf = get_config(self, config_file) + + from quoins.controllers import get_oid_connection + from openid.store.sqlstore import MySQLStore + import MySQLdb + + conn = get_oid_connection(conf) + store = MySQLStore(conn) + try: + store.createTables() + except MySQLdb.OperationalError, message: + errorcode = message[0] + if errorcode == 1050: + print 'ok' + else: + raise + +class BlogCommand(command.Command): + max_args = 1 + min_args = 1 + + usage = "CONFIG_FILE" + summary = "Create a new Quoins Blog" + group_name = "Quoins" + + parser = command.Command.standard_parser(verbose=True) + parser.add_option('--name', + action='store', + dest='section_name', + default=None, + help='The name of the section to set up (default: app:main)') + + def command(self): + config_file = self.args[0] + if self.verbose: + print "Using config file: %s" % config_file + + conf = get_config(self, config_file) + + from model import Blog, DBSession, init_model + from sqlalchemy import create_engine + init_model(create_engine(conf.get('sqlalchemy.url'))) + + title = raw_input("Blog title: ") + subtitle = raw_input("Blog subtitle: ") + comments = raw_input("Allow comments by default? (y/n) ") + if comments.strip().lower()=='y': + comments = True + else: + comments = False + b = Blog() + b.title = title.strip() + if subtitle: + b.subtitle = subtitle.strip() + b.allow_comments = comments + DBSession.add(b) + DBSession.flush() diff --git a/quoins/controllers.py b/quoins/controllers.py new file mode 100644 index 0000000..6b41cc1 --- /dev/null +++ b/quoins/controllers.py @@ -0,0 +1,878 @@ +# Quoins - A TurboGears blogging system. +# Copyright (C) 2008-2009 James E. Blair +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from tg import controllers, expose, flash, require, validate, request, redirect, session +from tg import TGController +from pylons.controllers.util import abort +from repoze.what import predicates +from tw.api import WidgetsList +import tw.forms as forms +import tw.forms.fields as fields +import tw.forms.validators as validators +from webhelpers.feedgenerator import Atom1Feed, Rss201rev2Feed +from model import * +import pylons +import cgi +from genshi.input import HTML +from genshi.filters import HTMLSanitizer +from urlparse import urlsplit +import os.path +from tw.tinymce import TinyMCE +import smtplib +from email.mime.text import MIMEText +from threading import Thread +import logging +log = logging.getLogger('quoins') + +import openid.consumer.consumer +import openid.server.server +import openid.extensions.sreg +from openid.store.sqlstore import MySQLStore +import MySQLdb +import sqlalchemy.engine.url +import types + +import xmlrpclib, sys, re +from linkback import LinkBackHandler, PingBackURI, TrackBackURI +import base64 + +def b64encode(x): + return base64.encodestring(x)[:-1] + +def get_oid_connection(config=None): + if config is None: + config = tg.config + backupuri = config.get('sqlalchemy.url') + uri = config.get('openid.store', backupuri) + u = sqlalchemy.engine.url.make_url(uri) + pw = u.password or '' + conn = MySQLdb.connect (host = u.host, + user = u.username, + passwd = pw, + db = u.database) + return conn + +def fix_url(url): + parts = urlsplit(url) + if not parts[0]: + url = 'http://'+url + i = url.find('://') + if '/' not in url[i+3:]: + url = url + '/' + return url + +def send_email(msg, frm, to): + host = tg.config.get('quoins.mailserver', None) + port = tg.config.get('quoins.mailport', 0) + helo = tg.config.get('quoins.mailhelo', None) + user = tg.config.get('quoins.mailuser', None) + pswd = tg.config.get('quoins.mailpass', None) + if not host: + log.warn('Quoins email notifications are not configured') + return #XXX log not sending mail + + s = smtplib.SMTP(host, port, helo) + if user and pswd: + s.login(user, pswd) + s.sendmail(frm, [to], msg.as_string()) + s.close() + log.info('Sent mail to: %s' % to) + +class SimpleForm(forms.Form): + template = """ +
+
+
+
+ + + """ + +class BlogPostForm(SimpleForm): + submit_text = "Post" + + class fields(WidgetsList): + post_id = fields.HiddenField() + title = fields.TextField(validator=validators.NotEmpty(), + attrs=dict(size=60)) + tags = fields.TextField(attrs=dict(size=30)) + comments = fields.CheckBox(label='Allow comments') + body = TinyMCE(validator=validators.NotEmpty(), + new_options = dict( + plugins = "media", + convert_urls = False + )) +## plugins = "pagebreak", +## pagebreak_separator = "", +## )) + file = fields.FileField(label='Upload image') + save = fields.SubmitButton(label=' ', default='Upload or save draft', + validator=validators.FancyValidator(if_missing=''), + named_button=True) + +blog_post_form = BlogPostForm() + + +class OpenIDField(fields.TextField): + template = """ + + + + """ + params = ["attrs", "img_url"] + params_doc = {'attrs' : 'Dictionary containing extra (X)HTML attributes for' + ' the input tag', + 'img_url' : 'The URL for the OpenID image',} + attrs = {} + img_url = lambda x: tg.url('/images/openid_small_logo.png') + + +class BlogCommentForm(SimpleForm): + submit_text = "Comment" + + class fields(WidgetsList): + id = fields.HiddenField() + name = fields.TextField() + url = OpenIDField(help_text='Enter your website or your OpenID here.') + body = fields.TextArea(validator=validators.NotEmpty()) + +blog_comment_form = BlogCommentForm() + +class Feed(TGController): + def get_feed_data(self, **kwargs): + # get the latest five blog entries in reversed order from SQLobject + blog = DBSession.query(Blog).get(1) + entries = [] + if kwargs.has_key('author'): + posts = blog.getPostsByAuthor(kwargs['author'])[:5] + else: + posts = blog.published_posts[:5] + for post in posts: + e = {} + e["title"] = post.title + e["published"] = post.created + e["author"] = post.author.display_name + e["email"] = post.author.email_address + e["link"] = self.blog_controller.absolute_url(post) + e["summary"] = post.short_body + tags = [tag.name for tag in post.tags] + if tags: + e["categories"] = tags + else: + e["categories"] = None + entries.append(e) + if blog.subtitle: + subtitle = blog.subtitle + else: + subtitle = '' + bloginfo = dict( + title = blog.title, + subtitle = subtitle, + link = self.blog_controller.absolute_url(), + description = u'The latest entries from %s' % blog.title, + id = self.blog_controller.absolute_url(), + entries = entries + ) + return bloginfo + + @expose(content_type='application/atom+xml') + def atom1_0(self, **kw): + info = self.get_feed_data(**kw) + feed = Atom1Feed( + title=info['title'], + link=info['link'], + description=info['description'], + language=u"en", + ) + for entry in info['entries']: + feed.add_item(title=entry['title'], + link=entry['link'], + description=entry['summary'], + author_name=entry['author'], + author_email=entry['email'], + pubdate=entry['published'], + categories=entry['categories']) + return feed.writeString('utf-8') + + @expose(content_type='application/rss+xml') + def rss2_0(self, **kw): + info = self.get_feed_data(**kw) + feed = Rss201rev2Feed( + title=info['title'], + link=info['link'], + description=info['description'], + language=u"en", + ) + for entry in info['entries']: + feed.add_item(title=entry['title'], + link=entry['link'], + description=entry['summary'], + author_name=entry['author'], + author_email=entry['email'], + pubdate=entry['published'], + categories=entry['categories']) + return feed.writeString('utf-8') + + + +class Pingback(TGController): + # The index method is based on code at: + # http://www.dalkescientific.com/writings/diary/archive/2006/10/24/xmlrpc_in_turbogears.html + + @expose(content_type='text/xml') + def index(self): + params, method = xmlrpclib.loads(request.body) + log.debug('Pingback method: %s' % method) + try: + if method != "pingback.ping": + raise AssertionError("method does not exist") + + # Call the method, convert it into a 1-element tuple + # as expected by dumps + response = self.ping(*params) + response = xmlrpclib.dumps((response,), methodresponse=1) + except xmlrpclib.Fault, fault: + # Can't marshal the result + response = xmlrpclib.dumps(fault) + except: + # Some other error; send back some error info + response = xmlrpclib.dumps( + xmlrpclib.Fault(0, "%s:%s" % (sys.exc_type, sys.exc_value)) + ) + + log.info('Pingback response: %s' % response) + return response + + post_re = re.compile(r'^.*/post/(\d+)$') + def ping(self, sourceURI, targetURI): + m = self.post_re.match(targetURI) + if not m: + return xmlrpclib.Fault(0x21, 'Unable to parse targetURI.') + id = int(m.group(1)) + post = DBSession.query(Post).get(id) + if not post: + return xmlrpclib.Fault(0x20, 'Post not found.') + elif not post.allow_comments: + return xmlrpclib.Fault(0x31, 'Comments are closed on this post.') + for lb in post.linkbacks: + if lb.url == sourceURI: + return xmlrpclib.Fault(0x30, 'Pingback already registered.') + lb = LinkBack() + DBSession.add(lb) + lb.post = post + lb.url = sourceURI + return 'Linkback recorded.' + +def post_paginate(start, posts, size): + start=int(start) + if start < 0: + start = 0 + if start > len(posts): + start = len(posts)-size + out_posts = posts[start:start+size] + next = prev = None + if len(posts)>start+size: + next = start+size + if start: + prev = start-size + if prev < 0: prev = 0 + return dict(posts = out_posts, + prev = prev, + next = next) + +class BlogController(TGController): + feed = Feed() + pingback = Pingback() + + def url(self, obj=None): + if obj is None: + u = tg.url(self.path) + elif isinstance(obj, basestring): + if obj.startswith('/'): obj = obj[1:] + u = tg.url(os.path.join(self.path, obj)) + elif isinstance(obj, Post): + u = tg.url(os.path.join(self.path, 'post', str(obj.id))) + elif isinstance(obj, Media): + u = tg.url(os.path.join(self.path, 'media', str(obj.post.id), str(obj.name))) + return u + + def absolute_url(self, obj=None): + u = self.url(obj) + port = tg.config.get('server.webport') + if port == '80': + port = '' + else: + port = ':'+port + return 'http://%s%s%s'%(tg.config.get('server.webhost'), port, u) + + def get_html(self, data): + return HTML(data) + + def send_comment_email(self, comment): + post = comment.post + blog = post.blog + fromaddr = tg.config.get('quoins.mailfrom', '<>') + toaddr = post.author.email_address + d = {} + d['blog_title'] = blog.title + d['post_title'] = post.title + d['name'] = comment.name + d['url'] = comment.url + d['comment'] = comment.body + if comment.approved: + d['approval'] = "Approval is not required for this comment." + else: + d['approval'] = "This comment is not yet approved. To approve it, visit:\n\n" + d['approval'] += " %s" % self.absolute_url('/unapproved_comments') + + message = """ +A new comment has been posted to the %(blog_title)s post +"%(post_title)s". + +%(approval)s + +Name: %(name)s +URL: %(url)s +Comment: + +%(comment)s +""" % d + + msg = MIMEText(message) + msg['From'] = fromaddr + msg['To'] = toaddr + msg['Subject'] = "New comment on %s" % post.title + + t = Thread(target=send_email, args=(msg, fromaddr, toaddr)) + t.start() + + def __init__(self, *args, **kw): + super(BlogController, self).__init__(*args, **kw) + self.path = kw.pop('path', '/') + self.post_paginate = kw.pop('paginate', 5) + self.feed.blog_controller = self + + @expose(template="genshi:quoinstemplates.index") + def index(self, start=0): + pylons.response.headers['X-XRDS-Location']=self.absolute_url('/yadis') + blog = DBSession.query(Blog).get(1) + d = post_paginate(start, blog.published_posts, self.post_paginate) + + d.update(dict(quoins = self, + blog = blog, + post = None, + author = None, + )) + return d + + @expose(template="genshi:quoinstemplates.index") + def tag(self, tagname, start=0): + blog = DBSession.query(Blog).get(1) + posts = blog.getPostsByTag(tagname) + d = post_paginate(start, posts, self.post_paginate) + d.update(dict(quoins = self, + blog = blog, + post = None, + author = None)) + return d + + @expose(template="genshi:quoinstemplates.index") + def archive(self, year='', month='', day='', start=0): + blog = DBSession.query(Blog).get(1) + try: year = int(year) + except: year = None + try: month = int(month) + except: month = None + try: day = int(day) + except: day = None + + if not year: + flash('Please supply a year for the archive.') + redirect(self.url('/')) + posts = blog.getPostsByDate(year, month, day) + d = post_paginate(start, posts, self.post_paginate) + d.update(dict(quoins = self, + blog = blog, + post = None, + author = None)) + return d + + @expose(template="genshi:quoinstemplates.index") + def author(self, name='', start=0): + blog = DBSession.query(Blog).get(1) + + if not name: + flash('Please supply the name of an author.') + redirect(self.url('/')) + posts = blog.getPostsByAuthor(name) + d = post_paginate(start, posts, self.post_paginate) + d.update(dict(quoins = self, + blog = blog, + post = None, + author = name)) + return d + + @expose(template="genshi:quoinstemplates.index") + @require(predicates.has_permission('blog-post')) + def unpublished_posts(self, start=0): + blog = DBSession.query(Blog).get(1) + posts = blog.unpublished_posts + d = post_paginate(start, posts, self.post_paginate) + d.update(dict(quoins = self, + blog = blog, + post = None, + author = None)) + return d + + @expose(template="genshi:quoinstemplates.post") + def post(self, id): + post = DBSession.query(Post).get(id) + pylons.response.headers['X-Pingback']=self.absolute_url('/pingback/') + return dict(quoins = self, + blog = post.blog, + post = post) + + @expose(template="genshi:quoinstemplates.new_comment") + def new_comment(self, id): + post = DBSession.query(Post).get(id) + if not post.allow_comments: + flash('This post does not allow comments.') + redirect(self.url(post)) + return dict(quoins = self, + blog = post.blog, + post = post, + form = blog_comment_form, + action = self.url('save_comment'), + defaults = {'id':id}) + + @expose() + def yadis(self): + doc = """ + + + + http://specs.openid.net/auth/2.0/return_to + %s + + + +""" % self.absolute_url('oid_comment') + return doc + + @expose() + @validate(form=blog_comment_form, error_handler=new_comment) + def save_comment(self, id, name='', url='', body=''): + post = DBSession.query(Post).get(id) + if not post.allow_comments: + flash('This post does not allow comments.') + redirect(self.url(post)) + if name and ('%' in name or DBSession.query(TGUser).filter_by(display_name=name).first() or name.lower()=='anonymous' or name.lower().startswith('openid')): + flash('The name %s is not allowed.'%name) + return self.new_comment(id) + if not name: name = 'Anonymous' + if url: + store = MySQLStore(get_oid_connection()) + con = openid.consumer.consumer.Consumer(session, store) + url = fix_url(str(url)) + try: + req = con.begin(url) + req.addExtensionArg('http://openid.net/sreg/1.0', + 'optional', + 'fullname,nickname,email') + + oid_url = req.redirectURL(self.absolute_url(), + self.absolute_url('oid_comment')) + session['oid_comment_body']=body + session['oid_comment_name']=name + session['oid_comment_post']=id + session['oid_comment_url']=url + session.save() + + redirect(oid_url) + except openid.consumer.consumer.DiscoveryFailure: + # treat as anonymous content without openid + pass + + c = Comment() + c.post = post + post.comments.append(c) + c.body = body + if request.identity: + c.author=request.identity['user'] + c.approved = True + flash('Your comment has been posted.') + else: + c.name = name + if url: c.url = url + flash('Your comment has been posted and is awaiting moderation.') + + self.send_comment_email(c) + + redirect(self.url(post)) + + @expose() + def oid_comment(self, **kw): + store = MySQLStore(get_oid_connection()) + con = openid.consumer.consumer.Consumer(session, store) + port = tg.config.get('server.webport') + if port == '80': + port = '' + else: + port = ':'+port + path = 'http://%s%s%s'%(tg.config.get('server.webhost'), port, request.path) + ret = con.complete(kw, path) + + session.save() + + if ret.status == openid.consumer.consumer.SUCCESS: + sreg = ret.extensionResponse('http://openid.net/sreg/1.0', True) + name = session['oid_comment_name'] + if sreg.has_key('fullname'): + name = sreg.get('fullname') + elif sreg.has_key('nickname'): + name = sreg.get('nickname') + body = session['oid_comment_body'] + id = session['oid_comment_post'] + url = session['oid_comment_url'] + + name = unicode(name) + post = DBSession.query(Post).get(id) + if not post.allow_comments: + flash('This post does not allow comments.') + redirect(self.url(post)) + if name and ('%' in name or DBSession.query(TGUser).filter_by(display_name=name).first() or name.lower()=='anonymous'): + name = u'OpenID: %s'%name + + c = Comment() + c.post = post + post.comments.append(c) + c.approved = True + c.body = body + c.url = url + c.name = name + flash('Your comment has been posted.') + self.send_comment_email(c) + redirect(self.url(post)) + else: + id = session['oid_comment_post'] + post = DBSession.query(Post).get(id) + flash('OpenID authentication failed') + redirect(self.url('new_comment/%s'%post.id)) + + @expose(template="genshi:quoinstemplates.delete_comment") + @require(predicates.has_permission('blog-post')) + def delete_comment(self, id, confirm=None): + comment = DBSession.query(Comment).get(id) + post = comment.post + if confirm: + DBSession.delete(comment) + flash('Comment deleted.') + redirect(self.url(post)) + return dict(quoins = self, + blog = comment.post.blog, + post = comment.post, + comment = comment) + + @expose(template="genshi:quoinstemplates.unapproved_comments") + @require(predicates.has_permission('blog-post')) + def unapproved_comments(self): + blog = DBSession.query(Blog).get(1) + return dict(quoins = self, + blog=blog, + post=None, + comments = blog.unapproved_comments) + + @expose() + @require(predicates.has_permission('blog-post')) + def approve_comments(self, **kwargs): + for name, value in kwargs.items(): + if not name.startswith('comment_'): continue + if value == 'ignore': continue + c, id = name.split('_') + comment = DBSession.query(Comment).get(int(id)) + if value == 'approve': + comment.approved = True + if value == 'delete': + DBSession.delete(comment) + flash('Your changes have been saved.') + redirect(self.url()) + + @expose(content_type=controllers.CUSTOM_CONTENT_TYPE) + def media(self, post_id, name): + # Apparently TG2 is adopting Zope misfeatures and we + # don't get .ext in our name argument. + name = request.environ['PATH_INFO'].split('/')[-1] + post = DBSession.query(Post).get(post_id) + media = None + for m in post.media: + if m.name==name: + media = m + if not media: + abort(404) + + pylons.response.headers['Content-Type'] = media.mimetype + return str(media.data) + + @expose() + @require(predicates.has_permission('blog-post')) + def delete_media(self, post_id, ids=[]): + if type(ids) != type([]): + ids = [ids] + for id in ids: + media = DBSession.query(Media).get(id) + DBSession.delete(media) + DBSession.flush() + flash('Deleted image') + return self.edit_post(post_id) + + @expose(template="genshi:quoinstemplates.new_post") + @require(predicates.has_permission('blog-post')) + def new_post(self, **args): + blog = DBSession.query(Blog).get(1) + return dict(quoins = self, + blog = blog, + post = None, + form = blog_post_form, + defaults = {'comments':blog.allow_comments}) + + @expose(template="genshi:quoinstemplates.delete_post") + @require(predicates.has_permission('blog-post')) + def delete_post(self, id, confirm=None): + post = DBSession.query(Post).get(id) + if confirm: + for tag in post.tags[:]: + post.untag(tag.name) + DBSession.delete(post) + flash('Post deleted.') + redirect(self.url('/')) + return dict(quoins = self, + blog = post.blog, + post = post) + + @expose(template="genshi:quoinstemplates.new_post") + @require(predicates.has_permission('blog-post')) + def edit_post(self, id): + post = DBSession.query(Post).get(id) + body = post.body + if post.teaser: + body = post.teaser + '\n----\n' + body + tags = ' '.join([t.name for t in post.tags]) + return dict(quoins = self, + blog = post.blog, + form = blog_post_form, + post = post, + defaults = {'comments':post.allow_comments, + 'title':post.title, + 'body':body, + 'tags':tags, + 'post_id':post.id}) + + @expose(template="genshi:quoinstemplates.save_post") + @validate(form=blog_post_form, error_handler=new_post) + @require(predicates.has_permission('blog-post')) + def save_post(self, title, body, comments='', tags='', post_id='', file=None, save='', submit=''): + flashes = [] + body = body.encode('utf8') + if not tags: tags = '' + + if post_id: + # Editing a post + post_id = int(post_id) + p = DBSession.query(Post).get(post_id) + blog = p.blog + for t in p.tags[:]: + p.untag(t.name) + else: + # Making a new post + blog = DBSession.query(Blog).get(1) + p = Post() + p.author=request.identity['user'] + p.blog=blog + DBSession.add(p) + p.title = title + p.body = body + + teaser = '' + newbody = '' + body = body.replace('\r', '') + for line in body.split('\n'): + if line.strip()=='----' and not teaser: + teaser = newbody.strip()+'\n' + newbody = '' + else: + newbody += line+'\n' + if teaser and newbody: p.teaser = teaser + if teaser and not newbody: + newbody = teaser + p.body = newbody.strip()+'\n' + + if comments: + p.allow_comments=True + else: + p.allow_comments=False + + for tag in tags.split(): + tag = tag.lower().strip() + p.tag(tag) + + if file is not None: + data = file.file.read() + if data: + media = Media() + media.post = p + media.name = file.filename + media.mimetype = file.type + media.data = data + DBSession.add(media) + flashes.append('File uploaded.') + + if save: + p.published = False + else: + p.published = True + + DBSession.flush() + + if p.published: + handler = LinkBackHandler() + uris = handler.findURIs(body) + + session['linkback_uris']=uris + session.save() + + myuris = [] + for (uri, title, lbs) in uris: + best = None + for lb in lbs: + if isinstance(lb, TrackBackURI): + best = lb + lb.type = 'TrackBack' + if isinstance(lb, PingBackURI): + lb.type = 'PingBack' + myuris.append((uri, b64encode(uri), + title, lbs, best)) + flashes.append("Published post.") + else: + flashes.append("Saved draft post.") + + + flash('\n'.join(flashes)) + + if not p.published: + return redirect('./edit_post/%s'%(p.id)) + + return dict(quoins=self, + blog=blog, + post=p, + uris=myuris) + + @expose() + @require(predicates.has_permission('blog-post')) + def send_linkbacks(self, id, **kw): + trackbacks = kw.pop('trackbacks', '') + blog = DBSession.query(Blog).get(1) + post = DBSession.query(Post).get(id) + + flashes = [] + uris = session.pop('linkback_uris', []) + for (uri, title, lbs) in uris: + b64uri = b64encode(uri) + action = kw.pop(b64uri, None) + if not action: continue + type, target = action.split(':',1) + for lb in lbs: + if (isinstance(lb, TrackBackURI) + and type=='TrackBack' + and target==lb.uri): + msg = lb.send(post.title, post.short_body, + self.absolute_url(post), + blog.title) + flashes.append(msg) + if (isinstance(lb, PingBackURI) + and type=='PingBack' + and target==lb.uri): + msg = lb.send(self.absolute_url(post), uri) + flashes.append(msg) + for uri in trackbacks.split('\n'): + uri = uri.strip() + if not uri: continue + lb = TrackBackURI(uri) + msg = lb.send(post.title, post.short_body, + self.absolute_url(post), + blog.title) + flashes.append(msg) + + flash('\n'.join(flashes)) + redirect (self.url()) + + @expose() + def trackback(self, id, url='', title='', excerpt='', blog_name=''): + message = '' + post = DBSession.query(Post).get(id) + if not post: + message = 'Post not found.' + elif not post.allow_comments: + message = 'Comments are closed on this post.' + for lb in post.linkbacks: + if lb.url == url: + message = 'Trackback already registered.' + if not message: + lb = LinkBack() + DBSession.add(lb) + lb.post = post + lb.url = url + lb.title = title + lb.name = blog_name + lb.body = excerpt + if message: + error = 1 + message = "%s\n" % message + else: + error = 0 + return """ + +%s +%s +""" % (error, message) diff --git a/quoins/linkback.py b/quoins/linkback.py new file mode 100644 index 0000000..404789c --- /dev/null +++ b/quoins/linkback.py @@ -0,0 +1,184 @@ +# Quoins - A TurboGears blogging system. +# Copyright (C) 2008 James E. Blair +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from xml.etree.ElementTree import XMLTreeBuilder +from HTMLParser import HTMLParser, HTMLParseError +import urllib, urllib2 +import re +import xmlrpclib + +class HTMLLinkParser(HTMLParser): + def __init__(self): + self.links = [] + self.curlink_href = None + self.curlink_title = None + HTMLParser.__init__(self) + + def handle_starttag(self, tag, attrs): + if tag != 'a': return + for k,v in attrs: + if k=='href': + self.curlink_href = v + self.curlink_title = '' + + def handle_data(self, data): + if self.curlink_href and data: + self.curlink_title += data + + def handle_endtag(self, tag): + if tag != 'a': return + if self.curlink_href: + title = self.curlink_title + if not title: + title = self.curlink_href + self.links.append((self.curlink_href, title)) + self.curlink_href = None + self.curlink_title = None + +class HTMLLinkBackParser(HTMLParser): + def __init__(self): + self.links = [] + HTMLParser.__init__(self) + + def handle_starttag(self, tag, attrs): + href = rel = None + for k,v in attrs: + if k=='href': + href = v + if k=='rel': + rel = v + if href and rel in ['pingback', 'trackback']: + self.links.append((rel, href)) + +class LinkBackURI(object): + def __init__(self, uri): + self.uri = uri + +class TrackBackURI(LinkBackURI): + def send(self, title='', excerpt='', url='', blog_name=''): + try: + msg = self._send(title, excerpt, url, blog_name) + except: + return 'Error sending TrackBack to %s' % self.uri + if msg: + return 'Remote error %s sending TrackBack to %s'%(msg, self.uri) + return 'Sent TrackBack to %s' % self.uri + + def _send(self, title, excerpt, url, blog_name): + builder = XMLTreeBuilder() + data = urllib.urlencode(dict( + title=title, + excerpt=excerpt, + url=url, + blog_name=blog_name, + )) + + req = urllib2.Request(self.uri, data) + response = urllib2.urlopen(req) + res = response.read() + + builder.feed(res.strip()) + tree = builder.close() + error = tree.find('error') + error = int(error.text) + if error: + message = tree.find('message') + return message.text + return None + +class PingBackURI(LinkBackURI): + def send(self, source_url='', target_url=''): + try: + msg = self._send(source_url, target_url) + except: + raise + return 'Error sending PingBack to %s' % self.uri + if msg: + return 'Remote error %s sending PingBack to %s'%(msg, self.uri) + return 'Sent PingBack to %s' % self.uri + + def _send(self, source_url, target_url): + server = xmlrpclib.ServerProxy(self.uri) + + try: + print 'ping', source_url, target_url + ret = server.pingback.ping(source_url, target_url) + print 'ok', ret + return None + except xmlrpclib.Error, v: + return v + +class LinkBackHandler(object): + + def __init__(self, trackbacks=True, pingbacks=True): + self.support_trackbacks = trackbacks + self.support_pingbacks = pingbacks + + def findURIs(self, text): + p = HTMLLinkParser() + p.feed(text) + p.close() + ret = [] + for uri, title in p.links: + try: + lbs = self.findLinkBackURIs(uri) + ret.append((uri, title, lbs)) + except ValueError: + pass + except HTMLParseError: + pass + except urllib2.HTTPError: + pass + return ret + + TB_RE = re.compile(r'trackback:ping="([^"]+)"') + PB_RE = re.compile(r'') + + def findLinkBackURIs(self, uri): + found = {} + ret = [] + req = urllib2.Request(uri) + response = urllib2.urlopen(req) + info = response.info() + res = response.read() + p = HTMLLinkBackParser() + p.feed(res) + p.close() + if self.support_trackbacks: + matches = self.TB_RE.findall(res) + for url in matches: + if url not in found: + found[url]=1 + ret.append(TrackBackURI(url)) + for rel, url in p.links: + if rel=='trackback' and url not in found: + found[url]=1 + ret.append(TrackBackURI(url)) + if self.support_pingbacks: + pb_header = info.get('X-Pingback', None) + if pb_header: + ret.append(PingBackURI(pb_header)) + else: + matches = self.PB_RE.findall(res) + for url in matches: + if url not in found: + found[url]=1 + ret.append(PingBackURI(url)) + for rel, url in p.links: + if rel=='pingback' and url not in found: + found[url]=1 + ret.append(PingBackURI(url)) + return ret diff --git a/quoins/model/__init__.py b/quoins/model/__init__.py new file mode 100644 index 0000000..8f551c8 --- /dev/null +++ b/quoins/model/__init__.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""The application's model objects""" + +from zope.sqlalchemy import ZopeTransactionExtension +from sqlalchemy.orm import scoped_session, sessionmaker +#from sqlalchemy import MetaData +from sqlalchemy.ext.declarative import declarative_base + +# Global session manager: DBSession() returns the Thread-local +# session object appropriate for the current web request. +maker = sessionmaker(autoflush=True, autocommit=False, + extension=ZopeTransactionExtension()) +DBSession = scoped_session(maker) + +# Base class for all of our model classes: By default, the data model is +# defined with SQLAlchemy's declarative extension, but if you need more +# control, you can switch to the traditional method. +DeclarativeBase = declarative_base() + +# There are two convenient ways for you to spare some typing. +# You can have a query property on all your model classes by doing this: +# DeclarativeBase.query = DBSession.query_property() +# Or you can use a session-aware mapper as it was used in TurboGears 1: +# DeclarativeBase = declarative_base(mapper=DBSession.mapper) + +# Global metadata. +# The default metadata is the one from the declarative base. +metadata = DeclarativeBase.metadata + +# If you have multiple databases with overlapping table names, you'll need a +# metadata for each database. Feel free to rename 'metadata2'. +#metadata2 = MetaData() + +##### +# Generally you will not want to define your table's mappers, and data objects +# here in __init__ but will want to create modules them in the model directory +# and import them at the bottom of this file. +# +###### + +def init_model(engine): + """Call me before using any of the tables or classes in the model.""" + + DBSession.configure(bind=engine) + # If you are using reflection to introspect your database and create + # table objects for you, your tables must be defined and mapped inside + # the init_model function, so that the engine is available if you + # use the model outside tg2, you need to make sure this is called before + # you use the model. + + # + # See the following example: + + #global t_reflected + + #t_reflected = Table("Reflected", metadata, + # autoload=True, autoload_with=engine) + + #mapper(Reflected, t_reflected) + +# Import your model modules here. +from quoins.model.blog import * diff --git a/quoins/model/blog.py b/quoins/model/blog.py new file mode 100644 index 0000000..608a2f1 --- /dev/null +++ b/quoins/model/blog.py @@ -0,0 +1,261 @@ +# Quoins - A TurboGears blogging system. +# Copyright (C) 2008-2009 James E. Blair +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from sqlalchemy import * +from sqlalchemy.orm import mapper, relation +from sqlalchemy import Table, ForeignKey, Column +from sqlalchemy.types import Integer, Unicode +#from sqlalchemy.orm import relation, backref +import tg +from datetime import datetime + +metadata = tg.config['quoins']['metadata'] +DBSession = tg.config['quoins']['session'] +TGUser = tg.config['quoins']['user'] + +tguser_table = TGUser.__table__ + +# Blog schema + +blog_table = Table('blog', metadata, + Column('id', Integer, primary_key=True), + Column('title', Unicode(255)), + Column('subtitle', Unicode(255)), + Column('allow_comments', Boolean, default=True, nullable=False), +) + +post_table = Table('post', metadata, + Column('id', Integer, primary_key=True), + Column('blog_id', Integer, ForeignKey('blog.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('user_id', Integer, ForeignKey(tguser_table.c.user_id, + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('title', Unicode(255)), + Column('teaser', TEXT), + Column('body', TEXT), + Column('created', DateTime, nullable=False, default=datetime.now), + Column('allow_comments', Boolean, nullable=False), + Column('published', Boolean, nullable=False, default=False, index=True), +) + +media_table = Table('media', metadata, + Column('id', Integer, primary_key=True), + Column('post_id', Integer, ForeignKey('post.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('name', String(255), nullable=False, index=True), + Column('mimetype', String(255)), + Column('data', BLOB), + UniqueConstraint('post_id', 'name'), +) + +tag_table = Table('tag', metadata, + Column('id', Integer, primary_key=True), + Column('name', Unicode(255)), +) + +post_tag_table = Table('post_tag', metadata, + Column('id', Integer, primary_key=True), + Column('post_id', Integer, ForeignKey('post.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('tag_id', Integer, ForeignKey('tag.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), +) + +comment_table = Table('comment', metadata, + Column('id', Integer, primary_key=True), + Column('parent_comment_id', Integer, ForeignKey('comment.id', + onupdate="CASCADE", ondelete="CASCADE"), index=True), + Column('post_id', Integer, ForeignKey('post.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('user_id', Integer, ForeignKey(tguser_table.c.user_id, + onupdate="CASCADE"), index=True), + Column('name', Unicode(255)), + Column('openid', String(255)), + Column('url', String(255)), + Column('title', Unicode(255)), + Column('body', TEXT), + Column('created', DateTime, nullable=False, default=datetime.now), + Column('approved', Boolean, nullable=False, default=False, index=True), +) + +linkback_table = Table('linkback', metadata, + Column('id', Integer, primary_key=True), + Column('post_id', Integer, ForeignKey('post.id', + onupdate="CASCADE", ondelete="CASCADE"), nullable=False, index=True), + Column('url', String(255)), + Column('title', Unicode(255)), + Column('body', Unicode(255)), + Column('name', Unicode(255)), + Column('created', DateTime, nullable=False, default=datetime.now), +) + +class Blog(object): + def get_tags(self): + # XXX this assumes that only one blog exists in the schema + return DBSession.query(Tag).all() + tags = property(get_tags) + + def get_users(self): + return [user for user in DBSession.query(TGUser).all() if 'blog-post' in [p.permission_name for p in user.permissions]] + authors = property(get_users) + + def getPostsByTag(self, tagname): + posts = DBSession.query(Post).filter(and_(post_table.c.blog_id==self.id, + post_table.c.id==post_tag_table.c.post_id, + post_tag_table.c.tag_id==tag_table.c.id, + tag_table.c.name==tagname, + post_table.c.published==True)).all() + return posts + + def getYears(self): + years = {} + for p in self.published_posts: + x = years.get(p.created.year, 0) + years[p.created.year] = x+1 + years = years.items() + years.sort(lambda a,b: cmp(a[0],b[0])) + years.reverse() + return years + + def getPostsByDate(self, year=None, month=None, day=None): + posts = [] + for p in self.published_posts: + if year and p.created.year!=year: + continue + if month and p.created.month!=month: + continue + if day and p.created.day!=day: + continue + posts.append(p) + return posts + + def getPostsByAuthor(self, name): + posts = [] + for p in self.published_posts: + if p.author.user_name==name: + posts.append(p) + return posts + + +class Post(object): + def get_teaser_or_body(self): + if self.teaser: return self.teaser + return self.body + short_body = property(get_teaser_or_body) + + def get_teaser_and_body(self): + if self.teaser: + return self.teaser + self.body + return self.body + long_body = property(get_teaser_and_body) + + def tag(self, name): + t = DBSession.query(Tag).filter_by(name=name).first() + if not t: + t = Tag() + DBSession.add(t) + t.name = name + self.tags.append(t) + + def untag(self, name): + t = DBSession.query(Tag).filter_by(name=name).first() + if len(t.posts)<2: + DBSession.delete(t) + self.tags.remove(t) + + def get_comments_and_linkbacks(self, trackbacks=1, pingbacks=1): + objects = self.approved_comments[:] + for x in self.linkbacks: + if (trackbacks and x.body) or (pingbacks and not x.body): + objects.append(x) + objects.sort(lambda a,b: cmp(a.created, b.created)) + return objects + comments_and_links = property(get_comments_and_linkbacks) + + +class Media(object): + pass + +class Tag(object): + pass + +class BaseComment(object): + def get_author_name(self): + if hasattr(self, 'author') and self.author: + return self.author.display_name + if self.name: + return self.name + return 'Anonymous' + author_name = property(get_author_name) + + def get_author_url(self): + if hasattr(self, 'author') and self.author: + return self.author.url + if self.url: + return self.url + return None + author_url = property(get_author_url) + +class Comment(BaseComment): + pass + +class LinkBack(BaseComment): + pass + +mapper(Blog, blog_table, + properties=dict(posts=relation(Post, + order_by=desc(post_table.c.created)), + published_posts=relation(Post, + primaryjoin=and_(post_table.c.blog_id==blog_table.c.id, + post_table.c.published==True), + order_by=desc(post_table.c.created)), + unpublished_posts=relation(Post, + primaryjoin=and_(post_table.c.blog_id==blog_table.c.id, + post_table.c.published==False), + order_by=desc(post_table.c.created)), + unapproved_comments=relation(Comment, secondary=post_table, + primaryjoin=post_table.c.blog_id==blog_table.c.id, + secondaryjoin=and_(comment_table.c.post_id==post_table.c.id, + comment_table.c.approved==False)))) + +mapper(Tag, tag_table, + order_by=desc(tag_table.c.name)) + +mapper(Media, media_table, + properties=dict(post=relation(Post))) + +mapper(Post, post_table, + order_by=desc(post_table.c.created), + properties=dict(blog=relation(Blog), + author=relation(TGUser), + tags=relation(Tag, secondary=post_tag_table, backref='posts'), + comments=relation(Comment, cascade="all, delete-orphan"), + approved_comments=relation(Comment, + primaryjoin=and_(comment_table.c.post_id==post_table.c.id, + comment_table.c.approved==True)), + unapproved_comments=relation(Comment, + primaryjoin=and_(comment_table.c.post_id==post_table.c.id, + comment_table.c.approved==False)), + media=relation(Media), + linkbacks=relation(LinkBack))) + +mapper(Comment, comment_table, + order_by=comment_table.c.created, + properties=dict(post=relation(Post), + author=relation(TGUser))) + +mapper(LinkBack, linkback_table, + properties=dict(post=relation(Post))) diff --git a/quoins/public/images/feed-icon-20x20.png b/quoins/public/images/feed-icon-20x20.png new file mode 100644 index 0000000..58cec04 Binary files /dev/null and b/quoins/public/images/feed-icon-20x20.png differ diff --git a/quoins/public/images/openid_small_logo.png b/quoins/public/images/openid_small_logo.png new file mode 100644 index 0000000..2829b00 Binary files /dev/null and b/quoins/public/images/openid_small_logo.png differ diff --git a/quoins/templates/__init__.py b/quoins/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quoins/templates/blog-master.html b/quoins/templates/blog-master.html new file mode 100644 index 0000000..a8ad0cc --- /dev/null +++ b/quoins/templates/blog-master.html @@ -0,0 +1,83 @@ + + + +
+ + +
+
+
+
+
+ +
+
+ +

${tg.identity.user.display_name}

+ +
+ +
+

Tags

+ +
+ +
+

Archive

+ +
+ +
+

Authors

+ +
+ + + +
+
+ + + +
+ + diff --git a/quoins/templates/delete_comment.html b/quoins/templates/delete_comment.html new file mode 100644 index 0000000..c2dd171 --- /dev/null +++ b/quoins/templates/delete_comment.html @@ -0,0 +1,60 @@ + + + + + + + + ${blog.title} + + + + +
+
+ +
+ +
+ +
+ + + +

Post: + ${comment.post.title} +

+

+ + + [ X ] + + +   + + ${comment.name} + + + ${comment.name} + +
+ + ${comment.created.strftime('%B %d, %Y at %I:%M %p')} + +

+ +
+ + + +
+
+ +
+
+ + + diff --git a/quoins/templates/delete_post.html b/quoins/templates/delete_post.html new file mode 100644 index 0000000..e237dad --- /dev/null +++ b/quoins/templates/delete_post.html @@ -0,0 +1,89 @@ + + + + + + + + ${blog.title} + + + + + + +
+
+ +
+ + Delete this post? +
+ + + + +
+ +
+

${post.title} +

+ +
+ +
+
+ +
+ +
+ +

${len(post.comments_and_links)} Comments

+ +
    +
  1. +

    + + + [${i + 1}] + + +   + + ${comment.author_name} + + + ${comment.author_name} + +
    + + ${comment.created.strftime('%B %d, %Y at %I:%M %p')} + + + +     + + Delete comment + + +

    + +
    +
  2. +
+
+
+ +
+
+ + + diff --git a/quoins/templates/index.html b/quoins/templates/index.html new file mode 100644 index 0000000..0935c20 --- /dev/null +++ b/quoins/templates/index.html @@ -0,0 +1,71 @@ + + + + + + + + + ${blog.title} + + + + + +
+
+ +
+
+

+ ${post.title} +

+ +
+ +
+ +
+ + ${len(post.approved_comments)} + comment + comments + + +
+ +
+
+ Tags: + + ${tag.name} +
+
+ +
+ + +
+
+ + + diff --git a/quoins/templates/master.html b/quoins/templates/master.html new file mode 100644 index 0000000..cf82a1f --- /dev/null +++ b/quoins/templates/master.html @@ -0,0 +1,24 @@ + + + + + Your title goes here + + + + + +
+ +
+ +
+ +
+ + + diff --git a/quoins/templates/new_comment.html b/quoins/templates/new_comment.html new file mode 100644 index 0000000..1c1ae9d --- /dev/null +++ b/quoins/templates/new_comment.html @@ -0,0 +1,44 @@ + + + + + + + + ${blog.title} + + + + +
+
+ +
+
+

${post.title} +

+ +
+ +
+
+ +
+ +
+ + ${form(defaults, action=action)} + +
+
+ + + diff --git a/quoins/templates/new_post.html b/quoins/templates/new_post.html new file mode 100644 index 0000000..0f38fd8 --- /dev/null +++ b/quoins/templates/new_post.html @@ -0,0 +1,46 @@ + + + + + + + + ${blog.title} + + + + +
+
+
+

This post is published.

+

This post is a draft and is not yet published.

+
+ + ${form(defaults, action=quoins.url('/save_post'))} + +
+

Images

+ +
+ +
+ +
+ + + diff --git a/quoins/templates/post.html b/quoins/templates/post.html new file mode 100644 index 0000000..72ee748 --- /dev/null +++ b/quoins/templates/post.html @@ -0,0 +1,85 @@ + + + + + + + + ${blog.title} + + + + + + +
+
+ +
+
+

${post.title} +

+ +
+ +
+
+ +
+ +
+ +

${len(post.comments_and_links)} Comments

+ +
    +
  1. +

    + + + [${i + 1}] + + +   + + ${comment.author_name} + + + ${comment.author_name} + +
    + + ${comment.created.strftime('%B %d, %Y at %I:%M %p')} + + + +     + + Delete comment + + +

    + +
    +
  2. +
+
+
+ + Comment on this post + +
+
+ + + diff --git a/quoins/templates/save_post.html b/quoins/templates/save_post.html new file mode 100644 index 0000000..d28c16b --- /dev/null +++ b/quoins/templates/save_post.html @@ -0,0 +1,79 @@ + + + + + + + + ${blog.title} + + + + +
+
+ +

+The following links were found in your post. If you would like to send +TrackBacks or PingBacks to any of these links, make the appropriate +selections below. If any TrackBack URLs you wish to use are not listed, +enter one per line in the box below. +

+

+No links were found in your post. If you would like to send +TrackBacks for this post, enter one per line in the box below. +

+ +
+ +
+${title} +
    +
  • + +
  • +
  • + +
  • +
  • + No TrackBack or PingBack addresses found for this URL +
  • +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + + diff --git a/quoins/templates/sitetemplate.html b/quoins/templates/sitetemplate.html new file mode 100644 index 0000000..39f4053 --- /dev/null +++ b/quoins/templates/sitetemplate.html @@ -0,0 +1,18 @@ + + + + + Your title goes here + + + + + +
+
+
+ + diff --git a/quoins/templates/unapproved_comments.html b/quoins/templates/unapproved_comments.html new file mode 100644 index 0000000..3a17c0e --- /dev/null +++ b/quoins/templates/unapproved_comments.html @@ -0,0 +1,78 @@ + + + + + + + + ${blog.title} + + + + +
+
+ +
+ +
+
+ +

${len(comments)} unapproved + comment + comments +

+ +
    +
  1. +

    Post: + ${comment.post.title} +
    + + + +

    +

    + + + [${i + 1}] + + +   + + ${comment.name} + + + ${comment.name} + +
    + + ${comment.created.strftime('%B %d, %Y at %I:%M %p')} + +

    + +
    +
  2. +
+ +
+
+
+ +
+
+ + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ca3cb4f --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +try: + from setuptools import setup, find_packages +except ImportError: + from ez_setup import use_setuptools + use_setuptools() + from setuptools import setup, find_packages + +setup( + name='quoins', + version='2.0', + description='', + author='', + author_email='', + #url='', + install_requires=[ + "TurboGears2 >= 2.0b7", + "Catwalk >= 2.0.2", + "Babel >=0.9.4", + #can be removed iif use_toscawidgets = False + "toscawidgets >= 0.9.7.1", + "zope.sqlalchemy >= 0.4 ", + "repoze.tm2 >= 1.0a4", + "tw.tinymce >= 0.8", + "python-openid >= 2.2.4", + "MySQL-python >= 1.2.3c1", + "repoze.what-quickstart >= 1.0", + ], + setup_requires=["PasteScript >= 1.7"], + paster_plugins=['PasteScript', 'Pylons', 'TurboGears2', 'tg.devtools'], + packages=find_packages(exclude=['ez_setup']), + include_package_data=True, + test_suite='nose.collector', + tests_require=['WebTest', 'BeautifulSoup'], + package_data={'quoins': ['i18n/*/LC_MESSAGES/*.mo', + 'templates/*/*', + 'public/*/*']}, + message_extractors={'quoins': [ + ('**.py', 'python', None), + ('templates/**.mako', 'mako', None), + ('templates/**.html', 'genshi', None), + ('public/**', 'ignore', None)]}, + + entry_points=""" + [paste.paster_command] + openid = quoins.command:OpenIDCommand + blog = quoins.command:BlogCommand + """, +) -- cgit v1.2.3