Half-Penny For Your Thoughts

rounded down to the nearest cent



Categories


Recent Articles




Wistle

Wistle Part 4: Multi-Site Subversion Models

Multi Site models

Ah, point #4, multiple sites hosted on one Wistle instance. I'm not going to create additional "library" functionality to support this. After all, this is getting pretty application-specific. But, I am going to take advantage of the existing Wistle library.

The key point here is going to be a Site model, that: a) Articles belong to; and b) takes over storing per-site configuration. In essence, it will replace Wistle::Config. The key is that "site-wide" configuration will subsitute for model-wide configuration. So, let's start with the Site model.

class Site
  include DataMapper::Resource

  has n, :articles

  property :id, Integer, :serial => true
  property :name, String, :unique => true, :nullable => false
  property :domain_regex, String

  # Subversion
  property :contents_uri, Text
  property :contents_revision, Integer, :default => 0
  property :username, String
  property :password, String
  property :property_prefix, String, :default => "ws:"
  property :extension, String, :default => "txt"

  # Content Filters
  property :article_filter, String
  property :comment_filter, String

  # Timestamps
  property :created_at, DateTime
  property :updated_at, DateTime
end

Here's the properties (and has n, :articles). Note that the properties under the "Subversion" heading are pretty close to the instance variables of Svn::Config. Also, notice contents_uri and contents_revision. These match with Config's uri and revision. Why the prefix? Because I want to able to use a different uri (possibly in another repo) for views and public files. But that is for the next section. I could set up username, etc this same way; I won't for now, because I have no use for doing so. If I were, however, I would probably create yet another model, called "Config" or something that belongs to a Site, with a role property. Like I said, it's not needed for now, so I won't bother.

The contents_* fields could create a problem though, because Wistle::SvnSync expects different names. A simple solution is some (not-quite) aliasing:

class Site
  def uri
    @contents_uri
  end

  def revision
    @contents_revision
  end

  def revision=(rev)
    attribute_set(:contents_revision, rev)
  end

  def body_property
    :body
  end
end

The revision= is also used by Wistle::SvnSync, and body_property is another configuration option that SvnSync expects. With body_property, there's only one option, at least so long as I only use the one model (Article). So, body_property always returns :body. I'll show how all this hooks into SvnSync in a moment. Before that, though, a bit about the :domain_regex property.

Wistle is not designed to be user-friendly in the traditional sense, except when the user is defined as me. For example, adding Sites, deleting Comments, etc. must, at this point, be done through a console. That's great by me, but for someone without programming experience, Wistle would probably not be a good choice. Another example is the domain regex property. It's used by Site.by_domain (below) to find a site based on a domain. Except, as it's name implies, domain_regex is a regular expression. Great for me, might be less attractive to others.

class Site
  class << self
    # Find a Site by domain regex, prefer longest match.
    def by_domain(val)
      possible = []

      # Find matching Sites
      Site.all.each do |s|
        r = Regexp.new(s.domain_regex.to_s, true)
        m = r.match(val)
        if m
          possible << [s, m[0].length] 
        end
      end

            # Sort for longest match.
      possible.sort!{ |a, b| b[1] <=> a[1] }
      possible[0] ? possible[0][0] : nil
    end
  end
end

I no longer need to include Wistle::Svn in the Article model, but I do need to add in the properties that Wistle::Svn took care of.

class Article
    # Subversion-specific properties
    property :path, String
    property :svn_created_at, DateTime
    property :svn_updated_at, DateTime
    property :svn_created_rev, String
    property :svn_updated_rev, String
    property :svn_created_by, String
    property :svn_updated_by, String
end

I also update how Filters works to deal with the *_filter properties. To utilize these properties, in Article and Comment, I change the :filter option of the body property to set :default => :site . This tells the Filters::Resource module to use the Site model to determine default filters. In Comment, I also add a method #site, because Filters may try to call this method.

class Comment
  def site
    @article.site
  end
end

