Commits

pfw committed 82e498e

Import

Comments (0)

Files changed (154)

+Acknowledgements
+================
+
+The Dulcinea developer team would like to thank everybody who
+contributed in any way, with code, hints, bug reports, ideas, moral
+support, endorsement, or even complaints.  Listed in alphabetical
+order:
+
+Anton Benard
+David Binger
+A.M. Kuchling    
+Roger Masse
+Neil Schemenauer
+Greg Ward
+
+Other contributors:
+Oleg Broytmann
+Michael Davidson
+Mario Ruggier
+Mike Watkins
+0.20: 34166
+2011-07-15
+
+The main purpose of this release is to synch with the
+DurusWorks release and catch up on many small changes.
+
+Remove the ability for the publisher's 'ensure_signed_in' to 
+take keyword arguments.  If a specific realm is needed, then 
+the publisher's version of ensure_signed_in should be overridden 
+to provide the realm in calls to ensure_signed_in_using_form, 
+ensure_signed_in_using_basic, ensure_signed_in_using_digest or
+one of the concrete implentations of ensure_signed_in.
+
+Change dulcinea implementation of ensure_signed_in keyword 'msg' 
+to 'title' to match other concrete implementations in qp.
+
+Add a 'sign_in_page' key to the Hit info if we need to have the user
+sign in.  This allows the header and footer parts to be customized for
+the login page.
+
+Don't delete the part BTree in Partition.remove if the part becomes empty.
+(partition.py)
+
+Do not try to produce a thumbnail for Photoshop files.  PIL can't do it.
+(browse.py)
+
+Expand the list of safe tags for format_text.
+
+Add a css "popup" class to lots of pages.
+
+When formatting a note.  Call nlnl2brbr to convert any two newlines
+in a row to two br tags.  This makes non-html note formatting look
+better. (note.qpy)
+
+FileStream keyword arg 'size' -> 'length'
+
+Add a module level function "static" that returns a path based on the
+path of the passed in module object. (util.py)
+
+Provide length of archive file to FileStream constructor.
+This allows range requests to apply to archive files. (browse.py)
+
+Add a new mixin Surveyed to dulcinea.survey which contains a ref to 
+a single survey.
+
+Add a new UI component SurveyedSurveysAndQuestionsUI which is a 
+SurveysAndQuestionsUI but also updates the reference on the passed
+in Surveyed if provided.
+
+Use standard mimetypes module pattern for customizing guess_type() for use
+with attachments.
+
+In javascript_headers, if there is both javascript src and javascript
+script, split the HTML output into two separate script tags.
+
+Don't require stored files to have mimetype in the predefined list.
+
+Add support for browsing into apk and jar files.
+
+Make thumbnails work, even without PIL.
+
+Avoid repeated categories in category.py.
+
+Add a SingleSelectAndString composite widget... which is really just the
+two widgets put together.  Nothing fancy.
+
+Add get_stat() and get_mtime() methods to stored_file.
+
+Refactor some of the Table methods.
+
+Add Pager, a subclass of Table, intended to display search results that
+are paged and sortable.
+
+Simplify the display of the TableSearchForm form.
+
+Refactor SearchForm and TableSearchForm to have a 'format_matches' method
+to match the existing 'format_no_matches'.  Implement SearchForm.render
+and TableSearchForm.render in terms of format_matches.
+
+Use "durus_id" as the name of the Durus persistent identifier.  The old
+name was "oid".
+
+Add a show_grants module level function to dulcinea.ui.permission which
+when there's a granter, renders a table showing users granted any 
+permission by this granter.  The rendering of the user also includes
+a link to "Edit Permissions" for that user.
+
+Make the edit permission_form a component that must be custom if you need 
+special behavour for get_valid_permissions or for side effects to grant
+and ungrant.
+
+
+0.19: 31795
+2009-08-19
+
+Add undo_percent_quoting function that locates % quoted bytes and 
+substitutes with unquoted byte values.  This is useful when trying
+to eliminated overquoting.
+
+Attempt to make the TarfileWrapper has_member() method work in a variety
+of python versions, where there seems to be some variation in the way
+directory names are located in the archive.
+
+Change the Attachment UI so that the filename is used is both the alt tag and 
+the source tag.
+
+Change the _q_lookup of DataUI to only browse if the filename is not an image 
+and the component is "browse", otherwise return the formatted thumbnail. 
+
+Separate show_name_size_type into show_name, and show_size_type in 
+attachment ui.  Include file name in the file link.
+
+Change the 'required' widget from CheckboxWidget to RadiobuttonsWidget.
+CheckboxWidget is not added to the fields of the request if the box is
+not checked.  This made turning off the requiredness of a property broken.
+This is in property_widget.py.
+
+Remove assertion about values in BigMultipleSelectWidget.
+The behavior should be the same as for MultipleSelectWidget.
+
+Make the default be to allow uploading of all types of files.
+
+Remove default 100% width for pretty table css.
+
+Add a get_form_keywords_matches method to SearchForm that returns the form 
+object, keywords entered and the matches found for the entered keywords.
+
+Split out the __init__ of SearchForm to call get_form_keywords_matches.
+
+Remove 'match_substrings' keyword from SearchForm and all calls to it.
+The behavior now is to not match substrings and rather match on whole 
+word boundaries always.
+
+Move format_interaction_summary from dulcinea.ui.inquiry to
+dulcinea.ui.user.interaction.
+
+0.18: svn revision 31367
+2008-12-03
+
+Convert stored datetimes to all be tz-aware.  Existing databases
+that use these classes must be converted to work with this revision.
+
+Convert to be compatible with py3k.
+
+Convert code to use the newer names and template syntax of the 
+latest qpy.
+
+Add expandable_div and collapsible_div functions with supporting javascript.
+
+Add a Period class.
+
+Update RSSTab and RSSFeed to display an item's pubDate attribute.
+
+Add __hash__() methods where necessary.
+In py3k, __eq__() kills any inherited __hash__().
+
+Change date_select_fix javascript to work with DateTimeSelectWidgets
+that are constructed with 'show_hour_min' is True.
+
+Make Dulcinea safe for installations without sancho.
+
+Add a simple script for checking the URLs associated with links.
+
+Add charset attribute to StoredFile.
+
+Add Propertied Mixin for objects with a PropertyList.
+Revise some PropertyList methods.
+
+Factor out the code that inserts javascript into the head portion of a page into
+a 'javascript_headers' function.  Add this to the ui.utils module.
+Use javascript_headers function in DulcineaPublisher.header
+
+Add a highlight_keywords function that wraps any words present in keywords 
+arg in a span with a "match" class.
+
+Make sortable headings work for server-side sorting.
+Add ability to make some columns not-sortable.
+Add even and odd classes to rows.
+
+0.17: svn revision 29799
+2007-05-02
+
+Use metaclass-loaded base classes, Specified, and Mixin, to nail down 
+slot names for all data classes.  Note that Specified base classes
+should come before Mixin base classes in a class definition.  If they
+don't, you'll see an error about the class you are loading already having
+a __slots__ value.  Instances of classes that derive from Dulcinea data 
+classes will not have __dict__ attributes.
+
+Add an example update_db.py script to the qp demo site (in dulcinea/site).
+If anyone has an existing Durus database for a Dulcinea application, you
+should look at this.  It shows how you can update a database to repickle
+objects that refer to, for example, 'Events', that now use slots for attributes
+and do not define their own __setstate__ methods.  Without an update like
+this, existing applications will have unpickling errors.  If you need help
+with this, please post questions to the qp mailing list.
+
+Add search capability to wiki.
+
+Add an 'invertable' keyword argument to BigMultipleSelectWidget that
+defaults to False.  When invertable is True an "invert" button is
+added to the widget that when pressed inverts the selected and 
+available lists.
+
+Care must be taken when using invertable BigMultipleSelectWidgets in
+forms, since pressing invert will cause form.is_submitted() to return
+True. 
+
+Let the Master Property widget have a None value.  The default is very
+unlikely to be the one that is desired.
+
+Improve CSS for PhysicalValueWidget and ToleranceWidget particularly
+for IE.
+
+Use the agent class as a css class in the BODY element.
+
+Make Categorized new-style.
+
+Make DulcineaUser specified.
+
+Make StoredFile slotted.
+
+Add search capability to wiki.
+
+Remove DulcineaSessionManager.
+
+Make Partition Persistent.
+
+Make sure that Specified comes before Mixin in every base class sequence.
+
+Add __eq__ and __ne__ to DulcineaPersistent.
+Add PropertyList.__eq__().
+Change PhysicalUnit __cmp__ to __eq__.
+
+Make Copyable new-style, and simplify it.
+Implement copy() and __copy__() directly on DulcineaPersistent,
+and make DulcineaPersistent be a PersistentObject.
+This should allow some classes that are subclasses of DulcineaPersistent
+to use slotted attributes.
+
+Enhance the search for inquiries to look at the contact for the inquiry.
+
+Add placeholder file so that demo site has a var directory.
+
+Drop support for quixote applications.  Dulcinea is all-QP now.
+This eliminates lots of complexity and redundant code.  
+Applications that need to keep using quixote should stick to
+previous versions of Dulcinea.
+
+All files that were ptl files are now qpy files.
+
+datetime.strftime() needs a real str.
+
+Make the widgets of a BigMultipleSelect float left.
+This seems to make the qp rendering look like the quixote one.
+
+Make PersonNameWidget look the same on qp as it does on quixote.
+The three subwidgets should float left.
+
+allow the socket for the urlopen in an RSSFeed to timeout quickly if
+not available.
+
+Disable abort in the test environment, so that a test session does
+not get tossed before a test request can be processed.
+
+Make page loader qp friendly.
+Don't write entire response body on failure.
+
+Add a 'user' keyword argument to the constructor of PageLoader
+
+Fix DateTimeSelectWidget.is_submitted()
+
+Make the signin_link float right.
+This fixes a display problem on IE.
+
+Remove open_connection().
+Use Site() to open database.
+
+Change
+redirect(get_base_path() + x) -> redirect(x)
+Redirects always prepend the script name to paths that start with '/'.
+Prepending it in cases like this will cause it to appear twice in the
+Location header.
+
+Remove get_base_path().  (We now have complete_path() instead.)
+
+Move special SITE handling into set_test_environment().
+Don't depend on SITE in scripts.  It is just for tests.
+Add an optional site_name keyword to the PageTest and CommonTest
+constructors.
+Import get_publisher and get_connection from qp.
+
+Remove access_denied and invalid_query.
+
+Use PersistentObject as the base class of more persistent classes.
+
+Don't make show_sidebar' keyword argument to hierarchy_header required.
+
+In lookup_user if matching on an email address and there are multiple matches,
+return the match that contains the user_id similar to the email address
+if it exists.
+
+0.16: svn revision 29023
+
+Use slots on some classes to save memory.
+
+Rename Parameter to Property.
+
+Make sure that the publisher's add_user function is called on registration.
+
+Convert dulcinea Item and ItemFolder to be based on Keyed and Keep.
+
+Make Dulcinea Applications use the same Sessions with QP or Quixote.
+Remove the persistent SessionManager.
+Add get_time_zone() to DulcineaPublisher.
+
+Add site_now() to common.
+
+Add an open_connection() in the QP branch.
+
+Use users BTree instead of UserDatabase.
+The Publisher is the authoritative source, through get_users(),
+of the user mapping.
+
+Add MiscDatabase.
+
+Move get_matching_user to the module level.
+
+Move gen_users_granted() to the module level.
+
+Make list_users() a module level function.
+
+Remove get_all_users().
+
+Put a create_user on the Quixote Dulcinea Publisher.
+
+Export full sized PNG version of images.
+
+The user for password change history is also the logged in user not the user
+whos password is being changed.
+
+Add features to the debug UI.
+
+Move form2 to form.
+
+Make DulcineaUser use the qp hash function when running under qp.
+Under qp, if the digest matches using the dulcinea hash function,
+use the qp hash function to set a new, qp-compliant hash value.
+The qp hash function is preferred because it allows for http 
+digest authentication.
+
+Improve SearchSelectOneWidget.
+Suppress errors when search is submitted.
+Reverse widget order.
+When there is exactly one match, go ahead and select it.
+Add format_search_select_css().
+
+Fix css font family typo.
+
+Add the From Field of an inquiry to the searchable fields.
+
+Add search capability to links.
+
+Make html2txt more resilient to UnicodeDecodeError.
+
+Use the latin1 charset for the password reset email.
+
+
+
+0.15: svn revision 28637
+2006-08-22
+
+Fix a chicken/egg error in the setup.py.  It imports __version__
+from __init__.py, and __init__.py expected dulcinea to already be
+on the path.
+
+Use set instead of Set.
+
+Revise Note class.
+
+Cache date_pair form pages.
+
+Add wiki code.
+
+Add DulcineaUserDatabase.get().
+
+Make user code more qp-compatible.
+
+Remove _q_index() from DynamicExportingDirectory.
+This would only be used in a case where we export ('', '_q_index', None, None)
+and don't provide any explicit '_q_index'.  If there are index pages that
+don't load after this change, those directories should implement _q_index,
+perhaps by calling index_page() as was done here.
+
+Add a QP Dulcinea demo.
+
+Implement a quixote-compatible version of Directory and put it in common.
+Change all imports of DynamicExportingDirectory to import Directory from
+common.
+
+Put get_directory_tree() in common.
+
+Remove enabling_cookies template and calls.
+
+0.14: svn revision 28488
+2006-08-16
+
+Make Attachment Persistent.  To convert our databases containing attachments
+to the new form, we ran this function in our update script.
+    def touch_attachables():
+        from dulcinea.attachable import Attachment, Attachable
+        Attachment.__init__ = lambda x: None
+        for attachable in gen_every_instance(get_connection(), Attachable):
+            attachable._p_note_change()
+
+Fix a bug in the change user password form that was not properly
+removing the user's password.
+
+Simplify the logic for SearchSelectOneWidget.
+
+Update get_crumb_tree to use a get_crumb_tree_menu_items generator function
+and to return, in addition, a derived css_class.
+
+Remove module level get_exports function from dulcinea.ui.crumbs.  Instead
+assume all directories have get_exports methods.
+
+Move dulcinea.ui.crumbs.get_crumb_tree to dulcinea.ui.directory so it
+can be more easily used by code not using qpy.
+
+Make a StaticDirectory in dulcinea.common for quixote derived from the
+quixote StaticDirectory that has a get_exports method.
+
+Change all imports of StaticDirectory and StaticFile to go through 
+duclinea.common.
+
+Add page_size selection to search form.
+
+When the permission form change a permission granted by a granter
+to a user, call the granter's update_permission_cache() method, if it
+exists.  This allows granters to maintain sets of users derived
+from permissions.
+
+Don't use tokens on the date_pair_form, since it is ordinarily used
+for forms that are really just queries.
+
+
+0.13: svn revision 27891
+2006-02-01
+
+This is a kind of transitional release as we are using qp more.  
+Our existing dulcinea-based applications are still using quixote and ptl.
+
+Define dulcinea.common to provide commonly used functions, and, in particular,
+to provide the quixote version to quixote applications and the qp version to
+qp applications.
+
+Remove the ObjectDatabase class.  Just call get_connection() and you
+get a connection to the durus storage that is configured for the 
+site (and the site is determined by the SITE environment variable).
+If you want a file storage connection instead, call 
+open_connection('foo.durus').  When the Connection is made, so is
+a site-specific Publisher.
+
+Make DulcineaUser class inherit from qp's User class.  Existing DulcineaUser
+instances must be converted because the password digests are stored differently.
+A revise_users() function is provided for this conversion.
+Also make DulcineaUserDatabase inherit from qp's UserDatabase.
+
+Remove use of local_ui and local in dulcinea, although dulcinea.local
+is still be defined when the site provides a local module.
+
+Use get_publisher().get_site_title() instead of local.SITE_NAME.
+
+Use dulcinea.common.CommonTest for unit tests involving a Connection.
+
+dulcinea.sendmail.sendmail() uses publisher.get_webmaster_address() instead
+of local.MAIL_SMTP_SENDER.
+
+Database root accessors are now defined on the site-specific publisher,
+and used through access functions.  For example, get_news_db() is defined
+in news.py.  It calls the method on the publisher, and the DulcineaPublisher
+provides a default implementation.
+
+Add a dulcinea.uiqp package that mirrors the dulcinea.ui package,
+except using the qpy compiler instead of the ptl compiler.
+
+Make stronger attribute specification on History class so that Event
+instances can be more easily verified.
+
+LinkTripleDatabase business code and UI was separated from the 
+LinkDatabase code.
+
+Use sorted() instead of function_sort().
+
+Remove the keyed, permission modules.
+
+Revise debug session browser.
+
+Use randbytes from qp.
+
+0.12: svn revision 27720
+2005-12-13
+
+Dulcinea now requires the qp and qpy packages. 
+Some Persistent classes that were in Dulcinea are now being used
+from qp instead, and more will move in future releases of Dulcinea
+as Dulcinea evolves to adapt to qp.
+Keeping up with these changes will involve changing imports and
+writing database update scripts.
+
+The keyed module is gone: where it was used, we are now using qp.lib.keep.
+Upgrading a database with KeyedMap instances will require a conversion
+script that converts them to Persistent Keep instances.
+
+The spec module is now imported from qp.
+The typeutils module, deprecated in the last release, is now gone.
+
+The code_utils module, and the utilities that used it are all removed, since
+the same functionality is available from qpy's qpcheck.py script.
+The DulcineaBase class is now named "Copyable".
+
+The Publisher class can be named in the site_config file now.
+This prepares the way for site-specific Publisher classes.
+
+wrap_text() is now safer for non-ascii str instances.
+
+
+0.11: svn revision 27556
+2005-10-18
+
+Change the DulcineaPublisher to set quixote's default encoding to utf8.
+This means form values are unicode, not strs.  That means that non-ascii
+str instances are hazardous, since they won't be automatically decoded
+when they are combined in some way with unicode instances.  It also means
+that calls to str() are hazardous, since the argument may now be a
+unicode instance that can't be encoded as ascii.  We tried to identify
+these hazards and change them into calls to stringify().  Note also that
+"%s" % x is hazardous if x is an object with a __str__ method that may
+return a unicode instance.  Code like this should be changed to 
+u"%s" % x.
+
+Add functions to dulcinea.util that we used to try to recover characters
+from str instances with unknown encodings.  Purge the old replace_characters()
+function that made this task more difficult.
+
+Add a "string" spec that includes ascii-only str instances and all
+unicode instances.  Change attribute specifications everywhere from
+"str" to "string".
+
+This release includes a revision to the way permissions are stored.
+The permissions.py module is deprecated, but for now it includes 
+a function that may be helpful in converting an existing database
+to the new model.  In short, the new model stores permissions on
+the users instead of on the objects that "grant" the permissions.
+The new model also uses PermissionSet, a class that is new in
+Durus-3.1.
+
+Use require() instead of typecheck() everywhere.  The typeutils.py module
+is still included, but deprecated.
+
+Alter Keyed class to keep a counter on a separate persistent instance.  
+
+Add a "pattern" spec.  Thanks to Mario Ruggier for the idea. 
+
+0.10: svn revision 27465
+2005-09-27
+
+Use specified attributes more.
+
+Add add_getters(), add_setters(), and add_getters_and_setters() to
+the spec module.  Use these to add trivial get_<name> and set_<name>
+methods as needed for classes with specified attributes.
+
+Add a PersistentSet implementation.
+
+Add an Outline implementation.
+
+Convert spec's 'any' to 'either' and 'all' to 'both'
+to avoid collision with new builtins 'any' and 'all' in python 2.5.
+
+Allow some attributes that were specified as 'str' to be 'basestring'.
+
+Expand Phone number fields in the registration form to 20 chars
+
+Use a factored out module level function from dulcinea.address
+to determine if a phone number is valid.
+
+Add Item.can_modify().
+
+Use default value in get_cache_size().
+
+Add utilities 'set_environment' and 'clear_environment' to help test ui code.
+ - set_environment sets up a Quixote publisher with a request
+ - script_name, path_info, and other variables may be passed in as keywords
+   to set_environment
+
+Remove special handling of TIDY_CHECK in site scripts.
+
+Replace ._p_changed = 1 with ._p_note_change() in lots of places.
+
+Change crumb javascript section so that it does not do anything except when
+it is actually required to get the right behavior.
+
+Change check_durus.py so that it no longer depends on grouch.
+It now expects all attributes to be specified using the spec pattern.
+
+Make a new general-purpose composite-widget PairWidget.
+
+Make DatePairWidget subclass from PairWidget
+
+In the browse module, force the mime-type for Python source code files
+to be text/plain
+
+Enhance enabling_cookies template to *not* assume that the version
+returned from HTTPRequest.guess_browser_version is always a string
+with at least one character.
+
+Use SCGIMount in dulcinea site script for apache.
+
+0.9: svn revision 27188
+2005-08-09
+
+
+Remove extent module.  Durus (>2.0) has a gen_every_instance() function that 
+serves this purpose.
+
+Add life_cycle_util module.
+
+Add note module.
+
+Add size statistics to the check_durus output.
+
+Remove code-coverage stuff from dulcinea that is duplicated in the sancho
+package.
+
+Use decorators in the category ui code.
+
+Change spec's match() function so that a tuple containing None or
+a numeric or string literal is treated as a disjunction.
+This makes (None, int) mean the same thing as any(None, int), 
+and it has the advantage of not requiring an import from the spec
+module.
+
+Add DatePairWidget.
+
+Add render_csv() to Table.
+
+
+0.8: svn revision 26679
+2005-05-26
+
+Add rss support for tabbed views.
+
+Avoid parsing date fields as floats in javascript in Table.
+
+Make all calls to format_user go through local_ui.
+
+Show 5 most recent history when formatting a user's profile if the
+logged in user is admin.
+
+Show [browse] link for browsable attachments.
+
+Add set_utils.
+
+Provide a default local and local_ui for tests.
+
+Add material classes.
+Add parameter classes.
+
+0.7: svn revision 26535
+2005-04-12
+
+Add browse.ptl, with support for browsing down into stored files
+that are tar or zip archives.
+
+Add get_module_directory() to dulcinea.util.
+
+Move thumbnail-generation code from attachment.ptl to thumbnail.ptl.
+
+Allow zip files to be uploaded as stored files.
+
+Simplify decoration-related parameters of Interactable mixin class.
+
+Restore reset_password to the profile page.
+
+Remove references to SessionError.
+
+Put revised signin handling in dulcinea.ui.user.util.
+Stop dropping userid cookies.
+Stop supporting insecure_login.
+
+Add safe_respond() to use for error responses.
+
+Change finish_interrupted_request() to make sure that cookies, set by the
+session manager, make it into the response.
+
+Make 'profile' a top-level directory that leads to individual user profiles
+through a lookup.
+
+Add a page() utility in dulcinea.ui.util.
+
+Add HUB-based scgi restart to site command.  This is fast, but it does
+not always work,
+
+
+0.6: svn revision 26293
+2005-03-07
+
+Use a Dulcinea version of Quixote Directory class.  Instead of using
+_q_exports, the new DynamicExportingDirectory classes implement a
+get_exports() method, which generates a sequence of
+(url_component, attribute_name, crumb, crumb_title) tuples
+for the exports.  See dulcinea.ui.directory for the implementation.
+
+Implement RespondNow handling behavior for the Dulcinea Publisher
+and use it for redirects, not signed in, invalid query, and 
+not found responses.  This is now the only PublishError subclass
+provided in Dulcinea. The error-handling changes can be found in
+in dulcinea.ui.publisher and dulcinea.ui.errors.
+
+Add a new Table class for rendering data tables that sort by
+column.  The Table class supports sorting on the client side or
+on the server side.
+
+Make opendb functional when readline is not available.
+
+Use the word "category" everywhere we are talking about Category 
+instances.  Stop using "group" for this purpose.
+
+Use not_found(), invalid_query() and access_error() instead of raising
+special exceptions.
+Use ensure_signed_in instead of NotLoggedInError.
+
+Remove dulcinea.errors.
+
+Make is_running() more portable.
+
+Use format_date() and format_date_time() from local_ui.
+
+Change none_quote() to return '' if the value is false.
+
+Call str on the method name so that method_sort can be called from templates,
+where the method name will normally be an htmltext instance.
+
+Use new quixote html_url function to generate url's with querystrings.
+
+Add a general page_loader: see dulcinea.ui.page_loader.
+This allows for sancho-style unit tests for a site.
+
+Revise the crumb formatting to include drop-down crumb menus and
+a menu of exports from the current Directory.
+
+Expand output of start-apache.py to include the interfaces for each site.
+
+Add a Message-of-the-day capability to DulcineaUser and DulcineaUserUI.
+
+0.5: svn revision 25738
+2004-12-09
+
+  * Use Timestamped mixin for Survey, Comment, and Item.
+
+  * 'get_url' -> 'get_local_url'
+
+  * Use Quixote 2.  Make all Quixote namespaces Directory instances.
+    Use quixote.server.scgi_server module.
+
+  * Move most of the code from sitecheck.py into dulcinea.traversal.
+    Change the behavior so that it does not complain about missing 
+    attributes when there is a _q_resolve present.
+
+  * Move utest.py to sancho.
+    Add convert.py script in sancho.
+    Add utest_* for each test_* in durus/test and dulcinea/lib/test
+
+  * Add a configurable 'base_path' that will be prepended to absolute
+    URLs.  Use relative paths when possible.
+
+  * Make stored_file more portable and improve attachment mime type handling.
+
+  * Make DateTimeSelectWidget more resilient to unusual dates.
+    datetime.strftime only works for year >= 1900
+
+  * Add a browse page that lists all urls reachable without _q_lookup, and
+    makes a little form for each significant _q_lookup that it finds.
+
+  * Make QuestionDatabase persistent.
+
+  * For consistency, change get_*_database() to get_*_db().
+
+  * In site_util, add get_docroot() and change _import_class() to import_class().
+
+  * Remove comma_format and test.  Add a test for csv().
+
+  * Add news.
+
+  * Improve BigMultipleSelectWidget behavior.
+
+  * Remove immutable_list module and test.  Use ImmutableSet instead.
+
+  * Add the ability to disable DulcineaUser.
+
+  * After a successful partial registration, redirect to the newly added
+    user's profile, not the login page.
+
+  * Get format_user from local_ui.
+    Add experimental DurusDirectory for browsing the durus database.
+    The browsing is disabled when is_live() is true.
+
+  * Allow users to sign in using either there user id or email address.
+    Add capability to disable login by email address 
+
+  * Rename LazyModule class as SiteModule.
+    Protect import of site_config so that it is possible to import from
+    dulcinea without any installed site_config (as long as SITE is not set).
+
+  * Ensure absorbed objects get repicked since we bypass the persistence hook
+
+  * Fix sorting on ItemFolder.get_recent_items
+
+  * Create an html2txt() to util module
+
+  * Add interaction logging support.
+
+0.4: svn revision >25025
+2004-09-14
+
+  * Revise site_utils to get site configuration information from
+    a "site_config.py" module, expected on the normal python path,
+    instead of from the site.conf file.  If you are using the site-related
+    code, you must write a site_config.py file for your site(s).
+    See site_util.get_config_value.__doc__ for a description of what 
+    must be in your site_config.py file.  Notably, the site configuration
+    variable names that included '-' are all converted to use '_' instead.
+
+  * Added pages for viewing publihser and site config values to the
+    debug ui.
+
+  * Allow address widget and contact address widget to work with a None value.
+
+  * Add site support for specifying the ability to do anonymous registration
+
+  * Cleanup URL space for attachment DataUI and allow multiple files
+    to be copied to the clipboard.  
+
+  * Treat Address and ContactAddress as immutable.
+
+  * Remove FileDatabase class because our external files are always
+    accessed through attachments. Refactor code in the stored_file
+    module, making new_file() and guess_mime_type() functions instead
+    of methods.
+  
+  * Rewrite get_url() function.  Behavior is now slightly different
+    but clearer and probably better.  If 'secure' is false then don't
+    bother monkeying with the port, just return a full URL.  If
+    'secure' is true then attempt to use SSL.  Note that if you want
+    SSL then site_config.config  must provide a value for 'https_address'.
+
+  * Start Apache if 'httpd' is defined as a site_config value..
+    
+  * Remove local.SUPPRESS_EMAIL flag and replace it by
+    site_util.is_email_enabled().  If email is not not enabled then
+    the dulcinea.sendmail module never sends mail.  This is less
+    confusing than the SUPPRESS_EMAIL flag.
+
+  * Add Attachable.is_image() .  Don't provide thumbnails for
+    non-images.
+
+  * Use optional 'apache_version' directive so that start-apache.py
+    generates a config that can work with apache2, and the apache2
+    mod_scgi module.
+
+0.3: svn revision >24702
+2004-07-16
+
+  * More use of Form2 forms.
+
+  * Replaced all use of mx.DateTime with the standard datetime.
+
+0.2.1: svn revision >23475
+~2004-02-16
+
+  * Since 0.2, there are some classes to support a survey capability,
+    and improved unit test coverage.
+
+0.2: svn revision ~23344
+2004-01-21
+
+  * Second release.  Many changes since 0.1.
+
+0.1: svn revision ~21750
+2003-05-29
+
+  * Initial release.
+
+
+
+
+
+CNRI OPEN SOURCE LICENSE AGREEMENT
+
+IMPORTANT: PLEASE READ THE FOLLOWING AGREEMENT CAREFULLY.  BY
+COPYING, INSTALLING OR OTHERWISE USING DULCINEA-0.20 SOFTWARE, YOU
+ARE DEEMED TO HAVE AGREED TO THE TERMS AND CONDITIONS OF THIS
+LICENSE AGREEMENT.
+
+1. This LICENSE AGREEMENT is between Corporation for National
+   Research Initiatives, having an office at 1895 Preston White
+   Drive, Reston, VA 20191 ("CNRI"), and the Individual or
+   Organization ("Licensee") copying, installing or otherwise using
+   Dulcinea-0.20 software in source or binary form and its associated
+   documentation ("Dulcinea-0.20").
+
+2. Subject to the terms and conditions of this License Agreement,
+   CNRI hereby grants Licensee a nonexclusive, royalty-free, world-
+   wide license to reproduce, analyze, test, perform and/or display
+   publicly, prepare derivative works, distribute, and otherwise use
+   Dulcinea-0.20 alone or in any derivative version, provided,
+   however, that CNRI's License Agreement and CNRI's notice of
+   copyright, i.e., "Copyright © 2011 Corporation for National
+   Research Initiatives; All Rights Reserved" are retained in
+   Dulcinea-0.20 alone or in any derivative version prepared by
+   Licensee.
+
+3. In the event Licensee prepares a derivative work that is based on
+   or incorporates Dulcinea-0.20 or any part thereof, and wants to
+   make the derivative work available to others as provided herein,
+   then Licensee hereby agrees to include in any such work a brief
+   summary of the changes made to Dulcinea-0.20.
+
+4. CNRI is making Dulcinea-0.20 available to Licensee on an "AS IS"
+   basis.  CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+   IMPLIED.  BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO
+   AND DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY
+   OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF DULCINEA
+   -0.20 WILL NOT INFRINGE ANY THIRD PARTY RIGHTS.
+
+5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF
+   DULCINEA-0.20 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL
+   DAMAGES OR LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR
+   OTHERWISE USING DULCINEA-0.20, OR ANY DERIVATIVE THEREOF, EVEN IF
+   ADVISED OF THE POSSIBILITY THEREOF.
+
+6. This License Agreement will automatically terminate upon a
+   material breach of its terms and conditions.
+
+7. This License Agreement shall be governed by and interpreted in
+   all respects by the law of the State of Virginia, excluding
+   Virginia's conflict of law provisions.  Nothing in this License
+   Agreement shall be deemed to create any relationship of agency,
+   partnership, or joint venture between CNRI and Licensee.  This
+   License Agreement does not grant permission to use CNRI
+   trademarks or trade name in a trademark sense to endorse or
+   promote products or services of Licensee, or any third party.
+
+8. By copying, installing or otherwise using Dulcinea-0.20, Licensee
+   agrees to be bound by the terms and conditions of this License
+   Agreement.
+
+Metadata-Version: 1.0
+Name: Dulcinea
+Version: 0.20
+Summary: A package of modules useful for developing DurusWorks applications.
+Home-page: http://www.mems-exchange.org/software/dulcinea/
+Author: CNRI
+Author-email: webmaster@mems-exchange.org
+License: see LICENSE.txt
+Download-URL: http://www.mems-exchange.org/software/files/dulcinea/Dulcinea-0.20.tar.gz
+Description: UNKNOWN
+Platform: UNKNOWN
+Classifier: Development Status :: 3 - Alpha
+Classifier: Intended Audience :: Developers
+Classifier: Operating System :: Unix
+Classifier: Topic :: Software Development :: Libraries
+Dulcinea
+========
+
+Dulcinea is a collection of modules useful for developing applications
+with DurusWorks.
+
+
+Documentation
+=============
+
+No detailed documentation has been written yet.
+
+
+Requirements
+============
+
+Dulcinea is developed using Python 2.4.  
+
+Dulcinea 0.20 requires DurusWorks.
+
+Installation
+============
+
+To install Dulcinea, simply run:
+
+    python setup.py install
+
+
+Home page
+==========================================
+
+The Dulcinea home page is:
+    http://www.mems-exchange.org/software/dulcinea/
+
+
+Copyright, license, authors
+===============================
+
+Copyright (c) Corporation for National Research Initiatives 2011.
+
+The license appears in the LICENSE.txt file.
+
+At CNRI, the current developers of Dulcinea are
+David Binger     <dbinger@mems-exchange.org>, and
+Roger Masse      <rmasse@mems-exchange.org>.
+
+#!/usr/bin/env python
+#
+# $URL: svn+ssh://sting.mems-exchange.org/repos/trunk/dulcinea/bin/bad_links.py $
+# $Id: bad_links.py 31107 2008-09-16 13:57:47Z dbinger $
+
+import sys
+from qp.lib.site import Site
+from dulcinea.util import urlopen
+
+def try_url(link):
+    try:
+        file = urlopen(link.get_link_url())
+    except IOError:
+        err = sys.exc_info()[1]
+        sys.stderr.write("Link %d Unable to fetch %s: %s\n" % (
+            link.get_key(), link.get_link_url(), err.strerror))
+
+def main (prog, args):
+    usage = "usage: %s [site]" % prog
+
+    if len(args) != 1:
+        sys.exit(usage)
+
+    link_db = Site(args[0]).get_connection().get_root()['link_db']
+    for link in link_db.get_all_links():
+        try_url(link)
+
+if __name__ == '__main__':
+    main(sys.argv[0], sys.argv[1:])

