Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial import, from 0.0 release tarballs.

  • Loading branch information...
commit 56c57dceeef12029e760ea80224d2eac3b2f4cea 0 parents
Matthew Draper matthewd authored
Showing with 3,274 additions and 0 deletions.
  1. +20 −0 LICENSE
  2. +377 −0 README
  3. +34 −0 init.rb
  4. +4 −0 lib/auto_admin.rb
  5. +306 −0 lib/auto_admin_configuration.rb
  6. +206 −0 lib/auto_admin_controller.rb
  7. +120 −0 lib/auto_admin_django_theme.rb
  8. +131 −0 lib/auto_admin_filter_sets.rb
  9. +126 −0 lib/auto_admin_helper.rb
  10. +435 −0 lib/auto_admin_simple_theme.rb
  11. +34 −0 lib/declarative_form_builder.rb
  12. +21 −0 test/README
  13. +4 −0 test/all_test.rb
  14. +27 −0 test/builder_tests.rb
  15. +211 −0 test/configuration_tests.rb
  16. +282 −0 test/functional_tests.rb
  17. +113 −0 test/helper_tests.rb
  18. +31 −0 test/label_tests.rb
  19. +52 −0 test/routing_tests.rb
  20. +74 −0 test/test_helper.rb
  21. BIN  themes/django/public/images/auto_admin/arrow-down.gif
  22. BIN  themes/django/public/images/auto_admin/arrow-up.gif
  23. BIN  themes/django/public/images/auto_admin/changelist-bg.gif
  24. BIN  themes/django/public/images/auto_admin/chooser-bg.gif
  25. BIN  themes/django/public/images/auto_admin/chooser_stacked-bg.gif
  26. BIN  themes/django/public/images/auto_admin/default-bg-reverse.gif
  27. BIN  themes/django/public/images/auto_admin/default-bg.gif
  28. BIN  themes/django/public/images/auto_admin/icon-no.gif
  29. BIN  themes/django/public/images/auto_admin/icon-yes.gif
  30. BIN  themes/django/public/images/auto_admin/icon_addlink.gif
  31. BIN  themes/django/public/images/auto_admin/icon_alert.gif
  32. BIN  themes/django/public/images/auto_admin/icon_calendar.gif
  33. BIN  themes/django/public/images/auto_admin/icon_changelink.gif
  34. BIN  themes/django/public/images/auto_admin/icon_clock.gif
  35. BIN  themes/django/public/images/auto_admin/icon_deletelink.gif
  36. BIN  themes/django/public/images/auto_admin/icon_error.gif
  37. BIN  themes/django/public/images/auto_admin/icon_searchbox.png
  38. BIN  themes/django/public/images/auto_admin/icon_success.gif
  39. BIN  themes/django/public/images/auto_admin/nav-bg-grabber.gif
  40. BIN  themes/django/public/images/auto_admin/nav-bg-reverse.gif
  41. BIN  themes/django/public/images/auto_admin/nav-bg.gif
  42. BIN  themes/django/public/images/auto_admin/selector-add.gif
  43. BIN  themes/django/public/images/auto_admin/selector-addall.gif
  44. BIN  themes/django/public/images/auto_admin/selector-remove.gif
  45. BIN  themes/django/public/images/auto_admin/selector-removeall.gif
  46. BIN  themes/django/public/images/auto_admin/selector-search.gif
  47. BIN  themes/django/public/images/auto_admin/selector_stacked-add.gif
  48. BIN  themes/django/public/images/auto_admin/selector_stacked-remove.gif
  49. BIN  themes/django/public/images/auto_admin/tool-left.gif
  50. BIN  themes/django/public/images/auto_admin/tool-left_over.gif
  51. BIN  themes/django/public/images/auto_admin/tool-right.gif
  52. BIN  themes/django/public/images/auto_admin/tool-right_over.gif
  53. BIN  themes/django/public/images/auto_admin/tooltag-add.gif
  54. BIN  themes/django/public/images/auto_admin/tooltag-add_over.gif
  55. BIN  themes/django/public/images/auto_admin/tooltag-arrowright.gif
  56. BIN  themes/django/public/images/auto_admin/tooltag-arrowright_over.gif
  57. +6 −0 themes/django/public/stylesheets/auto_admin.css
  58. +44 −0 themes/django/public/stylesheets/auto_admin_changelists.css
  59. +337 −0 themes/django/public/stylesheets/auto_admin_global.css
  60. +33 −0 themes/django/views/edit.rhtml
  61. +23 −0 themes/django/views/history.rhtml
  62. +37 −0 themes/django/views/index.rhtml
  63. +82 −0 themes/django/views/layout.rhtml
  64. +83 −0 themes/django/views/list.rhtml
  65. +21 −0 themes/django/views/login.rhtml