Now, you may have noticed a few weird methods that didn't do much in SvnSync, partically get and new_record. Here's where they come in. To use SvnSync with the new Site model (instead of the Wistle::Model Model), a few things have to change. First, Site doesn't have a config method, pointing to a Wistle::Config object. It does, however, respond to the the same methods as a Config object. Second, when creating or getting the content, we need to scope by Site. What to do? Inherit Wistle::SvnSync and override a few key methods.

class SiteSync < Wistle::SvnSync
  def initialize(model_row)
    @model_row = model_row
    @model = Article
    @config = model_row
  end

  # Get an Article by site and path.
  def get(path)
    Article.first(:site_id => @model_row.id, :path => short_path(path))
  end

  def new_record
    @model.new(:site_id => @model_row.id)
  end
end

Awesome-sauce.

Now, just hook in Site to SiteSync and all the ugly work is done!

class Site
  def sync
    SiteSync.new(self).run
  end

  class << self
    def sync_all
      Site.all.each do |site|
        site.sync if site.contents_uri
      end
    end
  end
end

The controllers need a few updates to filter by Site (and the application view needs one for the list of recent articles, but I'm ignoring views). Application needs updates first:

class Application < Merb::Controller
  before :sync_articles
  before :choose_site

  protected

  def sync_articles
    Site.sync_all
  end

  def choose_site
    @site = Site.by_domain(request.host)
  end
end

I change the syncarticles method to use Site.sync_all. Then, I add a choosesite before filter to assign @site, using Site.by_domain (request.host is the full host name including any port number).

One other bit I want to do that might as well fall in this section is folders as categories. My approach here is definately I reflection of my personal organizations styles; in addition, the code is probably not a good solution.

Anyway, I want each top-level folder under the articles directory to represent a category; I want to be able to add additional subfolders without them creating additional categories. I also prefer to use only one category per article, with additional "categorization" through tags (which I will not be implementing in this already way too long article).

To do so, I need to add a category property, which I'll update with a before :save hook

class Article
  property :category, String
  before :save, :update_category

  def update_category
    if attribute_dirty?(:category) || @category.nil?
      attribute_set(:category, @path.split('/')[0]) if @path
    end
  end
end

I then add two new methods to Site, one to get a list of categories, the second to find published articles by category.

class Site
  def categories 
    repository.adapter.query('SELECT category FROM articles WHERE site_id = ? group by category order by category', self.id)
  end

  def published_by_category(category = nil, options = {})
    conditions = "datetime(published_at) <= datetime('now') "
    if category
      conditions << "and path like '#{category}/%' "
    end
    Article.all(options.merge(
          :conditions => [conditions + "and site_id = ?", self.id],
          :order => [:published_at.desc]))
  end
end

Now is also a nice time for some routing updates, both to take advantage of categories, and for "permalink" paths for the Articles. I'm taking advantage of Merb's support for regular expressions in routes:

Merb::Router.prepare do |r|
  r.resources :articles do | article |
    article.resources :comments
  end

  r.match('/').to(:controller => 'articles', :action =>'index')

  r.match(%r[/categories/(.*)]).to(
     :controller => 'articles', :action => 'index', :category => '[1]')

  r.match(%r[/(.*)]).to(
     :controller => 'articles', :action => 'show', :path => '[1]')
end

The articles resource remains to support comments, although it is probably not needed.

The last match is the "permalink" one, so that there's not "articles" or other prefixes in permalinks; doing this obviously depends on the particular application.

And the Articles controller gets a couple of updates to take advantage of these routes:

class Articles < Application
  # provides :xml, :yaml, :js

  def index
    @articles = @site.published_by_category(params[:category])
    display @articles
  end

  def show
    if params[:path]
      @article = Article.first(:path => params[:path], :site_id => @site.id)
    else
      @article = Article.first(:id => params[:id], :site_id => @site.id)
    end

    raise NotFound unless @article
    display @article
  end
end

Revision 73

And, next, the views...


0 Comments

Add a comment