bin/expire_session.py

+#!/usr/bin/env python
+"""
+$URL: svn+ssh://sting.mems-exchange.org/repos/trunk/dulcinea/bin/expire_session.py $
+$Id: expire_session.py 29387 2007-02-12 21:04:47Z dbinger $
+
+Delete sessions whose access time is older than the age passed in as an
+argument (in hours).
+
+This script is meant to be run by cron.
+"""
+import sys
+from datetime import timedelta
+from qp.lib.site import Site
+from qp.pub.common import site_now, get_connection
+
+def main (prog, args):
+    usage = "usage: %s [site] [expire time]" % prog
+
+    if len(args) != 2:
+        sys.exit(usage)
+
+    sessions = Site(args[0]).get_publisher().get_sessions()
+    expiration_hours = float(args[1])
+    cutoff = site_now() - timedelta(hours=expiration_hours)
+    for key, session in sessions.items():
+        if session.get_access_time() < cutoff:
+            del sessions[key]
+    get_connection().commit()
+
+if __name__ == '__main__':
+    main(sys.argv[0], sys.argv[1:])

bin/gc_stored_files.py

+#!/usr/bin/env python
+"""
+$URL: svn+ssh://sting.mems-exchange.org/repos/trunk/dulcinea/bin/gc_stored_files.py $
+$Id: gc_stored_files.py 34059 2011-07-01 02:21:01Z dbinger $
+
+Delete files that are not referenced by StoredFile instances.
+"""
+import sys, os, glob, time
+from durus.storage import gen_durus_id_class
+from qp.lib.site import Site
+
+# new files are not deleted since someone could be in the process of
+# uploading them or attaching them in another transaction
+MIN_AGE = 60*60*24
+
+size = 0
+
+def maybe_remove(filename):
+    global size
+    if (time.time() - os.stat(filename).st_mtime) > MIN_AGE:
+        print(filename)
+        size += os.stat(filename).st_size
+        os.unlink(filename)
+
+
+def main (prog, args):
+    usage = "usage: %s [site]" % prog
+
+    if len(args) != 1:
+        sys.exit(usage)
+
+    site = Site(args[0])
+    connection = site.make_file_connection(readonly=True)
+    used = {}
+    for durus_id, class_name in gen_durus_id_class(connection.get_storage(), 'StoredFile'):
+        s = connection.get(durus_id)
+        used[s.get_full_path()] = 1
+    now = time.time()
+    root_directory = site.get('file_store')
+    print('root_directory %s' % root_directory)
+    for filename in glob.glob(os.path.join(root_directory, '?/??/*')):
+        if filename not in used:
+            maybe_remove(filename)
+    for filename in glob.glob(os.path.join(root_directory, 'tmp/upload.*')):
+        maybe_remove(filename)
+    print("%s MB" % (size / (1024*1024)))
+
+if __name__ == '__main__':
+    main(sys.argv[0], sys.argv[1:])
+"""
+$URL: svn+ssh://sting.mems-exchange.org/repos/trunk/dulcinea/lib/__init__.py $
+$Id: __init__.py 34120 2011-07-14 12:01:52Z dbinger $
+"""
+
+"""
+$URL: svn+ssh://sting.mems-exchange.org/repos/trunk/dulcinea/lib/address.py $
+$Id: address.py 29610 2007-03-14 21:12:59Z dbinger $
+"""
+from dulcinea.base import DulcineaPersistent
+from dulcinea.country import get_country_codes
+from qp.lib.delegation import delegate
+from qp.lib.spec import either, init, specify, string, Mixin
+from qp.lib.spec import spec, add_getters, add_getters_and_setters
+import re
+
+
+class Address (DulcineaPersistent):
+
+    street1_is = (string, None)
+    street2_is = (string, None)
+    city_is = (string, None)
+    state_is = (string, None)
+    zip_is = (string, None)
+    country_code_is = spec(
+        either(None, *get_country_codes()),
+        "ISO-3166 two-letter country code")
+
+    STATES = [
+        "AL", "AK", "AR", "AZ", "CA", "CO", "CT", "DE", "FL", "GA",
+        "HI", "IA", "ID", "IL", "IN", "KS", "KY", "LA", "MA", "MD",
+        "ME", "MI", "MN", "MO", "MS", "MT", "NC", "ND", "NE", "NH",
+        "NJ", "NM", "NV", "NY", "OH", "OK", "OR", "PA", "RI", "SC",
+        "SD", "TN", "TX", "UT", "VA", "VT", "WA", "WI", "WV", "WY",
+        # Commonwealths & territories
+        "AS", "DC", "FM", "GU", "MP", "PR", "VI",
+        # Armed forces codes
+        "AA", "AE", "AP"]
+
+    def __init__(self, **kwargs):
+        init(self, **kwargs)
+
+    def is_valid(self):
+        """() -> bool
+
+        Return true if this address is valid: either it's empty or
+        it's complete (and correct!).
+        """
+        return self._check_complete() == []
+
+    def _check_complete(self):
+        """() -> [(attrname:str, errtype:str)]
+
+        Check whether this address is complete: must have at least one
+        line of street address, city, and country; if country is US,
+        must have a valid state and ZIP code too.
+
+        Returns a list of (attrname, errtype) 2-tuples explaining any
+        problems.  'errtype' is either "missing" or "bad".
+        """
+        errors = []
+        for field in ['street1', 'city', 'country_code']:
+            if not getattr(self, field):
+                errors.append((field, "missing"))
+
+        # US-specific checks
+        if self.country_code == 'US':
+            if not self.state:
+                errors.append(('state', "missing"))
+            else:
+                state = self.state.upper()
+                if state not in self.STATES:
+                    errors.append(('state', "bad"))
+                elif state != self.state:
+                    self.state = state  # make uppercase
+
+            # Check the Zip code (5 digits, or 5+4 format)
+            if not self.zip:
+                errors.append(('zip', "missing"))
+            elif not self.has_valid_zip():
+                errors.append(('zip', "bad"))
+
+        return errors
+
+    def has_valid_zip(self):
+        if self.country_code == 'US':
+            if not self.zip:
+                return False
+            return re.match(r'\d{5}(-\d{4})?$', self.zip) is not None
+        return True
+
+    def has_valid_state(self):
+        if self.country_code == 'US':
+            return self.state and self.state in self.STATES
+        return True
+
+    def error_message(self):
+        msg = ""
+        for errors in self._check_complete():
+            msg = msg + "%s is %s, " % (
+                " ".join([word.capitalize() for word in errors[0].split("_")]),
+                errors[1])
+        return (msg and msg[:-2] + '.') or msg
+
+    def format(self):
+        address = ""
+        if self.street1:
+            address += self.street1 + "\n"
+        if self.street2:
+            address += self.street2 + "\n"
+        if self.city:
+            address += self.city + ", "
+        if self.state:
+            address += self.state + " "
+        if self.zip:
+            address += self.zip
+        if address[-1:] != "\n":
+            address += "\n"
+        if self.country_code:
+            address += self.country_code
+        return address
+
+    def format_street(self):
+        result = self.street1 or ''
+        if self.street2:
+            result += ', ' + self.street2
+        return result
+
+add_getters(Address)
+
+
+class Addressable (Mixin):
+    """
+    Mixin for objects that contain addresses
+    """
+    address_is = Address
+
+    def __init__(self, address=None, **kwargs):
+        self.address = address or Address(**kwargs)
+
+add_getters_and_setters(Addressable)
+
+
+class ContactAddress (DulcineaPersistent, Addressable):
+    """
+    This is an address with the additional information normally needed
+    for shipping.
+    """
+    contact_name_is = (string, None)
+    contact_phone_number_is = (string, None)
+    company_name_is = (string, None)
+
+    def __init__(self, contact_name=None, contact_phone_number=None,
+                 company_name=None, **kwargs):
+        Addressable.__init__(self, **kwargs)
+        specify(self,
+                contact_name=contact_name,
+                contact_phone_number=contact_phone_number,
+                company_name=company_name)
+
+    def is_valid(self):
+        return self._check_complete() == []
+
+    def error_message(self):
+        return self.address.error_message()
+
+    def set_address(self, address):
+        raise RuntimeError("ContactAddress is treated as immutable")
+
+    def has_valid_phone_number(self):
+        return is_valid_phone_number(self.get_contact_phone_number(),
+                                     self.get_country_code())
+    def _check_complete(self):
+        """() -> [(attrname:str, errtype:str)]
+
+        Returns a list of (attrname, errtype) 2-tuples explaining any
+        problems.  'errtype' is either "missing" or "bad".
+        """
+        errors = self.address._check_complete()
+        for field in ['contact_name', 'contact_phone_number']:
+            if not getattr(self, field):
+                errors.append((field, "missing"))
+        if self.contact_phone_number and not self.has_valid_phone_number():
+            errors.append(('contact_phone_number', 'bad'))
+        return errors
+
+    def format(self):
+        if self.get_contact_name():
+            output = self.get_contact_name() + "\n"
+        else:
+            output = ""
+        if self.company_name:
+            output += self.company_name + "\n"
+        output += self.address.format()
+        return output
+
+add_getters(ContactAddress)
+delegate(
+    ContactAddress,
+    'address.get_street1',
+    'address.get_street2',
+    'address.get_city',
+    'address.get_state',
+    'address.get_zip',
+    'address.get_country_code')
+
+
+def is_valid_phone_number(number, country_code):
+    if not number:
+        return False
+    number_of_digits = 0
+    for token in number:
+        if token.isdigit():
+            number_of_digits += 1
+    if country_code == 'US':
+        return number_of_digits >= 10
+    else:
+        return number_of_digits >= 7
+
+class ContactAddressable (Mixin):
+    """
+    Mixin for objects that have a contact address.
+    """
+
+    contact_address_is = ContactAddress
+
+    def __init__(self):
+        self.contact_address = ContactAddress()
+
+add_getters_and_setters(ContactAddressable)
+from durus.utils import BytesIO
+from qp.fill.static import FileStream
+from qp.pub.common import not_found
+import tarfile
+import zipfile
+
+class ArchiveWrapper (object):
+
+    def __init__(self, path_to_archive):
+        self.path_to_archive = path_to_archive
+        self.archive = None
+
+    def get_archive(self):
+        if not self.archive:
+            self.archive = self.open_archive(self.path_to_archive)
+        return self.archive
+
+    def open_archive(self, path_to_archive):
+        """(path_to_archive : str)
+
+        Return a reference to archive reading class saved on self.archive
+        """
+        raise NotImplementedError
+
+    def get_names(self):
+        """() -> [str]
+
+        Return a list of file and directory names in the order they appear in
+        the archive.  Directory names end with a slash '/' character.
+        """
+        raise NotImplementedError
+
+    def has_member(self, member_name):
+        """(name : str) -> bool
+
+        True if member is in the archive
+        """
+        raise NotImplementedError
+
+    def get_member_file(self, member_name):
+        """(member_name : str) -> file
+        """
+        raise NotImplementedError
+
+    def get_member_response(self, member_name):
+        """(member_name : str) -> str | Stream
+
+        Return the member named by member_name as a string or if member is
+        streamable as a Stream
+        """
+        raise NotImplementedError
+
+
+class TarFileWrapper (ArchiveWrapper):
+
+    def open_archive(self, path_to_archive):
+        try:
+            return tarfile.open(path_to_archive, 'r:gz')
+        except tarfile.ReadError:
+            try:
+                return tarfile.open(path_to_archive, 'r:')
+            except:
+                not_found("Can't open tar file")
+
+    def get_names(self):
+        fixed_names = []
+        for tarinfo in self.get_archive().getmembers():
+            name = tarinfo.name
+            if name.endswith('//'):
+                fixed_names.append(name[:-1])
+            else:
+                if not name.endswith('/') and tarinfo.isdir():
+                    name += '/'
+                fixed_names.append(name)
+        return fixed_names
+
+    def has_member(self, member_name):
+        if member_name.endswith('/'):
+            try:
+                member = self.get_archive().getmember(member_name)
+            except (KeyError, NameError):
+                member = None
+            if member is None:
+                try:
+                    member = self.get_archive().getmember(member_name[:-1])
+                except (KeyError, NameError):
+                    member = None
+            if member is None:
+                try:
+                    member = self.get_archive().getmember(member_name + '/')
+                except (KeyError, NameError):
+                    member = None
+            return member and member.isdir()
+        else:
+            try:
+                member = self.get_archive().getmember(member_name)
+                return member.isfile()
+            except (KeyError, NameError):
+                return False
+
+    def get_member_file(self, member_name):
+        try:
+            return self.get_archive().extractfile(member_name)
+        except KeyError:
+            return None
+        return None
+
+    def get_member_response(self, member_name):
+        return FileStream(self.get_member_file(member_name))
+
+
+class ZipFileWrapper (ArchiveWrapper):
+
+    def open_archive(self, path_to_archive):
+        if not zipfile.is_zipfile(path_to_archive):
+            not_found('Not a zip file')
+        try:
+            return zipfile.ZipFile(path_to_archive, 'r')
+        except zipfile.error:
+            not_found("Can't open zip file")
+
+    def get_names(self):
+        names = self.get_archive().namelist()
+        directories = set()
+        for name in names:
+            parts = name.split('/')
+            for j in range(len(parts)):
+                directories.add('/'.join(parts[:j]) + '/')
+        for d in directories:
+            if d not in names:
+                names.append(d)
+        return names
+
+    def has_member(self, member_name):
+        try:
+            return bool(self.get_archive().getinfo(member_name))
+        except KeyError:
+            return False
+
+    def get_member_file(self, member_name):
+        return BytesIO(self.get_member_response(member_name))
+
+    def get_member_response(self, member_name):
+        return self.get_archive().read(member_name)