20 LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2006 Matthew Draper
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject
+to the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR
+ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
377 README
@@ -0,0 +1,377 @@
+Rails AutoAdmin Plugin
+
+What is it?
+
+A plugin for Ruby on Rails that automagically creates an administration
+interface, based on your models. It is heavily inspired by the Django
+[1] administration system, and the only theme currently available is
+based directly on Django's administration system. From the screenshots
+posted so far, it appears to share goals with Streamlined [2].
+
+ [1] Django: http://www.djangoproject.com/
+ [2] Streamlined: http://streamlined.relevancellc.com/
+
+
+Example?
+
+ class Customer < ActiveRecord::Base
+ belongs_to :store
+ has_many :payments, :order => 'payment_date DESC'
+
+ def name; first_name + ' ' + last_name; end
+
+ sort_by :last_name
+ search_by :first_name, :last_name
+ filter_by :active, :store
+ default_filter :active => true
+ list_columns :store, :first_name, :last_name
+
+ admin_fieldset do |b|
+ b.text_field :first_name
+ b.text_field :last_name
+ b.select :store
+ end
+ admin_child_table 'Payments', :payments do |b|
+ b.static_text :payment_date
+ b.static_text :amount
+ end
+ end
+
+ Results in:
+ http://trebex.net/~matthew/auto-admin-0.0/list.png
+ http://trebex.net/~matthew/auto-admin-0.0/edit.png
+
+
+What isn't it?
+
+Scaffolding. This is not a view generator for you to then customise.
+Either it provides the interface you want, or it doesn't. (With a
+limited, but hopefully expanding, set of exceptions.)
+
+For everyone. This is for applications that have a public interface and
+a restricted-access administrative interface. Its goal is not to
+generate views you would otherwise have to craft manually, so much as
+generating views you otherwise wouldn't bother to create. Of course, a
+neat side-effect of using this is that your boss (or your client's IT
+manager) can make simple database-level changes that would otherwise
+require a developer to use either the console or direct SQL. If you're
+trying to create an interface for all your users, this probably isn't
+for you.
+
+
+Where is it?
+
+Right now, there's just a tarball available at
+http://trebex.net/~matthew/auto-admin-0.0/auto-admin-0.0.tar.gz.
+
+I need to get a public SVN repository set up for it, and populate a
+useful Web page. Writing this has at least given me some material to
+that end.
+
+
+Is it usable?
+
+Perhaps, but probably not quite yet. It currently doesn't like editable
+sublists, for one, and it lacks a reliable set of tests... I've TDDed a
+few features, but the tests covering the rest of the functionality are
+rather sparse.
+
+I'm releasing mostly for selfish reasons: I'm hoping that publishing the
+code will shame me into fixing the nasty bits. :)
+
+Other, more pressing, time constraints are forcing this release before I
+get it cleaned up as much as I'd like (hence the lack of public SVN or a
+website). An unfortunate side-effect of this is the timing with respect
+to Streamlined's release. On that note, I'll be looking closely at
+Streamlined upon its release, probably with a view to moving any useful
+functionality I've built here into it; Justin and Stuart have far more
+Rails experience than I do, and I expect that fact will be heavily
+reflected in any comparison between this plugin and Streamlined.
+
+
+What does it assume?
+
+All objects it encounters can be usefully represented to a human as a
+string. It achieves this by adding a to_label method to Object, which
+will return the first available of (label, name, to_s, or inspect).
+
+Your access control requirements for the administration section are
+relatively "all or nothing". I intend to add simple class- and fieldset-
+level declarative permission checking soonish (whenever I start to need
+it). Access control based on querying individual objects should come at
+some point, but I don't anticipate needing that level of control any
+time soon. You can currently customise which fields are displayed (the
+field list is a block of code, after all), but will end up with empty
+fieldsets if you don't include any.
+
+If you have any access control (which I expect will pretty much always
+be the case), you must have a User constant, it must respond to one of
+(authenticate, login, or find_by_username_and_password), and that method
+must take two strings, and return nil for failure or a non-false value
+for success. It *should* return the authenticated user's model -- if the
+returned value responds to one or more of (active?, enabled?, disabled?,
+and admin?), they will be treated appropriately. The currently logged-in
+user (as returned by the authentication function) will be looked for and
+stored in session[:user], so if other parts of your site do the same,
+things will Just Work. I'm concious of the fact that storing
+ActiveRecord instances in the session is inadvisable, and will probably
+change this sometime soon.
+
+
+How do I use it?
+
+Initially (after installing the plugin, obviously), you need to add a
+few lines to the bottom of your environment.rb:
+ AutoAdmin.config do |admin|
+ # This information is used by the theme to construct a useful
+ # header; the first parameter is the full URL of the main site, the
+ # second is the displayed name of the site, and the third (optional)
+ # parameter is the title for the administration site.
+ admin.set_site_info 'http://www.example.tld/', 'example.tld'
+
+ # "Primary Objects" are those for which lists should be directly
+ # accessible from the home page.
+ admin.primary_objects = %w(actor film user)
+
+ admin.theme = :django # Optional; this is the default.
+ end
+
+Having done that, you can now (re-)start script/server, and navigate to
+http://localhost:3000/admin/. Yes, it installs its own routes. Yes,
+they're hard-coded. Yes, that needs to change... for now, just don't try
+to use /admin/ for anything else. :)
+
+To customise which fields appear in the edit and list screens, you go on
+to...
+
+
+How does it work? - Part I, Declarative UI definition
+
+The plugin adds a number of singleton methods to ActiveRecord::Base,
+which permit you to declare how the administration interface should
+behave.
+
+This set of methods, which are quite central to the utility of the
+plugin, have grown rather organically, over a period of time (as has my
+Ruby-fu). I've attempted to clear out the most glaring API
+inconsitencies, but it's still a bit of a mess. Some of the
+implementations definitely leave a bit to be desired. Cleaning this up
+is near the top of my TODO list. That said, it should all work. :)
+
+I really need to go through and write decent documentation for all the
+published methods, but for now, the following summary should at least
+act as a guide. Essentially, inside the model, you can use the following
+methods:
+ object_group(group_name)
+ # Declares which 'object group' this object belongs to, for use in
+ # the interface. Currently, this is used to group together related
+ # objects on the index page.
+ sort_by(column, reverse=false)
+ # Instructs the list view to sort on the specified column by
+ # default.
+ search_by(*columns)
+ # Add rudimentary text searching across the named columns. Note that
+ # this defines a MyModel.search(many, query, options={}) wrapper
+ # around MyModel.find(many, options).
+ filter_by(*columns)
+ # Allow filtering of the list screen by the named columns (filtering
+ # currently works for: custom, boolean, date, belongs_to, has_one,
+ # and string). Note that the last three will do rather nasty and
+ # sub-optimal queries to determine the filter options.
+ default_filter(filters)
+ # Takes a hash of (column, value) pairs, to default a filter to
+ # something other than 'All'.
+ filter_options_for(column, choices, &block)
+ # Specifies a fixed set of choices to be offered as filter options
+ # instead of automatically working it out. Choices should be a
+ # (value, label) hash. The optional block will be given each value
+ # in turn, and should return an SQL condition fragment.
+ column_labels(labels)
+ # Takes a hash of (column, label) pairs, to change the default label
+ # for a field to explicitly define the human label for a column.
+ # This label will be the default used in both list and edit views.
+ list_columns(*columns, &proc)
+ # Takes either a simple-list of column names, or a Field Definition
+ # Block (see below)
+ admin_fieldset(label='', *columns, &proc)
+ # Defines a fieldset for edit views. For simple use, you can just
+ # give it a list of columns. Once you get started, you'll want to
+ # pass a Field Definition Block, though.
+ admin_child_table(label, collection, options={}, &proc)
+ # Defines a fieldset for edit views, to show a table of items from a
+ # child collection. It uses a Field Definition Block to declare what
+ # columns should be shown. Generally, you'd want to use the
+ # static_text helper, I suspect.
+ # WARNING: This has no tests, and I'm almost certain it will break
+ # horribly if you try to use anything other than static_text.
+ admin_child_form(collection, options={}, &proc)
+ # Defines a "fieldset" for edit views, to show *several* fieldsets,
+ # each containing one object from a child collection. It uses a
+ # Field Definition Block to declare what columns should be shown.
+ # I don't think it'd be wise to use this on a large collection, but
+ # it's your application. :)
+ # WARNING: This also has no tests, and I believe it will break
+ # horribly if you try to use it at all.
+
+Field Definition Block?!?
+
+A number of the above methods provide for a block to declare what fields
+are to be shown. This is achieved by yielding a builder to the block.
+Depending on context, the mood of a theme author, and the phase of the
+moon, a given block will see several builders in its lifetime. Not all
+builders will have an active object; all will respond to the +object+
+method, though. A basic field definition block will just call a field
+helper on the builder for each field that it wishes to display. The
++auto_field+ helper (which automatically determines an appropriate field
+type based on column and association metadata) is available if you only
+want to specify the field type for some of the fields. All field helpers
+take (field_name, options={}, *other_stuff). Most just take the two
+parameters, and I'm considering deprecating the extra parameters on
+those that currently support them. Note that unlike a standard builder,
+you don't have to do anything with the return value; the theme's actual
+FormBuilder is wrapped by a DeclarativeFormBuilder, which takes care of
+that for you.
+
+In theory, there's no compelling reason you can't add complex logic to a
+field definition block, such as examining the current user, or even the
+builder's active object (though I strongly encourage you to handle nil
+permissively, at this stage). It would be unwise to vary the fields
+returned based on the object for a list view, for fairly obvious
+reasons.
+
+Available Form Helpers
+
+Simple helpers that just delegate to the ActionView's FormBuilder:
+ hidden_field, date_select, datetime_select, text_field, text_area,
+ check_box
+
++select+ and +radio_group+ operate in basically the same way; they both
+provide a method of selecting one out of several choices (ignoring
+select :multiple, that is). Note that select's list of choices, normally
+the second parameter to the select helper, has been relegated to a
+:choices entry in the options, for API consistency.
+
++static_text+ just outputs an HTML-escaped string representation of the
+field's value. It is useful both for read-only fields in forms, and as
+the primary helper in lists.
+
++auto_field+, as discussed above, will automatically select a suitable
+field helper, based on the column and association metadata. Where there
+are multiple suitable candidates, it tries to go for the more
+generally-applicable choice (for example, it favours a +select+ over a
++radio_group+ for a belongs_to association).
+
+None of the following actually work, but they're defined, waiting for me
+to come back and write them. +html_area+ will eventually use FCKeditor
+by default, and presumably the file/image fields will delegate to
+file_column.
+ html_area, hyperlink, file_field, image_field, static_image,
+ static_file, static_html
+
+
+How does it work? - Part II, Themes
+
+The theme bundled with the plugin is named 'django'; all credit for its
+excellent appearance goes to the Django project. I hope we can get a
+couple of standard themes, but they won't be coming from me...
+experience shows that I shouldn't try to make things look good. I
+believe I've successfully drawn lines in all the right places for what
+is in the plugin's core, and what's in a theme. I've already developed
+most of a second theme (which will not be released) for my employer, so
+the infrastructure is mostly proven. A more coherent HOWTO on creating
+themes (which can just be installed as seperate Rails plugins, then
+selected in environment.rb) will be forthcoming Real Soon Now, though
+this section has ended up covering most of the basics.
+
+The 30 second summary -- a theme comprises:
+ FormBuilder (subclass of AutoAdminSimpleTheme::FormBuilder), to create
+ an Edit screen (a real form)
+
+ TableBuilder (subclass of AutoAdmin::TableBuilder(FormBuilder)), to
+ create a List screen (a creative interpretation of "form", which seems
+ to map surprisingly well, for now).
+
+ FormProcessor (subclass of AutoAdminSimpleTheme::FormProcessor), which
+ implements the same set of helper methods as the FormBuilders, but
+ instead of returning HTML, its job is to perform any transformations
+ on the params hash to correspond with unusual form field
+ representations -- the base FormProcessor transforms keys referencing
+ associations to reference the underlying columns (actor -> actor_id),
+ for example. This class will often be empty, especially once I provide
+ a facility with which to inject custom field helpers (for composed_of
+ and maybe some belongs_to, mostly) into the base builder and
+ processor.
+
+ A complete set of views, including a layout, which delegate the hard
+ work to the FormBuilders.
+
+ A 'public' directory, containing any required image, javascript, and
+ stylesheet assets.
+
+ A wrapper module, AutoAdmin#{name}Theme, which is responsible for:
+ * Containing the FormBuilders and FormProcessor
+ * Returning the full filesystem path to the 'views' and 'public'
+ directories
+ * Returing any theme-specific helpers, for injection into the
+ controller
+ * Injecting any theme-specific includes for ActiveRecord::Base
+ (I've proven this to be possible, though can't think of a sane
+ reason a theme would want to do so)
+
+Extending your theme module with AutoAdmin::ThemeHelpers will help to
+keep the module fairly DRY; it provides a +helper+ method, which can be
+given a list of modules and/or a block, and directs the 'view_directory'
+and 'asset_root' methods to a directory(*subdirs) singleton method,
+which you must define -- presumably using __FILE__.
+
+NB: For good reasons that I can't remember right now, a couple of helper
+methods have APIs that don't match the standard Rails FormBuilder,
+despite matching names. The one that comes to mind is +select+ -- the
+choices have been moved into the options hash, to keep all method
+signatures of the form (field_name, options, *other_stuff).
+
+
+What's planned, but missing?
+
+The ability for the application to inject custom field types into the
+base FormBuilder and FormProcessor. The theme-specific versions of these
+classes are available so that, for example, a theme can decide how a
+date_field should be presented, and can correspondingly recover the
+values from multiple inputs... they don't map as well to an
+application's requirement for a 'currency' field. Of course, there's
+nothing stopping an application re-opening the classes and adding an
+appropriate helper method to each... there's just a bit of undesirable
+complexity involved if you want auto_field to detect and use it (which
+suggests to me that auto_field needs a bit of a rethink).
+
+A way for the application to reliably extend the AutoAdminController,
+and add appropriate views somewhere, for those occasions when you have a
+couple of screens that need to be hand-crafted, such as a statistics
+display, or a particular edit screen that needs a specialised workflow.
+Note that if you feel this constraint too much, you're probably pushing
+the plugin into a role it doesn't fit.
+
+Simple methods allowing an application to add navigation options, and
+perhaps the ability to insert Components into the "dashboard" on the
+index page?
+
+A top-level "menu", containing links to the primary object lists by
+default, that a theme can permanently display.
+
+It's probably a better idea to store the logged-in user's id, instead of
+the user object, in the session.
+
+
+Longer-term architectural considerations?
+
+After starting off defining the administration interfaces directly in
+the models (as Django does), I was strongly considering moving them all
+into an application-specific controller, that would subclass
+AutoAdminController. I haven't gotten around to doing that, and am now
+quite intruiged by the approach taken by Streamlined -- adding a new
+type of class. Any such move is primarily aimed at solving a problem I'm
+not yet sufferring, though, so for now it's just a topic to ponder.
+
+
+
+
34 init.rb
@@ -0,0 +1,34 @@
+# Copyright (c) 2006 Matthew Draper
+# Released under the MIT License. See the LICENSE file for more details.
+
+class ActionController::Routing::RouteSet
+ alias draw_without_admin draw
+ def draw_with_admin
+ draw_without_admin do |map|
+ map.connect 'admin', :controller => 'auto_admin', :action => 'index'
+ map.connect 'admin/-/:action/:id', :controller => 'auto_admin', :action => 'index',
+ :requirements => { :model => nil }
+ map.connect 'admin/asset/*path', :controller => 'auto_admin', :action => 'asset'
+
+ map.connect 'admin/:model/:action', :controller => 'auto_admin', :action => 'list',
+ :requirements => { :action => /[^0-9].*/, :id => nil }
+ map.connect 'admin/:model/:id/:action', :controller => 'auto_admin', :action => 'edit',
+ :requirements => { :id => /\d+/ }
+ yield map
+ end
+ end
+ alias draw draw_with_admin
+end
+
+class ::Object
+ def to_label
+ return label if respond_to? :label
+ return name if respond_to? :name
+ return to_s if respond_to? :to_s
+ inspect
+ end
+end
+class ::TrueClass; def to_label; 'Yes'; end; end
+class ::FalseClass; def to_label; 'No'; end; end
+class ::NilClass; def to_label; '(none)'; end; end
+
4 lib/auto_admin.rb
@@ -0,0 +1,4 @@
+
+require 'auto_admin_filter_sets'
+require 'auto_admin_configuration'
+
306 lib/auto_admin_configuration.rb
@@ -0,0 +1,306 @@
+require 'auto_admin_simple_theme'
+
+module AutoAdmin
+def self.config
+ yield AutoAdminConfiguration
+end
+module AutoAdminConfiguration
+ DefaultTheme = :django
+ def self.theme; Object.const_get("AutoAdmin#{theme_name.to_s.camelize}Theme"); end
+ def self.theme_name; @@theme ||= DefaultTheme; end
+ def self.theme=(theme_name)
+ @@theme = theme_name.to_sym
+ end
+ def self.form_processor; theme::FormProcessor; end
+ def self.form_builder; theme::FormBuilder; end
+ def self.table_builder; theme::TableBuilder; end
+ def self.view_directory; theme.view_directory; end
+ def self.asset_root; theme.asset_root; end
+ def self.helpers; theme.respond_to?( :helpers ) ? [theme.helpers].flatten : []; end
+
+ def self.set_site_info full_url, site_name, admin_site_title='Site Administration'
+ ::AutoAdminHelper.site = ::AutoAdminHelper::Site.new full_url, site_name, admin_site_title
+ end
+ def self.primary_objects; @@primary_objects ||= []; end
+ def self.primary_objects= new_value; @@primary_objects = new_value; end
+ def self.model name
+ Object.const_get( name.to_s.camelcase )
+ end
+ def self.grouped_objects
+ objects = primary_objects.uniq.map { |po| model(po) }
+ groups = objects.map { |o| o.object_group }.uniq.sort
+ groups.each do |group|
+ group_objects = objects.find_all { |o| o.object_group == group }.sort_by { |o| o.name }
+ yield group, group_objects
+ end
+ end
+ def self.append_features base
+ super
+ base.extend ClassMethods
+ end
+ module ClassMethods
+ def self.defaulted_accessor name, default_value
+ class_eval <<EVAL
+ def #{name}; @#{name} ||= (respond_to?(:default_#{name}) ? default_#{name} : nil) || #{default_value}; end
+ def #{name}= new_value; @#{name} = new_value; end
+EVAL
+ end
+ def self.array_accessor *names
+ names.each {|name| defaulted_accessor name, '[]' }
+ end
+ def self.hash_accessor *names
+ names.each {|name| defaulted_accessor name, '{}' }
+ end
+ array_accessor :columns_for_search, :columns_for_filter
+ hash_accessor :labels_for_columns, :custom_filter_defaults
+ def filter_defaults
+ f = {}
+ columns_for_filter.each { |c| f[c.to_s] = '*' }
+ custom_filter_defaults.each { |k,v| f[k.to_s] = v.to_s }
+ f
+ end
+ def default_columns_for_list
+ columns = content_columns.select {|c| c.type != :binary }.map {|c| c.name}
+ #reflect_on_all_associations.select {|a| a.macro == :belongs_to }.each do |assoc|
+ # columns << assoc.name
+ #end
+ columns
+ end
+ def sort_by column, reverse=false; @sort_column = column.to_s; @sort_reverse = reverse; end
+ def search_by *columns; extend Searchable; @columns_for_search = columns; end
+ def filter_by *columns; @columns_for_filter = ensure_columns_are_filterable!(columns); end
+ def ensure_columns_are_filterable! columns
+ columns.each do |col|
+ raise "Unable to filter #{self} on column '#{col}'" unless filter_type( col )
+ end
+ end
+ def default_filter filters; custom_filter_defaults.update filters.stringify_keys; end
+ def list_columns(*columns, &proc)
+ if block_given? || !columns.empty?
+ @list_fieldset = ListFieldset.new(self, columns, proc)
+ else
+ @list_fieldset || ListFieldset.new(self, default_columns_for_list)
+ end
+ end
+ def column_labels labels; labels_for_columns.update labels.stringify_keys; end
+ def column_label column; labels_for_columns[column.to_s] || default_column_label( column.to_s ); end
+ def default_column_label column
+ label = column.to_s.humanize
+ label = "Date #{label.downcase}" if label.gsub!(/ on$| at$/, '')
+ label
+ end
+
+ def find_column name; name &&= name.to_s.sub(/\?$/, ''); columns.find { |c| name == c.name }; end
+
+ def filter_conditions filter_hash
+ statement_parts = []
+ parameters = []
+ merged_filters = filter_defaults.stringify_keys.merge((filter_hash || {}).stringify_keys)
+ filters.each do |filter|
+ option_sql = filter.sql( merged_filters[filter.name] )
+ unless option_sql.empty?
+ statement_parts << option_sql.shift
+ parameters.push *option_sql
+ end
+ end
+ [ statement_parts.join( ' AND '), *parameters ] unless statement_parts.empty?
+ end
+
+ def filter_option_sql column_name, option_name
+ filter_instance(column_name).sql(option_name)
+ end
+
+ def filter_options_for column_name, custom_options, &block
+ (@custom_filter_options ||= {})[column_name.to_sym] = AutoAdmin::CustomFilterSet.new( column_name, custom_options, &block )
+ end
+ def filters
+ columns_for_filter.map { |col| filter_instance( col ) }
+ end
+ def filter_instance column_name
+ column_name = column_name.to_sym
+ return @custom_filter_options[column_name] if @custom_filter_options && @custom_filter_options.has_key?( column_name )
+
+ klass = case type = filter_type( column_name )
+ when :belongs_to: AutoAdmin::AssociationFilterSet
+ when :has_one: AutoAdmin::AssociationFilterSet
+ when :datetime: AutoAdmin::DateFilterSet
+ else
+ const = type.to_s.camelcase + 'FilterSet'
+ if AutoAdmin.const_defined?( const )
+ AutoAdmin.const_get( const )
+ else
+ AutoAdmin::EmptyFilterSet
+ end
+ end
+
+ klass.new( self, reflect_on_association( column_name ) || find_column( column_name ) ) {|col| find_column(col) }
+ end
+ def filter_type column_name
+ column_name = column_name.to_sym
+ return :custom if @custom_filter_options && @custom_filter_options.has_key?( column_name )
+
+ column = find_column( column_name )
+ assoc = reflect_on_association( column_name )
+ return ( assoc && assoc.macro ) || ( column && column.type )
+ end
+
+ def searchable?; respond_to? :append_search_condition!; end
+ def sortable_by? column; find_column column; end
+ def default_sort_info; find_column( 'name' ) && { :column => 'name', :reverse => false }; end
+ def sort_column; defined?( @sort_column ) ? @sort_column : (default_sort_info[:column] rescue nil); end
+ def sort_reverse; defined?( @sort_column ) ? @sort_reverse : (default_sort_info[:reverse] rescue nil); end
+
+ array_accessor :admin_fieldsets
+ def default_admin_fieldsets
+ [InputFieldset.new( self, '', default_columns_for_edit )]
+ end
+ def active_admin_fieldsets
+ sets = admin_fieldsets
+ sets = default_admin_fieldsets + sets unless sets.find {|s| s.fieldset_type == :input }
+ sets
+ end
+ def default_columns_for_edit
+ columns = content_columns.map {|c| c.name}
+ reflect_on_all_associations.select {|a| [:belongs_to, :has_and_belongs_to_many].include?(a.macro) }.each do |assoc|
+ columns << assoc.name.to_s
+ end
+ columns
+ end
+ def admin_fieldset label='', *columns, &proc
+ set = InputFieldset.new( self, label, columns.map {|c| c.to_s }, proc )
+ (@admin_fieldsets ||= []) << set
+ end
+ def admin_child_table label, collection, options={}, &proc
+ (@admin_fieldsets ||= []) << TableFieldset.new( self, label, collection, proc, options )
+ end
+ def admin_child_form collection, options={}, &proc
+ (@admin_fieldsets ||= []) << ChildInputFieldset.new( self, collection, proc, options )
+ end
+
+ def object_group new_group=nil
+ @object_group = new_group if new_group
+ @object_group || ''
+ end
+
+ class ListFieldset
+ attr_accessor :object, :fields, :options, :proc
+ def initialize object, fields=[], proc=nil
+ @options = fields.last.is_a?(Hash) ? fields.pop : {}
+ @object, @fields, @proc = object, fields, proc
+ end
+ def build builder
+ builder.fieldset( :table ) do
+ fields.each {|f| builder.static_text f } if fields
+ proc.call( builder ) if proc
+ end
+ end
+ end
+ class InputFieldset
+ attr_accessor :object, :name, :fields, :options, :proc
+ def initialize object, name, fields=[], proc=nil
+ @options = fields.last.is_a?(Hash) ? fields.pop : {}
+ @object, @name, @fields, @proc = object, name, fields, proc
+ end
+ def build builder
+ builder.fieldset( :fields, name != '' ? name : nil ) do
+ fields.each {|f| builder.auto_field f } if fields
+ proc.call( builder ) if proc
+ end
+ end
+ def fieldset_type; :input; end
+ end
+ class ChildInputFieldset
+ attr_accessor :object, :field, :proc, :options
+ DEFAULT_CHILD_OPTIONS = { :read_only => true }.freeze
+ def initialize object, field, proc, options
+ @object, @field, @proc, @options = object, field, proc, DEFAULT_CHILD_OPTIONS.merge(options)
+ end
+ def build_object(builder, obj, idx, caption)
+ builder.inner_fields_for( field.to_s + '_' + idx, obj ) do |inner|
+ inner.fieldset( :fields, caption ) do
+ yield inner if block_given?
+ proc.call( inner ) if proc
+ end
+ end
+ end
+ def build builder, children=nil
+ children ||= builder.object.send( field )
+ idx = -1
+ children.each_with_index do |row, idx|
+ build_object(builder, row, idx, row.to_label) do |inner|
+ inner.hidden_field :id
+ end
+ end
+ if children.respond_to? :build
+ 1.upto blank_records do |n|
+ idx += 1
+ build_object(builder, children.build, idx,
+ "#{row.class.name.underscore.humanize.downcase} ##{n}")
+ end
+ end
+ end
+ def fieldset_type; :child_input; end
+ def blank_records; options[:read_only] ? 0 : options[:blank_records] || 3; end
+ end
+ class TableFieldset < ChildInputFieldset
+ attr_accessor :name
+ def initialize object, name, field, proc, options
+ @name = name
+ super object, field, proc, options
+ end
+ def fieldset_type; :tabular; end
+
+ def build_object(builder, obj, idx, caption)
+ builder.with_object(obj) do
+ builder.fieldset( :fields, caption ) do
+ yield builder if block_given?
+ proc.call( builder ) if proc
+ end
+ end
+ end
+ def build builder
+ children = builder.object.send( field )
+ builder.fieldset( :table, name ) do
+ model = object.reflect_on_association( field ).klass
+ builder.table_fields_for( field, nil, :model => model ) do |inner|
+ inner.outer do
+ inner.prologue do
+ build_object(inner, nil, nil, nil)
+ end
+ super inner, children
+ inner.epilogue do
+ end
+ end
+ end
+ end
+ end
+ end
+
+ module Searchable
+ def append_search_condition!(query, options={})
+ unless query.empty?
+ conditions = options[:conditions] || []
+ conditions = [conditions] unless conditions.is_a? Array
+ new_condition = '(' + columns_for_search.map { |col| "#{col} LIKE ?" }.join( ' OR ' ) + ')'
+ if conditions.size > 0
+ conditions[0] = "(#{conditions[0]}) AND (#{new_condition})"
+ else
+ conditions[0] = new_condition
+ end
+ conditions.push *( [ "%#{query}%" ] * columns_for_search.size )
+ options[:conditions] = conditions
+ end
+ options
+ end
+ def search many, query, options={}
+ options = options.dup
+ append_search_condition! query, options
+ find many, options
+ end
+ end
+ end
+ ::ActiveRecord::Base.send :include, self
+end
+end
+
+
206 lib/auto_admin_controller.rb
@@ -0,0 +1,206 @@
+
+class AutoAdminController < ActionController::Base
+ include AutoAdminHelper
+ def self.template_root
+ AutoAdmin::AutoAdminConfiguration.view_directory
+ end
+ def template_layout
+ './layout'
+ end
+ # Just the action name, thanks; we use our custom template_root to
+ # handle the rest.
+ def default_template_name(action_name = self.action_name)
+ super.split('/').last
+ end
+ layout :template_layout
+
+ # TODO: Write out a form containing the entire contents of params
+ # (except the bits that go in the URL), with a message of "Please
+ # click 'OK' to save your changes to 'Foo'", determined via a case
+ # statement over the requested action
+ verify :method => :post, :only => %w( save delete )
+ #, :redirect_to => { :action => 'confirm_post' }
+ helper AutoAdmin::AutoAdminConfiguration.helpers
+
+ #model :user
+ before_filter :require_valid_user, :except => [ :login, :asset ]
+ def require_valid_user
+ return unless has_user?
+
+ valid_user = false
+ if session[:user]
+ if permit_user_to_access_admin( session[:user] )
+ valid_user = true
+ else
+ flash[:warning] = 'Not permitted to access administration interface'
+ end
+ end
+ redirect_to :action => 'login', :model => nil unless valid_user
+ end
+ def permit_user_to_access_admin user
+ user &&
+ (!user.respond_to?( :active? ) || user.active?) &&
+ (!user.respond_to?( :enabled? ) || user.enabled?) &&
+ (!user.respond_to?( :disabled? ) || !user.disabled?) &&
+ (!user.respond_to?( :admin? ) || user.admin?)
+ end
+ private :permit_user_to_access_admin
+
+ def index
+ @no_crumbs = true
+ @history_items = AdminHistory.find( :all, :conditions => ['user_id = ?', user.id], :order => 'created_at DESC', :limit => 10 ) if has_history?
+ end
+ def login
+ if request.post?
+ auth_method = [ :authenticate, :login, :find_by_username_and_password ].detect {|m| User.respond_to? m }
+ if auth_method && session[:user] = User.send( auth_method, params[:username], params[:password] )
+ redirect_to :action => 'index'
+ end
+
+ flash.now[:warning] = "Invalid username or password"
+ end
+ @no_crumbs = true
+ end
+ def logout
+ session[:user] = nil
+ redirect_to :action => 'index'
+ end
+
+ class AssociationCollector
+ attr_reader :model, :associations
+ def initialize(model)
+ @model, @associations = model, []
+ end
+ def method_missing method, field=nil, options={}
+ associations << field if field && model.reflect_on_association( field )
+ yield if block_given?
+ end
+ end
+ def collect_associations_for_model
+ collector = AssociationCollector.new(model)
+ model.list_columns.build collector
+ collector.associations
+ end
+ private :collect_associations_for_model
+ def list
+ params[:filter] ||= {}
+ params[:filter] = model.filter_defaults.merge(params[:filter])
+ conditions = model.filter_conditions( params[:filter] )
+ unless sort_column = model.find_column( params[:sort] )
+ sort_column = model.find_column( params[:sort] = model.sort_column )
+ params[:sort_reverse] = model.sort_reverse
+ end
+ params[:sort_reverse] ||= false
+ order = sort_column && "#{sort_column.name} #{params[:sort_reverse] ? 'DESC' : 'ASC'}"
+ options = { :conditions => conditions, :order => order }
+ options[:include] = collect_associations_for_model
+ if params[:search] && model.searchable?
+ model.append_search_condition! params[:search], options
+ end
+ options.update( :per_page => (params[:per_page] || 20).to_i, :singular_name => params[:model] )
+ @pages, @objects = paginate(params[:model], options)
+ session[:admin_list_params] ||= {}
+ session[:admin_list_params][params[:model]] = params
+ end
+
+ def save
+ model.transaction do
+ @object = params[:id] ? model.find( params[:id] ) : model.new
+
+ # Use the active theme's FormProcessor to perform any required
+ # translations within the parameter hash
+ processor = AutoAdmin::AutoAdminConfiguration.form_processor.new( @object, params[:model], model, self, params[params[:model]] )
+ model.active_admin_fieldsets.each do |set|
+ set.build processor
+ end
+
+ # Save attributes on the primary object
+ unless @object.update_attributes( params[params[:model]] ) && @object.valid?
+ render :action => 'edit' and return
+ end
+
+ # Save child objects
+ model.admin_fieldsets.each do |set|
+ case set.fieldset_type
+ when :tabular, :child_input
+ children = @object.send( set.field )
+ child_class = children.build.class
+ child_params = params[params[:model].to_s + '_' + set.field.to_s]
+ child_params.each do |child_index, child_info|
+ o = child_info[:id] ? child_class.find( child_info[:id] ) : children.build
+ child_info.delete :id
+ if set.field_options.all? {|k,v| v[:required] ? child_info[k].blank? : true }
+ o.destroy
+ else
+ unless o.update_attributes child_info
+ render :action => 'edit' and return
+ end
+ end
+ end if child_params
+ end
+ end
+
+ if params[:id]
+ flash[:notice] = "The #{human_model.downcase} \"#{@object.to_label}\" was changed successfully. "
+ else
+ flash[:notice] = "The #{human_model.downcase} \"#{@object.to_label}\" was added successfully. "
+ end
+
+ if has_history?
+ history = { :user_id => session[:user].id, :object_label => @object.to_label, :model => params[:model], :obj_id => @object.id }
+ if params[:id]
+ history.update :change => 'edit', :description => 'Record modified'
+ else
+ history.update :change => 'add', :description => 'Record created'
+ end
+ AdminHistory.new( history ).save
+ end
+ end
+
+ if params[:_continue]
+ flash[:notice] << "You may edit it again below."
+ redirect_to :action => 'edit', :model => params[:model], :id => @object
+ elsif params[:_addanother]
+ flash[:notice] << "You may add another #{human_model.downcase} below."
+ redirect_to :action => 'edit', :model => params[:model]
+ else
+ redirect_to list_page_for_current
+ end
+ end
+ def edit
+ @object = params[:id] ? model.find( params[:id] ) : model.new
+ end
+
+ def history
+ @object = params[:id] ? model.find( params[:id] ) : model.new
+ @histories = AdminHistory.find :all, :conditions => ['model = ? AND obj_id = ?', params[:model], params[:id]], :order => 'admin_histories.created_at DESC', :limit => 50, :include => [:user]
+ end
+
+ # FIXME: Force use of POST, showing a confirmation page on GET. Isn't
+ # there a plugin that does that? I can't find it on the Wiki atm...
+ def delete
+ object = model.find( params[:id] )
+ label = @object.to_label
+ hist = AdminHistory.new( :user_id => session[:user].id, :object_label => @object.to_label, :model => params[:model], :obj_id => params[:id], :change => 'delete', :description => 'Record deleted' ) if has_history?
+ object.destroy
+ flash[:notice] = "The #{human_model.downcase} \"#{object.to_label}\" was deleted successfully."
+ hist.save! if hist
+ redirect_to list_page_for_current
+ end
+
+ def asset
+ mime_type = case params[:path].last
+ when /\.css$/; 'text/css'
+ when /\.gif$/; 'image/gif'
+ when /\.png$/; 'image/png'
+ else; 'text/plain'
+ end
+
+ roots = [ File.join(File.dirname(File.dirname(__FILE__)), 'public') ]
+ roots.unshift AutoAdmin::AutoAdminConfiguration.asset_root
+
+ filename = roots.map {|dir| File.join( dir, params[:path] ) }.detect {|file| File.exist?( file ) }
+ render :text => File.read(filename), :content_type => mime_type
+ end
+end
+
120 lib/auto_admin_django_theme.rb
@@ -0,0 +1,120 @@
+module AutoAdminDjangoTheme
+ extend AutoAdmin::ThemeHelpers
+ def self.directory(*subdirs)
+ File.join(File.dirname(File.dirname(__FILE__)), 'themes', 'django', *subdirs)
+ end
+
+ helper do
+ def history_link record
+ link = "(Unnamed #{human_model(record.model).downcase})"
+ link = record.object_label unless record.object_label.blank?
+ link = link_to h(link), :model => record.model, :action => 'edit', :id => record.obj_id unless record.change == 'delete'
+ link
+ end
+ def history_link_class record
+ case record.change
+ when 'add'; 'addlink'
+ when 'delete'; 'deletelink'
+ else 'changelink'
+ end
+ end
+ end
+
+ class FormProcessor < AutoAdminSimpleTheme::FormProcessor
+ end
+ class FormBuilder < AutoAdminSimpleTheme::FormBuilder
+ def fieldset_class(style)
+ case style
+ when :fields then 'module aligned'
+ when :table then 'module'
+ end
+ end
+ def wrap_field(field_type, field_name, options)
+ options[:class] = options[:class] ? options[:class].dup : ''
+ column = model.find_column( field_name )
+ assoc = model.reflect_on_association( field_name.to_sym )
+ column_type = ( assoc && assoc.macro ) || ( column && column.type )
+ case field_type
+ when :text_field
+ case column_type
+ when :string
+ options[:class] << ' vTextField'
+ options[:size] ||= 30
+ options[:maxlength] ||= column.limit
+ when :integer
+ options[:class] << ' vIntegerField'
+ options[:size] ||= 10
+ when :text
+ options[:class] << ' vTextField'
+ options[:size] ||= 50
+ end
+ when :text_area
+ options[:class] << ' vLargeTextField'
+ when :check_box
+ options[:class] << ' vCheckboxField'
+ when :date_select
+ options[:class] << ' vDateField'
+ when :datetime_select
+ options[:class] << ' vTimeField'
+ when :select
+ options[:class] << ' vSelectMultipleField' if options[:multiple]
+ end
+#.vFileUploadField { border:none; }
+#.vURLField { width:380px; }
+#.vLargeTextField, .vXMLLargeTextField { width:480px; }
+ options[:class].strip!
+
+ inner = super
+ inner << %(<p class="help">#{h options[:caption]}</p>) if options[:caption]
+
+ if field_invalid? field_name
+ %(<div class="form-row errors"><ul class="errorlist">) +
+ field_errors( field_name ).map {|msg|
+ %(<li>This field #{h msg}</li>)
+ }.join +
+ %(</ul>#{inner}</div>)
+ else
+ %(<div class="form-row">#{inner}</div>)
+ end
+ end
+ end
+ class TableBuilder < AutoAdmin::TableBuilder(FormBuilder)
+ def table_header(field_type, field_name, options)
+ klass = ''
+ caption = yield + ' '
+
+ if model.sortable_by? field_name
+ sorting = @template.params[option(:sort_key)] == field_name.to_s
+ sorted_reverse = sorting && @template.params["#{option(:sort_key)}_reverse".to_sym]
+ link_will_reverse = sorting && !sorted_reverse
+
+ klass = 'sorted ' + (sorted_reverse ? 'descending' : 'ascending') if sorting
+ caption = link_to caption, @template.similar_list_page( option(:sort_key) => field_name, "#{option(:sort_key)}_reverse".to_sym => link_will_reverse )
+ end
+
+ %(<th class="#{klass}">#{caption}</th>)
+ end
+ def outer; %(<table cellspacing="0">); end
+ def fieldset(style, title=nil)
+ @first = true
+ super
+ end
+ def table_cell(field_type, field_name, options)
+ column = model.find_column(field_name)
+ assoc = model.reflect_on_association(field_name.to_sym)
+ if !assoc && !column
+ raise [self,@object,model,field_type,field_name,options].inspect
+ end
+ klass = assoc ? assoc.klass.name.underscore.to_s : column.type.to_s
+
+ was_first, @first = @first, false
+ if was_first
+ link = link_to( yield, :action => 'edit', :model => model_name, :id => @object )
+ %(<th class="#{klass}">#{link}</th>)
+ else
+ %(<td class="#{klass}">#{yield}</td>)
+ end
+ end
+ end
+end
+
131 lib/auto_admin_filter_sets.rb
@@ -0,0 +1,131 @@
+
+module AutoAdmin
+ class SimpleFilterSet
+ attr_reader :klass, :column
+ def initialize klass, column
+ @klass, @column = klass, column
+ end
+ def name
+ column.name
+ end
+ def all_option
+ { :name => '*', :label => all_label }
+ end
+ def all_label; 'All'; end
+ def option option_name
+ return all_option if option_name == '*'
+ other_option( option_name ) || {}
+ end
+ def other_option option_name
+ other_options.find {|o| o[:name] == option_name }
+ end
+ def options
+ [ all_option ] + other_options
+ end
+ def sql option_name
+ return [] if option_name == '*'
+ sql_from_string option_name
+ end
+ def sql_from_string option_name
+ sql_from_value option_name
+ end
+ def sql_from_value option_name
+ ["#{column.name} = ?", option_name]
+ end
+ def build_option name, label
+ { :name => name.to_s, :label => label, :sql => sql_from_value( name ) }
+ end
+ end
+ class CustomFilterSet < SimpleFilterSet
+ def initialize klass, column, options, &block
+ super klass, column
+ @options, @block = options, block
+ end
+ def other_options
+ a = []
+ @options.each do |k,v|
+ o = build_option( k, v )
+ o[:sql] = block.call( k ) unless block.nil?
+ a << o
+ end
+ a.sort_by {|i| i[:label] }
+ end
+ end
+ class EmptyFilterSet < SimpleFilterSet
+ def other_options; []; end
+ end
+ class DynamicFilterSet < SimpleFilterSet
+ def other_option option_name
+ option_from_object( object_from_option_name( option_name ) )
+ end
+ def other_options
+ objects.map { |o| option_from_object( o ) }
+ end
+ end
+ class StringFilterSet < DynamicFilterSet
+ def objects
+ # FIXME: This really needn't load objects for every row in the
+ # DB...
+ klass.find(:all).map {|o| o.send column.name }.sort.uniq
+ end
+ def object_from_option_name option_name
+ option_name
+ end
+ def option_from_object obj
+ build_option obj, obj
+ end
+ end
+ class AssociationFilterSet < DynamicFilterSet
+ attr_reader :assoc
+ def initialize klass, assoc
+ @assoc = assoc
+ column = sql_column
+ column = yield column if block_given?
+ super klass, column
+ end
+ def name
+ assoc.name.to_s
+ end
+ def sql_column
+ (assoc.options && assoc.options[:foreign_key]) || (assoc.name.to_s + '_id')
+ end
+ def objects
+ assoc.klass.find :all
+ end
+ def object_from_option_name option_name
+ assoc.klass.find option_name.to_i
+ end
+ def option_from_object obj
+ build_option obj.id, obj.to_label
+ end
+ def sql_from_string option_name
+ sql_from_value option_name.to_i
+ end
+ end
+ class BooleanFilterSet < SimpleFilterSet
+ def other_options
+ [ build_option( true, 'Yes' ), build_option( false, 'No' ) ]
+ end
+ def sql_from_string option_name
+ sql_from_value( option_name == 'true' )
+ end
+ end
+ class DateFilterSet < SimpleFilterSet
+ def all_label; 'Any date'; end
+ def other_options
+ [ build_option( 'today', 'Today' ),
+ build_option( 'week', 'Past 7 days' ),
+ build_option( 'month', 'This month' ),
+ build_option( 'year', 'This year' ) ]
+ end
+ def sql_from_value option_name
+ {
+ 'today' => ["#{column.name} BETWEEN ? AND ?", Time.now.midnight, Time.now.tomorrow.midnight],
+ 'week' => ["#{column.name} BETWEEN ? AND ?", 7.days.ago.midnight, Time.now.tomorrow.midnight],
+ 'month' => ["#{column.name} BETWEEN ? AND ?", Time.now.beginning_of_month, Time.now.next_month.beginning_of_month],
+ 'year' => ["#{column.name} BETWEEN ? AND ?", Time.now.beginning_of_year, Time.now.next_year.beginning_of_year]
+ }[option_name]
+ end
+ end
+end
+
126 lib/auto_admin_helper.rb
@@ -0,0 +1,126 @@
+module AutoAdminHelper
+ def model name=nil
+ AutoAdmin::AutoAdminConfiguration.model( name || params[:model] )
+ end
+
+ # We can't just use const_defined?, because we want to give Rails a
+ # chance to auto-load it.
+ def has_history?
+ AdminHistory rescue nil
+ end
+ def has_user?
+ User rescue nil
+ end
+
+
+ class Site
+ attr_accessor :url, :short_url, :name
+ def initialize *args; @url, @short_url, @name = *args; end
+ end
+ def site
+ AutoAdminHelper.site
+ end
+ class << self
+ attr_accessor :site
+ end
+ def user
+ session[:user]
+ end
+ def human_model name=nil, pluralize=false
+ s = model(name).name
+ s = s.pluralize if pluralize && pluralize != 1
+ s.humanize
+ end
+
+
+
+
+
+
+ def value_html_for object, field, explicit_none=false, return_klass=false
+ value = object.send(field)
+ value = value.to_label if value.is_a? ActiveRecord::Base
+ column = model.find_column(field)
+ assoc = model.reflect_on_association(field)
+ klass = assoc ? assoc.klass.name.underscore.to_s : column.type.to_s
+ cell_content = case column && column.type
+ when :boolean
+ value = value ? 'Yes' : 'No'
+ image_tag url_for( :escape => false, :action => :asset, :path => "images/auto_admin/icon-#{value.downcase}.gif" ), :alt => value, :title => value
+ else
+ h value
+ end
+ if value.nil? || value == ''
+ cell_content = explicit_none ? '(none)' : ''
+ klass << ' none'
+ end
+ ret = [ cell_content ]
+ ret << klass if return_klass
+ return *ret
+ end
+
+ def list_page_for_current
+ list_page_for params[:model]
+ end
+ def list_page_for model
+ param_hash_to_link_hash( (session[:admin_list_params] || {})[model] || {} ).merge( :action => 'list', :model => model )
+ end
+ def current_list_page
+ param_hash_to_link_hash params
+ end
+ def current_list_page_as_fields *skip_keys
+ link_hash_to_hidden_fields current_list_page, skip_keys + [:controller, :action, :model, :id]
+ end
+ def similar_list_page options_changed
+ param_hash_to_link_hash params.merge( options_changed )
+ end
+ def similar_list_page_with_filter column, option
+ filter_hash = (params[:filter] || {}).dup
+ filter_hash[column] = option
+ filter_hash.delete column unless option
+ similar_list_page :filter => filter_hash
+ end
+ def link_hash_to_hidden_fields hash, skip_keys=[]
+ hash.reject {|k,v| skip_keys.include? k.to_sym }.to_a.map {|pair| hidden_field_tag pair[0], pair[1] }.join( "\n" )
+ end
+ def param_hash_to_link_hash hash
+ hash = hash.dup
+ if klass = hash[:model] && model( hash[:model] )
+ if hash[:filter]
+ f = hash[:filter].dup
+ defaults = klass.filter_defaults
+ f.delete_if {|k,v| defaults[k] == v }
+ hash[:filter] = f
+ end
+ if hash[:sort].to_s == klass.sort_column.to_s && hash[:sort_reverse] == klass.sort_reverse
+ hash.delete :sort
+ hash.delete :sort_reverse
+ end
+ end
+ hash.stringify_keys!
+ # FIXME: This should handle deeper hash hierarchies, and arrays.
+ hash.select {|k,v| v.is_a? Hash }.each do |k,v|
+ hash.delete k
+ v.each { |k2,v2| hash["#{k}[#{k2}]"] = v2 }
+ end
+ hash
+ end
+
+
+ def admin_form_for(object_name, object, options={}, &proc)
+ opts = { :builder => DeclarativeFormBuilder,
+ :inner_builder => AutoAdmin::AutoAdminConfiguration.form_builder,
+ :table_builder => AutoAdmin::AutoAdminConfiguration.table_builder,
+ :indent => 0, :html => { :multipart => true },
+ }.update(options)
+ form_for(object_name, object, opts, &proc)
+ end
+ def admin_table(options={}, &proc)
+ opts = { :builder => DeclarativeFormBuilder,
+ :inner_builder => AutoAdmin::AutoAdminConfiguration.table_builder,
+ :indent => 0,
+ }.update(options)
+ fields_for(nil, nil, opts, &proc)
+ end
+end
+
435 lib/auto_admin_simple_theme.rb
@@ -0,0 +1,435 @@
+module AutoAdmin
+ module ThemeHelpers
+ def view_directory
+ directory 'views'
+ end
+ def asset_root
+ directory 'public'
+ end
+ def helpers
+ @helpers || []
+ end
+ def helper *helpers, &proc
+ @helpers ||= []
+ helpers.each do |helper|
+ @helpers << helper
+ end
+ if block_given?
+ @helpers << Module.new(&proc)
+ end
+ end
+ end
+
+ module TableBuilder
+ def outer; %(<table>); end
+ def prologue; @header = true; %(<thead><tr>); end
+ def end_prologue; @header = false; %(</tr></thead><tbody>); end
+ def epilogue; %(</tbody>); end
+ def end_outer; %(</table>); end
+
+ def fieldset(style, title=nil)
+ @header ? '' : %(<tr class="row#{(@alt = !@alt) ? 1 : 2}">)
+ end
+ def end_fieldset
+ @header ? '' : %(</tr>)
+ end
+
+ def table_header(field_type, field_name, options)
+ %(<th>#{yield}</th>)
+ end
+ def table_cell(field_type, field_name, options)
+ %(<td>#{yield}</td>)
+ end
+
+ def wrap_field(field_type, field_name, options, &block)
+ if @header
+ table_header( field_type, field_name, options ) do ||
+ label_text( field_name, options )
+ end
+ else
+ table_cell( field_type, field_name, options, &block )
+ end
+ end
+
+ #def self.append_features base
+ # # I don't think I actually need this cleverness...
+ # instance_methods(false).each do |meth|
+ # base.class_eval <<-end_src, __FILE__, __LINE__
+ # alias :shadow_#{meth} :#{meth}
+ # end_src
+ # end
+ # super
+ #end
+ end
+ def self.TableBuilder(form_builder)
+ klass = Class.new(form_builder)
+ klass.send :include, TableBuilder
+ klass
+ end
+
+ module AutoFieldTypeSelector
+ # TODO: We need to provide a facility for registration of automatic
+ # field handlers -- esp. for composed_of, but maybe also for some
+ # belongs_to (in both cases, based on the class, presumably)
+ def auto_field(field, read_only = false)
+ field_type = macro_type(field)
+
+ if read_only
+ static_text field
+ else
+ case field_type
+ when :belongs_to #, :has_one
+ select field
+# when :has_and_belongs_to_many #, :has_many
+# select field, :multiple => true, :size => 7
+ when :has_and_belongs_to_many, :has_many, :has_one
+ # Until I work out a better strategy, we skip these.
+ when :text
+ text_area field
+ when :string
+ text_field field
+ when :boolean
+ radio_group field
+ when :date
+ date_select field
+ when :datetime
+ datetime_select field
+ else
+ # Don't know how to handle this column type, so we'll just use a
+ # standard text field, but we'll add a (completely non-standard)
+ # HTML attribute of 'unknown', so that looking at the source
+ # will help to reveal what's going on.
+ text_field field, :unknown => field_type
+ end
+ end
+ end
+
+ end
+ module PrivateFormHelpers
+ private
+ DEFAULT_OPTIONS = { :sort_key => :sort }.freeze
+ def option(opt_name)
+ DEFAULT_OPTIONS.include?( opt_name ) ? DEFAULT_OPTIONS[opt_name] : @options[opt_name]
+ end
+ def model
+ klass = @object ? @object.class : option(:model)
+ raise ArgumentError, "Unable to locate model" unless klass
+ klass
+ end
+ def model_name
+ model.name.underscore
+ end
+
+ def find_choices(field, options)
+ column = model.find_column( field )
+ assoc = model.reflect_on_association( field.to_sym )
+ macro = ( assoc && assoc.macro ) || ( column && column.type )
+
+ # If we were given a choice set, handle it
+ choices = options.delete(:choices)
+ if macro == :boolean && choices.is_a?( Array ) &&
+ choices.size == 2 && choices.all? {|c| c.is_a? String }
+
+ # Special case this one for API simplicity
+ return [[choices.first, true], [choices.last, false]]
+ end
+ return choices.to_a if Hash === choices
+ return choices if choices.respond_to? :each
+ raise "Expected Array or Hash as :choices" unless choices.nil?
+
+ # We haven't been explicitely told what to do, so we guess the
+ # applicable choices
+ case macro
+ when :boolean
+ [['True', true], ['False', false]]
+ when :belongs_to, :has_and_belongs_to_many, :has_many, :has_one
+ assoc.klass.find(:all).map {|o| [o.to_label, o.id] }
+ end
+ end
+ def get_option(options, option_name, object, default_value)
+ result = default_value
+ if options[option_name]
+ result = options[option_name]
+ result = result.call( object ) if result.respond_to? :call
+ end
+ result
+ end
+ def common_option_translations!(options)
+ classes = (options[:class] || '').split
+ classes << 'required' if options.delete( :required )
+ options[:class] = classes.join(' ')
+
+ options[:size] ||= 7 if options[:multiple]
+ end
+ def get_column_from_field(field)
+ assoc = model.reflect_on_association( field.to_sym )
+ assoc ? assoc.primary_key_name : field
+ end
+
+ def macro_type(column_name)
+ column = model.find_column( column_name )
+ assoc = model.reflect_on_association( column_name.to_sym )
+ return ( assoc && assoc.macro ) || ( column && column.type )
+ end
+ end
+ class BaseFormBuilder < ActionView::Helpers::FormBuilder
+ include AutoFieldTypeSelector
+ include PrivateFormHelpers
+
+ def none_string(options)
+ '(none)'
+ end
+ def helpers
+ @template
+ end
+ def h(string)
+ helpers.send :h, string
+ end
+ def link_to(*a)
+ helpers.send :link_to, *a
+ end
+
+ def field_invalid?(field)
+ column = get_column_from_field(field)
+ @object.errors.invalid? column
+ end
+ def field_errors(field)
+ column = get_column_from_field(field)
+ [@object.errors[column]].flatten
+ end
+ private :none_string, :helpers, :h, :link_to, :field_invalid?,
+ :field_errors
+
+ def field_value(field)
+ @object.send( get_column_from_field( field ) ) unless @object.nil?
+ end
+ private :field_value
+
+ def inner_fields_for(inner_object_name, inner_object)
+ @template.fields_for( "#{@object_name}_#{inner_object_name}", inner_object, @template, @options ) do |i|
+ yield i
+ end
+ end
+ def table_fields_for(inner_object_name, inner_object, extra_options={}, &proc)
+ options = @options.dup
+ options[:inner_builder] = options.delete(:table_builder)
+ options[:binding] ||= @proc.binding
+ options.update extra_options
+ @template.fields_for( "#{@object_name}_#{inner_object_name}", inner_object, @template, options ) do |i|
+ yield i
+ end
+ end
+ def with_object(object)
+ previous_object, @object = @object, object
+ yield
+ @object = previous_object
+ end
+
+ def hidden_field(field, options = {})
+ common_option_translations! options
+ super
+ end
+ def date_select(field, options = {})
+ common_option_translations! options
+ super
+ end
+ def datetime_select(field, options = {})
+ common_option_translations! options
+ super
+ end
+ def text_field(field, options = {})
+ common_option_translations! options
+ super
+ end
+ def text_area(field, options = {})
+ common_option_translations! options
+ super
+ end
+ def html_area(field, options = {})
+ common_option_translations! options
+ end
+ def select(field, options = {}, html_options = {})
+ common_option_translations! options
+ dropdown_options = find_choices(field, options)
+ column = get_column_from_field(field)
+ options[:selected] = field_value( field )
+ super( field, dropdown_options, options, html_options )
+ end
+ def radio_group(field, options = {})
+ common_option_translations! options
+ choices = find_choices(field, options)
+ choices = choices.to_a if choices.is_a? Hash
+ value = field_value( field )
+ combine_radio_buttons(choices.map do |choice|
+ opts = options.dup
+ # FIXME: This should be setting :checked to true or false,
+ # which'll work on edge rails, but not 1.1.
+ opts.update( :checked => 'checked' ) if value.to_s == choice.last.to_s
+ radio_button( field, choice.last, opts ) + " " + h( choice.first )
+ end)
+ end
+ def check_box(field, options = {}, checked_value = '1', unchecked_value = '0')
+ common_option_translations! options
+ super
+ end
+ def hyperlink(field, options = {})
+ value = @object.send( field )
+ return none_string( options ) if value.nil?
+
+ caption = get_option( options, :link_text, value, h( value.to_label ) )
+ url = get_option( options, :url, value,
+ { :controller => 'auto_admin', :action => 'edit', :model => value.class.name.underscore, :id => value.id } )
+
+ link_to( caption, url )
+ end
+ def file_field(field, options = {})
+ end
+ def image_field(field, options = {})
+ end
+ def secure_password(field, options = {})
+ end
+
+ def static_image(field, options = {})
+ end
+ def static_file(field, options = {})
+ hyperlink field, options
+ end
+ def static_text(field, options = {}, &block) # :yields: object
+ h static_html(field, options, &block)
+ end
+ def static_html(field = nil, options = {}) # :yields: object
+ raise ArgumentError, "Missing block or field name" unless field || block_given?
+ v = if block_given?
+ yield @object
+ else
+ @object.send(field)
+ end
+ if v.is_a? Array
+ v.map {|o| o.to_label }.to_sentence
+ else
+ v.to_label
+ end
+ end
+
+ def self.field_helpers
+ methods = BaseFormBuilder.public_instance_methods(false) - %w(auto_field with_object inner_fields_for table_fields_for)
+ ends = methods.select {|m| m =~ /^end_/ }
+ begins = ends.map {|m| m.sub /^end_/, '' }
+ methods - begins - ends
+ end
+
+ def label_text(field_name, options)
+ options[:label] || model.column_label( field_name )
+ end
+ private :label_text
+
+ def combine_radio_buttons(items)
+ items.join ' '
+ end
+ private :combine_radio_buttons
+
+ def outer; %(); end
+ def prologue; %(); end
+ def end_prologue; %(); end
+ def epilogue; %(); end
+ def end_epilogue; %(); end
+ def end_outer; %(); end
+ end
+end
+
+module AutoAdminSimpleTheme
+ extend AutoAdmin::ThemeHelpers
+ def self.directory(*subdirs)
+ raise "Can't use 'simple' theme; it's just an abstract base for other themes."
+ end
+
+ # This FormProcessor defines all the standard field helpers; they're
+ # just calls to common_field_translations! (which calls
+ # translate_association_to_column!), because everything else about the
+ # standard field helpers is handled by the standard save action.
+ class FormProcessor
+ include AutoAdmin::AutoFieldTypeSelector
+ include AutoAdmin::PrivateFormHelpers
+
+ def table_fields_for(inner_object_name, inner_object, extra_options={}, &proc)
+ options = @options.dup
+ options.update extra_options
+ name = "#{@object_name}_#{inner_object_name}"
+ table_params = @controller.params[name]
+ yield self.class.new( inner_object, name, extra_options[:model], @controller, table_params, options ) if table_params
+ end
+ def with_object(object)
+ previous_object, @object = @object, object
+ yield
+ @object = previous_object
+ end
+
+ attr_accessor :object, :object_name, :model, :controller, :params, :options
+ def initialize(object, object_name, model, controller, params, options={})
+ @object, @object_name, @model, @controller, @params, @options =
+ object, object_name, model, controller, params, options
+ end
+
+ AutoAdmin::BaseFormBuilder.field_helpers.each do |helper|
+ class_eval <<-end_src, __FILE__, __LINE__
+ def #{helper}(field, options={}, *args, &proc)
+ common_field_translations! field
+ end
+ end_src
+ end
+
+ %w(outer prologue epilogue).each do |helper|
+ class_eval <<-end_src, __FILE__, __LINE__
+ def #{helper}; yield if block_given?; end
+ def end_#{helper}; end
+ end_src
+ end
+ def fieldset(style, title=nil); yield if block_given?; end
+ def end_fieldset; end
+
+
+ def common_field_translations!(field_name)
+ return unless params.include? field_name
+ translate_association_to_column! field_name
+ end
+ def translate_association_to_column!(field_name)
+ column = get_column_from_field( field_name )
+ return if column.to_s == field_name.to_s
+ params[column] = params.delete( field_name )
+ end
+ end
+
+ class FormBuilder < AutoAdmin::BaseFormBuilder
+ def wrap_field(field_type, field_name, options)
+ label = label_text( field_name, options )
+ klass = options[:required] ? 'required' : ''
+ %(<label class="#{klass}" for="#{@object_name}_#{field_name}">#{h label}:</label> #{yield}<br />)
+ end
+
+ def fieldset_class(style)
+ case style
+ when :fields then 'fields'
+ end
+ end
+ def fieldset(style, title=nil)
+ %(<fieldset class="#{fieldset_class style}">) +
+ (title ? %(<h2>#{h title}</h2>) : '')
+ end
+ def end_fieldset
+ %(</fieldset>)
+ end
+
+ (field_helpers - %w(static_html hidden_field)).each do |helper|
+ class_eval <<-end_src, __FILE__, __LINE__
+ def #{helper}(field, options={}, *args, &proc)
+ wrap_field #{helper.to_sym.inspect}, field, options do |*a|
+ a.empty? ? super(field, options, *args, &proc) : super(*a)
+ end
+ end
+ end_src
+ end
+ end
+ class TableBuilder < AutoAdmin::TableBuilder(FormBuilder)
+ end
+end
+
34 lib/declarative_form_builder.rb
@@ -0,0 +1,34 @@
+class DeclarativeFormBuilder
+ def initialize(object_name, object, erb_self, options, proc)
+ @erb, @binding, @options = erb_self, options[:binding] || proc.binding, options
+ @inner = (options[:inner_builder] || ActionView::Helpers::FormBuilder).new(object_name, object, erb_self, options, proc)
+ @erb.concat("\n", @binding)
+ end
+ %w(object inner_fields_for table_fields_for with_object).each do |meth|
+ class_eval <<-end_src, __FILE__, __LINE__
+ def #{meth}(*args, &block); @inner.#{meth}(*args, &block); end
+ end_src
+ end
+ def method_missing(method, *args, &block)
+ e = 'end_' + method.to_s
+ # If there's an end_ variant of the method, we'll use the block
+ # ourselves. If not, the block must be intended for the underlying
+ # method.
+ if @inner.respond_to?(e)
+ buffer! @inner.send(method, *args)
+ if block_given?
+ @options[:indent] += 1 if @options[:indent]
+ yield
+ @options[:indent] -= 1 if @options[:indent]
+ buffer! @inner.send(e)
+ end
+ else
+ buffer! @inner.send(method, *args, &block)
+ end
+ nil
+ end
+ def buffer!(content)
+ pre = @options[:indent] ? (' ' * @options[:indent]) : ''
+ @erb.concat(pre + content + "\n", @binding) if content && content != ''
+ end
+end
21 test/README
@@ -0,0 +1,21 @@
+
+As I haven't done anything clever yet, to run the tests for the
+AutoAdmin plugin, you need auto_admin_test, which is a simple Rails
+application (just a database, and matching models with assocations).
+
+ http://trebex.net/~matthew/auto-admin-0.0/auto-admin-test-0.0.tar.gz
+
+The database used is a manual Rails-ification of Pagila [1], from CVS,
+as at whenever I went looking for one. The database dump almost
+certainly contains PostgreSQL-specific stuff; I'll address that at some
+point, and quite possibly move to an SQLite database, for that matter.
+
+Once that application is set up, you need to install the plugin, and
+then run the tests from the installed plugin's directory:
+
+ ruby test/all_test.rb ../../..
+
+Suggestions to make this work better are very welcome. :)
+
+ [1] Pagila: http://pgfoundry.org/projects/dbsamples/
+
4 test/all_test.rb
@@ -0,0 +1,4 @@
+%w(functional helper configuration label routing).each do |file|
+ require File.dirname(__FILE__) + "/#{file}_tests"
+end
+
27 test/builder_tests.rb
@@ -0,0 +1,27 @@
+require File.dirname(__FILE__) + '/test_helper'
+
+class BuilderTest < Test::Unit::TestCase
+ def setup
+ reset!
+ @template = build_template
+ @ed = Actor.find(3)
+ end
+ def build_template
+ template = ''
+ class << template
+ def h(v) v; end
+ include ActionView::Helpers::FormHelper
+ end
+ template
+ end
+ private :build_template
+ def test_dummy; end
+end
+class SimpleBuilderTest < BuilderTest
+ def test_text_field
+ builder = SimpleAdminFormBuilder.new( 'my_actor', @ed, @template, {}, binding )
+ html = builder.text_field( :first_name, :required => true )
+ assert_equal %(<label class="required" for="my_actor_first_name">First name:</label> <input class="required" id="my_actor_first_name" name="my_actor[first_name]" size="30" type="text" value="ED" /><br />), html
+ end
+end
+
211 test/configuration_tests.rb
@@ -0,0 +1,211 @@
+require File.dirname(__FILE__) + '/test_helper'
+
+class ConfigurationTest < Test::Unit::TestCase
+ def setup
+ reset!
+ disallow_class_creation!
+ end
+ def test_dummy; end
+end
+class ConfigurationEditTest < ConfigurationTest
+ def test_default_columns_for_edit
+ standard_columns = Film.default_columns_for_edit
+ assoc_columns = standard_columns.slice!(-3, 3).sort
+ assert_equal %w(title description rental_duration rental_rate length replacement_cost rating), standard_columns
+ assert_equal %w(actors category features), assoc_columns
+ end
+ def test_default_fieldset
+ assert_equal 1, Film.admin_fieldsets.size
+ fieldset = Film.admin_fieldsets.first
+ assert_equal '', fieldset.name
+ #FIXME
+ #assert_equal Film.default_columns_for_edit, fieldset.fields
+ assert_equal :input, fieldset.fieldset_type
+ end
+ def test_custom_fieldset
+ Film.admin_fieldset '', :title, :description, :length
+ Film.admin_fieldset 'Rental', :rental_duration, :rental_rate, :replacement_cost
+ assert_equal 2, Film.admin_fieldsets.size
+ basic, rental = Film.admin_fieldsets
+ assert_equal '', basic.name
+ #FIXME
+ #assert_equal %w(title description length), basic.fields
+ assert_equal 'Rental', rental.name
+ #FIXME
+ #assert_equal %w(rental_duration rental_rate replacement_cost), rental.fields
+ end
+ def test_child_table
+ Country.admin_fieldset '', :country
+ Country.admin_child_table 'Cities', :cities do |t|
+ t.add_field :city
+ end
+ assert_equal 2, Country.admin_fieldsets.size
+ cities = Country.admin_fieldsets.last
+ assert_equal 'Cities', cities.name
+ assert_equal :cities, cities.field
+ #FIXME
+ #assert_equal ['city'], cities.fields
+ end
+ def test_child_form
+ Country.admin_fieldset '', :country
+ Country.admin_child_form :cities do |t|
+ t.add_field :city
+ end
+ assert_equal 2, Country.admin_fieldsets.size
+ cities = Country.admin_fieldsets.last
+ assert_equal :cities, cities.field
+ #FIXME
+ #assert_equal ['city'], cities.fields
+ end
+end
+class ConfigurationIndexTest < ConfigurationTest
+ def test_object_group
+ assert_equal '', Address.object_group
+ Address.object_group 'Address'
+ assert_equal 'Address', Address.object_group
+ end
+ def test_grouped_objects
+ AutoAdmin::AutoAdminConfiguration.primary_objects = [ :address, :customer, :city, :country, :staff_member, :store, :payment, :rental, :film, :actor ]
+ expected_groups = ['', 'Clients', 'Internal', 'Transactions']
+ expected_groupings = [[Actor, Film], [Address, City, Country, Customer], [StaffMember, Store], [Payment, Rental]]
+ Address.object_group 'Clients'
+ Customer.object_group 'Clients'
+ City.object_group 'Clients'
+ Country.object_group 'Clients'
+ StaffMember.object_group 'Internal'
+ Store.object_group 'Internal'
+ Payment.object_group 'Transactions'
+ Rental.object_group 'Transactions'
+ Feature.object_group 'Other'
+ i = 0
+ AutoAdmin::AutoAdminConfiguration.grouped_objects do |label, objects|
+ assert_equal expected_groups[i], label
+ assert_equal expected_groupings[i], objects
+ i += 1
+ end
+ assert_equal 4, i
+ end
+ def test_primary_objects
+ assert_equal [], AutoAdmin::AutoAdminConfiguration.primary_objects
+ AutoAdmin::AutoAdminConfiguration.primary_objects = [ :address, :city, :country ]
+ assert_equal [ :address, :city, :country ], AutoAdmin::AutoAdminConfiguration.primary_objects
+ end
+end
+class ConfigurationListTest < ConfigurationTest
+ def test_default_search_blank
+ assert_equal [], Film.columns_for_search, "Model shouldn't has any columns to search before being told how to search"
+ assert !Film.respond_to?( :search ), "Model shouldn't respond to search before being told how to search"
+ end
+ def test_search_by_one
+ Film.search_by :title
+ assert_equal [:title], Film.columns_for_search, "Model should have a column to search after being told how to search"
+ assert Film.respond_to?( :search ), "Model should respond to search after being told how to search"
+ end
+ def test_search_by_two
+ Actor.search_by :first_name, :last_name
+ assert_equal [:first_name, :last_name], Actor.columns_for_search, "Model should have columns to search after being told how to search"
+ assert Actor.respond_to?( :search ), "Model should respond to search after being told how to search"
+ end
+ def test_default_search_by_name
+ todo!
+ end
+
+ def test_default_sort_interface
+ assert_equal( { :column => 'name', :reverse => false }, Feature.default_sort_info )
+ assert_equal 'name', Feature.sort_column, "Model should sort by default column unless told otherwise"
+ assert_equal false, Feature.sort_reverse, "Model should use default sort direction unless told otherwise"
+ end
+ def test_sort_interface
+ Film.sort_by :name, true
+ assert_equal 'name', Film.sort_column
+ assert_equal true, Film.sort_reverse
+
+ Film.sort_by :length
+ assert_equal 'length', Film.sort_column
+ assert_equal false, Film.sort_reverse
+ end
+
+ def test_default_sort_by_two
+ todo!
+ end
+ def test_sort_by_two
+ todo!
+ end
+
+
+ def test_filter_by_string
+ assert_equal [], Film.columns_for_filter
+ Film.filter_by :rating
+ assert_equal [:rating], Film.columns_for_filter
+ assert_equal( { 'rating' => '*' }, Film.filter_defaults )
+ end
+ def test_filter_by_string_with_default
+ assert_equal [], Film.columns_for_filter
+ Film.filter_by :rating
+ Film.default_filter :rating => 'NC-17'
+ assert_equal [:rating], Film.columns_for_filter
+ assert_equal( { 'rating' => 'NC-17' }, Film.filter_defaults )
+ assert_equal ['rating = ?', 'NC-17'], Film.filter_conditions( {} )
+ assert_equal ['rating = ?', 'PG'], Film.filter_conditions( :rating => 'PG' )
+ assert_equal 195, Film.count( :conditions => Film.filter_conditions( :rating => 'R' ) )
+ end
+ def test_filter_by_date
+ assert_equal [], Rental.columns_for_filter
+ Rental.filter_by :rent_date
+ assert_equal [:rent_date], Rental.columns_for_filter
+ assert_equal ['rent_date BETWEEN ? AND ?', 7.days.ago.midnight, Time.now.tomorrow.midnight], Rental.filter_conditions( :rent_date => 'week' )
+ end
+ def test_filter_by_has_one
+ to_test!
+ end
+ def test_filter_by_belongs_to
+ countries = Country.find(:all, :include => :cities, :limit => 5)
+ City.filter_by :country
+ countries.each do |country|
+ assert_equal ['country_id = ?', country.id], City.filter_conditions( :country => country.id )
+ assert_equal country.cities.count, City.count( :all, :conditions => City.filter_conditions( :country => country.id ) )
+ end
+ end
+ def test_filter_by_boolean
+ Customer.filter_by :active
+ assert_equal ['active = ?', true], Customer.filter_conditions( :active => 'true' )
+ assert_equal 15, Customer.count( :all, :conditions => Customer.filter_conditions( :active => 'false' ) )
+ end
+ def test_filter_by_float_just_has_all
+ Payment.filter_by :amount
+ assert_nil Payment.filter_conditions( :amount => '*' )
+ assert_equal 16088, Payment.count( :conditions => Payment.filter_conditions( :amount => '*' ) )
+ end
+
+ def test_filter_by_string_and_string_and_belongs_to
+ Address.filter_by :district, :postal_code, :city
+ Address.default_filter :district => 'QLD'
+ assert_equal ['district = ? AND city_id = ?', 'QLD', 7], Address.filter_conditions( :city => 7 )
+ assert_equal ['district = ? AND postal_code = ? AND city_id = ?', 'QLD', '123', 9], Address.filter_conditions( :city => 9, :postal_code => '123' )
+ assert_equal ['district = ?', 'QLD'], Address.filter_conditions( {} )
+ assert_equal 2, Address.count( :conditions => Address.filter_conditions( {} ) )
+ end
+
+
+ def test_default_list_columns
+ assert_equal %w{ address address2 district postal_code phone }, Address.default_columns_for_list
+ end
+ def test_specified_list_columns
+ cols = [:address, :address2, :city, :postal_code, :district]
+ Address.list_columns *cols
+ #assert_equal cols, Address.columns_for_list
+ end
+ def test_default_list_column_labels
+ assert_equal 'Address', Address.column_label( 'address' )
+ assert_equal 'Postal code', Address.column_label( 'postal_code' )
+ assert_equal 'Phone', Address.column_label( 'phone' )
+ end
+ def test_specified_list_column_labels
+ Address.column_labels :address => 'Address', :phone => 'Phone #'
+ assert_equal 'Phone #', Address.column_label( 'phone' ), 'Explicitly set label'
+ assert_equal 'Phone #', Address.column_label( :phone ), 'Explicitly set label, accessed as symbol'
+ assert_equal 'Address', Address.column_label( 'address' ), 'Explicitly set to default'
+ assert_equal 'Postal code', Address.column_label( 'postal_code' ), 'Unspecified label falls through to default'
+ end
+end
+
282 test/functional_tests.rb
@@ -0,0 +1,282 @@
+require File.dirname(__FILE__) + '/test_helper'
+
+module FunctionTests
+module StandardSetup
+ def standard_setup
+ reset!
+ @controller = AutoAdminController.new
+ @request = ActionController::TestRequest.new
+ @response = ActionController::TestResponse.new
+
+ @fred = DummyUser.register_user( DummyUser.new('fred', 'xyzzy') )
+ end
+ alias setup standard_setup
+end
+
+class AuthenticationTest < Test::Unit::TestCase
+ include StandardSetup
+ def test_doesnt_redirect_when_authenticated
+ with_empty_classes :User do
+ fake_user = Struct.new( :name ).new( 'Fred Smythe' )
+ get :index, {}, { :user => fake_user }
+ assert_response :success
+ end
+ end
+ def test_redirects_unless_authenticated
+ with_empty_classes :User do
+ get :index
+ assert_redirected_to :action => 'login'
+ end
+ end
+ def test_login_with_bad_username
+ with_dummy_classes :User do
+ post :login, :username => 'frank', :password => 'xyzzy'
+ assert_nil session[:user]
+ assert_response :success
+ end
+ end
+ def test_login_with_bad_password
+ with_dummy_classes :User do
+ post :login, :username => 'fred', :password => 'hackhack'
+ assert_nil session[:user]
+ assert_response :success
+ end
+ end
+ def test_login_with_good_details
+ with_dummy_classes :User do
+ post :login, :username => 'fred', :password => 'xyzzy'
+ assert_equal @fred, session[:user]
+ assert_redirected_to :action => 'index'
+ end
+ end
+ def test_logout
+ get :logout, {}, { :user => @fred }
+ assert_nil session[:user]
+ assert_redirected_to :action => 'index'
+ end
+end
+
+class EditTest < Test::Unit::TestCase
+ include StandardSetup
+ def test_assigns_with_id
+ get :edit, :model => 'actor', :id => 3
+ assert_equal 3, assigns['object'].id
+ assert !assigns['object'].new_record?
+ end
+ def test_assigns_with_new
+ get :edit, :model => 'actor'
+ assert assigns['object'].new_record?
+ end
+ def test_inputs_on_city_edit
+ Country.class_eval do
+ def to_label; country; end
+ end
+ get :edit, :model => 'city', :id => 3
+ assert_tag :tag => 'input', :attributes => { :id => 'city_city', :name => 'city[city]' }
+ assert_tag :tag => 'select', :children => { :count => 109 },
+ :child => { :tag => 'option', :child => { :content => 'Saint Vincent and the Grenadines' }, :attributes => { :value => '81' } },
+ :attributes => { :id => 'city_country', :name => 'city[country]' }
+ end
+ def test_django_text_input_naming
+ AutoAdmin::AutoAdminConfiguration.theme = :django
+ Actor.admin_fieldset do |f|
+ f.auto_field :last_name
+ f.text_field :first_name, :required => true
+ end
+ get :edit, :model => 'actor', :id => 3
+ assert_tag :tag => 'fieldset', :attributes => { :class => 'module aligned' }
+ assert_tag :tag => 'label', :child => { :content => 'First name:' }, :attributes => { :for => 'actor_first_name', :class => 'required' }
+ assert_tag :tag => 'input', :attributes => { :id => 'actor_first_name', :name => 'actor[first_name]', :size => 30, :class => 'vTextField required' }
+ end
+
+ def test_boolean_as_auto
+ AutoAdmin::AutoAdminConfiguration.theme = :django
+ Customer.admin_fieldset do |f|
+ f.auto_field :store
+ f.auto_field :first_name
+ f.auto_field :last_name
+ f.auto_field :active
+ end
+
+ get :edit, :model => 'customer', :id => 15
+ assert_tag :tag => 'input', :attributes => { :checked => true, :id => 'customer_active_true', :name => 'customer[active]', :value => 'true' }
+ assert_tag :tag => 'input', :attributes => { :checked => false, :id => 'customer_active_false', :name => 'customer[active]', :value => 'false' }
+ assert_tag :tag => 'div', :child => { :content => ' True' }
+ assert_tag :tag => 'div', :child => { :content => ' False' }
+
+ get :edit, :model => 'customer', :id => 16
+ assert_tag :tag => 'input', :attributes => { :checked => true, :id => 'customer_active_false', :name => 'customer[active]', :value => 'false' }
+ end
+ def test_boolean_as_select
+ AutoAdmin::AutoAdminConfiguration.theme = :django
+ Customer.admin_fieldset do |f|
+ f.auto_field :store
+ f.auto_field :first_name
+ f.auto_field :last_name
+ f.select :active
+ end
+
+ get :edit, :model => 'customer', :id => 15
+ assert_tag :tag => 'select', :children => { :count => 2 },
+ :child => { :tag => 'option', :child => { :content => 'True' }, :attributes => { :value => 'true', :selected => true } },
+ :attributes => { :id => 'customer_active', :name => 'customer[active]' }
+ assert_tag :tag => 'option', :child => { :content => 'False' }, :attributes => { :value => 'false', :selected => false }
+
+ get :edit, :model => 'customer', :id => 16
+ assert_tag :tag => 'option', :child => { :content => 'False' }, :attributes => { :value => 'false', :selected => true }
+ end
+ def test_boolean_as_check_box
+ AutoAdmin::AutoAdminConfiguration.theme = :django
+ Customer.admin_fieldset do |f|
+ f.auto_field :store
+ f.auto_field :first_name
+ f.auto_field :last_name
+ f.check_box :active
+ end
+
+ get :edit, :model => 'customer', :id => 15
+ assert_tag :tag => 'input', :attributes => { :checked => true, :id => 'customer_active', :name => 'customer[active]', :value => '1' }
+
+ get :edit, :model => 'customer', :id => 16
+ assert_tag :tag => 'input', :attributes => { :checked => false, :id => 'customer_active', :name => 'customer[active]', :value => '1' }
+ end
+ def test_boolean_as_radio_group
+ AutoAdmin::AutoAdminConfiguration.theme = :django
+ Customer.admin_fieldset do |f|
+ f.auto_field :store
+ f.auto_field :first_name
+ f.auto_field :last_name
+ f.radio_group :active
+ end
+
+ get :edit, :model => 'customer', :id => 15
+ assert_tag :tag => 'input', :attributes => { :checked => true, :id => 'customer_active_true', :name => 'customer[active]', :value => 'true' }
+ assert_tag :tag => 'input', :attributes => { :checked => false, :id => 'customer_active_false', :name => 'customer[active]', :value => 'false' }
+
+ get :edit, :model => 'customer', :id => 16
+ assert_tag :tag => 'input', :attributes => { :checked => true, :id => 'customer_active_false', :name => 'customer[active]', :value => 'false' }
+ end
+ def test_boolean_as_customised_radio_group
+ AutoAdmin::AutoAdminConfiguration.theme = :django
+ Customer.admin_fieldset do |f|
+ f.auto_field :store
+ f.auto_field :first_name
+ f.auto_field :last_name
+ f.radio_group :active, :choices => ['Active', 'Suspended']
+ end
+
+ get :edit, :model => 'customer', :id => 15
+ assert_tag :tag => 'input', :attributes => { :checked => true, :id => 'customer_active_true', :name => 'customer[active]', :value => 'true' }
+ assert_tag :tag => 'input', :attributes => { :checked => false, :id => 'customer_active_false', :name => 'customer[active]', :value => 'false' }
+ assert_tag :tag => 'div', :child => { :content => ' Active' }
+ assert_tag :tag => 'div', :child => { :content => ' Suspended' }
+
+ get :edit, :model => 'customer', :id => 16
+ assert_tag :tag => 'input', :attributes => { :checked => true, :id => 'customer_active_false', :name => 'customer[active]', :value => 'false' }
+ end
+end
+
+class ListTest < Test::Unit::TestCase
+ include StandardSetup
+ def test_trivial_list
+ AutoAdmin::AutoAdminConfiguration.theme = :django
+ get :list, { :model => 'category' }
+ assert_response :success
+ assert_tag :tag => 'table', :attributes => { :cellspacing => '0' }
+ end
+ def test_list_saves_params
+ Actor.search_by :last_name
+ get :list, { 'model' => 'actor', 'search' => 'MAN' }
+ assert_equal( { 'controller' => 'auto_admin', 'action' => 'list', 'model' => 'actor', 'sort' => nil, 'sort_reverse' => false, 'search' => 'MAN', 'filter' => {} }, session[:admin_list_params]['actor'] )
+ end
+ def test_filtered_list
+ StaffMember.filter_by :store
+ get :list, { 'model' => 'staff_member', 'filter' => { 'store' => '2' } }
+ assert_response :success
+ assert_equal 1, assigns['objects'].size
+ end
+ def test_sorted_list
+ get :list, { 'model' => 'staff_member', 'sort' => 'last_name', 'sort_reverse' => 'true' }
+ assert_response :success
+ assert_equal( %w(Stephens Hillyer), assigns['objects'].map {|o| o.last_name } )
+
+ get :list, { 'model' => 'staff_member', 'sort' => 'last_name' }
+ assert_response :success
+ assert_equal( %w(Hillyer Stephens), assigns['objects'].map {|o| o.last_name } )
+ end
+ def test_searched_list
+ Actor.search_by :last_name
+ get :list, { 'model' => 'actor', 'search' => 'MAN' }
+ assert_response :success
+ assert_equal 10, assigns['objects'].size
+ end
+ def test_paginated_list
+ get :list, { 'model' => 'customer', 'sort' => 'last_name', 'per_page' => '50', 'page' => '2' }
+ assert_response :success
+ assert_equal 50, assigns['objects'].size
+ assert_equal( %w(CAROLINE BYRON EMMA ANA TED VICKIE RUSSELL BEVERLY CHRIS ELIZABETH),
+ assigns['objects'][0...10].map {|o| o.first_name } )
+ assert_tag :tag => 'span', :attributes => { :class => 'this-page' }, :child => { :content => '2' }
+ assert_tag :tag => 'a', :attributes => { :href => /page=4/ }, :child => { :content => '4' }
+ assert_tag :tag => 'a', :attributes => { :href => /page=12/, :class => 'end' }, :child => { :content => '12' }
+ end
+ def test_paginated_list_with_twenty
+ get :list, { 'model' => 'customer', 'sort' => 'last_name', 'per_page' => '20' }
+ assert_response :success
+ assert_equal 20, assigns['objects'].size
+ end
+end
+
+class SaveTest < Test::Unit::TestCase
+ include StandardSetup
+ def test_save_with_belongs_to
+ Customer.class_eval do
+ def to_label; "#{first_name} #{last_name}"; end
+ end
+ post :save, { 'model' => 'customer', 'id' => '13', 'customer' => { 'store' => '1' } }
+ assert_response :redirect
+ get :edit, :model => 'customer', :id => 13
+ assert_response :success
+ assert_tag :tag => 'select', :attributes => { :id => 'customer_store', :name => 'customer[store]' }, :children => { :count => 2 },
+ :child => { :tag => 'option', :attributes => { :selected => true, :value => '1' } }
+ assert_tag :content => 'The customer &quot;KAREN JACKSON&quot; was changed successfully. '
+
+ post :save, { 'model' => 'customer', 'id' => '13', 'customer' => { 'store' => '2' } }
+ assert_response :redirect
+ get :edit, :model => 'customer', :id => 13
+ assert_response :success
+ assert_tag :tag => 'select', :attributes => { :id => 'customer_store', :name => 'customer[store]' }, :children => { :count => 2 },
+ :child => { :tag => 'option', :attributes => { :selected => true, :value => '2' } }
+ end
+ def test_save_with_belongs_to_under_django
+ AutoAdmin::AutoAdminConfiguration.theme = :django
+ Customer.class_eval do
+ def to_label; "#{first_name} #{last_name}"; end
+ end
+ post :save, { 'model' => 'customer', 'id' => '13', 'customer' => { 'store' => '1' } }
+ assert_response :redirect
+ get :edit, :model => 'customer', :id => 13
+ assert_response :success
+ assert_tag :tag => 'select', :attributes => { :id => 'customer_store', :name => 'customer[store]' }, :children => { :count => 2 },
+ :child => { :tag => 'option', :attributes => { :selected => true, :value => '1' } }
+ assert_tag :content => 'The customer &quot;KAREN JACKSON&quot; was changed successfully. '
+
+ post :save, { 'model' => 'customer', 'id' => '13', 'customer' => { 'store' => '2' } }
+ assert_response :redirect
+ get :edit, :model => 'customer', :id => 13
+ assert_response :success
+ assert_tag :tag => 'select', :attributes => { :id => 'customer_store', :name => 'customer[store]' }, :children => { :count => 2 },
+ :child => { :tag => 'option', :attributes => { :selected => true, :value => '2' } }
+ end
+end
+
+#class HistoryTest < Test::Unit::TestCase
+# include StandardSetup
+#end
+
+#class DeleteTest < Test::Unit::TestCase
+# include StandardSetup
+#end
+
+end # FunctionalTests
+
113 test/helper_tests.rb
@@ -0,0 +1,113 @@
+require File.dirname(__FILE__) + '/test_helper'
+
+class HelperTest < Test::Unit::TestCase
+ include AutoAdminHelper
+
+ # == model()
+ def test_model_should_find_class_for_value_in_params
+ end
+ def test_model_should_find_class_for_value_given
+ end
+
+ # == value_html_for()
+ def test_value_html_for_boolean_should_be_image
+ end
+ def test_value_html_for_string_should_be_string
+ end
+ def test_value_html_for_explicit_nil_on_string_should_be_none
+ end
+ def test_value_html_for_explicit_nil_on_boolean_should_be_image
+ end
+ def test_value_html_for_boolean_klass
+ end
+ def test_value_html_for_string_klass
+ end
+ def test_value_html_for_boolean_nil_klass
+ end
+ def test_value_html_for_string_nil_klass
+ end
+ def test_value_html_for_has_many_klass