lib/attachable.py

+"""
+$URL: svn+ssh://sting.mems-exchange.org/repos/trunk/dulcinea/lib/attachable.py $
+$Id: attachable.py 32894 2010-10-01 21:45:36Z dbinger $
+
+Provides the Attachable mixin class that allows objects to to have
+file attachments.
+"""
+from dulcinea.stored_file import StoredFile
+from durus.persistent import PersistentObject
+from qp.lib.spec import spec, specify, add_getters_and_setters, require, string
+from qp.lib.spec import Specified, Mixin, datetime_with_tz
+from qp.pub.common import site_now
+from qp.pub.user import User
+
+class Attachment (PersistentObject, Specified):
+    """
+    Stores properties of an attachment relationship between an Attachable
+    object and a StoredFile.
+    """
+    file_is = spec(
+        StoredFile,
+        "the file attached")
+    owner_is = spec(
+        User,
+        "the owner of this association")
+    date_is = spec(
+        datetime_with_tz,
+        "date this attachment was created")
+    filename_is = spec(
+        string,
+        "the filename to use when downloading the file")
+    description_is = spec(
+        (None, string),
+        "a description of the file")
+
+    def __init__(self, file, owner):
+        specify(self,
+                file=file,
+                owner=owner,
+                date=site_now(),
+                filename=file.get_filename(),
+                description=file.get_description())
+
+    def open(self):
+        return self.file.open()
+
+    def get_file_id(self):
+        return self.file.get_id()
+
+    def get_size(self):
+        return self.file.get_size()
+
+    def get_mime_type(self):
+        return self.file.get_mime_type()
+
+    def has_manage_access(self, user):
+        return (user and (user is self.owner or user.is_admin()))
+
+    def is_image(self):
+        return self.get_mime_type().startswith('image/')
+
+add_getters_and_setters(Attachment)
+
+
+class Attachable (Mixin):
+
+    attachments_is = spec(
+        [Attachment],
+        "attachments connected to this object")
+
+    def __init__(self, attachments=None):
+        specify(self, attachments=list(attachments or []))
+
+    def clear_attachments(self):
+        specify(self, attachments=[])
+
+    def get_attachments(self):
+        """() -> [Attachment]
+        Retrieve a list of the attachments for this object.
+        """
+        return self.attachments
+
+    def get_attached_files(self):
+        """() -> [StoredFile]
+        Retrieve a list of the file objects attached to this object.
+        """
+        return [a.get_file() for a in self.attachments]
+
+    def add_attachment(self, attachment):
+        require(attachment, Attachment)
+        for existing_attachment in self.attachments:
+            if existing_attachment.get_file_id() == attachment.get_file_id():
+                return
+        self.attachments = self.attachments + [attachment]
+
+    def get_attachment(self, id):
+        """(id : string) -> Attachment | None
+        """
+        for attachment in self.attachments:
+            if attachment.get_file_id() == id:
+                return attachment
+        return None
+
+    def attach_file(self, stored_file, user):
+        """(stored_file: StoredFile)
+        Attach a file to this object.
+        """
+        files = self.attachments[:]
+        files.append(Attachment(stored_file, user))
+        self.attachments = files
+
+    def detach_attachment(self, attachment):
+        """(attachment: Attachment)
+        Detach an attachment from this object.
+        """
+        require(attachment, Attachment)
+        files = self.attachments[:]
+        files.remove(attachment)
+        self.attachments = files
+
+    def attachment_modified(self, attachment, user):
+        """(attachment : Attachment, user : DulcineaUser)
+
+        'attachment' was modified by 'user'.
+        Override this method to intercept modification activity for attachments
+        """
+        pass
+
+    def get_allowed_mime_types(self, user=None):
+        """() -> [string]
+
+        Override to restrict the types of files that can be attached
+        For example: return [mime_type[0] for mime_type in MIME_TYPES]
+        """
+        return []
+
+
+"""
+$URL: svn+ssh://sting.mems-exchange.org/repos/trunk/dulcinea/lib/base.py $
+$Id: base.py 31344 2008-11-21 20:45:40Z dbinger $
+"""
+from durus.persistent import PersistentObject
+from copy import copy
+from qp.lib.spec import Specified, Mixin
+
+class Copyable (Mixin):
+
+    def copy(self):
+        return copy(self)
+
+
+class DulcineaPersistent (PersistentObject, Specified, Copyable):
+
+
+    def __copy__(self):
+        new_instance = PersistentObject.__new__(self.__class__)
+        new_instance.__setstate__(self.__getstate__())
+        return new_instance
+
+    def __eq__(self, other):
+        return (self.__class__ == getattr(other, '__class__', None) and
+            other.__getstate__() == self.__getstate__())
+
+    def __ne__(self, other):
+        return not self == other
+
+    def __hash__(self):
+        return id(self)
+"""
+$URL: svn+ssh://sting.mems-exchange.org/repos/trunk/dulcinea/lib/calendar.py $
+$Id: calendar.py 31389 2008-12-11 15:19:14Z dbinger $
+"""
+from datetime import timedelta
+from dulcinea.base import DulcineaPersistent
+from dulcinea.sort import method_sort
+from dulcinea.user import DulcineaUser as User
+from qp.lib.keep import Keyed, Keep
+from qp.lib.spec import boolean, add_getters_and_setters, init, specify
+from qp.lib.spec import string, datetime_with_tz
+
+
+def weekdays(start, end):
+    """(start: datetime, end: datetime) -> int
+
+    Return the number of weekdays between two dates.
+    """
+    days = (end - start).days
+    if days < 0:
+        raise ValueError('end date must be after start date')
+    weeks, partial = divmod(days, 7)
+    if partial > 0:
+        sunday_distance = (6 - start.weekday()) % 7
+        saturday_distance = (5 - start.weekday()) % 7
+        if sunday_distance < partial:
+            partial -= 1
+        if saturday_distance < partial:
+            partial -= 1
+    return 5*weeks + partial
+
+
+class Titled(object):
+
+    title_is = string
+    description_is = string
+
+    def __init__(self):
+        init(self)
+
+add_getters_and_setters(Titled)
+
+
+class Reservable(Keyed, Titled):
+
+    active_is = boolean
+
+    valid_permissions = {
+        'primary_contact' : 'primary contact',
+        'control' : 'able to change any reservations of this item',
+        'reserve' : 'able to make a reservation of this item',
+        }
+
+    def __init__(self):
+        Keyed.__init__(self)
+        Titled.__init__(self)
+        self.active = True
+
+    def set_active(self, value):
+        self.active = value
+
+    def is_active(self):
+        return self.active
+
+    def allows_reserve(self, user):
+        return user.is_granted('reserve', self)
+
+    def allows_control(self, user):
+        return (user.is_granted('control', self) or
+                user.is_granted('primary_contact', self))
+
+
+class Resource (DulcineaPersistent, Reservable):
+
+    def __init__(self):
+        Reservable.__init__(self)
+
+
+class Resources (DulcineaPersistent, Keep):
+
+    def __init__(self):
+        Keep.__init__(self, Resource)
+
+    def delete_resource(self, resource):
+        del self.get_mapping()[resource.get_key()]
+
+
+class Reservation (DulcineaPersistent, Keyed, Titled):
+
+    start_time_is = datetime_with_tz
+    end_time_is = datetime_with_tz
+    reserved_is = Reservable
+    reserver_is = User
+
+    def __init__(self):
+        Keyed.__init__(self)
+        Titled.__init__(self)
+        init(self)
+
+    def __str__(self):
+        return "%s: %s" % (self.key, self.start_time)
+
+    def set_start_time(self, time):
+        time.replace(second=0, microsecond=0)
+        specify(self, start_time=time)
+
+    def set_end_time(self, time):
+        assert time >= self.start_time
+        time.replace(second=0, microsecond=0)
+        specify(self, end_time=time)
+
+    def is_active(self, start, end):
+        return (self.get_end_time() > start and
+                self.get_start_time() < end)
+
+    def can_modify(self, user):
+        """(user: User) -> bool
+        """
+        if user is self.reserver:
+            return True
+        if not user:
+            return False
+        if user.is_admin():
+            return True
+        if self.reserved.allows_control(user):
+            return True
+        return False
+
+add_getters_and_setters(Reservation)
+
+
+def create_reservation_table(reservations, time_increment,
+                             start_time, end_time,
+                             start_time_of_day, end_time_of_day):
+    """(reservations : [Reservation]
+        time_increment:timedelta,
+        start_time:datetime,
+        end_time:datetime,
+        start_time_of_day:timedelta,
+        end_time_of_day:timedelta) -> {
+            (day:datetime, time_of_day:timedelta) :
+            (reservation:Reservation, rowspan:int) | None }
+
+    This calculates information useful for displaying a tabular view
+    of a set of reservations.
+    The time_increment is amount of time for each row of the table.
+    The start_time and end_time determine the columns (days) of the table.
+    The start_time_of_day and end_time_of_day limit the rows that are shown.
+
+    The function returns a dictionary with an entry for each time block
+    that is not vacant.  For the first block of each day for a given
+    reservation, the dictionary maps (day, time_of_day) to
+    (reservation, rowspan), meaning that this reservation appears first
+    on this day at this time of day, and that it extends across rowspan
+    time increments.
+
+    The cells of the table that are continuations of a reservation that
+    has already appeared above have entries that map (day, time_of_day)
+    to None.
+    """
+
+    reservations = [reservation for reservation in reservations
+                    if (reservation.get_start_time() < end_time and
+                        reservation.get_end_time() > start_time)]
+    reservations = method_sort(reservations, 'get_start_time')
+    tableau = {}
+    day_delta = timedelta(days=1)
+    def gen_days():
+        day = start_time
+        while day < end_time:
+            yield day
+            day = day + day_delta
+    for day in gen_days():
+        day_reservations = [
+            reservation for reservation in reservations
+            if reservation.is_active(day + start_time_of_day,
+                                     day + end_time_of_day)]
+        time_of_day = start_time_of_day
+        while time_of_day < end_time_of_day:
+            if day_reservations:
+                reservation = day_reservations[0]
+                period_end = day + time_of_day + time_increment
+                if reservation.get_start_time() < period_end:
+                    start = (day, time_of_day)
+                    reservation_end = reservation.get_end_time()
+                    rows = 1
+                    next_time = time_of_day + time_increment
+                    while (next_time < end_time_of_day and
+                           day + next_time < reservation_end):
+                        rows += 1
+                        time_of_day = next_time
+                        next_time = next_time + time_increment
+                        tableau[(day, time_of_day)] = None # mark continuation
+                    tableau[start] = (reservation, rows)
+                    day_reservations = day_reservations[1:]
+            time_of_day = time_of_day + time_increment
+    return tableau
+
+
+
+class Calendar (DulcineaPersistent, Keep):
+
+    def __init__(self):
+        Keep.__init__(self, Reservation)
+
+    def get_reservations(self):
+        return list(self.get_mapping().values())
+
+    def __iter__(self):
+        return self.get_mapping().itervalues()
+
+    def delete_reservation(self, reservation):
+        del self.get_mapping()[reservation.get_key()]
+
+    def get_reservations_for(self, resource):
+        return [reservation for reservation in self.get_reservations()
+                if resource is reservation.get_reserved()]
+
+    def get_table_map(self, resource, time_increment,
+                      start_time, end_time,
+                      start_time_of_day, end_time_of_day):
+        return create_reservation_table(self.get_reservations_for(resource),
+                                        time_increment, start_time, end_time,
+                                        start_time_of_day, end_time_of_day)
+"""
+$URL: svn+ssh://sting.mems-exchange.org/repos/trunk/dulcinea/lib/category.py $
+$Id: category.py 33181 2010-11-15 22:50:32Z dbinger $
+"""
+from dulcinea.base import DulcineaPersistent
+from durus.persistent import ComputedAttribute
+from durus.persistent_dict import PersistentDict
+from durus.persistent_list import PersistentList
+from qp.lib.spec import add_getters_and_setters, Mixin
+from qp.lib.spec import instance, sequence, specify, mapping
+from qp.lib.spec import spec, require, string, pattern, both
+
+
+class Category (DulcineaPersistent):
+    """
+    A category is a node in a partial ordering.
+    """
+    name_is = spec(
+        (None, both(string, pattern('[a-zA-Z0-9_-]*$'))),
+        "unique, URL-friendly identifier for this category")
+    label_is = spec(
+        (string, None),
+        "human-readable string that describes this category; probably but "
+        "not necessarily unique")
+    description_is = spec(
+        (string, None),
+        "paragraph-length, human-readable prose description of this category")
+    children_is = sequence(instance('Category'), PersistentList)
+    parents_is = sequence(instance('Category'), PersistentList)
+    _v_expansion_is = ComputedAttribute
+
+    def __init__(self, name=None, label=None, description=None):
+        specify(self, name=name, label=label, description=description,
+                children=PersistentList(),
+                parents=PersistentList(),
+                _v_expansion=ComputedAttribute())
+
+    def __str__(self):
+        return self.name or "*unnamed*"
+
+    def add_child(self, child):
+        require(child, Category)
+        if child.is_ancestor_of(self):
+            raise ValueError("Attempt to create circular relationship")
+        if child not in self.children:
+            self.children.append(child)
+            child.parents.append(self)
+        self._invalidate_expansion()
+
+    def remove_child(self, child):
+        require(child, Category)
+        self.children.remove(child)
+        child.parents.remove(self)
+        self._invalidate_expansion()
+
+    def set_children(self, children):
+        require(children, list)
+        for child in self.children[:]: # remove_child() modifies self.children
+            self.remove_child(child)
+        for child in children:
+            self.add_child(child)
+
+    def set_parents(self, parents):
+        require(parents, list)
+        for parent in self.parents[:]: # remove_child() modifies self.parents
+            parent.remove_child(self)
+        for parent in parents or []:
+            parent.add_child(self)
+
+    def _invalidate_expansion(self):
+        self._v_expansion.invalidate()
+        for parent in self.parents:
+            parent._invalidate_expansion()
+
+    def expand(self):
+        """() -> { Category : bool }
+
+        Return a set containing this category and its (recursive) children.
+        """
+        def compute():
+            expansion = {self: 1}
+            for child in self.children:
+                if child not in expansion:
+                    expansion.update(child.expand())
+            return expansion
+        return self._v_expansion.get(compute)
+
+    def get_descendants(self):
+        """() -> [ Category ]
+
+        Return list containing this category's (recursive) children.
+        """
+        descendants = []
+        for child in self.children:
+            if child not in descendants:
+                descendants.append(child)
+                descendants += [descendant