Mercurial > p > roundup > code
changeset 1242:3d0158c8c32b maint-0.5
subsumed by other doc
| author | Richard Jones <richard@users.sourceforge.net> |
|---|---|
| date | Mon, 30 Sep 2002 01:17:10 +0000 |
| parents | bc0ffa0cd7ac |
| children | 83f33642d220 |
| files | BUILD.txt CHANGES.txt MANIFEST.in TODO.txt cgi-bin/roundup.cgi doc/.cvsignore doc/FAQ.txt doc/Makefile doc/announcement.txt doc/customizing.txt doc/design.txt doc/developers.txt doc/getting_started.txt doc/index.txt doc/installation.txt doc/upgrading.txt doc/user_guide.txt frontends/ZRoundup/ZRoundup.py frontends/ZRoundup/__init__.py roundup/__init__.py roundup/admin.py roundup/backends/__init__.py roundup/backends/back_anydbm.py roundup/backends/back_bsddb3.py roundup/backends/back_gadfly.py roundup/backends/back_metakit.py roundup/backends/back_sqlite.py roundup/backends/portalocker.py roundup/backends/rdbms_common.py roundup/cgi/PageTemplates/PythonExpr.py roundup/cgi/PageTemplates/TALES.py roundup/cgi/TAL/TALGenerator.py roundup/cgi/TAL/TALInterpreter.py roundup/cgi/__init__.py roundup/cgi/client.py roundup/cgi/templating.py roundup/date.py roundup/hyperdb.py roundup/mailgw.py roundup/password.py roundup/roundupdb.py roundup/scripts/roundup_server.py roundup/templates/classic/config.py roundup/templates/classic/dbinit.py roundup/templates/classic/detectors/statusauditor.py roundup/templates/classic/html/issue.index roundup/templates/classic/html/issue.item roundup/templates/classic/html/issue.search roundup/templates/classic/html/query.item roundup/templates/classic/html/style.css roundup/templates/classic/html/user.index roundup/templates/classic/html/user.item roundup/templates/classic/interfaces.py roundup/templates/minimal/config.py roundup/templates/minimal/dbinit.py roundup/templates/minimal/html/style.css roundup/templates/minimal/html/user.index roundup/templates/minimal/interfaces.py setup.py test/test_dates.py test/test_db.py test/test_locking.py test/test_mailgw.py test/test_mailsplit.py |
| diffstat | 63 files changed, 0 insertions(+), 25769 deletions(-) [+] |
line wrap: on
line diff
--- a/BUILD.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,55 +0,0 @@ - Roundup - ======= - -1. Building Releases -==================== - -Roundup is currently a source-only release - it has no binary components. I -want it to stay that way, too. - -This means that we only need to ever build source releases. This is done by -running: - - 0. Edit setup.py and doc/announcement.txt to reflect the new version and - appropriate announcments. - 1. python setup.py clean --all - 2. Edit setup.py to ensure that all information therein (version, contact - information etc) is correct. - 3. python setup.py sdist --manifest-only - 4. Check the MANIFEST to make sure that any new files are included. If - they are not, edit MANIFEST.in to include them. "Documentation" for - MANIFEST.in may be found in disutils.filelist._parse_template_line. - 5. python setup.py sdist - (if you find sdist a little verbose, add "--quiet" to the end of the - command) - -So, those commands in a nice, cut'n'pasteable form :) -python setup.py clean --all -python setup.py sdist --manifest-only -python setup.py sdist --quiet - -or, for the sad RedHat users: -python2 setup.py clean --all -python2 setup.py sdist --manifest-only -python2 setup.py sdist --quiet - - -2. Distributing Releases -======================== - -Once a release is built, follow these steps: - 1. FTP the tar.gz from the dist directory to to the "incoming" directory on - "upload.sourceforge.net". - 2. Make a quick release at: - http://sourceforge.net/project/admin/qrs.php?package_id=&group_id=31577 - 3. Add a news item at: - https://sourceforge.net/news/submit.php?group_id=31577 - using the top of doc/announcement.txt - 4. Send doc/announcement.txt to python-announce@python.org - 5. Notify any other news services as appropriate... - - -3. Author -========= -richard@users.sourceforge.net -
--- a/CHANGES.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,767 +0,0 @@ -This file contains the changes to the Roundup system over time. The entries -are given with the most recent entry first. - -2002-09-?? 0.5.0 ???? -- handling of None for Date/Interval/Password values in export/import -- handling of journal values in export/import -- password edit now has a confirmation field -- registration error punts back to register page -- gadfly backend now handles changes to the schema - but only one property - at a time -- cgi.client base URL is now obtained from the config TRACKER_WEB -- request.url has gone away - there's too much magic in trying to figure - what it should be -- cgi-bin script redirects to https now if the request was https -- FileClass "content" property wasn't being returned by getprops() in most - backends -- we now verify instance attributes on instance open and throw a useful error - if they're not all there -- sf 611217 ] menu() has problems when labelprop==None -- verify contents of tracker module when the tracker is opened -- many performance improvements in *dbm and sql backends -- mailgw was missing an "import sys" -- setup now installs scripts with python -O flag, doubling performance in some - cases (there's a lot of __debug__ use) -- fix :required for Link menus -- import wasn't setting the ID to maxid+1 -- added getItem to HTMLClass so you can access arbitrary items in templates -- index filtering form values may now be key values too -- replaced the content() callback ickiness with Page Template macro usage -- changed the default CSS style to be less offensive to some ;) -- better handling of Page Template compilation errors -- handle multiple unrelated indexed classes -- #614188 ] Exception in mailgw.py -- #613310 ] traceback on onexistant items -- #613291 ] typos in nosy list -- handle stupid mailers that QUOTE their Re; 'Re: "[issue1] bla blah"' -- giving a user a Role that doesn't exist doesn't break stuff any more -- revamped user guide, customisation guide, added maintenance guide -- merge Zope Collector #538 fix from ZPT CVS trunk (path expressions with a - non-path final alternate no longer try to call a value returned by that - alternate) -- merge Zope Collector #573 fix from ZPT CVS trunk -- merge Zope Collector #580 fix from ZPT CVS trunk -- added "crypt" password encoding and ability to set password with - already encrypted password through roundup-admin - - -2002-09-13 0.5.0 beta2 -- all backends now have a .close() method, and it's used everywhere -- fixed bug in detectors __init__ -- switched the default issue item display to only show issue summary - (added instructions to doc to make it display entire content) -- MANIFEST.in was missing a lot of template files -- added generic item editing -- much nicer layout of template rendering errors -- added context/is_edit_ok and context/is_view_ok convenience methods and - implemented use of them in the classic template - - -2002-09-11 0.5.0 beta1 -Fixed: -- #576086 ] dumb copying mistake (frontends/ZRoundup.py) -- installation instructions now mention "python2" in "testing your python". -- made the unit tests run again - they were quite b0rken -- #571170 ] gdbm deadlock -- #576241 ] MultiLink problems in parsePropsFromForm -- fixed the date module so that Date(". - 2d") works -- web forms may now unset Link values (like assignedto) -- cleanup: moved roundup.templatebuilder to roundup.templates.builder -- instance __init__ no longer silently traps dbinit import errors - -Feature: -- new backend for metakit (thanks Gordon McMillan) -- new backend for gadfly (it's as done as it's going to get) -- further split the dbm backends from the core code, allowing easier - non-dict-like backends (eg metakit, RDB) -- implemented and used the new access control mechanisms (Permissions, Roles) - (see doc/security.txt) -- switched templating to use Zope's PageTemplates (yay!) -- switched to sessions for web authentication -- added Boolean and Number types -- fixed the journal bloat -- updated design document for new access controls -- updated customisation document, including more examples -- entire database export and import (incl files) -- better mailgw help message (feature request #558562) -- re-enabled link backrefs from messages (feature request #568714) -- the page layout is now templatable -- re-worked cgi interface to abstract out the explicit "issue" interface -- have index page handle mid-page errors better so header and footer are - still visible -- we handle "not found", access and item page render errors better -- fixed double-submit by having new-item-submit redirect at end -- daemonify roundup-server (fork, logfile, pidfile) -- modify cgitb to display PageTemplate errors better -- rename to "instance" to "tracker" -- have roundup.cgi pick up tracker config from the environment -- revamped look and feel in web interface -- cleaned up stylesheet usage -- several bug fixes and documentation fixes -- added is_retired test to hyperdb.Class -- added capability to save queries: - - a query Class with name, klass (to search) and url (query string) - properties - - a Multilink to query on user called queries - - html templates for query, and a list of queries in user.item - - search form has Save button & name input - - saved queries put in menu in pagehead - - for migration, none of the above is required and old behavior preserved. - - showquery translates search form <-> query string -- cleaned up the indexer code: - - it splits more words out - - removed code we'll never use (roundup.roundup_indexer has the full - implementation, and replaces roundup.indexer) - - only index text/plain and rfc822/message (ideas for other text formats to - index are welcome) - - added simple unit test for indexer. Needs more tests for regression. - - all String properties may now be indexed too. Currently there's a bit of - "issue" specific code in the actual searching which needs to be - addressed. In a nutshell: - + pass 'indexme="yes"' as a String() property initialisation arg, eg: - file = FileClass(db, "file", name=String(), type=String(), - comment=String(indexme="yes")) - + the comment will then be indexed and be searchable, with the results - related back to the issue that the file is linked to - - as a result of this work, the FileClass has a default MIME type that may - be overridden in a subclass, or by the use of a "type" property as is - done in the default templates. - - the regeneration of the indexes (if necessary) is done once the schema is - set up in the dbinit. - - new "reindex" command in roundup-admin used to force regeneration of the - index -- added email display function - mangles email addrs so they're not so easily - scraped from the web -- switched to using a session-based web login -- made mailgw handle set and modify operations on multilinks (bug #579094) -- fixed the journal bloat from multilink changes - we just log the add or - remove operations, not the whole list - - -2002-06-24 0.4.2 -Fixed: -- Cleaned up the hyperdb unit tests. -- Applied patch from Andrew W. Nosenko to give nicer Unauthorised message - when anonymous user tries to edit. Should've been applied in 0.4.2pr1. Oops. -- Added more detailed note to MIGRATION regarding the detectors changes. - - -2002-06-19 0.4.2pr1 -Feature: -- added a "detectors" directory for people to put their useful auditors and - reactors in. Note - the roundupdb.IssueClass.sendmessage method has been - split and renamed "nosymessage" specifically for things like the nosy - reactor, and "send_message" which just sends the message. -- link() htmltemplate function now has a "showid" option for links and - multilinks. When true, it only displays the linked node id as the anchor - text. The link value is displayed as a tooltip using the title anchor - attribute. - To use in eg. the superseder field, have something like this: - <td> - <display call="field('superseder', showid=1)"> - <display call="classhelp('issue', 'id,title', label='list', width=500)"> - <property name="superseder"> - <br>View: <display call="link('superseder', showid=1)"> - </property> - </td> -- stripping of the email message body can now be controlled through the - config variables EMAIL_KEEP_QUOTED_TEXT and EMAIL_LEAVE_BODY_UNCHANGED. -- all database files created are now group readable and writable. -- added option to automatically add the authors and recipients of messages - to the nosy lists with the options ADD_AUTHOR_TO_NOSY (default 'new') and - ADD_RECIPIENTS_TO_NOSY (default 'new'). These settings emulate the current - behaviour. Setting them to 'yes' will add the author/recipients to the nosy - on messages that create issues and followup messages. -- reverting to dates for intervals > 2 months sucks -- changed the default message list in issues to display the message body -- applied patch #558876 ] cgi client customization -- split instance initialisation into two steps, allowing config changes - before the database is initialised. -- don't create an empty message on email issue creation if the email is empty -- may now display additional fields in Multilink form menus -- #541941 ] changing multilink properties by mail -- #526730 ] search for messages capability -- #505180 ] split MailGW.handle_Message - - also changed cgi client since it was duplicating the functionality - -Fixed: -- stop sending blank (whitespace-only) notes -- cleanup of serialisation for database storage -- node ids are now generated from a lockable store - no more race conditions -- sorting was applied to all nodes of the MultiLink class instead of - to the nodes that are actually linked to in the "field" template - function. This adds about 20+ seconds in the display of an issue if - your database has a 1000 or more issues in it. -- added missing documentation for a few of the config option values -- file upload broke if you didn't supply a change note -- fixed SCRIPT_NAME in ZRoundup for instances not at top level of Zope - (thanks dman) -- fixed some sorting issues that were breaking some unit tests under py2.2 -- mailgw test output dir was confusing the init test (but only on 2.2 *shrug*) -- node caching now works, and gives a small boost in performance -- #449374 ] re-enable bsddb3 backend - bsddb3 backend now works, reinstating -- #551483 ] assignedto in Client.make_index_link -- made backends.__init__ be more specific about which ImportErrors it really - wants to ignore -- fixed the example addresses in the templates to use correct example domains -- cleaned out the template stylesheets, removing a bunch of junk that really - wasn't necessary (font specs, styles never used) and added a style for - message content -- build htmlbase if tests are run using CVS checkout -- #565979 ] code error in hyperdb.Class.find -- #565996 ] The "Attach a File to this Issue" fails -- #564271 ] find() and new properties -- #562130 ] cookie path generated from ZRoundup was wrong in some situations -- remove CR characters embedded in messages (ZRoundup) -- properly quote the email address and "real name" in all situations using the - 'email' module if it is available and 'rfc822' otherwise -- #565992 ] if ISSUE_TRACKER_WEB doesn't have the trailing '/', add it -- use the rfc822 module to ensure that every (oddball) email address and - real-name is properly quoted -- #558867 ] ZRoundup redirect /instance requests to /instance/ -- #569415 ] {version} -- #569178 ] type error - was fixed as part of the general cleanup of reactors - - -2002-03-25 - 0.4.1 -Feature: -- use blobfiles in back_anydbm which is used in back_bsddb. - change test_db as dirlist does not work for subdirectories. - ATTENTION: blobfiles now creates subdirectories for files. -- add module blobfiles in backends with file access functions. -- roundup db catch only IOError in getfile. -- roundup db catches retrieving not existing files. -- #503204 ] mailgw needs a default class - - partially done - the setting of additional properties can wait for a - better configuration system. -- Alternate email addresses are now available for users. See the MIGRATION - file for info on how to activate the feature. -- #511168 ] Web interface: Adding new products - Classes that don't provide template html get a default edit interface now: - - access using the admin "class list" interface - - limited to admin-only - - requires the csv module from object-craft (url given if it's missing) -- Added popup help for classes using the classhelp html template function. - - add <display call="classhelp('priority', 'id,name,description')"> - to an item page, and it generates a link to a popup window which displays - the id, name and description for the priority class. The description - field won't exist in most installations, but it will be added to the - default templates. -- #517734 ] web header customisation is obscure -- All messages sent to the nosy list are now encoded as - quoted-printable before they are sent. -- Fixed display of mutlilink properties when using the template - functions, menu and plain. - -Fixed: -- Clean up mail handling, multipart handling. -- respect encodings in non multipart messages. -- makeHtmlBase: re.sub under python 2.2 did not replace '.', string.replace - does it. -- preamble in tepmlateBuilder mentioned htmldata -- mailgw checks encoding on first part too. -- #511586 ] unittest FAIL: testReldate_date -- Added a uniquely Roundup header to email, "X-Roundup-Name" -- All forms now have "double-submit" protection when Javascript is enabled - on the client-side. -- #516883 ] mail interface + ANONYMOUS_REGISTER -- #516854 ] "My Issues" and redisplay -- #517906 ] Attribute order in "View customisation" -- #514854 ] History: "User" is always ticket creator -- wasn't handling cvs parser feeding correctly -- fixed some problems in date calculations (calendar.py doesn't handle over- - and under-flow). Also, hour/minute/second intervals may now be more than - 99 each. -- #527416 ] roundup-admin uses undefined value -- #527503 ] unfriendly init blowup when parent dir - (also handles UsageError correctly now in init) -- #524129 ] roundup-admin gets python path wrong - - -2002-01-24 - 0.4.0 -Feature: -- much nicer history display (actualy real handling of property types etc) -- journal entries for link and mutlilink properties can be switched on or - off -- properties in change note are now sorted -- you can now use the roundup-admin tool pack the database - -Fixed: -- the mail gateway now responds with an error message when invalid values - for arguments are specified for link or mutlilink properties -- modified unit test to check nosy and assignedto when specified as arguments -- handle attachments with no name (eg tnef) -- fixed setting nosy as argument in subject line -- fixed back_bsddb so it passed the journal tests -- fixed status changes in mail gateway (eg. unread -> chatting) -- we'll actually distribute the frontends directory now, as advertised... -- handle stripping of "AW:" from subject line -- htmltemplate list() wasn't sorting... -- unit tests for html templating (and re-enabled the listbox field for - multilinks) -- allow abbreviation of "help" in admin tool too. -- run_tests testReldate_date failed if LANG is 'german' -- mailgw failures (unexpected ones) are forwarded to the roundup admin - - -2002-01-16 - 0.4.0b2 -Fixed: -- #495392 ] empty nosy -patch -- #500574 ] messageid must have format <part1@part2> -- fixed some problems with web editing and change detection -- mail splitting wasn't detecting responses in the same "section" as quoted - text -- missed a "from i18n import _" in date.py -- #501690 ] MIGRATION.txt incomplete -- #502342 ] pipe interface -- #502437 ] rogue reactor and unittest -- re-enabled dumbdbm when using python >2.1.1 (ie 2.1.2, 2.2) -- changed all config accesses so they access either the instance or the - config attriubute on the db. This means that all config is obtained from - instance_config instead of the mish-mash of classes. This will make - switching to a ConfigParser setup easier too, I hope. -- #502951 ] adding new properties to old database -- #502953 ] nosy-like treatment of other multilinks -- #503164 ] create and passwords -- plain rendering of links in the htmltemplate now generate a hyperlink to - the linked node's page. -- #503330 ] ANONYMOUS_REGISTER now applies to mail -- #503353 ] setting properties in initial email -- #502956 ] filtering by multilink not supported -- #503340 ] creating issue with [asignedto=p.ohly] -- #502949 ] index view for non-issues and redisplay -- #503793 ] changing assignedto resets nosy list -- lots of date/interval related changes: - - more relaxed date format for input - - handle None for date/interval properties - - -2002-01-08 - 0.4.0b1 -Feature: -- Added INSTANCE_NAME to configuration - used in web and email to identify - the instance. -- Added EMAIL_SIGNATURE_POSITION to indicate where to place the roundup - signature info in e-mails. -- Some more flexibility in the mail gateway and more error handling. -- Login now takes you to the page you back to the were denied access to. -- Admin user now can has a user index link on their web interface. -- We now have basic transaction support. Information is only written to - the database when the commit() method is called. Only the anydbm and - bsddb3 backends are modified in this way - the bsddb3 backend needs a - lot more work anyway... - - the CGI and mailgw automatically commit() at the end of processing a - single transaction - - the admin tool requires an explicit "commit" - it will prompt at exit - if there are unsaved changes. A "rollback" removes all changes made - during the session (up to the last commit). -- Added the "display" command to the admin tool - displays a node's values -- Message author's name appears in From: instead of roundup instance name - (which still appears in the Reply-To:) -- Added a Zope frontend for roundup. -- Centralised the python version check code, bumped version to 2.1.1 (really - needs to be 2.1.2, but that isn't released yet :) -- much better attaching of erroneous messages in the mail gateway -- #496356 ] Use threading in messages - This adds the tracking of messages by message-id and allows threading - using in-reply-to. Most e-mail clients support threading using this - feature, and we hope to add support for it to the web gateway. - -Fixed: -- Lots of bugs, thanks Roché and others on the devel mailing list! -- login_action and newuser_action return values were being ignored -- Woohoo! Found that bloody re-login bug that was killing the mail - gateway. -- Fixed login/registration forwarding the user to the right page (or not, - on a failure) -- We now use weakrefs in the Classes to keep the database reference, so - the close() method on the database is no longer needed. -- #487480 ] roundup-server -- #487476 ] INSTALL.txt -- #489760 ] [issue] only subject -- fixed doc/index.html to include the quoting in the mail alias. -- fixed the backends __init__ so we can pydoc the backend modules -- web i/f reports "note added" if there are no changes but a note is entered -- we were assuming database files created by anydbm had the same name, but - this is not the case for dbm. We now perform a much better check _and_ - cope with the anydbm implementation module changing too! -- envelope-from is now set to the roundup-admin and not roundup itself so - delivery reports aren't sent to roundup (thanks Patrick Ohly) -- #495400 ] entering blanks - Values with spaces are now accepted in roundup-admin - check the long help - for details. -- #496360 ] table width does not work -- detectors were being registered multiple times -- added tests for mailgw - - -2001-11-23 - 0.3.0 -Feature: -- #467129 ] Lossage when username=e-mail-address -- #473123 ] Change message generation for author -- MailGW now moves 'resolved' to 'chatting' on receiving e-mail for an issue. -- Added Structured Text rendering to htmltemplate, thanks Brad Clements. -- Added CGI configuration via env vars (see roundup.cgi for details) -- "roundup.cgi" is now installed to "<python-prefix>/share/roundup/cgi-bin" -- roundup-admin now accepts abbreviated commands (eg. l = li = lis = list) -- roundup-mailgw now supports unix mailbox and POP as sources of mail. -- roundup-admin now handles all hyperdb exceptions -- users may attach files to issues (and support in ext) through the web now -- incorporated patch from Roch'e Compaan implementing attachments in nosy - e-mail -- added a target version field to the extended issue schema -- added dummy hooks for I18N and some preliminary (test) markup of - translatable messages - -Fixed: -- Fixed a bug in HTMLTemplate changes. -- 'unread' to 'chatting' automagic status change was b0rken. -- Anonymous user lockout wasn't working. -- roundup-server now works on Windows, thanks Juergen Hermann. -- Fixed install documentation, also thanks Juergen Hermann. -- Fixed some URL issues in roundup.cgi, again thanks Juergen Hermann. -- bug #475347 ] WindowsError still not caught (patch from Juergen Hermann) -- bug #474749 ] indentations lost -- bug #477104 ] HTML tag error in roundup-server -- bug #477107 ] HTTP header problem -- bug #477687 ] conforming html -- bug #474372 ] Netscape 4.77 do not render Support form -- bug #477685 ] base64.decodestring breaks -- bug #477837 ] lynx does not like the cookie -- bug #477892 ] Password edit doesn't fix login cookie -- newuser_action now presents error messages rather than tracebacks. -- bug #479511 ] mailgw to pop -- bug #479508 ] roundup-admin crash on wrong class -- bad error report in hyperdb -- roundup.mailgw now handles errors on the set() and create() at the end - of processing -- roundup.mailgw also handles messages that are passed to it that don't - contain a From: line - apparently some POP servers can do this. It punts - an error message to the roundup admin. -- fixed nosy reaction and author copy handling -- errors in nosy reaction will be propogated now (were effectively being - squashed) -- re-open the database as the author in mail handling -- missing "return" in filter_section (thanks Roch'e Compaan) - - -2001-10-23 - 0.3.0 pre 3 -Feature: -- MailGW now moves 'unread' to 'chatting' on receiving e-mail for an issue. -- feature #473127: Filenames. I modified the file.index and htmltemplate - source so that the filename is used in the link and the creation - information is displayed. - Admin Tool (roundup-admin): - - Interactive mode for running multiple (independant at present) commands. - - Tabular display of nodes. - - Import and export via colon-separated files. - -Changed: -- re-organised the html templating code. Fixed some bugs, probably - introduced some more. Hopefully not too many. - -Fixed: -- Stand-alone server now has a configurable setuid user. -- CGI interface wasn't handling checkboxes at all. -- Fixed quopri usage in mailgw from bug reports on mailing list. -- Remove the "freshen" command from the roundup-admin tool. -- Catch errors in login - no username or password supplied. -- Fixed editing of password (Password property type) thanks Roch'e Compaan. -- Fixed grouping of non-str properties thanks Roch'e Compaan. -- bug #473121: The customisation view and filters (CGI interface view - customisation section may now be hidden (patch from Roch'e Compaan.) -- bug #473122: Issue id sorting (hyperdb sorts strings-that-look-like-numbers - as numbers now. -- bug #473124: UI inconsistency with Link fields. - This also prompted me to fix a fairly long-standing usability issue - - that of being able to turn off certain filters. -- bug #473125: Paragraph in e-mails -- bug #473126: Sender unknown -- bug #473130: Nosy list not set correctly - - -2001-10-11 - 0.3.0 pre 2 -Fixed: -- Hyperdatabase was inserting empty strings instead of None for missing - property values. This broke a lot of things. - - -2001-10-10 - 0.3.0 pre 1 -Feature: -- roundup-admin create now prompts for property info if none is supplied - on the command-line. -- hyperdb Class getprops() method may now return only the mutable - properties. -- CGI interfaces now generate a top-level index of their known instances. - -Changed: -- Login now uses cookies, which makes it a whole lot more flexible. We can - now support anonymous user access (read-only, unless there's an - "anonymous" user, in which case write access is permitted). Login - handling has been moved into cgi_client.Client.main() -- The "extended" schema is now the default in roundup init. -- The schemas have had their page headings modified to cope with the new - login handling. Existing installations should copy the interfaces.py - file from the roundup lib directory to their instance home. -- Passwords are now encoded by default (except exising databases which - will only be encoded when the passwords are changed). The scheme used - at the moment is SHA - but the code is flexible enough to take any - number of encoding systems. -- The roundup-admin tool always operates as the "admin" user now. Database - protection should be achieved using file system protections (see the - documentation for details.) - -Fixed: -- Incorrectly had a Bizar Software copyright on the cgitb.py module from - Ping - has been removed. -- Pretty time interval wasn't handling > 1 month properly. -- Generation of links to Link/Multilink in indexes. (thanks Hubert Hoegl) -- AssignedTo wasn't in the "classic" schema's item page. -- Fixed a whole bunch of places in the CGI interface where we should have - been returning Not Found instead of throwing an exception. -- Fixed a deviation from the spec: trying to modify the 'id' property of - an item now throws an exception. -- The plain() template function now html-escapes the content. -- Change message was stuffing up for multilinks with no key property. - - - --------------- - -2001-08-30 - 0.2.8 -Fixed: -- Wasn't handling unguessable mime types for file uploads. -- Missing import in mailgw. - - -2001-08-29 - 0.2.7 -Feature: -- Text searches are now case insensitive. All forms of text search use - regular expressions now. - -Fixed: -- Had another 2.1-ism in the unit tests -- Made the mail parser a little more robust w.r.t missing Subject: - (both thanks Mikhail Sobolev) -- Missed some isFooType usages (thanks Mikhail Sobolev for spotting them) -- Reverted back to sending change messages to the web editor of a node so - that the change note message is actually genrated. -- CGI interface wasn't generating correct change messages. -- Notes entered during a change are saved to the messages list even if - there's no nosy list. No message is generated if there's no nosy list and - there's no change note (since it would just duplicates the journal). -- Completely removed the bsddb3 module from the tests - will be reinstated - when the http://bsddb.sourceforge.net/'s bugs #439959 and #456408 are - dealt with. One is fixed in CVS, the other pending. - - -2001-08-08 - 0.2.6 -Note: -- Roundup is now released under the same terms as the Python License. - -Feature: -- Added tests for instance initialisation. No more releasing the software - with bugs in roundup.init! -- Now bundling unittest with the package so that python 2.0 users can use - the tests. -- Much better error handling and messages generated by the mail gateway. - -Fixed: -- Implemented correct mail splitting. Added unit tests. Also snips - signatures now too. -- Bug #447671 - typo in roundup/init.py -- Changed date.Date to use regular string formatting instead of strftime - - win32 seems to have problems with %T and no hour... or something... -- Bug #448484 - now catching correct exception from makedirs. -- Instances are now opened by a special function that generates a unique - module name for the instances on import time. - - -2001-08-03 - 0.2.5 -Note: -- The bsddb3 module has a bug that renders it non-functional. Users should - select the anydbm or bsddb backend instead. - -Fixed: -- Python 2.0 does not contain the unittest module. The setup.py module now - checks for unittest before attempting to run the unit tests. - - -2001-08-03 - 0.2.4 -Features: -- Added ability for cgi newblah forms to indicate that the new node - should be linked somewhere. -- Added time logging and file uploading to the templates. -- Added "My Issues" and "My Support" to extended template. Changed "Your - Details" to "My Details". Changed the "New Foo" links to "Add Foo". - Added links for unassigned support and issues. Generally reorganised and - cleanup the header up. -- Changed the order of the information in the message generated by web edits. -- Extended the range of intervals that are pretty-printed before actual dates - are displayed. -- Added more BUILD instructions including the "clean" command to force - rebuild. -- Web edit messages aren't sent to the person who did the edit any more. No - message is generated if they are the only person on the nosy list. -- Roundupdb now appends "mailing list" information to its messages which - include the e-mail address and web interface address. Templates may - override this in their db classes to include specific information (support - instructions, etc). - -Fixed: -- Argument handling for the roundup-admin find command. -- Handling of summary when no note supplied for newblah. Again. -- Detection of no form in htmltemplate Field display. -- Checklist html template command was setting wrong name. -- 2.1-specific gmtime() (no arg) call in roundup.date. (thanks Paul Wright) -- mailgw was making naughty assumptions about the schema of the classes it - was creating nodes for. -- remove the $Foo$ from the HTML files stored in the htmlbase modules. -- Instance import now imports the instance using imp.load_module so that - we can have instance homes of "roundup" or other existing python package - names. - - -2001-07-30 - 0.2.3 -Big change: -- I've split off the support class from the issue class in "extended". - Anyone who has any support entries, sorry. It should be possible to - write a scipt that moves the entries over pretty easily. If this causes - you pain, I'll do so. You'll want to update your instance with the new - code in "extended" either way. - -Features: -- Added the unit tests to the start of setup.py so they're run whenever - we do anything distutils'y. -- Added nicer prompting to the roundup-admin "init" command. -- Actually, the roundup-admin code is totally revamped, and has command - help and better command-line arg handling. -- The cgi_client.Client base class now reflects the structure of "classic" - rather than "extended" since "classic" is more of a "base" template. -- Added more DB to test. Skips tests where imports fail. - -Fixed: -- One of the tests in test_date had the wrong expected result. -- Fixed IssueClass so that superseders links to its classname rather than - hard-coded to "issue". -- templatebuilder was catching IOError instead of OSError. -- The cgi_client newblah method wasn't detecting the __note form field - properly. -- The History command in htmltemplate didn't handle a new node (None - nodeid) properly. - - -2001-07-29 - 0.2.2 -Features: -- Added implementation.txt to the doc directory. Contains implementation - notes specific to this implementations of Roundup. -- Cleaned up mailgw some (subclass Message for getPart) and added some - tests for multipart splitting. -- Better checking for html dir in templatebuilder. -- Base hyperdb.Class now fakes the "id" property. -- Made the classic roundup look more like the original prototype. -- Made cgi_client and templating slightly more generic. -- Moved some code around in cgi_client allowing for subclassing to change - behaviour. -- Added the fabricated property "id" to all hyperdb classes. -- Cleanup of the link label generation (new method on hyperdb.Class to do - it). - -Fixed: -- Everything uses errno module now to check errno values. -- New issue form handles lack of note better now. -- HTML templating uses section-bar style for index group headers now. -- Fixed problem in link display when Link value is None. -- Form handling in cgi client wasn't propogating through the previous - query elements. -- Fixed sort arguments generated for column headings so sorting can be - changed now. - - -2001-07-28 - 0.2.1 -Features: -- Added docstring to roundup package so pydoc reports useful information. -- Added the roundup 1 software carpentry submission HTML to the doc - directory as "overview.html". - -Fixes: -- Fixed bug in init command - templatebuilder was assuming existence of - "html" directory in instance home. -- Fixed INSTALL.txt to reflect some changes in the installation and test - procedure. Whatdya know, "setup.py install" does the script install. - There you go... -- Fixed some non-string node ids in cgi_client now that the hyperdb is - strict about such things. - -2001-07-26 - 0.2.0 -Features: -- Major reorganisation of code to allow multiple roundup instances and a - single, site-packages -based installation. Also allows multiple database - back-ends. -- Moved the bin/ proggies into the top dir, so that it all works - out-of-the-box -- Added the "classic" template - a direct implementation of the Roundup - spec. Well, as close as we're going to get, anyway. -- Added an issue priority of support to "extended" -- Added command-line arg handling to roundup-server so it's more useful - out-of-the-box. -- Added distutils-style installation of "lib" files. -- Added some unit tests. - -Fixes: -- Fixed bug in re generation in the filter -- Fixed handling of None String property in grouped list headings -- Fixed adding new issue with no change note -- Fixed values in text input fields which contained quotes (") are now - quoted. -- Fixed a bug in the hyperdb filter - wrong variable names in the error - message. - -2001-07-19 - 0.1.3 -- Reldate now takes an argument "pretty" - when true, it pretty-prints the - interval generated up to 5 days, then pretty-prints the date of last - activity. The issue index and item now use the pretty format. -- Classes list for admin user in CGI interface. -- Made the view configuration more accessible, neater and more realistic. -- Fixed list view grouping handling grouping by a Multilink or String or Link - value of None or Date, ... (mind you, sorting by Date???) -- Fixed bug in the plain formatter when a Link was None. -- Fixed ordering of list view column headings. -- Fixed list view column heading sort links - and limited the number of - columns to sort by to 2. -- Added searching by glob to StringType filtering - - ^text - search for text at start of fields - text$ - search for text at end of fields - ^text$ - exactly match text in fields - te*xt - search for text matching "te"<any characters>"xt" - te?xt - search for text matching "te"<any one character>"xt" -- Added more fields to the issue.filter and issue.index templates - - -2001-07-18 - 0.1.2 -- Set default index to ?:group=priority&:columns=activity,status,title so - the priority column isn't displayed. -- Thanks Anthony: - - added notes to the README about Python prerequisites - - added check to roundup.py, roundup.cgi, server.py and roundup-mailgw.py - for python 2+ - and made the file itself parseable by 1.5.2 ;) - - python 2.0 didn't have the default args for the time module functions. - - better handling of db directory in initDB -- Sorting on the extra properties defined by roundupdb classes was broken - due to the caching used. May now sort on activity and creation - properties, etc. -- Set the default index to sort on activity - - -2001-07-18 - 0.1.1 -- Initial version release with consent of Roundup spec author, Ka-Ping Yee: - "Amazing! Nice work. I'll watch for the source code on your website." - -2001-07-11 - 0.1.0 -- Needed a bug tracking system. Looked around. Tried to install many - Perl-based systems, to no avail. Got tired of waiting for Roundup to be - released. Had just finished major product project, so needed something - different for a while. Roundup here I come... - -
--- a/MANIFEST.in Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,12 +0,0 @@ -recursive-include roundup *.* home* page* -recursive-include frontends *.* -recursive-include scripts *.* *-* -recursive-include tools *.* -recursive-include cgi-bin *.cgi -recursive-include test *.py *.txt -recursive-include doc *.html *.png *.txt -recursive-exclude roundup *.pyc *.pyo .cvsignore -recursive-exclude frontends *.pyc *.pyo .cvsignore -include run_tests *.txt -exclude BUILD.txt I18N_PROGRESS.txt TODO.txt -
--- a/TODO.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,59 +0,0 @@ -General Roundup project TODO list. Note that some of these are semi-formed -ideas. Those ideas that don't make the cutoff for the next major release are -punted automatically into the subsequent major release TODO. When stuff is -done, it's moved to the CHANGES file. - -======= ========= ============================================================ -State Component Description -======= ========= ============================================================ -pending example meta/parent bug implementation (feature request #506815) -pending example replace the "extended" example with a "help desk" one, and - rename "classic" to "bug tracker" -pending example script for retrieval of "mbox" archive of all messages -pending hyperdb range searching of values (dates in particular). - Filter specifies {property: (comparison function, value)} - comparison functions: lt, le, eq, ge, gt. eq and - [value, value, ...] implies "in" -pending hyperdb migrate "id" property to be Number type -pending tracker split instance.open() into open() and login() -pending mailgw allow commands (feature request #556996) - like "help", "dump issue123" (would send all info about - issue123, including a digest of all messages, but probably - not all files...), "list issue", ... -pending mailgw Allow multiple email addresses at one gw with different - default classes and property values (possibly through - command-line args to the mailgw as invoked in the mail - delivery "aliases" file) eg:: - - roundup: "|roundup-mailgw /instances/dev" - vmbugs: "|roundup-mailgw /instances/dev component=voicemail" - -pending mailgw Identification of users should have a configurable degree of - strictness (ie. turn off username==address matching) -pending project switch to a Roundup instance for Roundup bug/feature tracking -pending security an LDAP user database implementation -pending security authenticate over a secure connection -pending security optionally auth with Basic HTTP auth instead of cookies -pending security use digital signatures in mailgw -pending admin "roundup-admin list" should list all the classnames -pending web I18N -pending web Better message summary display (feature request #520244) -pending web Navigating around the issues (feature request #559149) -pending web Quick help links next to the property labels giving a - description of the property. Combine with help for the actual - form element too, eg. how to use the nosy list edit box. -pending web clicking on a group header should filter for that type of - entry -pending web re-enable auth basic http auth -pending web search "refinement" - pre-fill the search page with the - current search parameters -pending web UNIX init.d script for roundup-server -pending web allow multilink selections to select a "none" element to allow - people with broken browsers to select nothing? -pending web automagically link designators -pending web add checkbox-based removal/addition for multilink entries - (eg "add me"/"remove me" for nosy list) - -bug docs need to mention somewhere how sorting works -======= ========= ============================================================= -
--- a/cgi-bin/roundup.cgi Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,213 +0,0 @@ -#!/usr/bin/env python -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: roundup.cgi,v 1.33 2002-09-23 00:50:32 richard Exp $ - -# python version check -from roundup import version_check -from roundup.i18n import _ -import sys - -# -## Configuration -# - -# Configuration can also be provided through the OS environment (or via -# the Apache "SetEnv" configuration directive). If the variables -# documented below are set, they _override_ any configuation defaults -# given in this file. - -# TRACKER_HOMES is a list of instances, in the form -# "NAME=DIR<sep>NAME2=DIR2<sep>...", where <sep> is the directory path -# separator (";" on Windows, ":" on Unix). - -# Make sure the NAME part doesn't include any url-unsafe characters like -# spaces, as these confuse the cookie handling in browsers like IE. - -# ROUNDUP_LOG is the name of the logfile; if it's empty or does not exist, -# logging is turned off (unless you changed the default below). - -# ROUNDUP_DEBUG is a debug level, currently only 0 (OFF) and 1 (ON) are -# used in the code. Higher numbers means more debugging output. - -# This indicates where the Roundup instance lives -TRACKER_HOMES = { - 'demo': '/var/roundup/instances/demo', -} - -# Where to log debugging information to. Use an instance of DevNull if you -# don't want to log anywhere. -class DevNull: - def write(self, info): - pass - def close(self): - pass -#LOG = open('/var/log/roundup.cgi.log', 'a') -LOG = DevNull() - -# -## end configuration -# - - -# -# Set up the error handler -# -try: - import traceback, StringIO, cgi - from roundup.cgi import cgitb -except: - print "Content-Type: text/plain\n" - print _("Failed to import cgitb!\n\n") - s = StringIO.StringIO() - traceback.print_exc(None, s) - print s.getvalue() - - -# -# Check environment for config items -# -def checkconfig(): - import os, string - global TRACKER_HOMES, LOG - - # see if there's an environment var. ROUNDUP_INSTANCE_HOMES is the - # old name for it. - if os.environ.has_key('ROUNDUP_INSTANCE_HOMES'): - homes = os.environ.get('ROUNDUP_INSTANCE_HOMES') - else: - homes = os.environ.get('TRACKER_HOMES', '') - if homes: - TRACKER_HOMES = {} - for home in string.split(homes, os.pathsep): - try: - name, dir = string.split(home, '=', 1) - except ValueError: - # ignore invalid definitions - continue - if name and dir: - TRACKER_HOMES[name] = dir - - logname = os.environ.get('ROUNDUP_LOG', '') - if logname: - LOG = open(logname, 'a') - - # ROUNDUP_DEBUG is checked directly in "roundup.cgi.client" - - -# -# Provide interface to CGI HTTP response handling -# -class RequestWrapper: - '''Used to make the CGI server look like a BaseHTTPRequestHandler - ''' - def __init__(self, wfile): - self.wfile = wfile - def write(self, data): - self.wfile.write(data) - def send_response(self, code): - self.write('Status: %s\r\n'%code) - def send_header(self, keyword, value): - self.write("%s: %s\r\n" % (keyword, value)) - def end_headers(self): - self.write("\r\n") - -# -# Main CGI handler -# -def main(out, err): - import os, string - import roundup.instance - path = string.split(os.environ.get('PATH_INFO', '/'), '/') - request = RequestWrapper(out) - request.path = os.environ.get('PATH_INFO', '/') - instance = path[1] - os.environ['TRACKER_NAME'] = instance - os.environ['PATH_INFO'] = string.join(path[2:], '/') - if TRACKER_HOMES.has_key(instance): - # redirect if we need a trailing '/' - if len(path) == 2: - request.send_response(301) - # redirect - if os.environ.get('HTTPS', '') == 'on': - protocol = 'https' - else: - protocol = 'http' - absolute_url = '%s://%s%s/'%(protocol, os.environ['HTTP_HOST'], - os.environ['REQUEST_URI']) - request.send_header('Location', absolute_url) - request.end_headers() - out.write('Moved Permanently') - else: - instance_home = TRACKER_HOMES[instance] - instance = roundup.instance.open(instance_home) - from roundup.cgi.client import Unauthorised, NotFound - client = instance.Client(instance, request, os.environ) - try: - client.main() - except Unauthorised: - request.send_response(403) - request.send_header('Content-Type', 'text/html') - request.end_headers() - out.write('Unauthorised') - except NotFound: - request.send_response(404) - request.send_header('Content-Type', 'text/html') - request.end_headers() - out.write('Not found: %s'%client.path) - - else: - import urllib - request.send_response(200) - request.send_header('Content-Type', 'text/html') - request.end_headers() - w = request.write - w(_('<html><head><title>Roundup instances index</title></head>\n')) - w(_('<body><h1>Roundup instances index</h1><ol>\n')) - homes = TRACKER_HOMES.keys() - homes.sort() - for instance in homes: - w(_('<li><a href="%(instance_url)s/index">%(instance_name)s</a>\n')%{ - 'instance_url': os.environ['SCRIPT_NAME']+'/'+urllib.quote(instance), - 'instance_name': cgi.escape(instance)}) - w(_('</ol></body></html>')) - -# -# Now do the actual CGI handling -# -out, err = sys.stdout, sys.stderr -try: - # force input/output to binary (important for file up/downloads) - if sys.platform == "win32": - import os, msvcrt - msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) - msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) - checkconfig() - sys.stdout = sys.stderr = LOG - main(out, err) -except SystemExit: - pass -except: - sys.stdout, sys.stderr = out, err - out.write('Content-Type: text/html\n\n') - cgitb.handler() -sys.stdout.flush() -sys.stdout, sys.stderr = out, err -LOG.close() - -# vim: set filetype=python ts=4 sw=4 et si
--- a/doc/.cvsignore Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,15 +0,0 @@ -announcement.html -customizing.html -developers.html -getting_started.html -implementation.html -index.html -installation.html -user_guide.html -FAQ.html -security.html -features.html -upgrading.html -glossary.html -design.html -maintenance.html
--- a/doc/FAQ.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,48 +0,0 @@ -=========== -Roundup FAQ -=========== - -:Version: $Revision: 1.10 $ - -NOTE: This is just a grabbag, most of this should go into documentation. - -.. contents:: - - -Changing HTML layout --------------------- - -Installation ------------- - -Living without a mailserver. -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Remove the nosy reactor, means delete the tracker file -'detectors/nosyreactor.py'. - - -Rights issues (MISSING) -~~~~~~~~~~~~~~~~~~~~~~~ - -Different jobs run under different users. - -* Standalone roundup-server is started by whome ? - -* Running cgi under apache. - -* roundup-mailgw called via .forward from MTA, or running a cron job - fetching via pop. - -see Troubleshooting_. - - -Troubleshooting ---------------- - ------------------ - -Back to `Table of Contents`_ - -.. _`Table of Contents`: index.html -
--- a/doc/Makefile Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,17 +0,0 @@ -PYTHON = /usr/bin/python2 -STXTOHTML = -c "from docutils.core import publish;publish(writer_name='html')" - -SOURCE = announcement.txt customizing.txt developers.txt FAQ.txt features.txt \ - getting_started.txt glossary.txt implementation.txt index.txt \ - installation.txt security.txt upgrading.txt user_guide.txt \ - maintenance.txt - -COMPILED := $(SOURCE:.txt=.html) - -all: ${COMPILED} - -%.html: %.txt - ${PYTHON} ${STXTOHTML} --report=warning -d $< $@ - -clean: - rm -f ${COMPILED}
--- a/doc/announcement.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,110 +0,0 @@ -================================================================= -SC-Track Roundup 0.5 final pre-release - an issue tracking system -================================================================= - -Note: This is the final pre-release of the newest version of Roundup. It is - strongly recommended that you maintain your existing 0.4 installation if - you have one, and run 0.5 on a copy of the database. If you are - upgrading from 0.4, you must read doc/upgrading.txt! - -Roundup requires python 2.1.1 for correct operation. Support for dumbdbm -requires python 2.1.2 or 2.2. 2.1.3 and 2.2.1 are recommended. - -This beta release fixes the following specific problems: - -- fixes to import/export -- password edit now has a confirmation field -- cleanups and fixes to the shipped classic template -- new backend for sqlite (and it rocks :) -- many performance improvements in dbm and sql backends -- cgi.client base URL is now obtained from the config TRACKER_WEB (as a result - request.url has gone away - there's too much magic in trying to figure - what it should be) -- cgi-bin script redirects to https now if the request was https -- FileClass "content" property wasn't being returned by getprops() in most - backends -- we now verify instance attributes on instance open and throw a useful error - if they're not all there -- sf bug 611217 ] menu() has problems when labelprop==None -- verify contents of tracker module when the tracker is opened -- fixes to value parsing from edit forms -- mailgw was missing an "import sys" (!) -- setup now installs scripts with python -O flag, doubling performance in some - cases (there's a lot of __debug__ use) -- added getItem to HTMLClass so you can access arbitrary items in templates -- replaced the content() callback ickiness with Page Template macro usage -- changed the default CSS style to be less offensive to some ;) -- better handling of Page Template compilation errors -- sf bug 614188 ] Exception in mailgw.py -- sf bug 613310 ] traceback on onexistant items -- sf bug 613291 ] typos in nosy list -- handle stupid mailers that QUOTE their Re; 'Re: "[issue1] bla blah"' -- giving a user a Role that doesn't exist doesn't break stuff any more -- revamped user guide, customisation guide, added basic maintenance guide -- merge Zope Collector #580 fix from ZPT CVS trunk -- added the "minimal" template - -A lot has been done since 0.4: - -- new backend for metakit (thanks Gordon McMillan) -- new backend for sqlite -- new backend for gadfly (it's as done as it's going to get) -- further split the dbm backends from the core code, allowing easier - non-dict-like backends (eg metakit, RDB) -- added Boolean and Number types -- fixed the journal bloat -- full-text search may also search certain String properties -- entire database export and import (incl files) -- implemented and used the new access control mechanisms (Permissions, Roles) -- switched templating to use Zope's PageTemplates giving much more flexibility -- revamped look and feel in web interface including cleaned up CSS usage -- re-worked cgi interface to abstract out the explicit "issue" interface -- switched to sessions for web authentication -- saving of named search queries -- updated design document for new access controls -- updated customisation document, including more examples -- added maintenance guide -- better mailgw help message (feature request #558562) -- we handle "not found", access and item page render errors better -- fixed double-submit by having new-item-submit redirect at end -- roundup-server may be a daemon now (fork, logfile, pidfile) -- renamed "instance" to "tracker" everywhere, and "node" to "item" in most - places -- many more bug fixes, cleanups and minor improvements - -Source and documentation is available at the website: - http://roundup.sourceforge.net/ -Release Info (via download page): - http://sourceforge.net/projects/roundup -Mailing lists - the place to ask questions: - http://sourceforge.net/mail/?group_id=31577 - - -About Roundup -============= - -Roundup is a simple-to-use and -install issue-tracking system with -command-line, web and e-mail interfaces. It is based on the winning design -from Ka-Ping Yee in the Software Carpentry "Track" design competition. - -Note: Ping is not responsible for this project. The contact for this project -is richard@users.sourceforge.net. - -Roundup manages a number of issues (with flexible properties such as -"description", "priority", and so on) and provides the ability to: - -(a) submit new issues, -(b) find and edit existing issues, and -(c) discuss issues with other participants. - -The system will facilitate communication among the participants by managing -discussions and notifying interested parties when issues are edited. One of -the major design goals for Roundup that it be simple to get going. Roundup -is therefore usable "out of the box" with any python 2.1+ installation. It -doesn't even need to be "installed" to be operational, though a -disutils-based install script is provided. - -It comes with two issue tracker templates (a classic bug/feature tracker and -a minimal skeleton) and six database back-ends (anydbm, bsddb, bsddb3, sqlite, -metakit and gadfly). -
--- a/doc/customizing.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,2072 +0,0 @@ -=================== -Customising Roundup -=================== - -:Version: $Revision: 1.47 $ - -.. This document borrows from the ZopeBook section on ZPT. The original is at: - http://www.zope.org/Documentation/Books/ZopeBook/current/ZPT.stx - -.. contents:: - :depth: 1 - -What You Can Do -=============== - -Customisation of Roundup can take one of five forms: - -1. `tracker configuration`_ file changes -2. database, or `tracker schema`_ changes -3. "definition" class `database content`_ changes -4. behavioural changes, through detectors_ -5. `access controls`_ - -The third case is special because it takes two distinctly different forms -depending upon whether the tracker has been initialised or not. The other two -may be done at any time, before or after tracker initialisation. Yes, this -includes adding or removing properties from classes. - - -Trackers in a Nutshell -====================== - -Trackers have the following structure: - -=================== ======================================================== -Tracker File Description -=================== ======================================================== -config.py Holds the basic `tracker configuration`_ -dbinit.py Holds the `tracker schema`_ -interfaces.py Defines the Web and E-Mail interfaces for the tracker -select_db.py Selects the database back-end for the tracker -db/ Holds the tracker's database -db/files/ Holds the tracker's upload files and messages -detectors/ Auditors and reactors for this tracker -html/ Web interface templates, images and style sheets -=================== ======================================================== - -Tracker Configuration -===================== - -The config.py located in your tracker home contains the basic -configuration for the web and e-mail components of roundup's interfaces. This -file is a Python module. The configuration variables available are: - -**TRACKER_HOME** - ``os.path.split(__file__)[0]`` - The tracker home directory. The above default code will automatically - determine the tracker home for you. - -**MAILHOST** - ``'localhost'`` - The SMTP mail host that roundup will use to send e-mail. - -**MAIL_DOMAIN** - ``'your.tracker.email.domain.example'`` - The domain name used for email addresses. - -**DATABASE** - ``os.path.join(TRACKER_HOME, 'db')`` - This is the directory that the database is going to be stored in. By default - it is in the tracker home. - -**TEMPLATES** - ``os.path.join(TRACKER_HOME, 'html')`` - This is the directory that the HTML templates reside in. By default they are - in the tracker home. - -**TRACKER_NAME** - ``'Roundup issue tracker'`` - A descriptive name for your roundup tracker. This is sent out in e-mails and - appears in the heading of CGI pages. - -**TRACKER_EMAIL** - ``'issue_tracker@%s'%MAIL_DOMAIN`` - The email address that e-mail sent to roundup should go to. Think of it as the - tracker's personal e-mail address. - -**TRACKER_WEB** - ``'http://your.tracker.url.example/'`` - The web address that the tracker is viewable at. This will be included in - information sent to users of the tracker. - -**ADMIN_EMAIL** - ``'roundup-admin@%s'%MAIL_DOMAIN`` - The email address that roundup will complain to if it runs into trouble. - -**MESSAGES_TO_AUTHOR** - ``'yes'`` or``'no'`` - Send nosy messages to the author of the message. - -**ADD_AUTHOR_TO_NOSY** - ``'new'``, ``'yes'`` or ``'no'`` - Does the author of a message get placed on the nosy list automatically? - If ``'new'`` is used, then the author will only be added when a message - creates a new issue. If ``'yes'``, then the author will be added on followups - too. If ``'no'``, they're never added to the nosy. - -**ADD_RECIPIENTS_TO_NOSY** - ``'new'``, ``'yes'`` or ``'no'`` - Do the recipients (To:, Cc:) of a message get placed on the nosy list? - If ``'new'`` is used, then the recipients will only be added when a message - creates a new issue. If ``'yes'``, then the recipients will be added on - followups too. If ``'no'``, they're never added to the nosy. - -**EMAIL_SIGNATURE_POSITION** - ``'top'``, ``'bottom'`` or ``'none'`` - Where to place the email signature in messages that Roundup generates. - -**EMAIL_KEEP_QUOTED_TEXT** - ``'yes'`` or ``'no'`` - Keep email citations. Citations are the part of e-mail which the sender has - quoted in their reply to previous e-mail. - -**EMAIL_LEAVE_BODY_UNCHANGED** - ``'no'`` - Preserve the email body as is. Enabiling this will cause the entire message - body to be stored, including all citations and signatures. It should be - either ``'yes'`` or ``'no'``. - -**MAIL_DEFAULT_CLASS** - ``'issue'`` or ``''`` - Default class to use in the mailgw if one isn't supplied in email - subjects. To disable, comment out the variable below or leave it blank. - -The default config.py is given below - as you -can see, the MAIL_DOMAIN must be edited before any interaction with the -tracker is attempted.:: - - # roundup home is this package's directory - TRACKER_HOME=os.path.split(__file__)[0] - - # The SMTP mail host that roundup will use to send mail - MAILHOST = 'localhost' - - # The domain name used for email addresses. - MAIL_DOMAIN = 'your.tracker.email.domain.example' - - # This is the directory that the database is going to be stored in - DATABASE = os.path.join(TRACKER_HOME, 'db') - - # This is the directory that the HTML templates reside in - TEMPLATES = os.path.join(TRACKER_HOME, 'html') - - # A descriptive name for your roundup tracker - TRACKER_NAME = 'Roundup issue tracker' - - # The email address that mail to roundup should go to - TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN - - # The web address that the tracker is viewable at - TRACKER_WEB = 'http://your.tracker.url.example/' - - # The email address that roundup will complain to if it runs into trouble - ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN - - # Send nosy messages to the author of the message - MESSAGES_TO_AUTHOR = 'no' # either 'yes' or 'no' - - # Does the author of a message get placed on the nosy list automatically? - # If 'new' is used, then the author will only be added when a message - # creates a new issue. If 'yes', then the author will be added on followups - # too. If 'no', they're never added to the nosy. - ADD_AUTHOR_TO_NOSY = 'new' # one of 'yes', 'no', 'new' - - # Do the recipients (To:, Cc:) of a message get placed on the nosy list? - # If 'new' is used, then the recipients will only be added when a message - # creates a new issue. If 'yes', then the recipients will be added on followups - # too. If 'no', they're never added to the nosy. - ADD_RECIPIENTS_TO_NOSY = 'new' # either 'yes', 'no', 'new' - - # Where to place the email signature - EMAIL_SIGNATURE_POSITION = 'bottom' # one of 'top', 'bottom', 'none' - - # Keep email citations - EMAIL_KEEP_QUOTED_TEXT = 'no' # either 'yes' or 'no' - - # Preserve the email body as is - EMAIL_LEAVE_BODY_UNCHANGED = 'no' # either 'yes' or 'no' - - # Default class to use in the mailgw if one isn't supplied in email - # subjects. To disable, comment out the variable below or leave it blank. - # Examples: - MAIL_DEFAULT_CLASS = 'issue' # use "issue" class by default - #MAIL_DEFAULT_CLASS = '' # disable (or just comment the var out) - -Tracker Schema -============== - -Note: if you modify the schema, you'll most likely need to edit the - `web interface`_ HTML template files and `detectors`_ to reflect - your changes. - -A tracker schema defines what data is stored in the tracker's database. -The schemas shipped with Roundup turn it into a typical software bug tracker -or help desk. - -XXX make sure we ship the help desk - -Schemas are defined using Python code in the ``dbinit.py`` module of your -tracker. The "classic" schema looks like this:: - - pri = Class(db, "priority", name=String(), order=String()) - pri.setkey("name") - pri.create(name="critical", order="1") - pri.create(name="urgent", order="2") - pri.create(name="bug", order="3") - pri.create(name="feature", order="4") - pri.create(name="wish", order="5") - - stat = Class(db, "status", name=String(), order=String()) - stat.setkey("name") - stat.create(name="unread", order="1") - stat.create(name="deferred", order="2") - stat.create(name="chatting", order="3") - stat.create(name="need-eg", order="4") - stat.create(name="in-progress", order="5") - stat.create(name="testing", order="6") - stat.create(name="done-cbb", order="7") - stat.create(name="resolved", order="8") - - keyword = Class(db, "keyword", name=String()) - keyword.setkey("name") - - user = Class(db, "user", username=String(), password=String(), - address=String(), realname=String(), phone=String(), - organisation=String()) - user.setkey("username") - user.create(username="admin", password=adminpw, - address=config.ADMIN_EMAIL) - - msg = FileClass(db, "msg", author=Link("user"), recipients=Multilink - ("user"), date=Date(), summary=String(), files=Multilink("file")) - - file = FileClass(db, "file", name=String(), type=String()) - - issue = IssueClass(db, "issue", assignedto=Link("user"), - topic=Multilink("keyword"), priority=Link("priority"), status=Link - ("status")) - issue.setkey('title') - -XXX security definitions - -Classes and Properties - creating a new information store ---------------------------------------------------------- - -In the tracker above, we've defined 7 classes of information: - - priority - Defines the possible levels of urgency for issues. - - status - Defines the possible states of processing the issue may be in. - - keyword - Initially empty, will hold keywords useful for searching issues. - - user - Initially holding the "admin" user, will eventually have an entry for all - users using roundup. - - msg - Initially empty, will all e-mail messages sent to or generated by - roundup. - - file - Initially empty, will all files attached to issues. - - issue - Initially empty, this is where the issue information is stored. - -We define the "priority" and "status" classes to allow two things: reduction in -the amount of information stored on the issue and more powerful, accurate -searching of issues by priority and status. By only requiring a link on the -issue (which is stored as a single number) we reduce the chance that someone -mis-types a priority or status - or simply makes a new one up. - -Class and Items -~~~~~~~~~~~~~~~ - -A Class defines a particular class (or type) of data that will be stored in the -database. A class comprises one or more properties, which given the information -about the class items. -The actual data entered into the database, using class.create() are called -items. They have a special immutable property called id. We sometimes refer to -this as the itemid. - -Properties -~~~~~~~~~~ - -A Class is comprised of one or more properties of the following types: - -* String properties are for storing arbitrary-length strings. -* Password properties are for storing encoded arbitrary-length strings. The - default encoding is defined on the roundup.password.Password class. -* Date properties store date-and-time stamps. Their values are Timestamp - objects. -* Number properties store numeric values. -* Boolean properties store on/off, yes/no, true/false values. -* A Link property refers to a single other item selected from a specified - class. The class is part of the property; the value is an integer, the id - of the chosen item. -* A Multilink property refers to possibly many items in a specified class. - The value is a list of integers. - -FileClass -~~~~~~~~~ - -FileClasses save their "content" attribute off in a separate file from the rest -of the database. This reduces the number of large entries in the database, -which generally makes databases more efficient, and also allows us to use -command-line tools to operate on the files. They are stored in the files sub- -directory of the db directory in your tracker. - -IssueClass -~~~~~~~~~~ - -IssueClasses automatically include the "messages", "files", "nosy", and -"superseder" properties. -The messages and files properties list the links to the messages and files -related to the issue. The nosy property is a list of links to users who wish to -be informed of changes to the issue - they get "CC'ed" e-mails when messages -are sent to or generated by the issue. The nosy reactor (in the detectors -directory) handles this action. The superceder link indicates an issue which -has superceded this one. -They also have the dynamically generated "creation", "activity" and "creator" -properties. -The value of the "creation" property is the date when an item was created, and -the value of the "activity" property is the date when any property on the item -was last edited (equivalently, these are the dates on the first and last -records in the item's journal). The "creator" property holds a link to the user -that created the issue. - -setkey(property) -~~~~~~~~~~~~~~~~ - -Select a String property of the class to be the key property. The key property -muse be unique, and allows references to the items in the class by the content -of the key property. That is, we can refer to users by their username, e.g. -let's say that there's an issue in roundup, issue 23. There's also a user, -richard who happens to be user 2. To assign an issue to him, we could do either -of:: - - roundup-admin set issue assignedto=2 - -or:: - - roundup-admin set issue assignedto=richard - -Note, the same thing can be done in the web and e-mail interfaces. - -create(information) -~~~~~~~~~~~~~~~~~~~ - -Create an item in the database. This is generally used to create items in the -"definitional" classes like "priority" and "status". - - -Examples of adding to your schema ---------------------------------- - -TODO - - -Detectors - adding behaviour to your tracker -============================================ -.. _detectors: - -Detectors are initialised every time you open your tracker database, so you're -free to add and remove them any time, even after the database is initliased -via the "roundup-admin initalise" command. - -The detectors in your tracker fire before (*auditors*) and after (*reactors*) -changes to the contents of your database. They are Python modules that sit in -your tracker's ``detectors`` directory. You will have some installed by -default - have a look. You can write new detectors or modify the existing -ones. The existing detectors installed for you are: - -**nosyreaction.py** - This provides the automatic nosy list maintenance and email sending. The nosy - reactor (``nosyreaction``) fires when new messages are added to issues. - The nosy auditor (``updatenosy``) fires when issues are changed and figures - what changes need to be made to the nosy list (like adding new authors etc) -**statusauditor.py** - This provides the ``chatty`` auditor which changes the issue status from - ``unread`` or ``closed`` to ``chatting`` if new messages appear. It also - provides the ``presetunread`` auditor which pre-sets the status to - ``unread`` on new items if the status isn't explicitly defined. - -See the detectors section in the `design document`__ for details of the -interface for detectors. - -__ design.html - -Sample additional detectors that have been found useful will appear in the -``detectors`` directory of the Roundup distribution: - -**newissuecopy.py** - This detector sends an email to a team address whenever a new issue is - created. The address is hard-coded into the detector, so edit it before you - use it (look for the text 'team@team.host') or you'll get email errors! - - The detector code:: - - from roundup import roundupdb - - def newissuecopy(db, cl, nodeid, oldvalues): - ''' Copy a message about new issues to a team address. - ''' - # so use all the messages in the create - change_note = cl.generateCreateNote(nodeid) - - # send a copy to the nosy list - for msgid in cl.get(nodeid, 'messages'): - try: - # note: last arg must be a list - cl.send_message(nodeid, msgid, change_note, ['team@team.host']) - except roundupdb.MessageSendError, message: - raise roundupdb.DetectorError, message - - def init(db): - db.issue.react('create', newissuecopy) - - -Database Content -================ - -Note: if you modify the content of definitional classes, you'll most likely - need to edit the tracker `detectors`_ to reflect your changes. - -Customisation of the special "definitional" classes (eg. status, priority, -resolution, ...) may be done either before or after the tracker is -initialised. The actual method of doing so is completely different in each -case though, so be careful to use the right one. - -**Changing content before tracker initialisation** - Edit the dbinit module in your tracker to alter the items created in using - the create() methods. - -**Changing content after tracker initialisation** - Use the roundup-admin interface's create, set and retire methods to add, - alter or remove items from the classes in question. - -XXX example - - -Web Interface -============= - -.. contents:: - :local: - :depth: 1 - -The web is provided by the roundup.cgi.client module and is used by -roundup.cgi, roundup-server and ZRoundup. -In all cases, we determine which tracker is being accessed -(the first part of the URL path inside the scope of the CGI handler) and pass -control on to the tracker interfaces.Client class - which uses the Client class -from roundup.cgi.client - which handles the rest of -the access through its main() method. This means that you can do pretty much -anything you want as a web interface to your tracker. - -Repurcussions of changing the tracker schema ---------------------------------------------- - -If you choose to change the `tracker schema`_ you will need to ensure the web -interface knows about it: - -1. Index, item and search pages for the relevant classes may need to have - properties added or removed, -2. The "page" template may require links to be changed, as might the "home" - page's content arguments. - -How requests are processed --------------------------- - -The basic processing of a web request proceeds as follows: - -1. figure out who we are, defaulting to the "anonymous" user -2. figure out what the request is for - we call this the "context" -3. handle any requested action (item edit, search, ...) -4. render the template requested by the context, resulting in HTML output - -In some situations, exceptions occur: - -- HTTP Redirect (generally raised by an action) -- SendFile (generally raised by determine_context) - here we serve up a FileClass "content" property -- SendStaticFile (generally raised by determine_context) - here we serve up a file from the tracker "html" directory -- Unauthorised (generally raised by an action) - here the action is cancelled, the request is rendered and an error - message is displayed indicating that permission was not - granted for the action to take place -- NotFound (raised wherever it needs to be) - this exception percolates up to the CGI interface that called the client - -Determining web context ------------------------ - -To determine the "context" of a request, we look at the URL and the special -request variable ``:template``. The URL path after the tracker identifier -is examined. Typical URL paths look like: - -1. ``/tracker/issue`` -2. ``/tracker/issue1`` -3. ``/tracker/_file/style.css`` -4. ``/cgi-bin/roundup.cgi/tracker/file1`` -5. ``/cgi-bin/roundup.cgi/tracker/file1/kitten.png`` - -where the "tracker identifier" is "tracker" in the above cases. That means -we're looking at "issue", "issue1", "_file/style.css", "file1" and -"file1/kitten.png" in the cases above. The path is generally only one -entry long - longer paths are handled differently. - -a. if there is no path, then we are in the "home" context. -b. if the path starts with "_file" (as in example 3, - "/tracker/_file/style.css"), then the additional path entry, - "style.css" specifies the filename of a static file we're to serve up - from the tracker "html" directory. Raises a SendStaticFile - exception. -c. if there is something in the path (as in example 1, "issue"), it identifies - the tracker class we're to display. -d. if the path is an item designator (as in examples 2 and 4, "issue1" and - "file1"), then we're to display a specific item. -e. if the path starts with an item designator and is longer than - one entry (as in example 5, "file1/kitten.png"), then we're assumed - to be handling an item of a - FileClass, and the extra path information gives the filename - that the client is going to label the download with (ie - "file1/kitten.png" is nicer to download than "file1"). This - raises a SendFile exception. - -Both b. and e. stop before we bother to -determine the template we're going to use. That's because they -don't actually use templates. - -The template used is specified by the ``:template`` CGI variable, -which defaults to: - -- only classname suplied: "index" -- full item designator supplied: "item" - - -Performing actions in web requests ----------------------------------- - -When a user requests a web page, they may optionally also request for an -action to take place. As described in `how requests are processed`_, the -action is performed before the requested page is generated. Actions are -triggered by using a ``:action`` CGI variable, where the value is one of: - -**login** - Attempt to log a user in. -**logout** - Log the user out - make them "anonymous". -**register** - Attempt to create a new user based on the contents of the form and then log - them in. -**edit** - Perform an edit of an item in the database. There are some special form - elements you may use: - - :link=designator:property and :multilink=designator:property - The value specifies an item designator and the property on that - item to add _this_ item to as a link or multilink. - :note - Create a message and attach it to the current item's - "messages" property. - :file - Create a file and attach it to the current item's - "files" property. Attach the file to the message created from - the :note if it's supplied. - :required=property,property,... - The named properties are required to be filled in the form. - -**new** - Add a new item to the database. You may use the same special form elements - as in the "edit" action. - -**editCSV** - Performs an edit of all of a class' items in one go. See also the - *class*.csv templating method which generates the CSV data to be edited, and - the "_generic.index" template which uses both of these features. - -**search** - Mangle some of the form variables. - - Set the form ":filter" variable based on the values of the - filter variables - if they're set to anything other than - "dontcare" then add them to :filter. - - Also handle the ":queryname" variable and save off the query to - the user's query list. - -Each of the actions is implemented by a corresponding *actionAction* (where -"action" is the name of the action) method on -the roundup.cgi.Client class, which also happens to be in your tracker as -interfaces.Client. So if you need to define new actions, you may add them -there (see `defining new web actions`_). - -Each action also has a corresponding *actionPermission* (where -"action" is the name of the action) method which determines -whether the action is permissible given the current user. The base permission -checks are: - -**login** - Determine whether the user has permission to log in. - Base behaviour is to check the user has "Web Access". -**logout** - No permission checks are made. -**register** - Determine whether the user has permission to register - Base behaviour is to check the user has "Web Registration". -**edit** - Determine whether the user has permission to edit this item. - Base behaviour is to check the user can edit this class. If we're - editing the "user" class, users are allowed to edit their own - details. Unless it's the "roles" property, which requires the - special Permission "Web Roles". -**new** - Determine whether the user has permission to create (edit) this item. - Base behaviour is to check the user can edit this class. No - additional property checks are made. Additionally, new user items - may be created if the user has the "Web Registration" Permission. -**editCSV** - Determine whether the user has permission to edit this class. - Base behaviour is to check the user can edit this class. -**search** - Determine whether the user has permission to search this class. - Base behaviour is to check the user can view this class. - - -Default templates ------------------ - -Most customisation of the web view can be done by modifying the templates in -the tracker **html** directory. There are several types of files in there: - -**page** - This template usually defines the overall look of your tracker. When you - view an issue, it appears inside this template. When you view an index, it - also appears inside this template. This template defines a macro called - "icing" which is used by almost all other templates as a coating for their - content, using its "content" slot. It will also define the "head_title" - and "body_title" slots to allow setting of the page title. -**home** - the default page displayed when no other page is indicated by the user -**home.classlist** - a special version of the default page that lists the classes in the tracker -**classname.item** - displays an item of the *classname* class -**classname.index** - displays a list of *classname* items -**classname.search** - displays a search page for *classname* items -**_generic.index** - used to display a list of items where there is no *classname*.index available -**_generic.help** - used to display a "class help" page where there is no *classname*.help -**user.register** - a special page just for the user class that renders the registration page -**style.css** - a static file that is served up as-is - -Note: Remember that you can create any template extension you want to, so -if you just want to play around with the templating for new issues, you can -copy the current "issue.item" template to "issue.test", and then access the -test template using the ":template" URL argument:: - - http://your.tracker.example/tracker/issue?:template=test - -and it won't affect your users using the "issue.item" template. - - -How the templates work ----------------------- - -Basic Templating Actions -~~~~~~~~~~~~~~~~~~~~~~~~ - -Roundup's templates consist of special attributes on your template tags. -These attributes form the Template Attribute Language, or TAL. The basic tag -commands are: - -**tal:define="variable expression; variable expression; ..."** - Define a new variable that is local to this tag and its contents. For - example:: - - <html tal:define="title request/description"> - <head><title tal:content="title"></title></head> - </html> - - In the example, the variable "title" is defined as being the result of the - expression "request/description". The tal:content command inside the <html> - tag may then use the "title" variable. - -**tal:condition="expression"** - Only keep this tag and its contents if the expression is true. For example:: - - <p tal:condition="python:request.user.hasPermission('View', 'issue')"> - Display some issue information. - </p> - - In the example, the <p> tag and its contents are only displayed if the - user has the View permission for issues. We consider the number zero, a - blank string, an empty list, and the built-in variable nothing to be false - values. Nearly every other value is true, including non-zero numbers, and - strings with anything in them (even spaces!). - -**tal:repeat="variable expression"** - Repeat this tag and its contents for each element of the sequence that the - expression returns, defining a new local variable and a special "repeat" - variable for each element. For example:: - - <tr tal:repeat="u user/list"> - <td tal:content="u/id"></td> - <td tal:content="u/username"></td> - <td tal:content="u/realname"></td> - </tr> - - The example would iterate over the sequence of users returned by - "user/list" and define the local variable "u" for each entry. - -**tal:replace="expression"** - Replace this tag with the result of the expression. For example:: - - <span tal:replace="request/user/realname"></span> - - The example would replace the <span> tag and its contents with the user's - realname. If the user's realname was "Bruce" then the resultant output - would be "Bruce". - -**tal:content="expression"** - Replace the contents of this tag with the result of the expression. For - example:: - - <span tal:content="request/user/realname">user's name appears here</span> - - The example would replace the contents of the <span> tag with the user's - realname. If the user's realname was "Bruce" then the resultant output - would be "<span>Bruce</span>". - -**tal:attributes="attribute expression; attribute expression; ..."** - Set attributes on this tag to the results of expressions. For example:: - - <a tal:attributes="href string:user${request/user/id}">My Details</a> - - In the example, the "href" attribute of the <a> tag is set to the value of - the "string:user${request/user/id}" expression, which will be something - like "user123". - -**tal:omit-tag="expression"** - Remove this tag (but not its contents) if the expression is true. For - example:: - - <span tal:omit-tag="python:1">Hello, world!</span> - - would result in output of:: - - Hello, world! - -Note that the commands on a given tag are evaulated in the order above, so -*define* comes before *condition*, and so on. - -Additionally, a tag is defined, tal:block, which is removed from output. Its -content is not, but the tag itself is (so don't go using any tal:attributes -commands on it). This is useful for making arbitrary blocks of HTML -conditional or repeatable (very handy for repeating multiple table rows, -which would othewise require an illegal tag placement to effect the repeat). - - -Templating Expressions -~~~~~~~~~~~~~~~~~~~~~~ - -The expressions you may use in the attibute values may be one of the following -forms: - -**Path Expressions** - eg. ``item/status/checklist`` - These are object attribute / item accesses. Roughly speaking, the path - ``item/status/checklist`` is broken into parts ``item``, ``status`` - and ``checklist``. The ``item`` part is the root of the expression. - We then look for a ``status`` attribute on ``item``, or failing that, a - ``status`` item (as in ``item['status']``). If that - fails, the path expression fails. When we get to the end, the object we're - left with is evaluated to get a string - methods are called, objects are - stringified. Path expressions may have an optional ``path:`` prefix, though - they are the default expression type, so it's not necessary. - - XXX | components of expressions - - XXX "nothing" and "default" - -**String Expressions** - eg. ``string:hello ${user/name}`` - These expressions are simple string interpolations (though they can be just - plain strings with no interpolation if you want. The expression in the - ``${ ... }`` is just a path expression as above. - -**Python Expressions** - eg. ``python: 1+1`` - These expressions give the full power of Python. All the "root level" - variables are available, so ``python:item.status.checklist()`` would be - equivalent to ``item/status/checklist``, assuming that ``checklist`` is - a method. - -Template Macros -~~~~~~~~~~~~~~~ - -Macros are used in Roundup to save us from repeating the same common page -stuctures over and over. The most common (and probably only) macro you'll use -is the "icing" macro defined in the "page" template. - -Macros are generated and used inside your templates using special attributes -similar to the `basic templating actions`_. In this case though, the -attributes belong to the Macro Expansion Template Attribute Language, or -METAL. The macro commands are: - -**metal:define-macro="macro name"** - Define that the tag and its contents are now a macro that may be inserted - into other templates using the *use-macro* command. For example:: - - <html metal:define-macro="page"> - ... - </html> - - defines a macro called "page" using the ``<html>`` tag and its contents. - Once defined, macros are stored on the template they're defined on in the - ``macros`` attribute. You can access them later on through the ``templates`` - variable, eg. the most common ``templates/page/macros/icing`` to access the - "page" macro of the "page" template. - -**metal:use-macro="path expression"** - Use a macro, which is identified by the path expression (see above). This - will replace the current tag with the identified macro contents. For - example:: - - <tal:block metal:use-macro="templates/page/macros/icing"> - ... - </tal:block> - - will replace the tag and its contents with the "page" macro of the "page" - template. - -**metal:define-slot="slot name"** and **metal:fill-slot="slot name"** - To define *dynamic* parts of the macro, you define "slots" which may be - filled when the macro is used with a *use-macro* command. For example, the - ``templates/page/macros/icing`` macro defines a slot like so:: - - <title metal:define-slot="head_title">title goes here</title> - - In your *use-macro* command, you may now use a *fill-slot* command like - this:: - - <title metal:fill-slot="head_title">My Title</title> - - where the tag that fills the slot completely replaces the one defined as - the slot in the macro. - -Note that you may not mix METAL and TAL commands on the same tag, but TAL -commands may be used freely inside METAL-using tags (so your *fill-slots* -tags may have all manner of TAL inside them). - - -Information available to templates ----------------------------------- - -Note: this is implemented by roundup.cgi.templating.RoundupPageTemplate - -The following variables are available to templates. - -**context** - The current context. This is either None, a - `hyperdb class wrapper`_ or a `hyperdb item wrapper`_ -**request** - Includes information about the current request, including: - - the url - - the current index information (``filterspec``, ``filter`` args, - ``properties``, etc) parsed out of the form. - - methods for easy filterspec link generation - - *user*, the current user item as an HTMLItem instance - - *form* - The current CGI form information as a mapping of form argument - name to value -**tracker** - The current tracker -**db** - The current database, through which db.config may be reached. -**templates** - Access to all the tracker templates by name. Used mainly in *use-macro* - commands. -**utils** - This variable makes available some utility functions like batching. -**nothing** - This is a special variable - if an expression evaluates to this, then the - tag (in the case of a tal:replace), its contents (in the case of - tal:content) or some attributes (in the case of tal:attributes) will not - appear in the the output. So for example:: - - <span tal:attributes="class nothing">Hello, World!</span> - - would result in:: - - <span>Hello, World!</span> - -**default** - Also a special variable - if an expression evaluates to this, then the - existing HTML in the template will not be replaced or removed, it will - remain. So:: - - <span tal:replace="default">Hello, World!</span> - - would result in:: - - <span>Hello, World!</span> - -The context variable -~~~~~~~~~~~~~~~~~~~~ - -The *context* variable is one of three things based on the current context -(see `determining web context`_ for how we figure this out): - -1. if we're looking at a "home" page, then it's None -2. if we're looking at a specific hyperdb class, it's a - `hyperdb class wrapper`_. -3. if we're looking at a specific hyperdb item, it's a - `hyperdb item wrapper`_. - -If the context is not None, we can access the properties of the class or item. -The only real difference between cases 2 and 3 above are: - -1. the properties may have a real value behind them, and this will appear if - the property is displayed through ``context/property`` or - ``context/property/field``. -2. the context's "id" property will be a false value in the second case, but - a real, or true value in the third. Thus we can determine whether we're - looking at a real item from the hyperdb by testing "context/id". - -Hyperdb class wrapper -::::::::::::::::::::: - -Note: this is implemented by the roundup.cgi.templating.HTMLClass class. - -This wrapper object provides access to a hyperb class. It is used primarily -in both index view and new item views, but it's also usable anywhere else that -you wish to access information about a class, or the items of a class, when -you don't have a specific item of that class in mind. - -We allow access to properties. There will be no "id" property. The value -accessed through the property will be the current value of the same name from -the CGI form. - -There are several methods available on these wrapper objects: - -=========== ============================================================= -Method Description -=========== ============================================================= -properties return a `hyperdb property wrapper`_ for all of this class' - properties. -list lists all of the active (not retired) items in the class. -csv return the items of this class as a chunk of CSV text. -propnames lists the names of the properties of this class. -filter lists of items from this class, filtered and sorted - by the current *request* filterspec/filter/sort/group args -classhelp display a link to a javascript popup containing this class' - "help" template. -submit generate a submit button (and action hidden element) -renderWith render this class with the given template. -history returns 'New node - no history' :) -is_edit_ok is the user allowed to Edit the current class? -is_view_ok is the user allowed to View the current class? -=========== ============================================================= - -Note that if you have a property of the same name as one of the above methods, -you'll need to access it using a python "item access" expression. For example:: - - python:context['list'] - -will access the "list" property, rather than the list method. - - -Hyperdb item wrapper -:::::::::::::::::::: - -Note: this is implemented by the roundup.cgi.templating.HTMLItem class. - -This wrapper object provides access to a hyperb item. - -We allow access to properties. There will be no "id" property. The value -accessed through the property will be the current value of the same name from -the CGI form. - -There are several methods available on these wrapper objects: - -=============== ============================================================= -Method Description -=============== ============================================================= -submit generate a submit button (and action hidden element) -journal return the journal of the current item (**not implemented**) -history render the journal of the current item as HTML -renderQueryForm specific to the "query" class - render the search form for - the query -hasPermission specific to the "user" class - determine whether the user - has a Permission -is_edit_ok is the user allowed to Edit the current item? -is_view_ok is the user allowed to View the current item? -=============== ============================================================= - - -Note that if you have a property of the same name as one of the above methods, -you'll need to access it using a python "item access" expression. For example:: - - python:context['journal'] - -will access the "journal" property, rather than the journal method. - - -Hyperdb property wrapper -:::::::::::::::::::::::: - -Note: this is implemented by subclasses roundup.cgi.templating.HTMLProperty -class (HTMLStringProperty, HTMLNumberProperty, and so on). - -This wrapper object provides access to a single property of a class. Its -value may be either: - -1. if accessed through a `hyperdb item wrapper`_, then it's a value from the - hyperdb -2. if access through a `hyperdb class wrapper`_, then it's a value from the - CGI form - - -The property wrapper has some useful attributes: - -=============== ============================================================= -Attribute Description -=============== ============================================================= -_name the name of the property -_value the value of the property if any -=============== ============================================================= - -There are several methods available on these wrapper objects: - -=========== ================================================================= -Method Description -=========== ================================================================= -plain render a "plain" representation of the property -field render a form edit field for the property -stext only on String properties - render the value of the - property as StructuredText (requires the StructureText module - to be installed separately) -multiline only on String properties - render a multiline form edit - field for the property -email only on String properties - render the value of the - property as an obscured email address -confirm only on Password properties - render a second form edit field for - the property, used for confirmation that the user typed the - password correctly. Generates a field with name "name:confirm". -reldate only on Date properties - render the interval between the - date and now -pretty only on Interval properties - render the interval in a - pretty format (eg. "yesterday") -menu only on Link and Multilink properties - render a form select - list for this property -reverse only on Multilink properties - produce a list of the linked - items in reverse order -=========== ================================================================= - -The request variable -~~~~~~~~~~~~~~~~~~~~ - -Note: this is implemented by the roundup.cgi.templating.HTMLRequest class. - -The request variable is packed with information about the current request. - -.. taken from roundup.cgi.templating.HTMLRequest docstring - -=========== ================================================================= -Variable Holds -=========== ================================================================= -form the CGI form as a cgi.FieldStorage -env the CGI environment variables -url the current URL path for this request -base the base URL for this tracker -user a HTMLUser instance for this user -classname the current classname (possibly None) -template the current template (suffix, also possibly None) -form the current CGI form variables in a FieldStorage -=========== ================================================================= - -**Index page specific variables (indexing arguments)** - -=========== ================================================================= -Variable Holds -=========== ================================================================= -columns dictionary of the columns to display in an index page -show a convenience access to columns - request/show/colname will - be true if the columns should be displayed, false otherwise -sort index sort column (direction, column name) -group index grouping property (direction, column name) -filter properties to filter the index on -filterspec values to filter the index on -search_text text to perform a full-text search on for an index -=========== ================================================================= - -There are several methods available on the request variable: - -=============== ============================================================= -Method Description -=============== ============================================================= -description render a description of the request - handle for the page - title -indexargs_form render the current index args as form elements -indexargs_url render the current index args as a URL -base_javascript render some javascript that is used by other components of - the templating -batch run the current index args through a filter and return a - list of items (see `hyperdb item wrapper`_, and - `batching`_) -=============== ============================================================= - -The form variable -::::::::::::::::: - -The form variable is a little special because it's actually a python -FieldStorage object. That means that you have two ways to access its -contents. For example, to look up the CGI form value for the variable -"name", use the path expression:: - - request/form/name/value - -or the python expression:: - - python:request.form['name'].value - -Note the "item" access used in the python case, and also note the explicit -"value" attribute we have to access. That's because the form variables are -stored as MiniFieldStorages. If there's more than one "name" value in -the form, then the above will break since ``request/form/name`` is actually a -*list* of MiniFieldStorages. So it's best to know beforehand what you're -dealing with. - - -The db variable -~~~~~~~~~~~~~~~ - -Note: this is implemented by the roundup.cgi.templating.HTMLDatabase class. - -Allows access to all hyperdb classes as attributes of this variable. If you -want access to the "user" class, for example, you would use:: - - db/user - python:db.user - -The access results in a `hyperdb class wrapper`_. - -The templates variable -~~~~~~~~~~~~~~~~~~~~~~ - -Note: this is implemented by the roundup.cgi.templating.Templates class. - -This variable doesn't have any useful methods defined. It supports being -used in expressions to access the templates, and subsequently the template -macros. You may access the templates using the following path expression:: - - templates/name - -or the python expression:: - - templates[name] - -where "name" is the name of the template you wish to access. The template you -get access to has one useful attribute, "macros". To access a specific macro -(called "macro_name"), use the path expression:: - - templates/name/macros/macro_name - -or the python expression:: - - templates[name].macros[macro_name] - - -The utils variable -~~~~~~~~~~~~~~~~~~ - -Note: this is implemented by the roundup.cgi.templating.TemplatingUtils class. - -=============== ============================================================= -Method Description -=============== ============================================================= -Batch return a batch object using the supplied list -=============== ============================================================= - -Batching -:::::::: - -Use Batch to turn a list of items, or item ids of a given class, into a series -of batches. Its usage is:: - - python:util.Batch(sequence, size, start, end=0, orphan=0, overlap=0) - -or, to get the current index batch:: - - request/batch - -The parameters are: - -========= ================================================================== -Parameter Usage -========= ================================================================== -sequence a list of HTMLItems -size how big to make the sequence. -start where to start (0-indexed) in the sequence. -end where to end (0-indexed) in the sequence. -orphan if the next batch would contain less items than this - value, then it is combined with this batch -overlap the number of items shared between adjacent batches -========= ================================================================== - -All of the parameters are assigned as attributes on the batch object. In -addition, it has several more attributes: - -=============== ============================================================ -Attribute Description -=============== ============================================================ -start indicates the start index of the batch. *Note: unlike the - argument, is a 1-based index (I know, lame)* -first indicates the start index of the batch *as a 0-based - index* -length the actual number of elements in the batch -sequence_length the length of the original, unbatched, sequence. -=============== ============================================================ - -And several methods: - -=============== ============================================================ -Method Description -=============== ============================================================ -previous returns a new Batch with the previous batch settings -next returns a new Batch with the next batch settings -propchanged detect if the named property changed on the current item - when compared to the last item -=============== ============================================================ - -An example of batching:: - - <table class="otherinfo"> - <tr><th colspan="4" class="header">Existing Keywords</th></tr> - <tr tal:define="keywords db/keyword/list" - tal:repeat="start python:range(0, len(keywords), 4)"> - <td tal:define="batch python:utils.Batch(keywords, 4, start)" - tal:repeat="keyword batch" tal:content="keyword/name">keyword here</td> - </tr> - </table> - -... which will produce a table with four columns containing the items of the -"keyword" class (well, their "name" anyway). - -Displaying Properties ---------------------- - -Properties appear in the user interface in three contexts: in indices, in -editors, and as search arguments. -For each type of property, there are several display possibilities. -For example, in an index view, a string property may just be -printed as a plain string, but in an editor view, that property may be -displayed in an editable field. - - -Index Views ------------ - -This is one of the class context views. It is also the default view for -classes. The template used is "*classname*.index". - -Index View Specifiers -~~~~~~~~~~~~~~~~~~~~~ - -An index view specifier (URL fragment) looks like this (whitespace has been -added for clarity):: - - /issue?status=unread,in-progress,resolved& - topic=security,ui& - :group=+priority& - :sort==activity& - :filters=status,topic& - :columns=title,status,fixer - -The index view is determined by two parts of the specifier: the layout part and -the filter part. The layout part consists of the query parameters that begin -with colons, and it determines the way that the properties of selected items -are displayed. The filter part consists of all the other query parameters, and -it determines the criteria by which items are selected for display. -The filter part is interactively manipulated with the form widgets displayed in -the filter section. The layout part is interactively manipulated by clicking on -the column headings in the table. - -The filter part selects the union of the sets of items with values matching any -specified Link properties and the intersection of the sets of items with values -matching any specified Multilink properties. - -The example specifies an index of "issue" items. Only items with a "status" of -either "unread" or "in-progres" or "resolved" are displayed, and only items -with "topic" values including both "security" and "ui" are displayed. The items -are grouped by priority, arranged in ascending order; and within groups, sorted -by activity, arranged in descending order. The filter section shows filters for -the "status" and "topic" properties, and the table includes columns for the -"title", "status", and "fixer" properties. - -Filtering of indexes -~~~~~~~~~~~~~~~~~~~~ - -TODO - -Searching Views ---------------- - -This is one of the class context views. The template used is typically -"*classname*.search". - -TODO - -Item Views ----------- - -The basic view of a hyperdb item is provided by the "*classname*.item" -template. It generally has three sections; an "editor", a "spool" and a -"history" section. - - - -Editor Section -~~~~~~~~~~~~~~ - -The editor section is used to manipulate the item - it may be a -static display if the user doesn't have permission to edit the item. - -Here's an example of a basic editor template (this is the default "classic" -template issue item edit form - from the "issue.item" template):: - - <table class="form"> - <tr> - <th nowrap>Title</th> - <td colspan=3 tal:content="structure python:context.title.field(size=60)">title</td> - </tr> - - <tr> - <th nowrap>Priority</th> - <td tal:content="structure context/priority/menu">priority</td> - <th nowrap>Status</th> - <td tal:content="structure context/status/menu">status</td> - </tr> - - <tr> - <th nowrap>Superseder</th> - <td> - <span tal:replace="structure python:context.superseder.field(showid=1, size=20)" /> - <span tal:replace="structure python:db.issue.classhelp('id,title')" /> - <span tal:condition="context/superseder"> - <br>View: <span tal:replace="structure python:context.superseder.link(showid=1)" /> - </span> - </td> - <th nowrap>Nosy List</th> - <td> - <span tal:replace="structure context/nosy/field" /> - <span tal:replace="structure python:db.user.classhelp('username,realname,address,phone')" /> - </td> - </tr> - - <tr> - <th nowrap>Assigned To</th> - <td tal:content="structure context/assignedto/menu"> - assignedto menu - </td> - <td> </td> - <td> </td> - </tr> - - <tr> - <th nowrap>Change Note</th> - <td colspan=3> - <textarea name=":note" wrap="hard" rows="5" cols="60"></textarea> - </td> - </tr> - - <tr> - <th nowrap>File</th> - <td colspan=3><input type="file" name=":file" size="40"></td> - </tr> - - <tr> - <td> </td> - <td colspan=3 tal:content="structure context/submit"> - submit button will go here - </td> - </tr> - </table> - - -When a change is submitted, the system automatically generates a message -describing the changed properties. As shown in the example, the editor -template can use the ":note" and ":file" fields, which are added to the -standard change note message generated by Roundup. - -Spool Section -~~~~~~~~~~~~~ - -The spool section lists related information like the messages and files of -an issue. - -TODO - - -History Section -~~~~~~~~~~~~~~~ - -The final section displayed is the history of the item - its database journal. -This is generally generated with the template:: - - <tal:block tal:replace="structure context/history" /> - -*To be done:* - -*The actual history entries of the item may be accessed for manual templating -through the "journal" method of the item*:: - - <tal:block tal:repeat="entry context/journal"> - a journal entry - </tal:block> - -*where each journal entry is an HTMLJournalEntry.* - -Defining new web actions ------------------------- - -XXX - - -Access Controls -=============== - -A set of Permissions are built in to the security module by default: - -- Edit (everything) -- View (everything) - -The default interfaces define: - -- Web Registration -- Web Access -- Web Roles -- Email Registration -- Email Access - -These are hooked into the default Roles: - -- Admin (Edit everything, View everything, Web Roles) -- User (Web Access, Email Access) -- Anonymous (Web Registration, Email Registration) - -And finally, the "admin" user gets the "Admin" Role, and the "anonymous" user -gets the "Anonymous" assigned when the database is initialised on installation. -The two default schemas then define: - -- Edit issue, View issue (both) -- Edit file, View file (both) -- Edit msg, View msg (both) -- Edit support, View support (extended only) - -and assign those Permissions to the "User" Role. New users are assigned the -Roles defined in the config file as: - -- NEW_WEB_USER_ROLES -- NEW_EMAIL_USER_ROLES - -You may alter the configuration variables to change the Role that new web or -email users get, for example to not give them access to the web interface if -they register through email. - -You may use the ``roundup-admin`` "``security``" command to display the -current Role and Permission configuration in your tracker. - -Adding a new Permission ------------------------ - -When adding a new Permission, you will need to: - -1. add it to your tracker's dbinit so it is created -2. enable it for the Roles that should have it (verify with - "``roundup-admin security``") -3. add it to the relevant HTML interface templates -4. add it to the appropriate xxxPermission methods on in your tracker - interfaces module - - - -Examples -======== - -Adding a new field to the classic schema ----------------------------------------- - -This example shows how to add a new constrained property (ie. a selection of -distinct values) to your tracker. - -Introduction -~~~~~~~~~~~~ - -To make the classic schema of roundup useful as a todo tracking system -for a group of systems administrators, it needed an extra data field -per issue: a category. - -This would let sysads quickly list all todos in their particular -area of interest without having to do complex queries, and without -relying on the spelling capabilities of other sysads (a losing -proposition at best). - -Adding a field to the database -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This is the easiest part of the change. The category would just be a plain -string, nothing fancy. To change what is in the database you need to add -some lines to the ``open()`` function in ``dbinit.py``:: - - category = Class(db, "category", name=String()) - category.setkey("name") - -Here we are setting up a chunk of the database which we are calling -"category". It contains a string, which we are refering to as "name" for -lack of a more imaginative title. Then we are setting the key of this chunk -of the database to be that "name". This is equivalent to an index for -database types. This also means that there can only be one category with a -given name. - -Adding the above lines allows us to create categories, but they're not tied -to the issues that we are going to be creating. It's just a list of categories -off on its own, which isn't much use. We need to link it in with the issues. -To do that, find the lines in the ``open()`` function in ``dbinit.py`` which -set up the "issue" class, and then add a link to the category:: - - issue = IssueClass(db, "issue", ... , category=Multilink("category"), ... ) - -The Multilink() means that each issue can have many categories. If you were -adding something with a more one to one relationship use Link() instead. - -That is all you need to do to change the schema. The rest of the effort is -fiddling around so you can actually use the new category. - -Setting up security on the new objects -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -By default only the admin user can look at and change objects. This doesn't -suit us, as we want any user to be able to create new categories as -required, and obviously everyone needs to be able to view the categories of -issues for it to be useful. - -We therefore need to change the security of the category objects. This is -also done in the ``open()`` function of ``dbinit.py``. - -There are currently two loops which set up permissions and then assign them -to various roles. Simply add the new "category" to both lists:: - - # new permissions for this schema - for cl in 'issue', 'file', 'msg', 'user', 'category': - db.security.addPermission(name="Edit", klass=cl, - description="User is allowed to edit "+cl) - db.security.addPermission(name="View", klass=cl, - description="User is allowed to access "+cl) - - # Assign the access and edit permissions for issue, file and message - # to regular users now - for cl in 'issue', 'file', 'msg', 'category': - p = db.security.getPermission('View', cl) - db.security.addPermissionToRole('User', p) - p = db.security.getPermission('Edit', cl) - db.security.addPermissionToRole('User', p) - -So you are in effect doing the following:: - - db.security.addPermission(name="Edit", klass='category', - description="User is allowed to edit "+'category') - db.security.addPermission(name="View", klass='category', - description="User is allowed to access "+'category') - -which is creating two permission types; that of editing and viewing -"category" objects respectively. Then the following lines assign those new -permissions to the "User" role, so that normal users can view and edit -"category" objects:: - - p = db.security.getPermission('View', 'category') - db.security.addPermissionToRole('User', p) - - p = db.security.getPermission('Edit', 'category') - db.security.addPermissionToRole('User', p) - -This is all the work that needs to be done for the database. It will store -categories, and let users view and edit them. Now on to the interface -stuff. - -Changing the web left hand frame -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -We need to give the users the ability to create new categories, and the -place to put the link to this functionality is in the left hand function -bar, under the "Issues" area. The file that defines how this area looks is -``html/page``, which is what we are going to be editing next. - -If you look at this file you can see that it contains a lot of "classblock" -sections which are chunks of HTML that will be included or excluded in the -output depending on whether the condition in the classblock is met. Under -the end of the classblock for issue is where we are going to add the -category code:: - - <p class="classblock" - tal:condition="python:request.user.hasPermission('View', 'category')"> - <b>Categories</b><br> - <a tal:condition="python:request.user.hasPermission('Edit', 'category')" - href="category?:template=item">New Category<br></a> - </p> - -The first two lines is the classblock definition, which sets up a condition -that only users who have "View" permission to the "category" object will -have this section included in their output. Next comes a plain "Categories" -header in bold. Everyone who can view categories will get that. - -Next comes the link to the editing area of categories. This link will only -appear if the condition is matched: that condition being that the user has -"Edit" permissions for the "category" objects. If they do have permission -then they will get a link to another page which will let the user add new -categories. - -Note that if you have permission to view but not edit categories then all -you will see is a "Categories" header with nothing underneath it. This is -obviously not very good interface design, but will do for now. I just claim -that it is so I can add more links in this section later on. However to fix -the problem you could change the condition in the classblock statement, so -that only users with "Edit" permission would see the "Categories" stuff. - -Setting up a page to edit categories -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -We defined code in the previous section which let users with the -appropriate permissions see a link to a page which would let them edit -conditions. Now we have to write that page. - -The link was for the item template for the category object. This translates -into the system looking for a file called ``category.item`` in the ``html`` -tracker directory. This is the file that we are going to write now. - -First we add an info tag in a comment which doesn't affect the outcome -of the code at all but is useful for debugging. If you load a page in a -browser and look at the page source, you can see which sections come -from which files by looking for these comments:: - - <!-- category.item --> - -Next we need to add in the METAL macro stuff so we get the normal page -trappings:: - - <tal:block metal:use-macro="templates/page/macros/icing"> - <title metal:fill-slot="head_title">Category editing</title> - <td class="page-header-top" metal:fill-slot="body_title"> - <h2>Category editing</h2> - </td> - <td class="content" metal:fill-slot="content"> - -Next we need to setup up a standard HTML form, which is the whole -purpose of this file. We link to some handy javascript which sends the form -through only once. This is to stop users hitting the send button -multiple times when they are impatient and thus having the form sent -multiple times:: - - <form method="POST" onSubmit="return submit_once()" - enctype="multipart/form-data"> - -Next we define some code which sets up the minimum list of fields that we -require the user to enter. There will be only one field, that of "name", so -they user better put something in it otherwise the whole form is pointless:: - - <input type="hidden" name=":required" value="name"> - -To get everything to line up properly we will put everything in a table, -and put a nice big header on it so the user has an idea what is happening:: - - <table class="form"> - <tr><th class="header" colspan=2>Category</th></tr> - -Next we need the actual field that the user is going to enter the new -category. The "context.name.field(size=60)" bit tells roundup to generate a -normal HTML field of size 60, and the contents of that field will be the -"name" variable of the current context (which is "category"). The upshot of -this is that when the user types something in to the form, a new category -will be created with that name:: - - <tr> - <th nowrap>Name</th> - <td tal:content="structure python:context.name.field(size=60)">name</td> - </tr> - -Then a submit button so that the user can submit the new category:: - - <tr> - <td> </td> - <td colspan=3 tal:content="structure context/submit"> - submit button will go here - </td> - </tr> - -Finally we finish off the tags we used at the start to do the METAL stuff:: - - </td> - </tal:block> - -So putting it all together, and closing the table and form we get:: - - <!-- category.item --> - <tal:block metal:use-macro="templates/page/macros/icing"> - <title metal:fill-slot="head_title">Category editing</title> - <td class="page-header-top" metal:fill-slot="body_title"> - <h2>Category editing</h2> - </td> - <td class="content" metal:fill-slot="content"> - <form method="POST" onSubmit="return submit_once()" - enctype="multipart/form-data"> - - <input type="hidden" name=":required" value="name"> - - <table class="form"> - <tr><th class="header" colspan=2>Category</th></tr> - - <tr> - <th nowrap>Name</th> - <td tal:content="structure python:context.name.field(size=60)">name</td> - </tr> - - <tr> - <td> </td> - <td colspan=3 tal:content="structure context/submit"> - submit button will go here - </td> - </tr> - </table> - </form> - </td> - </tal:block> - -This is quite a lot to just ask the user one simple question, but -there is a lot of setup for basically one line (the form line) to do -its work. To add another field to "category" would involve one more line -(well maybe a few extra to get the formatting correct). - -Adding the category to the issue -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -We now have the ability to create issues to our hearts content, but -that is pointless unless we can assign categories to issues. Just like -the ``html/category.item`` file was used to define how to add a new -category, the ``html/issue.item`` is used to define how a new issue is -created. - -Just like ``category.issue`` this file defines a form which has a table to lay -things out. It doesn't matter where in the table we add new stuff, -it is entirely up to your sense of aesthetics:: - - <th nowrap>Category</th> - <td><span tal:replace="structure context/category/field" /> - <span tal:replace="structure db/category/classhelp" /> - </td> - -First we define a nice header so that the user knows what the next section -is, then the middle line does what we are most interested in. This -``context/category/field`` gets replaced with a field which contains the -category in the current context (the current context being the new issue). - -The classhelp lines generate a link (labelled "list") to a popup window -which contains the list of currently known categories. - -Searching on categories -~~~~~~~~~~~~~~~~~~~~~~~ - -We can add categories, and create issues with categories. The next obvious -thing that we would like to be would be to search issues based on their -category, so that any one working on the web server could look at all -issues in the category "Web" for example. - -If you look in the html/page file and look for the "Search Issues" you will -see that it looks something like ``<a href="issue?:template=search">Search -Issues</a>`` which shows us that when you click on "Search Issues" it will -be looking for a ``issue.search`` file to display. So that is indeed the file -that we are going to change. - -If you look at this file it should be starting to seem familiar. It is a -simple HTML form using a table to define structure. You can add the new -category search code anywhere you like within that form:: - - <tr> - <th>Category:</th> - <td> - <select name="category"> - <option value="">don't care</option> - <option value="">------------</option> - <option tal:repeat="s db/category/list" tal:attributes="value s/name" - tal:content="s/name">category to filter on</option> - </select> - </td> - <td><input type="checkbox" name=":columns" value="category" checked></td> - <td><input type="radio" name=":sort" value="category"></td> - <td><input type="radio" name=":group" value="category"></td> - </tr> - -Most of this is straightforward to anyone who knows HTML. It is just -setting up a select list followed by a checkbox and a couple of radio -buttons. - -The ``tal:repeat`` part repeats the tag for every item in the "category" -table and setting "s" to be each category in turn. - -The ``tal:attributes`` part is setting up the ``value=`` part of the option tag -to be the name part of "s" which is the current category in the loop. - -The ``tal:content`` part is setting the contents of the option tag to be the -name part of "s" again. For objects more complex than category, obviously -you would put an id in the value, and the descriptive part in the content; -but for category they are the same. - -Adding category to the default view -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -We can now add categories, add issues with categories, and search issues -based on categories. This is everything that we need to do, however there -is some more icing that we would like. I think the category of an issue is -important enough that it should be displayed by default when listing all -the issues. - -Unfortunately, this is a bit less obvious than the previous steps. The code -defining how the issues look is in ``html/issue.index``. This is a large table -with a form down the bottom for redisplaying and so forth. - -Firstly we need to add an appropriate header to the start of the table:: - - <th tal:condition="request/show/category">Category</th> - -The condition part of this statement is so that if the user has selected -not to see the Category column then they won't. - -The rest of the table is a loop which will go through every issue that -matches the display criteria. The loop variable is "i" - which means that -every issue gets assigned to "i" in turn. - -The new part of code to display the category will look like this:: - - <td tal:condition="request/show/category" tal:content="i/category"></td> - -The condition is the same as above: only display the condition when the -user hasn't asked for it to be hidden. The next part is to set the content -of the cell to be the category part of "i" - the current issue. - -Finally we have to edit ``html/page`` again. This time to tell it that when the -user clicks on "Unnasigned Issues" or "All Issues" that the category should -be displayed. If you scroll down the page file, you can see the links with -lots of options. The option that we are interested in is the ``:columns=`` one -which tells roundup which fields of the issue to display. Simply add -"category" to that list and it all should work. - - -Adding in state transition control ----------------------------------- - -Sometimes tracker admins want to control the states that users may move issues -to. - -1. add a Multilink property to the status class:: - - stat = Class(db, "status", ... , transitions=Multilink('status'), ...) - - and then edit the statuses already created through the web using the - generic class list / CSV editor. - -2. add an auditor module ``checktransition.py`` in your tracker's - ``detectors`` directory:: - - def checktransition(db, cl, nodeid, newvalues): - ''' Check that the desired transition is valid for the "status" - property. - ''' - if not newvalues.has_key('status'): - return - current = cl.get(nodeid, 'status') - new = newvalues['status'] - if new == current: - return - ok = db.status.get(current, 'transitions') - if new not in ok: - raise ValueError, 'Status not allowed to move from "%s" to "%s"'%( - db.status.get(current, 'name'), db.status.get(new, 'name')) - - def init(db): - db.issue.audit('set', checktransition) - -3. in the ``issue.item`` template, change the status editing bit from:: - - <th nowrap>Status</th> - <td tal:content="structure context/status/menu">status</td> - - to:: - - <th nowrap>Status</th> - <td> - <select tal:condition="context/id" name="status"> - <tal:block tal:define="ok context/status/transitions" - tal:repeat="state db/status/list"> - <option tal:condition="python:state.id in ok" - tal:attributes="value state/id; - selected python:state.id == context.status.id" - tal:content="state/name"></option> - </tal:block> - </select> - <tal:block tal:condition="not:context/id" - tal:replace="structure context/status/menu" /> - </td> - - which displays only the allowed status to transition to. - - -Displaying entire message contents in the issue display -------------------------------------------------------- - -Alter the issue.item template section for messages to:: - - <table class="messages" tal:condition="context/messages"> - <tr><th colspan=3 class="header">Messages</th></tr> - <tal:block tal:repeat="msg context/messages/reverse"> - <tr> - <th><a tal:attributes="href string:msg${msg/id}" - tal:content="string:msg${msg/id}"></a></th> - <th tal:content="string:Author: ${msg/author}">author</th> - <th tal:content="string:Date: ${msg/date}">date</th> - </tr> - <tr> - <td colspan="3" class="content"> - <pre tal:content="msg/content">content</pre> - </td> - </tr> - </tal:block> - </table> - -Restricting the list of users that are assignable to a task ------------------------------------------------------------ - -1. In your tracker's "dbinit.py", create a new Role, say "Developer":: - - db.security.addRole(name='Developer', description='A developer') - -2. Just after that, create a new Permission, say "Fixer", specific to "issue":: - - p = db.security.addPermission(name='Fixer', klass='issue', - description='User is allowed to be assigned to fix issues') - -3. Then assign the new Permission to your "Developer" Role:: - - db.security.addPermissionToRole('Developer', p) - -4. In the issue item edit page ("html/issue.item" in your tracker dir), use - the new Permission in restricting the "assignedto" list:: - - <select name="assignedto"> - <option value="-1">- no selection -</option> - <tal:block tal:repeat="user db/user/list"> - <option tal:condition="python:user.hasPermission('Fixer', context.classname)" - tal:attributes="value user/id; - selected python:user.id == context.assignedto" - tal:content="user/realname"></option> - </tal:block> - </select> - -For extra security, you may wish to set up an auditor to enforce the -Permission requirement (install this as "assignedtoFixer.py" in your tracker -"detectors" directory):: - - def assignedtoMustBeFixer(db, cl, nodeid, newvalues): - ''' Ensure the assignedto value in newvalues is a used with the Fixer - Permission - ''' - if not newvalues.has_key('assignedto'): - # don't care - return - - # get the userid - userid = newvalues['assignedto'] - if not db.security.hasPermission('Fixer', userid, cl.classname): - raise ValueError, 'You do not have permission to edit %s'%cl.classname - - def init(db): - db.issue.audit('set', assignedtoMustBeFixer) - db.issue.audit('create', assignedtoMustBeFixer) - -So now, if the edit attempts to set the assignedto to a user that doesn't have -the "Fixer" Permission, the error will be raised. - - -Setting up a "wizard" (or "druid") for controlled adding of issues ------------------------------------------------------------------- - -1. Set up the page templates you wish to use for data input. My wizard - is going to be a two-step process, first figuring out what category of - issue the user is submitting, and then getting details specific to that - category. The first page includes a table of help, explaining what the - category names mean, and then the core of the form:: - - <form method="POST" onSubmit="return submit_once()" - enctype="multipart/form-data"> - <input type="hidden" name=":template" value="add_page1"> - <input type="hidden" name=":action" value="page1submit"> - - <strong>Category:</strong> - <tal:block tal:replace="structure context/category/menu" /> - <input type="submit" value="Continue"> - </form> - - The next page has the usual issue entry information, with the addition of - the following form fragments:: - - <form method="POST" onSubmit="return submit_once()" - enctype="multipart/form-data" tal:condition="context/is_edit_ok" - tal:define="cat request/form/category/value"> - - <input type="hidden" name=":template" value="add_page2"> - <input type="hidden" name=":required" value="title"> - <input type="hidden" name="category" tal:attributes="value cat"> - - . - . - . - </form> - - Note that later in the form, I test the value of "cat" include form - elements that are appropriate. For example:: - - <tal:block tal:condition="python:cat in '6 10 13 14 15 16 17'.split()"> - <tr> - <th nowrap>Operating System</th> - <td tal:content="structure context/os/field"></td> - </tr> - <tr> - <th nowrap>Web Browser</th> - <td tal:content="structure context/browser/field"></td> - </tr> - </tal:block> - - ... the above section will only be displayed if the category is one of 6, - 10, 13, 14, 15, 16 or 17. - -3. Determine what actions need to be taken between the pages - these are - usually to validate user choices and determine what page is next. Now - encode those actions in methods on the interfaces Client class and insert - hooks to those actions in the "actions" attribute on that class, like so:: - - actions = client.Class.actions + ( - ('page1_submit', page1SubmitAction), - ) - - def page1SubmitAction(self): - ''' Verify that the user has selected a category, and then move on - to page 2. - ''' - category = self.form['category'].value - if category == '-1': - self.error_message.append('You must select a category of report') - return - # everything's ok, move on to the next page - self.template = 'add_page2' - -4. Use the usual "new" action as the :action on the final page, and you're - done (the standard context/submit method can do this for you). - -------------------- - -Back to `Table of Contents`_ - -.. _`Table of Contents`: index.html -
--- a/doc/design.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1642 +0,0 @@ -======================================================== -Roundup - An Issue-Tracking System for Knowledge Workers -======================================================== - -:Authors: Ka-Ping Yee (original__), Richard Jones (implementation) - -__ spec.html - -.. contents:: - -Introduction ---------------- - -This document presents a description of the components -of the Roundup system and specifies their interfaces and -behaviour in sufficient detail to guide an implementation. -For the philosophy and rationale behind the Roundup design, -see the first-round Software Carpentry submission for Roundup. -This document fleshes out that design as well as specifying -interfaces so that the components can be developed separately. - - -The Layer Cake ------------------ - -Lots of software design documents come with a picture of -a cake. Everybody seems to like them. I also like cakes -(i think they are tasty). So i, too, shall include -a picture of a cake here:: - - _________________________________________________________________________ - | E-mail Client | Web Browser | Detector Scripts | Shell | - |------------------+-----------------+----------------------+-------------| - | E-mail User | Web User | Detector | Command | - |-------------------------------------------------------------------------| - | Roundup Database Layer | - |-------------------------------------------------------------------------| - | Hyperdatabase Layer | - |-------------------------------------------------------------------------| - | Storage Layer | - ------------------------------------------------------------------------- - -The colourful parts of the cake are part of our system; -the faint grey parts of the cake are external components. - -I will now proceed to forgo all table manners and -eat from the bottom of the cake to the top. You may want -to stand back a bit so you don't get covered in crumbs. - - -Hyperdatabase -------------- - -The lowest-level component to be implemented is the hyperdatabase. -The hyperdatabase is intended to be -a flexible data store that can hold configurable data in -records which we call items. - -The hyperdatabase is implemented on top of the storage layer, -an external module for storing its data. The storage layer could -be a third-party RDBMS; for a "batteries-included" distribution, -implementing the hyperdatabase on the standard bsddb -module is suggested. - -Dates and Date Arithmetic -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Before we get into the hyperdatabase itself, we need a -way of handling dates. The hyperdatabase module provides -Timestamp objects for -representing date-and-time stamps and Interval objects for -representing date-and-time intervals. - -As strings, date-and-time stamps are specified with -the date in international standard format -(``yyyy-mm-dd``) -joined to the time (``hh:mm:ss``) -by a period "``.``". Dates in -this form can be easily compared and are fairly readable -when printed. An example of a valid stamp is -"``2000-06-24.13:03:59``". -We'll call this the "full date format". When Timestamp objects are -printed as strings, they appear in the full date format with -the time always given in GMT. The full date format is always -exactly 19 characters long. - -For user input, some partial forms are also permitted: -the whole time or just the seconds may be omitted; and the whole date -may be omitted or just the year may be omitted. If the time is given, -the time is interpreted in the user's local time zone. -The Date constructor takes care of these conversions. -In the following examples, suppose that ``yyyy`` is the current year, -``mm`` is the current month, and ``dd`` is the current -day of the month; and suppose that the user is on Eastern Standard Time. - -- "2000-04-17" means <Date 2000-04-17.00:00:00> -- "01-25" means <Date yyyy-01-25.00:00:00> -- "2000-04-17.03:45" means <Date 2000-04-17.08:45:00> -- "08-13.22:13" means <Date yyyy-08-14.03:13:00> -- "11-07.09:32:43" means <Date yyyy-11-07.14:32:43> -- "14:25" means -- <Date yyyy-mm-dd.19:25:00> -- "8:47:11" means -- <Date yyyy-mm-dd.13:47:11> -- the special date "." means "right now" - - -Date intervals are specified using the suffixes -"y", "m", and "d". The suffix "w" (for "week") means 7 days. -Time intervals are specified in hh:mm:ss format (the seconds -may be omitted, but the hours and minutes may not). - -- "3y" means three years -- "2y 1m" means two years and one month -- "1m 25d" means one month and 25 days -- "2w 3d" means two weeks and three days -- "1d 2:50" means one day, two hours, and 50 minutes -- "14:00" means 14 hours -- "0:04:33" means four minutes and 33 seconds - - -The Date class should understand simple date expressions of the form -*stamp* ``+`` *interval* and *stamp* ``-`` *interval*. -When adding or subtracting intervals involving months or years, the -components are handled separately. For example, when evaluating -"``2000-06-25 + 1m 10d``", we first add one month to -get 2000-07-25, then add 10 days to get -2000-08-04 (rather than trying to decide whether -1m 10d means 38 or 40 or 41 days). - -Here is an outline of the Date and Interval classes:: - - class Date: - def __init__(self, spec, offset): - """Construct a date given a specification and a time zone offset. - - 'spec' is a full date or a partial form, with an optional - added or subtracted interval. 'offset' is the local time - zone offset from GMT in hours. - """ - - def __add__(self, interval): - """Add an interval to this date to produce another date.""" - - def __sub__(self, interval): - """Subtract an interval from this date to produce another date.""" - - def __cmp__(self, other): - """Compare this date to another date.""" - - def __str__(self): - """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format.""" - - def local(self, offset): - """Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone.""" - - class Interval: - def __init__(self, spec): - """Construct an interval given a specification.""" - - def __cmp__(self, other): - """Compare this interval to another interval.""" - - def __str__(self): - """Return this interval as a string.""" - - - -Here are some examples of how these classes would behave in practice. -For the following examples, assume that we are on Eastern Standard -Time and the current local time is 19:34:02 on 25 June 2000:: - - >>> Date(".") - <Date 2000-06-26.00:34:02> - >>> _.local(-5) - "2000-06-25.19:34:02" - >>> Date(". + 2d") - <Date 2000-06-28.00:34:02> - >>> Date("1997-04-17", -5) - <Date 1997-04-17.00:00:00> - >>> Date("01-25", -5) - <Date 2000-01-25.00:00:00> - >>> Date("08-13.22:13", -5) - <Date 2000-08-14.03:13:00> - >>> Date("14:25", -5) - <Date 2000-06-25.19:25:00> - >>> Interval(" 3w 1 d 2:00") - <Interval 22d 2:00> - >>> Date(". + 2d") - Interval("3w") - <Date 2000-06-07.00:34:02> - -Items and Classes -~~~~~~~~~~~~~~~~~ - -Items contain data in properties. To Python, these -properties are presented as the key-value pairs of a dictionary. -Each item belongs to a class which defines the names -and types of its properties. The database permits the creation -and modification of classes as well as items. - -Identifiers and Designators -~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Each item has a numeric identifier which is unique among -items in its class. The items are numbered sequentially -within each class in order of creation, starting from 1. -The designator -for an item is a way to identify an item in the database, and -consists of the name of the item's class concatenated with -the item's numeric identifier. - -For example, if "spam" and "eggs" are classes, the first -item created in class "spam" has id 1 and designator "spam1". -The first item created in class "eggs" also has id 1 but has -the distinct designator "eggs1". Item designators are -conventionally enclosed in square brackets when mentioned -in plain text. This permits a casual mention of, say, -"[patch37]" in an e-mail message to be turned into an active -hyperlink. - -Property Names and Types -~~~~~~~~~~~~~~~~~~~~~~~~ - -Property names must begin with a letter. - -A property may be one of five basic types: - -- String properties are for storing arbitrary-length strings. - -- Boolean properties are for storing true/false, or yes/no values. - -- Number properties are for storing numeric values. - -- Date properties store date-and-time stamps. - Their values are Timestamp objects. - -- A Link property refers to a single other item - selected from a specified class. The class is part of the property; - the value is an integer, the id of the chosen item. - -- A Multilink property refers to possibly many items - in a specified class. The value is a list of integers. - -*None* is also a permitted value for any of these property -types. An attempt to store None into a Multilink property stores an empty list. - -A property that is not specified will return as None from a *get* -operation. - -Hyperdb Interface Specification -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -TODO: replace the Interface Specifications with links to the pydoc - -The hyperdb module provides property objects to designate -the different kinds of properties. These objects are used when -specifying what properties belong in classes:: - - class String: - def __init__(self, indexme='no'): - """An object designating a String property.""" - - class Boolean: - def __init__(self): - """An object designating a Boolean property.""" - - class Number: - def __init__(self): - """An object designating a Number property.""" - - class Date: - def __init__(self): - """An object designating a Date property.""" - - class Link: - def __init__(self, classname, do_journal='yes'): - """An object designating a Link property that links to - items in a specified class. - - If the do_journal argument is not 'yes' then changes to - the property are not journalled in the linked item. - """ - - class Multilink: - def __init__(self, classname, do_journal='yes'): - """An object designating a Multilink property that links - to items in a specified class. - - If the do_journal argument is not 'yes' then changes to - the property are not journalled in the linked item(s). - """ - - -Here is the interface provided by the hyperdatabase:: - - class Database: - """A database for storing records containing flexible data types.""" - - def __init__(self, config, journaltag=None): - """Open a hyperdatabase given a specifier to some storage. - - The 'storagelocator' is obtained from config.DATABASE. - The meaning of 'storagelocator' depends on the particular - implementation of the hyperdatabase. It could be a file name, - a directory path, a socket descriptor for a connection to a - database over the network, etc. - - The 'journaltag' is a token that will be attached to the journal - entries for any edits done on the database. If 'journaltag' is - None, the database is opened in read-only mode: the Class.create(), - Class.set(), and Class.retire() methods are disabled. - """ - - def __getattr__(self, classname): - """A convenient way of calling self.getclass(classname).""" - - def getclasses(self): - """Return a list of the names of all existing classes.""" - - def getclass(self, classname): - """Get the Class object representing a particular class. - - If 'classname' is not a valid class name, a KeyError is raised. - """ - - class Class: - """The handle to a particular class of items in a hyperdatabase.""" - - def __init__(self, db, classname, **properties): - """Create a new class with a given name and property specification. - - 'classname' must not collide with the name of an existing class, - or a ValueError is raised. The keyword arguments in 'properties' - must map names to property objects, or a TypeError is raised. - """ - - # Editing items: - - def create(self, **propvalues): - """Create a new item of this class and return its id. - - The keyword arguments in 'propvalues' map property names to values. - The values of arguments must be acceptable for the types of their - corresponding properties or a TypeError is raised. If this class - has a key property, it must be present and its value must not - collide with other key strings or a ValueError is raised. Any other - properties on this class that are missing from the 'propvalues' - dictionary are set to None. If an id in a link or multilink - property does not refer to a valid item, an IndexError is raised. - """ - - def get(self, itemid, propname): - """Get the value of a property on an existing item of this class. - - 'itemid' must be the id of an existing item of this class or an - IndexError is raised. 'propname' must be the name of a property - of this class or a KeyError is raised. - """ - - def set(self, itemid, **propvalues): - """Modify a property on an existing item of this class. - - 'itemid' must be the id of an existing item of this class or an - IndexError is raised. Each key in 'propvalues' must be the name - of a property of this class or a KeyError is raised. All values - in 'propvalues' must be acceptable types for their corresponding - properties or a TypeError is raised. If the value of the key - property is set, it must not collide with other key strings or a - ValueError is raised. If the value of a Link or Multilink - property contains an invalid item id, a ValueError is raised. - """ - - def retire(self, itemid): - """Retire an item. - - The properties on the item remain available from the get() method, - and the item's id is never reused. Retired items are not returned - by the find(), list(), or lookup() methods, and other items may - reuse the values of their key properties. - """ - - def history(self, itemid): - """Retrieve the journal of edits on a particular item. - - 'itemid' must be the id of an existing item of this class or an - IndexError is raised. - - The returned list contains tuples of the form - - (date, tag, action, params) - - 'date' is a Timestamp object specifying the time of the change and - 'tag' is the journaltag specified when the database was opened. - 'action' may be: - - 'create' or 'set' -- 'params' is a dictionary of property values - 'link' or 'unlink' -- 'params' is (classname, itemid, propname) - 'retire' -- 'params' is None - """ - - # Locating items: - - def setkey(self, propname): - """Select a String property of this class to be the key property. - - 'propname' must be the name of a String property of this class or - None, or a TypeError is raised. The values of the key property on - all existing items must be unique or a ValueError is raised. - """ - - def getkey(self): - """Return the name of the key property for this class or None.""" - - def lookup(self, keyvalue): - """Locate a particular item by its key property and return its id. - - If this class has no key property, a TypeError is raised. If the - 'keyvalue' matches one of the values for the key property among - the items in this class, the matching item's id is returned; - otherwise a KeyError is raised. - """ - - def find(self, propname, itemid): - """Get the ids of items in this class which link to the given items. - - 'propspec' consists of keyword args propname={itemid:1,} - 'propname' must be the name of a property in this class, or a - KeyError is raised. That property must be a Link or Multilink - property, or a TypeError is raised. - - Any item in this class whose 'propname' property links to any of the - itemids will be returned. Used by the full text indexing, which - knows that "foo" occurs in msg1, msg3 and file7, so we have hits - on these issues: - - db.issue.find(messages={'1':1,'3':1}, files={'7':1}) - """ - - def filter(self, search_matches, filterspec, sort, group): - ''' Return a list of the ids of the active items in this class that - match the 'filter' spec, sorted by the group spec and then the - sort spec. - ''' - - def list(self): - """Return a list of the ids of the active items in this class.""" - - def count(self): - """Get the number of items in this class. - - If the returned integer is 'numitems', the ids of all the items - in this class run from 1 to numitems, and numitems+1 will be the - id of the next item to be created in this class. - """ - - # Manipulating properties: - - def getprops(self): - """Return a dictionary mapping property names to property objects.""" - - def addprop(self, **properties): - """Add properties to this class. - - The keyword arguments in 'properties' must map names to property - objects, or a TypeError is raised. None of the keys in 'properties' - may collide with the names of existing properties, or a ValueError - is raised before any properties have been added. - """ - - def getitem(self, itemid, cache=1): - ''' Return a Item convenience wrapper for the item. - - 'itemid' must be the id of an existing item of this class or an - IndexError is raised. - - 'cache' indicates whether the transaction cache should be queried - for the item. If the item has been modified and you need to - determine what its values prior to modification are, you need to - set cache=0. - ''' - - class Item: - ''' A convenience wrapper for the given item. It provides a mapping - interface to a single item's properties - ''' - -Hyperdatabase Implementations -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Hyperdatabase implementations exist to create the interface described in the -`hyperdb interface specification`_ -over an existing storage mechanism. Examples are relational databases, -\*dbm key-value databases, and so on. - -Several implementations are provided - they belong in the roundup.backends -package. - - -Application Example -~~~~~~~~~~~~~~~~~~~ - -Here is an example of how the hyperdatabase module would work in practice:: - - >>> import hyperdb - >>> db = hyperdb.Database("foo.db", "ping") - >>> db - <hyperdb.Database "foo.db" opened by "ping"> - >>> hyperdb.Class(db, "status", name=hyperdb.String()) - <hyperdb.Class "status"> - >>> _.setkey("name") - >>> db.status.create(name="unread") - 1 - >>> db.status.create(name="in-progress") - 2 - >>> db.status.create(name="testing") - 3 - >>> db.status.create(name="resolved") - 4 - >>> db.status.count() - 4 - >>> db.status.list() - [1, 2, 3, 4] - >>> db.status.lookup("in-progress") - 2 - >>> db.status.retire(3) - >>> db.status.list() - [1, 2, 4] - >>> hyperdb.Class(db, "issue", title=hyperdb.String(), status=hyperdb.Link("status")) - <hyperdb.Class "issue"> - >>> db.issue.create(title="spam", status=1) - 1 - >>> db.issue.create(title="eggs", status=2) - 2 - >>> db.issue.create(title="ham", status=4) - 3 - >>> db.issue.create(title="arguments", status=2) - 4 - >>> db.issue.create(title="abuse", status=1) - 5 - >>> hyperdb.Class(db, "user", username=hyperdb.Key(), password=hyperdb.String()) - <hyperdb.Class "user"> - >>> db.issue.addprop(fixer=hyperdb.Link("user")) - >>> db.issue.getprops() - {"title": <hyperdb.String>, "status": <hyperdb.Link to "status">, - "user": <hyperdb.Link to "user">} - >>> db.issue.set(5, status=2) - >>> db.issue.get(5, "status") - 2 - >>> db.status.get(2, "name") - "in-progress" - >>> db.issue.get(5, "title") - "abuse" - >>> db.issue.find("status", db.status.lookup("in-progress")) - [2, 4, 5] - >>> db.issue.history(5) - [(<Date 2000-06-28.19:09:43>, "ping", "create", {"title": "abuse", "status": 1}), - (<Date 2000-06-28.19:11:04>, "ping", "set", {"status": 2})] - >>> db.status.history(1) - [(<Date 2000-06-28.19:09:43>, "ping", "link", ("issue", 5, "status")), - (<Date 2000-06-28.19:11:04>, "ping", "unlink", ("issue", 5, "status"))] - >>> db.status.history(2) - [(<Date 2000-06-28.19:11:04>, "ping", "link", ("issue", 5, "status"))] - - -For the purposes of journalling, when a Multilink property is -set to a new list of items, the hyperdatabase compares the old -list to the new list. -The journal records "unlink" events for all the items that appear -in the old list but not the new list, -and "link" events for -all the items that appear in the new list but not in the old list. - - -Roundup Database ----------------- - -The Roundup database layer is implemented on top of the -hyperdatabase and mediates calls to the database. -Some of the classes in the Roundup database are considered -issue classes. -The Roundup database layer adds detectors and user items, -and on issues it provides mail spools, nosy lists, and superseders. - -Reserved Classes -~~~~~~~~~~~~~~~~ - -Internal to this layer we reserve three special classes -of items that are not issues. - -Users -""""" - -Users are stored in the hyperdatabase as items of -class "user". The "user" class has the definition:: - - hyperdb.Class(db, "user", username=hyperdb.String(), - password=hyperdb.String(), - address=hyperdb.String()) - db.user.setkey("username") - -Messages -"""""""" - -E-mail messages are represented by hyperdatabase items of class "msg". -The actual text content of the messages is stored in separate files. -(There's no advantage to be gained by stuffing them into the -hyperdatabase, and if messages are stored in ordinary text files, -they can be grepped from the command line.) The text of a message is -saved in a file named after the message item designator (e.g. "msg23") -for the sake of the command interface (see below). Attachments are -stored separately and associated with "file" items. -The "msg" class has the definition:: - - hyperdb.Class(db, "msg", author=hyperdb.Link("user"), - recipients=hyperdb.Multilink("user"), - date=hyperdb.Date(), - summary=hyperdb.String(), - files=hyperdb.Multilink("file")) - -The "author" property indicates the author of the message -(a "user" item must exist in the hyperdatabase for any messages -that are stored in the system). -The "summary" property contains a summary of the message for display -in a message index. - -Files -""""" - -Submitted files are represented by hyperdatabase -items of class "file". Like e-mail messages, the file content -is stored in files outside the database, -named after the file item designator (e.g. "file17"). -The "file" class has the definition:: - - hyperdb.Class(db, "file", user=hyperdb.Link("user"), - name=hyperdb.String(), - type=hyperdb.String()) - -The "user" property indicates the user who submitted the -file, the "name" property holds the original name of the file, -and the "type" property holds the MIME type of the file as received. - -Issue Classes -~~~~~~~~~~~~~ - -All issues have the following standard properties: - -=========== ========================== -Property Definition -=========== ========================== -title hyperdb.String() -messages hyperdb.Multilink("msg") -files hyperdb.Multilink("file") -nosy hyperdb.Multilink("user") -superseder hyperdb.Multilink("issue") -=========== ========================== - -Also, two Date properties named "creation" and "activity" are -fabricated by the Roundup database layer. By "fabricated" we -mean that no such properties are actually stored in the -hyperdatabase, but when properties on issues are requested, the -"creation" and "activity" properties are made available. -The value of the "creation" property is the date when an issue was -created, and the value of the "activity" property is the -date when any property on the issue was last edited (equivalently, -these are the dates on the first and last records in the issue's journal). - -Roundupdb Interface Specification -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The interface to a Roundup database delegates most method -calls to the hyperdatabase, except for the following -changes and additional methods:: - - class Database: - def getuid(self): - """Return the id of the "user" item associated with the user - that owns this connection to the hyperdatabase.""" - - class Class: - # Overridden methods: - - def create(self, **propvalues): - def set(self, **propvalues): - def retire(self, itemid): - """These operations trigger detectors and can be vetoed. Attempts - to modify the "creation" or "activity" properties cause a KeyError. - """ - - # New methods: - - def audit(self, event, detector): - def react(self, event, detector): - """Register a detector (see below for more details).""" - - class IssueClass(Class): - # Overridden methods: - - def __init__(self, db, classname, **properties): - """The newly-created class automatically includes the "messages", - "files", "nosy", and "superseder" properties. If the 'properties' - dictionary attempts to specify any of these properties or a - "creation" or "activity" property, a ValueError is raised.""" - - def get(self, itemid, propname): - def getprops(self): - """In addition to the actual properties on the item, these - methods provide the "creation" and "activity" properties.""" - - # New methods: - - def addmessage(self, itemid, summary, text): - """Add a message to an issue's mail spool. - - A new "msg" item is constructed using the current date, the - user that owns the database connection as the author, and - the specified summary text. The "files" and "recipients" - fields are left empty. The given text is saved as the body - of the message and the item is appended to the "messages" - field of the specified issue. - """ - - def sendmessage(self, itemid, msgid): - """Send a message to the members of an issue's nosy list. - - The message is sent only to users on the nosy list who are not - already on the "recipients" list for the message. These users - are then added to the message's "recipients" list. - """ - - -Default Schema -~~~~~~~~~~~~~~ - -The default schema included with Roundup turns it into a -typical software bug tracker. The database is set up like this:: - - pri = Class(db, "priority", name=hyperdb.String(), order=hyperdb.String()) - pri.setkey("name") - pri.create(name="critical", order="1") - pri.create(name="urgent", order="2") - pri.create(name="bug", order="3") - pri.create(name="feature", order="4") - pri.create(name="wish", order="5") - - stat = Class(db, "status", name=hyperdb.String(), order=hyperdb.String()) - stat.setkey("name") - stat.create(name="unread", order="1") - stat.create(name="deferred", order="2") - stat.create(name="chatting", order="3") - stat.create(name="need-eg", order="4") - stat.create(name="in-progress", order="5") - stat.create(name="testing", order="6") - stat.create(name="done-cbb", order="7") - stat.create(name="resolved", order="8") - - Class(db, "keyword", name=hyperdb.String()) - - Class(db, "issue", fixer=hyperdb.Multilink("user"), - topic=hyperdb.Multilink("keyword"), - priority=hyperdb.Link("priority"), - status=hyperdb.Link("status")) - -(The "order" property hasn't been explained yet. It -gets used by the Web user interface for sorting.) - -The above isn't as pretty-looking as the schema specification -in the first-stage submission, but it could be made just as easy -with the addition of a convenience function like Choice -for setting up the "priority" and "status" classes:: - - def Choice(name, *options): - cl = Class(db, name, name=hyperdb.String(), order=hyperdb.String()) - for i in range(len(options)): - cl.create(name=option[i], order=i) - return hyperdb.Link(name) - - -Detector Interface ------------------- - -Detectors are Python functions that are triggered on certain -kinds of events. The definitions of the -functions live in Python modules placed in a directory set aside -for this purpose. Importing the Roundup database module also -imports all the modules in this directory, and the ``init()`` -function of each module is called when a database is opened to -provide it a chance to register its detectors. - -There are two kinds of detectors: - -1. an auditor is triggered just before modifying an item -2. a reactor is triggered just after an item has been modified - -When the Roundup database is about to perform a -``create()``, ``set()``, or ``retire()`` -operation, it first calls any *auditors* that -have been registered for that operation on that class. -Any auditor may raise a *Reject* exception -to abort the operation. - -If none of the auditors raises an exception, the database -proceeds to carry out the operation. After it's done, it -then calls all of the *reactors* that have been registered -for the operation. - -Detector Interface Specification -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The ``audit()`` and ``react()`` methods -register detectors on a given class of items:: - - class Class: - def audit(self, event, detector): - """Register an auditor on this class. - - 'event' should be one of "create", "set", or "retire". - 'detector' should be a function accepting four arguments. - """ - - def react(self, event, detector): - """Register a reactor on this class. - - 'event' should be one of "create", "set", or "retire". - 'detector' should be a function accepting four arguments. - """ - -Auditors are called with the arguments:: - - audit(db, cl, itemid, newdata) - -where ``db`` is the database, ``cl`` is an -instance of Class or IssueClass within the database, and ``newdata`` -is a dictionary mapping property names to values. - -For a ``create()`` -operation, the ``itemid`` argument is None and newdata -contains all of the initial property values with which the item -is about to be created. - -For a ``set()`` operation, newdata -contains only the names and values of properties that are about -to be changed. - -For a ``retire()`` operation, newdata is None. - -Reactors are called with the arguments:: - - react(db, cl, itemid, olddata) - -where ``db`` is the database, ``cl`` is an -instance of Class or IssueClass within the database, and ``olddata`` -is a dictionary mapping property names to values. - -For a ``create()`` -operation, the ``itemid`` argument is the id of the -newly-created item and ``olddata`` is None. - -For a ``set()`` operation, ``olddata`` -contains the names and previous values of properties that were changed. - -For a ``retire()`` operation, ``itemid`` is the -id of the retired item and ``olddata`` is None. - -Detector Example -~~~~~~~~~~~~~~~~ - -Here is an example of detectors written for a hypothetical -project-management application, where users can signal approval -of a project by adding themselves to an "approvals" list, and -a project proceeds when it has three approvals:: - - # Permit users only to add themselves to the "approvals" list. - - def check_approvals(db, cl, id, newdata): - if newdata.has_key("approvals"): - if cl.get(id, "status") == db.status.lookup("approved"): - raise Reject, "You can't modify the approvals list " \ - "for a project that has already been approved." - old = cl.get(id, "approvals") - new = newdata["approvals"] - for uid in old: - if uid not in new and uid != db.getuid(): - raise Reject, "You can't remove other users from the " - "approvals list; you can only remove yourself." - for uid in new: - if uid not in old and uid != db.getuid(): - raise Reject, "You can't add other users to the approvals " - "list; you can only add yourself." - - # When three people have approved a project, change its - # status from "pending" to "approved". - - def approve_project(db, cl, id, olddata): - if olddata.has_key("approvals") and len(cl.get(id, "approvals")) == 3: - if cl.get(id, "status") == db.status.lookup("pending"): - cl.set(id, status=db.status.lookup("approved")) - - def init(db): - db.project.audit("set", check_approval) - db.project.react("set", approve_project) - -Here is another example of a detector that can allow or prevent -the creation of new items. In this scenario, patches for a software -project are submitted by sending in e-mail with an attached file, -and we want to ensure that there are text/plain attachments on -the message. The maintainer of the package can then apply the -patch by setting its status to "applied":: - - # Only accept attempts to create new patches that come with patch files. - - def check_new_patch(db, cl, id, newdata): - if not newdata["files"]: - raise Reject, "You can't submit a new patch without " \ - "attaching a patch file." - for fileid in newdata["files"]: - if db.file.get(fileid, "type") != "text/plain": - raise Reject, "Submitted patch files must be text/plain." - - # When the status is changed from "approved" to "applied", apply the patch. - - def apply_patch(db, cl, id, olddata): - if cl.get(id, "status") == db.status.lookup("applied") and \ - olddata["status"] == db.status.lookup("approved"): - # ...apply the patch... - - def init(db): - db.patch.audit("create", check_new_patch) - db.patch.react("set", apply_patch) - - -Command Interface ------------------ - -The command interface is a very simple and minimal interface, -intended only for quick searches and checks from the shell prompt. -(Anything more interesting can simply be written in Python using -the Roundup database module.) - -Command Interface Specification -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -A single command, roundup, provides basic access to -the hyperdatabase from the command line:: - - roundup-admin help - roundup-admin get [-list] designator[, designator,...] propname - roundup-admin set designator[, designator,...] propname=value ... - roundup-admin find [-list] classname propname=value ... - -See ``roundup-admin help commands`` for a complete list of commands. - -Property values are represented as strings in command arguments -and in the printed results: - -- Strings are, well, strings. - -- Numbers are displayed the same as strings. - -- Booleans are displayed as 'Yes' or 'No'. - -- Date values are printed in the full date format in the local - time zone, and accepted in the full format or any of the partial - formats explained above. - -- Link values are printed as item designators. When given as - an argument, item designators and key strings are both accepted. - -- Multilink values are printed as lists of item designators - joined by commas. When given as an argument, item designators - and key strings are both accepted; an empty string, a single item, - or a list of items joined by commas is accepted. - -When multiple items are specified to the -roundup get or roundup set -commands, the specified properties are retrieved or set -on all the listed items. - -When multiple results are returned by the roundup get -or roundup find commands, they are printed one per -line (default) or joined by commas (with the -list) option. - -Usage Example -~~~~~~~~~~~~~ - -To find all messages regarding in-progress issues that -contain the word "spam", for example, you could execute the -following command from the directory where the database -dumps its files:: - - shell% for issue in `roundup find issue status=in-progress`; do - > grep -l spam `roundup get $issue messages` - > done - msg23 - msg49 - msg50 - msg61 - shell% - -Or, using the -list option, this can be written as a single command:: - - shell% grep -l spam `roundup get \ - \`roundup find -list issue status=in-progress\` messages` - msg23 - msg49 - msg50 - msg61 - shell% - - -E-mail User Interface ---------------------- - -The Roundup system must be assigned an e-mail address -at which to receive mail. Messages should be piped to -the Roundup mail-handling script by the mail delivery -system (e.g. using an alias beginning with "|" for sendmail). - -Message Processing -~~~~~~~~~~~~~~~~~~ - -Incoming messages are examined for multiple parts. -In a multipart/mixed message or part, each subpart is -extracted and examined. In a multipart/alternative -message or part, we look for a text/plain subpart and -ignore the other parts. The text/plain subparts are -assembled to form the textual body of the message, to -be stored in the file associated with a "msg" class item. -Any parts of other types are each stored in separate -files and given "file" class items that are linked to -the "msg" item. - -The "summary" property on message items is taken from -the first non-quoting section in the message body. -The message body is divided into sections by blank lines. -Sections where the second and all subsequent lines begin -with a ">" or "|" character are considered "quoting -sections". The first line of the first non-quoting -section becomes the summary of the message. - -All of the addresses in the To: and Cc: headers of the -incoming message are looked up among the user items, and -the corresponding users are placed in the "recipients" -property on the new "msg" item. The address in the From: -header similarly determines the "author" property of the -new "msg" item. -The default handling for -addresses that don't have corresponding users is to create -new users with no passwords and a username equal to the -address. (The web interface does not permit logins for -users with no passwords.) If we prefer to reject mail from -outside sources, we can simply register an auditor on the -"user" class that prevents the creation of user items with -no passwords. - -The subject line of the incoming message is examined to -determine whether the message is an attempt to create a new -issue or to discuss an existing issue. A designator enclosed -in square brackets is sought as the first thing on the -subject line (after skipping any "Fwd:" or "Re:" prefixes). - -If an issue designator (class name and id number) is found -there, the newly created "msg" item is added to the "messages" -property for that issue, and any new "file" items are added to -the "files" property for the issue. - -If just an issue class name is found there, we attempt to -create a new issue of that class with its "messages" property -initialized to contain the new "msg" item and its "files" -property initialized to contain any new "file" items. - -Both cases may trigger detectors (in the first case we -are calling the set() method to add the message to the -issue's spool; in the second case we are calling the -create() method to create a new item). If an auditor -raises an exception, the original message is bounced back to -the sender with the explanatory message given in the exception. - -Nosy Lists -~~~~~~~~~~ - -A standard detector is provided that watches for additions -to the "messages" property. When a new message is added, the -detector sends it to all the users on the "nosy" list for the -issue that are not already on the "recipients" list of the -message. Those users are then appended to the "recipients" -property on the message, so multiple copies of a message -are never sent to the same user. The journal recorded by -the hyperdatabase on the "recipients" property then provides -a log of when the message was sent to whom. - -Setting Properties -~~~~~~~~~~~~~~~~~~ - -The e-mail interface also provides a simple way to set -properties on issues. At the end of the subject line, -``propname=value`` pairs can be -specified in square brackets, using the same conventions -as for the roundup ``set`` shell command. - - -Web User Interface ------------------- - -The web interface is provided by a CGI script that can be -run under any web server. A simple web server can easily be -built on the standard CGIHTTPServer module, and -should also be included in the distribution for quick -out-of-the-box deployment. - -The user interface is constructed from a number of template -files containing mostly HTML. Among the HTML tags in templates -are interspersed some nonstandard tags, which we use as -placeholders to be replaced by properties and their values. - -Views and View Specifiers -~~~~~~~~~~~~~~~~~~~~~~~~~ - -There are two main kinds of views: *index* views and *issue* views. -An index view displays a list of issues of a particular class, -optionally sorted and filtered as requested. An issue view -presents the properties of a particular issue for editing -and displays the message spool for the issue. - -A view specifier is a string that specifies -all the options needed to construct a particular view. -It goes after the URL to the Roundup CGI script or the -web server to form the complete URL to a view. When the -result of selecting a link or submitting a form takes -the user to a new view, the Web browser should be redirected -to a canonical location containing a complete view specifier -so that the view can be bookmarked. - -Displaying Properties -~~~~~~~~~~~~~~~~~~~~~ - -Properties appear in the user interface in three contexts: -in indices, in editors, and as filters. For each type of -property, there are several display possibilities. For example, -in an index view, a string property may just be printed as -a plain string, but in an editor view, that property should -be displayed in an editable field. - -The display of a property is handled by functions in -a displayers module. Each function accepts at -least three standard arguments -- the database, class name, -and item id -- and returns a chunk of HTML. - -Displayer functions are triggered by <display> -tags in templates. The call attribute of the tag -provides a Python expression for calling the displayer -function. The three standard arguments are inserted in -front of the arguments given. For example, the occurrence of:: - - <display call="plain('status', max=30)"> - -in a template triggers a call to:: - - plain(db, "issue", 13, "status", max=30) - - -when displaying issue 13 in the "issue" class. The displayer -functions can accept extra arguments to further specify -details about the widgets that should be generated. By defining new -displayer functions, the user interface can be highly customized. - -Some of the standard displayer functions include: - -========= ==================================================================== -Function Description -========= ==================================================================== -plain display a String property directly; - display a Date property in a specified time zone with an option - to omit the time from the date stamp; for a Link or Multilink - property, display the key strings of the linked items (or the - ids if the linked class has no key property) -field display a property like the - plain displayer above, but in a text field - to be edited -menu for a Link property, display - a menu of the available choices -link for a Link or Multilink property, - display the names of the linked items, hyperlinked to the - issue views on those items -count for a Multilink property, display - a count of the number of links in the list -reldate display a Date property in terms - of an interval relative to the current date (e.g. "+ 3w", "- 2d"). -download show a Link("file") or Multilink("file") - property using links that allow you to download files -checklist for a Link or Multilink property, - display checkboxes for the available choices to permit filtering -========= ==================================================================== - -TODO: See the htmltemplate pydoc for a complete list of the functions - - -Index Views -~~~~~~~~~~~ - -An index view contains two sections: a filter section -and an index section. -The filter section provides some widgets for selecting -which issues appear in the index. The index section is -a table of issues. - -Index View Specifiers -""""""""""""""""""""" - -An index view specifier looks like this (whitespace -has been added for clarity):: - - /issue?status=unread,in-progress,resolved& - topic=security,ui& - :group=priority& - :sort=-activity& - :filters=status,topic& - :columns=title,status,fixer - - -The index view is determined by two parts of the -specifier: the layout part and the filter part. -The layout part consists of the query parameters that -begin with colons, and it determines the way that the -properties of selected items are displayed. -The filter part consists of all the other query parameters, -and it determines the criteria by which items -are selected for display. - -The filter part is interactively manipulated with -the form widgets displayed in the filter section. The -layout part is interactively manipulated by clicking -on the column headings in the table. - -The filter part selects the union of the -sets of issues with values matching any specified Link -properties and the intersection of the sets -of issues with values matching any specified Multilink -properties. - -The example specifies an index of "issue" items. -Only issues with a "status" of either -"unread" or "in-progres" or "resolved" are displayed, -and only issues with "topic" values including both -"security" and "ui" are displayed. The issues -are grouped by priority, arranged in ascending order; -and within groups, sorted by activity, arranged in -descending order. The filter section shows filters -for the "status" and "topic" properties, and the -table includes columns for the "title", "status", and -"fixer" properties. - -Associated with each issue class is a default -layout specifier. The layout specifier in the above -example is the default layout to be provided with -the default bug-tracker schema described above in -section 4.4. - -Filter Section -"""""""""""""" - -The template for a filter section provides the -filtering widgets at the top of the index view. -Fragments enclosed in ``<property>...</property>`` -tags are included or omitted depending on whether the -view specifier requests a filter for a particular property. - -Here's a simple example of a filter template:: - - <property name=status> - <display call="checklist('status')"> - </property> - <br> - <property name=priority> - <display call="checklist('priority')"> - </property> - <br> - <property name=fixer> - <display call="menu('fixer')"> - </property> - -Index Section -""""""""""""" - -The template for an index section describes one row of -the index table. -Fragments enclosed in ``<property>...</property>`` -tags are included or omitted depending on whether the -view specifier requests a column for a particular property. -The table cells should contain <display> tags -to display the values of the issue's properties. - -Here's a simple example of an index template:: - - <tr> - <property name=title> - <td><display call="plain('title', max=50)"></td> - </property> - <property name=status> - <td><display call="plain('status')"></td> - </property> - <property name=fixer> - <td><display call="plain('fixer')"></td> - </property> - </tr> - -Sorting -""""""" - -String and Date values are sorted in the natural way. -Link properties are sorted according to the value of the -"order" property on the linked items if it is present; or -otherwise on the key string of the linked items; or -finally on the item ids. Multilink properties are -sorted according to how many links are present. - -Issue Views -~~~~~~~~~~~ - -An issue view contains an editor section and a spool section. -At the top of an issue view, links to superseding and superseded -issues are always displayed. - -Issue View Specifiers -""""""""""""""""""""" - -An issue view specifier is simply the issue's designator:: - - /patch23 - - -Editor Section -"""""""""""""" - -The editor section is generated from a template -containing <display> tags to insert -the appropriate widgets for editing properties. - -Here's an example of a basic editor template:: - - <table> - <tr> - <td colspan=2> - <display call="field('title', size=60)"> - </td> - </tr> - <tr> - <td> - <display call="field('fixer', size=30)"> - </td> - <td> - <display call="menu('status')> - </td> - </tr> - <tr> - <td> - <display call="field('nosy', size=30)"> - </td> - <td> - <display call="menu('priority')> - </td> - </tr> - <tr> - <td colspan=2> - <display call="note()"> - </td> - </tr> - </table> - -As shown in the example, the editor template can also -request the display of a "note" field, which is a -text area for entering a note to go along with a change. - -When a change is submitted, the system automatically -generates a message describing the changed properties. -The message displays all of the property values on the -issue and indicates which ones have changed. -An example of such a message might be this:: - - title: Polly Parrot is dead - priority: critical - status: unread -> in-progress - fixer: (none) - keywords: parrot,plumage,perch,nailed,dead - -If a note is given in the "note" field, the note is -appended to the description. The message is then added -to the issue's message spool (thus triggering the standard -detector to react by sending out this message to the nosy list). - -Spool Section -""""""""""""" - -The spool section lists messages in the issue's "messages" -property. The index of messages displays the "date", "author", -and "summary" properties on the message items, and selecting a -message takes you to its content. - -Access Control --------------- - -At each point that requires an action to be performed, the security mechanisms -are asked if the current user has permission. This permission is defined as a -Permission. - -Individual assignment of Permission to user is unwieldy. The concept of a -Role, which encompasses several Permissions and may be assigned to many Users, -is quite well developed in many projects. Roundup will take this path, and -allow the multiple assignment of Roles to Users, and multiple Permissions to -Roles. These definitions are not persistent - they're defined when the -application initialises. - -There will be two levels of Permission. The Class level permissions define -logical permissions associated with all items of a particular class (or all -classes). The Item level permissions define logical permissions associated -with specific items by way of their user-linked properties. - - -Access Control Interface Specification -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The security module defines:: - - class Permission: - ''' Defines a Permission with the attributes - - name - - description - - klass (optional) - - The klass may be unset, indicating that this permission is not - locked to a particular hyperdb class. There may be multiple - Permissions for the same name for different classes. - ''' - - class Role: - ''' Defines a Role with the attributes - - name - - description - - permissions - ''' - - class Security: - def __init__(self, db): - ''' Initialise the permission and role stores, and add in the - base roles (for admin user). - ''' - - def getPermission(self, permission, classname=None): - ''' Find the Permission matching the name and for the class, if the - classname is specified. - - Raise ValueError if there is no exact match. - ''' - - def hasPermission(self, permission, userid, classname=None): - ''' Look through all the Roles, and hence Permissions, and see if - "permission" is there for the specified classname. - ''' - - def hasItemPermission(self, classname, itemid, **propspec): - ''' Check the named properties of the given item to see if the - userid appears in them. If it does, then the user is granted - this permission check. - - 'propspec' consists of a set of properties and values that - must be present on the given item for access to be granted. - - If a property is a Link, the value must match the property - value. If a property is a Multilink, the value must appear - in the Multilink list. - ''' - - def addPermission(self, **propspec): - ''' Create a new Permission with the properties defined in - 'propspec' - ''' - - def addRole(self, **propspec): - ''' Create a new Role with the properties defined in 'propspec' - ''' - - def addPermissionToRole(self, rolename, permission): - ''' Add the permission to the role's permission list. - - 'rolename' is the name of the role to add permission to. - ''' - -Modules such as ``cgi/client.py`` and ``mailgw.py`` define their own -permissions like so (this example is ``cgi/client.py``):: - - def initialiseSecurity(security): - ''' Create some Permissions and Roles on the security object - - This function is directly invoked by security.Security.__init__() - as a part of the Security object instantiation. - ''' - p = security.addPermission(name="Web Registration", - description="Anonymous users may register through the web") - security.addToRole('Anonymous', p) - -Detectors may also define roles in their init() function:: - - def init(db): - # register an auditor that checks that a user has the "May Resolve" - # Permission before allowing them to set an issue status to "resolved" - db.issue.audit('set', checkresolvedok) - p = db.security.addPermission(name="May Resolve", klass="issue") - security.addToRole('Manager', p) - -The tracker dbinit module then has in ``open()``:: - - # open the database - it must be modified to init the Security class - # from security.py as db.security - db = Database(config, name) - - # add some extra permissions and associate them with roles - ei = db.security.addPermission(name="Edit", klass="issue", - description="User is allowed to edit issues") - db.security.addPermissionToRole('User', ei) - ai = db.security.addPermission(name="View", klass="issue", - description="User is allowed to access issues") - db.security.addPermissionToRole('User', ai) - -In the dbinit ``init()``:: - - # create the two default users - user.create(username="admin", password=Password(adminpw), - address=config.ADMIN_EMAIL, roles='Admin') - user.create(username="anonymous", roles='Anonymous') - -Then in the code that matters, calls to ``hasPermission`` and -``hasItemPermission`` are made to determine if the user has permission -to perform some action:: - - if db.security.hasPermission('issue', 'Edit', userid): - # all ok - - if db.security.hasItemPermission('issue', itemid, assignedto=userid): - # all ok - -Code in the core will make use of these methods, as should code in auditors in -custom templates. The htmltemplate will implement a new tag, ``<require>`` -which has the form:: - - <require permission="name,name,name" assignedto="$userid" status="open"> - HTML to display if the user has the permission. - <else> - HTML to display if the user does not have the permission. - </require> - -where: - -- the permission attribute gives a comma-separated list of permission names. - These are checked in turn using ``hasPermission`` and requires one to - be OK. -- the other attributes are lookups on the item using ``hasItemPermission``. If - the attribute value is "$userid" then the current user's userid is tested. - -Any of these tests must pass or the ``<require>`` check will fail. The section -of html within the side of the ``<else>`` that fails is remove from processing. - -Authentication of Users -~~~~~~~~~~~~~~~~~~~~~~~ - -Users must be authenticated correctly for the above controls to work. This is -not done in the current mail gateway at all. Use of digital signing of -messages could alleviate this problem. - -The exact mechanism of registering the digital signature should be flexible, -with perhaps a level of trust. Users who supply their signature through their -first message into the tracker should be at a lower level of trust to those -who supply their signature to an admin for submission to their user details. - - -Anonymous Users -~~~~~~~~~~~~~~~ - -The "anonymous" user must always exist, and defines the access permissions for -anonymous users. Unknown users accessing Roundup through the web or email -interfaces will be logged in as the "anonymous" user. - - -Use Cases -~~~~~~~~~ - -public - end users can submit bugs, request new features, request support - The Users would be given the default "User" Role which gives "View" and - "Edit" Permission to the "issue" class. -developer - developers can fix bugs, implement new features, provide support - A new Role "Developer" is created with the Permission "Fixer" which is - checked for in custom auditors that see whether the issue is being - resolved with a particular resolution ("fixed", "implemented", - "supported") and allows that resolution only if the permission is - available. -manager - approvers/managers can approve new features and signoff bug fixes - A new Role "Manager" is created with the Permission "Signoff" which is - checked for in custom auditors that see whether the issue status is being - changed similar to the developer example. -admin - administrators can add users and set user's roles - The existing Role "Admin" has the Permissions "Edit" for all classes - (including "user") and "Web Roles" which allow the desired actions. -system - automated request handlers running various report/escalation scripts - A combination of existing and new Roles, Permissions and auditors could - be used here. -privacy - issues that are only visible to some users - A new property is added to the issue which marks the user or group of - users who are allowed to view and edit the issue. An auditor will check - for edit access, and the htmltemplate <require> tag can check for view - access. - - -Deployment Scenarios --------------------- - -The design described above should be general enough -to permit the use of Roundup for bug tracking, managing -projects, managing patches, or holding discussions. By -using items of multiple types, one could deploy a system -that maintains requirement specifications, catalogs bugs, -and manages submitted patches, where patches could be -linked to the bugs and requirements they address. - - -Acknowledgements ----------------- - -My thanks are due to Christy Heyl for -reviewing and contributing suggestions to this paper -and motivating me to get it done, and to -Jesse Vincent, Mark Miller, Christopher Simons, -Jeff Dunmall, Wayne Gramlich, and Dean Tribble for -their assistance with the first-round submission. - -Changes to this document ------------------------- - -- Added Boolean and Number types -- Added section Hyperdatabase Implementations -- "Item" has been renamed to "Issue" to account for the more specific nature - of the Class. - -
--- a/doc/developers.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,74 +0,0 @@ -================== -Developing Roundup -================== - -:Version: $Revision: 1.4 $ - -Note: the intended audience of this document is the developers of the core -Roundup code. If you just wish to alter some behaviour of your Roundup -installation, see `customising roundup`_. - -.. contents:: - -Getting Started ---------------- - -Anyone wishing to help in the development of Roundup must read `Roundup's -Design Document`_ and the `implementation notes`_. - -All development is coordinated through two resources: - -- roundup-dev mailing list at - http://lists.sourceforge.net/mailman/listinfo/roundup-devel -- Sourceforge's issue trackers at - https://sourceforge.net/tracker/?group_id=31577 - -Small Changes -------------- - -Most small changes can be submitted through the Feature tracker, with patches -attached that give context diffs of the affected source. - - -CVS Access ----------- - -To get CVS access, contact richard@users.sourceforge.net. - - -Project Rules -------------- - -Mostly the project follows Guido's Style (though naming tends to be a little -relaxed sometimes). In short: - -- 80 column width code -- 4-space indentations -- All modules must have a CVS Id line near the top, and a CVS Log at the end - -Other project rules: - -- New functionality must be documented, even briefly (so at least we know - where there's missing documentation) and changes to tracker configuration - must be logged in the upgrading document. -- subscribe to roundup-checkins to receive checkin notifications from the - other developers with CVS access -- discuss any changes with the other developers on roundup-dev. If nothing - else, this makes sure there's no rude shocks -- write unit tests for changes you make (where possible), and ensure that - all unit tests run before committing changes -- run pychecker over changed code - -The administrators of the project reserve the right to boot developers who -consistently check in code which is either broken or takes the codebase in -directions that have not been agreed to. - - ------------------ - -Back to `Table of Contents`_ - -.. _`Table of Contents`: index.html -.. _`Customising Roundup`: customizing.html -.. _`Roundup's Design Document`: spec.html -.. _`implementation notes`: implementation.html
--- a/doc/getting_started.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,279 +0,0 @@ -=============== -Getting Started -=============== - -:Version: $Revision: 1.7 $ - -.. contents:: - - -The following instructions assume that you have installed roundup. If you -haven't, you may still proceed - just run the commands as -"``PYTHONPATH=. python roundup/scripts/roundup_admin.py``" for -``roundup-admin`` and -"``PYTHONPATH=. python roundup/scripts/roundup_server.py``" for -``roundup-server``. - -The Tracker ------------ - -We'll be referring to the term tracker a lot from now on. A tracker is a -directory in your filesystem that is where all the information about a live -issue -tracker database is stored. The data that is entered as issues, the users who -access the database and the definition of the database itself all reside there: - -Hyperdatabase - This is the lowest component of Roundup and is where all the issues, - users, file attachments and messages are stored. - -Database schema - This describes the content of the hyperdatabase - what fields are stored - for issues, what user information, etc. Being stored in the tracker, - this allows it to be customised for a particular application. It also - means that changes in the Roundup core code do not affect a running - tracker. - -Web Interface - The web interface templates are defined in the tracker too - and the - actual CGI interface class is defined (mostly using base classes in the - Roundup core code) so it, like the database, may be customised for each - tracker in use. - -Trackers are created using the ``roundup-admin`` tool. - -Command Line Tool ------------------ - -To set up a new tracker, run "``roundup-admin install``". You will be -asked a few questions: - -1. Tracker home directory -2. Schema to use -3. Database back-end to use - -Once you've chosen these, roundup will install the tracker for you. It will -then indicate that you should configure some more information in the -file "``config.py``" in the tracker home. It -should be edited before roundup is initialised, and may have the following -variable declarations: - -MAILHOST - The SMTP mail host that roundup will use to send mail -MAIL_DOMAIN - The domain name used for email addresses -TRACKER_WEB - The web address of the issue tracker's web interface - -The email addresses used by the system by default are: - -TRACKER_EMAIL: ``issue_tracker@MAIL_DOMAIN`` - submissions of issues - -ADMIN_EMAIL: ``roundup-admin@MAIL_DOMAIN`` - roundup's internal use (problems, etc) - -You may also alter the default schema - see the `customisation`_ documentation -for more info on both configuration variables and schema modifications. - -Once you're happy (and note that you can change any of this after the tracker -is initialised too!) you must run "``roundup-admin initialise``". - -You should also think about whether there is going to be controlled access -to the -tracker on the machine the tracker is running on. That is, who can -actually make -changes to the database using the roundup-admin tool. See the section on -Users_and_Access_Control for information on how to secure your tracker from the -start. - -E-Mail Interface ----------------- - -Setup 1: As a mail alias pipe process -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Set up a mail alias called "issue_tracker" as (include the quote marks): -"``|/usr/bin/python /usr/local/bin/roundup-mailgw <tracker_home>``" - -In some installations (e.g. RedHat 6.2 I think) you'll need to set up smrsh so -sendmail will accept the pipe command. In that case, symlink -``/etc/smrsh/roundup-mailgw`` to "``/usr/local/bin/roundup-mailgw``" and change -the command to:: - - |roundup-mailgw <tracker_home> - -To test the mail gateway on unix systems, try:: - - echo test |mail -s '[issue] test' issue_tracker@your.domain - - -Setup 2: As a regular cron job using a mailbox source -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Set ``roundup-mailgw`` up to run every 10 minutes or so. For example:: - - 10 * * * * /usr/local/bin/roundup-mailgw <tracker_home> mailbox <mail_spool_file> - -Where the ``mail_spool_file`` argument is the location of the roundup submission -user's mail spool. On most systems, the spool for a user "issue_tracker" -will be "``/var/mail/issue_tracker``". - -Setup 3: As a regular cron job using a POP source -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To retrieve from a POP mailbox, use a similar cron entry to the mailbox one:: - - 10 * * * * /usr/local/bin/roundup-mailgw <tracker_home> pop <pop_spec> - -where pop_spec is "``username:password@server``" that specifies the roundup -submission user's POP account name, password and server. - - -Web Interface -------------- - -This software will work through apache or stand-alone. - -Stand-alone: - 1. Edit roundup-server at the top - ``TRACKER_HOMES`` needs to know - about your tracker. You may also specify the values for - ``TRACKER_HOMES`` on the command-line using "name=home" pairs. - - 2. "``roundup-server [-p port] (name=tracker_home)*``" (hostname may be "") - - 3. Load up the page "``/<tracker name>/index``" where tracker name is the name - you nominated in ``TRACKER_HOMES``. - -Apache: - 1. The CGI script is found in the cgi-bin directory of the roundup - distribution. - - 2. Make sure roundup.cgi is executable. Edit it at the top - - ``TRACKER_HOMES`` needs to know about your tracker. - - 3. Edit your "``/etc/httpd/conf/httpd.conf``" and make sure that the - "``/home/httpd/html/roundup/roundup.cgi``" script will be treated as a CGI script. - - 4. Re-start your apache to re-load the config if necessary. - - 5. Load up the page "``/roundup/roundup.cgi/index/``" where tracker name is the - name you nominated in ``TRACKER_HOMES``. - - 6. To use the CGI script unchanged, which allows much easier updates, add - these directives to your "httpd.conf":: - - SetEnv ROUNDUP_LOG "/var/log/roundup.log" - SetEnv TRACKER_HOMES "Default=/usr/local/share/roundup/trackers/Default" - SetEnv ROUNDUP_DEBUG "0" - - 7. On Windows, write a batch file "roundup.bat" similar to the one below and - place it into your cgi-bin directory:: - - @echo off - set ROUNDUP_LOG=c:\Python21\share\roundup\cgi.log - set TRACKER_HOMES=Default=c:\Python21\share\roundup\trackers\Default; - set ROUNDUP_DEBUG=0 - c:\Python21\python.exe c:\Python21\share\roundup\cgi-bin\roundup.cgi - - -Users ------ - -Users and permissions -~~~~~~~~~~~~~~~~~~~~~ - -By default, roundup automatically creates one user when the tracker database is -initialised (using roundup-admin init). The user is "admin" and the password is -the one you supply at that time. - -If users attempt to use roundup in any manner and are not identified to roundup, -they will be using the database in a read-only mode. That is, if roundup doesn't -know who they are, they can't change anything. This has the following -repurcussions: - -Command-line interface - The data modification commands (create, init, retire, set) are performed - as the "admin" user. It is therefore important that the database be - protected by the filesystem if protection is required. On a Unix system, - the easiest and most flexible method of doing so is: - - 1. Add a new user and group to your system (e.g. "issue_tracker") - - 2. When creating a new tracker home, use the following commands - (substituting tracker_home for the directory you want to use):: - - mkdir tracker_home - chown issue_tracker:issue_tracker tracker_home - chmod g+rwxs tracker_home - roundup-admin -i tracker_home init - - 3. Now, edit the /etc/group line for the issue_tracker group so it - includes the unix logins of all the users who are going to - administer your roundup tracker. If you're running the web or mail - gateways, then be sure to include those users in the group too (on - some Linux systems, these users are "www" or "apache" and "mail".) - -E-Mail interface - Users are identified by e-mail address - a new user entry will be created - for any e-mail address that is not recognised, so users are always - identified by roundup. - -Web interface - Unidentified users have read-only access. If the users database has an - entry with the username "anonymous", then unidentified users are - automatically logged in as that user. This gives them write access. - -**anonymous access and the ANONYMOUS_* configurations.** - - -Adding users -~~~~~~~~~~~~ - -To add users, use one of the following interfaces: - -1. On the web, access the URL .../<tracker name>/newuser to bring up a form - which may be used to add a new user. - -2. On the command-line, use:: - - roundup-admin -i <tracker home> create user username=bozo password=bozo - address=richard@clown.org - - Supply the admin username and password. roundup-admin will print the id - of the new user. - -3. Any e-mail sent to roundup from an address that doesn't match an existing - user in the database will result in a new user entry being created for - that user. - - -Issues ------- - -To add issues, use one of the following interfaces: - -1. On the web, access the URL .../<tracker name>/newissue to bring up a - form which may be used to add a new issue. - -2. On the command-line, use:: - - roundup-admin -i <tracker home> create issue title="test issue" - - Supply the admin username and password. roundup-admin will print the id - of the new issue. - -3. Any e-mail sent to roundup with the subject line containing [issue] will - automatically created a new issue in the database using the contents of - the e-mail. - ------------------ - -Back to `Table of Contents`_ - -Next: `User Guide`_ - -.. _`table of contents`: index.html -.. _`user guide`: user_guide.html -.. _`customisation`: customizing.html -
--- a/doc/index.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,82 +0,0 @@ -======================================================= -Roundup: an Issue-Tracking System for Knowledge Workers -======================================================= - -Contents -======== - -- Overview_ and Features_ -- Installation_ and `Getting Started`_ (and Upgrading_) -- `User Guide`_ -- `Customising Roundup`_ -- `Maintaining Roundup Trackers`_ -- `Roundup's Design`_ (original_) -- `Developing Roundup`_ -- Contact_ -- Acknowledgements_ -- License_ - -.. _Features: features.html -.. _Overview: overview.html -.. _Installation: installation.html -.. _`Getting Started`: getting_started.html -.. _`User Guide`: user_guide.html -.. _`Customising Roundup`: customizing.html -.. _`Maintaining Roundup Trackers`: maintenance.html -.. _`Developing Roundup`: developers.html -.. _`Roundup's Design`: design.html -.. _original: spec.html -.. _Upgrading: upgrading.html - - -Contact -======= - -For general support enquiries about usage, a mailing list is available: - - roundup-users@sourceforge.net - -If you've got a great idea for roundup, or have found a bug, please -submit -an issue to the tracker at: - - http://sourceforge.net/tracker/?group_id=31577 - -For discussions about developing or enhancing roundup: - - roundup-devel@sourceforge.net - -The admin for this project is Richard Jones: - - richard@users.sourceforge.net - -but he should only be contacted directly when none of the -above avenues of contact are suitable. - - -Acknowledgements -================ - -Go Ping, you rock! Also, go Bizar Software and ekit.com for letting me -implement this system on their time. - -Thanks also to the many people on the mailing list and in the sourceforge -project: -Anthony Baxter, -Jeff Blaine, -Duncan Booth, -Titus Brown, -Roch'e Compaan, -Engelbert Gruber, -Juergen Hermann, -Gordon McMillan, -Patrick Ohly, -Dougal Scott, -Stefan Seefeld. - - -License -======= - -See COPYING.txt in the software distribution for the licensing terms. -
--- a/doc/installation.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,408 +0,0 @@ -================== -Installing Roundup -================== - -:Version: $Revision: 1.28 $ - -.. contents:: - - -Overview -======== - -Broken out separately, there are several conceptual pieces to a -Roundup installation: - -Roundup trackers - Trackers consist of issues (be they bug reports or otherwise), tracker - configuration file(s), web HTML files etc. Roundup trackers are initialised - with a "Template" which defines the fields usable/assignable on a - per-issue basis. Descriptions of the provided templates are given in - `choosing your template`_. - -Roundup support code - Installed into your Python install's lib directory - -Roundup scripts - These include the email gateway, the roundup - HTTP server, the roundup administration command-line interface, etc. - - -Prerequisites -============= - -Python 2.1.1 or newer with a functioning anydbm or bsddb module. Download the -latest version from http://www.python.org/. It is highly recommended that -users install the latest patch version of python - 2.1.3 or 2.2.1 - as these -contain many fixes to serious bugs. - -If you want to use Berkeley DB bsddb3 with Roundup, use version 3.3.0 or -later. Download the latest version from http://pybsddb.sourceforge.net/. - - -Getting Roundup -=============== - -Download the latest version from http://roundup.sf.net/. - -Testing your Python -------------------- - -Once you've unpacked roundup's source, run ``python ./run_tests`` in the -source directory and make sure there are no errors. -If there are errors, please let us know! - -If the above fails, you may be using the wrong version of python. Try -``python2 ./run_tests``. If that works, you will need to substitute -``python2`` for ``python`` in all further commands you use in relation to -Roundup -- from installation and scripts. - - -Installation -============ - -Set aside 15-30 minutes. Please make sure you're using a supported version of -Python -- see `testing your python`_. There's four steps to follow in your -installation: - -1. `basic installation steps`_ that all installers must follow -2. then optionally `configure a web interface`_ -3. and optionally `configure an email interface`_ -4. `shared environment steps`_ to take if you're installing on a shared - UNIX machine and want to restrict local access to roundup - -Most users will only need to follow the first step, since the environment will -be a trusted one. - - -Basic Installation Steps ------------------------- - -1. To install the Roundup support code into your Python tree and - Roundup scripts into /usr/local/bin:: - - python setup.py install - - If you would like to place the Roundup scripts in a directory other - than ``/usr/local/bin``, then specify the preferred location with - ``--install-script``. For example, to install them in - ``/opt/roundup/bin``:: - - python setup.py install --install-scripts=/opt/roundup/bin - -2. To create a Roundup tracker (necessary to do before you can - use the software in any real fashion): - - a. (Optional) If you intend to keep your roundup trackers - under one top level directory which does not exist yet, - you should create that directory now. Example:: - - mkdir /opt/roundup/trackers - - b. Either add the Roundup script location to your ``PATH`` - environment variable or specify the full path to - the command in the next step. - - c. Install a new tracker with the command ``roundup-admin install``. - You will be asked a series of questions. Descriptions of the provided - templates can be found in `choosing your template`_ below. Descriptions - of the available backends can be found in `choosing your backend`_ - below. The questions will be something like (you may have more - templates or backends available):: - - Enter tracker home: /opt/roundup/trackers/support - Templates: classic - Select template [classic]: classic - Back ends: anydbm, bsddb - Select backend [anydbm]: anydbm - - You will now be directed to edit the tracker configuration and - initial schema. See `Customising Roundup`_ for details on configuration - and schema changes. Note that you may change any of the configuration - after you've initialised the tracker - it's just better to have valid - values for this stuff now. - - d. Initialise the tracker database with ``roundup-admin initialise``. - You will need to supply an admin password at this step. You will be - prompted:: - - Admin Password: - Confirm: - - Once this is done, the tracker has been created. - -At this point, your tracker is set up, but doesn't have a nice user interface. -To set that up, we need to `configure a web interface`_ and optionally -`configure an email interface`_. - - -Choosing Your Template ----------------------- - -Classic Template -~~~~~~~~~~~~~~~~ - -The classic template is the one defined in the `Roundup Specification`_. It -holds issues which have priorities and statuses. Each issue may also have a -set of messages which are disseminated to the issue's list of nosy users. - -Minimal Template -~~~~~~~~~~~~~~~~ - -The minimal template has the minimum setup required for a tracker -installation. That is, it has the configuration files, defines a user database -and the basic HTML interface to that. It's a completely clean slate for you to -create your tracker on. - - -Choosing Your Backend ---------------------- - -The actual storage of Roundup tracker information is handled by backends. -There's several to choose from, each with benefits and limitations: - -**anydbm** - This backend is guaranteed to work on any system that Python runs on. It - will generally choose the best dbm backend that is available on your system - (from the list dbhash, gdbm, dbm, dumbdbm). It is the least scaleable of all - backends, but performs well enough for a smallish tracker (a couple of - thousand issues, under fifty users, ...). -**bsddb** - This effectively the same as anydbm, but uses the bsddb backend. This allows - it to gain some performance and scaling benefits. -**bsddb3** - Again, this effectively the same as anydbm, but uses the bsddb3 backend. - This allows it to gain some performance and scaling benefits. -**sqlite_** - This uses the SQLite_ embedded RDBMS to provide a fast, scaleable backend. - There are no limitations, and it's much faster and more scaleable than the - dbm backends. -**metakit_** - This backend is implemented over the metakit_ storage system, using Mk4Py as - the interface. It scales much better than the dbm backends. -**gadfly** - This is a proof-of-concept relational database backend, not really intended - for actual production use, although it can be. It uses the Gadfly RDBMS - to store data. It is unable to perform string searches due to gadfly not - having a LIKE operation. It should scale well, assuming a client/server - setup is used. It's much slower than even the dbm backends. - -Note: you may set your tracker up with the anydbm backend (which is guaranteed -to be available) and switch to one of the other backends at any time using the -instructions in the `maintenance documentation`_. - - -Configure a Web Interface -------------------------- - -There are three web interfaces to choose from: - -1. `web server cgi-bin`_ -2. `stand-alone web server`_ -3. `Zope product - ZRoundup`_ - -You may need to give the web server user permission to access the tracker home -- see the `shared environment steps`_ for information. - - -Web Server cgi-bin -~~~~~~~~~~~~~~~~~~ - -A benefit of using the cgi-bin approach is that it's the easiest way to -restrict access to your tracker to only use HTTPS. Access will be slower -than through the `stand-alone web server`_ though. - -Copy the ``cgi-bin/roundup.cgi`` file to your web server's ``cgi-bin`` -directory. You will need to configure it to tell it where your tracker home -is. You can do this either: - -through an environment variable - set the variable TRACKER_HOMES to be a colon (":") separated list of - name=home pairs (if you're using apache, the SetEnv directive can do this) -directly in the ``roundup.cgi`` file itself - add your instance to the TRACKER_HOMES variable as ``'name': 'home'`` - -The "name" part of the configuration will appear in the URL and identifies the -tracker (so you may have more than one tracker per cgi-bin script). Make sure -there are no spaces or other illegal characters in it (to be safe, stick to -letters and numbers). The "name" forms part of the URL that appears in the -tracker config TRACKER_WEB variable, so make sure they match. The "home" -part of the configuration is the tracker home directory. - -Stand-alone Web Server -~~~~~~~~~~~~~~~~~~~~~~ - -This approach will give you the fastest of the three web interfaces. You may -investigate using ProxyPass or similar configuration in apache to have your -tracker accessed through the same URL as other systems. - -The stand-alone web server is started with the command ``roundup-server``. It -has several options - display them with ``roundup-server -h``. - -The tracker home configuration is similar to the cgi-bin - you may either edit -the script to change the TRACKER_HOMES variable or you may supply the -name=home values on the command-line after all the other options. - -To make the server run in the background, use the "-d" option, specifying the -name of a file to write the server process id (pid) to. - - -Zope Product - ZRoundup -~~~~~~~~~~~~~~~~~~~~~~~ - -ZRoundup installs as a regular Zope product. Copy the ZRoundup directory to -your Products directory either in INSTANCE_HOME/Products or the Zope -code tree lib/python/Products. - -When you next (re)start up Zope, you will be able to add a ZRoundup object -that interfaces to your new tracker. - - -Configure an Email Interface ----------------------------- - -If you don't want to use the email component of Roundup, then remove the -"``nosyreator.py``" module from your tracker "``detectors``" directory. - -There are three supported ways to get emailed issues into the -Roundup tracker. You should pick ONE of the following, all -of which will continue my example setup from above: - -As a mail alias pipe process -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Set up a mail alias called "issue_tracker" as (include the quote marks): -"``|/usr/bin/python /usr/local/bin/roundup-mailgw <tracker_home>``" - -In some installations (e.g. RedHat 6.2 I think) you'll need to set up smrsh so -sendmail will accept the pipe command. In that case, symlink -``/etc/smrsh/roundup-mailgw`` to "``/usr/local/bin/roundup-mailgw``" and change -the command to:: - - |roundup-mailgw /opt/roundup/trackers/support - -To test the mail gateway on unix systems, try:: - - echo test |mail -s '[issue] test' support@YOUR_DOMAIN_HERE - -As a regular cron job using a mailbox source -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Set ``roundup-mailgw`` up to run every 10 minutes or so. For example:: - - 10 * * * * /usr/local/bin/roundup-mailgw /opt/roundup/trackers/support mailbox <mail_spool_file> - -Where the ``mail_spool_file`` argument is the location of the roundup submission -user's mail spool. On most systems, the spool for a user "issue_tracker" -will be "``/var/mail/issue_tracker``". - -As a regular cron job using a POP source -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To retrieve from a POP mailbox, use a similar cron entry to the mailbox one:: - - 10 * * * * /usr/local/bin/roundup-mailgw /opt/roundup/trackers/support pop <pop_spec> - -where pop_spec is "``username:password@server``" that specifies the roundup -submission user's POP account name, password and server. - - -Shared Environment Steps ------------------------- - -Each tracker ideally should have its own UNIX group, so create -a UNIX group (edit ``/etc/group`` or your appropriate NIS map if -you're using NIS). To continue with my examples so far, I would -create the UNIX group 'support', although the name of the UNIX -group does not have to be the same as the tracker name. To this -'support' group I then add all of the UNIX usernames who will be -working with this Roundup tracker. In addition to 'real' users, -the Roundup email gateway will need to have permissions to this -area as well, so add the user your mail service runs as to the -group. The UNIX group might then look like:: - - support:*:1002:jblaine,samh,geezer,mail - -If you intend to use the web interface (as most people do), you -should also add the username your web server runs as to the group. -My group now looks like this:: - - support:*:1002:jblaine,samh,geezer,mail,apache - -The tracker "db" directory should be chmod'ed g+sw so that the group can -write to the database, and any new files created in the database will be owned -by the group. - -An alternative to the above is to create a new user who has the sole -responsibility of running roundup. This user: - -1. runs the CGI interface daemon -2. runs regular polls for email -3. runs regular checks (using cron) to ensure the daemon is up -4. optionally has no login password so that nobody but the "root" user - may actually login and play with the roundup setup. - - -Maintenance -=========== - -Read the separate `maintenance documentation`_ for information about how to -perform common maintenance tasks with Roundup. - - -Upgrading -========= - -Read the separate `upgrading document`_, which describes the steps needed to -upgrade existing tracker trackers for each version of Roundup that is -released. - - -Further Reading -=============== - -If you intend to use Roundup with anything other than the defualt -templates, if you would like to hack on Roundup, or if you would -like implementation details, you should read `Customising Roundup`_. - - -Platform-Specific Notes -======================= - -Sendmail smrsh --------------- - -If you use Sendmail's ``smrsh`` mechanism, you will need to tell -smrsh that roundup-mailgw is a valid/trusted mail handler -before it will work. - -This is usually done via the following 2 steps: - -1. make a symlink in ``/etc/smrsh`` called ``roundup-mailgw`` - which points to the full path of your actual ``roundup-mailgw`` - script. - -2. change your alias to ``"|roundup-mailgw <tracker_home>"`` - - -Linux ------ - -Python 2.1.1 as shipped with SuSE7.3 might be missing module -``_weakref``. - -------------------------------------------------------------------------------- - -Back to `Table of Contents`_ - -Next: `Getting Started`_ - -.. _`table of contents`: index.html -.. _`getting started`: getting_started.html -.. _`roundup specification`: spec.html -.. _`customising roundup`: customizing.html -.. _`upgrading document`: upgrading.html -.. _`maintenance documentation`: maintenance.html -.. _sqlite: http://www.hwaci.com/sw/sqlite/ -.. _metakit: http://www.equi4.com/metakit/ -
--- a/doc/upgrading.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,729 +0,0 @@ -====================================== -Upgrading to newer versions of Roundup -====================================== - -Please read each section carefully and edit your tracker home files -accordingly. - -.. contents:: - -Migrating from 0.4.x to 0.5.0 -============================= - -This has been a fairly major revision of Roundup: - -1. Brand new, much more powerful, flexible, tasty and nutritious templating. - Unfortunately, this means all your current templates are useless. Hopefully - the new documentation and examples will be enough to help you make the - transition. Please don't hesitate to ask on roundup-users for help (or - complete conversions if you're completely stuck)! -2. The database backed got a lot more flexible, allowing Metakit and SQL - databases! The only decent SQL database implemented at present is sqlite, - but others shouldn't be a whole lot more work. -3. A brand new, highly flexible and much more robust security system including - a system of Permissions, Roles and Role assignments to users. You may now - define your own Permissions that may be checked in CGI transactions. -4. Journalling has been made less storage-hungry, so has been turned on - by default *except* for author, recipient and nosy link/unlink events. You - are advised to turn it off in your trackers too. -5. We've changed the terminology from "instance" to "tracker", to ease the - learning curve/impact for new users. -6. Because of the above changes, the tracker configuration has seen some - major changes. See below for the details. - -Please, **back up your database** before you start the migration process. This -is as simple as copying the "db" directory and all its contents from your -tracker to somewhere safe. - - -0.5.0 Configuration -------------------- - -First up, rename your ``instance_config.py`` file to just ``config.py``. - -Then edit your tracker's ``__init__.py`` module. It'll currently look -like this:: - - from instance_config import * - try: - from dbinit import * - except ImportError: - pass # in installdir (probably :) - from interfaces import * - -and it needs to be:: - - import config - from dbinit import open, init - from interfaces import Client, MailGW - -Due to the new templating having a top-level ``page`` that defines links for -searching, indexes, adding items etc, the following variables are no longer -used: - -- HEADER_INDEX_LINKS -- HEADER_ADD_LINKS -- HEADER_SEARCH_LINKS -- SEARCH_FILTERS -- DEFAULT_INDEX -- UNASSIGNED_INDEX -- USER_INDEX -- ISSUE_FILTER - -The new security implementation will require additions to the dbinit module, -but also removes the need for the following tracker config variables: - -- ANONYMOUS_ACCESS -- ANONYMOUS_REGISTER - -but requires two new variables which define the Roles assigned to users who -register through the web and e-mail interfaces: - -- NEW_WEB_USER_ROLES -- NEW_EMAIL_USER_ROLES - -in both cases, 'User' is a good initial setting. To emulate -``ANONYMOUS_ACCESS='deny'``, remove all "View" Permissions from the -"Anonymous" Role. To emulate ``ANONYMOUS_REGISTER='deny'``, remove the "Web -Registration" and/or the "Email Registration" Permission from the "Anonymous" -Role. See the section on customising security in the `customisation -documentation`_ for more information. - -Finally, the following config variables have been renamed to make more sense: - -- INSTANCE_HOME -> TRACKER_HOME -- INSTANCE_NAME -> TRACKER_NAME -- ISSUE_TRACKER_WEB -> TRACKER_WEB -- ISSUE_TRACKER_EMAIL -> TRACKER_EMAIL - - -0.5.0 Schema Specification --------------------------- - -0.5.0 Database backend changes -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Your select_db module in your tracker has changed a fair bit. Where it used -to contain:: - - # WARNING: DO NOT EDIT THIS FILE!!! - from roundup.backends.back_anydbm import Database - -it must now contain:: - - # WARNING: DO NOT EDIT THIS FILE!!! - from roundup.backends.back_anydbm import Database, Class, FileClass, IssueClass - -Yes, I realise the irony of the "DO NOT EDIT THIS FILE" statement :) -Note the addition of the Class, FileClass, IssueClass imports. These are very -important, as they're going to make the next change work too. You now need to -modify the top of the dbinit module in your tracker from:: - - import instance_config - from roundup import roundupdb - from select_db import Database - - from roundup.roundupdb import Class, FileClass - - class Database(roundupdb.Database, select_db.Database): - ''' Creates a hybrid database from: - . the selected database back-end from select_db - . the roundup extensions from roundupdb - ''' - pass - - class IssueClass(roundupdb.IssueClass): - ''' issues need the email information - ''' - pass - -to:: - - import config - from select_db import Database, Class, FileClass, IssueClass - -Yes, remove the Database and IssueClass definitions and those other imports. -They're not needed any more! - -Look for places in dbinit.py where ``instance_config`` is used too, and -rename them ``config``. - - -0.5.0 Journalling changes -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Journalling has been optimised for storage. Journalling of links has been -turned back on by default. If your tracker has a large user base, you may wish -to turn off journalling of nosy list, message author and message recipient -link and unlink events. You do this by adding ``do_journal='no'`` to the Class -initialisation in your dbinit. For example, your *msg* class initialisation -probably looks like this:: - - msg = FileClass(db, "msg", - author=Link("user"), recipients=Multilink("user"), - date=Date(), summary=String(), - files=Multilink("file"), - messageid=String(), inreplyto=String()) - -to turn off journalling of author and recipient link events, add -``do_journal='no'`` to the ``author=Link("user")`` part of the statement, -like so:: - - msg = FileClass(db, "msg", - author=Link("user", do_journal='no'), - recipients=Multilink("user", do_journal='no'), - date=Date(), summary=String(), - files=Multilink("file"), - messageid=String(), inreplyto=String()) - -Nosy list link event journalling is actually turned off by default now. If you -want to turn it on, change to your issue class' nosy list, change its -definition from:: - - issue = IssueClass(db, "issue", - assignedto=Link("user"), topic=Multilink("keyword"), - priority=Link("priority"), status=Link("status")) - -to:: - - issue = IssueClass(db, "issue", nosy=Multilink("user", do_journal='yes'), - assignedto=Link("user"), topic=Multilink("keyword"), - priority=Link("priority"), status=Link("status")) - -noting that your definition of the nosy Multilink will override the normal one. - - -0.5.0 User schema changes -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Users have two more properties, "queries" and "roles". You'll have something -like this in your dbinit module now:: - - user = Class(db, "user", - username=String(), password=Password(), - address=String(), realname=String(), - phone=String(), organisation=String(), - alternate_addresses=String()) - user.setkey("username") - -and you'll need to add the new properties and the new "query" class to it -like so:: - - query = Class(db, "query", - klass=String(), name=String(), - url=String()) - query.setkey("name") - - # Note: roles is a comma-separated string of Role names - user = Class(db, "user", - username=String(), password=Password(), - address=String(), realname=String(), - phone=String(), organisation=String(), - alternate_addresses=String(), - queries=Multilink('query'), roles=String()) - user.setkey("username") - -The "queries" property is used to store off the user's favourite database -queries. The "roles" property is explained below in `0.5.0 Security -Settings`_. - - -0.5.0 Security Settings -~~~~~~~~~~~~~~~~~~~~~~~ - -See the `security documentation`_ for an explanation of how the new security -system works. In a nutshell though, the security is handled as a four step -process: - -1. Permissions are defined as having a name and optionally a hyperdb class - they're specific to, -2. Roles are defined that have one or more Permissions, -3. Users are assigned Roles in their "roles" property, and finally -4. Roundup checks that users have appropriate Permissions at appropriate times - (like editing issues). - -Your tracker dbinit module's *open* function now has to define any -Permissions that are specific to your tracker, and also the assignment -of Permissions to Roles. At the moment, your open function -ends with:: - - import detectors - detectors.init(db) - - return db - -and what we need to do is insert some commands that will set up the security -parameters. Right above the ``import detectors`` line, you'll want to insert -these lines:: - - # - # SECURITY SETTINGS - # - # new permissions for this schema - for cl in 'issue', 'file', 'msg', 'user': - db.security.addPermission(name="Edit", klass=cl, - description="User is allowed to edit "+cl) - db.security.addPermission(name="View", klass=cl, - description="User is allowed to access "+cl) - - # Assign the access and edit permissions for issue, file and message - # to regular users now - for cl in 'issue', 'file', 'msg': - p = db.security.getPermission('View', cl) - db.security.addPermissionToRole('User', p) - p = db.security.getPermission('Edit', cl) - db.security.addPermissionToRole('User', p) - # and give the regular users access to the web and email interface - p = db.security.getPermission('Web Access') - db.security.addPermissionToRole('User', p) - p = db.security.getPermission('Email Access') - db.security.addPermissionToRole('User', p) - - # May users view other user information? Comment these lines out - # if you don't want them to - p = db.security.getPermission('View', 'user') - db.security.addPermissionToRole('User', p) - - # Assign the appropriate permissions to the anonymous user's Anonymous - # Role. Choices here are: - # - Allow anonymous users to register through the web - p = db.security.getPermission('Web Registration') - db.security.addPermissionToRole('Anonymous', p) - # - Allow anonymous (new) users to register through the email gateway - p = db.security.getPermission('Email Registration') - db.security.addPermissionToRole('Anonymous', p) - # - Allow anonymous users access to the "issue" class of data - # Note: this also grants access to related information like files, - # messages, statuses etc that are linked to issues - #p = db.security.getPermission('View', 'issue') - #db.security.addPermissionToRole('Anonymous', p) - # - Allow anonymous users access to edit the "issue" class of data - # Note: this also grants access to create related information like - # files and messages etc that are linked to issues - #p = db.security.getPermission('Edit', 'issue') - #db.security.addPermissionToRole('Anonymous', p) - - # oh, g'wan, let anonymous access the web interface too - p = db.security.getPermission('Web Access') - db.security.addPermissionToRole('Anonymous', p) - -Note in the comments there the places where you might change the permissions -to restrict users or grant users more access. If you've created additional -classes that users should be able to edit and view, then you should add them -to the "new permissions for this schema" section at the start of the security -block. Then add them to the "Assign the access and edit permissions" section -too, so people actually have the new Permission you've created. - -One final change is needed that finishes off the security system's -initialisation. We need to add a call to ``db.post_init()`` at the end of the -dbinit open() function. Add it like this:: - - import detectors - detectors.init(db) - - # schema is set up - run any post-initialisation - db.post_init() - return db - -You may verify the setup of Permissions and Roles using the new -"``roundup-admin security``" command. - - -0.5.0 User changes -~~~~~~~~~~~~~~~~~~ - -To support all those schema changes, you'll need to massage your user database -a little too, to: - -1. make sure there's an "anonymous" user - this user is mandatory now and is - the one that unknown users are logged in as. -2. make sure all users have at least one Role. - -If you don't have the "anonymous" user, create it now with the command:: - - roundup-admin create user username=anonymous roles=Anonymous - -making sure the capitalisation is the same as above. Once you've done that, -you'll need to set the roles property on all users to a reasonable default. -The admin user should get "Admin", the anonymous user "Anonymous" -and all other users "User". The ``fixroles.py`` script in the tools directory -will do this. Run it like so (where python is your python 2+ binary):: - - python tools/fixroles.py -i <tracker home> - - - -0.5.0 CGI interface changes ---------------------------- - -The CGI interface code was completely reorganised and largely rewritten. The -end result is that this section of your tracker interfaces module will need -changing from:: - - from roundup import mailgw - from roundup.cgi import client - - class Client(client.Client): - ''' derives basic CGI implementation from the standard module, - with any specific extensions - ''' - pass - -to:: - - from roundup import cgi_client, mailgw - from roundup.i18n import _ - - class Client(cgi_client.Client): - ''' derives basic CGI implementation from the standard module, - with any specific extensions - ''' - pass - -You will also need to install the new version of roundup.cgi from the source -cgi-bin directory if you're using it. - - -0.5.0 HTML templating ---------------------- - -You'll want to make a backup of your current tracker html directory. You -should then copy the html directory from the Roundup source "classic" template -and modify it according to your local schema changes. - -If you need help with the new templating system, please ask questions on the -roundup-users mailing list (available through the roundup project page on -sourceforge, http://roundup.sf.net/) - - -0.5.0 Detectors ---------------- - -The nosy reactor has been updated to handle the tracker not having an -"assignedto" property on issues. You may want to copy it into your tracker's -detectors directory. Chances are you've already fixed it though :) - - -Migrating from 0.4.1 to 0.4.2 -============================= - -0.4.2 Configuration -------------------- -The USER_INDEX definition introduced in 0.4.1 was too restrictive in its -allowing replacement of 'assignedto' with the user's userid. Users must change -the None value of 'assignedto' to 'CURRENT USER' (the string, in quotes) for -the replacement behaviour to occur now. - -The new configuration variables are: - -- EMAIL_KEEP_QUOTED_TEXT -- EMAIL_LEAVE_BODY_UNCHANGED -- ADD_RECIPIENTS_TO_NOSY - -See the sample configuration files in:: - - <roundup source>/roundup/templates/classic/instance_config.py - -and:: - - <roundup source>/roundup/templates/extended/instance_config.py - -and the `customisation documentation`_ for information on how they're used. - - -0.4.2 Changes to detectors --------------------------- -You will need to copy the detectors from the distribution into your instance -home "detectors" directory. If you used the classic schema, the detectors -are in:: - - <roundup source>/roundup/templates/classic/detectors/ - -If you used the extended schema, the detectors are in:: - - <roundup source>/roundup/templates/extended/detectors/ - -The change means that schema-specific code has been removed from the -mail gateway and cgi interface and made into auditors: - -- nosyreactor.py has now got an updatenosy auditor which updates the nosy - list with author, recipient and assignedto information. -- statusauditor.py makes the unread or resolved -> chatting changes and - presets the status of an issue to unread. - -There's also a bug or two fixed in the nosyreactor code. - -0.4.2 HTML templating changes ------------------------------ -The link() htmltemplate function now has a "showid" option for links and -multilinks. When true, it only displays the linked item id as the anchor -text. The link value is displayed as a tooltip using the title anchor -attribute. To use in eg. the superseder field, have something like this:: - - <td> - <display call="field('superseder', showid=1)"> - <display call="classhelp('issue', 'id,title', label='list', width=500)"> - <property name="superseder"> - <br>View: <display call="link('superseder', showid=1)"> - </property> - </td> - -The stylesheets have been cleaned up too. You may want to use the newer -versions in:: - - <roundup source>/roundup/templates/<template>/html/default.css - - - -Migrating from 0.4.0 to 0.4.1 -============================= - -0.4.1 Files storage -------------------- - -Messages and files from newly created issues will be put into subdierectories -in thousands e.g. msg123 will be put into files/msg/0/msg123, file2003 -will go into files/file/2/file2003. Previous messages are still found, but -could be put into this structure. - -0.4.1 Configuration -------------------- - -To allow more fine-grained access control, the variable used to check -permission to auto-register users in the mail gateway is now called -ANONYMOUS_REGISTER_MAIL rather than overloading ANONYMOUS_REGISTER. If the -variable doesn't exist, then ANONYMOUS_REGISTER is tested as before. - -Configuring the links in the web header is now easier too. The following -variables have been added to the classic instance_config.py:: - - HEADER_INDEX_LINKS - defines the "index" links to be made available - HEADER_ADD_LINKS - defines the "add" links - DEFAULT_INDEX - specifies the index view for DEFAULT - UNASSIGNED_INDEX - specifies the index view for UNASSIGNED - USER_INDEX - specifies the index view for USER - -See the <roundup source>/roundup/templates/classic/instance_config.py for more -information - including how the variables are to be set up. Most users will -just be able to copy the variables from the source to their instance home. If -you've modified the header by changing the source of the interfaces.py file in -the instance home, you'll need to remove that customisation and move it into -the appropriate variables in instance_config.py. - -The extended schema has similar variables added too - see the source for more -info. - -0.4.1 Alternate E-Mail Addresses --------------------------------- - -If you add the property "alternate_addresses" to your user class, your users -will be able to register alternate email addresses that they may use to -communicate with roundup as. All email from roundup will continue to be sent -to their primary address. - -If you have not edited the dbinit.py file in your instance home directory, -you may simply copy the new dbinit.py file from the core code. If you used -the classic schema, the interfaces file is in:: - - <roundup source>/roundup/templates/classic/dbinit.py - -If you used the extended schema, the file is in:: - - <roundup source>/roundup/templates/extended/dbinit.py - -If you have modified your dbinit.py file, you need to edit the dbinit.py -file in your instance home directory. Find the lines which define the user -class:: - - user = Class(db, "msg", - username=String(), password=Password(), - address=String(), realname=String(), - phone=String(), organisation=String(), - alternate_addresses=String()) - -You will also want to add the property to the user's details page. The -template for this is the "user.item" file in your instance home "html" -directory. Similar to above, you may copy the file from the roundup source if -you haven't modified it. Otherwise, add the following to the template:: - - <display call="multiline('alternate_addresses')"> - -with appropriate labelling etc. See the standard template for an idea. - - - -Migrating from 0.3.x to 0.4.0 -============================= - -0.4.0 Message-ID and In-Reply-To addition ------------------------------------------ -0.4.0 adds the tracking of messages by message-id and allows threading -using in-reply-to. Most e-mail clients support threading using this -feature, and we hope to add support for it to the web gateway. If you -have not edited the dbinit.py file in your instance home directory, you may -simply copy the new dbinit.py file from the core code. If you used the -classic schema, the interfaces file is in:: - - <roundup source>/roundup/templates/classic/dbinit.py - -If you used the extended schema, the file is in:: - - <roundup source>/roundup/templates/extended/dbinit.py - -If you have modified your dbinit.py file, you need to edit the dbinit.py -file in your instance home directory. Find the lines which define the msg -class:: - - msg = FileClass(db, "msg", - author=Link("user"), recipients=Multilink("user"), - date=Date(), summary=String(), - files=Multilink("file")) - -and add the messageid and inreplyto properties like so:: - - msg = FileClass(db, "msg", - author=Link("user"), recipients=Multilink("user"), - date=Date(), summary=String(), - files=Multilink("file"), - messageid=String(), inreplyto=String()) - -Also, configuration is being cleaned up. This means that your dbinit.py will -also need to be changed in the open function. If you haven't changed your -dbinit.py, the above copy will be enough. If you have, you'll need to change -the line (round line 50):: - - db = Database(instance_config.DATABASE, name) - -to:: - - db = Database(instance_config, name) - - -0.4.0 Configuration --------------------- -``TRACKER_NAME`` and ``EMAIL_SIGNATURE_POSITION`` have been added to the -instance_config.py. The simplest solution is to copy the default values -from template in the core source. - -The mail gateway now checks ``ANONYMOUS_REGISTER`` to see if unknown users -are to be automatically registered with the tracker. If it is set to "deny" -then unknown users will not have access. If it is set to "allow" they will be -automatically registered with the tracker. - - -0.4.0 CGI script roundup.cgi ----------------------------- -The CGI script has been updated with some features and a bugfix, so you should -copy it from the roundup cgi-bin source directory again. Make sure you update -the ROUNDUP_INSTANCE_HOMES after the copy. - - -0.4.0 Nosy reactor ------------------- -The nosy reactor has also changed - copy the nosyreactor.py file from the core -source:: - - <roundup source>/roundup/templates/<template>/detectors/nosyreactor.py - -to your instance home "detectors" directory. - - -0.4.0 HTML templating ---------------------- -The field() function was incorrectly implemented - links and multilinks now -display as text fields when rendered using field(). To display a menu (drop- -down or select box) you need to use the menu() function. - - - -Migrating from 0.2.x to 0.3.x -============================= - -0.3.x Cookie Authentication changes ------------------------------------ -0.3.0 introduces cookie authentication - you will need to copy the -interfaces.py file from the roundup source to your instance home to enable -authentication. If you used the classic schema, the interfaces file is in:: - - <roundup source>/roundup/templates/classic/interfaces.py - -If you used the extended schema, the file is in:: - - <roundup source>/roundup/templates/extended/interfaces.py - -If you have modified your interfaces.Client class, you will need to take -note of the login/logout functionality provided in roundup.cgi_client.Client -(classic schema) or roundup.cgi_client.ExtendedClient (extended schema) and -modify your instance code apropriately. - - -0.3.x Password encoding ------------------------ -This release also introduces encoding of passwords in the database. If you -have not edited the dbinit.py file in your instance home directory, you may -simply copy the new dbinit.py file from the core code. If you used the -classic schema, the interfaces file is in:: - - <roundup source>/roundup/templates/classic/dbinit.py - -If you used the extended schema, the file is in:: - - <roundup source>/roundup/templates/extended/dbinit.py - - -If you have modified your dbinit.py file, you may use encoded passwords: - -1. Edit the dbinit.py file in your instance home directory - a. At the first code line of the open() function:: - - from roundup.hyperdb import String, Date, Link, Multilink - - alter to include Password, as so:: - - from roundup.hyperdb import String, Password, Date, Link, Multilink - - b. Where the password property is defined (around line 66):: - - user = Class(db, "user", - username=String(), password=String(), - address=String(), realname=String(), - phone=String(), organisation=String()) - user.setkey("username") - - alter the "password=String()" to "password=Password()":: - - user = Class(db, "user", - username=String(), password=Password(), - address=String(), realname=String(), - phone=String(), organisation=String()) - user.setkey("username") - -2. Any existing passwords in the database will remain cleartext until they - are edited. It is recommended that at a minimum the admin password be - changed immediately:: - - roundup-admin -i <instance home> set user1 password=<new password> - - -0.3.x Configuration -------------------- -FILTER_POSITION, ANONYMOUS_ACCESS, ANONYMOUS_REGISTER have been added to -the instance_config.py. Simplest solution is to copy the default values from -template in the core source. - -MESSAGES_TO_AUTHOR has been added to the IssueClass in dbinit.py. Set to 'yes' -to send nosy messages to the author. Default behaviour is to not send nosy -messages to the author. You will need to add MESSAGES_TO_AUTHOR to your -dbinit.py in your instance home. - - -0.3.x CGI script roundup.cgi ----------------------------- -There have been some structural changes to the roundup.cgi script - you will -need to install it again from the cgi-bin directory of the source -distribution. Make sure you update the ROUNDUP_INSTANCE_HOMES after the -copy. - - -.. _`customisation documentation`: customizing.html -.. _`security documentation`: security.html
--- a/doc/user_guide.txt Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,374 +0,0 @@ -========== -User Guide -========== - -:Version: $Revision: 1.9 $ - -.. contents:: - -Note: this document will refer to *issues* as the primary store of information -in the tracker. This is the default of the classic template, bubt may vary in -any given installation. - -Your Tracker in a Nutshell -========================== - -Your tracker holds information about issues in bundles we call *items*. An -item may be an *issue* (a bug or feature request) or a *user*. The issue-ness or -user-ness is called the item's *class*. So, for bug reports and features, the -class is "issue", and for users the class is "user". - -Each item in the tracker has an id number that identifies it along with its -item class. To identify a particular issue or user, we combine the class with -the number to create a unique label, so that user 1 (who, incidentally, is -*always* the "admin" user) is referred to as "user1". Issue number 315 is -referred to as "issue315". We call that label the item's *designator*. - -Accessing the Tracker ---------------------- - -You may access your tracker through one of three ways: - -1. through the `web interface`_, -2. through the `e-mail gateway`_, or -3. using the `command line tool`_. - -The last is usually only used by administrators. Most users will use the web -and email interfaces. All three are explained below. - - -Web Interface -============= - -Note: this document contains screenshots of the default look and feel. Your -site may have a slightly (or very) different look, but the functionality will -be very similar, and the concepts still hold. - -The web interface is broken up into the following parts: - -1. `lists of items`_, -2. `display, edit or entry of an item`_, and -3. `searching page`_. - - -Lists of Items --------------- - -The first thing you'll see when you log into Roundup will be a list of open -(ie. not resolved) issues. This list has been generated by a bunch of controls -`under the covers`_ but for now, you can see something like: - -.. img: images/index_logged_out.png - -The screen is divided up into three sections: - -.. img: images/page_layout.png - -you may either register or log in. Registration takes you to: - -.. img: images/registration.png - -Once you're logged in, the screen changes slightly to: - -.. img: images/index_logged_in.png - -Note that the sidebar menu has changed slightly, so you can now get to your -"My Details" page: - -.. img: images/my_details.png - -Note the new information on this page - the history. - - -Display, edit or entry of an item ---------------------------------- - -Create a new issue with "create new" under the issue subheading. This will -take you to: - -.. img: images/new_issue.png - -The `nosy list`_ is explained below. -Enter some information and click "submit new entry" and you'll be rewarded -with: - -.. img: images/new_issue_created.png - -or, if you don't enter all the required information (or some other error -occurs) you'll get something like: - -.. img: images/new_issue_error.png - - -Searching Page --------------- - -XXX: some information about how searching works - - -Under the covers ----------------- - -Index views may be modified by the following arguments: - -========== ============================================================= -Argument Description -========== ============================================================= -:sort sort by prop name, optionally preceeded with '-' - to give descending or nothing for ascending sorting. -:group group by prop name, optionally preceeded with '-' or - to sort in descending or nothing for ascending order. -:filter selects which props should be displayed in the filter - section. Default is all. -:columns selects the columns that should be displayed. - Default is all. -propname selects the values the item properties given by propname - must have (very basic search/filter). -========== ============================================================= - -Access Controls ---------------- - -XXX - - -E-Mail Gateway -============== - -E-mail sent to Roundup is examined for several pieces of information: - -1. `subject-line information`_ identifying the purpose of the e-mail -2. `e-mail message content`_ which is to be extracted -3. e-mail attachments which should be associated with the message - -Subject-line information ------------------------- - -The subject line of the incoming message is examined to find one of: - -1. the item that the message is responding to, -2. the type of item the message should create, or -3. we default the item class and try some trickiness - -If the subject line contains a prefix in ``[square brackets]`` then we're -looking at case 1 or 2 above. Note that any "re:" or "fwd:" prefixes are -stripped off the subject line before we start looking for real information. - -If an item designator (class name and id number, for example ``issue123``) -is found there, a new "msg" item is added to the "messages" property for -that item, and any new "file" items are added to the "files" property for -the item. - -If just an item class name is found there, we attempt to create a new item of -that class with its "messages" property initialized to contain the new "msg" -item and its "files" property initialized to contain any new "file" items. - -The third case above - where no ``[information]`` is provided, the tracker's -``MAIL_DEFAULT_CLASS`` configuration variable defines what class of item -the message relates to. We try to match the subject line to an existing -item of the default class, and if there's a match, the message is related to -that matched item. If not, then a new item of the default class is created. - -Setting Properties -~~~~~~~~~~~~~~~~~~ - -The e-mail interface also provides a simple way to set properties on items. At -the end of the subject line, propname=value pairs can be specified in square -brackets, using the same conventions as for the roundup set shell command. - -For example, - -- setting the priority of an issue:: - - Subject: Re: [issue1] the coffee machine is broken! [priority=urgent] - -- adding yourself to a nosy list:: - - Subject: Re: [issue2] we're out of widgets [nosy=+richard] - -- setting the nosy list to just you:: - - Subject: Re: [issue2] we're out of widgets [nosy=richard] - -- removing yourself from a nosy list:: - - Subject: Re: [issue2] we're out of widgets [nosy=-richard] - -In all cases, the message relates to issue 2. The ``Re:`` prefix is stripped -off. - - -E-Mail Message Content ----------------------- - -Roundup only associates plain text (MIME type ``text/plain``) as messages for -items. Any other parts of a message are associated as downloadable files. If -no plain text part is found, the message is rejected. - -To do this, incoming messages are examined for multiple parts: - -* In a multipart/mixed message or part, each subpart is extracted and - examined. The text/plain subparts are assembled to form the textual body - of the message, to be stored in the file associated with a "msg" class - item. Any parts of other types are each stored in separate files and - given "file" class items that are linked to the "msg" item. -* In a multipart/alternative message or part, we look for a text/plain - subpart and ignore the other parts. - -If the message is a response to a previous message, and contains quoted -sections, then these will be stripped out of the message if the -``EMAIL_KEEP_QUOTED_TEXT`` configuration variable is set to ``'no'``. - -Message summary -~~~~~~~~~~~~~~~ - -The "summary" property on message items is taken from the first non-quoting -section in the message body. The message body is divided into sections by blank -lines. Sections where the second and all subsequent lines begin with a ">" or -"|" character are considered "quoting sections". The first line of the first -non-quoting section becomes the summary of the message. - - -Address handling ----------------- - -All of the addresses in the ``To:`` and ``Cc:`` headers of the incoming -message are -looked up among the tracker users, and the corresponding users are placed -in the -"recipients" property on the new "msg" item. The address in the ``From:`` header -similarly determines the "author" property of the new "msg" item. The default -handling for addresses that don't have corresponding users is to create new -users with no passwords and a username equal to the address. - -The addresses mentioned in the ``To:``, ``From:`` and ``Cc:`` headers of -the message may be added to the `nosy list`_ depending on: - -``ADD_AUTHOR_TO_NOSY`` - Does the author of a message get placed on the nosy list automatically? - If 'new' is used, then the author will only be added when a message - creates a new issue. If 'yes', then the author will be added on followups - too. If 'no', they're never added to the nosy. - -``ADD_RECIPIENTS_TO_NOSY`` - Do the recipients (To:, Cc:) of a message get placed on the nosy list? - If 'new' is used, then the recipients will only be added when a message - creates a new issue. If 'yes', then the recipients will be added on - followups too. If 'no', they're never added to the nosy. - - -Nosy List -~~~~~~~~~ - -Roundup watches for additions to the "messages" property of items. - -When a new message is added, it is sent to all the users -on the "nosy" list for the item that are not already on the "recipients" list -of the message. Those users are then appended to the "recipients" property on -the message, so multiple copies of a message are never sent to the same user. -The journal recorded by the hyperdatabase on the "recipients" property then -provides a log of when the message was sent to whom. - -If the author of the message is also in the nosy list for the item that the -message is attached to, then the config var ``MESSAGES_TO_AUTHOR`` is queried -to determine if they get a nosy list copy of the message too. - - -Command Line Tool -================= - -The basic usage is:: - - Help: - roundup-admin -h - roundup-admin help -- this help - roundup-admin help <command> -- command-specific help - roundup-admin help all -- all available help - - Options: - -i instance home -- specify the issue tracker "home directory" to administer - -u -- the user[:password] to use for commands - -c -- when outputting lists of data, just comma-separate them - - Commands: - commit - create classname property=value ... - display designator - export [class[,class]] export_dir - find classname propname=value ... - get property designator[,designator]* - help topic - history designator - import import_dir - initialise [adminpw] - install [template [backend [admin password]]] - list classname [property] - pack period | date - reindex - retire designator[,designator]* - rollback - security [Role name] - set designator[,designator]* propname=value ... - specification classname - table classname [property[,property]*] - -Commands may be abbreviated as long as the abbreviation matches only one -command, e.g. l == li == lis == list. - -All commands (except help) require a tracker specifier. This is just the -path to the roundup tracker you're working with. A roundup tracker is where -roundup keeps the database and configuration file that defines an issue -tracker. It may be thought of as the issue tracker's "home directory". -It may be specified in the environment variable ``TRACKER_HOME`` or on -the command line as "``-i tracker``". - -A designator is a classname and an itemid concatenated, eg. bug1, user10, ... -Property values are represented as strings in command arguments and in the printed -results: - -- Strings are, well, strings. -- Password values will display as their encoded value. -- Date values are printed in the full date format in the local time zone, - and accepted in the full format or any of the partial formats explained - below.:: - - Input of... Means... - "2000-04-17.03:45" 2000-04-17.08:45:00 - "2000-04-17" 2000-04-17.00:00:00 - "01-25" yyyy-01-25.00:00:00 - "08-13.22:13" yyyy-08-14.03:13:00 - "11-07.09:32:43" yyyy-11-07.14:32:43 - "14:25" yyyy-mm-dd.19:25:00 - "8:47:11" yyyy-mm-dd.13:47:11 - "." "right now" - -- Link values are printed as item designators. When given as an argument, - item designators and key strings are both accepted. -- Multilink values are printed as lists of item designators joined by - commas. When given as an argument, item designators and key strings are - both accepted; an empty string, a single item, or a list of items joined - by commas is accepted. - -When multiple items are specified to the roundup get or roundup set -commands, the specified properties are retrieved or set on all the listed -items. When multiple results are returned by the roundup get or roundup -find commands, they are printed one per line (default) or joined by commas -(with the "``-c``" option). - -Where the command changes data, a login name/password is required. The login may -be specified as either "``name``" or "``name:password``". - -- ``ROUNDUP_LOGIN`` environment variable -- the "``-u``" command-line option - -If either the name or password is not supplied, they are obtained from the -command-line. - - - ------------------ - -Back to `Table of Contents`_ - -.. _`Table of Contents`: index.html -
--- a/frontends/ZRoundup/ZRoundup.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,209 +0,0 @@ -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: ZRoundup.py,v 1.13 2002-09-10 03:01:18 richard Exp $ -# -''' ZRoundup module - exposes the roundup web interface to Zope - -This frontend works by providing a thin layer that sits between Zope and the -regular CGI interface of roundup, providing the web frontend with the minimum -of effort. - -This means that the regular CGI interface does all authentication quite -independently of Zope. The roundup code is kept in memory though, and it -runs in the same server as all your other Zope stuff, so it does have _some_ -advantages over regular CGI :) - -It also means that any requests which specify :filter, :columns or :sort -_must_ be done using a GET, so that this interface can re-parse the -QUERY_STRING. Zope interprets the ':' as a special character, and the special -args are lost to it. -''' - -import urlparse - -from Globals import InitializeClass, HTMLFile -from OFS.SimpleItem import Item -from OFS.PropertyManager import PropertyManager -from Acquisition import Implicit -from Persistence import Persistent -from AccessControl import ClassSecurityInfo -from AccessControl import ModuleSecurityInfo -modulesecurity = ModuleSecurityInfo() - -import roundup.instance -from roundup.cgi.client import NotFound - -modulesecurity.declareProtected('View management screens', - 'manage_addZRoundupForm') -manage_addZRoundupForm = HTMLFile('dtml/manage_addZRoundupForm', globals()) - -modulesecurity.declareProtected('Add Z Roundups', 'manage_addZRoundup') -def manage_addZRoundup(self, id, instance_home, REQUEST): - """Add a ZRoundup product """ - # validate the instance_home - roundup.instance.open(instance_home) - self._setObject(id, ZRoundup(id, instance_home)) - return self.manage_main(self, REQUEST) - -class RequestWrapper: - '''Make the Zope RESPONSE look like a BaseHTTPServer - ''' - def __init__(self, RESPONSE): - self.RESPONSE = RESPONSE - self.wfile = self.RESPONSE - def send_response(self, status): - self.RESPONSE.setStatus(status) - def send_header(self, header, value): - self.RESPONSE.addHeader(header, value) - def end_headers(self): - # not needed - the RESPONSE object handles this internally on write() - pass - -class FormItem: - '''Make a Zope form item look like a cgi.py one - ''' - def __init__(self, value): - self.value = value - if hasattr(self.value, 'filename'): - self.filename = self.value.filename - self.file = self.value - -class FormWrapper: - '''Make a Zope form dict look like a cgi.py one - ''' - def __init__(self, form): - self.form = form - def __getitem__(self, item): - return FormItem(self.form[item]) - def has_key(self, item): - return self.form.has_key(item) - def keys(self): - return self.form.keys() - -class ZRoundup(Item, PropertyManager, Implicit, Persistent): - '''An instance of this class provides an interface between Zope and - roundup for one roundup instance - ''' - meta_type = 'Z Roundup' - security = ClassSecurityInfo() - - def __init__(self, id, instance_home): - self.id = id - self.instance_home = instance_home - - # define the properties that define this object - _properties = ( - {'id':'id', 'type': 'string', 'mode': 'w'}, - {'id':'instance_home', 'type': 'string', 'mode': 'w'}, - ) - property_extensible_schema__ = 0 - - # define the tabs for the management interface - manage_options= PropertyManager.manage_options + ( - {'label': 'View', 'action':'index_html'}, - ) + Item.manage_options - - icon = "misc_/ZRoundup/icon" - - security.declarePrivate('_opendb') - def _opendb(self): - '''Open the roundup instance database for a transaction. - ''' - instance = roundup.instance.open(self.instance_home) - request = RequestWrapper(self.REQUEST['RESPONSE']) - env = self.REQUEST.environ - - # figure out the path components to set - url = urlparse.urlparse( self.absolute_url() ) - path = url[2] - path_components = path.split( '/' ) - - # special case when roundup is '/' in this virtual host, - if path == "/" : - env['SCRIPT_NAME'] = "/" - env['TRACKER_NAME'] = '' - else : - # all but the last element is the path - env['SCRIPT_NAME'] = '/'.join( path_components[:-1] ) - # the last element is the name - env['TRACKER_NAME'] = path_components[-1] - - if env['REQUEST_METHOD'] == 'GET': - # force roundup to re-parse the request because Zope fiddles - # with it and we lose all the :filter, :columns, etc goodness - form = None - else: - # For some reason, CRs are embeded in multiline notes. - # It doesn't occur with apache/roundup.cgi, though. - form = self.REQUEST.form - if form.has_key( '__note' ) : - form['__note'] = form['__note'].replace( '\r' , '' ) - form = FormWrapper(form) - - return instance.Client(instance, request, env, form) - - - security.declareProtected('View', 'index_html') - def index_html(self): - '''Alias index_html to roundup's index - ''' - - # Redirect misdirected requests -- bugs 558867 , 565992 - - # PATH_INFO, as defined by the CGI spec, has the *real* request path - orig_path = self.REQUEST.environ[ 'PATH_INFO' ] - if orig_path[-1] != '/' : - url = urlparse.urlparse( self.absolute_url() ) - url = list( url ) # make mutable - url[2] = url[2]+'/' # patch - url = urlparse.urlunparse( url ) # reassemble - RESPONSE = self.REQUEST.RESPONSE - RESPONSE.setStatus( "MovedPermanently" ) # 301 - RESPONSE.setHeader( "Location" , url ) - return RESPONSE - - client = self._opendb() - # fake the path that roundup should use - client.split_path = ['index'] - return client.main() - - def __getitem__(self, item): - '''All other URL accesses are passed throuh to roundup - ''' - try: - client = self._opendb() - # fake the path that roundup should use - client.split_path = [item] - # and call roundup to do something - client.main() - return '' - except NotFound: - raise 'NotFound', self.REQUEST.URL - pass - except: - import traceback - traceback.print_exc() - # all other exceptions in roundup are valid - raise - raise KeyError, item - - -InitializeClass(ZRoundup) -modulesecurity.apply(globals()) - - -# vim: set filetype=python ts=4 sw=4 et si
--- a/frontends/ZRoundup/__init__.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,56 +0,0 @@ -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: __init__.py,v 1.3 2002-09-10 03:01:18 richard Exp $ -# -__version__='1.0' - -import os -# figure where ZRoundup is installed -here = None -if os.environ.has_key('TRACKER_HOME'): - here = os.environ['TRACKER_HOME'] - path = os.path.join(here, 'Products', 'ZRoundup') - if not os.path.exists(path): - path = os.path.join(here, 'lib', 'python', 'Products', 'ZRoundup') - if not os.path.exists(path): - here = None -if here is None: - from __main__ import here - path = os.path.join(here, 'Products', 'ZRoundup') - if not os.path.exists(path): - path = os.path.join(here, 'lib', 'python', 'Products', 'ZRoundup') - if not os.path.exists(path): - raise ValueError, "Can't determine where ZRoundup is installed" - -# product initialisation -import ZRoundup -def initialize(context): - context.registerClass( - ZRoundup, meta_type = 'Z Roundup', - constructors = ( - ZRoundup.manage_addZRoundupForm, ZRoundup.manage_addZRoundup - ) - ) - -# set up the icon -from ImageFile import ImageFile -misc_ = { - 'icon': ImageFile('icons/tick_symbol.gif', path), -} - - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/__init__.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,70 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: __init__.py,v 1.13 2002-09-26 04:19:53 richard Exp $ - -__doc__ = ''' -This is a simple-to-use and -install issue-tracking system with -command-line, web and e-mail interfaces. - -Roundup manages a number of issues (with properties such as -"description", "priority", and so on) and provides the ability to (a) submit -new issues, (b) find and edit existing issues, and (c) discuss issues with -other participants. The system will facilitate communication among the -participants by managing discussions and notifying interested parties when -issues are edited. - -Roundup's structure is that of a cake: - - _________________________________________________________________________ -| E-mail Client | Web Browser | Detector Scripts | Shell | -|------------------+-----------------+----------------------+-------------| -| E-mail User | Web User | Detector | Command | -|-------------------------------------------------------------------------| -| Roundup Database Layer | -|-------------------------------------------------------------------------| -| Hyperdatabase Layer | -|-------------------------------------------------------------------------| -| Storage Layer | - ------------------------------------------------------------------------- - -The first layer represents the users (chocolate). -The second layer is the Roundup interface to the users (vanilla). -The third and fourth layers are the internal Roundup database storage - mechanisms (strawberry). -The final, lowest layer is the underlying database storage (rum). - -These are implemented in the code in the following manner: - E-mail User: roundup-mailgw and roundup.mailgw - Web User: cgi-bin/roundup.cgi or roundup-server over - roundup.cgi_client, roundup.cgitb and roundup.htmltemplate - Detector: roundup.roundupdb and templates/<template>/detectors - Command: roundup-admin - Roundup DB: roundup.roundupdb - Hyper DB: roundup.hyperdb, roundup.date - Storage: roundup.backends.* - -Additionally, there is a directory of unit tests in "test". - -For more information, see the original overview and specification documents -written by Ka-Ping Yee in the "doc" directory. If nothing else, it has a -much prettier cake :) -''' - -__version__ = '0.5.0pr1' - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/admin.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1190 +0,0 @@ -#! /usr/bin/env python -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: admin.py,v 1.34 2002-09-26 07:41:54 richard Exp $ - -import sys, os, getpass, getopt, re, UserDict, shlex, shutil -try: - import csv -except ImportError: - csv = None -from roundup import date, hyperdb, roundupdb, init, password, token -from roundup import __version__ as roundup_version -import roundup.instance -from roundup.i18n import _ - -class CommandDict(UserDict.UserDict): - '''Simple dictionary that lets us do lookups using partial keys. - - Original code submitted by Engelbert Gruber. - ''' - _marker = [] - def get(self, key, default=_marker): - if self.data.has_key(key): - return [(key, self.data[key])] - keylist = self.data.keys() - keylist.sort() - l = [] - for ki in keylist: - if ki.startswith(key): - l.append((ki, self.data[ki])) - if not l and default is self._marker: - raise KeyError, key - return l - -class UsageError(ValueError): - pass - -class AdminTool: - - def __init__(self): - self.commands = CommandDict() - for k in AdminTool.__dict__.keys(): - if k[:3] == 'do_': - self.commands[k[3:]] = getattr(self, k) - self.help = {} - for k in AdminTool.__dict__.keys(): - if k[:5] == 'help_': - self.help[k[5:]] = getattr(self, k) - self.tracker_home = '' - self.db = None - - def get_class(self, classname): - '''Get the class - raise an exception if it doesn't exist. - ''' - try: - return self.db.getclass(classname) - except KeyError: - raise UsageError, _('no such class "%(classname)s"')%locals() - - def props_from_args(self, args): - props = {} - for arg in args: - if arg.find('=') == -1: - raise UsageError, _('argument "%(arg)s" not propname=value')%locals() - try: - key, value = arg.split('=') - except ValueError: - raise UsageError, _('argument "%(arg)s" not propname=value')%locals() - if value: - props[key] = value - else: - props[key] = None - return props - - def usage(self, message=''): - if message: - message = _('Problem: %(message)s)\n\n')%locals() - print _('''%(message)sUsage: roundup-admin [options] <command> <arguments> - -Options: - -i instance home -- specify the issue tracker "home directory" to administer - -u -- the user[:password] to use for commands - -c -- when outputting lists of data, just comma-separate them - -Help: - roundup-admin -h - roundup-admin help -- this help - roundup-admin help <command> -- command-specific help - roundup-admin help all -- all available help -''')%locals() - self.help_commands() - - def help_commands(self): - print _('Commands:'), - commands = [''] - for command in self.commands.values(): - h = command.__doc__.split('\n')[0] - commands.append(' '+h[7:]) - commands.sort() - commands.append(_('Commands may be abbreviated as long as the abbreviation matches only one')) - commands.append(_('command, e.g. l == li == lis == list.')) - print '\n'.join(commands) - print - - def help_commands_html(self, indent_re=re.compile(r'^(\s+)\S+')): - commands = self.commands.values() - def sortfun(a, b): - return cmp(a.__name__, b.__name__) - commands.sort(sortfun) - for command in commands: - h = command.__doc__.split('\n') - name = command.__name__[3:] - usage = h[0] - print _(''' -<tr><td valign=top><strong>%(name)s</strong></td> - <td><tt>%(usage)s</tt><p> -<pre>''')%locals() - indent = indent_re.match(h[3]) - if indent: indent = len(indent.group(1)) - for line in h[3:]: - if indent: - print line[indent:] - else: - print line - print _('</pre></td></tr>\n') - - def help_all(self): - print _(''' -All commands (except help) require a tracker specifier. This is just the path -to the roundup tracker you're working with. A roundup tracker is where -roundup keeps the database and configuration file that defines an issue -tracker. It may be thought of as the issue tracker's "home directory". It may -be specified in the environment variable TRACKER_HOME or on the command -line as "-i tracker". - -A designator is a classname and a nodeid concatenated, eg. bug1, user10, ... - -Property values are represented as strings in command arguments and in the -printed results: - . Strings are, well, strings. - . Date values are printed in the full date format in the local time zone, and - accepted in the full format or any of the partial formats explained below. - . Link values are printed as node designators. When given as an argument, - node designators and key strings are both accepted. - . Multilink values are printed as lists of node designators joined by commas. - When given as an argument, node designators and key strings are both - accepted; an empty string, a single node, or a list of nodes joined by - commas is accepted. - -When property values must contain spaces, just surround the value with -quotes, either ' or ". A single space may also be backslash-quoted. If a -valuu must contain a quote character, it must be backslash-quoted or inside -quotes. Examples: - hello world (2 tokens: hello, world) - "hello world" (1 token: hello world) - "Roch'e" Compaan (2 tokens: Roch'e Compaan) - Roch\'e Compaan (2 tokens: Roch'e Compaan) - address="1 2 3" (1 token: address=1 2 3) - \\ (1 token: \) - \n\r\t (1 token: a newline, carriage-return and tab) - -When multiple nodes are specified to the roundup get or roundup set -commands, the specified properties are retrieved or set on all the listed -nodes. - -When multiple results are returned by the roundup get or roundup find -commands, they are printed one per line (default) or joined by commas (with -the -c) option. - -Where the command changes data, a login name/password is required. The -login may be specified as either "name" or "name:password". - . ROUNDUP_LOGIN environment variable - . the -u command-line option -If either the name or password is not supplied, they are obtained from the -command-line. - -Date format examples: - "2000-04-17.03:45" means <Date 2000-04-17.08:45:00> - "2000-04-17" means <Date 2000-04-17.00:00:00> - "01-25" means <Date yyyy-01-25.00:00:00> - "08-13.22:13" means <Date yyyy-08-14.03:13:00> - "11-07.09:32:43" means <Date yyyy-11-07.14:32:43> - "14:25" means <Date yyyy-mm-dd.19:25:00> - "8:47:11" means <Date yyyy-mm-dd.13:47:11> - "." means "right now" - -Command help: -''') - for name, command in self.commands.items(): - print _('%s:')%name - print _(' '), command.__doc__ - - def do_help(self, args, nl_re=re.compile('[\r\n]'), - indent_re=re.compile(r'^(\s+)\S+')): - '''Usage: help topic - Give help about topic. - - commands -- list commands - <command> -- help specific to a command - initopts -- init command options - all -- all available help - ''' - if len(args)>0: - topic = args[0] - else: - topic = 'help' - - - # try help_ methods - if self.help.has_key(topic): - self.help[topic]() - return 0 - - # try command docstrings - try: - l = self.commands.get(topic) - except KeyError: - print _('Sorry, no help for "%(topic)s"')%locals() - return 1 - - # display the help for each match, removing the docsring indent - for name, help in l: - lines = nl_re.split(help.__doc__) - print lines[0] - indent = indent_re.match(lines[1]) - if indent: indent = len(indent.group(1)) - for line in lines[1:]: - if indent: - print line[indent:] - else: - print line - return 0 - - def help_initopts(self): - import roundup.templates - templates = roundup.templates.listTemplates() - print _('Templates:'), ', '.join(templates) - import roundup.backends - backends = roundup.backends.__all__ - print _('Back ends:'), ', '.join(backends) - - def do_install(self, tracker_home, args): - '''Usage: install [template [backend [admin password]]] - Install a new Roundup tracker. - - The command will prompt for the tracker home directory (if not supplied - through TRACKER_HOME or the -i option). The template, backend and admin - password may be specified on the command-line as arguments, in that - order. - - The initialise command must be called after this command in order - to initialise the tracker's database. You may edit the tracker's - initial database contents before running that command by editing - the tracker's dbinit.py module init() function. - - See also initopts help. - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - - # make sure the tracker home can be created - parent = os.path.split(tracker_home)[0] - if not os.path.exists(parent): - raise UsageError, _('Instance home parent directory "%(parent)s"' - ' does not exist')%locals() - - # select template - import roundup.templates - templates = roundup.templates.listTemplates() - template = len(args) > 1 and args[1] or '' - if template not in templates: - print _('Templates:'), ', '.join(templates) - while template not in templates: - template = raw_input(_('Select template [classic]: ')).strip() - if not template: - template = 'classic' - - # select hyperdb backend - import roundup.backends - backends = roundup.backends.__all__ - backend = len(args) > 2 and args[2] or '' - if backend not in backends: - print _('Back ends:'), ', '.join(backends) - while backend not in backends: - backend = raw_input(_('Select backend [anydbm]: ')).strip() - if not backend: - backend = 'anydbm' - # XXX perform a unit test based on the user's selections - - # install! - init.install(tracker_home, template, backend) - - print _(''' - You should now edit the tracker configuration file: - %(config_file)s - ... at a minimum, you must set MAILHOST, MAIL_DOMAIN and ADMIN_EMAIL. - - If you wish to modify the default schema, you should also edit the database - initialisation file: - %(database_config_file)s - ... see the documentation on customizing for more information. -''')%{ - 'config_file': os.path.join(tracker_home, 'config.py'), - 'database_config_file': os.path.join(tracker_home, 'dbinit.py') -} - return 0 - - - def do_initialise(self, tracker_home, args): - '''Usage: initialise [adminpw] - Initialise a new Roundup tracker. - - The administrator details will be set at this step. - - Execute the tracker's initialisation function dbinit.init() - ''' - # password - if len(args) > 1: - adminpw = args[1] - else: - adminpw = '' - confirm = 'x' - while adminpw != confirm: - adminpw = getpass.getpass(_('Admin Password: ')) - confirm = getpass.getpass(_(' Confirm: ')) - - # make sure the tracker home is installed - if not os.path.exists(tracker_home): - raise UsageError, _('Instance home does not exist')%locals() - if not os.path.exists(os.path.join(tracker_home, 'html')): - raise UsageError, _('Instance has not been installed')%locals() - - # is there already a database? - if os.path.exists(os.path.join(tracker_home, 'db')): - print _('WARNING: The database is already initialised!') - print _('If you re-initialise it, you will lose all the data!') - ok = raw_input(_('Erase it? Y/[N]: ')).strip() - if ok.lower() != 'y': - return 0 - - # nuke it - shutil.rmtree(os.path.join(tracker_home, 'db')) - - # GO - init.initialise(tracker_home, adminpw) - - return 0 - - - def do_get(self, args): - '''Usage: get property designator[,designator]* - Get the given property of one or more designator(s). - - Retrieves the property value of the nodes specified by the designators. - ''' - if len(args) < 2: - raise UsageError, _('Not enough arguments supplied') - propname = args[0] - designators = args[1].split(',') - l = [] - for designator in designators: - # decode the node designator - try: - classname, nodeid = hyperdb.splitDesignator(designator) - except hyperdb.DesignatorError, message: - raise UsageError, message - - # get the class - cl = self.get_class(classname) - try: - if self.comma_sep: - l.append(cl.get(nodeid, propname)) - else: - print cl.get(nodeid, propname) - except IndexError: - raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals() - except KeyError: - raise UsageError, _('no such %(classname)s property ' - '"%(propname)s"')%locals() - if self.comma_sep: - print ','.join(l) - return 0 - - - def do_set(self, args, pwre = re.compile(r'{(\w+)}(.+)')): - '''Usage: set [items] property=value property=value ... - Set the given properties of one or more items(s). - - The items may be specified as a class or as a comma-separeted - list of item designators (ie "designator[,designator,...]"). - - This command sets the properties to the values for all designators - given. If the value is missing (ie. "property=") then the property is - un-set. - ''' - if len(args) < 2: - raise UsageError, _('Not enough arguments supplied') - from roundup import hyperdb - - designators = args[0].split(',') - if len(designators) == 1: - designator = designators[0] - try: - designator = hyperdb.splitDesignator(designator) - designators = [designator] - except hyperdb.DesignatorError: - cl = self.get_class(designator) - designators = [(designator, x) for x in cl.list()] - else: - try: - designators = [hyperdb.splitDesignator(x) for x in designators] - except hyperdb.DesignatorError, message: - raise UsageError, message - - # get the props from the args - props = self.props_from_args(args[1:]) - - # now do the set for all the nodes - for classname, itemid in designators: - cl = self.get_class(classname) - - properties = cl.getprops() - for key, value in props.items(): - proptype = properties[key] - if isinstance(proptype, hyperdb.Multilink): - if value is None: - props[key] = [] - else: - props[key] = value.split(',') - elif value is None: - continue - elif isinstance(proptype, hyperdb.String): - continue - elif isinstance(proptype, hyperdb.Password): - m = pwre.match(value) - if m: - # password is being given to us encrypted - p = password.Password() - p.scheme = m.group(1) - p.password = m.group(2) - props[key] = p - else: - props[key] = password.Password(value) - elif isinstance(proptype, hyperdb.Date): - try: - props[key] = date.Date(value) - except ValueError, message: - raise UsageError, '"%s": %s'%(value, message) - elif isinstance(proptype, hyperdb.Interval): - try: - props[key] = date.Interval(value) - except ValueError, message: - raise UsageError, '"%s": %s'%(value, message) - elif isinstance(proptype, hyperdb.Link): - props[key] = value - elif isinstance(proptype, hyperdb.Boolean): - props[key] = value.lower() in ('yes', 'true', 'on', '1') - elif isinstance(proptype, hyperdb.Number): - props[key] = int(value) - - # try the set - try: - apply(cl.set, (itemid, ), props) - except (TypeError, IndexError, ValueError), message: - import traceback; traceback.print_exc() - raise UsageError, message - return 0 - - def do_find(self, args): - '''Usage: find classname propname=value ... - Find the nodes of the given class with a given link property value. - - Find the nodes of the given class with a given link property value. The - value may be either the nodeid of the linked node, or its key value. - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - classname = args[0] - # get the class - cl = self.get_class(classname) - - # handle the propname=value argument - props = self.props_from_args(args[1:]) - - # if the value isn't a number, look up the linked class to get the - # number - for propname, value in props.items(): - num_re = re.compile('^\d+$') - if not num_re.match(value): - # get the property - try: - property = cl.properties[propname] - except KeyError: - raise UsageError, _('%(classname)s has no property ' - '"%(propname)s"')%locals() - - # make sure it's a link - if (not isinstance(property, hyperdb.Link) and not - isinstance(property, hyperdb.Multilink)): - raise UsageError, _('You may only "find" link properties') - - # get the linked-to class and look up the key property - link_class = self.db.getclass(property.classname) - try: - props[propname] = link_class.lookup(value) - except TypeError: - raise UsageError, _('%(classname)s has no key property"')%{ - 'classname': link_class.classname} - - # now do the find - try: - if self.comma_sep: - print ','.join(apply(cl.find, (), props)) - else: - print apply(cl.find, (), props) - except KeyError: - raise UsageError, _('%(classname)s has no property ' - '"%(propname)s"')%locals() - except (ValueError, TypeError), message: - raise UsageError, message - return 0 - - def do_specification(self, args): - '''Usage: specification classname - Show the properties for a classname. - - This lists the properties for a given class. - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - classname = args[0] - # get the class - cl = self.get_class(classname) - - # get the key property - keyprop = cl.getkey() - for key, value in cl.properties.items(): - if keyprop == key: - print _('%(key)s: %(value)s (key property)')%locals() - else: - print _('%(key)s: %(value)s')%locals() - - def do_display(self, args): - '''Usage: display designator - Show the property values for the given node. - - This lists the properties and their associated values for the given - node. - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - - # decode the node designator - try: - classname, nodeid = hyperdb.splitDesignator(args[0]) - except hyperdb.DesignatorError, message: - raise UsageError, message - - # get the class - cl = self.get_class(classname) - - # display the values - for key in cl.properties.keys(): - value = cl.get(nodeid, key) - print _('%(key)s: %(value)s')%locals() - - def do_create(self, args, pwre = re.compile(r'{(\w+)}(.+)')): - '''Usage: create classname property=value ... - Create a new entry of a given class. - - This creates a new entry of the given class using the property - name=value arguments provided on the command line after the "create" - command. - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - from roundup import hyperdb - - classname = args[0] - - # get the class - cl = self.get_class(classname) - - # now do a create - props = {} - properties = cl.getprops(protected = 0) - if len(args) == 1: - # ask for the properties - for key, value in properties.items(): - if key == 'id': continue - name = value.__class__.__name__ - if isinstance(value , hyperdb.Password): - again = None - while value != again: - value = getpass.getpass(_('%(propname)s (Password): ')%{ - 'propname': key.capitalize()}) - again = getpass.getpass(_(' %(propname)s (Again): ')%{ - 'propname': key.capitalize()}) - if value != again: print _('Sorry, try again...') - if value: - props[key] = value - else: - value = raw_input(_('%(propname)s (%(proptype)s): ')%{ - 'propname': key.capitalize(), 'proptype': name}) - if value: - props[key] = value - else: - props = self.props_from_args(args[1:]) - - # convert types - for propname, value in props.items(): - # get the property - try: - proptype = properties[propname] - except KeyError: - raise UsageError, _('%(classname)s has no property ' - '"%(propname)s"')%locals() - - if isinstance(proptype, hyperdb.Date): - try: - props[propname] = date.Date(value) - except ValueError, message: - raise UsageError, _('"%(value)s": %(message)s')%locals() - elif isinstance(proptype, hyperdb.Interval): - try: - props[propname] = date.Interval(value) - except ValueError, message: - raise UsageError, _('"%(value)s": %(message)s')%locals() - elif isinstance(proptype, hyperdb.Password): - m = pwre.match(value) - if m: - # password is being given to us encrypted - p = password.Password() - p.scheme = m.group(1) - p.password = m.group(2) - props[propname] = p - else: - props[propname] = password.Password(value) - elif isinstance(proptype, hyperdb.Multilink): - props[propname] = value.split(',') - elif isinstance(proptype, hyperdb.Boolean): - props[propname] = value.lower() in ('yes', 'true', 'on', '1') - elif isinstance(proptype, hyperdb.Number): - props[propname] = int(value) - - # check for the key property - propname = cl.getkey() - if propname and not props.has_key(propname): - raise UsageError, _('you must provide the "%(propname)s" ' - 'property.')%locals() - - # do the actual create - try: - print apply(cl.create, (), props) - except (TypeError, IndexError, ValueError), message: - raise UsageError, message - return 0 - - def do_list(self, args): - '''Usage: list classname [property] - List the instances of a class. - - Lists all instances of the given class. If the property is not - specified, the "label" property is used. The label property is tried - in order: the key, "name", "title" and then the first property, - alphabetically. - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - classname = args[0] - - # get the class - cl = self.get_class(classname) - - # figure the property - if len(args) > 1: - propname = args[1] - else: - propname = cl.labelprop() - - if self.comma_sep: - print ','.join(cl.list()) - else: - for nodeid in cl.list(): - try: - value = cl.get(nodeid, propname) - except KeyError: - raise UsageError, _('%(classname)s has no property ' - '"%(propname)s"')%locals() - print _('%(nodeid)4s: %(value)s')%locals() - return 0 - - def do_table(self, args): - '''Usage: table classname [property[,property]*] - List the instances of a class in tabular form. - - Lists all instances of the given class. If the properties are not - specified, all properties are displayed. By default, the column widths - are the width of the property names. The width may be explicitly defined - by defining the property as "name:width". For example:: - roundup> table priority id,name:10 - Id Name - 1 fatal-bug - 2 bug - 3 usability - 4 feature - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - classname = args[0] - - # get the class - cl = self.get_class(classname) - - # figure the property names to display - if len(args) > 1: - prop_names = args[1].split(',') - all_props = cl.getprops() - for spec in prop_names: - if ':' in spec: - try: - propname, width = spec.split(':') - except (ValueError, TypeError): - raise UsageError, _('"%(spec)s" not name:width')%locals() - else: - propname = spec - if not all_props.has_key(propname): - raise UsageError, _('%(classname)s has no property ' - '"%(propname)s"')%locals() - else: - prop_names = cl.getprops().keys() - - # now figure column widths - props = [] - for spec in prop_names: - if ':' in spec: - name, width = spec.split(':') - props.append((name, int(width))) - else: - props.append((spec, len(spec))) - - # now display the heading - print ' '.join([name.capitalize().ljust(width) for name,width in props]) - - # and the table data - for nodeid in cl.list(): - l = [] - for name, width in props: - if name != 'id': - try: - value = str(cl.get(nodeid, name)) - except KeyError: - # we already checked if the property is valid - a - # KeyError here means the node just doesn't have a - # value for it - value = '' - else: - value = str(nodeid) - f = '%%-%ds'%width - l.append(f%value[:width]) - print ' '.join(l) - return 0 - - def do_history(self, args): - '''Usage: history designator - Show the history entries of a designator. - - Lists the journal entries for the node identified by the designator. - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - try: - classname, nodeid = hyperdb.splitDesignator(args[0]) - except hyperdb.DesignatorError, message: - raise UsageError, message - - try: - print self.db.getclass(classname).history(nodeid) - except KeyError: - raise UsageError, _('no such class "%(classname)s"')%locals() - except IndexError: - raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals() - return 0 - - def do_commit(self, args): - '''Usage: commit - Commit all changes made to the database. - - The changes made during an interactive session are not - automatically written to the database - they must be committed - using this command. - - One-off commands on the command-line are automatically committed if - they are successful. - ''' - self.db.commit() - return 0 - - def do_rollback(self, args): - '''Usage: rollback - Undo all changes that are pending commit to the database. - - The changes made during an interactive session are not - automatically written to the database - they must be committed - manually. This command undoes all those changes, so a commit - immediately after would make no changes to the database. - ''' - self.db.rollback() - return 0 - - def do_retire(self, args): - '''Usage: retire designator[,designator]* - Retire the node specified by designator. - - This action indicates that a particular node is not to be retrieved by - the list or find commands, and its key value may be re-used. - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - designators = args[0].split(',') - for designator in designators: - try: - classname, nodeid = hyperdb.splitDesignator(designator) - except hyperdb.DesignatorError, message: - raise UsageError, message - try: - self.db.getclass(classname).retire(nodeid) - except KeyError: - raise UsageError, _('no such class "%(classname)s"')%locals() - except IndexError: - raise UsageError, _('no such %(classname)s node "%(nodeid)s"')%locals() - return 0 - - def do_export(self, args): - '''Usage: export [class[,class]] export_dir - Export the database to colon-separated-value files. - - This action exports the current data from the database into - colon-separated-value files that are placed in the nominated - destination directory. The journals are not exported. - ''' - # we need the CSV module - if csv is None: - raise UsageError, \ - _('Sorry, you need the csv module to use this function.\n' - 'Get it from: http://www.object-craft.com.au/projects/csv/') - - # grab the directory to export to - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - dir = args[-1] - - # get the list of classes to export - if len(args) == 2: - classes = args[0].split(',') - else: - classes = self.db.classes.keys() - - # use the csv parser if we can - it's faster - p = csv.parser(field_sep=':') - - # do all the classes specified - for classname in classes: - cl = self.get_class(classname) - f = open(os.path.join(dir, classname+'.csv'), 'w') - properties = cl.getprops() - propnames = properties.keys() - propnames.sort() - print >> f, p.join(propnames) - - # all nodes for this class - for nodeid in cl.list(): - print >>f, p.join(cl.export_list(propnames, nodeid)) - return 0 - - def do_import(self, args): - '''Usage: import import_dir - Import a database from the directory containing CSV files, one per - class to import. - - The files must define the same properties as the class (including having - a "header" line with those property names.) - - The imported nodes will have the same nodeid as defined in the - import file, thus replacing any existing content. - - The new nodes are added to the existing database - if you want to - create a new database using the imported data, then create a new - database (or, tediously, retire all the old data.) - ''' - if len(args) < 1: - raise UsageError, _('Not enough arguments supplied') - if csv is None: - raise UsageError, \ - _('Sorry, you need the csv module to use this function.\n' - 'Get it from: http://www.object-craft.com.au/projects/csv/') - - from roundup import hyperdb - - for file in os.listdir(args[0]): - f = open(os.path.join(args[0], file)) - - # get the classname - classname = os.path.splitext(file)[0] - - # ensure that the properties and the CSV file headings match - cl = self.get_class(classname) - p = csv.parser(field_sep=':') - file_props = p.parse(f.readline()) - properties = cl.getprops() - propnames = properties.keys() - propnames.sort() - m = file_props[:] - m.sort() - if m != propnames: - raise UsageError, _('Import file doesn\'t define the same ' - 'properties as "%(arg0)s".')%{'arg0': args[0]} - - # loop through the file and create a node for each entry - maxid = 1 - while 1: - line = f.readline() - if not line: break - - # parse lines until we get a complete entry - while 1: - l = p.parse(line) - if l: break - line = f.readline() - if not line: - raise ValueError, "Unexpected EOF during CSV parse" - - # do the import and figure the current highest nodeid - maxid = max(maxid, int(cl.import_list(propnames, l))) - - print 'setting', classname, maxid+1 - self.db.setid(classname, str(maxid+1)) - return 0 - - def do_pack(self, args): - '''Usage: pack period | date - -Remove journal entries older than a period of time specified or -before a certain date. - -A period is specified using the suffixes "y", "m", and "d". The -suffix "w" (for "week") means 7 days. - - "3y" means three years - "2y 1m" means two years and one month - "1m 25d" means one month and 25 days - "2w 3d" means two weeks and three days - -Date format is "YYYY-MM-DD" eg: - 2001-01-01 - - ''' - if len(args) <> 1: - raise UsageError, _('Not enough arguments supplied') - - # are we dealing with a period or a date - value = args[0] - date_re = re.compile(r''' - (?P<date>\d\d\d\d-\d\d?-\d\d?)? # yyyy-mm-dd - (?P<period>(\d+y\s*)?(\d+m\s*)?(\d+d\s*)?)? - ''', re.VERBOSE) - m = date_re.match(value) - if not m: - raise ValueError, _('Invalid format') - m = m.groupdict() - if m['period']: - pack_before = date.Date(". - %s"%value) - elif m['date']: - pack_before = date.Date(value) - self.db.pack(pack_before) - return 0 - - def do_reindex(self, args): - '''Usage: reindex - Re-generate a tracker's search indexes. - - This will re-generate the search indexes for a tracker. This will - typically happen automatically. - ''' - self.db.indexer.force_reindex() - self.db.reindex() - return 0 - - def do_security(self, args): - '''Usage: security [Role name] - Display the Permissions available to one or all Roles. - ''' - if len(args) == 1: - role = args[0] - try: - roles = [(args[0], self.db.security.role[args[0]])] - except KeyError: - print _('No such Role "%(role)s"')%locals() - return 1 - else: - roles = self.db.security.role.items() - role = self.db.config.NEW_WEB_USER_ROLES - if ',' in role: - print _('New Web users get the Roles "%(role)s"')%locals() - else: - print _('New Web users get the Role "%(role)s"')%locals() - role = self.db.config.NEW_EMAIL_USER_ROLES - if ',' in role: - print _('New Email users get the Roles "%(role)s"')%locals() - else: - print _('New Email users get the Role "%(role)s"')%locals() - roles.sort() - for rolename, role in roles: - print _('Role "%(name)s":')%role.__dict__ - for permission in role.permissions: - if permission.klass: - print _(' %(description)s (%(name)s for "%(klass)s" ' - 'only)')%permission.__dict__ - else: - print _(' %(description)s (%(name)s)')%permission.__dict__ - return 0 - - def run_command(self, args): - '''Run a single command - ''' - command = args[0] - - # handle help now - if command == 'help': - if len(args)>1: - self.do_help(args[1:]) - return 0 - self.do_help(['help']) - return 0 - if command == 'morehelp': - self.do_help(['help']) - self.help_commands() - self.help_all() - return 0 - - # figure what the command is - try: - functions = self.commands.get(command) - except KeyError: - # not a valid command - print _('Unknown command "%(command)s" ("help commands" for a ' - 'list)')%locals() - return 1 - - # check for multiple matches - if len(functions) > 1: - print _('Multiple commands match "%(command)s": %(list)s')%{'command': - command, 'list': ', '.join([i[0] for i in functions])} - return 1 - command, function = functions[0] - - # make sure we have a tracker_home - while not self.tracker_home: - self.tracker_home = raw_input(_('Enter tracker home: ')).strip() - - # before we open the db, we may be doing an install or init - if command == 'initialise': - try: - return self.do_initialise(self.tracker_home, args) - except UsageError, message: - print _('Error: %(message)s')%locals() - return 1 - elif command == 'install': - try: - return self.do_install(self.tracker_home, args) - except UsageError, message: - print _('Error: %(message)s')%locals() - return 1 - - # get the tracker - try: - tracker = roundup.instance.open(self.tracker_home) - except ValueError, message: - self.tracker_home = '' - print _("Error: Couldn't open tracker: %(message)s")%locals() - return 1 - - # only open the database once! - if not self.db: - self.db = tracker.open('admin') - - # do the command - ret = 0 - try: - ret = function(args[1:]) - except UsageError, message: - print _('Error: %(message)s')%locals() - print - print function.__doc__ - ret = 1 - except: - import traceback - traceback.print_exc() - ret = 1 - return ret - - def interactive(self): - '''Run in an interactive mode - ''' - print _('Roundup %s ready for input.'%roundup_version) - print _('Type "help" for help.') - try: - import readline - except ImportError: - print _('Note: command history and editing not available') - - while 1: - try: - command = raw_input(_('roundup> ')) - except EOFError: - print _('exit...') - break - if not command: continue - args = token.token_split(command) - if not args: continue - if args[0] in ('quit', 'exit'): break - self.run_command(args) - - # exit.. check for transactions - if self.db and self.db.transactions: - commit = raw_input(_('There are unsaved changes. Commit them (y/N)? ')) - if commit and commit[0].lower() == 'y': - self.db.commit() - return 0 - - def main(self): - try: - opts, args = getopt.getopt(sys.argv[1:], 'i:u:hc') - except getopt.GetoptError, e: - self.usage(str(e)) - return 1 - - # handle command-line args - self.tracker_home = os.environ.get('TRACKER_HOME', '') - # TODO: reinstate the user/password stuff (-u arg too) - name = password = '' - if os.environ.has_key('ROUNDUP_LOGIN'): - l = os.environ['ROUNDUP_LOGIN'].split(':') - name = l[0] - if len(l) > 1: - password = l[1] - self.comma_sep = 0 - for opt, arg in opts: - if opt == '-h': - self.usage() - return 0 - if opt == '-i': - self.tracker_home = arg - if opt == '-c': - self.comma_sep = 1 - - # if no command - go interactive - # wrap in a try/finally so we always close off the db - ret = 0 - try: - if not args: - self.interactive() - else: - ret = self.run_command(args) - if self.db: self.db.commit() - return ret - finally: - if self.db: - self.db.close() - -if __name__ == '__main__': - tool = AdminTool() - sys.exit(tool.main()) - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/backends/__init__.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,85 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: __init__.py,v 1.18 2002-09-23 12:02:53 richard Exp $ - -__all__ = [] - -try: - import sys, anydbm - if not hasattr(sys, 'version_info') or sys.version_info < (2,1,2): - import dumbdbm - # dumbdbm only works in python 2.1.2+ - assert anydbm._defaultmod != dumbdbm - del anydbm - del dumbdbm -except AssertionError: - print "WARNING: you should upgrade to python 2.1.3" -except ImportError, message: - if str(message) != 'No module named anydbm': raise -else: - import back_anydbm - anydbm = back_anydbm - __all__.append('anydbm') - -try: - import gadfly - import gadfly.client -except ImportError, message: - if str(message) != 'No module named gadfly': raise -else: - import back_gadfly - gadfly = back_gadfly - __all__.append('gadfly') - -try: - import sqlite -except ImportError, message: - if str(message) != 'No module named sqlite': raise -else: - import back_sqlite - sqlite = back_sqlite - __all__.append('sqlite') - -try: - import bsddb -except ImportError, message: - if str(message) != 'No module named bsddb': raise -else: - import back_bsddb - bsddb = back_bsddb - __all__.append('bsddb') - -try: - import bsddb3 -except ImportError, message: - if str(message) != 'No module named bsddb3': raise -else: - import back_bsddb3 - bsddb3 = back_bsddb3 - __all__.append('bsddb3') - -try: - import metakit -except ImportError, message: - if str(message) != 'No module named metakit': raise -else: - import back_metakit - metakit = back_metakit - __all__.append('metakit') - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/backends/back_anydbm.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1972 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -#$Id: back_anydbm.py,v 1.86 2002-09-26 03:04:24 richard Exp $ -''' -This module defines a backend that saves the hyperdatabase in a database -chosen by anydbm. It is guaranteed to always be available in python -versions >2.1.1 (the dumbdbm fallback in 2.1.1 and earlier has several -serious bugs, and is not available) -''' - -import whichdb, anydbm, os, marshal, re, weakref, string, copy -from roundup import hyperdb, date, password, roundupdb, security -from blobfiles import FileStorage -from sessions import Sessions -from roundup.indexer import Indexer -from locking import acquire_lock, release_lock -from roundup.hyperdb import String, Password, Date, Interval, Link, \ - Multilink, DatabaseError, Boolean, Number - -# -# Now the database -# -class Database(FileStorage, hyperdb.Database, roundupdb.Database): - '''A database for storing records containing flexible data types. - - Transaction stuff TODO: - . check the timestamp of the class file and nuke the cache if it's - modified. Do some sort of conflict checking on the dirty stuff. - . perhaps detect write collisions (related to above)? - - ''' - def __init__(self, config, journaltag=None): - '''Open a hyperdatabase given a specifier to some storage. - - The 'storagelocator' is obtained from config.DATABASE. - The meaning of 'storagelocator' depends on the particular - implementation of the hyperdatabase. It could be a file name, - a directory path, a socket descriptor for a connection to a - database over the network, etc. - - The 'journaltag' is a token that will be attached to the journal - entries for any edits done on the database. If 'journaltag' is - None, the database is opened in read-only mode: the Class.create(), - Class.set(), and Class.retire() methods are disabled. - ''' - self.config, self.journaltag = config, journaltag - self.dir = config.DATABASE - self.classes = {} - self.cache = {} # cache of nodes loaded or created - self.dirtynodes = {} # keep track of the dirty nodes by class - self.newnodes = {} # keep track of the new nodes by class - self.destroyednodes = {}# keep track of the destroyed nodes by class - self.transactions = [] - self.indexer = Indexer(self.dir) - self.sessions = Sessions(self.config) - self.security = security.Security(self) - # ensure files are group readable and writable - os.umask(0002) - - def post_init(self): - ''' Called once the schema initialisation has finished. - ''' - # reindex the db if necessary - if self.indexer.should_reindex(): - self.reindex() - - # figure the "curuserid" - if self.journaltag is None: - self.curuserid = None - elif self.journaltag == 'admin': - # admin user may not exist, but always has ID 1 - self.curuserid = '1' - else: - self.curuserid = self.user.lookup(self.journaltag) - - def reindex(self): - for klass in self.classes.values(): - for nodeid in klass.list(): - klass.index(nodeid) - self.indexer.save_index() - - def __repr__(self): - return '<back_anydbm instance at %x>'%id(self) - - # - # Classes - # - def __getattr__(self, classname): - '''A convenient way of calling self.getclass(classname).''' - if self.classes.has_key(classname): - if __debug__: - print >>hyperdb.DEBUG, '__getattr__', (self, classname) - return self.classes[classname] - raise AttributeError, classname - - def addclass(self, cl): - if __debug__: - print >>hyperdb.DEBUG, 'addclass', (self, cl) - cn = cl.classname - if self.classes.has_key(cn): - raise ValueError, cn - self.classes[cn] = cl - - def getclasses(self): - '''Return a list of the names of all existing classes.''' - if __debug__: - print >>hyperdb.DEBUG, 'getclasses', (self,) - l = self.classes.keys() - l.sort() - return l - - def getclass(self, classname): - '''Get the Class object representing a particular class. - - If 'classname' is not a valid class name, a KeyError is raised. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'getclass', (self, classname) - try: - return self.classes[classname] - except KeyError: - raise KeyError, 'There is no class called "%s"'%classname - - # - # Class DBs - # - def clear(self): - '''Delete all database contents - ''' - if __debug__: - print >>hyperdb.DEBUG, 'clear', (self,) - for cn in self.classes.keys(): - for dummy in 'nodes', 'journals': - path = os.path.join(self.dir, 'journals.%s'%cn) - if os.path.exists(path): - os.remove(path) - elif os.path.exists(path+'.db'): # dbm appends .db - os.remove(path+'.db') - - def getclassdb(self, classname, mode='r'): - ''' grab a connection to the class db that will be used for - multiple actions - ''' - if __debug__: - print >>hyperdb.DEBUG, 'getclassdb', (self, classname, mode) - return self.opendb('nodes.%s'%classname, mode) - - def determine_db_type(self, path): - ''' determine which DB wrote the class file - ''' - db_type = '' - if os.path.exists(path): - db_type = whichdb.whichdb(path) - if not db_type: - raise DatabaseError, "Couldn't identify database type" - elif os.path.exists(path+'.db'): - # if the path ends in '.db', it's a dbm database, whether - # anydbm says it's dbhash or not! - db_type = 'dbm' - return db_type - - def opendb(self, name, mode): - '''Low-level database opener that gets around anydbm/dbm - eccentricities. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'opendb', (self, name, mode) - - # figure the class db type - path = os.path.join(os.getcwd(), self.dir, name) - db_type = self.determine_db_type(path) - - # new database? let anydbm pick the best dbm - if not db_type: - if __debug__: - print >>hyperdb.DEBUG, "opendb anydbm.open(%r, 'c')"%path - return anydbm.open(path, 'c') - - # open the database with the correct module - try: - dbm = __import__(db_type) - except ImportError: - raise DatabaseError, \ - "Couldn't open database - the required module '%s'"\ - " is not available"%db_type - if __debug__: - print >>hyperdb.DEBUG, "opendb %r.open(%r, %r)"%(db_type, path, - mode) - return dbm.open(path, mode) - - def lockdb(self, name): - ''' Lock a database file - ''' - path = os.path.join(os.getcwd(), self.dir, '%s.lock'%name) - return acquire_lock(path) - - # - # Node IDs - # - def newid(self, classname): - ''' Generate a new id for the given class - ''' - # open the ids DB - create if if doesn't exist - lock = self.lockdb('_ids') - db = self.opendb('_ids', 'c') - if db.has_key(classname): - newid = db[classname] = str(int(db[classname]) + 1) - else: - # the count() bit is transitional - older dbs won't start at 1 - newid = str(self.getclass(classname).count()+1) - db[classname] = newid - db.close() - release_lock(lock) - return newid - - def setid(self, classname, setid): - ''' Set the id counter: used during import of database - ''' - # open the ids DB - create if if doesn't exist - lock = self.lockdb('_ids') - db = self.opendb('_ids', 'c') - db[classname] = str(setid) - db.close() - release_lock(lock) - - # - # Nodes - # - def addnode(self, classname, nodeid, node): - ''' add the specified node to its class's db - ''' - if __debug__: - print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node) - - # we'll be supplied these props if we're doing an import - if not node.has_key('creator'): - # add in the "calculated" properties (dupe so we don't affect - # calling code's node assumptions) - node = node.copy() - node['creator'] = self.curuserid - node['creation'] = node['activity'] = date.Date() - - self.newnodes.setdefault(classname, {})[nodeid] = 1 - self.cache.setdefault(classname, {})[nodeid] = node - self.savenode(classname, nodeid, node) - - def setnode(self, classname, nodeid, node): - ''' change the specified node - ''' - if __debug__: - print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, node) - self.dirtynodes.setdefault(classname, {})[nodeid] = 1 - - # update the activity time (dupe so we don't affect - # calling code's node assumptions) - node = node.copy() - node['activity'] = date.Date() - - # can't set without having already loaded the node - self.cache[classname][nodeid] = node - self.savenode(classname, nodeid, node) - - def savenode(self, classname, nodeid, node): - ''' perform the saving of data specified by the set/addnode - ''' - if __debug__: - print >>hyperdb.DEBUG, 'savenode', (self, classname, nodeid, node) - self.transactions.append((self.doSaveNode, (classname, nodeid, node))) - - def getnode(self, classname, nodeid, db=None, cache=1): - ''' get a node from the database - ''' - if __debug__: - print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid, db) - if cache: - # try the cache - cache_dict = self.cache.setdefault(classname, {}) - if cache_dict.has_key(nodeid): - if __debug__: - print >>hyperdb.TRACE, 'get %s %s cached'%(classname, - nodeid) - return cache_dict[nodeid] - - if __debug__: - print >>hyperdb.TRACE, 'get %s %s'%(classname, nodeid) - - # get from the database and save in the cache - if db is None: - db = self.getclassdb(classname) - if not db.has_key(nodeid): - raise IndexError, "no such %s %s"%(classname, nodeid) - - # check the uncommitted, destroyed nodes - if (self.destroyednodes.has_key(classname) and - self.destroyednodes[classname].has_key(nodeid)): - raise IndexError, "no such %s %s"%(classname, nodeid) - - # decode - res = marshal.loads(db[nodeid]) - - # reverse the serialisation - res = self.unserialise(classname, res) - - # store off in the cache dict - if cache: - cache_dict[nodeid] = res - - return res - - def destroynode(self, classname, nodeid): - '''Remove a node from the database. Called exclusively by the - destroy() method on Class. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid) - - # remove from cache and newnodes if it's there - if (self.cache.has_key(classname) and - self.cache[classname].has_key(nodeid)): - del self.cache[classname][nodeid] - if (self.newnodes.has_key(classname) and - self.newnodes[classname].has_key(nodeid)): - del self.newnodes[classname][nodeid] - - # see if there's any obvious commit actions that we should get rid of - for entry in self.transactions[:]: - if entry[1][:2] == (classname, nodeid): - self.transactions.remove(entry) - - # add to the destroyednodes map - self.destroyednodes.setdefault(classname, {})[nodeid] = 1 - - # add the destroy commit action - self.transactions.append((self.doDestroyNode, (classname, nodeid))) - - def serialise(self, classname, node): - '''Copy the node contents, converting non-marshallable data into - marshallable data. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'serialise', classname, node - properties = self.getclass(classname).getprops() - d = {} - for k, v in node.items(): - # if the property doesn't exist, or is the "retired" flag then - # it won't be in the properties dict - if not properties.has_key(k): - d[k] = v - continue - - # get the property spec - prop = properties[k] - - if isinstance(prop, Password): - d[k] = str(v) - elif isinstance(prop, Date) and v is not None: - d[k] = v.serialise() - elif isinstance(prop, Interval) and v is not None: - d[k] = v.serialise() - else: - d[k] = v - return d - - def unserialise(self, classname, node): - '''Decode the marshalled node data - ''' - if __debug__: - print >>hyperdb.DEBUG, 'unserialise', classname, node - properties = self.getclass(classname).getprops() - d = {} - for k, v in node.items(): - # if the property doesn't exist, or is the "retired" flag then - # it won't be in the properties dict - if not properties.has_key(k): - d[k] = v - continue - - # get the property spec - prop = properties[k] - - if isinstance(prop, Date) and v is not None: - d[k] = date.Date(v) - elif isinstance(prop, Interval) and v is not None: - d[k] = date.Interval(v) - elif isinstance(prop, Password): - p = password.Password() - p.unpack(v) - d[k] = p - else: - d[k] = v - return d - - def hasnode(self, classname, nodeid, db=None): - ''' determine if the database has a given node - ''' - if __debug__: - print >>hyperdb.DEBUG, 'hasnode', (self, classname, nodeid, db) - - # try the cache - cache = self.cache.setdefault(classname, {}) - if cache.has_key(nodeid): - if __debug__: - print >>hyperdb.TRACE, 'has %s %s cached'%(classname, nodeid) - return 1 - if __debug__: - print >>hyperdb.TRACE, 'has %s %s'%(classname, nodeid) - - # not in the cache - check the database - if db is None: - db = self.getclassdb(classname) - res = db.has_key(nodeid) - return res - - def countnodes(self, classname, db=None): - if __debug__: - print >>hyperdb.DEBUG, 'countnodes', (self, classname, db) - - count = 0 - - # include the uncommitted nodes - if self.newnodes.has_key(classname): - count += len(self.newnodes[classname]) - if self.destroyednodes.has_key(classname): - count -= len(self.destroyednodes[classname]) - - # and count those in the DB - if db is None: - db = self.getclassdb(classname) - count = count + len(db.keys()) - return count - - def getnodeids(self, classname, db=None): - if __debug__: - print >>hyperdb.DEBUG, 'getnodeids', (self, classname, db) - - res = [] - - # start off with the new nodes - if self.newnodes.has_key(classname): - res += self.newnodes[classname].keys() - - if db is None: - db = self.getclassdb(classname) - res = res + db.keys() - - # remove the uncommitted, destroyed nodes - if self.destroyednodes.has_key(classname): - for nodeid in self.destroyednodes[classname].keys(): - if db.has_key(nodeid): - res.remove(nodeid) - - return res - - - # - # Files - special node properties - # inherited from FileStorage - - # - # Journal - # - def addjournal(self, classname, nodeid, action, params, creator=None, - creation=None): - ''' Journal the Action - 'action' may be: - - 'create' or 'set' -- 'params' is a dictionary of property values - 'link' or 'unlink' -- 'params' is (classname, nodeid, propname) - 'retire' -- 'params' is None - ''' - if __debug__: - print >>hyperdb.DEBUG, 'addjournal', (self, classname, nodeid, - action, params, creator, creation) - self.transactions.append((self.doSaveJournal, (classname, nodeid, - action, params, creator, creation))) - - def getjournal(self, classname, nodeid): - ''' get the journal for id - - Raise IndexError if the node doesn't exist (as per history()'s - API) - ''' - if __debug__: - print >>hyperdb.DEBUG, 'getjournal', (self, classname, nodeid) - # attempt to open the journal - in some rare cases, the journal may - # not exist - try: - db = self.opendb('journals.%s'%classname, 'r') - except anydbm.error, error: - if str(error) == "need 'c' or 'n' flag to open new db": - raise IndexError, 'no such %s %s'%(classname, nodeid) - elif error.args[0] != 2: - raise - raise IndexError, 'no such %s %s'%(classname, nodeid) - try: - journal = marshal.loads(db[nodeid]) - except KeyError: - db.close() - raise IndexError, 'no such %s %s'%(classname, nodeid) - db.close() - res = [] - for nodeid, date_stamp, user, action, params in journal: - res.append((nodeid, date.Date(date_stamp), user, action, params)) - return res - - def pack(self, pack_before): - ''' Delete all journal entries except "create" before 'pack_before'. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'packjournal', (self, pack_before) - - pack_before = pack_before.serialise() - for classname in self.getclasses(): - # get the journal db - db_name = 'journals.%s'%classname - path = os.path.join(os.getcwd(), self.dir, classname) - db_type = self.determine_db_type(path) - db = self.opendb(db_name, 'w') - - for key in db.keys(): - # get the journal for this db entry - journal = marshal.loads(db[key]) - l = [] - last_set_entry = None - for entry in journal: - # unpack the entry - (nodeid, date_stamp, self.journaltag, action, - params) = entry - # if the entry is after the pack date, _or_ the initial - # create entry, then it stays - if date_stamp > pack_before or action == 'create': - l.append(entry) - db[key] = marshal.dumps(l) - if db_type == 'gdbm': - db.reorganize() - db.close() - - - # - # Basic transaction support - # - def commit(self): - ''' Commit the current transactions. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'commit', (self,) - # TODO: lock the DB - - # keep a handle to all the database files opened - self.databases = {} - - # now, do all the transactions - reindex = {} - for method, args in self.transactions: - reindex[method(*args)] = 1 - - # now close all the database files - for db in self.databases.values(): - db.close() - del self.databases - # TODO: unlock the DB - - # reindex the nodes that request it - for classname, nodeid in filter(None, reindex.keys()): - print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid) - self.getclass(classname).index(nodeid) - - # save the indexer state - self.indexer.save_index() - - self.clearCache() - - def clearCache(self): - # all transactions committed, back to normal - self.cache = {} - self.dirtynodes = {} - self.newnodes = {} - self.destroyednodes = {} - self.transactions = [] - - def getCachedClassDB(self, classname): - ''' get the class db, looking in our cache of databases for commit - ''' - # get the database handle - db_name = 'nodes.%s'%classname - if not self.databases.has_key(db_name): - self.databases[db_name] = self.getclassdb(classname, 'c') - return self.databases[db_name] - - def doSaveNode(self, classname, nodeid, node): - if __debug__: - print >>hyperdb.DEBUG, 'doSaveNode', (self, classname, nodeid, - node) - - db = self.getCachedClassDB(classname) - - # now save the marshalled data - db[nodeid] = marshal.dumps(self.serialise(classname, node)) - - # return the classname, nodeid so we reindex this content - return (classname, nodeid) - - def getCachedJournalDB(self, classname): - ''' get the journal db, looking in our cache of databases for commit - ''' - # get the database handle - db_name = 'journals.%s'%classname - if not self.databases.has_key(db_name): - self.databases[db_name] = self.opendb(db_name, 'c') - return self.databases[db_name] - - def doSaveJournal(self, classname, nodeid, action, params, creator, - creation): - # serialise the parameters now if necessary - if isinstance(params, type({})): - if action in ('set', 'create'): - params = self.serialise(classname, params) - - # handle supply of the special journalling parameters (usually - # supplied on importing an existing database) - if creator: - journaltag = creator - else: - journaltag = self.curuserid - if creation: - journaldate = creation.serialise() - else: - journaldate = date.Date().serialise() - - # create the journal entry - entry = (nodeid, journaldate, journaltag, action, params) - - if __debug__: - print >>hyperdb.DEBUG, 'doSaveJournal', entry - - db = self.getCachedJournalDB(classname) - - # now insert the journal entry - if db.has_key(nodeid): - # append to existing - s = db[nodeid] - l = marshal.loads(s) - l.append(entry) - else: - l = [entry] - - db[nodeid] = marshal.dumps(l) - - def doDestroyNode(self, classname, nodeid): - if __debug__: - print >>hyperdb.DEBUG, 'doDestroyNode', (self, classname, nodeid) - - # delete from the class database - db = self.getCachedClassDB(classname) - if db.has_key(nodeid): - del db[nodeid] - - # delete from the database - db = self.getCachedJournalDB(classname) - if db.has_key(nodeid): - del db[nodeid] - - # return the classname, nodeid so we reindex this content - return (classname, nodeid) - - def rollback(self): - ''' Reverse all actions from the current transaction. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'rollback', (self, ) - for method, args in self.transactions: - # delete temporary files - if method == self.doStoreFile: - self.rollbackStoreFile(*args) - self.cache = {} - self.dirtynodes = {} - self.newnodes = {} - self.destroyednodes = {} - self.transactions = [] - - def close(self): - ''' Nothing to do - ''' - pass - -_marker = [] -class Class(hyperdb.Class): - '''The handle to a particular class of nodes in a hyperdatabase.''' - - def __init__(self, db, classname, **properties): - '''Create a new class with a given name and property specification. - - 'classname' must not collide with the name of an existing class, - or a ValueError is raised. The keyword arguments in 'properties' - must map names to property objects, or a TypeError is raised. - ''' - if (properties.has_key('creation') or properties.has_key('activity') - or properties.has_key('creator')): - raise ValueError, '"creation", "activity" and "creator" are '\ - 'reserved' - - self.classname = classname - self.properties = properties - self.db = weakref.proxy(db) # use a weak ref to avoid circularity - self.key = '' - - # should we journal changes (default yes) - self.do_journal = 1 - - # do the db-related init stuff - db.addclass(self) - - self.auditors = {'create': [], 'set': [], 'retire': []} - self.reactors = {'create': [], 'set': [], 'retire': []} - - def enableJournalling(self): - '''Turn journalling on for this class - ''' - self.do_journal = 1 - - def disableJournalling(self): - '''Turn journalling off for this class - ''' - self.do_journal = 0 - - # Editing nodes: - - def create(self, **propvalues): - '''Create a new node of this class and return its id. - - The keyword arguments in 'propvalues' map property names to values. - - The values of arguments must be acceptable for the types of their - corresponding properties or a TypeError is raised. - - If this class has a key property, it must be present and its value - must not collide with other key strings or a ValueError is raised. - - Any other properties on this class that are missing from the - 'propvalues' dictionary are set to None. - - If an id in a link or multilink property does not refer to a valid - node, an IndexError is raised. - - These operations trigger detectors and can be vetoed. Attempts - to modify the "creation" or "activity" properties cause a KeyError. - ''' - if propvalues.has_key('id'): - raise KeyError, '"id" is reserved' - - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - - if propvalues.has_key('creation') or propvalues.has_key('activity'): - raise KeyError, '"creation" and "activity" are reserved' - - self.fireAuditors('create', None, propvalues) - - # new node's id - newid = self.db.newid(self.classname) - - # validate propvalues - num_re = re.compile('^\d+$') - for key, value in propvalues.items(): - if key == self.key: - try: - self.lookup(value) - except KeyError: - pass - else: - raise ValueError, 'node with key "%s" exists'%value - - # try to handle this property - try: - prop = self.properties[key] - except KeyError: - raise KeyError, '"%s" has no property "%s"'%(self.classname, - key) - - if value is not None and isinstance(prop, Link): - if type(value) != type(''): - raise ValueError, 'link value must be String' - link_class = self.properties[key].classname - # if it isn't a number, it's a key - if not num_re.match(value): - try: - value = self.db.classes[link_class].lookup(value) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - key, value, link_class) - elif not self.db.getclass(link_class).hasnode(value): - raise IndexError, '%s has no node %s'%(link_class, value) - - # save off the value - propvalues[key] = value - - # register the link with the newly linked node - if self.do_journal and self.properties[key].do_journal: - self.db.addjournal(link_class, value, 'link', - (self.classname, newid, key)) - - elif isinstance(prop, Multilink): - if type(value) != type([]): - raise TypeError, 'new property "%s" not a list of ids'%key - - # clean up and validate the list of links - link_class = self.properties[key].classname - l = [] - for entry in value: - if type(entry) != type(''): - raise ValueError, '"%s" multilink value (%r) '\ - 'must contain Strings'%(key, value) - # if it isn't a number, it's a key - if not num_re.match(entry): - try: - entry = self.db.classes[link_class].lookup(entry) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - key, entry, self.properties[key].classname) - l.append(entry) - value = l - propvalues[key] = value - - # handle additions - for nodeid in value: - if not self.db.getclass(link_class).hasnode(nodeid): - raise IndexError, '%s has no node %s'%(link_class, - nodeid) - # register the link with the newly linked node - if self.do_journal and self.properties[key].do_journal: - self.db.addjournal(link_class, nodeid, 'link', - (self.classname, newid, key)) - - elif isinstance(prop, String): - if type(value) != type(''): - raise TypeError, 'new property "%s" not a string'%key - - elif isinstance(prop, Password): - if not isinstance(value, password.Password): - raise TypeError, 'new property "%s" not a Password'%key - - elif isinstance(prop, Date): - if value is not None and not isinstance(value, date.Date): - raise TypeError, 'new property "%s" not a Date'%key - - elif isinstance(prop, Interval): - if value is not None and not isinstance(value, date.Interval): - raise TypeError, 'new property "%s" not an Interval'%key - - elif value is not None and isinstance(prop, Number): - try: - float(value) - except ValueError: - raise TypeError, 'new property "%s" not numeric'%key - - elif value is not None and isinstance(prop, Boolean): - try: - int(value) - except ValueError: - raise TypeError, 'new property "%s" not boolean'%key - - # make sure there's data where there needs to be - for key, prop in self.properties.items(): - if propvalues.has_key(key): - continue - if key == self.key: - raise ValueError, 'key property "%s" is required'%key - if isinstance(prop, Multilink): - propvalues[key] = [] - else: - propvalues[key] = None - - # done - self.db.addnode(self.classname, newid, propvalues) - if self.do_journal: - self.db.addjournal(self.classname, newid, 'create', propvalues) - - self.fireReactors('create', newid, None) - - return newid - - def export_list(self, propnames, nodeid): - ''' Export a node - generate a list of CSV-able data in the order - specified by propnames for the given node. - ''' - properties = self.getprops() - l = [] - for prop in propnames: - proptype = properties[prop] - value = self.get(nodeid, prop) - # "marshal" data where needed - if value is None: - pass - elif isinstance(proptype, hyperdb.Date): - value = value.get_tuple() - elif isinstance(proptype, hyperdb.Interval): - value = value.get_tuple() - elif isinstance(proptype, hyperdb.Password): - value = str(value) - l.append(repr(value)) - return l - - def import_list(self, propnames, proplist): - ''' Import a node - all information including "id" is present and - should not be sanity checked. Triggers are not triggered. The - journal should be initialised using the "creator" and "created" - information. - - Return the nodeid of the node imported. - ''' - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - properties = self.getprops() - - # make the new node's property map - d = {} - for i in range(len(propnames)): - # Use eval to reverse the repr() used to output the CSV - value = eval(proplist[i]) - - # Figure the property for this column - propname = propnames[i] - prop = properties[propname] - - # "unmarshal" where necessary - if propname == 'id': - newid = value - continue - elif value is None: - # don't set Nones - continue - elif isinstance(prop, hyperdb.Date): - value = date.Date(value) - elif isinstance(prop, hyperdb.Interval): - value = date.Interval(value) - elif isinstance(prop, hyperdb.Password): - pwd = password.Password() - pwd.unpack(value) - value = pwd - d[propname] = value - - # add the node and journal - self.db.addnode(self.classname, newid, d) - - # extract the journalling stuff and nuke it - if d.has_key('creator'): - creator = d['creator'] - del d['creator'] - else: - creator = None - if d.has_key('creation'): - creation = d['creation'] - del d['creation'] - else: - creation = None - if d.has_key('activity'): - del d['activity'] - self.db.addjournal(self.classname, newid, 'create', d, creator, - creation) - return newid - - def get(self, nodeid, propname, default=_marker, cache=1): - '''Get the value of a property on an existing node of this class. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. 'propname' must be the name of a property - of this class or a KeyError is raised. - - 'cache' indicates whether the transaction cache should be queried - for the node. If the node has been modified and you need to - determine what its values prior to modification are, you need to - set cache=0. - - Attempts to get the "creation" or "activity" properties should - do the right thing. - ''' - if propname == 'id': - return nodeid - - # get the node's dict - d = self.db.getnode(self.classname, nodeid, cache=cache) - - # check for one of the special props - if propname == 'creation': - if d.has_key('creation'): - return d['creation'] - if not self.do_journal: - raise ValueError, 'Journalling is disabled for this class' - journal = self.db.getjournal(self.classname, nodeid) - if journal: - return self.db.getjournal(self.classname, nodeid)[0][1] - else: - # on the strange chance that there's no journal - return date.Date() - if propname == 'activity': - if d.has_key('activity'): - return d['activity'] - if not self.do_journal: - raise ValueError, 'Journalling is disabled for this class' - journal = self.db.getjournal(self.classname, nodeid) - if journal: - return self.db.getjournal(self.classname, nodeid)[-1][1] - else: - # on the strange chance that there's no journal - return date.Date() - if propname == 'creator': - if d.has_key('creator'): - return d['creator'] - if not self.do_journal: - raise ValueError, 'Journalling is disabled for this class' - journal = self.db.getjournal(self.classname, nodeid) - if journal: - num_re = re.compile('^\d+$') - value = self.db.getjournal(self.classname, nodeid)[0][2] - if num_re.match(value): - return value - else: - # old-style "username" journal tag - try: - return self.db.user.lookup(value) - except KeyError: - # user's been retired, return admin - return '1' - else: - return self.db.curuserid - - # get the property (raises KeyErorr if invalid) - prop = self.properties[propname] - - if not d.has_key(propname): - if default is _marker: - if isinstance(prop, Multilink): - return [] - else: - return None - else: - return default - - # return a dupe of the list so code doesn't get confused - if isinstance(prop, Multilink): - return d[propname][:] - - return d[propname] - - # not in spec - def getnode(self, nodeid, cache=1): - ''' Return a convenience wrapper for the node. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. - - 'cache' indicates whether the transaction cache should be queried - for the node. If the node has been modified and you need to - determine what its values prior to modification are, you need to - set cache=0. - ''' - return Node(self, nodeid, cache=cache) - - def set(self, nodeid, **propvalues): - '''Modify a property on an existing node of this class. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. - - Each key in 'propvalues' must be the name of a property of this - class or a KeyError is raised. - - All values in 'propvalues' must be acceptable types for their - corresponding properties or a TypeError is raised. - - If the value of the key property is set, it must not collide with - other key strings or a ValueError is raised. - - If the value of a Link or Multilink property contains an invalid - node id, a ValueError is raised. - - These operations trigger detectors and can be vetoed. Attempts - to modify the "creation" or "activity" properties cause a KeyError. - ''' - if not propvalues: - return propvalues - - if propvalues.has_key('creation') or propvalues.has_key('activity'): - raise KeyError, '"creation" and "activity" are reserved' - - if propvalues.has_key('id'): - raise KeyError, '"id" is reserved' - - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - - self.fireAuditors('set', nodeid, propvalues) - # Take a copy of the node dict so that the subsequent set - # operation doesn't modify the oldvalues structure. - try: - # try not using the cache initially - oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid, - cache=0)) - except IndexError: - # this will be needed if somone does a create() and set() - # with no intervening commit() - oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid)) - - node = self.db.getnode(self.classname, nodeid) - if node.has_key(self.db.RETIRED_FLAG): - raise IndexError - num_re = re.compile('^\d+$') - - # if the journal value is to be different, store it in here - journalvalues = {} - - for propname, value in propvalues.items(): - # check to make sure we're not duplicating an existing key - if propname == self.key and node[propname] != value: - try: - self.lookup(value) - except KeyError: - pass - else: - raise ValueError, 'node with key "%s" exists'%value - - # this will raise the KeyError if the property isn't valid - # ... we don't use getprops() here because we only care about - # the writeable properties. - try: - prop = self.properties[propname] - except KeyError: - raise KeyError, '"%s" has no property named "%s"'%( - self.classname, propname) - - # if the value's the same as the existing value, no sense in - # doing anything - if node.has_key(propname) and value == node[propname]: - del propvalues[propname] - continue - - # do stuff based on the prop type - if isinstance(prop, Link): - link_class = prop.classname - # if it isn't a number, it's a key - if value is not None and not isinstance(value, type('')): - raise ValueError, 'property "%s" link value be a string'%( - propname) - if isinstance(value, type('')) and not num_re.match(value): - try: - value = self.db.classes[link_class].lookup(value) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - propname, value, prop.classname) - - if (value is not None and - not self.db.getclass(link_class).hasnode(value)): - raise IndexError, '%s has no node %s'%(link_class, value) - - if self.do_journal and prop.do_journal: - # register the unlink with the old linked node - if node[propname] is not None: - self.db.addjournal(link_class, node[propname], 'unlink', - (self.classname, nodeid, propname)) - - # register the link with the newly linked node - if value is not None: - self.db.addjournal(link_class, value, 'link', - (self.classname, nodeid, propname)) - - elif isinstance(prop, Multilink): - if type(value) != type([]): - raise TypeError, 'new property "%s" not a list of'\ - ' ids'%propname - link_class = self.properties[propname].classname - l = [] - for entry in value: - # if it isn't a number, it's a key - if type(entry) != type(''): - raise ValueError, 'new property "%s" link value ' \ - 'must be a string'%propname - if not num_re.match(entry): - try: - entry = self.db.classes[link_class].lookup(entry) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - propname, entry, - self.properties[propname].classname) - l.append(entry) - value = l - propvalues[propname] = value - - # figure the journal entry for this property - add = [] - remove = [] - - # handle removals - if node.has_key(propname): - l = node[propname] - else: - l = [] - for id in l[:]: - if id in value: - continue - # register the unlink with the old linked node - if self.do_journal and self.properties[propname].do_journal: - self.db.addjournal(link_class, id, 'unlink', - (self.classname, nodeid, propname)) - l.remove(id) - remove.append(id) - - # handle additions - for id in value: - if not self.db.getclass(link_class).hasnode(id): - raise IndexError, '%s has no node %s'%(link_class, id) - if id in l: - continue - # register the link with the newly linked node - if self.do_journal and self.properties[propname].do_journal: - self.db.addjournal(link_class, id, 'link', - (self.classname, nodeid, propname)) - l.append(id) - add.append(id) - - # figure the journal entry - l = [] - if add: - l.append(('+', add)) - if remove: - l.append(('-', remove)) - if l: - journalvalues[propname] = tuple(l) - - elif isinstance(prop, String): - if value is not None and type(value) != type(''): - raise TypeError, 'new property "%s" not a string'%propname - - elif isinstance(prop, Password): - if not isinstance(value, password.Password): - raise TypeError, 'new property "%s" not a Password'%propname - propvalues[propname] = value - - elif value is not None and isinstance(prop, Date): - if not isinstance(value, date.Date): - raise TypeError, 'new property "%s" not a Date'% propname - propvalues[propname] = value - - elif value is not None and isinstance(prop, Interval): - if not isinstance(value, date.Interval): - raise TypeError, 'new property "%s" not an '\ - 'Interval'%propname - propvalues[propname] = value - - elif value is not None and isinstance(prop, Number): - try: - float(value) - except ValueError: - raise TypeError, 'new property "%s" not numeric'%propname - - elif value is not None and isinstance(prop, Boolean): - try: - int(value) - except ValueError: - raise TypeError, 'new property "%s" not boolean'%propname - - node[propname] = value - - # nothing to do? - if not propvalues: - return propvalues - - # do the set, and journal it - self.db.setnode(self.classname, nodeid, node) - - if self.do_journal: - propvalues.update(journalvalues) - self.db.addjournal(self.classname, nodeid, 'set', propvalues) - - self.fireReactors('set', nodeid, oldvalues) - - return propvalues - - def retire(self, nodeid): - '''Retire a node. - - The properties on the node remain available from the get() method, - and the node's id is never reused. - - Retired nodes are not returned by the find(), list(), or lookup() - methods, and other nodes may reuse the values of their key properties. - - These operations trigger detectors and can be vetoed. Attempts - to modify the "creation" or "activity" properties cause a KeyError. - ''' - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - - self.fireAuditors('retire', nodeid, None) - - node = self.db.getnode(self.classname, nodeid) - node[self.db.RETIRED_FLAG] = 1 - self.db.setnode(self.classname, nodeid, node) - if self.do_journal: - self.db.addjournal(self.classname, nodeid, 'retired', None) - - self.fireReactors('retire', nodeid, None) - - def is_retired(self, nodeid): - '''Return true if the node is retired. - ''' - node = self.db.getnode(cn, nodeid, cldb) - if node.has_key(self.db.RETIRED_FLAG): - return 1 - return 0 - - def destroy(self, nodeid): - '''Destroy a node. - - WARNING: this method should never be used except in extremely rare - situations where there could never be links to the node being - deleted - WARNING: use retire() instead - WARNING: the properties of this node will not be available ever again - WARNING: really, use retire() instead - - Well, I think that's enough warnings. This method exists mostly to - support the session storage of the cgi interface. - ''' - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - self.db.destroynode(self.classname, nodeid) - - def history(self, nodeid): - '''Retrieve the journal of edits on a particular node. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. - - The returned list contains tuples of the form - - (date, tag, action, params) - - 'date' is a Timestamp object specifying the time of the change and - 'tag' is the journaltag specified when the database was opened. - ''' - if not self.do_journal: - raise ValueError, 'Journalling is disabled for this class' - return self.db.getjournal(self.classname, nodeid) - - # Locating nodes: - def hasnode(self, nodeid): - '''Determine if the given nodeid actually exists - ''' - return self.db.hasnode(self.classname, nodeid) - - def setkey(self, propname): - '''Select a String property of this class to be the key property. - - 'propname' must be the name of a String property of this class or - None, or a TypeError is raised. The values of the key property on - all existing nodes must be unique or a ValueError is raised. If the - property doesn't exist, KeyError is raised. - ''' - prop = self.getprops()[propname] - if not isinstance(prop, String): - raise TypeError, 'key properties must be String' - self.key = propname - - def getkey(self): - '''Return the name of the key property for this class or None.''' - return self.key - - def labelprop(self, default_to_id=0): - ''' Return the property name for a label for the given node. - - This method attempts to generate a consistent label for the node. - It tries the following in order: - 1. key property - 2. "name" property - 3. "title" property - 4. first property from the sorted property name list - ''' - k = self.getkey() - if k: - return k - props = self.getprops() - if props.has_key('name'): - return 'name' - elif props.has_key('title'): - return 'title' - if default_to_id: - return 'id' - props = props.keys() - props.sort() - return props[0] - - # TODO: set up a separate index db file for this? profile? - def lookup(self, keyvalue): - '''Locate a particular node by its key property and return its id. - - If this class has no key property, a TypeError is raised. If the - 'keyvalue' matches one of the values for the key property among - the nodes in this class, the matching node's id is returned; - otherwise a KeyError is raised. - ''' - if not self.key: - raise TypeError, 'No key property set for class %s'%self.classname - cldb = self.db.getclassdb(self.classname) - try: - for nodeid in self.db.getnodeids(self.classname, cldb): - node = self.db.getnode(self.classname, nodeid, cldb) - if node.has_key(self.db.RETIRED_FLAG): - continue - if node[self.key] == keyvalue: - cldb.close() - return nodeid - finally: - cldb.close() - raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key, - keyvalue, self.classname) - - # change from spec - allows multiple props to match - def find(self, **propspec): - '''Get the ids of nodes in this class which link to the given nodes. - - 'propspec' consists of keyword args propname=nodeid or - propname={nodeid:1, } - 'propname' must be the name of a property in this class, or a - KeyError is raised. That property must be a Link or - Multilink property, or a TypeError is raised. - - Any node in this class whose 'propname' property links to any of the - nodeids will be returned. Used by the full text indexing, which knows - that "foo" occurs in msg1, msg3 and file7, so we have hits on these - issues: - - db.issue.find(messages={'1':1,'3':1}, files={'7':1}) - ''' - propspec = propspec.items() - for propname, nodeids in propspec: - # check the prop is OK - prop = self.properties[propname] - if not isinstance(prop, Link) and not isinstance(prop, Multilink): - raise TypeError, "'%s' not a Link/Multilink property"%propname - - # ok, now do the find - cldb = self.db.getclassdb(self.classname) - l = [] - try: - for id in self.db.getnodeids(self.classname, db=cldb): - node = self.db.getnode(self.classname, id, db=cldb) - if node.has_key(self.db.RETIRED_FLAG): - continue - for propname, nodeids in propspec: - # can't test if the node doesn't have this property - if not node.has_key(propname): - continue - if type(nodeids) is type(''): - nodeids = {nodeids:1} - prop = self.properties[propname] - value = node[propname] - if isinstance(prop, Link) and nodeids.has_key(value): - l.append(id) - break - elif isinstance(prop, Multilink): - hit = 0 - for v in value: - if nodeids.has_key(v): - l.append(id) - hit = 1 - break - if hit: - break - finally: - cldb.close() - return l - - def stringFind(self, **requirements): - '''Locate a particular node by matching a set of its String - properties in a caseless search. - - If the property is not a String property, a TypeError is raised. - - The return is a list of the id of all nodes that match. - ''' - for propname in requirements.keys(): - prop = self.properties[propname] - if isinstance(not prop, String): - raise TypeError, "'%s' not a String property"%propname - requirements[propname] = requirements[propname].lower() - l = [] - cldb = self.db.getclassdb(self.classname) - try: - for nodeid in self.db.getnodeids(self.classname, cldb): - node = self.db.getnode(self.classname, nodeid, cldb) - if node.has_key(self.db.RETIRED_FLAG): - continue - for key, value in requirements.items(): - if node[key] is None or node[key].lower() != value: - break - else: - l.append(nodeid) - finally: - cldb.close() - return l - - def list(self): - ''' Return a list of the ids of the active nodes in this class. - ''' - l = [] - cn = self.classname - cldb = self.db.getclassdb(cn) - try: - for nodeid in self.db.getnodeids(cn, cldb): - node = self.db.getnode(cn, nodeid, cldb) - if node.has_key(self.db.RETIRED_FLAG): - continue - l.append(nodeid) - finally: - cldb.close() - l.sort() - return l - - def filter(self, search_matches, filterspec, sort, group, - num_re = re.compile('^\d+$')): - ''' Return a list of the ids of the active nodes in this class that - match the 'filter' spec, sorted by the group spec and then the - sort spec. - - "filterspec" is {propname: value(s)} - "sort" and "group" are (dir, prop) where dir is '+', '-' or None - and prop is a prop name or None - "search_matches" is {nodeid: marker} - - The filter must match all properties specificed - but if the - property value to match is a list, any one of the values in the - list may match for that property to match. - ''' - cn = self.classname - - # optimise filterspec - l = [] - props = self.getprops() - LINK = 0 - MULTILINK = 1 - STRING = 2 - OTHER = 6 - for k, v in filterspec.items(): - propclass = props[k] - if isinstance(propclass, Link): - if type(v) is not type([]): - v = [v] - # replace key values with node ids - u = [] - link_class = self.db.classes[propclass.classname] - for entry in v: - if entry == '-1': entry = None - elif not num_re.match(entry): - try: - entry = link_class.lookup(entry) - except (TypeError,KeyError): - raise ValueError, 'property "%s": %s not a %s'%( - k, entry, self.properties[k].classname) - u.append(entry) - - l.append((LINK, k, u)) - elif isinstance(propclass, Multilink): - if type(v) is not type([]): - v = [v] - # replace key values with node ids - u = [] - link_class = self.db.classes[propclass.classname] - for entry in v: - if not num_re.match(entry): - try: - entry = link_class.lookup(entry) - except (TypeError,KeyError): - raise ValueError, 'new property "%s": %s not a %s'%( - k, entry, self.properties[k].classname) - u.append(entry) - l.append((MULTILINK, k, u)) - elif isinstance(propclass, String): - # simple glob searching - v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', v) - v = v.replace('?', '.') - v = v.replace('*', '.*?') - l.append((STRING, k, re.compile(v, re.I))) - elif isinstance(propclass, Boolean): - if type(v) is type(''): - bv = v.lower() in ('yes', 'true', 'on', '1') - else: - bv = v - l.append((OTHER, k, bv)) - elif isinstance(propclass, Number): - l.append((OTHER, k, int(v))) - else: - l.append((OTHER, k, v)) - filterspec = l - - # now, find all the nodes that are active and pass filtering - l = [] - cldb = self.db.getclassdb(cn) - try: - # TODO: only full-scan once (use items()) - for nodeid in self.db.getnodeids(cn, cldb): - node = self.db.getnode(cn, nodeid, cldb) - if node.has_key(self.db.RETIRED_FLAG): - continue - # apply filter - for t, k, v in filterspec: - # make sure the node has the property - if not node.has_key(k): - # this node doesn't have this property, so reject it - break - - # now apply the property filter - if t == LINK: - # link - if this node's property doesn't appear in the - # filterspec's nodeid list, skip it - if node[k] not in v: - break - elif t == MULTILINK: - # multilink - if any of the nodeids required by the - # filterspec aren't in this node's property, then skip - # it - have = node[k] - for want in v: - if want not in have: - break - else: - continue - break - elif t == STRING: - # RE search - if node[k] is None or not v.search(node[k]): - break - elif t == OTHER: - # straight value comparison for the other types - if node[k] != v: - break - else: - l.append((nodeid, node)) - finally: - cldb.close() - l.sort() - - # filter based on full text search - if search_matches is not None: - k = [] - for v in l: - if search_matches.has_key(v[0]): - k.append(v) - l = k - - # now, sort the result - def sortfun(a, b, sort=sort, group=group, properties=self.getprops(), - db = self.db, cl=self): - a_id, an = a - b_id, bn = b - # sort by group and then sort - for dir, prop in group, sort: - if dir is None or prop is None: continue - - # sorting is class-specific - propclass = properties[prop] - - # handle the properties that might be "faked" - # also, handle possible missing properties - try: - if not an.has_key(prop): - an[prop] = cl.get(a_id, prop) - av = an[prop] - except KeyError: - # the node doesn't have a value for this property - if isinstance(propclass, Multilink): av = [] - else: av = '' - try: - if not bn.has_key(prop): - bn[prop] = cl.get(b_id, prop) - bv = bn[prop] - except KeyError: - # the node doesn't have a value for this property - if isinstance(propclass, Multilink): bv = [] - else: bv = '' - - # String and Date values are sorted in the natural way - if isinstance(propclass, String): - # clean up the strings - if av and av[0] in string.uppercase: - av = an[prop] = av.lower() - if bv and bv[0] in string.uppercase: - bv = bn[prop] = bv.lower() - if (isinstance(propclass, String) or - isinstance(propclass, Date)): - # it might be a string that's really an integer - try: - av = int(av) - bv = int(bv) - except: - pass - if dir == '+': - r = cmp(av, bv) - if r != 0: return r - elif dir == '-': - r = cmp(bv, av) - if r != 0: return r - - # Link properties are sorted according to the value of - # the "order" property on the linked nodes if it is - # present; or otherwise on the key string of the linked - # nodes; or finally on the node ids. - elif isinstance(propclass, Link): - link = db.classes[propclass.classname] - if av is None and bv is not None: return -1 - if av is not None and bv is None: return 1 - if av is None and bv is None: continue - if link.getprops().has_key('order'): - if dir == '+': - r = cmp(link.get(av, 'order'), - link.get(bv, 'order')) - if r != 0: return r - elif dir == '-': - r = cmp(link.get(bv, 'order'), - link.get(av, 'order')) - if r != 0: return r - elif link.getkey(): - key = link.getkey() - if dir == '+': - r = cmp(link.get(av, key), link.get(bv, key)) - if r != 0: return r - elif dir == '-': - r = cmp(link.get(bv, key), link.get(av, key)) - if r != 0: return r - else: - if dir == '+': - r = cmp(av, bv) - if r != 0: return r - elif dir == '-': - r = cmp(bv, av) - if r != 0: return r - - # Multilink properties are sorted according to how many - # links are present. - elif isinstance(propclass, Multilink): - if dir == '+': - r = cmp(len(av), len(bv)) - if r != 0: return r - elif dir == '-': - r = cmp(len(bv), len(av)) - if r != 0: return r - elif isinstance(propclass, Number) or isinstance(propclass, Boolean): - if dir == '+': - r = cmp(av, bv) - elif dir == '-': - r = cmp(bv, av) - - # end for dir, prop in sort, group: - # if all else fails, compare the ids - return cmp(a[0], b[0]) - - l.sort(sortfun) - return [i[0] for i in l] - - def count(self): - '''Get the number of nodes in this class. - - If the returned integer is 'numnodes', the ids of all the nodes - in this class run from 1 to numnodes, and numnodes+1 will be the - id of the next node to be created in this class. - ''' - return self.db.countnodes(self.classname) - - # Manipulating properties: - - def getprops(self, protected=1): - '''Return a dictionary mapping property names to property objects. - If the "protected" flag is true, we include protected properties - - those which may not be modified. - - In addition to the actual properties on the node, these - methods provide the "creation" and "activity" properties. If the - "protected" flag is true, we include protected properties - those - which may not be modified. - ''' - d = self.properties.copy() - if protected: - d['id'] = String() - d['creation'] = hyperdb.Date() - d['activity'] = hyperdb.Date() - d['creator'] = hyperdb.Link('user') - return d - - def addprop(self, **properties): - '''Add properties to this class. - - The keyword arguments in 'properties' must map names to property - objects, or a TypeError is raised. None of the keys in 'properties' - may collide with the names of existing properties, or a ValueError - is raised before any properties have been added. - ''' - for key in properties.keys(): - if self.properties.has_key(key): - raise ValueError, key - self.properties.update(properties) - - def index(self, nodeid): - '''Add (or refresh) the node to search indexes - ''' - # find all the String properties that have indexme - for prop, propclass in self.getprops().items(): - if isinstance(propclass, String) and propclass.indexme: - try: - value = str(self.get(nodeid, prop)) - except IndexError: - # node no longer exists - entry should be removed - self.db.indexer.purge_entry((self.classname, nodeid, prop)) - else: - # and index them under (classname, nodeid, property) - self.db.indexer.add_text((self.classname, nodeid, prop), - value) - - # - # Detector interface - # - def audit(self, event, detector): - '''Register a detector - ''' - l = self.auditors[event] - if detector not in l: - self.auditors[event].append(detector) - - def fireAuditors(self, action, nodeid, newvalues): - '''Fire all registered auditors. - ''' - for audit in self.auditors[action]: - audit(self.db, self, nodeid, newvalues) - - def react(self, event, detector): - '''Register a detector - ''' - l = self.reactors[event] - if detector not in l: - self.reactors[event].append(detector) - - def fireReactors(self, action, nodeid, oldvalues): - '''Fire all registered reactors. - ''' - for react in self.reactors[action]: - react(self.db, self, nodeid, oldvalues) - -class FileClass(Class): - '''This class defines a large chunk of data. To support this, it has a - mandatory String property "content" which is typically saved off - externally to the hyperdb. - - The default MIME type of this data is defined by the - "default_mime_type" class attribute, which may be overridden by each - node if the class defines a "type" String property. - ''' - default_mime_type = 'text/plain' - - def create(self, **propvalues): - ''' snaffle the file propvalue and store in a file - ''' - content = propvalues['content'] - del propvalues['content'] - newid = Class.create(self, **propvalues) - self.db.storefile(self.classname, newid, None, content) - return newid - - def import_list(self, propnames, proplist): - ''' Trap the "content" property... - ''' - # dupe this list so we don't affect others - propnames = propnames[:] - - # extract the "content" property from the proplist - i = propnames.index('content') - content = eval(proplist[i]) - del propnames[i] - del proplist[i] - - # do the normal import - newid = Class.import_list(self, propnames, proplist) - - # save off the "content" file - self.db.storefile(self.classname, newid, None, content) - return newid - - def get(self, nodeid, propname, default=_marker, cache=1): - ''' trap the content propname and get it from the file - ''' - - poss_msg = 'Possibly a access right configuration problem.' - if propname == 'content': - try: - return self.db.getfile(self.classname, nodeid, None) - except IOError, (strerror): - # BUG: by catching this we donot see an error in the log. - return 'ERROR reading file: %s%s\n%s\n%s'%( - self.classname, nodeid, poss_msg, strerror) - if default is not _marker: - return Class.get(self, nodeid, propname, default, cache=cache) - else: - return Class.get(self, nodeid, propname, cache=cache) - - def getprops(self, protected=1): - ''' In addition to the actual properties on the node, these methods - provide the "content" property. If the "protected" flag is true, - we include protected properties - those which may not be - modified. - ''' - d = Class.getprops(self, protected=protected).copy() - d['content'] = hyperdb.String() - return d - - def index(self, nodeid): - ''' Index the node in the search index. - - We want to index the content in addition to the normal String - property indexing. - ''' - # perform normal indexing - Class.index(self, nodeid) - - # get the content to index - content = self.get(nodeid, 'content') - - # figure the mime type - if self.properties.has_key('type'): - mime_type = self.get(nodeid, 'type') - else: - mime_type = self.default_mime_type - - # and index! - self.db.indexer.add_text((self.classname, nodeid, 'content'), content, - mime_type) - -# deviation from spec - was called ItemClass -class IssueClass(Class, roundupdb.IssueClass): - # Overridden methods: - def __init__(self, db, classname, **properties): - '''The newly-created class automatically includes the "messages", - "files", "nosy", and "superseder" properties. If the 'properties' - dictionary attempts to specify any of these properties or a - "creation" or "activity" property, a ValueError is raised. - ''' - if not properties.has_key('title'): - properties['title'] = hyperdb.String(indexme='yes') - if not properties.has_key('messages'): - properties['messages'] = hyperdb.Multilink("msg") - if not properties.has_key('files'): - properties['files'] = hyperdb.Multilink("file") - if not properties.has_key('nosy'): - # note: journalling is turned off as it really just wastes - # space. this behaviour may be overridden in an instance - properties['nosy'] = hyperdb.Multilink("user", do_journal="no") - if not properties.has_key('superseder'): - properties['superseder'] = hyperdb.Multilink(classname) - Class.__init__(self, db, classname, **properties) - -#
--- a/roundup/backends/back_bsddb3.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,105 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -#$Id: back_bsddb3.py,v 1.17 2002-09-13 08:20:12 richard Exp $ - -import bsddb3, os, marshal -from roundup import hyperdb, date - -# these classes are so similar, we just use the anydbm methods -from back_anydbm import Database, Class, FileClass, IssueClass - -# -# Now the database -# -class Database(Database): - """A database for storing records containing flexible data types.""" - # - # Class DBs - # - def clear(self): - for cn in self.classes.keys(): - db = os.path.join(self.dir, 'nodes.%s'%cn) - bsddb3.btopen(db, 'n') - db = os.path.join(self.dir, 'journals.%s'%cn) - bsddb3.btopen(db, 'n') - - def getclassdb(self, classname, mode='r'): - ''' grab a connection to the class db that will be used for - multiple actions - ''' - path = os.path.join(os.getcwd(), self.dir, 'nodes.%s'%classname) - if os.path.exists(path): - return bsddb3.btopen(path, mode) - else: - return bsddb3.btopen(path, 'c') - - def opendb(self, name, mode): - '''Low-level database opener that gets around anydbm/dbm - eccentricities. - ''' - if __debug__: - print >>hyperdb.DEBUG, self, 'opendb', (self, name, mode) - # determine which DB wrote the class file - path = os.path.join(os.getcwd(), self.dir, name) - if not os.path.exists(path): - if __debug__: - print >>hyperdb.DEBUG, "opendb bsddb3.open(%r, 'c')"%path - return bsddb3.btopen(path, 'c') - - # open the database with the correct module - if __debug__: - print >>hyperdb.DEBUG, "opendb bsddb3.open(%r, %r)"%(path, mode) - return bsddb3.btopen(path, mode) - - # - # Journal - # - def getjournal(self, classname, nodeid): - ''' get the journal for id - ''' - # attempt to open the journal - in some rare cases, the journal may - # not exist - try: - db = bsddb3.btopen(os.path.join(self.dir, 'journals.%s'%classname), - 'r') - except bsddb3._db.DBNoSuchFileError: - raise IndexError, 'no such %s %s'%(classname, nodeid) - # more handling of bad journals - if not db.has_key(nodeid): - raise IndexError, 'no such %s %s'%(classname, nodeid) - journal = marshal.loads(db[nodeid]) - res = [] - for entry in journal: - (nodeid, date_stamp, user, action, params) = entry - date_obj = date.Date(date_stamp) - res.append((nodeid, date_obj, user, action, params)) - db.close() - return res - - def getCachedJournalDB(self, classname): - ''' get the journal db, looking in our cache of databases for commit - ''' - # get the database handle - db_name = 'journals.%s'%classname - if self.databases.has_key(db_name): - return self.databases[db_name] - else: - db = bsddb3.btopen(os.path.join(self.dir, db_name), 'c') - self.databases[db_name] = db - return db -
--- a/roundup/backends/back_gadfly.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,264 +0,0 @@ -# $Id: back_gadfly.py,v 1.27 2002-09-26 03:04:24 richard Exp $ -__doc__ = ''' -About Gadfly -============ - -Gadfly is a collection of python modules that provides relational -database functionality entirely implemented in Python. It supports a -subset of the intergalactic standard RDBMS Structured Query Language -SQL. - - -Basic Structure -=============== - -We map roundup classes to relational tables. Automatically detect schema -changes and modify the gadfly table schemas appropriately. Multilinks -(which represent a many-to-many relationship) are handled through -intermediate tables. - -Journals are stored adjunct to the per-class tables. - -Table names and columns have "_" prepended so the names can't -clash with restricted names (like "order"). Retirement is determined by the -__retired__ column being true. - -All columns are defined as VARCHAR, since it really doesn't matter what -type they're defined as. We stuff all kinds of data in there ;) [as long as -it's marshallable, gadfly doesn't care] - - -Additional Instance Requirements -================================ - -The instance configuration must specify where the database is. It does this -with GADFLY_DATABASE, which is used as the arguments to the gadfly.gadfly() -method: - -Using an on-disk database directly (not a good idea): - GADFLY_DATABASE = (database name, directory) - -Using a network database (much better idea): - GADFLY_DATABASE = (policy, password, address, port) - -Because multiple accesses directly to a gadfly database aren't handled, but -multiple network accesses are, it's strongly advised that the latter setup be -used. - -''' - -# standard python modules -import sys, os, time, re, errno, weakref, copy - -# roundup modules -from roundup import hyperdb, date, password, roundupdb, security -from roundup.hyperdb import String, Password, Date, Interval, Link, \ - Multilink, DatabaseError, Boolean, Number - -# basic RDBMS backen implementation -from roundup.backends import rdbms_common - -# the all-important gadfly :) -import gadfly -import gadfly.client -import gadfly.database - -class Database(rdbms_common.Database): - # char to use for positional arguments - arg = '?' - - def open_connection(self): - db = getattr(self.config, 'GADFLY_DATABASE', ('database', self.dir)) - if len(db) == 2: - # ensure files are group readable and writable - os.umask(0002) - try: - self.conn = gadfly.gadfly(*db) - except IOError, error: - if error.errno != errno.ENOENT: - raise - self.database_schema = {} - self.conn = gadfly.gadfly() - self.conn.startup(*db) - self.cursor = self.conn.cursor() - self.cursor.execute('create table schema (schema varchar)') - self.cursor.execute('create table ids (name varchar, num integer)') - else: - self.cursor = self.conn.cursor() - self.cursor.execute('select schema from schema') - self.database_schema = self.cursor.fetchone()[0] - else: - self.conn = gadfly.client.gfclient(*db) - self.database_schema = self.load_dbschema() - - def __repr__(self): - return '<roundfly 0x%x>'%id(self) - - def sql_fetchone(self): - ''' Fetch a single row. If there's nothing to fetch, return None. - ''' - try: - return self.cursor.fetchone() - except gadfly.database.error, message: - if message == 'no more results': - return None - raise - - def sql_fetchall(self): - ''' Fetch a single row. If there's nothing to fetch, return []. - ''' - try: - return self.cursor.fetchall() - except gadfly.database.error, message: - if message == 'no more results': - return [] - raise - - def save_dbschema(self, schema): - ''' Save the schema definition that the database currently implements - ''' - self.sql('insert into schema values (?)', (self.database_schema,)) - - def load_dbschema(self): - ''' Load the schema definition that the database currently implements - ''' - self.cursor.execute('select schema from schema') - return self.cursor.fetchone()[0] - - def save_journal(self, classname, cols, nodeid, journaldate, - journaltag, action, params): - ''' Save the journal entry to the database - ''' - # nothing special to do - entry = (nodeid, journaldate, journaltag, action, params) - - # do the insert - a = self.arg - sql = 'insert into %s__journal (%s) values (?,?,?,?,?)'%(classname, - cols) - if __debug__: - print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry) - self.cursor.execute(sql, entry) - - def load_journal(self, classname, cols, nodeid): - ''' Load the journal from the database - ''' - # now get the journal entries - sql = 'select %s from %s__journal where nodeid=%s'%(cols, classname, - self.arg) - if __debug__: - print >>hyperdb.DEBUG, 'getjournal', (self, sql, nodeid) - self.cursor.execute(sql, (nodeid,)) - res = [] - for nodeid, date_stamp, user, action, params in self.cursor.fetchall(): - res.append((nodeid, date.Date(date_stamp), user, action, params)) - return res - -class GadflyClass: - def filter(self, search_matches, filterspec, sort, group): - ''' Gadfly doesn't have a LIKE predicate :( - ''' - cn = self.classname - - # figure the WHERE clause from the filterspec - props = self.getprops() - frum = ['_'+cn] - where = [] - args = [] - a = self.db.arg - for k, v in filterspec.items(): - propclass = props[k] - if isinstance(propclass, Multilink): - tn = '%s_%s'%(cn, k) - frum.append(tn) - if isinstance(v, type([])): - s = ','.join([a for x in v]) - where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s)) - args = args + v - else: - where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a)) - args.append(v) - else: - if isinstance(v, type([])): - s = ','.join([a for x in v]) - where.append('_%s in (%s)'%(k, s)) - args = args + v - else: - where.append('_%s=%s'%(k, a)) - args.append(v) - - # add results of full text search - if search_matches is not None: - v = search_matches.keys() - s = ','.join([a for x in v]) - where.append('id in (%s)'%s) - args = args + v - - # "grouping" is just the first-order sorting in the SQL fetch - # can modify it...) - orderby = [] - ordercols = [] - if group[0] is not None and group[1] is not None: - if group[0] != '-': - orderby.append('_'+group[1]) - ordercols.append('_'+group[1]) - else: - orderby.append('_'+group[1]+' desc') - ordercols.append('_'+group[1]) - - # now add in the sorting - group = '' - if sort[0] is not None and sort[1] is not None: - direction, colname = sort - if direction != '-': - if colname == 'id': - orderby.append(colname) - else: - orderby.append('_'+colname) - ordercols.append('_'+colname) - else: - if colname == 'id': - orderby.append(colname+' desc') - ordercols.append(colname) - else: - orderby.append('_'+colname+' desc') - ordercols.append('_'+colname) - - # construct the SQL - frum = ','.join(frum) - if where: - where = ' where ' + (' and '.join(where)) - else: - where = '' - cols = ['id'] - if orderby: - cols = cols + ordercols - order = ' order by %s'%(','.join(orderby)) - else: - order = '' - cols = ','.join(cols) - sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order) - args = tuple(args) - if __debug__: - print >>hyperdb.DEBUG, 'filter', (self, sql, args) - self.db.cursor.execute(sql, args) - l = self.db.cursor.fetchall() - - # return the IDs - return [row[0] for row in l] - - def find(self, **propspec): - ''' Overload to filter out duplicates in the result - ''' - d = {} - for k in rdbms_common.Class.find(self, **propspec): - d[k] = 1 - return d.keys() - -class Class(GadflyClass, rdbms_common.Class): - pass -class IssueClass(GadflyClass, rdbms_common.IssueClass): - pass -class FileClass(GadflyClass, rdbms_common.FileClass): - pass -
--- a/roundup/backends/back_metakit.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1278 +0,0 @@ -from roundup import hyperdb, date, password, roundupdb, security -import metakit -from sessions import Sessions -import re, marshal, os, sys, weakref, time, calendar -from roundup import indexer -import locking - -_dbs = {} - -def Database(config, journaltag=None): - db = _dbs.get(config.DATABASE, None) - if db is None or db._db is None: - db = _Database(config, journaltag) - _dbs[config.DATABASE] = db - else: - db.journaltag = journaltag - try: - delattr(db, 'curuserid') - except AttributeError: - pass - return db - -class _Database(hyperdb.Database): - def __init__(self, config, journaltag=None): - self.config = config - self.journaltag = journaltag - self.classes = {} - self.dirty = 0 - self.lockfile = None - self._db = self.__open() - self.indexer = Indexer(self.config.DATABASE, self._db) - self.sessions = Sessions(self.config) - self.security = security.Security(self) - - os.umask(0002) - - def post_init(self): - if self.indexer.should_reindex(): - self.reindex() - - def reindex(self): - for klass in self.classes.values(): - for nodeid in klass.list(): - klass.index(nodeid) - self.indexer.save_index() - - - # --- defined in ping's spec - def __getattr__(self, classname): - if classname == 'curuserid': - if self.journaltag is None: - return None - - try: - self.curuserid = x = int(self.classes['user'].lookup(self.journaltag)) - except KeyError: - if self.journaltag == 'admin': - self.curuserid = x = 1 - else: - x = 0 - return x - elif classname == 'transactions': - return self.dirty - return self.getclass(classname) - def getclass(self, classname): - try: - return self.classes[classname] - except KeyError: - raise KeyError, 'There is no class called "%s"'%classname - def getclasses(self): - return self.classes.keys() - # --- end of ping's spec - # --- exposed methods - def commit(self): - if self.dirty: - self._db.commit() - for cl in self.classes.values(): - cl._commit() - self.indexer.save_index() - self.dirty = 0 - def rollback(self): - if self.dirty: - for cl in self.classes.values(): - cl._rollback() - self._db.rollback() - self._db = None - self._db = metakit.storage(self.dbnm, 1) - self.hist = self._db.view('history') - self.tables = self._db.view('tables') - self.indexer.datadb = self._db - self.dirty = 0 - def clearCache(self): - for cl in self.classes.values(): - cl._commit() - def clear(self): - for cl in self.classes.values(): - cl._clear() - def hasnode(self, classname, nodeid): - return self.getclass(classname).hasnode(nodeid) - def pack(self, pack_before): - mindate = int(calendar.timegm(pack_before.get_tuple())) - i = 0 - while i < len(self.hist): - if self.hist[i].date < mindate and self.hist[i].action != _CREATE: - self.hist.delete(i) - else: - i = i + 1 - def addclass(self, cl): - self.classes[cl.classname] = cl - if self.tables.find(name=cl.classname) < 0: - self.tables.append(name=cl.classname) - def addjournal(self, tablenm, nodeid, action, params, creator=None, - creation=None): - tblid = self.tables.find(name=tablenm) - if tblid == -1: - tblid = self.tables.append(name=tablenm) - if creator is None: - creator = self.curuserid - else: - try: - creator = int(creator) - except TypeError: - creator = int(self.getclass('user').lookup(creator)) - if creation is None: - creation = int(time.time()) - elif isinstance(creation, date.Date): - creation = int(calendar.timegm(creation.get_tuple())) - # tableid:I,nodeid:I,date:I,user:I,action:I,params:B - self.hist.append(tableid=tblid, - nodeid=int(nodeid), - date=creation, - action=action, - user = creator, - params = marshal.dumps(params)) - def getjournal(self, tablenm, nodeid): - rslt = [] - tblid = self.tables.find(name=tablenm) - if tblid == -1: - return rslt - q = self.hist.select(tableid=tblid, nodeid=int(nodeid)) - if len(q) == 0: - raise IndexError, "no history for id %s in %s" % (nodeid, tablenm) - i = 0 - #userclass = self.getclass('user') - for row in q: - try: - params = marshal.loads(row.params) - except ValueError: - print "history couldn't unmarshal %r" % row.params - params = {} - #usernm = userclass.get(str(row.user), 'username') - dt = date.Date(time.gmtime(row.date)) - #rslt.append((nodeid, dt, usernm, _actionnames[row.action], params)) - rslt.append((nodeid, dt, str(row.user), _actionnames[row.action], params)) - return rslt - - def destroyjournal(self, tablenm, nodeid): - nodeid = int(nodeid) - tblid = self.tables.find(name=tablenm) - if tblid == -1: - return - i = 0 - hist = self.hist - while i < len(hist): - if hist[i].tableid == tblid and hist[i].nodeid == nodeid: - hist.delete(i) - else: - i = i + 1 - self.dirty = 1 - - def close(self): - for cl in self.classes.values(): - cl.db = None - self._db = None - if self.lockfile is not None: - locking.release_lock(self.lockfile) - if _dbs.has_key(self.config.DATABASE): - del _dbs[self.config.DATABASE] - if self.lockfile is not None: - self.lockfile.close() - self.lockfile = None - self.classes = {} - self.indexer = None - - # --- internal - def __open(self): - if not os.path.exists(self.config.DATABASE): - os.makedirs(self.config.DATABASE) - self.dbnm = db = os.path.join(self.config.DATABASE, 'tracker.mk4') - lockfilenm = db[:-3]+'lck' - self.lockfile = locking.acquire_lock(lockfilenm) - self.lockfile.write(str(os.getpid())) - self.lockfile.flush() - self.fastopen = 0 - if os.path.exists(db): - dbtm = os.path.getmtime(db) - pkgnm = self.config.__name__.split('.')[0] - schemamod = sys.modules.get(pkgnm+'.dbinit', None) - if schemamod: - if os.path.exists(schemamod.__file__): - schematm = os.path.getmtime(schemamod.__file__) - if schematm < dbtm: - # found schema mod - it's older than the db - self.fastopen = 1 - else: - # can't find schemamod - must be frozen - self.fastopen = 1 - db = metakit.storage(db, 1) - hist = db.view('history') - tables = db.view('tables') - if not self.fastopen: - if not hist.structure(): - hist = db.getas('history[tableid:I,nodeid:I,date:I,user:I,action:I,params:B]') - if not tables.structure(): - tables = db.getas('tables[name:S]') - db.commit() - self.tables = tables - self.hist = hist - return db - -_STRINGTYPE = type('') -_LISTTYPE = type([]) -_CREATE, _SET, _RETIRE, _LINK, _UNLINK = range(5) - -_actionnames = { - _CREATE : 'create', - _SET : 'set', - _RETIRE : 'retire', - _LINK : 'link', - _UNLINK : 'unlink', -} - -_marker = [] - -_ALLOWSETTINGPRIVATEPROPS = 0 - -class Class: - privateprops = None - def __init__(self, db, classname, **properties): - #self.db = weakref.proxy(db) - self.db = db - self.classname = classname - self.keyname = None - self.ruprops = properties - self.privateprops = { 'id' : hyperdb.String(), - 'activity' : hyperdb.Date(), - 'creation' : hyperdb.Date(), - 'creator' : hyperdb.Link('user') } - self.auditors = {'create': [], 'set': [], 'retire': []} # event -> list of callables - self.reactors = {'create': [], 'set': [], 'retire': []} # ditto - view = self.__getview() - self.maxid = 1 - if view: - self.maxid = view[-1].id + 1 - self.uncommitted = {} - self.rbactions = [] - # people reach inside!! - self.properties = self.ruprops - self.db.addclass(self) - self.idcache = {} - - # default is to journal changes - self.do_journal = 1 - - def enableJournalling(self): - '''Turn journalling on for this class - ''' - self.do_journal = 1 - - def disableJournalling(self): - '''Turn journalling off for this class - ''' - self.do_journal = 0 - - # --- the roundup.Class methods - def audit(self, event, detector): - l = self.auditors[event] - if detector not in l: - self.auditors[event].append(detector) - def fireAuditors(self, action, nodeid, newvalues): - for audit in self.auditors[action]: - audit(self.db, self, nodeid, newvalues) - def fireReactors(self, action, nodeid, oldvalues): - for react in self.reactors[action]: - react(self.db, self, nodeid, oldvalues) - def react(self, event, detector): - l = self.reactors[event] - if detector not in l: - self.reactors[event].append(detector) - # --- the hyperdb.Class methods - def create(self, **propvalues): - self.fireAuditors('create', None, propvalues) - rowdict = {} - rowdict['id'] = newid = self.maxid - self.maxid += 1 - ndx = self.getview(1).append(rowdict) - propvalues['#ISNEW'] = 1 - try: - self.set(str(newid), **propvalues) - except Exception: - self.maxid -= 1 - raise - return str(newid) - - def get(self, nodeid, propname, default=_marker, cache=1): - # default and cache aren't in the spec - # cache=0 means "original value" - - view = self.getview() - id = int(nodeid) - if cache == 0: - oldnode = self.uncommitted.get(id, None) - if oldnode and oldnode.has_key(propname): - return oldnode[propname] - ndx = self.idcache.get(id, None) - if ndx is None: - ndx = view.find(id=id) - if ndx < 0: - raise IndexError, "%s has no node %s" % (self.classname, nodeid) - self.idcache[id] = ndx - try: - raw = getattr(view[ndx], propname) - except AttributeError: - raise KeyError, propname - rutyp = self.ruprops.get(propname, None) - if rutyp is None: - rutyp = self.privateprops[propname] - converter = _converters.get(rutyp.__class__, None) - if converter: - raw = converter(raw) - return raw - - def set(self, nodeid, **propvalues): - isnew = 0 - if propvalues.has_key('#ISNEW'): - isnew = 1 - del propvalues['#ISNEW'] - if not isnew: - self.fireAuditors('set', nodeid, propvalues) - if not propvalues: - return propvalues - if propvalues.has_key('id'): - raise KeyError, '"id" is reserved' - if self.db.journaltag is None: - raise hyperdb.DatabaseError, 'Database open read-only' - view = self.getview(1) - # node must exist & not be retired - id = int(nodeid) - ndx = view.find(id=id) - if ndx < 0: - raise IndexError, "%s has no node %s" % (self.classname, nodeid) - row = view[ndx] - if row._isdel: - raise IndexError, "%s has no node %s" % (self.classname, nodeid) - oldnode = self.uncommitted.setdefault(id, {}) - changes = {} - - for key, value in propvalues.items(): - # this will raise the KeyError if the property isn't valid - # ... we don't use getprops() here because we only care about - # the writeable properties. - if _ALLOWSETTINGPRIVATEPROPS: - prop = self.ruprops.get(key, None) - if not prop: - prop = self.privateprops[key] - else: - prop = self.ruprops[key] - converter = _converters.get(prop.__class__, lambda v: v) - # if the value's the same as the existing value, no sense in - # doing anything - oldvalue = converter(getattr(row, key)) - if value == oldvalue: - del propvalues[key] - continue - - # check to make sure we're not duplicating an existing key - if key == self.keyname: - iv = self.getindexview(1) - ndx = iv.find(k=value) - if ndx == -1: - iv.append(k=value, i=row.id) - if not isnew: - ndx = iv.find(k=oldvalue) - if ndx > -1: - iv.delete(ndx) - else: - raise ValueError, 'node with key "%s" exists'%value - - # do stuff based on the prop type - if isinstance(prop, hyperdb.Link): - link_class = prop.classname - # must be a string or None - if value is not None and not isinstance(value, type('')): - raise ValueError, 'property "%s" link value be a string'%( - key) - # Roundup sets to "unselected" by passing None - if value is None: - value = 0 - # if it isn't a number, it's a key - try: - int(value) - except ValueError: - try: - value = self.db.getclass(link_class).lookup(value) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - key, value, prop.classname) - - if (value is not None and - not self.db.getclass(link_class).hasnode(value)): - raise IndexError, '%s has no node %s'%(link_class, value) - - setattr(row, key, int(value)) - changes[key] = oldvalue - - if self.do_journal and prop.do_journal: - # register the unlink with the old linked node - if oldvalue: - self.db.addjournal(link_class, value, _UNLINK, - (self.classname, str(row.id), key)) - - # register the link with the newly linked node - if value: - self.db.addjournal(link_class, value, _LINK, - (self.classname, str(row.id), key)) - - elif isinstance(prop, hyperdb.Multilink): - if value is not None and type(value) != _LISTTYPE: - raise TypeError, 'new property "%s" not a list of ids'%key - link_class = prop.classname - l = [] - if value is None: - value = [] - for entry in value: - if type(entry) != _STRINGTYPE: - raise ValueError, 'new property "%s" link value ' \ - 'must be a string'%key - # if it isn't a number, it's a key - try: - int(entry) - except ValueError: - try: - entry = self.db.getclass(link_class).lookup(entry) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - key, entry, prop.classname) - l.append(entry) - propvalues[key] = value = l - - # handle removals - rmvd = [] - for id in oldvalue: - if id not in value: - rmvd.append(id) - # register the unlink with the old linked node - if self.do_journal and prop.do_journal: - self.db.addjournal(link_class, id, _UNLINK, - (self.classname, str(row.id), key)) - - # handle additions - adds = [] - for id in value: - if id not in oldvalue: - if not self.db.getclass(link_class).hasnode(id): - raise IndexError, '%s has no node %s'%( - link_class, id) - adds.append(id) - # register the link with the newly linked node - if self.do_journal and prop.do_journal: - self.db.addjournal(link_class, id, _LINK, - (self.classname, str(row.id), key)) - - sv = getattr(row, key) - i = 0 - while i < len(sv): - if str(sv[i].fid) in rmvd: - sv.delete(i) - else: - i += 1 - for id in adds: - sv.append(fid=int(id)) - changes[key] = oldvalue - if not rmvd and not adds: - del propvalues[key] - - elif isinstance(prop, hyperdb.String): - if value is not None and type(value) != _STRINGTYPE: - raise TypeError, 'new property "%s" not a string'%key - if value is None: - value = '' - setattr(row, key, value) - changes[key] = oldvalue - if hasattr(prop, 'isfilename') and prop.isfilename: - propvalues[key] = os.path.basename(value) - if prop.indexme and value is not None: - self.db.indexer.add_text((self.classname, nodeid, key), - value, 'text/plain') - - elif isinstance(prop, hyperdb.Password): - if value is not None and not isinstance(value, password.Password): - raise TypeError, 'new property "%s" not a Password'% key - if value is None: - value = '' - setattr(row, key, str(value)) - changes[key] = str(oldvalue) - propvalues[key] = str(value) - - elif isinstance(prop, hyperdb.Date): - if value is not None and not isinstance(value, date.Date): - raise TypeError, 'new property "%s" not a Date'% key - if value is None: - setattr(row, key, 0) - else: - setattr(row, key, int(calendar.timegm(value.get_tuple()))) - changes[key] = str(oldvalue) - propvalues[key] = str(value) - - elif isinstance(prop, hyperdb.Interval): - if value is not None and not isinstance(value, date.Interval): - raise TypeError, 'new property "%s" not an Interval'% key - if value is None: - setattr(row, key, '') - else: - setattr(row, key, str(value)) - changes[key] = str(oldvalue) - propvalues[key] = str(value) - - elif isinstance(prop, hyperdb.Number): - if value is None: - value = 0 - try: - v = int(value) - except ValueError: - raise TypeError, "%s (%s) is not numeric" % (key, repr(value)) - setattr(row, key, v) - changes[key] = oldvalue - propvalues[key] = value - - elif isinstance(prop, hyperdb.Boolean): - if value is None: - bv = 0 - elif value not in (0,1): - raise TypeError, "%s (%s) is not boolean" % (key, repr(value)) - else: - bv = value - setattr(row, key, bv) - changes[key] = oldvalue - propvalues[key] = value - - oldnode[key] = oldvalue - - # nothing to do? - if not propvalues: - return propvalues - if not propvalues.has_key('activity'): - row.activity = int(time.time()) - if isnew: - if not row.creation: - row.creation = int(time.time()) - if not row.creator: - row.creator = self.db.curuserid - - self.db.dirty = 1 - if self.do_journal: - if isnew: - self.db.addjournal(self.classname, nodeid, _CREATE, {}) - self.fireReactors('create', nodeid, None) - else: - self.db.addjournal(self.classname, nodeid, _SET, changes) - self.fireReactors('set', nodeid, oldnode) - - return propvalues - - def retire(self, nodeid): - if self.db.journaltag is None: - raise hyperdb.DatabaseError, 'Database open read-only' - self.fireAuditors('retire', nodeid, None) - view = self.getview(1) - ndx = view.find(id=int(nodeid)) - if ndx < 0: - raise KeyError, "nodeid %s not found" % nodeid - row = view[ndx] - oldvalues = self.uncommitted.setdefault(row.id, {}) - oldval = oldvalues['_isdel'] = row._isdel - row._isdel = 1 - if self.do_journal: - self.db.addjournal(self.classname, nodeid, _RETIRE, {}) - if self.keyname: - iv = self.getindexview(1) - ndx = iv.find(k=getattr(row, self.keyname),i=row.id) - if ndx > -1: - iv.delete(ndx) - self.db.dirty = 1 - self.fireReactors('retire', nodeid, None) - def history(self, nodeid): - if not self.do_journal: - raise ValueError, 'Journalling is disabled for this class' - return self.db.getjournal(self.classname, nodeid) - def setkey(self, propname): - if self.keyname: - if propname == self.keyname: - return - raise ValueError, "%s already indexed on %s" % (self.classname, self.keyname) - prop = self.properties.get(propname, None) - if prop is None: - prop = self.privateprops.get(propname, None) - if prop is None: - raise KeyError, "no property %s" % propname - if not isinstance(prop, hyperdb.String): - raise TypeError, "%s is not a String" % propname - # first setkey for this run - self.keyname = propname - iv = self.db._db.view('_%s' % self.classname) - if self.db.fastopen and iv.structure(): - return - # very first setkey ever - self.db.dirty = 1 - iv = self.db._db.getas('_%s[k:S,i:I]' % self.classname) - iv = iv.ordered(1) -# print "setkey building index" - for row in self.getview(): - iv.append(k=getattr(row, propname), i=row.id) - self.db.commit() - def getkey(self): - return self.keyname - def lookup(self, keyvalue): - if type(keyvalue) is not _STRINGTYPE: - raise TypeError, "%r is not a string" % keyvalue - iv = self.getindexview() - if iv: - ndx = iv.find(k=keyvalue) - if ndx > -1: - return str(iv[ndx].i) - else: - view = self.getview() - ndx = view.find({self.keyname:keyvalue, '_isdel':0}) - if ndx > -1: - return str(view[ndx].id) - raise KeyError, keyvalue - - def destroy(self, id): - view = self.getview(1) - ndx = view.find(id=int(id)) - if ndx > -1: - if self.keyname: - keyvalue = getattr(view[ndx], self.keyname) - iv = self.getindexview(1) - if iv: - ivndx = iv.find(k=keyvalue) - if ivndx > -1: - iv.delete(ivndx) - view.delete(ndx) - self.db.destroyjournal(self.classname, id) - self.db.dirty = 1 - - def find(self, **propspec): - """Get the ids of nodes in this class which link to the given nodes. - - 'propspec' consists of keyword args propname={nodeid:1,} - 'propname' must be the name of a property in this class, or a - KeyError is raised. That property must be a Link or - Multilink property, or a TypeError is raised. - - Any node in this class whose propname property links to any of the - nodeids will be returned. Used by the full text indexing, which knows - that "foo" occurs in msg1, msg3 and file7; so we have hits on these - issues: - - db.issue.find(messages={'1':1,'3':1}, files={'7':1}) - - """ - propspec = propspec.items() - for propname, nodeid in propspec: - # check the prop is OK - prop = self.ruprops[propname] - if (not isinstance(prop, hyperdb.Link) and - not isinstance(prop, hyperdb.Multilink)): - raise TypeError, "'%s' not a Link/Multilink property"%propname - - vws = [] - for propname, ids in propspec: - if type(ids) is _STRINGTYPE: - ids = {ids:1} - prop = self.ruprops[propname] - view = self.getview() - if isinstance(prop, hyperdb.Multilink): - view = view.flatten(getattr(view, propname)) - def ff(row, nm=propname, ids=ids): - return ids.has_key(str(row.fid)) - else: - def ff(row, nm=propname, ids=ids): - return ids.has_key(str(getattr(row, nm))) - ndxview = view.filter(ff) - vws.append(ndxview.unique()) - - # handle the empty match case - if not vws: - return [] - - ndxview = vws[0] - for v in vws[1:]: - ndxview = ndxview.union(v) - view = view.remapwith(ndxview) - rslt = [] - for row in view: - rslt.append(str(row.id)) - return rslt - - - def list(self): - l = [] - for row in self.getview().select(_isdel=0): - l.append(str(row.id)) - return l - def count(self): - return len(self.getview()) - def getprops(self, protected=1): - # protected is not in ping's spec - allprops = self.ruprops.copy() - if protected and self.privateprops is not None: - allprops.update(self.privateprops) - return allprops - def addprop(self, **properties): - for key in properties.keys(): - if self.ruprops.has_key(key): - raise ValueError, "%s is already a property of %s" % (key, self.classname) - self.ruprops.update(properties) - self.db.fastopen = 0 - view = self.__getview() - self.db.commit() - # ---- end of ping's spec - def filter(self, search_matches, filterspec, sort, group): - # search_matches is None or a set (dict of {nodeid: {propname:[nodeid,...]}}) - # filterspec is a dict {propname:value} - # sort and group are lists of propnames - # sort and group are (dir, prop) where dir is '+', '-' or None - # and prop is a prop name or None - - where = {'_isdel':0} - mlcriteria = {} - regexes = {} - orcriteria = {} - for propname, value in filterspec.items(): - prop = self.ruprops.get(propname, None) - if prop is None: - prop = self.privateprops[propname] - if isinstance(prop, hyperdb.Multilink): - if type(value) is not _LISTTYPE: - value = [value] - # transform keys to ids - u = [] - for item in value: - try: - item = int(item) - except (TypeError, ValueError): - item = int(self.db.getclass(prop.classname).lookup(item)) - if item == -1: - item = 0 - u.append(item) - mlcriteria[propname] = u - elif isinstance(prop, hyperdb.Link): - if type(value) is not _LISTTYPE: - value = [value] - # transform keys to ids - u = [] - for item in value: - try: - item = int(item) - except (TypeError, ValueError): - item = int(self.db.getclass(prop.classname).lookup(item)) - if item == -1: - item = 0 - u.append(item) - if len(u) == 1: - where[propname] = u[0] - else: - orcriteria[propname] = u - elif isinstance(prop, hyperdb.String): - # simple glob searching - v = re.sub(r'([\|\{\}\\\.\+\[\]\(\)])', r'\\\1', value) - v = v.replace('?', '.') - v = v.replace('*', '.*?') - regexes[propname] = re.compile(v, re.I) - elif propname == 'id': - where[propname] = int(value) - elif isinstance(prop, hyperdb.Boolean): - if type(value) is _STRINGTYPE: - bv = value.lower() in ('yes', 'true', 'on', '1') - else: - bv = value - where[propname] = bv - elif isinstance(prop, hyperdb.Number): - where[propname] = int(value) - else: - where[propname] = str(value) - v = self.getview() - #print "filter start at %s" % time.time() - if where: - v = v.select(where) - #print "filter where at %s" % time.time() - - if mlcriteria: - # multilink - if any of the nodeids required by the - # filterspec aren't in this node's property, then skip - # it - def ff(row, ml=mlcriteria): - for propname, values in ml.items(): - sv = getattr(row, propname) - for id in values: - if sv.find(fid=id) == -1: - return 0 - return 1 - iv = v.filter(ff) - v = v.remapwith(iv) - - #print "filter mlcrit at %s" % time.time() - - if orcriteria: - def ff(row, crit=orcriteria): - for propname, allowed in crit.items(): - val = getattr(row, propname) - if val not in allowed: - return 0 - return 1 - - iv = v.filter(ff) - v = v.remapwith(iv) - - #print "filter orcrit at %s" % time.time() - if regexes: - def ff(row, r=regexes): - for propname, regex in r.items(): - val = getattr(row, propname) - if not regex.search(val): - return 0 - return 1 - - iv = v.filter(ff) - v = v.remapwith(iv) - #print "filter regexs at %s" % time.time() - - if sort or group: - sortspec = [] - rev = [] - for dir, propname in group, sort: - if propname is None: continue - isreversed = 0 - if dir == '-': - isreversed = 1 - try: - prop = getattr(v, propname) - except AttributeError: - print "MK has no property %s" % propname - continue - propclass = self.ruprops.get(propname, None) - if propclass is None: - propclass = self.privateprops.get(propname, None) - if propclass is None: - print "Schema has no property %s" % propname - continue - if isinstance(propclass, hyperdb.Link): - linkclass = self.db.getclass(propclass.classname) - lv = linkclass.getview() - lv = lv.rename('id', propname) - v = v.join(lv, prop, 1) - if linkclass.getprops().has_key('order'): - propname = 'order' - else: - propname = linkclass.labelprop() - prop = getattr(v, propname) - if isreversed: - rev.append(prop) - sortspec.append(prop) - v = v.sortrev(sortspec, rev)[:] #XXX Metakit bug - #print "filter sort at %s" % time.time() - - rslt = [] - for row in v: - id = str(row.id) - if search_matches is not None: - if search_matches.has_key(id): - rslt.append(id) - else: - rslt.append(id) - return rslt - - def hasnode(self, nodeid): - return int(nodeid) < self.maxid - - def labelprop(self, default_to_id=0): - ''' Return the property name for a label for the given node. - - This method attempts to generate a consistent label for the node. - It tries the following in order: - 1. key property - 2. "name" property - 3. "title" property - 4. first property from the sorted property name list - ''' - k = self.getkey() - if k: - return k - props = self.getprops() - if props.has_key('name'): - return 'name' - elif props.has_key('title'): - return 'title' - if default_to_id: - return 'id' - props = props.keys() - props.sort() - return props[0] - def stringFind(self, **requirements): - """Locate a particular node by matching a set of its String - properties in a caseless search. - - If the property is not a String property, a TypeError is raised. - - The return is a list of the id of all nodes that match. - """ - for propname in requirements.keys(): - prop = self.properties[propname] - if isinstance(not prop, hyperdb.String): - raise TypeError, "'%s' not a String property"%propname - requirements[propname] = requirements[propname].lower() - requirements['_isdel'] = 0 - - l = [] - for row in self.getview().select(requirements): - l.append(str(row.id)) - return l - - def addjournal(self, nodeid, action, params): - self.db.addjournal(self.classname, nodeid, action, params) - - def index(self, nodeid): - ''' Add (or refresh) the node to search indexes ''' - # find all the String properties that have indexme - for prop, propclass in self.getprops().items(): - if isinstance(propclass, hyperdb.String) and propclass.indexme: - # index them under (classname, nodeid, property) - self.db.indexer.add_text((self.classname, nodeid, prop), - str(self.get(nodeid, prop))) - - def export_list(self, propnames, nodeid): - ''' Export a node - generate a list of CSV-able data in the order - specified by propnames for the given node. - ''' - properties = self.getprops() - l = [] - for prop in propnames: - proptype = properties[prop] - value = self.get(nodeid, prop) - # "marshal" data where needed - if value is None: - pass - elif isinstance(proptype, hyperdb.Date): - value = value.get_tuple() - elif isinstance(proptype, hyperdb.Interval): - value = value.get_tuple() - elif isinstance(proptype, hyperdb.Password): - value = str(value) - l.append(repr(value)) - return l - - def import_list(self, propnames, proplist): - ''' Import a node - all information including "id" is present and - should not be sanity checked. Triggers are not triggered. The - journal should be initialised using the "creator" and "creation" - information. - - Return the nodeid of the node imported. - ''' - if self.db.journaltag is None: - raise hyperdb.DatabaseError, 'Database open read-only' - properties = self.getprops() - - d = {} - view = self.getview(1) - for i in range(len(propnames)): - value = eval(proplist[i]) - propname = propnames[i] - prop = properties[propname] - if propname == 'id': - newid = value - value = int(value) - elif isinstance(prop, hyperdb.Date): - value = int(calendar.timegm(value)) - elif isinstance(prop, hyperdb.Interval): - value = str(date.Interval(value)) - d[propname] = value - view.append(d) - creator = d.get('creator', None) - creation = d.get('creation', None) - self.db.addjournal(self.classname, newid, 'create', {}, creator, creation) - return newid - - # --- used by Database - def _commit(self): - """ called post commit of the DB. - interested subclasses may override """ - self.uncommitted = {} - self.rbactions = [] - self.idcache = {} - def _rollback(self): - """ called pre rollback of the DB. - interested subclasses may override """ - for action in self.rbactions: - action() - self.rbactions = [] - self.uncommitted = {} - self.idcache = {} - def _clear(self): - view = self.getview(1) - if len(view): - view[:] = [] - self.db.dirty = 1 - iv = self.getindexview(1) - if iv: - iv[:] = [] - def rollbackaction(self, action): - """ call this to register a callback called on rollback - callback is removed on end of transaction """ - self.rbactions.append(action) - # --- internal - def __getview(self): - db = self.db._db - view = db.view(self.classname) - mkprops = view.structure() - if mkprops and self.db.fastopen: - return view.ordered(1) - # is the definition the same? - for nm, rutyp in self.ruprops.items(): - for mkprop in mkprops: - if mkprop.name == nm: - break - else: - mkprop = None - if mkprop is None: - break - if _typmap[rutyp.__class__] != mkprop.type: - break - else: - return view.ordered(1) - # need to create or restructure the mk view - # id comes first, so MK will order it for us - self.db.dirty = 1 - s = ["%s[id:I" % self.classname] - for nm, rutyp in self.ruprops.items(): - mktyp = _typmap[rutyp.__class__] - s.append('%s:%s' % (nm, mktyp)) - if mktyp == 'V': - s[-1] += ('[fid:I]') - s.append('_isdel:I,activity:I,creation:I,creator:I]') - v = self.db._db.getas(','.join(s)) - self.db.commit() - return v.ordered(1) - def getview(self, RW=0): - return self.db._db.view(self.classname).ordered(1) - def getindexview(self, RW=0): - return self.db._db.view("_%s" % self.classname).ordered(1) - -def _fetchML(sv): - l = [] - for row in sv: - if row.fid: - l.append(str(row.fid)) - return l - -def _fetchPW(s): - p = password.Password() - p.unpack(s) - return p - -def _fetchLink(n): - return n and str(n) or None - -def _fetchDate(n): - return date.Date(time.gmtime(n)) - -_converters = { - hyperdb.Date : _fetchDate, - hyperdb.Link : _fetchLink, - hyperdb.Multilink : _fetchML, - hyperdb.Interval : date.Interval, - hyperdb.Password : _fetchPW, - hyperdb.Boolean : lambda n: n, - hyperdb.Number : lambda n: n, - hyperdb.String : str, -} - -class FileName(hyperdb.String): - isfilename = 1 - -_typmap = { - FileName : 'S', - hyperdb.String : 'S', - hyperdb.Date : 'I', - hyperdb.Link : 'I', - hyperdb.Multilink : 'V', - hyperdb.Interval : 'S', - hyperdb.Password : 'S', - hyperdb.Boolean : 'I', - hyperdb.Number : 'I', -} -class FileClass(Class): - ' like Class but with a content property ' - default_mime_type = 'text/plain' - def __init__(self, db, classname, **properties): - properties['content'] = FileName() - if not properties.has_key('type'): - properties['type'] = hyperdb.String() - Class.__init__(self, db, classname, **properties) - def get(self, nodeid, propname, default=_marker, cache=1): - x = Class.get(self, nodeid, propname, default, cache) - if propname == 'content': - if x.startswith('file:'): - fnm = x[5:] - try: - x = open(fnm, 'rb').read() - except Exception, e: - x = repr(e) - return x - def create(self, **propvalues): - content = propvalues['content'] - del propvalues['content'] - newid = Class.create(self, **propvalues) - if not content: - return newid - nm = bnm = '%s%s' % (self.classname, newid) - sd = str(int(int(newid) / 1000)) - d = os.path.join(self.db.config.DATABASE, 'files', self.classname, sd) - if not os.path.exists(d): - os.makedirs(d) - nm = os.path.join(d, nm) - open(nm, 'wb').write(content) - self.set(newid, content = 'file:'+nm) - mimetype = propvalues.get('type', self.default_mime_type) - self.db.indexer.add_text((self.classname, newid, 'content'), content, mimetype) - def undo(fnm=nm, action1=os.remove, indexer=self.db.indexer): - action1(fnm) - self.rollbackaction(undo) - return newid - def index(self, nodeid): - Class.index(self, nodeid) - mimetype = self.get(nodeid, 'type') - if not mimetype: - mimetype = self.default_mime_type - self.db.indexer.add_text((self.classname, nodeid, 'content'), - self.get(nodeid, 'content'), mimetype) - -class IssueClass(Class, roundupdb.IssueClass): - # Overridden methods: - def __init__(self, db, classname, **properties): - """The newly-created class automatically includes the "messages", - "files", "nosy", and "superseder" properties. If the 'properties' - dictionary attempts to specify any of these properties or a - "creation" or "activity" property, a ValueError is raised.""" - if not properties.has_key('title'): - properties['title'] = hyperdb.String(indexme='yes') - if not properties.has_key('messages'): - properties['messages'] = hyperdb.Multilink("msg") - if not properties.has_key('files'): - properties['files'] = hyperdb.Multilink("file") - if not properties.has_key('nosy'): - # note: journalling is turned off as it really just wastes - # space. this behaviour may be overridden in an instance - properties['nosy'] = hyperdb.Multilink("user", do_journal="no") - if not properties.has_key('superseder'): - properties['superseder'] = hyperdb.Multilink(classname) - Class.__init__(self, db, classname, **properties) - -CURVERSION = 1 - -class Indexer(indexer.Indexer): - disallows = {'THE':1, 'THIS':1, 'ZZZ':1, 'THAT':1, 'WITH':1} - def __init__(self, path, datadb): - self.db = metakit.storage(os.path.join(path, 'index.mk4'), 1) - self.datadb = datadb - self.reindex = 0 - v = self.db.view('version') - if not v.structure(): - v = self.db.getas('version[vers:I]') - self.db.commit() - v.append(vers=CURVERSION) - self.reindex = 1 - elif v[0].vers != CURVERSION: - v[0].vers = CURVERSION - self.reindex = 1 - if self.reindex: - self.db.getas('ids[tblid:I,nodeid:I,propid:I]') - self.db.getas('index[word:S,hits[pos:I]]') - self.db.commit() - self.reindex = 1 - self.changed = 0 - self.propcache = {} - def force_reindex(self): - v = self.db.view('ids') - v[:] = [] - v = self.db.view('index') - v[:] = [] - self.db.commit() - self.reindex = 1 - def should_reindex(self): - return self.reindex - def _getprops(self, classname): - props = self.propcache.get(classname, None) - if props is None: - props = self.datadb.view(classname).structure() - props = [prop.name for prop in props] - self.propcache[classname] = props - return props - def _getpropid(self, classname, propname): - return self._getprops(classname).index(propname) - def _getpropname(self, classname, propid): - return self._getprops(classname)[propid] - - def add_text(self, identifier, text, mime_type='text/plain'): - if mime_type != 'text/plain': - return - classname, nodeid, property = identifier - tbls = self.datadb.view('tables') - tblid = tbls.find(name=classname) - if tblid < 0: - raise KeyError, "unknown class %r"%classname - nodeid = int(nodeid) - propid = self._getpropid(classname, property) - pos = self.db.view('ids').append(tblid=tblid,nodeid=nodeid,propid=propid) - - wordlist = re.findall(r'\b\w{3,25}\b', text) - words = {} - for word in wordlist: - word = word.upper() - if not self.disallows.has_key(word): - words[word] = 1 - words = words.keys() - - index = self.db.view('index').ordered(1) - for word in words: - ndx = index.find(word=word) - if ndx < 0: - ndx = index.append(word=word) - hits = index[ndx].hits - if len(hits)==0 or hits.find(pos=pos) < 0: - hits.append(pos=pos) - self.changed = 1 - - def find(self, wordlist): - hits = None - index = self.db.view('index').ordered(1) - for word in wordlist: - if not 2 < len(word) < 26: - continue - ndx = index.find(word=word) - if ndx < 0: - return {} - if hits is None: - hits = index[ndx].hits - else: - hits = hits.intersect(index[ndx].hits) - if len(hits) == 0: - return {} - if hits is None: - return {} - rslt = {} - ids = self.db.view('ids').remapwith(hits) - tbls = self.datadb.view('tables') - for i in range(len(ids)): - hit = ids[i] - classname = tbls[hit.tblid].name - nodeid = str(hit.nodeid) - property = self._getpropname(classname, hit.propid) - rslt[i] = (classname, nodeid, property) - return rslt - def save_index(self): - if self.changed: - self.db.commit() - self.changed = 0
--- a/roundup/backends/back_sqlite.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,134 +0,0 @@ -# $Id: back_sqlite.py,v 1.5 2002-09-24 01:59:28 richard Exp $ -__doc__ = ''' -See https://pysqlite.sourceforge.net/ for pysqlite info -''' -import base64, marshal -from roundup.backends.rdbms_common import * -import sqlite - -class Database(Database): - # char to use for positional arguments - arg = '%s' - - def open_connection(self): - # ensure files are group readable and writable - os.umask(0002) - db = os.path.join(self.config.DATABASE, 'db') - self.conn = sqlite.connect(db=db) - self.cursor = self.conn.cursor() - try: - self.database_schema = self.load_dbschema() - except sqlite.DatabaseError, error: - if str(error) != 'no such table: schema': - raise - self.database_schema = {} - self.cursor.execute('create table schema (schema varchar)') - self.cursor.execute('create table ids (name varchar, num integer)') - - def __repr__(self): - return '<roundlite 0x%x>'%id(self) - - def sql_fetchone(self): - ''' Fetch a single row. If there's nothing to fetch, return None. - ''' - return self.cursor.fetchone() - - def sql_fetchall(self): - ''' Fetch a single row. If there's nothing to fetch, return []. - ''' - return self.cursor.fetchall() - - def sql_commit(self): - ''' Actually commit to the database. - - Ignore errors if there's nothing to commit. - ''' - try: - self.conn.commit() - except sqlite.DatabaseError, error: - if str(error) != 'cannot commit - no transaction is active': - raise - - def save_dbschema(self, schema): - ''' Save the schema definition that the database currently implements - ''' - s = repr(self.database_schema) - self.sql('insert into schema values (%s)', (s,)) - - def load_dbschema(self): - ''' Load the schema definition that the database currently implements - ''' - self.cursor.execute('select schema from schema') - return eval(self.cursor.fetchone()[0]) - - def save_journal(self, classname, cols, nodeid, journaldate, - journaltag, action, params): - ''' Save the journal entry to the database - ''' - # make the params db-friendly - params = repr(params) - entry = (nodeid, journaldate, journaltag, action, params) - - # do the insert - a = self.arg - sql = 'insert into %s__journal (%s) values (%s,%s,%s,%s,%s)'%(classname, - cols, a, a, a, a, a) - if __debug__: - print >>hyperdb.DEBUG, 'addjournal', (self, sql, entry) - self.cursor.execute(sql, entry) - - def load_journal(self, classname, cols, nodeid): - ''' Load the journal from the database - ''' - # now get the journal entries - sql = 'select %s from %s__journal where nodeid=%s'%(cols, classname, - self.arg) - if __debug__: - print >>hyperdb.DEBUG, 'getjournal', (self, sql, nodeid) - self.cursor.execute(sql, (nodeid,)) - res = [] - for nodeid, date_stamp, user, action, params in self.cursor.fetchall(): - params = eval(params) - res.append((nodeid, date.Date(date_stamp), user, action, params)) - return res - - def unserialise(self, classname, node): - ''' Decode the marshalled node data - - SQLite stringifies _everything_... so we need to re-numberificate - Booleans and Numbers. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'unserialise', classname, node - properties = self.getclass(classname).getprops() - d = {} - for k, v in node.items(): - # if the property doesn't exist, or is the "retired" flag then - # it won't be in the properties dict - if not properties.has_key(k): - d[k] = v - continue - - # get the property spec - prop = properties[k] - - if isinstance(prop, Date) and v is not None: - d[k] = date.Date(v) - elif isinstance(prop, Interval) and v is not None: - d[k] = date.Interval(v) - elif isinstance(prop, Password): - p = password.Password() - p.unpack(v) - d[k] = p - elif isinstance(prop, Boolean) and v is not None: - d[k] = int(v) - elif isinstance(prop, Number) and v is not None: - # try int first, then assume it's a float - try: - d[k] = int(v) - except ValueError: - d[k] = float(v) - else: - d[k] = v - return d -
--- a/roundup/backends/portalocker.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,93 +0,0 @@ -# portalocker.py - Cross-platform (posix/nt) API for flock-style file locking. -# Requires python 1.5.2 or better. - -# ID line added by richard for Roundup file tracking -# $Id: portalocker.py,v 1.1 2002-04-15 23:25:15 richard Exp $ - -"""Cross-platform (posix/nt) API for flock-style file locking. - -Synopsis: - - import portalocker - file = open("somefile", "r+") - portalocker.lock(file, portalocker.LOCK_EX) - file.seek(12) - file.write("foo") - file.close() - -If you know what you're doing, you may choose to - - portalocker.unlock(file) - -before closing the file, but why? - -Methods: - - lock( file, flags ) - unlock( file ) - -Constants: - - LOCK_EX - LOCK_SH - LOCK_NB - -I learned the win32 technique for locking files from sample code -provided by John Nielsen <nielsenjf@my-deja.com> in the documentation -that accompanies the win32 modules. - -Author: Jonathan Feinberg <jdf@pobox.com> -Version: Id: portalocker.py,v 1.3 2001/05/29 18:47:55 Administrator Exp - **un-cvsified by richard so the version doesn't change** -""" -import os - -if os.name == 'nt': - import win32con - import win32file - import pywintypes - LOCK_EX = win32con.LOCKFILE_EXCLUSIVE_LOCK - LOCK_SH = 0 # the default - LOCK_NB = win32con.LOCKFILE_FAIL_IMMEDIATELY - # is there any reason not to reuse the following structure? - __overlapped = pywintypes.OVERLAPPED() -elif os.name == 'posix': - import fcntl - LOCK_EX = fcntl.LOCK_EX - LOCK_SH = fcntl.LOCK_SH - LOCK_NB = fcntl.LOCK_NB -else: - raise RuntimeError("PortaLocker only defined for nt and posix platforms") - -if os.name == 'nt': - def lock(file, flags): - hfile = win32file._get_osfhandle(file.fileno()) - win32file.LockFileEx(hfile, flags, 0, 0xffff0000, __overlapped) - - def unlock(file): - hfile = win32file._get_osfhandle(file.fileno()) - win32file.UnlockFileEx(hfile, 0, 0xffff0000, __overlapped) - -elif os.name =='posix': - def lock(file, flags): - fcntl.flock(file.fileno(), flags) - - def unlock(file): - fcntl.flock(file.fileno(), fcntl.LOCK_UN) - -if __name__ == '__main__': - from time import time, strftime, localtime - import sys - import portalocker - - log = open('log.txt', "a+") - portalocker.lock(log, portalocker.LOCK_EX) - - timestamp = strftime("%m/%d/%Y %H:%M:%S\n", localtime(time())) - log.write( timestamp ) - - print "Wrote lines. Hit enter to release lock." - dummy = sys.stdin.readline() - - log.close() -
--- a/roundup/backends/rdbms_common.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,2022 +0,0 @@ -# $Id: rdbms_common.py,v 1.19 2002-09-26 03:04:24 richard Exp $ - -# standard python modules -import sys, os, time, re, errno, weakref, copy - -# roundup modules -from roundup import hyperdb, date, password, roundupdb, security -from roundup.hyperdb import String, Password, Date, Interval, Link, \ - Multilink, DatabaseError, Boolean, Number - -# support -from blobfiles import FileStorage -from roundup.indexer import Indexer -from sessions import Sessions - -# number of rows to keep in memory -ROW_CACHE_SIZE = 100 - -class Database(FileStorage, hyperdb.Database, roundupdb.Database): - ''' Wrapper around an SQL database that presents a hyperdb interface. - - - some functionality is specific to the actual SQL database, hence - the sql_* methods that are NotImplemented - - we keep a cache of the latest ROW_CACHE_SIZE row fetches. - ''' - def __init__(self, config, journaltag=None): - ''' Open the database and load the schema from it. - ''' - self.config, self.journaltag = config, journaltag - self.dir = config.DATABASE - self.classes = {} - self.indexer = Indexer(self.dir) - self.sessions = Sessions(self.config) - self.security = security.Security(self) - - # additional transaction support for external files and the like - self.transactions = [] - - # keep a cache of the N most recently retrieved rows of any kind - # (classname, nodeid) = row - self.cache = {} - self.cache_lru = [] - - # open a connection to the database, creating the "conn" attribute - self.open_connection() - - def clearCache(self): - self.cache = {} - self.cache_lru = [] - - def open_connection(self): - ''' Open a connection to the database, creating it if necessary - ''' - raise NotImplemented - - def sql(self, sql, args=None): - ''' Execute the sql with the optional args. - ''' - if __debug__: - print >>hyperdb.DEBUG, (self, sql, args) - if args: - self.cursor.execute(sql, args) - else: - self.cursor.execute(sql) - - def sql_fetchone(self): - ''' Fetch a single row. If there's nothing to fetch, return None. - ''' - raise NotImplemented - - def sql_stringquote(self, value): - ''' Quote the string so it's safe to put in the 'sql quotes' - ''' - return re.sub("'", "''", str(value)) - - def save_dbschema(self, schema): - ''' Save the schema definition that the database currently implements - ''' - raise NotImplemented - - def load_dbschema(self): - ''' Load the schema definition that the database currently implements - ''' - raise NotImplemented - - def post_init(self): - ''' Called once the schema initialisation has finished. - - We should now confirm that the schema defined by our "classes" - attribute actually matches the schema in the database. - ''' - # now detect changes in the schema - save = 0 - for classname, spec in self.classes.items(): - if self.database_schema.has_key(classname): - dbspec = self.database_schema[classname] - if self.update_class(spec, dbspec): - self.database_schema[classname] = spec.schema() - save = 1 - else: - self.create_class(spec) - self.database_schema[classname] = spec.schema() - save = 1 - - for classname in self.database_schema.keys(): - if not self.classes.has_key(classname): - self.drop_class(classname) - - # update the database version of the schema - if save: - self.sql('delete from schema') - self.save_dbschema(self.database_schema) - - # reindex the db if necessary - if self.indexer.should_reindex(): - self.reindex() - - # commit - self.conn.commit() - - # figure the "curuserid" - if self.journaltag is None: - self.curuserid = None - elif self.journaltag == 'admin': - # admin user may not exist, but always has ID 1 - self.curuserid = '1' - else: - self.curuserid = self.user.lookup(self.journaltag) - - def reindex(self): - for klass in self.classes.values(): - for nodeid in klass.list(): - klass.index(nodeid) - self.indexer.save_index() - - def determine_columns(self, properties): - ''' Figure the column names and multilink properties from the spec - - "properties" is a list of (name, prop) where prop may be an - instance of a hyperdb "type" _or_ a string repr of that type. - ''' - cols = ['_activity', '_creator', '_creation'] - mls = [] - # add the multilinks separately - for col, prop in properties: - if isinstance(prop, Multilink): - mls.append(col) - elif isinstance(prop, type('')) and prop.find('Multilink') != -1: - mls.append(col) - else: - cols.append('_'+col) - cols.sort() - return cols, mls - - def update_class(self, spec, dbspec): - ''' Determine the differences between the current spec and the - database version of the spec, and update where necessary - ''' - spec_schema = spec.schema() - if spec_schema == dbspec: - # no save needed for this one - return 0 - if __debug__: - print >>hyperdb.DEBUG, 'update_class FIRING' - - # key property changed? - if dbspec[0] != spec_schema[0]: - if __debug__: - print >>hyperdb.DEBUG, 'update_class setting keyprop', `spec[0]` - # XXX turn on indexing for the key property - - # dict 'em up - spec_propnames,spec_props = [],{} - for propname,prop in spec_schema[1]: - spec_propnames.append(propname) - spec_props[propname] = prop - dbspec_propnames,dbspec_props = [],{} - for propname,prop in dbspec[1]: - dbspec_propnames.append(propname) - dbspec_props[propname] = prop - - # now compare - for propname in spec_propnames: - prop = spec_props[propname] - if dbspec_props.has_key(propname) and prop==dbspec_props[propname]: - continue - if __debug__: - print >>hyperdb.DEBUG, 'update_class ADD', (propname, prop) - - if not dbspec_props.has_key(propname): - # add the property - if isinstance(prop, Multilink): - # all we have to do here is create a new table, easy! - self.create_multilink_table(spec, propname) - continue - - # no ALTER TABLE, so we: - # 1. pull out the data, including an extra None column - oldcols, x = self.determine_columns(dbspec[1]) - oldcols.append('id') - oldcols.append('__retired__') - cn = spec.classname - sql = 'select %s,%s from _%s'%(','.join(oldcols), self.arg, cn) - if __debug__: - print >>hyperdb.DEBUG, 'update_class', (self, sql, None) - self.cursor.execute(sql, (None,)) - olddata = self.cursor.fetchall() - - # 2. drop the old table - self.cursor.execute('drop table _%s'%cn) - - # 3. create the new table - cols, mls = self.create_class_table(spec) - # ensure the new column is last - cols.remove('_'+propname) - assert oldcols == cols, "Column lists don't match!" - cols.append('_'+propname) - - # 4. populate with the data from step one - s = ','.join([self.arg for x in cols]) - scols = ','.join(cols) - sql = 'insert into _%s (%s) values (%s)'%(cn, scols, s) - - # GAH, nothing had better go wrong from here on in... but - # we have to commit the drop... - # XXX this isn't necessary in sqlite :( - self.conn.commit() - - # do the insert - for row in olddata: - self.sql(sql, tuple(row)) - - else: - # modify the property - if __debug__: - print >>hyperdb.DEBUG, 'update_class NOOP' - pass # NOOP in gadfly - - # and the other way - only worry about deletions here - for propname in dbspec_propnames: - prop = dbspec_props[propname] - if spec_props.has_key(propname): - continue - if __debug__: - print >>hyperdb.DEBUG, 'update_class REMOVE', `prop` - - # delete the property - if isinstance(prop, Multilink): - sql = 'drop table %s_%s'%(spec.classname, prop) - if __debug__: - print >>hyperdb.DEBUG, 'update_class', (self, sql) - self.cursor.execute(sql) - else: - # no ALTER TABLE, so we: - # 1. pull out the data, excluding the removed column - oldcols, x = self.determine_columns(spec.properties.items()) - oldcols.append('id') - oldcols.append('__retired__') - # remove the missing column - oldcols.remove('_'+propname) - cn = spec.classname - sql = 'select %s from _%s'%(','.join(oldcols), cn) - self.cursor.execute(sql, (None,)) - olddata = sql.fetchall() - - # 2. drop the old table - self.cursor.execute('drop table _%s'%cn) - - # 3. create the new table - cols, mls = self.create_class_table(self, spec) - assert oldcols != cols, "Column lists don't match!" - - # 4. populate with the data from step one - qs = ','.join([self.arg for x in cols]) - sql = 'insert into _%s values (%s)'%(cn, s) - self.cursor.execute(sql, olddata) - return 1 - - def create_class_table(self, spec): - ''' create the class table for the given spec - ''' - cols, mls = self.determine_columns(spec.properties.items()) - - # add on our special columns - cols.append('id') - cols.append('__retired__') - - # create the base table - scols = ','.join(['%s varchar'%x for x in cols]) - sql = 'create table _%s (%s)'%(spec.classname, scols) - if __debug__: - print >>hyperdb.DEBUG, 'create_class', (self, sql) - self.cursor.execute(sql) - - return cols, mls - - def create_journal_table(self, spec): - ''' create the journal table for a class given the spec and - already-determined cols - ''' - # journal table - cols = ','.join(['%s varchar'%x - for x in 'nodeid date tag action params'.split()]) - sql = 'create table %s__journal (%s)'%(spec.classname, cols) - if __debug__: - print >>hyperdb.DEBUG, 'create_class', (self, sql) - self.cursor.execute(sql) - - def create_multilink_table(self, spec, ml): - ''' Create a multilink table for the "ml" property of the class - given by the spec - ''' - sql = 'create table %s_%s (linkid varchar, nodeid varchar)'%( - spec.classname, ml) - if __debug__: - print >>hyperdb.DEBUG, 'create_class', (self, sql) - self.cursor.execute(sql) - - def create_class(self, spec): - ''' Create a database table according to the given spec. - ''' - cols, mls = self.create_class_table(spec) - self.create_journal_table(spec) - - # now create the multilink tables - for ml in mls: - self.create_multilink_table(spec, ml) - - # ID counter - sql = 'insert into ids (name, num) values (%s,%s)'%(self.arg, self.arg) - vals = (spec.classname, 1) - if __debug__: - print >>hyperdb.DEBUG, 'create_class', (self, sql, vals) - self.cursor.execute(sql, vals) - - def drop_class(self, spec): - ''' Drop the given table from the database. - - Drop the journal and multilink tables too. - ''' - # figure the multilinks - mls = [] - for col, prop in spec.properties.items(): - if isinstance(prop, Multilink): - mls.append(col) - - sql = 'drop table _%s'%spec.classname - if __debug__: - print >>hyperdb.DEBUG, 'drop_class', (self, sql) - self.cursor.execute(sql) - - sql = 'drop table %s__journal'%spec.classname - if __debug__: - print >>hyperdb.DEBUG, 'drop_class', (self, sql) - self.cursor.execute(sql) - - for ml in mls: - sql = 'drop table %s_%s'%(spec.classname, ml) - if __debug__: - print >>hyperdb.DEBUG, 'drop_class', (self, sql) - self.cursor.execute(sql) - - # - # Classes - # - def __getattr__(self, classname): - ''' A convenient way of calling self.getclass(classname). - ''' - if self.classes.has_key(classname): - if __debug__: - print >>hyperdb.DEBUG, '__getattr__', (self, classname) - return self.classes[classname] - raise AttributeError, classname - - def addclass(self, cl): - ''' Add a Class to the hyperdatabase. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'addclass', (self, cl) - cn = cl.classname - if self.classes.has_key(cn): - raise ValueError, cn - self.classes[cn] = cl - - def getclasses(self): - ''' Return a list of the names of all existing classes. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'getclasses', (self,) - l = self.classes.keys() - l.sort() - return l - - def getclass(self, classname): - '''Get the Class object representing a particular class. - - If 'classname' is not a valid class name, a KeyError is raised. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'getclass', (self, classname) - try: - return self.classes[classname] - except KeyError: - raise KeyError, 'There is no class called "%s"'%classname - - def clear(self): - ''' Delete all database contents. - - Note: I don't commit here, which is different behaviour to the - "nuke from orbit" behaviour in the *dbms. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'clear', (self,) - for cn in self.classes.keys(): - sql = 'delete from _%s'%cn - if __debug__: - print >>hyperdb.DEBUG, 'clear', (self, sql) - self.cursor.execute(sql) - - # - # Node IDs - # - def newid(self, classname): - ''' Generate a new id for the given class - ''' - # get the next ID - sql = 'select num from ids where name=%s'%self.arg - if __debug__: - print >>hyperdb.DEBUG, 'newid', (self, sql, classname) - self.cursor.execute(sql, (classname, )) - newid = self.cursor.fetchone()[0] - - # update the counter - sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg) - vals = (int(newid)+1, classname) - if __debug__: - print >>hyperdb.DEBUG, 'newid', (self, sql, vals) - self.cursor.execute(sql, vals) - - # return as string - return str(newid) - - def setid(self, classname, setid): - ''' Set the id counter: used during import of database - ''' - sql = 'update ids set num=%s where name=%s'%(self.arg, self.arg) - vals = (setid, classname) - if __debug__: - print >>hyperdb.DEBUG, 'setid', (self, sql, vals) - self.cursor.execute(sql, vals) - - # - # Nodes - # - - def addnode(self, classname, nodeid, node): - ''' Add the specified node to its class's db. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'addnode', (self, classname, nodeid, node) - # gadfly requires values for all non-multilink columns - cl = self.classes[classname] - cols, mls = self.determine_columns(cl.properties.items()) - - # we'll be supplied these props if we're doing an import - if not node.has_key('creator'): - # add in the "calculated" properties (dupe so we don't affect - # calling code's node assumptions) - node = node.copy() - node['creation'] = node['activity'] = date.Date() - node['creator'] = self.curuserid - - # default the non-multilink columns - for col, prop in cl.properties.items(): - if not isinstance(col, Multilink): - if not node.has_key(col): - node[col] = None - - # clear this node out of the cache if it's in there - key = (classname, nodeid) - if self.cache.has_key(key): - del self.cache[key] - self.cache_lru.remove(key) - - # make the node data safe for the DB - node = self.serialise(classname, node) - - # make sure the ordering is correct for column name -> column value - vals = tuple([node[col[1:]] for col in cols]) + (nodeid, 0) - s = ','.join([self.arg for x in cols]) + ',%s,%s'%(self.arg, self.arg) - cols = ','.join(cols) + ',id,__retired__' - - # perform the inserts - sql = 'insert into _%s (%s) values (%s)'%(classname, cols, s) - if __debug__: - print >>hyperdb.DEBUG, 'addnode', (self, sql, vals) - self.cursor.execute(sql, vals) - - # insert the multilink rows - for col in mls: - t = '%s_%s'%(classname, col) - for entry in node[col]: - sql = 'insert into %s (linkid, nodeid) values (%s,%s)'%(t, - self.arg, self.arg) - self.sql(sql, (entry, nodeid)) - - # make sure we do the commit-time extra stuff for this node - self.transactions.append((self.doSaveNode, (classname, nodeid, node))) - - def setnode(self, classname, nodeid, values, multilink_changes): - ''' Change the specified node. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'setnode', (self, classname, nodeid, values) - - # clear this node out of the cache if it's in there - key = (classname, nodeid) - if self.cache.has_key(key): - del self.cache[key] - self.cache_lru.remove(key) - - # add the special props - values = values.copy() - values['activity'] = date.Date() - - # make db-friendly - values = self.serialise(classname, values) - - cl = self.classes[classname] - cols = [] - mls = [] - # add the multilinks separately - props = cl.getprops() - for col in values.keys(): - prop = props[col] - if isinstance(prop, Multilink): - mls.append(col) - else: - cols.append('_'+col) - cols.sort() - - # if there's any updates to regular columns, do them - if cols: - # make sure the ordering is correct for column name -> column value - sqlvals = tuple([values[col[1:]] for col in cols]) + (nodeid,) - s = ','.join(['%s=%s'%(x, self.arg) for x in cols]) - cols = ','.join(cols) - - # perform the update - sql = 'update _%s set %s where id=%s'%(classname, s, self.arg) - if __debug__: - print >>hyperdb.DEBUG, 'setnode', (self, sql, sqlvals) - self.cursor.execute(sql, sqlvals) - - # now the fun bit, updating the multilinks ;) - for col, (add, remove) in multilink_changes.items(): - tn = '%s_%s'%(classname, col) - if add: - sql = 'insert into %s (nodeid, linkid) values (%s,%s)'%(tn, - self.arg, self.arg) - for addid in add: - self.sql(sql, (nodeid, addid)) - if remove: - sql = 'delete from %s where nodeid=%s and linkid=%s'%(tn, - self.arg, self.arg) - for removeid in remove: - self.sql(sql, (nodeid, removeid)) - - # make sure we do the commit-time extra stuff for this node - self.transactions.append((self.doSaveNode, (classname, nodeid, values))) - - def getnode(self, classname, nodeid): - ''' Get a node from the database. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'getnode', (self, classname, nodeid) - - # see if we have this node cached - key = (classname, nodeid) - if self.cache.has_key(key): - # push us back to the top of the LRU - self.cache_lru.remove(key) - self.cache_lru.insert(0, key) - # return the cached information - return self.cache[key] - - # figure the columns we're fetching - cl = self.classes[classname] - cols, mls = self.determine_columns(cl.properties.items()) - scols = ','.join(cols) - - # perform the basic property fetch - sql = 'select %s from _%s where id=%s'%(scols, classname, self.arg) - self.sql(sql, (nodeid,)) - - values = self.sql_fetchone() - if values is None: - raise IndexError, 'no such %s node %s'%(classname, nodeid) - - # make up the node - node = {} - for col in range(len(cols)): - node[cols[col][1:]] = values[col] - - # now the multilinks - for col in mls: - # get the link ids - sql = 'select linkid from %s_%s where nodeid=%s'%(classname, col, - self.arg) - self.cursor.execute(sql, (nodeid,)) - # extract the first column from the result - node[col] = [x[0] for x in self.cursor.fetchall()] - - # un-dbificate the node data - node = self.unserialise(classname, node) - - # save off in the cache - key = (classname, nodeid) - self.cache[key] = node - # update the LRU - self.cache_lru.insert(0, key) - if len(self.cache_lru) > ROW_CACHE_SIZE: - del self.cache[self.cache_lru.pop()] - - return node - - def destroynode(self, classname, nodeid): - '''Remove a node from the database. Called exclusively by the - destroy() method on Class. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'destroynode', (self, classname, nodeid) - - # make sure the node exists - if not self.hasnode(classname, nodeid): - raise IndexError, '%s has no node %s'%(classname, nodeid) - - # see if we have this node cached - if self.cache.has_key((classname, nodeid)): - del self.cache[(classname, nodeid)] - - # see if there's any obvious commit actions that we should get rid of - for entry in self.transactions[:]: - if entry[1][:2] == (classname, nodeid): - self.transactions.remove(entry) - - # now do the SQL - sql = 'delete from _%s where id=%s'%(classname, self.arg) - self.sql(sql, (nodeid,)) - - # remove from multilnks - cl = self.getclass(classname) - x, mls = self.determine_columns(cl.properties.items()) - for col in mls: - # get the link ids - sql = 'delete from %s_%s where nodeid=%s'%(classname, col, self.arg) - self.cursor.execute(sql, (nodeid,)) - - # remove journal entries - sql = 'delete from %s__journal where nodeid=%s'%(classname, self.arg) - self.sql(sql, (nodeid,)) - - def serialise(self, classname, node): - '''Copy the node contents, converting non-marshallable data into - marshallable data. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'serialise', classname, node - properties = self.getclass(classname).getprops() - d = {} - for k, v in node.items(): - # if the property doesn't exist, or is the "retired" flag then - # it won't be in the properties dict - if not properties.has_key(k): - d[k] = v - continue - - # get the property spec - prop = properties[k] - - if isinstance(prop, Password): - d[k] = str(v) - elif isinstance(prop, Date) and v is not None: - d[k] = v.serialise() - elif isinstance(prop, Interval) and v is not None: - d[k] = v.serialise() - else: - d[k] = v - return d - - def unserialise(self, classname, node): - '''Decode the marshalled node data - ''' - if __debug__: - print >>hyperdb.DEBUG, 'unserialise', classname, node - properties = self.getclass(classname).getprops() - d = {} - for k, v in node.items(): - # if the property doesn't exist, or is the "retired" flag then - # it won't be in the properties dict - if not properties.has_key(k): - d[k] = v - continue - - # get the property spec - prop = properties[k] - - if isinstance(prop, Date) and v is not None: - d[k] = date.Date(v) - elif isinstance(prop, Interval) and v is not None: - d[k] = date.Interval(v) - elif isinstance(prop, Password): - p = password.Password() - p.unpack(v) - d[k] = p - else: - d[k] = v - return d - - def hasnode(self, classname, nodeid): - ''' Determine if the database has a given node. - ''' - sql = 'select count(*) from _%s where id=%s'%(classname, self.arg) - if __debug__: - print >>hyperdb.DEBUG, 'hasnode', (self, sql, nodeid) - self.cursor.execute(sql, (nodeid,)) - return int(self.cursor.fetchone()[0]) - - def countnodes(self, classname): - ''' Count the number of nodes that exist for a particular Class. - ''' - sql = 'select count(*) from _%s'%classname - if __debug__: - print >>hyperdb.DEBUG, 'countnodes', (self, sql) - self.cursor.execute(sql) - return self.cursor.fetchone()[0] - - def getnodeids(self, classname, retired=0): - ''' Retrieve all the ids of the nodes for a particular Class. - - Set retired=None to get all nodes. Otherwise it'll get all the - retired or non-retired nodes, depending on the flag. - ''' - # flip the sense of the flag if we don't want all of them - if retired is not None: - retired = not retired - sql = 'select id from _%s where __retired__ <> %s'%(classname, self.arg) - if __debug__: - print >>hyperdb.DEBUG, 'getnodeids', (self, sql, retired) - self.cursor.execute(sql, (retired,)) - return [x[0] for x in self.cursor.fetchall()] - - def addjournal(self, classname, nodeid, action, params, creator=None, - creation=None): - ''' Journal the Action - 'action' may be: - - 'create' or 'set' -- 'params' is a dictionary of property values - 'link' or 'unlink' -- 'params' is (classname, nodeid, propname) - 'retire' -- 'params' is None - ''' - # serialise the parameters now if necessary - if isinstance(params, type({})): - if action in ('set', 'create'): - params = self.serialise(classname, params) - - # handle supply of the special journalling parameters (usually - # supplied on importing an existing database) - if creator: - journaltag = creator - else: - journaltag = self.curuserid - if creation: - journaldate = creation.serialise() - else: - journaldate = date.Date().serialise() - - # create the journal entry - cols = ','.join('nodeid date tag action params'.split()) - - if __debug__: - print >>hyperdb.DEBUG, 'addjournal', (nodeid, journaldate, - journaltag, action, params) - - self.save_journal(classname, cols, nodeid, journaldate, - journaltag, action, params) - - def save_journal(self, classname, cols, nodeid, journaldate, - journaltag, action, params): - ''' Save the journal entry to the database - ''' - raise NotImplemented - - def getjournal(self, classname, nodeid): - ''' get the journal for id - ''' - # make sure the node exists - if not self.hasnode(classname, nodeid): - raise IndexError, '%s has no node %s'%(classname, nodeid) - - cols = ','.join('nodeid date tag action params'.split()) - return self.load_journal(classname, cols, nodeid) - - def load_journal(self, classname, cols, nodeid): - ''' Load the journal from the database - ''' - raise NotImplemented - - def pack(self, pack_before): - ''' Delete all journal entries except "create" before 'pack_before'. - ''' - # get a 'yyyymmddhhmmss' version of the date - date_stamp = pack_before.serialise() - - # do the delete - for classname in self.classes.keys(): - sql = "delete from %s__journal where date<%s and "\ - "action<>'create'"%(classname, self.arg) - if __debug__: - print >>hyperdb.DEBUG, 'pack', (self, sql, date_stamp) - self.cursor.execute(sql, (date_stamp,)) - - def sql_commit(self): - ''' Actually commit to the database. - ''' - self.conn.commit() - - def commit(self): - ''' Commit the current transactions. - - Save all data changed since the database was opened or since the - last commit() or rollback(). - ''' - if __debug__: - print >>hyperdb.DEBUG, 'commit', (self,) - - # commit the database - self.sql_commit() - - # now, do all the other transaction stuff - reindex = {} - for method, args in self.transactions: - reindex[method(*args)] = 1 - - # reindex the nodes that request it - for classname, nodeid in filter(None, reindex.keys()): - print >>hyperdb.DEBUG, 'commit.reindex', (classname, nodeid) - self.getclass(classname).index(nodeid) - - # save the indexer state - self.indexer.save_index() - - # clear out the transactions - self.transactions = [] - - def rollback(self): - ''' Reverse all actions from the current transaction. - - Undo all the changes made since the database was opened or the last - commit() or rollback() was performed. - ''' - if __debug__: - print >>hyperdb.DEBUG, 'rollback', (self,) - - # roll back - self.conn.rollback() - - # roll back "other" transaction stuff - for method, args in self.transactions: - # delete temporary files - if method == self.doStoreFile: - self.rollbackStoreFile(*args) - self.transactions = [] - - def doSaveNode(self, classname, nodeid, node): - ''' dummy that just generates a reindex event - ''' - # return the classname, nodeid so we reindex this content - return (classname, nodeid) - - def close(self): - ''' Close off the connection. - ''' - self.conn.close() - -# -# The base Class class -# -class Class(hyperdb.Class): - ''' The handle to a particular class of nodes in a hyperdatabase. - - All methods except __repr__ and getnode must be implemented by a - concrete backend Class. - ''' - - def __init__(self, db, classname, **properties): - '''Create a new class with a given name and property specification. - - 'classname' must not collide with the name of an existing class, - or a ValueError is raised. The keyword arguments in 'properties' - must map names to property objects, or a TypeError is raised. - ''' - if (properties.has_key('creation') or properties.has_key('activity') - or properties.has_key('creator')): - raise ValueError, '"creation", "activity" and "creator" are '\ - 'reserved' - - self.classname = classname - self.properties = properties - self.db = weakref.proxy(db) # use a weak ref to avoid circularity - self.key = '' - - # should we journal changes (default yes) - self.do_journal = 1 - - # do the db-related init stuff - db.addclass(self) - - self.auditors = {'create': [], 'set': [], 'retire': []} - self.reactors = {'create': [], 'set': [], 'retire': []} - - def schema(self): - ''' A dumpable version of the schema that we can store in the - database - ''' - return (self.key, [(x, repr(y)) for x,y in self.properties.items()]) - - def enableJournalling(self): - '''Turn journalling on for this class - ''' - self.do_journal = 1 - - def disableJournalling(self): - '''Turn journalling off for this class - ''' - self.do_journal = 0 - - # Editing nodes: - def create(self, **propvalues): - ''' Create a new node of this class and return its id. - - The keyword arguments in 'propvalues' map property names to values. - - The values of arguments must be acceptable for the types of their - corresponding properties or a TypeError is raised. - - If this class has a key property, it must be present and its value - must not collide with other key strings or a ValueError is raised. - - Any other properties on this class that are missing from the - 'propvalues' dictionary are set to None. - - If an id in a link or multilink property does not refer to a valid - node, an IndexError is raised. - ''' - if propvalues.has_key('id'): - raise KeyError, '"id" is reserved' - - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - - if propvalues.has_key('creation') or propvalues.has_key('activity'): - raise KeyError, '"creation" and "activity" are reserved' - - self.fireAuditors('create', None, propvalues) - - # new node's id - newid = self.db.newid(self.classname) - - # validate propvalues - num_re = re.compile('^\d+$') - for key, value in propvalues.items(): - if key == self.key: - try: - self.lookup(value) - except KeyError: - pass - else: - raise ValueError, 'node with key "%s" exists'%value - - # try to handle this property - try: - prop = self.properties[key] - except KeyError: - raise KeyError, '"%s" has no property "%s"'%(self.classname, - key) - - if value is not None and isinstance(prop, Link): - if type(value) != type(''): - raise ValueError, 'link value must be String' - link_class = self.properties[key].classname - # if it isn't a number, it's a key - if not num_re.match(value): - try: - value = self.db.classes[link_class].lookup(value) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - key, value, link_class) - elif not self.db.getclass(link_class).hasnode(value): - raise IndexError, '%s has no node %s'%(link_class, value) - - # save off the value - propvalues[key] = value - - # register the link with the newly linked node - if self.do_journal and self.properties[key].do_journal: - self.db.addjournal(link_class, value, 'link', - (self.classname, newid, key)) - - elif isinstance(prop, Multilink): - if type(value) != type([]): - raise TypeError, 'new property "%s" not a list of ids'%key - - # clean up and validate the list of links - link_class = self.properties[key].classname - l = [] - for entry in value: - if type(entry) != type(''): - raise ValueError, '"%s" multilink value (%r) '\ - 'must contain Strings'%(key, value) - # if it isn't a number, it's a key - if not num_re.match(entry): - try: - entry = self.db.classes[link_class].lookup(entry) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - key, entry, self.properties[key].classname) - l.append(entry) - value = l - propvalues[key] = value - - # handle additions - for nodeid in value: - if not self.db.getclass(link_class).hasnode(nodeid): - raise IndexError, '%s has no node %s'%(link_class, - nodeid) - # register the link with the newly linked node - if self.do_journal and self.properties[key].do_journal: - self.db.addjournal(link_class, nodeid, 'link', - (self.classname, newid, key)) - - elif isinstance(prop, String): - if type(value) != type(''): - raise TypeError, 'new property "%s" not a string'%key - - elif isinstance(prop, Password): - if not isinstance(value, password.Password): - raise TypeError, 'new property "%s" not a Password'%key - - elif isinstance(prop, Date): - if value is not None and not isinstance(value, date.Date): - raise TypeError, 'new property "%s" not a Date'%key - - elif isinstance(prop, Interval): - if value is not None and not isinstance(value, date.Interval): - raise TypeError, 'new property "%s" not an Interval'%key - - elif value is not None and isinstance(prop, Number): - try: - float(value) - except ValueError: - raise TypeError, 'new property "%s" not numeric'%key - - elif value is not None and isinstance(prop, Boolean): - try: - int(value) - except ValueError: - raise TypeError, 'new property "%s" not boolean'%key - - # make sure there's data where there needs to be - for key, prop in self.properties.items(): - if propvalues.has_key(key): - continue - if key == self.key: - raise ValueError, 'key property "%s" is required'%key - if isinstance(prop, Multilink): - propvalues[key] = [] - else: - propvalues[key] = None - - # done - self.db.addnode(self.classname, newid, propvalues) - if self.do_journal: - self.db.addjournal(self.classname, newid, 'create', propvalues) - - self.fireReactors('create', newid, None) - - return newid - - def export_list(self, propnames, nodeid): - ''' Export a node - generate a list of CSV-able data in the order - specified by propnames for the given node. - ''' - properties = self.getprops() - l = [] - for prop in propnames: - proptype = properties[prop] - value = self.get(nodeid, prop) - # "marshal" data where needed - if value is None: - pass - elif isinstance(proptype, hyperdb.Date): - value = value.get_tuple() - elif isinstance(proptype, hyperdb.Interval): - value = value.get_tuple() - elif isinstance(proptype, hyperdb.Password): - value = str(value) - l.append(repr(value)) - return l - - def import_list(self, propnames, proplist): - ''' Import a node - all information including "id" is present and - should not be sanity checked. Triggers are not triggered. The - journal should be initialised using the "creator" and "created" - information. - - Return the nodeid of the node imported. - ''' - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - properties = self.getprops() - - # make the new node's property map - d = {} - for i in range(len(propnames)): - # Use eval to reverse the repr() used to output the CSV - value = eval(proplist[i]) - - # Figure the property for this column - propname = propnames[i] - prop = properties[propname] - - # "unmarshal" where necessary - if propname == 'id': - newid = value - continue - elif value is None: - # don't set Nones - continue - elif isinstance(prop, hyperdb.Date): - value = date.Date(value) - elif isinstance(prop, hyperdb.Interval): - value = date.Interval(value) - elif isinstance(prop, hyperdb.Password): - pwd = password.Password() - pwd.unpack(value) - value = pwd - d[propname] = value - - # add the node and journal - self.db.addnode(self.classname, newid, d) - - # extract the extraneous journalling gumpf and nuke it - if d.has_key('creator'): - creator = d['creator'] - del d['creator'] - else: - creator = None - if d.has_key('creation'): - creation = d['creation'] - del d['creation'] - else: - creation = None - if d.has_key('activity'): - del d['activity'] - self.db.addjournal(self.classname, newid, 'create', d, creator, - creation) - return newid - - _marker = [] - def get(self, nodeid, propname, default=_marker, cache=1): - '''Get the value of a property on an existing node of this class. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. 'propname' must be the name of a property - of this class or a KeyError is raised. - - 'cache' indicates whether the transaction cache should be queried - for the node. If the node has been modified and you need to - determine what its values prior to modification are, you need to - set cache=0. - ''' - if propname == 'id': - return nodeid - - # get the node's dict - d = self.db.getnode(self.classname, nodeid) - - if propname == 'creation': - if d.has_key('creation'): - return d['creation'] - else: - return date.Date() - if propname == 'activity': - if d.has_key('activity'): - return d['activity'] - else: - return date.Date() - if propname == 'creator': - if d.has_key('creator'): - return d['creator'] - else: - return self.db.curuserid - - # get the property (raises KeyErorr if invalid) - prop = self.properties[propname] - - if not d.has_key(propname): - if default is self._marker: - if isinstance(prop, Multilink): - return [] - else: - return None - else: - return default - - # don't pass our list to other code - if isinstance(prop, Multilink): - return d[propname][:] - - return d[propname] - - def getnode(self, nodeid, cache=1): - ''' Return a convenience wrapper for the node. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. - - 'cache' indicates whether the transaction cache should be queried - for the node. If the node has been modified and you need to - determine what its values prior to modification are, you need to - set cache=0. - ''' - return Node(self, nodeid, cache=cache) - - def set(self, nodeid, **propvalues): - '''Modify a property on an existing node of this class. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. - - Each key in 'propvalues' must be the name of a property of this - class or a KeyError is raised. - - All values in 'propvalues' must be acceptable types for their - corresponding properties or a TypeError is raised. - - If the value of the key property is set, it must not collide with - other key strings or a ValueError is raised. - - If the value of a Link or Multilink property contains an invalid - node id, a ValueError is raised. - ''' - if not propvalues: - return propvalues - - if propvalues.has_key('creation') or propvalues.has_key('activity'): - raise KeyError, '"creation" and "activity" are reserved' - - if propvalues.has_key('id'): - raise KeyError, '"id" is reserved' - - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - - self.fireAuditors('set', nodeid, propvalues) - # Take a copy of the node dict so that the subsequent set - # operation doesn't modify the oldvalues structure. - # XXX used to try the cache here first - oldvalues = copy.deepcopy(self.db.getnode(self.classname, nodeid)) - - node = self.db.getnode(self.classname, nodeid) - if self.is_retired(nodeid): - raise IndexError, 'Requested item is retired' - num_re = re.compile('^\d+$') - - # if the journal value is to be different, store it in here - journalvalues = {} - - # remember the add/remove stuff for multilinks, making it easier - # for the Database layer to do its stuff - multilink_changes = {} - - for propname, value in propvalues.items(): - # check to make sure we're not duplicating an existing key - if propname == self.key and node[propname] != value: - try: - self.lookup(value) - except KeyError: - pass - else: - raise ValueError, 'node with key "%s" exists'%value - - # this will raise the KeyError if the property isn't valid - # ... we don't use getprops() here because we only care about - # the writeable properties. - try: - prop = self.properties[propname] - except KeyError: - raise KeyError, '"%s" has no property named "%s"'%( - self.classname, propname) - - # if the value's the same as the existing value, no sense in - # doing anything - if node.has_key(propname) and value == node[propname]: - del propvalues[propname] - continue - - # do stuff based on the prop type - if isinstance(prop, Link): - link_class = prop.classname - # if it isn't a number, it's a key - if value is not None and not isinstance(value, type('')): - raise ValueError, 'property "%s" link value be a string'%( - propname) - if isinstance(value, type('')) and not num_re.match(value): - try: - value = self.db.classes[link_class].lookup(value) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - propname, value, prop.classname) - - if (value is not None and - not self.db.getclass(link_class).hasnode(value)): - raise IndexError, '%s has no node %s'%(link_class, value) - - if self.do_journal and prop.do_journal: - # register the unlink with the old linked node - if node[propname] is not None: - self.db.addjournal(link_class, node[propname], 'unlink', - (self.classname, nodeid, propname)) - - # register the link with the newly linked node - if value is not None: - self.db.addjournal(link_class, value, 'link', - (self.classname, nodeid, propname)) - - elif isinstance(prop, Multilink): - if type(value) != type([]): - raise TypeError, 'new property "%s" not a list of'\ - ' ids'%propname - link_class = self.properties[propname].classname - l = [] - for entry in value: - # if it isn't a number, it's a key - if type(entry) != type(''): - raise ValueError, 'new property "%s" link value ' \ - 'must be a string'%propname - if not num_re.match(entry): - try: - entry = self.db.classes[link_class].lookup(entry) - except (TypeError, KeyError): - raise IndexError, 'new property "%s": %s not a %s'%( - propname, entry, - self.properties[propname].classname) - l.append(entry) - value = l - propvalues[propname] = value - - # figure the journal entry for this property - add = [] - remove = [] - - # handle removals - if node.has_key(propname): - l = node[propname] - else: - l = [] - for id in l[:]: - if id in value: - continue - # register the unlink with the old linked node - if self.do_journal and self.properties[propname].do_journal: - self.db.addjournal(link_class, id, 'unlink', - (self.classname, nodeid, propname)) - l.remove(id) - remove.append(id) - - # handle additions - for id in value: - if not self.db.getclass(link_class).hasnode(id): - raise IndexError, '%s has no node %s'%(link_class, id) - if id in l: - continue - # register the link with the newly linked node - if self.do_journal and self.properties[propname].do_journal: - self.db.addjournal(link_class, id, 'link', - (self.classname, nodeid, propname)) - l.append(id) - add.append(id) - - # figure the journal entry - l = [] - if add: - l.append(('+', add)) - if remove: - l.append(('-', remove)) - multilink_changes[propname] = (add, remove) - if l: - journalvalues[propname] = tuple(l) - - elif isinstance(prop, String): - if value is not None and type(value) != type(''): - raise TypeError, 'new property "%s" not a string'%propname - - elif isinstance(prop, Password): - if not isinstance(value, password.Password): - raise TypeError, 'new property "%s" not a Password'%propname - propvalues[propname] = value - - elif value is not None and isinstance(prop, Date): - if not isinstance(value, date.Date): - raise TypeError, 'new property "%s" not a Date'% propname - propvalues[propname] = value - - elif value is not None and isinstance(prop, Interval): - if not isinstance(value, date.Interval): - raise TypeError, 'new property "%s" not an '\ - 'Interval'%propname - propvalues[propname] = value - - elif value is not None and isinstance(prop, Number): - try: - float(value) - except ValueError: - raise TypeError, 'new property "%s" not numeric'%propname - - elif value is not None and isinstance(prop, Boolean): - try: - int(value) - except ValueError: - raise TypeError, 'new property "%s" not boolean'%propname - - # nothing to do? - if not propvalues: - return propvalues - - # do the set, and journal it - self.db.setnode(self.classname, nodeid, propvalues, multilink_changes) - - if self.do_journal: - propvalues.update(journalvalues) - self.db.addjournal(self.classname, nodeid, 'set', propvalues) - - self.fireReactors('set', nodeid, oldvalues) - - return propvalues - - def retire(self, nodeid): - '''Retire a node. - - The properties on the node remain available from the get() method, - and the node's id is never reused. - - Retired nodes are not returned by the find(), list(), or lookup() - methods, and other nodes may reuse the values of their key properties. - ''' - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - - # use the arg for __retired__ to cope with any odd database type - # conversion (hello, sqlite) - sql = 'update _%s set __retired__=%s where id=%s'%(self.classname, - self.db.arg, self.db.arg) - if __debug__: - print >>hyperdb.DEBUG, 'retire', (self, sql, nodeid) - self.db.cursor.execute(sql, (1, nodeid)) - - def is_retired(self, nodeid): - '''Return true if the node is rerired - ''' - sql = 'select __retired__ from _%s where id=%s'%(self.classname, - self.db.arg) - if __debug__: - print >>hyperdb.DEBUG, 'is_retired', (self, sql, nodeid) - self.db.cursor.execute(sql, (nodeid,)) - return int(self.db.sql_fetchone()[0]) - - def destroy(self, nodeid): - '''Destroy a node. - - WARNING: this method should never be used except in extremely rare - situations where there could never be links to the node being - deleted - WARNING: use retire() instead - WARNING: the properties of this node will not be available ever again - WARNING: really, use retire() instead - - Well, I think that's enough warnings. This method exists mostly to - support the session storage of the cgi interface. - - The node is completely removed from the hyperdb, including all journal - entries. It will no longer be available, and will generally break code - if there are any references to the node. - ''' - if self.db.journaltag is None: - raise DatabaseError, 'Database open read-only' - self.db.destroynode(self.classname, nodeid) - - def history(self, nodeid): - '''Retrieve the journal of edits on a particular node. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. - - The returned list contains tuples of the form - - (date, tag, action, params) - - 'date' is a Timestamp object specifying the time of the change and - 'tag' is the journaltag specified when the database was opened. - ''' - if not self.do_journal: - raise ValueError, 'Journalling is disabled for this class' - return self.db.getjournal(self.classname, nodeid) - - # Locating nodes: - def hasnode(self, nodeid): - '''Determine if the given nodeid actually exists - ''' - return self.db.hasnode(self.classname, nodeid) - - def setkey(self, propname): - '''Select a String property of this class to be the key property. - - 'propname' must be the name of a String property of this class or - None, or a TypeError is raised. The values of the key property on - all existing nodes must be unique or a ValueError is raised. - ''' - # XXX create an index on the key prop column - prop = self.getprops()[propname] - if not isinstance(prop, String): - raise TypeError, 'key properties must be String' - self.key = propname - - def getkey(self): - '''Return the name of the key property for this class or None.''' - return self.key - - def labelprop(self, default_to_id=0): - ''' Return the property name for a label for the given node. - - This method attempts to generate a consistent label for the node. - It tries the following in order: - 1. key property - 2. "name" property - 3. "title" property - 4. first property from the sorted property name list - ''' - k = self.getkey() - if k: - return k - props = self.getprops() - if props.has_key('name'): - return 'name' - elif props.has_key('title'): - return 'title' - if default_to_id: - return 'id' - props = props.keys() - props.sort() - return props[0] - - def lookup(self, keyvalue): - '''Locate a particular node by its key property and return its id. - - If this class has no key property, a TypeError is raised. If the - 'keyvalue' matches one of the values for the key property among - the nodes in this class, the matching node's id is returned; - otherwise a KeyError is raised. - ''' - if not self.key: - raise TypeError, 'No key property set for class %s'%self.classname - - # use the arg to handle any odd database type conversion (hello, - # sqlite) - sql = "select id from _%s where _%s=%s and __retired__ <> %s"%( - self.classname, self.key, self.db.arg, self.db.arg) - self.db.sql(sql, (keyvalue, 1)) - - # see if there was a result that's not retired - row = self.db.sql_fetchone() - if not row: - raise KeyError, 'No key (%s) value "%s" for "%s"'%(self.key, - keyvalue, self.classname) - - # return the id - return row[0] - - def find(self, **propspec): - '''Get the ids of nodes in this class which link to the given nodes. - - 'propspec' consists of keyword args propname=nodeid or - propname={nodeid:1, } - 'propname' must be the name of a property in this class, or a - KeyError is raised. That property must be a Link or Multilink - property, or a TypeError is raised. - - Any node in this class whose 'propname' property links to any of the - nodeids will be returned. Used by the full text indexing, which knows - that "foo" occurs in msg1, msg3 and file7, so we have hits on these - issues: - - db.issue.find(messages={'1':1,'3':1}, files={'7':1}) - ''' - if __debug__: - print >>hyperdb.DEBUG, 'find', (self, propspec) - - # shortcut - if not propspec: - return [] - - # validate the args - props = self.getprops() - propspec = propspec.items() - for propname, nodeids in propspec: - # check the prop is OK - prop = props[propname] - if not isinstance(prop, Link) and not isinstance(prop, Multilink): - raise TypeError, "'%s' not a Link/Multilink property"%propname - - # first, links - where = [] - allvalues = () - a = self.db.arg - for prop, values in propspec: - if not isinstance(props[prop], hyperdb.Link): - continue - if type(values) is type(''): - allvalues += (values,) - where.append('_%s = %s'%(prop, a)) - else: - allvalues += tuple(values.keys()) - where.append('_%s in (%s)'%(prop, ','.join([a]*len(values)))) - tables = [] - if where: - tables.append('select id as nodeid from _%s where %s'%( - self.classname, ' and '.join(where))) - - # now multilinks - for prop, values in propspec: - if not isinstance(props[prop], hyperdb.Multilink): - continue - if type(values) is type(''): - allvalues += (values,) - s = a - else: - allvalues += tuple(values.keys()) - s = ','.join([a]*len(values)) - tables.append('select nodeid from %s_%s where linkid in (%s)'%( - self.classname, prop, s)) - sql = '\nunion\n'.join(tables) - self.db.sql(sql, allvalues) - l = [x[0] for x in self.db.sql_fetchall()] - if __debug__: - print >>hyperdb.DEBUG, 'find ... ', l - return l - - def stringFind(self, **requirements): - '''Locate a particular node by matching a set of its String - properties in a caseless search. - - If the property is not a String property, a TypeError is raised. - - The return is a list of the id of all nodes that match. - ''' - where = [] - args = [] - for propname in requirements.keys(): - prop = self.properties[propname] - if isinstance(not prop, String): - raise TypeError, "'%s' not a String property"%propname - where.append(propname) - args.append(requirements[propname].lower()) - - # generate the where clause - s = ' and '.join(['_%s=%s'%(col, self.db.arg) for col in where]) - sql = 'select id from _%s where %s'%(self.classname, s) - self.db.sql(sql, tuple(args)) - l = [x[0] for x in self.db.sql_fetchall()] - if __debug__: - print >>hyperdb.DEBUG, 'find ... ', l - return l - - def list(self): - ''' Return a list of the ids of the active nodes in this class. - ''' - return self.db.getnodeids(self.classname, retired=0) - - def filter(self, search_matches, filterspec, sort, group): - ''' Return a list of the ids of the active nodes in this class that - match the 'filter' spec, sorted by the group spec and then the - sort spec - - "filterspec" is {propname: value(s)} - "sort" and "group" are (dir, prop) where dir is '+', '-' or None - and prop is a prop name or None - "search_matches" is {nodeid: marker} - - The filter must match all properties specificed - but if the - property value to match is a list, any one of the values in the - list may match for that property to match. - ''' - # just don't bother if the full-text search matched diddly - if search_matches == {}: - return [] - - cn = self.classname - - # figure the WHERE clause from the filterspec - props = self.getprops() - frum = ['_'+cn] - where = [] - args = [] - a = self.db.arg - for k, v in filterspec.items(): - propclass = props[k] - # now do other where clause stuff - if isinstance(propclass, Multilink): - tn = '%s_%s'%(cn, k) - frum.append(tn) - if isinstance(v, type([])): - s = ','.join([a for x in v]) - where.append('id=%s.nodeid and %s.linkid in (%s)'%(tn,tn,s)) - args = args + v - else: - where.append('id=%s.nodeid and %s.linkid = %s'%(tn, tn, a)) - args.append(v) - elif isinstance(propclass, String): - if not isinstance(v, type([])): - v = [v] - - # Quote the bits in the string that need it and then embed - # in a "substring" search. Note - need to quote the '%' so - # they make it through the python layer happily - v = ['%%'+self.db.sql_stringquote(s)+'%%' for s in v] - - # now add to the where clause - where.append(' or '.join(["_%s LIKE '%s'"%(k, s) for s in v])) - # note: args are embedded in the query string now - elif isinstance(propclass, Link): - if isinstance(v, type([])): - if '-1' in v: - v.remove('-1') - xtra = ' or _%s is NULL'%k - else: - xtra = '' - s = ','.join([a for x in v]) - where.append('(_%s in (%s)%s)'%(k, s, xtra)) - args = args + v - else: - if v == '-1': - v = None - where.append('_%s is NULL'%k) - else: - where.append('_%s=%s'%(k, a)) - args.append(v) - else: - if isinstance(v, type([])): - s = ','.join([a for x in v]) - where.append('_%s in (%s)'%(k, s)) - args = args + v - else: - where.append('_%s=%s'%(k, a)) - args.append(v) - - # add results of full text search - if search_matches is not None: - v = search_matches.keys() - s = ','.join([a for x in v]) - where.append('id in (%s)'%s) - args = args + v - - # "grouping" is just the first-order sorting in the SQL fetch - # can modify it...) - orderby = [] - ordercols = [] - if group[0] is not None and group[1] is not None: - if group[0] != '-': - orderby.append('_'+group[1]) - ordercols.append('_'+group[1]) - else: - orderby.append('_'+group[1]+' desc') - ordercols.append('_'+group[1]) - - # now add in the sorting - group = '' - if sort[0] is not None and sort[1] is not None: - direction, colname = sort - if direction != '-': - if colname == 'id': - orderby.append(colname) - else: - orderby.append('_'+colname) - ordercols.append('_'+colname) - else: - if colname == 'id': - orderby.append(colname+' desc') - ordercols.append(colname) - else: - orderby.append('_'+colname+' desc') - ordercols.append('_'+colname) - - # construct the SQL - frum = ','.join(frum) - if where: - where = ' where ' + (' and '.join(where)) - else: - where = '' - cols = ['id'] - if orderby: - cols = cols + ordercols - order = ' order by %s'%(','.join(orderby)) - else: - order = '' - cols = ','.join(cols) - sql = 'select %s from %s %s%s%s'%(cols, frum, where, group, order) - args = tuple(args) - if __debug__: - print >>hyperdb.DEBUG, 'filter', (self, sql, args) - self.db.cursor.execute(sql, args) - l = self.db.cursor.fetchall() - - # return the IDs (the first column) - # XXX The filter(None, l) bit is sqlite-specific... if there's _NO_ - # XXX matches to a fetch, it returns NULL instead of nothing!?! - return filter(None, [row[0] for row in l]) - - def count(self): - '''Get the number of nodes in this class. - - If the returned integer is 'numnodes', the ids of all the nodes - in this class run from 1 to numnodes, and numnodes+1 will be the - id of the next node to be created in this class. - ''' - return self.db.countnodes(self.classname) - - # Manipulating properties: - def getprops(self, protected=1): - '''Return a dictionary mapping property names to property objects. - If the "protected" flag is true, we include protected properties - - those which may not be modified. - ''' - d = self.properties.copy() - if protected: - d['id'] = String() - d['creation'] = hyperdb.Date() - d['activity'] = hyperdb.Date() - d['creator'] = hyperdb.Link('user') - return d - - def addprop(self, **properties): - '''Add properties to this class. - - The keyword arguments in 'properties' must map names to property - objects, or a TypeError is raised. None of the keys in 'properties' - may collide with the names of existing properties, or a ValueError - is raised before any properties have been added. - ''' - for key in properties.keys(): - if self.properties.has_key(key): - raise ValueError, key - self.properties.update(properties) - - def index(self, nodeid): - '''Add (or refresh) the node to search indexes - ''' - # find all the String properties that have indexme - for prop, propclass in self.getprops().items(): - if isinstance(propclass, String) and propclass.indexme: - try: - value = str(self.get(nodeid, prop)) - except IndexError: - # node no longer exists - entry should be removed - self.db.indexer.purge_entry((self.classname, nodeid, prop)) - else: - # and index them under (classname, nodeid, property) - self.db.indexer.add_text((self.classname, nodeid, prop), - value) - - - # - # Detector interface - # - def audit(self, event, detector): - '''Register a detector - ''' - l = self.auditors[event] - if detector not in l: - self.auditors[event].append(detector) - - def fireAuditors(self, action, nodeid, newvalues): - '''Fire all registered auditors. - ''' - for audit in self.auditors[action]: - audit(self.db, self, nodeid, newvalues) - - def react(self, event, detector): - '''Register a detector - ''' - l = self.reactors[event] - if detector not in l: - self.reactors[event].append(detector) - - def fireReactors(self, action, nodeid, oldvalues): - '''Fire all registered reactors. - ''' - for react in self.reactors[action]: - react(self.db, self, nodeid, oldvalues) - -class FileClass(Class): - '''This class defines a large chunk of data. To support this, it has a - mandatory String property "content" which is typically saved off - externally to the hyperdb. - - The default MIME type of this data is defined by the - "default_mime_type" class attribute, which may be overridden by each - node if the class defines a "type" String property. - ''' - default_mime_type = 'text/plain' - - def create(self, **propvalues): - ''' snaffle the file propvalue and store in a file - ''' - content = propvalues['content'] - del propvalues['content'] - newid = Class.create(self, **propvalues) - self.db.storefile(self.classname, newid, None, content) - return newid - - def import_list(self, propnames, proplist): - ''' Trap the "content" property... - ''' - # dupe this list so we don't affect others - propnames = propnames[:] - - # extract the "content" property from the proplist - i = propnames.index('content') - content = eval(proplist[i]) - del propnames[i] - del proplist[i] - - # do the normal import - newid = Class.import_list(self, propnames, proplist) - - # save off the "content" file - self.db.storefile(self.classname, newid, None, content) - return newid - - _marker = [] - def get(self, nodeid, propname, default=_marker, cache=1): - ''' trap the content propname and get it from the file - ''' - - poss_msg = 'Possibly a access right configuration problem.' - if propname == 'content': - try: - return self.db.getfile(self.classname, nodeid, None) - except IOError, (strerror): - # BUG: by catching this we donot see an error in the log. - return 'ERROR reading file: %s%s\n%s\n%s'%( - self.classname, nodeid, poss_msg, strerror) - if default is not self._marker: - return Class.get(self, nodeid, propname, default, cache=cache) - else: - return Class.get(self, nodeid, propname, cache=cache) - - def getprops(self, protected=1): - ''' In addition to the actual properties on the node, these methods - provide the "content" property. If the "protected" flag is true, - we include protected properties - those which may not be - modified. - ''' - d = Class.getprops(self, protected=protected).copy() - d['content'] = hyperdb.String() - return d - - def index(self, nodeid): - ''' Index the node in the search index. - - We want to index the content in addition to the normal String - property indexing. - ''' - # perform normal indexing - Class.index(self, nodeid) - - # get the content to index - content = self.get(nodeid, 'content') - - # figure the mime type - if self.properties.has_key('type'): - mime_type = self.get(nodeid, 'type') - else: - mime_type = self.default_mime_type - - # and index! - self.db.indexer.add_text((self.classname, nodeid, 'content'), content, - mime_type) - -# XXX deviation from spec - was called ItemClass -class IssueClass(Class, roundupdb.IssueClass): - # Overridden methods: - def __init__(self, db, classname, **properties): - '''The newly-created class automatically includes the "messages", - "files", "nosy", and "superseder" properties. If the 'properties' - dictionary attempts to specify any of these properties or a - "creation" or "activity" property, a ValueError is raised. - ''' - if not properties.has_key('title'): - properties['title'] = hyperdb.String(indexme='yes') - if not properties.has_key('messages'): - properties['messages'] = hyperdb.Multilink("msg") - if not properties.has_key('files'): - properties['files'] = hyperdb.Multilink("file") - if not properties.has_key('nosy'): - # note: journalling is turned off as it really just wastes - # space. this behaviour may be overridden in an instance - properties['nosy'] = hyperdb.Multilink("user", do_journal="no") - if not properties.has_key('superseder'): - properties['superseder'] = hyperdb.Multilink(classname) - Class.__init__(self, db, classname, **properties) -
--- a/roundup/cgi/PageTemplates/PythonExpr.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,87 +0,0 @@ -############################################################################## -# -# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved. -# -# This software is subject to the provisions of the Zope Public License, -# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. -# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED -# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS -# FOR A PARTICULAR PURPOSE -# -############################################################################## - -"""Generic Python Expression Handler - -Modified for Roundup 0.5 release: - -- more informative traceback info - -""" - -__version__='$Revision: 1.3 $'[11:-2] - -from TALES import CompilerError -from string import strip, split, join, replace, lstrip -from sys import exc_info - -class getSecurityManager: - '''Null security manager''' - def validate(self, *args, **kwargs): - return 1 - addContext = removeContext = validateValue = validate - -class PythonExpr: - def __init__(self, name, expr, engine): - self.expr = expr = replace(strip(expr), '\n', ' ') - try: - d = {} - exec 'def f():\n return %s\n' % strip(expr) in d - self._f = d['f'] - except: - raise CompilerError, ('Python expression error:\n' - '%s: %s') % exc_info()[:2] - self._get_used_names() - - def _get_used_names(self): - self._f_varnames = vnames = [] - for vname in self._f.func_code.co_names: - if vname[0] not in '$_': - vnames.append(vname) - - def _bind_used_names(self, econtext): - # Bind template variables - names = {} - vars = econtext.vars - getType = econtext._engine.getTypes().get - for vname in self._f_varnames: - has, val = vars.has_get(vname) - if not has: - has = val = getType(vname) - if has: - val = ExprTypeProxy(vname, val, econtext) - if has: - names[vname] = val - return names - - def __call__(self, econtext): - __traceback_info__ = 'python expression "%s"'%self.expr - f = self._f - f.func_globals.update(self._bind_used_names(econtext)) - return f() - - def __str__(self): - return 'Python expression "%s"' % self.expr - def __repr__(self): - return '<PythonExpr %s>' % self.expr - -class ExprTypeProxy: - '''Class that proxies access to an expression type handler''' - def __init__(self, name, handler, econtext): - self._name = name - self._handler = handler - self._econtext = econtext - def __call__(self, text): - return self._handler(self._name, text, - self._econtext._engine)(self._econtext) -
--- a/roundup/cgi/PageTemplates/TALES.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,288 +0,0 @@ -############################################################################## -# -# Copyright (c) 2001 Zope Corporation and Contributors. All Rights Reserved. -# -# This software is subject to the provisions of the Zope Public License, -# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. -# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED -# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS -# FOR A PARTICULAR PURPOSE -# -############################################################################## -"""TALES - -An implementation of a generic TALES engine - -Modified for Roundup 0.5 release: - -- changed imports to import from roundup.cgi -""" - -__version__='$Revision: 1.3 $'[11:-2] - -import re, sys -from roundup.cgi import ZTUtils -from MultiMapping import MultiMapping - -StringType = type('') - -NAME_RE = r"[a-zA-Z][a-zA-Z0-9_]*" -_parse_expr = re.compile(r"(%s):" % NAME_RE).match -_valid_name = re.compile('%s$' % NAME_RE).match - -class TALESError(Exception): - """Error during TALES expression evaluation""" - -class Undefined(TALESError): - '''Exception raised on traversal of an undefined path''' - -class RegistrationError(Exception): - '''TALES Type Registration Error''' - -class CompilerError(Exception): - '''TALES Compiler Error''' - -class Default: - '''Retain Default''' -Default = Default() - -_marker = [] - -class SafeMapping(MultiMapping): - '''Mapping with security declarations and limited method exposure. - - Since it subclasses MultiMapping, this class can be used to wrap - one or more mapping objects. Restricted Python code will not be - able to mutate the SafeMapping or the wrapped mappings, but will be - able to read any value. - ''' - __allow_access_to_unprotected_subobjects__ = 1 - push = pop = None - - _push = MultiMapping.push - _pop = MultiMapping.pop - - def has_get(self, key, _marker=[]): - v = self.get(key, _marker) - return v is not _marker, v - -class Iterator(ZTUtils.Iterator): - def __init__(self, name, seq, context): - ZTUtils.Iterator.__init__(self, seq) - self.name = name - self._context = context - - def next(self): - if ZTUtils.Iterator.next(self): - self._context.setLocal(self.name, self.item) - return 1 - return 0 - - -class ErrorInfo: - """Information about an exception passed to an on-error handler.""" - __allow_access_to_unprotected_subobjects__ = 1 - - def __init__(self, err, position=(None, None)): - if isinstance(err, Exception): - self.type = err.__class__ - self.value = err - else: - self.type = err - self.value = None - self.lineno = position[0] - self.offset = position[1] - - -class Engine: - '''Expression Engine - - An instance of this class keeps a mutable collection of expression - type handlers. It can compile expression strings by delegating to - these handlers. It can provide an expression Context, which is - capable of holding state and evaluating compiled expressions. - ''' - Iterator = Iterator - - def __init__(self, Iterator=None): - self.types = {} - if Iterator is not None: - self.Iterator = Iterator - - def registerType(self, name, handler): - if not _valid_name(name): - raise RegistrationError, 'Invalid Expression type "%s".' % name - types = self.types - if types.has_key(name): - raise RegistrationError, ( - 'Multiple registrations for Expression type "%s".' % - name) - types[name] = handler - - def getTypes(self): - return self.types - - def compile(self, expression): - m = _parse_expr(expression) - if m: - type = m.group(1) - expr = expression[m.end():] - else: - type = "standard" - expr = expression - try: - handler = self.types[type] - except KeyError: - raise CompilerError, ( - 'Unrecognized expression type "%s".' % type) - return handler(type, expr, self) - - def getContext(self, contexts=None, **kwcontexts): - if contexts is not None: - if kwcontexts: - kwcontexts.update(contexts) - else: - kwcontexts = contexts - return Context(self, kwcontexts) - - def getCompilerError(self): - return CompilerError - -class Context: - '''Expression Context - - An instance of this class holds context information that it can - use to evaluate compiled expressions. - ''' - - _context_class = SafeMapping - position = (None, None) - source_file = None - - def __init__(self, engine, contexts): - self._engine = engine - self.contexts = contexts - contexts['nothing'] = None - contexts['default'] = Default - - self.repeat_vars = rv = {} - # Wrap this, as it is visible to restricted code - contexts['repeat'] = rep = self._context_class(rv) - contexts['loop'] = rep # alias - - self.global_vars = gv = contexts.copy() - self.local_vars = lv = {} - self.vars = self._context_class(gv, lv) - - # Keep track of what needs to be popped as each scope ends. - self._scope_stack = [] - - def beginScope(self): - self._scope_stack.append([self.local_vars.copy()]) - - def endScope(self): - scope = self._scope_stack.pop() - self.local_vars = lv = scope[0] - v = self.vars - v._pop() - v._push(lv) - # Pop repeat variables, if any - i = len(scope) - 1 - while i: - name, value = scope[i] - if value is None: - del self.repeat_vars[name] - else: - self.repeat_vars[name] = value - i = i - 1 - - def setLocal(self, name, value): - self.local_vars[name] = value - - def setGlobal(self, name, value): - self.global_vars[name] = value - - def setRepeat(self, name, expr): - expr = self.evaluate(expr) - if not expr: - return self._engine.Iterator(name, (), self) - it = self._engine.Iterator(name, expr, self) - old_value = self.repeat_vars.get(name) - self._scope_stack[-1].append((name, old_value)) - self.repeat_vars[name] = it - return it - - def evaluate(self, expression, - isinstance=isinstance, StringType=StringType): - if isinstance(expression, StringType): - expression = self._engine.compile(expression) - __traceback_supplement__ = ( - TALESTracebackSupplement, self, expression) - v = expression(self) - return v - - evaluateValue = evaluate - evaluateBoolean = evaluate - - def evaluateText(self, expr, None=None): - text = self.evaluate(expr) - if text is Default or text is None: - return text - return str(text) - - def evaluateStructure(self, expr): - return self.evaluate(expr) - evaluateStructure = evaluate - - def evaluateMacro(self, expr): - # XXX Should return None or a macro definition - return self.evaluate(expr) - evaluateMacro = evaluate - - def createErrorInfo(self, err, position): - return ErrorInfo(err, position) - - def getDefault(self): - return Default - - def setSourceFile(self, source_file): - self.source_file = source_file - - def setPosition(self, position): - self.position = position - - - -class TALESTracebackSupplement: - """Implementation of ITracebackSupplement""" - def __init__(self, context, expression): - self.context = context - self.source_url = context.source_file - self.line = context.position[0] - self.column = context.position[1] - self.expression = repr(expression) - - def getInfo(self, as_html=0): - import pprint - data = self.context.contexts.copy() - s = pprint.pformat(data) - if not as_html: - return ' - Names:\n %s' % string.replace(s, '\n', '\n ') - else: - from cgi import escape - return '<b>Names:</b><pre>%s</pre>' % (escape(s)) - return None - - - -class SimpleExpr: - '''Simple example of an expression type handler''' - def __init__(self, name, expr, engine): - self._name = name - self._expr = expr - def __call__(self, econtext): - return self._name, self._expr - def __repr__(self): - return '<SimpleExpr %s %s>' % (self._name, `self._expr`) -
--- a/roundup/cgi/TAL/TALGenerator.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,583 +0,0 @@ -############################################################################## -# -# Copyright (c) 2001, 2002 Zope Corporation and Contributors. -# All Rights Reserved. -# -# This software is subject to the provisions of the Zope Public License, -# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. -# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED -# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS -# FOR A PARTICULAR PURPOSE -# -############################################################################## -""" -Code generator for TALInterpreter intermediate code. -""" - -import string -import re -import cgi - -from TALDefs import * - -class TALGenerator: - - inMacroUse = 0 - inMacroDef = 0 - source_file = None - - def __init__(self, expressionCompiler=None, xml=1, source_file=None): - if not expressionCompiler: - from DummyEngine import DummyEngine - expressionCompiler = DummyEngine() - self.expressionCompiler = expressionCompiler - self.CompilerError = expressionCompiler.getCompilerError() - self.program = [] - self.stack = [] - self.todoStack = [] - self.macros = {} - self.slots = {} - self.slotStack = [] - self.xml = xml - self.emit("version", TAL_VERSION) - self.emit("mode", xml and "xml" or "html") - if source_file is not None: - self.source_file = source_file - self.emit("setSourceFile", source_file) - - def getCode(self): - assert not self.stack - assert not self.todoStack - return self.optimize(self.program), self.macros - - def optimize(self, program): - output = [] - collect = [] - rawseen = cursor = 0 - if self.xml: - endsep = "/>" - else: - endsep = " />" - for cursor in xrange(len(program)+1): - try: - item = program[cursor] - except IndexError: - item = (None, None) - opcode = item[0] - if opcode == "rawtext": - collect.append(item[1]) - continue - if opcode == "endTag": - collect.append("</%s>" % item[1]) - continue - if opcode == "startTag": - if self.optimizeStartTag(collect, item[1], item[2], ">"): - continue - if opcode == "startEndTag": - if self.optimizeStartTag(collect, item[1], item[2], endsep): - continue - if opcode in ("beginScope", "endScope"): - # Push *Scope instructions in front of any text instructions; - # this allows text instructions separated only by *Scope - # instructions to be joined together. - output.append(self.optimizeArgsList(item)) - continue - text = string.join(collect, "") - if text: - i = string.rfind(text, "\n") - if i >= 0: - i = len(text) - (i + 1) - output.append(("rawtextColumn", (text, i))) - else: - output.append(("rawtextOffset", (text, len(text)))) - if opcode != None: - output.append(self.optimizeArgsList(item)) - rawseen = cursor+1 - collect = [] - return self.optimizeCommonTriple(output) - - def optimizeArgsList(self, item): - if len(item) == 2: - return item - else: - return item[0], tuple(item[1:]) - - actionIndex = {"replace":0, "insert":1, "metal":2, "tal":3, "xmlns":4, - 0: 0, 1: 1, 2: 2, 3: 3, 4: 4} - def optimizeStartTag(self, collect, name, attrlist, end): - if not attrlist: - collect.append("<%s%s" % (name, end)) - return 1 - opt = 1 - new = ["<" + name] - for i in range(len(attrlist)): - item = attrlist[i] - if len(item) > 2: - opt = 0 - name, value, action = item[:3] - action = self.actionIndex[action] - attrlist[i] = (name, value, action) + item[3:] - else: - if item[1] is None: - s = item[0] - else: - s = "%s=%s" % (item[0], quote(item[1])) - attrlist[i] = item[0], s - if item[1] is None: - new.append(" " + item[0]) - else: - new.append(" %s=%s" % (item[0], quote(item[1]))) - if opt: - new.append(end) - collect.extend(new) - return opt - - def optimizeCommonTriple(self, program): - if len(program) < 3: - return program - output = program[:2] - prev2, prev1 = output - for item in program[2:]: - if ( item[0] == "beginScope" - and prev1[0] == "setPosition" - and prev2[0] == "rawtextColumn"): - position = output.pop()[1] - text, column = output.pop()[1] - prev1 = None, None - closeprev = 0 - if output and output[-1][0] == "endScope": - closeprev = 1 - output.pop() - item = ("rawtextBeginScope", - (text, column, position, closeprev, item[1])) - output.append(item) - prev2 = prev1 - prev1 = item - return output - - def todoPush(self, todo): - self.todoStack.append(todo) - - def todoPop(self): - return self.todoStack.pop() - - def compileExpression(self, expr): - try: - return self.expressionCompiler.compile(expr) - except self.CompilerError, err: - raise TALError('%s in expression %s' % (err.args[0], `expr`), - self.position) - - def pushProgram(self): - self.stack.append(self.program) - self.program = [] - - def popProgram(self): - program = self.program - self.program = self.stack.pop() - return self.optimize(program) - - def pushSlots(self): - self.slotStack.append(self.slots) - self.slots = {} - - def popSlots(self): - slots = self.slots - self.slots = self.slotStack.pop() - return slots - - def emit(self, *instruction): - self.program.append(instruction) - - def emitStartTag(self, name, attrlist, isend=0): - if isend: - opcode = "startEndTag" - else: - opcode = "startTag" - self.emit(opcode, name, attrlist) - - def emitEndTag(self, name): - if self.xml and self.program and self.program[-1][0] == "startTag": - # Minimize empty element - self.program[-1] = ("startEndTag",) + self.program[-1][1:] - else: - self.emit("endTag", name) - - def emitOptTag(self, name, optTag, isend): - program = self.popProgram() #block - start = self.popProgram() #start tag - if (isend or not program) and self.xml: - # Minimize empty element - start[-1] = ("startEndTag",) + start[-1][1:] - isend = 1 - cexpr = optTag[0] - if cexpr: - cexpr = self.compileExpression(optTag[0]) - self.emit("optTag", name, cexpr, optTag[1], isend, start, program) - - def emitRawText(self, text): - self.emit("rawtext", text) - - def emitText(self, text): - self.emitRawText(cgi.escape(text)) - - def emitDefines(self, defines): - for part in splitParts(defines): - m = re.match( - r"(?s)\s*(?:(global|local)\s+)?(%s)\s+(.*)\Z" % NAME_RE, part) - if not m: - raise TALError("invalid define syntax: " + `part`, - self.position) - scope, name, expr = m.group(1, 2, 3) - scope = scope or "local" - cexpr = self.compileExpression(expr) - if scope == "local": - self.emit("setLocal", name, cexpr) - else: - self.emit("setGlobal", name, cexpr) - - def emitOnError(self, name, onError): - block = self.popProgram() - key, expr = parseSubstitution(onError) - cexpr = self.compileExpression(expr) - if key == "text": - self.emit("insertText", cexpr, []) - else: - assert key == "structure" - self.emit("insertStructure", cexpr, {}, []) - self.emitEndTag(name) - handler = self.popProgram() - self.emit("onError", block, handler) - - def emitCondition(self, expr): - cexpr = self.compileExpression(expr) - program = self.popProgram() - self.emit("condition", cexpr, program) - - def emitRepeat(self, arg): - m = re.match("(?s)\s*(%s)\s+(.*)\Z" % NAME_RE, arg) - if not m: - raise TALError("invalid repeat syntax: " + `arg`, - self.position) - name, expr = m.group(1, 2) - cexpr = self.compileExpression(expr) - program = self.popProgram() - self.emit("loop", name, cexpr, program) - - def emitSubstitution(self, arg, attrDict={}): - key, expr = parseSubstitution(arg) - cexpr = self.compileExpression(expr) - program = self.popProgram() - if key == "text": - self.emit("insertText", cexpr, program) - else: - assert key == "structure" - self.emit("insertStructure", cexpr, attrDict, program) - - def emitDefineMacro(self, macroName): - program = self.popProgram() - macroName = string.strip(macroName) - if self.macros.has_key(macroName): - raise METALError("duplicate macro definition: %s" % `macroName`, - self.position) - if not re.match('%s$' % NAME_RE, macroName): - raise METALError("invalid macro name: %s" % `macroName`, - self.position) - self.macros[macroName] = program - self.inMacroDef = self.inMacroDef - 1 - self.emit("defineMacro", macroName, program) - - def emitUseMacro(self, expr): - cexpr = self.compileExpression(expr) - program = self.popProgram() - self.inMacroUse = 0 - self.emit("useMacro", expr, cexpr, self.popSlots(), program) - - def emitDefineSlot(self, slotName): - program = self.popProgram() - slotName = string.strip(slotName) - if not re.match('%s$' % NAME_RE, slotName): - raise METALError("invalid slot name: %s" % `slotName`, - self.position) - self.emit("defineSlot", slotName, program) - - def emitFillSlot(self, slotName): - program = self.popProgram() - slotName = string.strip(slotName) - if self.slots.has_key(slotName): - raise METALError("duplicate fill-slot name: %s" % `slotName`, - self.position) - if not re.match('%s$' % NAME_RE, slotName): - raise METALError("invalid slot name: %s" % `slotName`, - self.position) - self.slots[slotName] = program - self.inMacroUse = 1 - self.emit("fillSlot", slotName, program) - - def unEmitWhitespace(self): - collect = [] - i = len(self.program) - 1 - while i >= 0: - item = self.program[i] - if item[0] != "rawtext": - break - text = item[1] - if not re.match(r"\A\s*\Z", text): - break - collect.append(text) - i = i-1 - del self.program[i+1:] - if i >= 0 and self.program[i][0] == "rawtext": - text = self.program[i][1] - m = re.search(r"\s+\Z", text) - if m: - self.program[i] = ("rawtext", text[:m.start()]) - collect.append(m.group()) - collect.reverse() - return string.join(collect, "") - - def unEmitNewlineWhitespace(self): - collect = [] - i = len(self.program) - while i > 0: - i = i-1 - item = self.program[i] - if item[0] != "rawtext": - break - text = item[1] - if re.match(r"\A[ \t]*\Z", text): - collect.append(text) - continue - m = re.match(r"(?s)^(.*)(\n[ \t]*)\Z", text) - if not m: - break - text, rest = m.group(1, 2) - collect.reverse() - rest = rest + string.join(collect, "") - del self.program[i:] - if text: - self.emit("rawtext", text) - return rest - return None - - def replaceAttrs(self, attrlist, repldict): - if not repldict: - return attrlist - newlist = [] - for item in attrlist: - key = item[0] - if repldict.has_key(key): - item = item[:2] + ("replace", repldict[key]) - del repldict[key] - newlist.append(item) - for key, value in repldict.items(): # Add dynamic-only attributes - item = (key, None, "insert", value) - newlist.append(item) - return newlist - - def emitStartElement(self, name, attrlist, taldict, metaldict, - position=(None, None), isend=0): - if not taldict and not metaldict: - # Handle the simple, common case - self.emitStartTag(name, attrlist, isend) - self.todoPush({}) - if isend: - self.emitEndElement(name, isend) - return - - self.position = position - for key, value in taldict.items(): - if key not in KNOWN_TAL_ATTRIBUTES: - raise TALError("bad TAL attribute: " + `key`, position) - if not (value or key == 'omit-tag'): - raise TALError("missing value for TAL attribute: " + - `key`, position) - for key, value in metaldict.items(): - if key not in KNOWN_METAL_ATTRIBUTES: - raise METALError("bad METAL attribute: " + `key`, - position) - if not value: - raise TALError("missing value for METAL attribute: " + - `key`, position) - todo = {} - defineMacro = metaldict.get("define-macro") - useMacro = metaldict.get("use-macro") - defineSlot = metaldict.get("define-slot") - fillSlot = metaldict.get("fill-slot") - define = taldict.get("define") - condition = taldict.get("condition") - repeat = taldict.get("repeat") - content = taldict.get("content") - replace = taldict.get("replace") - attrsubst = taldict.get("attributes") - onError = taldict.get("on-error") - omitTag = taldict.get("omit-tag") - TALtag = taldict.get("tal tag") - if len(metaldict) > 1 and (defineMacro or useMacro): - raise METALError("define-macro and use-macro cannot be used " - "together or with define-slot or fill-slot", - position) - if content and replace: - raise TALError("content and replace are mutually exclusive", - position) - - repeatWhitespace = None - if repeat: - # Hack to include preceding whitespace in the loop program - repeatWhitespace = self.unEmitNewlineWhitespace() - if position != (None, None): - # XXX at some point we should insist on a non-trivial position - self.emit("setPosition", position) - if self.inMacroUse: - if fillSlot: - self.pushProgram() - if self.source_file is not None: - self.emit("setSourceFile", self.source_file) - todo["fillSlot"] = fillSlot - self.inMacroUse = 0 - else: - if fillSlot: - raise METALError, ("fill-slot must be within a use-macro", - position) - if not self.inMacroUse: - if defineMacro: - self.pushProgram() - self.emit("version", TAL_VERSION) - self.emit("mode", self.xml and "xml" or "html") - if self.source_file is not None: - self.emit("setSourceFile", self.source_file) - todo["defineMacro"] = defineMacro - self.inMacroDef = self.inMacroDef + 1 - if useMacro: - self.pushSlots() - self.pushProgram() - todo["useMacro"] = useMacro - self.inMacroUse = 1 - if defineSlot: - if not self.inMacroDef: - raise METALError, ( - "define-slot must be within a define-macro", - position) - self.pushProgram() - todo["defineSlot"] = defineSlot - - if taldict: - dict = {} - for item in attrlist: - key, value = item[:2] - dict[key] = value - self.emit("beginScope", dict) - todo["scope"] = 1 - if onError: - self.pushProgram() # handler - self.emitStartTag(name, list(attrlist)) # Must copy attrlist! - self.pushProgram() # block - todo["onError"] = onError - if define: - self.emitDefines(define) - todo["define"] = define - if condition: - self.pushProgram() - todo["condition"] = condition - if repeat: - todo["repeat"] = repeat - self.pushProgram() - if repeatWhitespace: - self.emitText(repeatWhitespace) - if content: - todo["content"] = content - if replace: - todo["replace"] = replace - self.pushProgram() - optTag = omitTag is not None or TALtag - if optTag: - todo["optional tag"] = omitTag, TALtag - self.pushProgram() - if attrsubst: - repldict = parseAttributeReplacements(attrsubst) - for key, value in repldict.items(): - repldict[key] = self.compileExpression(value) - else: - repldict = {} - if replace: - todo["repldict"] = repldict - repldict = {} - self.emitStartTag(name, self.replaceAttrs(attrlist, repldict), isend) - if optTag: - self.pushProgram() - if content: - self.pushProgram() - if todo and position != (None, None): - todo["position"] = position - self.todoPush(todo) - if isend: - self.emitEndElement(name, isend) - - def emitEndElement(self, name, isend=0, implied=0): - todo = self.todoPop() - if not todo: - # Shortcut - if not isend: - self.emitEndTag(name) - return - - self.position = position = todo.get("position", (None, None)) - defineMacro = todo.get("defineMacro") - useMacro = todo.get("useMacro") - defineSlot = todo.get("defineSlot") - fillSlot = todo.get("fillSlot") - repeat = todo.get("repeat") - content = todo.get("content") - replace = todo.get("replace") - condition = todo.get("condition") - onError = todo.get("onError") - define = todo.get("define") - repldict = todo.get("repldict", {}) - scope = todo.get("scope") - optTag = todo.get("optional tag") - - if implied > 0: - if defineMacro or useMacro or defineSlot or fillSlot: - exc = METALError - what = "METAL" - else: - exc = TALError - what = "TAL" - raise exc("%s attributes on <%s> require explicit </%s>" % - (what, name, name), position) - - if content: - self.emitSubstitution(content, {}) - if optTag: - self.emitOptTag(name, optTag, isend) - elif not isend: - self.emitEndTag(name) - if replace: - self.emitSubstitution(replace, repldict) - if repeat: - self.emitRepeat(repeat) - if condition: - self.emitCondition(condition) - if onError: - self.emitOnError(name, onError) - if scope: - self.emit("endScope") - if defineSlot: - self.emitDefineSlot(defineSlot) - if fillSlot: - self.emitFillSlot(fillSlot) - if useMacro: - self.emitUseMacro(useMacro) - if defineMacro: - self.emitDefineMacro(defineMacro) - -def test(): - t = TALGenerator() - t.pushProgram() - t.emit("bar") - p = t.popProgram() - t.emit("foo", p) - -if __name__ == "__main__": - test()
--- a/roundup/cgi/TAL/TALInterpreter.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,626 +0,0 @@ -############################################################################## -# -# Copyright (c) 2001, 2002 Zope Corporation and Contributors. -# All Rights Reserved. -# -# This software is subject to the provisions of the Zope Public License, -# Version 2.0 (ZPL). A copy of the ZPL should accompany this distribution. -# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED -# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS -# FOR A PARTICULAR PURPOSE -# -############################################################################## -""" -Interpreter for a pre-compiled TAL program. -""" - -import sys -import getopt - -from cgi import escape -from string import join, lower, rfind -try: - from strop import lower, rfind -except ImportError: - pass - -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO - -from TALDefs import quote, TAL_VERSION, TALError, METALError -from TALDefs import isCurrentVersion, getProgramVersion, getProgramMode -from TALGenerator import TALGenerator - -BOOLEAN_HTML_ATTRS = [ - # List of Boolean attributes in HTML that should be rendered in - # minimized form (e.g. <img ismap> rather than <img ismap="">) - # From http://www.w3.org/TR/xhtml1/#guidelines (C.10) - # XXX The problem with this is that this is not valid XML and - # can't be parsed back! - "compact", "nowrap", "ismap", "declare", "noshade", "checked", - "disabled", "readonly", "multiple", "selected", "noresize", - "defer" -] - -EMPTY_HTML_TAGS = [ - # List of HTML tags with an empty content model; these are - # rendered in minimized form, e.g. <img />. - # From http://www.w3.org/TR/xhtml1/#dtds - "base", "meta", "link", "hr", "br", "param", "img", "area", - "input", "col", "basefont", "isindex", "frame", -] - -class AltTALGenerator(TALGenerator): - - def __init__(self, repldict, expressionCompiler=None, xml=0): - self.repldict = repldict - self.enabled = 1 - TALGenerator.__init__(self, expressionCompiler, xml) - - def enable(self, enabled): - self.enabled = enabled - - def emit(self, *args): - if self.enabled: - apply(TALGenerator.emit, (self,) + args) - - def emitStartElement(self, name, attrlist, taldict, metaldict, - position=(None, None), isend=0): - metaldict = {} - taldict = {} - if self.enabled and self.repldict: - taldict["attributes"] = "x x" - TALGenerator.emitStartElement(self, name, attrlist, - taldict, metaldict, position, isend) - - def replaceAttrs(self, attrlist, repldict): - if self.enabled and self.repldict: - repldict = self.repldict - self.repldict = None - return TALGenerator.replaceAttrs(self, attrlist, repldict) - - -class TALInterpreter: - - def __init__(self, program, macros, engine, stream=None, - debug=0, wrap=60, metal=1, tal=1, showtal=-1, - strictinsert=1, stackLimit=100): - self.program = program - self.macros = macros - self.engine = engine - self.Default = engine.getDefault() - self.stream = stream or sys.stdout - self._stream_write = self.stream.write - self.debug = debug - self.wrap = wrap - self.metal = metal - self.tal = tal - if tal: - self.dispatch = self.bytecode_handlers_tal - else: - self.dispatch = self.bytecode_handlers - assert showtal in (-1, 0, 1) - if showtal == -1: - showtal = (not tal) - self.showtal = showtal - self.strictinsert = strictinsert - self.stackLimit = stackLimit - self.html = 0 - self.endsep = "/>" - self.endlen = len(self.endsep) - self.macroStack = [] - self.popMacro = self.macroStack.pop - self.position = None, None # (lineno, offset) - self.col = 0 - self.level = 0 - self.scopeLevel = 0 - self.sourceFile = None - - def saveState(self): - return (self.position, self.col, self.stream, - self.scopeLevel, self.level) - - def restoreState(self, state): - (self.position, self.col, self.stream, scopeLevel, level) = state - self._stream_write = self.stream.write - assert self.level == level - while self.scopeLevel > scopeLevel: - self.engine.endScope() - self.scopeLevel = self.scopeLevel - 1 - self.engine.setPosition(self.position) - - def restoreOutputState(self, state): - (dummy, self.col, self.stream, scopeLevel, level) = state - self._stream_write = self.stream.write - assert self.level == level - assert self.scopeLevel == scopeLevel - - def pushMacro(self, macroName, slots, entering=1): - if len(self.macroStack) >= self.stackLimit: - raise METALError("macro nesting limit (%d) exceeded " - "by %s" % (self.stackLimit, `macroName`)) - self.macroStack.append([macroName, slots, entering]) - - def macroContext(self, what): - macroStack = self.macroStack - i = len(macroStack) - while i > 0: - i = i-1 - if macroStack[i][0] == what: - return i - return -1 - - def __call__(self): - assert self.level == 0 - assert self.scopeLevel == 0 - self.interpret(self.program) - assert self.level == 0 - assert self.scopeLevel == 0 - if self.col > 0: - self._stream_write("\n") - self.col = 0 - - def stream_write(self, s, - len=len, rfind=rfind): - self._stream_write(s) - i = rfind(s, '\n') - if i < 0: - self.col = self.col + len(s) - else: - self.col = len(s) - (i + 1) - - bytecode_handlers = {} - - def interpret(self, program, None=None): - oldlevel = self.level - self.level = oldlevel + 1 - handlers = self.dispatch - try: - if self.debug: - for (opcode, args) in program: - s = "%sdo_%s%s\n" % (" "*self.level, opcode, - repr(args)) - if len(s) > 80: - s = s[:76] + "...\n" - sys.stderr.write(s) - handlers[opcode](self, args) - else: - for (opcode, args) in program: - handlers[opcode](self, args) - finally: - self.level = oldlevel - - def do_version(self, version): - assert version == TAL_VERSION - bytecode_handlers["version"] = do_version - - def do_mode(self, mode): - assert mode in ("html", "xml") - self.html = (mode == "html") - if self.html: - self.endsep = " />" - else: - self.endsep = "/>" - self.endlen = len(self.endsep) - bytecode_handlers["mode"] = do_mode - - def do_setSourceFile(self, source_file): - self.sourceFile = source_file - self.engine.setSourceFile(source_file) - bytecode_handlers["setSourceFile"] = do_setSourceFile - - def do_setPosition(self, position): - self.position = position - self.engine.setPosition(position) - bytecode_handlers["setPosition"] = do_setPosition - - def do_startEndTag(self, stuff): - self.do_startTag(stuff, self.endsep, self.endlen) - bytecode_handlers["startEndTag"] = do_startEndTag - - def do_startTag(self, (name, attrList), - end=">", endlen=1, _len=len): - # The bytecode generator does not cause calls to this method - # for start tags with no attributes; those are optimized down - # to rawtext events. Hence, there is no special "fast path" - # for that case. - _stream_write = self._stream_write - _stream_write("<" + name) - namelen = _len(name) - col = self.col + namelen + 1 - wrap = self.wrap - align = col + 1 - if align >= wrap/2: - align = 4 # Avoid a narrow column far to the right - attrAction = self.dispatch["<attrAction>"] - try: - for item in attrList: - if _len(item) == 2: - name, s = item - else: - ok, name, s = attrAction(self, item) - if not ok: - continue - slen = _len(s) - if (wrap and - col >= align and - col + 1 + slen > wrap): - _stream_write("\n" + " "*align) - col = align + slen - else: - s = " " + s - col = col + 1 + slen - _stream_write(s) - _stream_write(end) - col = col + endlen - finally: - self.col = col - bytecode_handlers["startTag"] = do_startTag - - def attrAction(self, item): - name, value, action = item[:3] - if action == 1 or (action > 1 and not self.showtal): - return 0, name, value - macs = self.macroStack - if action == 2 and self.metal and macs: - if len(macs) > 1 or not macs[-1][2]: - # Drop all METAL attributes at a use-depth above one. - return 0, name, value - # Clear 'entering' flag - macs[-1][2] = 0 - # Convert or drop depth-one METAL attributes. - i = rfind(name, ":") + 1 - prefix, suffix = name[:i], name[i:] - if suffix == "define-macro": - # Convert define-macro as we enter depth one. - name = prefix + "use-macro" - value = macs[-1][0] # Macro name - elif suffix == "define-slot": - name = prefix + "slot" - elif suffix == "fill-slot": - pass - else: - return 0, name, value - - if value is None: - value = name - else: - value = "%s=%s" % (name, quote(value)) - return 1, name, value - - def attrAction_tal(self, item): - name, value, action = item[:3] - if action > 1: - return self.attrAction(item) - ok = 1 - if self.html and lower(name) in BOOLEAN_HTML_ATTRS: - evalue = self.engine.evaluateBoolean(item[3]) - if evalue is self.Default: - if action == 1: # Cancelled insert - ok = 0 - elif evalue: - value = None - else: - ok = 0 - else: - evalue = self.engine.evaluateText(item[3]) - if evalue is self.Default: - if action == 1: # Cancelled insert - ok = 0 - else: - if evalue is None: - ok = 0 - value = evalue - if ok: - if value is None: - value = name - value = "%s=%s" % (name, quote(value)) - return ok, name, value - - bytecode_handlers["<attrAction>"] = attrAction - - def no_tag(self, start, program): - state = self.saveState() - self.stream = stream = StringIO() - self._stream_write = stream.write - self.interpret(start) - self.restoreOutputState(state) - self.interpret(program) - - def do_optTag(self, (name, cexpr, tag_ns, isend, start, program), - omit=0): - if tag_ns and not self.showtal: - return self.no_tag(start, program) - - self.interpret(start) - if not isend: - self.interpret(program) - s = '</%s>' % name - self._stream_write(s) - self.col = self.col + len(s) - - def do_optTag_tal(self, stuff): - cexpr = stuff[1] - if cexpr is not None and (cexpr == '' or - self.engine.evaluateBoolean(cexpr)): - self.no_tag(stuff[-2], stuff[-1]) - else: - self.do_optTag(stuff) - bytecode_handlers["optTag"] = do_optTag - - def dumpMacroStack(self, prefix, suffix, value): - sys.stderr.write("+---- %s%s = %s\n" % (prefix, suffix, value)) - for i in range(len(self.macroStack)): - what, macroName, slots = self.macroStack[i] - sys.stderr.write("| %2d. %-12s %-12s %s\n" % - (i, what, macroName, slots and slots.keys())) - sys.stderr.write("+--------------------------------------\n") - - def do_rawtextBeginScope(self, (s, col, position, closeprev, dict)): - self._stream_write(s) - self.col = col - self.do_setPosition(position) - if closeprev: - engine = self.engine - engine.endScope() - engine.beginScope() - else: - self.engine.beginScope() - self.scopeLevel = self.scopeLevel + 1 - - def do_rawtextBeginScope_tal(self, (s, col, position, closeprev, dict)): - self._stream_write(s) - self.col = col - self.do_setPosition(position) - engine = self.engine - if closeprev: - engine.endScope() - engine.beginScope() - else: - engine.beginScope() - self.scopeLevel = self.scopeLevel + 1 - engine.setLocal("attrs", dict) - bytecode_handlers["rawtextBeginScope"] = do_rawtextBeginScope - - def do_beginScope(self, dict): - self.engine.beginScope() - self.scopeLevel = self.scopeLevel + 1 - - def do_beginScope_tal(self, dict): - engine = self.engine - engine.beginScope() - engine.setLocal("attrs", dict) - self.scopeLevel = self.scopeLevel + 1 - bytecode_handlers["beginScope"] = do_beginScope - - def do_endScope(self, notused=None): - self.engine.endScope() - self.scopeLevel = self.scopeLevel - 1 - bytecode_handlers["endScope"] = do_endScope - - def do_setLocal(self, notused): - pass - - def do_setLocal_tal(self, (name, expr)): - self.engine.setLocal(name, self.engine.evaluateValue(expr)) - bytecode_handlers["setLocal"] = do_setLocal - - def do_setGlobal_tal(self, (name, expr)): - self.engine.setGlobal(name, self.engine.evaluateValue(expr)) - bytecode_handlers["setGlobal"] = do_setLocal - - def do_insertText(self, stuff): - self.interpret(stuff[1]) - - def do_insertText_tal(self, stuff): - text = self.engine.evaluateText(stuff[0]) - if text is None: - return - if text is self.Default: - self.interpret(stuff[1]) - return - s = escape(text) - self._stream_write(s) - i = rfind(s, '\n') - if i < 0: - self.col = self.col + len(s) - else: - self.col = len(s) - (i + 1) - bytecode_handlers["insertText"] = do_insertText - - def do_insertStructure(self, stuff): - self.interpret(stuff[2]) - - def do_insertStructure_tal(self, (expr, repldict, block)): - structure = self.engine.evaluateStructure(expr) - if structure is None: - return - if structure is self.Default: - self.interpret(block) - return - text = str(structure) - if not (repldict or self.strictinsert): - # Take a shortcut, no error checking - self.stream_write(text) - return - if self.html: - self.insertHTMLStructure(text, repldict) - else: - self.insertXMLStructure(text, repldict) - bytecode_handlers["insertStructure"] = do_insertStructure - - def insertHTMLStructure(self, text, repldict): - from HTMLTALParser import HTMLTALParser - gen = AltTALGenerator(repldict, self.engine, 0) - p = HTMLTALParser(gen) # Raises an exception if text is invalid - p.parseString(text) - program, macros = p.getCode() - self.interpret(program) - - def insertXMLStructure(self, text, repldict): - from TALParser import TALParser - gen = AltTALGenerator(repldict, self.engine, 0) - p = TALParser(gen) - gen.enable(0) - p.parseFragment('<!DOCTYPE foo PUBLIC "foo" "bar"><foo>') - gen.enable(1) - p.parseFragment(text) # Raises an exception if text is invalid - gen.enable(0) - p.parseFragment('</foo>', 1) - program, macros = gen.getCode() - self.interpret(program) - - def do_loop(self, (name, expr, block)): - self.interpret(block) - - def do_loop_tal(self, (name, expr, block)): - iterator = self.engine.setRepeat(name, expr) - while iterator.next(): - self.interpret(block) - bytecode_handlers["loop"] = do_loop - - def do_rawtextColumn(self, (s, col)): - self._stream_write(s) - self.col = col - bytecode_handlers["rawtextColumn"] = do_rawtextColumn - - def do_rawtextOffset(self, (s, offset)): - self._stream_write(s) - self.col = self.col + offset - bytecode_handlers["rawtextOffset"] = do_rawtextOffset - - def do_condition(self, (condition, block)): - if not self.tal or self.engine.evaluateBoolean(condition): - self.interpret(block) - bytecode_handlers["condition"] = do_condition - - def do_defineMacro(self, (macroName, macro)): - macs = self.macroStack - if len(macs) == 1: - entering = macs[-1][2] - if not entering: - macs.append(None) - self.interpret(macro) - macs.pop() - return - self.interpret(macro) - bytecode_handlers["defineMacro"] = do_defineMacro - - def do_useMacro(self, (macroName, macroExpr, compiledSlots, block)): - if not self.metal: - self.interpret(block) - return - macro = self.engine.evaluateMacro(macroExpr) - if macro is self.Default: - macro = block - else: - if not isCurrentVersion(macro): - raise METALError("macro %s has incompatible version %s" % - (`macroName`, `getProgramVersion(macro)`), - self.position) - mode = getProgramMode(macro) - if mode != (self.html and "html" or "xml"): - raise METALError("macro %s has incompatible mode %s" % - (`macroName`, `mode`), self.position) - self.pushMacro(macroName, compiledSlots) - saved_source = self.sourceFile - saved_position = self.position # Used by Boa Constructor - self.interpret(macro) - if self.sourceFile != saved_source: - self.engine.setSourceFile(saved_source) - self.sourceFile = saved_source - self.popMacro() - bytecode_handlers["useMacro"] = do_useMacro - - def do_fillSlot(self, (slotName, block)): - # This is only executed if the enclosing 'use-macro' evaluates - # to 'default'. - self.interpret(block) - bytecode_handlers["fillSlot"] = do_fillSlot - - def do_defineSlot(self, (slotName, block)): - if not self.metal: - self.interpret(block) - return - macs = self.macroStack - if macs and macs[-1] is not None: - saved_source = self.sourceFile - saved_position = self.position # Used by Boa Constructor - macroName, slots = self.popMacro()[:2] - slot = slots.get(slotName) - if slot is not None: - self.interpret(slot) - if self.sourceFile != saved_source: - self.engine.setSourceFile(saved_source) - self.sourceFile = saved_source - self.pushMacro(macroName, slots, entering=0) - return - self.pushMacro(macroName, slots) - if len(macs) == 1: - self.interpret(block) - return - self.interpret(block) - bytecode_handlers["defineSlot"] = do_defineSlot - - def do_onError(self, (block, handler)): - self.interpret(block) - - def do_onError_tal(self, (block, handler)): - state = self.saveState() - self.stream = stream = StringIO() - self._stream_write = stream.write - try: - self.interpret(block) - except: - exc = sys.exc_info()[1] - self.restoreState(state) - engine = self.engine - engine.beginScope() - error = engine.createErrorInfo(exc, self.position) - engine.setLocal('error', error) - try: - self.interpret(handler) - finally: - engine.endScope() - else: - self.restoreOutputState(state) - self.stream_write(stream.getvalue()) - bytecode_handlers["onError"] = do_onError - - bytecode_handlers_tal = bytecode_handlers.copy() - bytecode_handlers_tal["rawtextBeginScope"] = do_rawtextBeginScope_tal - bytecode_handlers_tal["beginScope"] = do_beginScope_tal - bytecode_handlers_tal["setLocal"] = do_setLocal_tal - bytecode_handlers_tal["setGlobal"] = do_setGlobal_tal - bytecode_handlers_tal["insertStructure"] = do_insertStructure_tal - bytecode_handlers_tal["insertText"] = do_insertText_tal - bytecode_handlers_tal["loop"] = do_loop_tal - bytecode_handlers_tal["onError"] = do_onError_tal - bytecode_handlers_tal["<attrAction>"] = attrAction_tal - bytecode_handlers_tal["optTag"] = do_optTag_tal - - -def test(): - from driver import FILE, parsefile - from DummyEngine import DummyEngine - try: - opts, args = getopt.getopt(sys.argv[1:], "") - except getopt.error, msg: - print msg - sys.exit(2) - if args: - file = args[0] - else: - file = FILE - doc = parsefile(file) - compiler = TALCompiler(doc) - program, macros = compiler() - engine = DummyEngine() - interpreter = TALInterpreter(program, macros, engine) - interpreter() - -if __name__ == "__main__": - test()
--- a/roundup/cgi/client.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1211 +0,0 @@ -# $Id: client.py,v 1.46 2002-09-26 03:45:09 richard Exp $ - -__doc__ = """ -WWW request handler (also used in the stand-alone server). -""" - -import os, os.path, cgi, StringIO, urlparse, re, traceback, mimetypes, urllib -import binascii, Cookie, time, random - -from roundup import roundupdb, date, hyperdb, password -from roundup.i18n import _ - -from roundup.cgi.templating import Templates, HTMLRequest, NoTemplate -from roundup.cgi import cgitb - -from roundup.cgi.PageTemplates import PageTemplate - -class Unauthorised(ValueError): - pass - -class NotFound(ValueError): - pass - -class Redirect(Exception): - pass - -class SendFile(Exception): - ' Sent a file from the database ' - -class SendStaticFile(Exception): - ' Send a static file from the instance html directory ' - -def initialiseSecurity(security): - ''' Create some Permissions and Roles on the security object - - This function is directly invoked by security.Security.__init__() - as a part of the Security object instantiation. - ''' - security.addPermission(name="Web Registration", - description="User may register through the web") - p = security.addPermission(name="Web Access", - description="User may access the web interface") - security.addPermissionToRole('Admin', p) - - # doing Role stuff through the web - make sure Admin can - p = security.addPermission(name="Web Roles", - description="User may manipulate user Roles through the web") - security.addPermissionToRole('Admin', p) - -class Client: - ''' - A note about login - ------------------ - - If the user has no login cookie, then they are anonymous. There - are two levels of anonymous use. If there is no 'anonymous' user, there - is no login at all and the database is opened in read-only mode. If the - 'anonymous' user exists, the user is logged in using that user (though - there is no cookie). This allows them to modify the database, and all - modifications are attributed to the 'anonymous' user. - - Once a user logs in, they are assigned a session. The Client instance - keeps the nodeid of the session as the "session" attribute. - - Client attributes: - "path" is the PATH_INFO inside the instance (with no leading '/') - "base" is the base URL for the instance - ''' - - def __init__(self, instance, request, env, form=None): - hyperdb.traceMark() - self.instance = instance - self.request = request - self.env = env - - # save off the path - self.path = env['PATH_INFO'] - - # this is the base URL for this instance - self.base = self.instance.config.TRACKER_WEB - - # see if we need to re-parse the environment for the form (eg Zope) - if form is None: - self.form = cgi.FieldStorage(environ=env) - else: - self.form = form - - # turn debugging on/off - try: - self.debug = int(env.get("ROUNDUP_DEBUG", 0)) - except ValueError: - # someone gave us a non-int debug level, turn it off - self.debug = 0 - - # flag to indicate that the HTTP headers have been sent - self.headers_done = 0 - - # additional headers to send with the request - must be registered - # before the first write - self.additional_headers = {} - self.response_code = 200 - - def main(self): - ''' Wrap the real main in a try/finally so we always close off the db. - ''' - try: - self.inner_main() - finally: - if hasattr(self, 'db'): - self.db.close() - - def inner_main(self): - ''' Process a request. - - The most common requests are handled like so: - 1. figure out who we are, defaulting to the "anonymous" user - see determine_user - 2. figure out what the request is for - the context - see determine_context - 3. handle any requested action (item edit, search, ...) - see handle_action - 4. render a template, resulting in HTML output - - In some situations, exceptions occur: - - HTTP Redirect (generally raised by an action) - - SendFile (generally raised by determine_context) - serve up a FileClass "content" property - - SendStaticFile (generally raised by determine_context) - serve up a file from the tracker "html" directory - - Unauthorised (generally raised by an action) - the action is cancelled, the request is rendered and an error - message is displayed indicating that permission was not - granted for the action to take place - - NotFound (raised wherever it needs to be) - percolates up to the CGI interface that called the client - ''' - self.content_action = None - self.ok_message = [] - self.error_message = [] - try: - # make sure we're identified (even anonymously) - self.determine_user() - # figure out the context and desired content template - self.determine_context() - # possibly handle a form submit action (may change self.classname - # and self.template, and may also append error/ok_messages) - self.handle_action() - # now render the page - - # we don't want clients caching our dynamic pages - self.additional_headers['Cache-Control'] = 'no-cache' - self.additional_headers['Pragma'] = 'no-cache' - self.additional_headers['Expires'] = 'Thu, 1 Jan 1970 00:00:00 GMT' - - # render the content - self.write(self.renderContext()) - except Redirect, url: - # let's redirect - if the url isn't None, then we need to do - # the headers, otherwise the headers have been set before the - # exception was raised - if url: - self.additional_headers['Location'] = url - self.response_code = 302 - self.write('Redirecting to <a href="%s">%s</a>'%(url, url)) - except SendFile, designator: - self.serve_file(designator) - except SendStaticFile, file: - self.serve_static_file(str(file)) - except Unauthorised, message: - self.write(self.renderTemplate('page', '', error_message=message)) - except NotFound: - # pass through - raise - except: - # everything else - self.write(cgitb.html()) - - def determine_user(self): - ''' Determine who the user is - ''' - # determine the uid to use - self.opendb('admin') - - # make sure we have the session Class - sessions = self.db.sessions - - # age sessions, remove when they haven't been used for a week - # TODO: this shouldn't be done every access - week = 60*60*24*7 - now = time.time() - for sessid in sessions.list(): - interval = now - sessions.get(sessid, 'last_use') - if interval > week: - sessions.destroy(sessid) - - # look up the user session cookie - cookie = Cookie.Cookie(self.env.get('HTTP_COOKIE', '')) - user = 'anonymous' - - # bump the "revision" of the cookie since the format changed - if (cookie.has_key('roundup_user_2') and - cookie['roundup_user_2'].value != 'deleted'): - - # get the session key from the cookie - self.session = cookie['roundup_user_2'].value - # get the user from the session - try: - # update the lifetime datestamp - sessions.set(self.session, last_use=time.time()) - sessions.commit() - user = sessions.get(self.session, 'user') - except KeyError: - user = 'anonymous' - - # sanity check on the user still being valid, getting the userid - # at the same time - try: - self.userid = self.db.user.lookup(user) - except (KeyError, TypeError): - user = 'anonymous' - - # make sure the anonymous user is valid if we're using it - if user == 'anonymous': - self.make_user_anonymous() - else: - self.user = user - - # reopen the database as the correct user - self.opendb(self.user) - - def determine_context(self, dre=re.compile(r'([^\d]+)(\d+)')): - ''' Determine the context of this page from the URL: - - The URL path after the instance identifier is examined. The path - is generally only one entry long. - - - if there is no path, then we are in the "home" context. - * if the path is "_file", then the additional path entry - specifies the filename of a static file we're to serve up - from the instance "html" directory. Raises a SendStaticFile - exception. - - if there is something in the path (eg "issue"), it identifies - the tracker class we're to display. - - if the path is an item designator (eg "issue123"), then we're - to display a specific item. - * if the path starts with an item designator and is longer than - one entry, then we're assumed to be handling an item of a - FileClass, and the extra path information gives the filename - that the client is going to label the download with (ie - "file123/image.png" is nicer to download than "file123"). This - raises a SendFile exception. - - Both of the "*" types of contexts stop before we bother to - determine the template we're going to use. That's because they - don't actually use templates. - - The template used is specified by the :template CGI variable, - which defaults to: - - only classname suplied: "index" - full item designator supplied: "item" - - We set: - self.classname - the class to display, can be None - self.template - the template to render the current context with - self.nodeid - the nodeid of the class we're displaying - ''' - # default the optional variables - self.classname = None - self.nodeid = None - - # determine the classname and possibly nodeid - path = self.path.split('/') - if not path or path[0] in ('', 'home', 'index'): - if self.form.has_key(':template'): - self.template = self.form[':template'].value - else: - self.template = '' - return - elif path[0] == '_file': - raise SendStaticFile, path[1] - else: - self.classname = path[0] - if len(path) > 1: - # send the file identified by the designator in path[0] - raise SendFile, path[0] - - # see if we got a designator - m = dre.match(self.classname) - if m: - self.classname = m.group(1) - self.nodeid = m.group(2) - if not self.db.getclass(self.classname).hasnode(self.nodeid): - raise NotFound, '%s/%s'%(self.classname, self.nodeid) - # with a designator, we default to item view - self.template = 'item' - else: - # with only a class, we default to index view - self.template = 'index' - - # see if we have a template override - if self.form.has_key(':template'): - self.template = self.form[':template'].value - - # see if we were passed in a message - if self.form.has_key(':ok_message'): - self.ok_message.append(self.form[':ok_message'].value) - if self.form.has_key(':error_message'): - self.error_message.append(self.form[':error_message'].value) - - def serve_file(self, designator, dre=re.compile(r'([^\d]+)(\d+)')): - ''' Serve the file from the content property of the designated item. - ''' - m = dre.match(str(designator)) - if not m: - raise NotFound, str(designator) - classname, nodeid = m.group(1), m.group(2) - if classname != 'file': - raise NotFound, designator - - # we just want to serve up the file named - file = self.db.file - self.additional_headers['Content-Type'] = file.get(nodeid, 'type') - self.write(file.get(nodeid, 'content')) - - def serve_static_file(self, file): - # we just want to serve up the file named - mt = mimetypes.guess_type(str(file))[0] - self.additional_headers['Content-Type'] = mt - self.write(open(os.path.join(self.instance.config.TEMPLATES, - file)).read()) - - def renderContext(self): - ''' Return a PageTemplate for the named page - ''' - name = self.classname - extension = self.template - pt = Templates(self.instance.config.TEMPLATES).get(name, extension) - - # catch errors so we can handle PT rendering errors more nicely - args = { - 'ok_message': self.ok_message, - 'error_message': self.error_message - } - try: - # let the template render figure stuff out - return pt.render(self, None, None, **args) - except NoTemplate, message: - return '<strong>%s</strong>'%message - except: - # everything else - return cgitb.pt_html() - - # these are the actions that are available - actions = ( - ('edit', 'editItemAction'), - ('editCSV', 'editCSVAction'), - ('new', 'newItemAction'), - ('register', 'registerAction'), - ('login', 'loginAction'), - ('logout', 'logout_action'), - ('search', 'searchAction'), - ) - def handle_action(self): - ''' Determine whether there should be an _action called. - - The action is defined by the form variable :action which - identifies the method on this object to call. The four basic - actions are defined in the "actions" sequence on this class: - "edit" -> self.editItemAction - "new" -> self.newItemAction - "register" -> self.registerAction - "login" -> self.loginAction - "logout" -> self.logout_action - "search" -> self.searchAction - - ''' - if not self.form.has_key(':action'): - return None - try: - # get the action, validate it - action = self.form[':action'].value - for name, method in self.actions: - if name == action: - break - else: - raise ValueError, 'No such action "%s"'%action - - # call the mapped action - getattr(self, method)() - except Redirect: - raise - except Unauthorised: - raise - except: - self.db.rollback() - s = StringIO.StringIO() - traceback.print_exc(None, s) - self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue())) - - def write(self, content): - if not self.headers_done: - self.header() - self.request.wfile.write(content) - - def header(self, headers=None, response=None): - '''Put up the appropriate header. - ''' - if headers is None: - headers = {'Content-Type':'text/html'} - if response is None: - response = self.response_code - - # update with additional info - headers.update(self.additional_headers) - - if not headers.has_key('Content-Type'): - headers['Content-Type'] = 'text/html' - self.request.send_response(response) - for entry in headers.items(): - self.request.send_header(*entry) - self.request.end_headers() - self.headers_done = 1 - if self.debug: - self.headers_sent = headers - - def set_cookie(self, user, password): - # TODO generate a much, much stronger session key ;) - self.session = binascii.b2a_base64(repr(random.random())).strip() - - # clean up the base64 - if self.session[-1] == '=': - if self.session[-2] == '=': - self.session = self.session[:-2] - else: - self.session = self.session[:-1] - - # insert the session in the sessiondb - self.db.sessions.set(self.session, user=user, last_use=time.time()) - - # and commit immediately - self.db.sessions.commit() - - # expire us in a long, long time - expire = Cookie._getdate(86400*365) - - # generate the cookie path - make sure it has a trailing '/' - path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'], - '')) - self.additional_headers['Set-Cookie'] = \ - 'roundup_user_2=%s; expires=%s; Path=%s;'%(self.session, expire, path) - - def make_user_anonymous(self): - ''' Make us anonymous - - This method used to handle non-existence of the 'anonymous' - user, but that user is mandatory now. - ''' - self.userid = self.db.user.lookup('anonymous') - self.user = 'anonymous' - - def opendb(self, user): - ''' Open the database. - ''' - # open the db if the user has changed - if not hasattr(self, 'db') or user != self.db.journaltag: - if hasattr(self, 'db'): - self.db.close() - self.db = self.instance.open(user) - - # - # Actions - # - def loginAction(self): - ''' Attempt to log a user in. - - Sets up a session for the user which contains the login - credentials. - ''' - # we need the username at a minimum - if not self.form.has_key('__login_name'): - self.error_message.append(_('Username required')) - return - - self.user = self.form['__login_name'].value - # re-open the database for real, using the user - self.opendb(self.user) - if self.form.has_key('__login_password'): - password = self.form['__login_password'].value - else: - password = '' - # make sure the user exists - try: - self.userid = self.db.user.lookup(self.user) - except KeyError: - name = self.user - self.make_user_anonymous() - self.error_message.append(_('No such user "%(name)s"')%locals()) - return - - # and that the password is correct - pw = self.db.user.get(self.userid, 'password') - if password != pw: - self.make_user_anonymous() - self.error_message.append(_('Incorrect password')) - return - - # make sure we're allowed to be here - if not self.loginPermission(): - self.make_user_anonymous() - raise Unauthorised, _("You do not have permission to login") - - # set the session cookie - self.set_cookie(self.user, password) - - def loginPermission(self): - ''' Determine whether the user has permission to log in. - - Base behaviour is to check the user has "Web Access". - ''' - if not self.db.security.hasPermission('Web Access', self.userid): - return 0 - return 1 - - def logout_action(self): - ''' Make us really anonymous - nuke the cookie too - ''' - # log us out - self.make_user_anonymous() - - # construct the logout cookie - now = Cookie._getdate() - path = '/'.join((self.env['SCRIPT_NAME'], self.env['TRACKER_NAME'], - '')) - self.additional_headers['Set-Cookie'] = \ - 'roundup_user_2=deleted; Max-Age=0; expires=%s; Path=%s;'%(now, path) - - # Let the user know what's going on - self.ok_message.append(_('You are logged out')) - - def registerAction(self): - '''Attempt to create a new user based on the contents of the form - and then set the cookie. - - return 1 on successful login - ''' - # create the new user - cl = self.db.user - - # parse the props from the form - try: - props = parsePropsFromForm(self.db, cl, self.form, self.nodeid) - except (ValueError, KeyError), message: - self.error_message.append(_('Error: ') + str(message)) - return - - # make sure we're allowed to register - if not self.registerPermission(props): - raise Unauthorised, _("You do not have permission to register") - - # re-open the database as "admin" - if self.user != 'admin': - self.opendb('admin') - - # create the new user - cl = self.db.user - try: - props = parsePropsFromForm(self.db, cl, self.form) - props['roles'] = self.instance.config.NEW_WEB_USER_ROLES - self.userid = cl.create(**props) - self.db.commit() - except (ValueError, KeyError), message: - self.error_message.append(message) - return - - # log the new user in - self.user = cl.get(self.userid, 'username') - # re-open the database for real, using the user - self.opendb(self.user) - password = self.db.user.get(self.userid, 'password') - self.set_cookie(self.user, password) - - # nice message - message = _('You are now registered, welcome!') - - # redirect to the item's edit page - raise Redirect, '%s%s%s?:ok_message=%s'%( - self.base, self.classname, self.userid, urllib.quote(message)) - - def registerPermission(self, props): - ''' Determine whether the user has permission to register - - Base behaviour is to check the user has "Web Registration". - ''' - # registration isn't allowed to supply roles - if props.has_key('roles'): - return 0 - if self.db.security.hasPermission('Web Registration', self.userid): - return 1 - return 0 - - def editItemAction(self): - ''' Perform an edit of an item in the database. - - Some special form elements: - - :link=designator:property - :multilink=designator:property - The value specifies a node designator and the property on that - node to add _this_ node to as a link or multilink. - :note - Create a message and attach it to the current node's - "messages" property. - :file - Create a file and attach it to the current node's - "files" property. Attach the file to the message created from - the :note if it's supplied. - - :required=property,property,... - The named properties are required to be filled in the form. - - ''' - cl = self.db.classes[self.classname] - - # parse the props from the form - try: - props = parsePropsFromForm(self.db, cl, self.form, self.nodeid) - except (ValueError, KeyError), message: - self.error_message.append(_('Error: ') + str(message)) - return - - # check permission - if not self.editItemPermission(props): - self.error_message.append( - _('You do not have permission to edit %(classname)s'% - self.__dict__)) - return - - # perform the edit - try: - # make changes to the node - props = self._changenode(props) - # handle linked nodes - self._post_editnode(self.nodeid) - except (ValueError, KeyError), message: - self.error_message.append(_('Error: ') + str(message)) - return - - # commit now that all the tricky stuff is done - self.db.commit() - - # and some nice feedback for the user - if props: - message = _('%(changes)s edited ok')%{'changes': - ', '.join(props.keys())} - elif self.form.has_key(':note') and self.form[':note'].value: - message = _('note added') - elif (self.form.has_key(':file') and self.form[':file'].filename): - message = _('file added') - else: - message = _('nothing changed') - - # redirect to the item's edit page - raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname, - self.nodeid, urllib.quote(message)) - - def editItemPermission(self, props): - ''' Determine whether the user has permission to edit this item. - - Base behaviour is to check the user can edit this class. If we're - editing the "user" class, users are allowed to edit their own - details. Unless it's the "roles" property, which requires the - special Permission "Web Roles". - ''' - # if this is a user node and the user is editing their own node, then - # we're OK - has = self.db.security.hasPermission - if self.classname == 'user': - # reject if someone's trying to edit "roles" and doesn't have the - # right permission. - if props.has_key('roles') and not has('Web Roles', self.userid, - 'user'): - return 0 - # if the item being edited is the current user, we're ok - if self.nodeid == self.userid: - return 1 - if self.db.security.hasPermission('Edit', self.userid, self.classname): - return 1 - return 0 - - def newItemAction(self): - ''' Add a new item to the database. - - This follows the same form as the editItemAction, with the same - special form values. - ''' - cl = self.db.classes[self.classname] - - # parse the props from the form - try: - props = parsePropsFromForm(self.db, cl, self.form, self.nodeid) - except (ValueError, KeyError), message: - self.error_message.append(_('Error: ') + str(message)) - return - - if not self.newItemPermission(props): - self.error_message.append( - _('You do not have permission to create %s' %self.classname)) - - # create a little extra message for anticipated :link / :multilink - if self.form.has_key(':multilink'): - link = self.form[':multilink'].value - elif self.form.has_key(':link'): - link = self.form[':multilink'].value - else: - link = None - xtra = '' - if link: - designator, linkprop = link.split(':') - xtra = ' for <a href="%s">%s</a>'%(designator, designator) - - try: - # do the create - nid = self._createnode(props) - - # handle linked nodes - self._post_editnode(nid) - - # commit now that all the tricky stuff is done - self.db.commit() - - # render the newly created item - self.nodeid = nid - - # and some nice feedback for the user - message = _('%(classname)s created ok')%self.__dict__ + xtra - except (ValueError, KeyError), message: - self.error_message.append(_('Error: ') + str(message)) - return - except: - # oops - self.db.rollback() - s = StringIO.StringIO() - traceback.print_exc(None, s) - self.error_message.append('<pre>%s</pre>'%cgi.escape(s.getvalue())) - return - - # redirect to the new item's page - raise Redirect, '%s%s%s?:ok_message=%s'%(self.base, self.classname, - nid, urllib.quote(message)) - - def newItemPermission(self, props): - ''' Determine whether the user has permission to create (edit) this - item. - - Base behaviour is to check the user can edit this class. No - additional property checks are made. Additionally, new user items - may be created if the user has the "Web Registration" Permission. - ''' - has = self.db.security.hasPermission - if self.classname == 'user' and has('Web Registration', self.userid, - 'user'): - return 1 - if has('Edit', self.userid, self.classname): - return 1 - return 0 - - def editCSVAction(self): - ''' Performs an edit of all of a class' items in one go. - - The "rows" CGI var defines the CSV-formatted entries for the - class. New nodes are identified by the ID 'X' (or any other - non-existent ID) and removed lines are retired. - ''' - # this is per-class only - if not self.editCSVPermission(): - self.error_message.append( - _('You do not have permission to edit %s' %self.classname)) - - # get the CSV module - try: - import csv - except ImportError: - self.error_message.append(_( - 'Sorry, you need the csv module to use this function.<br>\n' - 'Get it from: <a href="http://www.object-craft.com.au/projects/csv/">http://www.object-craft.com.au/projects/csv/')) - return - - cl = self.db.classes[self.classname] - idlessprops = cl.getprops(protected=0).keys() - idlessprops.sort() - props = ['id'] + idlessprops - - # do the edit - rows = self.form['rows'].value.splitlines() - p = csv.parser() - found = {} - line = 0 - for row in rows[1:]: - line += 1 - values = p.parse(row) - # not a complete row, keep going - if not values: continue - - # skip property names header - if values == props: - continue - - # extract the nodeid - nodeid, values = values[0], values[1:] - found[nodeid] = 1 - - # confirm correct weight - if len(idlessprops) != len(values): - self.error_message.append( - _('Not enough values on line %(line)s')%{'line':line}) - return - - # extract the new values - d = {} - for name, value in zip(idlessprops, values): - value = value.strip() - # only add the property if it has a value - if value: - # if it's a multilink, split it - if isinstance(cl.properties[name], hyperdb.Multilink): - value = value.split(':') - d[name] = value - - # perform the edit - if cl.hasnode(nodeid): - # edit existing - cl.set(nodeid, **d) - else: - # new node - found[cl.create(**d)] = 1 - - # retire the removed entries - for nodeid in cl.list(): - if not found.has_key(nodeid): - cl.retire(nodeid) - - # all OK - self.db.commit() - - self.ok_message.append(_('Items edited OK')) - - def editCSVPermission(self): - ''' Determine whether the user has permission to edit this class. - - Base behaviour is to check the user can edit this class. - ''' - if not self.db.security.hasPermission('Edit', self.userid, - self.classname): - return 0 - return 1 - - def searchAction(self): - ''' Mangle some of the form variables. - - Set the form ":filter" variable based on the values of the - filter variables - if they're set to anything other than - "dontcare" then add them to :filter. - - Also handle the ":queryname" variable and save off the query to - the user's query list. - ''' - # generic edit is per-class only - if not self.searchPermission(): - self.error_message.append( - _('You do not have permission to search %s' %self.classname)) - - # add a faked :filter form variable for each filtering prop - props = self.db.classes[self.classname].getprops() - for key in self.form.keys(): - if not props.has_key(key): continue - if not self.form[key].value: continue - self.form.value.append(cgi.MiniFieldStorage(':filter', key)) - - # handle saving the query params - if self.form.has_key(':queryname'): - queryname = self.form[':queryname'].value.strip() - if queryname: - # parse the environment and figure what the query _is_ - req = HTMLRequest(self) - url = req.indexargs_href('', {}) - - # handle editing an existing query - try: - qid = self.db.query.lookup(queryname) - self.db.query.set(qid, klass=self.classname, url=url) - except KeyError: - # create a query - qid = self.db.query.create(name=queryname, - klass=self.classname, url=url) - - # and add it to the user's query multilink - queries = self.db.user.get(self.userid, 'queries') - queries.append(qid) - self.db.user.set(self.userid, queries=queries) - - # commit the query change to the database - self.db.commit() - - def searchPermission(self): - ''' Determine whether the user has permission to search this class. - - Base behaviour is to check the user can view this class. - ''' - if not self.db.security.hasPermission('View', self.userid, - self.classname): - return 0 - return 1 - - def remove_action(self, dre=re.compile(r'([^\d]+)(\d+)')): - # XXX I believe this could be handled by a regular edit action that - # just sets the multilink... - target = self.index_arg(':target')[0] - m = dre.match(target) - if m: - classname = m.group(1) - nodeid = m.group(2) - cl = self.db.getclass(classname) - cl.retire(nodeid) - # now take care of the reference - parentref = self.index_arg(':multilink')[0] - parent, prop = parentref.split(':') - m = dre.match(parent) - if m: - self.classname = m.group(1) - self.nodeid = m.group(2) - cl = self.db.getclass(self.classname) - value = cl.get(self.nodeid, prop) - value.remove(nodeid) - cl.set(self.nodeid, **{prop:value}) - func = getattr(self, 'show%s'%self.classname) - return func() - else: - raise NotFound, parent - else: - raise NotFound, target - - # - # Utility methods for editing - # - def _changenode(self, props): - ''' change the node based on the contents of the form - ''' - cl = self.db.classes[self.classname] - - # create the message - message, files = self._handle_message() - if message: - props['messages'] = cl.get(self.nodeid, 'messages') + [message] - if files: - props['files'] = cl.get(self.nodeid, 'files') + files - - # make the changes - return cl.set(self.nodeid, **props) - - def _createnode(self, props): - ''' create a node based on the contents of the form - ''' - cl = self.db.classes[self.classname] - - # check for messages and files - message, files = self._handle_message() - if message: - props['messages'] = [message] - if files: - props['files'] = files - # create the node and return it's id - return cl.create(**props) - - def _handle_message(self): - ''' generate an edit message - ''' - # handle file attachments - files = [] - if self.form.has_key(':file'): - file = self.form[':file'] - if file.filename: - filename = file.filename.split('\\')[-1] - mime_type = mimetypes.guess_type(filename)[0] - if not mime_type: - mime_type = "application/octet-stream" - # create the new file entry - files.append(self.db.file.create(type=mime_type, - name=filename, content=file.file.read())) - - # we don't want to do a message if none of the following is true... - cn = self.classname - cl = self.db.classes[self.classname] - props = cl.getprops() - note = None - # in a nutshell, don't do anything if there's no note or there's no - # NOSY - if self.form.has_key(':note'): - note = self.form[':note'].value.strip() - if not note: - return None, files - if not props.has_key('messages'): - return None, files - if not isinstance(props['messages'], hyperdb.Multilink): - return None, files - if not props['messages'].classname == 'msg': - return None, files - if not (self.form.has_key('nosy') or note): - return None, files - - # handle the note - if '\n' in note: - summary = re.split(r'\n\r?', note)[0] - else: - summary = note - m = ['%s\n'%note] - - # handle the messageid - # TODO: handle inreplyto - messageid = "<%s.%s.%s@%s>"%(time.time(), random.random(), - self.classname, self.instance.config.MAIL_DOMAIN) - - # now create the message, attaching the files - content = '\n'.join(m) - message_id = self.db.msg.create(author=self.userid, - recipients=[], date=date.Date('.'), summary=summary, - content=content, files=files, messageid=messageid) - - # update the messages property - return message_id, files - - def _post_editnode(self, nid): - '''Do the linking part of the node creation. - - If a form element has :link or :multilink appended to it, its - value specifies a node designator and the property on that node - to add _this_ node to as a link or multilink. - - This is typically used on, eg. the file upload page to indicated - which issue to link the file to. - - TODO: I suspect that this and newfile will go away now that - there's the ability to upload a file using the issue :file form - element! - ''' - cn = self.classname - cl = self.db.classes[cn] - # link if necessary - keys = self.form.keys() - for key in keys: - if key == ':multilink': - value = self.form[key].value - if type(value) != type([]): value = [value] - for value in value: - designator, property = value.split(':') - link, nodeid = hyperdb.splitDesignator(designator) - link = self.db.classes[link] - # take a dupe of the list so we're not changing the cache - value = link.get(nodeid, property)[:] - value.append(nid) - link.set(nodeid, **{property: value}) - elif key == ':link': - value = self.form[key].value - if type(value) != type([]): value = [value] - for value in value: - designator, property = value.split(':') - link, nodeid = hyperdb.splitDesignator(designator) - link = self.db.classes[link] - link.set(nodeid, **{property: nid}) - - -def parsePropsFromForm(db, cl, form, nodeid=0, num_re=re.compile('^\d+$')): - ''' Pull properties for the given class out of the form. - - If a ":required" parameter is supplied, then the names property values - must be supplied or a ValueError will be raised. - ''' - required = [] - if form.has_key(':required'): - value = form[':required'] - if isinstance(value, type([])): - required = [i.value.strip() for i in value] - else: - required = [i.strip() for i in value.value.split(',')] - - props = {} - keys = form.keys() - properties = cl.getprops() - for key in keys: - if not properties.has_key(key): - continue - proptype = properties[key] - - # Get the form value. This value may be a MiniFieldStorage or a list - # of MiniFieldStorages. - value = form[key] - - # make sure non-multilinks only get one value - if not isinstance(proptype, hyperdb.Multilink): - if isinstance(value, type([])): - raise ValueError, 'You have submitted more than one value'\ - ' for the %s property'%key - # we've got a MiniFieldStorage, so pull out the value and strip - # surrounding whitespace - value = value.value.strip() - - if isinstance(proptype, hyperdb.String): - if not value: - continue - elif isinstance(proptype, hyperdb.Password): - if not value: - # ignore empty password values - continue - if not form.has_key('%s:confirm'%key): - raise ValueError, 'Password and confirmation text do not match' - confirm = form['%s:confirm'%key] - if isinstance(confirm, type([])): - raise ValueError, 'You have submitted more than one value'\ - ' for the %s property'%key - if value != confirm.value: - raise ValueError, 'Password and confirmation text do not match' - value = password.Password(value) - elif isinstance(proptype, hyperdb.Date): - if value: - value = date.Date(form[key].value.strip()) - else: - continue - elif isinstance(proptype, hyperdb.Interval): - if value: - value = date.Interval(form[key].value.strip()) - else: - continue - elif isinstance(proptype, hyperdb.Link): - # see if it's the "no selection" choice - if value == '-1': - value = None - else: - # handle key values - link = proptype.classname - if not num_re.match(value): - try: - value = db.classes[link].lookup(value) - except KeyError: - raise ValueError, _('property "%(propname)s": ' - '%(value)s not a %(classname)s')%{'propname':key, - 'value': value, 'classname': link} - except TypeError, message: - raise ValueError, _('you may only enter ID values ' - 'for property "%(propname)s": %(message)s')%{ - 'propname':key, 'message': message} - elif isinstance(proptype, hyperdb.Multilink): - if isinstance(value, type([])): - # it's a list of MiniFieldStorages - value = [i.value.strip() for i in value] - else: - # it's a MiniFieldStorage, but may be a comma-separated list - # of values - value = [i.strip() for i in value.value.split(',')] - link = proptype.classname - l = [] - for entry in map(str, value): - if entry == '': continue - if not num_re.match(entry): - try: - entry = db.classes[link].lookup(entry) - except KeyError: - raise ValueError, _('property "%(propname)s": ' - '"%(value)s" not an entry of %(classname)s')%{ - 'propname':key, 'value': entry, 'classname': link} - except TypeError, message: - raise ValueError, _('you may only enter ID values ' - 'for property "%(propname)s": %(message)s')%{ - 'propname':key, 'message': message} - l.append(entry) - l.sort() - value = l - elif isinstance(proptype, hyperdb.Boolean): - props[key] = value = value.lower() in ('yes', 'true', 'on', '1') - elif isinstance(proptype, hyperdb.Number): - props[key] = value = int(value) - - # register this as received if required? - if key in required and value is not None: - required.remove(key) - - # get the old value - if nodeid: - try: - existing = cl.get(nodeid, key) - except KeyError: - # this might be a new property for which there is no existing - # value - if not properties.has_key(key): raise - - # if changed, set it - if value != existing: - props[key] = value - else: - props[key] = value - - # see if all the required properties have been supplied - if required: - if len(required) > 1: - p = 'properties' - else: - p = 'property' - raise ValueError, 'Required %s %s not supplied'%(p, ', '.join(required)) - - return props - -
--- a/roundup/cgi/templating.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1542 +0,0 @@ -import sys, cgi, urllib, os, re, os.path, time, errno - -from roundup import hyperdb, date -from roundup.i18n import _ - -try: - import cPickle as pickle -except ImportError: - import pickle -try: - import cStringIO as StringIO -except ImportError: - import StringIO -try: - import StructuredText -except ImportError: - StructuredText = None - -# bring in the templating support -from roundup.cgi.PageTemplates import PageTemplate -from roundup.cgi.PageTemplates.Expressions import getEngine -from roundup.cgi.TAL.TALInterpreter import TALInterpreter -from roundup.cgi import ZTUtils - -class NoTemplate(Exception): - pass - -class Templates: - templates = {} - - def __init__(self, dir): - self.dir = dir - - def precompileTemplates(self): - ''' Go through a directory and precompile all the templates therein - ''' - for filename in os.listdir(self.dir): - if os.path.isdir(filename): continue - if '.' in filename: - name, extension = filename.split('.') - self.getTemplate(name, extension) - else: - self.getTemplate(filename, None) - - def get(self, name, extension): - ''' Interface to get a template, possibly loading a compiled template. - - "name" and "extension" indicate the template we're after, which in - most cases will be "name.extension". If "extension" is None, then - we look for a template just called "name" with no extension. - - If the file "name.extension" doesn't exist, we look for - "_generic.extension" as a fallback. - ''' - # default the name to "home" - if name is None: - name = 'home' - - # find the source, figure the time it was last modified - if extension: - filename = '%s.%s'%(name, extension) - else: - filename = name - src = os.path.join(self.dir, filename) - try: - stime = os.stat(src)[os.path.stat.ST_MTIME] - except os.error, error: - if error.errno != errno.ENOENT: - raise - if not extension: - raise NoTemplate, 'Template file "%s" doesn\'t exist'%name - - # try for a generic template - generic = '_generic.%s'%extension - src = os.path.join(self.dir, generic) - try: - stime = os.stat(src)[os.path.stat.ST_MTIME] - except os.error, error: - if error.errno != errno.ENOENT: - raise - # nicer error - raise NoTemplate, 'No template file exists for templating '\ - '"%s" with template "%s" (neither "%s" nor "%s")'%(name, - extension, filename, generic) - filename = generic - - if self.templates.has_key(filename) and \ - stime < self.templates[filename].mtime: - # compiled template is up to date - return self.templates[filename] - - # compile the template - self.templates[filename] = pt = RoundupPageTemplate() - pt.write(open(src).read()) - pt.id = filename - pt.mtime = time.time() - return pt - - def __getitem__(self, name): - name, extension = os.path.splitext(name) - if extension: - extension = extension[1:] - try: - return self.get(name, extension) - except NoTemplate, message: - raise KeyError, message - -class RoundupPageTemplate(PageTemplate.PageTemplate): - ''' A Roundup-specific PageTemplate. - - Interrogate the client to set up the various template variables to - be available: - - *context* - this is one of three things: - 1. None - we're viewing a "home" page - 2. The current class of item being displayed. This is an HTMLClass - instance. - 3. The current item from the database, if we're viewing a specific - item, as an HTMLItem instance. - *request* - Includes information about the current request, including: - - the url - - the current index information (``filterspec``, ``filter`` args, - ``properties``, etc) parsed out of the form. - - methods for easy filterspec link generation - - *user*, the current user node as an HTMLItem instance - - *form*, the current CGI form information as a FieldStorage - *tracker* - The current tracker - *db* - The current database, through which db.config may be reached. - ''' - def getContext(self, client, classname, request): - c = { - 'options': {}, - 'nothing': None, - 'request': request, - 'db': HTMLDatabase(client), - 'tracker': client.instance, - 'utils': TemplatingUtils(client), - 'templates': Templates(client.instance.config.TEMPLATES), - } - # add in the item if there is one - if client.nodeid: - if classname == 'user': - c['context'] = HTMLUser(client, classname, client.nodeid) - else: - c['context'] = HTMLItem(client, classname, client.nodeid) - elif client.db.classes.has_key(classname): - c['context'] = HTMLClass(client, classname) - return c - - def render(self, client, classname, request, **options): - """Render this Page Template""" - - if not self._v_cooked: - self._cook() - - __traceback_supplement__ = (PageTemplate.PageTemplateTracebackSupplement, self) - - if self._v_errors: - raise PageTemplate.PTRuntimeError, \ - 'Page Template %s has errors.'%self.id - - # figure the context - classname = classname or client.classname - request = request or HTMLRequest(client) - c = self.getContext(client, classname, request) - c.update({'options': options}) - - # and go - output = StringIO.StringIO() - TALInterpreter(self._v_program, self.macros, - getEngine().getContext(c), output, tal=1, strictinsert=0)() - return output.getvalue() - -class HTMLDatabase: - ''' Return HTMLClasses for valid class fetches - ''' - def __init__(self, client): - self._client = client - - # we want config to be exposed - self.config = client.db.config - - def __getitem__(self, item): - self._client.db.getclass(item) - return HTMLClass(self._client, item) - - def __getattr__(self, attr): - try: - return self[attr] - except KeyError: - raise AttributeError, attr - - def classes(self): - l = self._client.db.classes.keys() - l.sort() - return [HTMLClass(self._client, cn) for cn in l] - -def lookupIds(db, prop, ids, num_re=re.compile('-?\d+')): - cl = db.getclass(prop.classname) - l = [] - for entry in ids: - if num_re.match(entry): - l.append(entry) - else: - try: - l.append(cl.lookup(entry)) - except KeyError: - # ignore invalid keys - pass - return l - -class HTMLPermissions: - ''' Helpers that provide answers to commonly asked Permission questions. - ''' - def is_edit_ok(self): - ''' Is the user allowed to Edit the current class? - ''' - return self._db.security.hasPermission('Edit', self._client.userid, - self._classname) - def is_view_ok(self): - ''' Is the user allowed to View the current class? - ''' - return self._db.security.hasPermission('View', self._client.userid, - self._classname) - def is_only_view_ok(self): - ''' Is the user only allowed to View (ie. not Edit) the current class? - ''' - return self.is_view_ok() and not self.is_edit_ok() - -class HTMLClass(HTMLPermissions): - ''' Accesses through a class (either through *class* or *db.<classname>*) - ''' - def __init__(self, client, classname): - self._client = client - self._db = client.db - - # we want classname to be exposed, but _classname gives a - # consistent API for extending Class/Item - self._classname = self.classname = classname - self._klass = self._db.getclass(self.classname) - self._props = self._klass.getprops() - - def __repr__(self): - return '<HTMLClass(0x%x) %s>'%(id(self), self.classname) - - def __getitem__(self, item): - ''' return an HTMLProperty instance - ''' - #print 'HTMLClass.getitem', (self, item) - - # we don't exist - if item == 'id': - return None - - # get the property - prop = self._props[item] - - # look up the correct HTMLProperty class - form = self._client.form - for klass, htmlklass in propclasses: - if not isinstance(prop, klass): - continue - if form.has_key(item): - if isinstance(prop, hyperdb.Multilink): - value = lookupIds(self._db, prop, - handleListCGIValue(form[item])) - elif isinstance(prop, hyperdb.Link): - value = form[item].value.strip() - if value: - value = lookupIds(self._db, prop, [value])[0] - else: - value = None - else: - value = form[item].value.strip() or None - else: - if isinstance(prop, hyperdb.Multilink): - value = [] - else: - value = None - return htmlklass(self._client, '', prop, item, value) - - # no good - raise KeyError, item - - def __getattr__(self, attr): - ''' convenience access ''' - try: - return self[attr] - except KeyError: - raise AttributeError, attr - - def getItem(self, itemid, num_re=re.compile('\d+')): - ''' Get an item of this class by its item id. - ''' - # make sure we're looking at an itemid - if not num_re.match(itemid): - itemid = self._klass.lookup(itemid) - - if self.classname == 'user': - klass = HTMLUser - else: - klass = HTMLItem - - return klass(self._client, self.classname, itemid) - - def properties(self): - ''' Return HTMLProperty for all of this class' properties. - ''' - l = [] - for name, prop in self._props.items(): - for klass, htmlklass in propclasses: - if isinstance(prop, hyperdb.Multilink): - value = [] - else: - value = None - if isinstance(prop, klass): - l.append(htmlklass(self._client, '', prop, name, value)) - return l - - def list(self): - ''' List all items in this class. - ''' - if self.classname == 'user': - klass = HTMLUser - else: - klass = HTMLItem - - # get the list and sort it nicely - l = self._klass.list() - sortfunc = make_sort_function(self._db, self.classname) - l.sort(sortfunc) - - l = [klass(self._client, self.classname, x) for x in l] - return l - - def csv(self): - ''' Return the items of this class as a chunk of CSV text. - ''' - # get the CSV module - try: - import csv - except ImportError: - return 'Sorry, you need the csv module to use this function.\n'\ - 'Get it from: http://www.object-craft.com.au/projects/csv/' - - props = self.propnames() - p = csv.parser() - s = StringIO.StringIO() - s.write(p.join(props) + '\n') - for nodeid in self._klass.list(): - l = [] - for name in props: - value = self._klass.get(nodeid, name) - if value is None: - l.append('') - elif isinstance(value, type([])): - l.append(':'.join(map(str, value))) - else: - l.append(str(self._klass.get(nodeid, name))) - s.write(p.join(l) + '\n') - return s.getvalue() - - def propnames(self): - ''' Return the list of the names of the properties of this class. - ''' - idlessprops = self._klass.getprops(protected=0).keys() - idlessprops.sort() - return ['id'] + idlessprops - - def filter(self, request=None): - ''' Return a list of items from this class, filtered and sorted - by the current requested filterspec/filter/sort/group args - ''' - if request is not None: - filterspec = request.filterspec - sort = request.sort - group = request.group - if self.classname == 'user': - klass = HTMLUser - else: - klass = HTMLItem - l = [klass(self._client, self.classname, x) - for x in self._klass.filter(None, filterspec, sort, group)] - return l - - def classhelp(self, properties=None, label='list', width='500', - height='400'): - ''' Pop up a javascript window with class help - - This generates a link to a popup window which displays the - properties indicated by "properties" of the class named by - "classname". The "properties" should be a comma-separated list - (eg. 'id,name,description'). Properties defaults to all the - properties of a class (excluding id, creator, created and - activity). - - You may optionally override the label displayed, the width and - height. The popup window will be resizable and scrollable. - ''' - if properties is None: - properties = self._klass.getprops(protected=0).keys() - properties.sort() - properties = ','.join(properties) - return '<a href="javascript:help_window(\'%s?:template=help&' \ - 'properties=%s\', \'%s\', \'%s\')"><b>(%s)</b></a>'%( - self.classname, properties, width, height, label) - - def submit(self, label="Submit New Entry"): - ''' Generate a submit button (and action hidden element) - ''' - return ' <input type="hidden" name=":action" value="new">\n'\ - ' <input type="submit" name="submit" value="%s">'%label - - def history(self): - return 'New node - no history' - - def renderWith(self, name, **kwargs): - ''' Render this class with the given template. - ''' - # create a new request and override the specified args - req = HTMLRequest(self._client) - req.classname = self.classname - req.update(kwargs) - - # new template, using the specified classname and request - pt = Templates(self._db.config.TEMPLATES).get(self.classname, name) - - # use our fabricated request - return pt.render(self._client, self.classname, req) - -class HTMLItem(HTMLPermissions): - ''' Accesses through an *item* - ''' - def __init__(self, client, classname, nodeid): - self._client = client - self._db = client.db - self._classname = classname - self._nodeid = nodeid - self._klass = self._db.getclass(classname) - self._props = self._klass.getprops() - - def __repr__(self): - return '<HTMLItem(0x%x) %s %s>'%(id(self), self._classname, - self._nodeid) - - def __getitem__(self, item): - ''' return an HTMLProperty instance - ''' - #print 'HTMLItem.getitem', (self, item) - if item == 'id': - return self._nodeid - - # get the property - prop = self._props[item] - - # get the value, handling missing values - value = self._klass.get(self._nodeid, item, None) - if value is None: - if isinstance(self._props[item], hyperdb.Multilink): - value = [] - - # look up the correct HTMLProperty class - for klass, htmlklass in propclasses: - if isinstance(prop, klass): - return htmlklass(self._client, self._nodeid, prop, item, value) - - raise KeyErorr, item - - def __getattr__(self, attr): - ''' convenience access to properties ''' - try: - return self[attr] - except KeyError: - raise AttributeError, attr - - def submit(self, label="Submit Changes"): - ''' Generate a submit button (and action hidden element) - ''' - return ' <input type="hidden" name=":action" value="edit">\n'\ - ' <input type="submit" name="submit" value="%s">'%label - - def journal(self, direction='descending'): - ''' Return a list of HTMLJournalEntry instances. - ''' - # XXX do this - return [] - - def history(self, direction='descending'): - l = ['<table class="history">' - '<tr><th colspan="4" class="header">', - _('History'), - '</th></tr><tr>', - _('<th>Date</th>'), - _('<th>User</th>'), - _('<th>Action</th>'), - _('<th>Args</th>'), - '</tr>'] - comments = {} - history = self._klass.history(self._nodeid) - history.sort() - if direction == 'descending': - history.reverse() - for id, evt_date, user, action, args in history: - date_s = str(evt_date).replace("."," ") - arg_s = '' - if action == 'link' and type(args) == type(()): - if len(args) == 3: - linkcl, linkid, key = args - arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid, - linkcl, linkid, key) - else: - arg_s = str(args) - - elif action == 'unlink' and type(args) == type(()): - if len(args) == 3: - linkcl, linkid, key = args - arg_s += '<a href="%s%s">%s%s %s</a>'%(linkcl, linkid, - linkcl, linkid, key) - else: - arg_s = str(args) - - elif type(args) == type({}): - cell = [] - for k in args.keys(): - # try to get the relevant property and treat it - # specially - try: - prop = self._props[k] - except KeyError: - prop = None - if prop is not None: - if args[k] and (isinstance(prop, hyperdb.Multilink) or - isinstance(prop, hyperdb.Link)): - # figure what the link class is - classname = prop.classname - try: - linkcl = self._db.getclass(classname) - except KeyError: - labelprop = None - comments[classname] = _('''The linked class - %(classname)s no longer exists''')%locals() - labelprop = linkcl.labelprop(1) - hrefable = os.path.exists( - os.path.join(self._db.config.TEMPLATES, - classname+'.item')) - - if isinstance(prop, hyperdb.Multilink) and \ - len(args[k]) > 0: - ml = [] - for linkid in args[k]: - if isinstance(linkid, type(())): - sublabel = linkid[0] + ' ' - linkids = linkid[1] - else: - sublabel = '' - linkids = [linkid] - subml = [] - for linkid in linkids: - label = classname + linkid - # if we have a label property, try to use it - # TODO: test for node existence even when - # there's no labelprop! - try: - if labelprop is not None: - label = linkcl.get(linkid, labelprop) - except IndexError: - comments['no_link'] = _('''<strike>The - linked node no longer - exists</strike>''') - subml.append('<strike>%s</strike>'%label) - else: - if hrefable: - subml.append('<a href="%s%s">%s</a>'%( - classname, linkid, label)) - ml.append(sublabel + ', '.join(subml)) - cell.append('%s:\n %s'%(k, ', '.join(ml))) - elif isinstance(prop, hyperdb.Link) and args[k]: - label = classname + args[k] - # if we have a label property, try to use it - # TODO: test for node existence even when - # there's no labelprop! - if labelprop is not None: - try: - label = linkcl.get(args[k], labelprop) - except IndexError: - comments['no_link'] = _('''<strike>The - linked node no longer - exists</strike>''') - cell.append(' <strike>%s</strike>,\n'%label) - # "flag" this is done .... euwww - label = None - if label is not None: - if hrefable: - cell.append('%s: <a href="%s%s">%s</a>\n'%(k, - classname, args[k], label)) - else: - cell.append('%s: %s' % (k,label)) - - elif isinstance(prop, hyperdb.Date) and args[k]: - d = date.Date(args[k]) - cell.append('%s: %s'%(k, str(d))) - - elif isinstance(prop, hyperdb.Interval) and args[k]: - d = date.Interval(args[k]) - cell.append('%s: %s'%(k, str(d))) - - elif isinstance(prop, hyperdb.String) and args[k]: - cell.append('%s: %s'%(k, cgi.escape(args[k]))) - - elif not args[k]: - cell.append('%s: (no value)\n'%k) - - else: - cell.append('%s: %s\n'%(k, str(args[k]))) - else: - # property no longer exists - comments['no_exist'] = _('''<em>The indicated property - no longer exists</em>''') - cell.append('<em>%s: %s</em>\n'%(k, str(args[k]))) - arg_s = '<br />'.join(cell) - else: - # unkown event!! - comments['unknown'] = _('''<strong><em>This event is not - handled by the history display!</em></strong>''') - arg_s = '<strong><em>' + str(args) + '</em></strong>' - date_s = date_s.replace(' ', ' ') - l.append('<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>'%( - date_s, user, action, arg_s)) - if comments: - l.append(_('<tr><td colspan=4><strong>Note:</strong></td></tr>')) - for entry in comments.values(): - l.append('<tr><td colspan=4>%s</td></tr>'%entry) - l.append('</table>') - return '\n'.join(l) - - def renderQueryForm(self): - ''' Render this item, which is a query, as a search form. - ''' - # create a new request and override the specified args - req = HTMLRequest(self._client) - req.classname = self._klass.get(self._nodeid, 'klass') - req.updateFromURL(self._klass.get(self._nodeid, 'url')) - - # new template, using the specified classname and request - pt = getTemplate(self._db.config.TEMPLATES, req.classname, 'search') - - # use our fabricated request - return pt.render(self._client, req.classname, req) - -class HTMLUser(HTMLItem): - ''' Accesses through the *user* (a special case of item) - ''' - def __init__(self, client, classname, nodeid): - HTMLItem.__init__(self, client, 'user', nodeid) - self._default_classname = client.classname - - # used for security checks - self._security = client.db.security - - _marker = [] - def hasPermission(self, role, classname=_marker): - ''' Determine if the user has the Role. - - The class being tested defaults to the template's class, but may - be overidden for this test by suppling an alternate classname. - ''' - if classname is self._marker: - classname = self._default_classname - return self._security.hasPermission(role, self._nodeid, classname) - - def is_edit_ok(self): - ''' Is the user allowed to Edit the current class? - Also check whether this is the current user's info. - ''' - return self._db.security.hasPermission('Edit', self._client.userid, - self._classname) or self._nodeid == self._client.userid - - def is_view_ok(self): - ''' Is the user allowed to View the current class? - Also check whether this is the current user's info. - ''' - return self._db.security.hasPermission('Edit', self._client.userid, - self._classname) or self._nodeid == self._client.userid - -class HTMLProperty: - ''' String, Number, Date, Interval HTMLProperty - - Has useful attributes: - - _name the name of the property - _value the value of the property if any - - A wrapper object which may be stringified for the plain() behaviour. - ''' - def __init__(self, client, nodeid, prop, name, value): - self._client = client - self._db = client.db - self._nodeid = nodeid - self._prop = prop - self._name = name - self._value = value - def __repr__(self): - return '<HTMLProperty(0x%x) %s %r %r>'%(id(self), self._name, self._prop, self._value) - def __str__(self): - return self.plain() - def __cmp__(self, other): - if isinstance(other, HTMLProperty): - return cmp(self._value, other._value) - return cmp(self._value, other) - -class StringHTMLProperty(HTMLProperty): - def plain(self, escape=0): - ''' Render a "plain" representation of the property - ''' - if self._value is None: - return '' - if escape: - return cgi.escape(str(self._value)) - return str(self._value) - - def stext(self, escape=0): - ''' Render the value of the property as StructuredText. - - This requires the StructureText module to be installed separately. - ''' - s = self.plain(escape=escape) - if not StructuredText: - return s - return StructuredText(s,level=1,header=0) - - def field(self, size = 30): - ''' Render a form edit field for the property - ''' - if self._value is None: - value = '' - else: - value = cgi.escape(str(self._value)) - value = '"'.join(value.split('"')) - return '<input name="%s" value="%s" size="%s">'%(self._name, value, size) - - def multiline(self, escape=0, rows=5, cols=40): - ''' Render a multiline form edit field for the property - ''' - if self._value is None: - value = '' - else: - value = cgi.escape(str(self._value)) - value = '"'.join(value.split('"')) - return '<textarea name="%s" rows="%s" cols="%s">%s</textarea>'%( - self._name, rows, cols, value) - - def email(self, escape=1): - ''' Render the value of the property as an obscured email address - ''' - if self._value is None: value = '' - else: value = str(self._value) - if value.find('@') != -1: - name, domain = value.split('@') - domain = ' '.join(domain.split('.')[:-1]) - name = name.replace('.', ' ') - value = '%s at %s ...'%(name, domain) - else: - value = value.replace('.', ' ') - if escape: - value = cgi.escape(value) - return value - -class PasswordHTMLProperty(HTMLProperty): - def plain(self): - ''' Render a "plain" representation of the property - ''' - if self._value is None: - return '' - return _('*encrypted*') - - def field(self, size = 30): - ''' Render a form edit field for the property. - ''' - return '<input type="password" name="%s" size="%s">'%(self._name, size) - - def confirm(self, size = 30): - ''' Render a second form edit field for the property, used for - confirmation that the user typed the password correctly. Generates - a field with name "name:confirm". - ''' - return '<input type="password" name="%s:confirm" size="%s">'%( - self._name, size) - -class NumberHTMLProperty(HTMLProperty): - def plain(self): - ''' Render a "plain" representation of the property - ''' - return str(self._value) - - def field(self, size = 30): - ''' Render a form edit field for the property - ''' - if self._value is None: - value = '' - else: - value = cgi.escape(str(self._value)) - value = '"'.join(value.split('"')) - return '<input name="%s" value="%s" size="%s">'%(self._name, value, size) - -class BooleanHTMLProperty(HTMLProperty): - def plain(self): - ''' Render a "plain" representation of the property - ''' - if self.value is None: - return '' - return self._value and "Yes" or "No" - - def field(self): - ''' Render a form edit field for the property - ''' - checked = self._value and "checked" or "" - s = '<input type="radio" name="%s" value="yes" %s>Yes'%(self._name, - checked) - if checked: - checked = "" - else: - checked = "checked" - s += '<input type="radio" name="%s" value="no" %s>No'%(self._name, - checked) - return s - -class DateHTMLProperty(HTMLProperty): - def plain(self): - ''' Render a "plain" representation of the property - ''' - if self._value is None: - return '' - return str(self._value) - - def field(self, size = 30): - ''' Render a form edit field for the property - ''' - if self._value is None: - value = '' - else: - value = cgi.escape(str(self._value)) - value = '"'.join(value.split('"')) - return '<input name="%s" value="%s" size="%s">'%(self._name, value, size) - - def reldate(self, pretty=1): - ''' Render the interval between the date and now. - - If the "pretty" flag is true, then make the display pretty. - ''' - if not self._value: - return '' - - # figure the interval - interval = date.Date('.') - self._value - if pretty: - return interval.pretty() - return str(interval) - -class IntervalHTMLProperty(HTMLProperty): - def plain(self): - ''' Render a "plain" representation of the property - ''' - if self._value is None: - return '' - return str(self._value) - - def pretty(self): - ''' Render the interval in a pretty format (eg. "yesterday") - ''' - return self._value.pretty() - - def field(self, size = 30): - ''' Render a form edit field for the property - ''' - if self._value is None: - value = '' - else: - value = cgi.escape(str(self._value)) - value = '"'.join(value.split('"')) - return '<input name="%s" value="%s" size="%s">'%(self._name, value, size) - -class LinkHTMLProperty(HTMLProperty): - ''' Link HTMLProperty - Include the above as well as being able to access the class - information. Stringifying the object itself results in the value - from the item being displayed. Accessing attributes of this object - result in the appropriate entry from the class being queried for the - property accessed (so item/assignedto/name would look up the user - entry identified by the assignedto property on item, and then the - name property of that user) - ''' - def __getattr__(self, attr): - ''' return a new HTMLItem ''' - #print 'Link.getattr', (self, attr, self._value) - if not self._value: - raise AttributeError, "Can't access missing value" - if self._prop.classname == 'user': - klass = HTMLUser - else: - klass = HTMLItem - i = klass(self._client, self._prop.classname, self._value) - return getattr(i, attr) - - def plain(self, escape=0): - ''' Render a "plain" representation of the property - ''' - if self._value is None: - return '' - linkcl = self._db.classes[self._prop.classname] - k = linkcl.labelprop(1) - value = str(linkcl.get(self._value, k)) - if escape: - value = cgi.escape(value) - return value - - def field(self, showid=0, size=None): - ''' Render a form edit field for the property - ''' - linkcl = self._db.getclass(self._prop.classname) - if linkcl.getprops().has_key('order'): - sort_on = 'order' - else: - sort_on = linkcl.labelprop() - options = linkcl.filter(None, {}, ('+', sort_on), (None, None)) - # TODO: make this a field display, not a menu one! - l = ['<select name="%s">'%self._name] - k = linkcl.labelprop(1) - if self._value is None: - s = 'selected ' - else: - s = '' - l.append(_('<option %svalue="-1">- no selection -</option>')%s) - for optionid in options: - # get the option value, and if it's None use an empty string - option = linkcl.get(optionid, k) or '' - - # figure if this option is selected - s = '' - if optionid == self._value: - s = 'selected ' - - # figure the label - if showid: - lab = '%s%s: %s'%(self._prop.classname, optionid, option) - else: - lab = option - - # truncate if it's too long - if size is not None and len(lab) > size: - lab = lab[:size-3] + '...' - - # and generate - lab = cgi.escape(lab) - l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab)) - l.append('</select>') - return '\n'.join(l) - - def menu(self, size=None, height=None, showid=0, additional=[], - **conditions): - ''' Render a form select list for this property - ''' - value = self._value - - # sort function - sortfunc = make_sort_function(self._db, self._prop.classname) - - linkcl = self._db.getclass(self._prop.classname) - l = ['<select name="%s">'%self._name] - k = linkcl.labelprop(1) - s = '' - if value is None: - s = 'selected ' - l.append(_('<option %svalue="-1">- no selection -</option>')%s) - if linkcl.getprops().has_key('order'): - sort_on = ('+', 'order') - else: - sort_on = ('+', linkcl.labelprop()) - options = linkcl.filter(None, conditions, sort_on, (None, None)) - for optionid in options: - # get the option value, and if it's None use an empty string - option = linkcl.get(optionid, k) or '' - - # figure if this option is selected - s = '' - if value in [optionid, option]: - s = 'selected ' - - # figure the label - if showid: - lab = '%s%s: %s'%(self._prop.classname, optionid, option) - else: - lab = option - - # truncate if it's too long - if size is not None and len(lab) > size: - lab = lab[:size-3] + '...' - if additional: - m = [] - for propname in additional: - m.append(linkcl.get(optionid, propname)) - lab = lab + ' (%s)'%', '.join(map(str, m)) - - # and generate - lab = cgi.escape(lab) - l.append('<option %svalue="%s">%s</option>'%(s, optionid, lab)) - l.append('</select>') - return '\n'.join(l) -# def checklist(self, ...) - -class MultilinkHTMLProperty(HTMLProperty): - ''' Multilink HTMLProperty - - Also be iterable, returning a wrapper object like the Link case for - each entry in the multilink. - ''' - def __len__(self): - ''' length of the multilink ''' - return len(self._value) - - def __getattr__(self, attr): - ''' no extended attribute accesses make sense here ''' - raise AttributeError, attr - - def __getitem__(self, num): - ''' iterate and return a new HTMLItem - ''' - #print 'Multi.getitem', (self, num) - value = self._value[num] - if self._prop.classname == 'user': - klass = HTMLUser - else: - klass = HTMLItem - return klass(self._client, self._prop.classname, value) - - def __contains__(self, value): - ''' Support the "in" operator - ''' - return value in self._value - - def reverse(self): - ''' return the list in reverse order - ''' - l = self._value[:] - l.reverse() - if self._prop.classname == 'user': - klass = HTMLUser - else: - klass = HTMLItem - return [klass(self._client, self._prop.classname, value) for value in l] - - def plain(self, escape=0): - ''' Render a "plain" representation of the property - ''' - linkcl = self._db.classes[self._prop.classname] - k = linkcl.labelprop(1) - labels = [] - for v in self._value: - labels.append(linkcl.get(v, k)) - value = ', '.join(labels) - if escape: - value = cgi.escape(value) - return value - - def field(self, size=30, showid=0): - ''' Render a form edit field for the property - ''' - sortfunc = make_sort_function(self._db, self._prop.classname) - linkcl = self._db.getclass(self._prop.classname) - value = self._value[:] - if value: - value.sort(sortfunc) - # map the id to the label property - if not linkcl.getkey(): - showid=1 - if not showid: - k = linkcl.labelprop(1) - value = [linkcl.get(v, k) for v in value] - value = cgi.escape(','.join(value)) - return '<input name="%s" size="%s" value="%s">'%(self._name, size, value) - - def menu(self, size=None, height=None, showid=0, additional=[], - **conditions): - ''' Render a form select list for this property - ''' - value = self._value - - # sort function - sortfunc = make_sort_function(self._db, self._prop.classname) - - linkcl = self._db.getclass(self._prop.classname) - if linkcl.getprops().has_key('order'): - sort_on = ('+', 'order') - else: - sort_on = ('+', linkcl.labelprop()) - options = linkcl.filter(None, conditions, sort_on, (None,None)) - height = height or min(len(options), 7) - l = ['<select multiple name="%s" size="%s">'%(self._name, height)] - k = linkcl.labelprop(1) - for optionid in options: - # get the option value, and if it's None use an empty string - option = linkcl.get(optionid, k) or '' - - # figure if this option is selected - s = '' - if optionid in value or option in value: - s = 'selected ' - - # figure the label - if showid: - lab = '%s%s: %s'%(self._prop.classname, optionid, option) - else: - lab = option - # truncate if it's too long - if size is not None and len(lab) > size: - lab = lab[:size-3] + '...' - if additional: - m = [] - for propname in additional: - m.append(linkcl.get(optionid, propname)) - lab = lab + ' (%s)'%', '.join(m) - - # and generate - lab = cgi.escape(lab) - l.append('<option %svalue="%s">%s</option>'%(s, optionid, - lab)) - l.append('</select>') - return '\n'.join(l) - -# set the propclasses for HTMLItem -propclasses = ( - (hyperdb.String, StringHTMLProperty), - (hyperdb.Number, NumberHTMLProperty), - (hyperdb.Boolean, BooleanHTMLProperty), - (hyperdb.Date, DateHTMLProperty), - (hyperdb.Interval, IntervalHTMLProperty), - (hyperdb.Password, PasswordHTMLProperty), - (hyperdb.Link, LinkHTMLProperty), - (hyperdb.Multilink, MultilinkHTMLProperty), -) - -def make_sort_function(db, classname): - '''Make a sort function for a given class - ''' - linkcl = db.getclass(classname) - if linkcl.getprops().has_key('order'): - sort_on = 'order' - else: - sort_on = linkcl.labelprop() - def sortfunc(a, b, linkcl=linkcl, sort_on=sort_on): - return cmp(linkcl.get(a, sort_on), linkcl.get(b, sort_on)) - return sortfunc - -def handleListCGIValue(value): - ''' Value is either a single item or a list of items. Each item has a - .value that we're actually interested in. - ''' - if isinstance(value, type([])): - return [value.value for value in value] - else: - value = value.value.strip() - if not value: - return [] - return value.split(',') - -class ShowDict: - ''' A convenience access to the :columns index parameters - ''' - def __init__(self, columns): - self.columns = {} - for col in columns: - self.columns[col] = 1 - def __getitem__(self, name): - return self.columns.has_key(name) - -class HTMLRequest: - ''' The *request*, holding the CGI form and environment. - - "form" the CGI form as a cgi.FieldStorage - "env" the CGI environment variables - "base" the base URL for this instance - "user" a HTMLUser instance for this user - "classname" the current classname (possibly None) - "template" the current template (suffix, also possibly None) - - Index args: - "columns" dictionary of the columns to display in an index page - "show" a convenience access to columns - request/show/colname will - be true if the columns should be displayed, false otherwise - "sort" index sort column (direction, column name) - "group" index grouping property (direction, column name) - "filter" properties to filter the index on - "filterspec" values to filter the index on - "search_text" text to perform a full-text search on for an index - - ''' - def __init__(self, client): - self.client = client - - # easier access vars - self.form = client.form - self.env = client.env - self.base = client.base - self.user = HTMLUser(client, 'user', client.userid) - - # store the current class name and action - self.classname = client.classname - self.template = client.template - - self._post_init() - - def _post_init(self): - ''' Set attributes based on self.form - ''' - # extract the index display information from the form - self.columns = [] - if self.form.has_key(':columns'): - self.columns = handleListCGIValue(self.form[':columns']) - self.show = ShowDict(self.columns) - - # sorting - self.sort = (None, None) - if self.form.has_key(':sort'): - sort = self.form[':sort'].value - if sort.startswith('-'): - self.sort = ('-', sort[1:]) - else: - self.sort = ('+', sort) - if self.form.has_key(':sortdir'): - self.sort = ('-', self.sort[1]) - - # grouping - self.group = (None, None) - if self.form.has_key(':group'): - group = self.form[':group'].value - if group.startswith('-'): - self.group = ('-', group[1:]) - else: - self.group = ('+', group) - if self.form.has_key(':groupdir'): - self.group = ('-', self.group[1]) - - # filtering - self.filter = [] - if self.form.has_key(':filter'): - self.filter = handleListCGIValue(self.form[':filter']) - self.filterspec = {} - db = self.client.db - if self.classname is not None: - props = db.getclass(self.classname).getprops() - for name in self.filter: - if self.form.has_key(name): - prop = props[name] - fv = self.form[name] - if (isinstance(prop, hyperdb.Link) or - isinstance(prop, hyperdb.Multilink)): - self.filterspec[name] = lookupIds(db, prop, - handleListCGIValue(fv)) - else: - self.filterspec[name] = fv.value - - # full-text search argument - self.search_text = None - if self.form.has_key(':search_text'): - self.search_text = self.form[':search_text'].value - - # pagination - size and start index - # figure batch args - if self.form.has_key(':pagesize'): - self.pagesize = int(self.form[':pagesize'].value) - else: - self.pagesize = 50 - if self.form.has_key(':startwith'): - self.startwith = int(self.form[':startwith'].value) - else: - self.startwith = 0 - - def updateFromURL(self, url): - ''' Parse the URL for query args, and update my attributes using the - values. - ''' - self.form = {} - for name, value in cgi.parse_qsl(url): - if self.form.has_key(name): - if isinstance(self.form[name], type([])): - self.form[name].append(cgi.MiniFieldStorage(name, value)) - else: - self.form[name] = [self.form[name], - cgi.MiniFieldStorage(name, value)] - else: - self.form[name] = cgi.MiniFieldStorage(name, value) - self._post_init() - - def update(self, kwargs): - ''' Update my attributes using the keyword args - ''' - self.__dict__.update(kwargs) - if kwargs.has_key('columns'): - self.show = ShowDict(self.columns) - - def description(self): - ''' Return a description of the request - handle for the page title. - ''' - s = [self.client.db.config.TRACKER_NAME] - if self.classname: - if self.client.nodeid: - s.append('- %s%s'%(self.classname, self.client.nodeid)) - else: - if self.template == 'item': - s.append('- new %s'%self.classname) - elif self.template == 'index': - s.append('- %s index'%self.classname) - else: - s.append('- %s %s'%(self.classname, self.template)) - else: - s.append('- home') - return ' '.join(s) - - def __str__(self): - d = {} - d.update(self.__dict__) - f = '' - for k in self.form.keys(): - f += '\n %r=%r'%(k,handleListCGIValue(self.form[k])) - d['form'] = f - e = '' - for k,v in self.env.items(): - e += '\n %r=%r'%(k, v) - d['env'] = e - return ''' -form: %(form)s -base: %(base)r -classname: %(classname)r -template: %(template)r -columns: %(columns)r -sort: %(sort)r -group: %(group)r -filter: %(filter)r -search_text: %(search_text)r -pagesize: %(pagesize)r -startwith: %(startwith)r -env: %(env)s -'''%d - - def indexargs_form(self, columns=1, sort=1, group=1, filter=1, - filterspec=1): - ''' return the current index args as form elements ''' - l = [] - s = '<input type="hidden" name="%s" value="%s">' - if columns and self.columns: - l.append(s%(':columns', ','.join(self.columns))) - if sort and self.sort[1] is not None: - if self.sort[0] == '-': - val = '-'+self.sort[1] - else: - val = self.sort[1] - l.append(s%(':sort', val)) - if group and self.group[1] is not None: - if self.group[0] == '-': - val = '-'+self.group[1] - else: - val = self.group[1] - l.append(s%(':group', val)) - if filter and self.filter: - l.append(s%(':filter', ','.join(self.filter))) - if filterspec: - for k,v in self.filterspec.items(): - l.append(s%(k, ','.join(v))) - if self.search_text: - l.append(s%(':search_text', self.search_text)) - l.append(s%(':pagesize', self.pagesize)) - l.append(s%(':startwith', self.startwith)) - return '\n'.join(l) - - def indexargs_url(self, url, args): - ''' embed the current index args in a URL ''' - l = ['%s=%s'%(k,v) for k,v in args.items()] - if self.columns and not args.has_key(':columns'): - l.append(':columns=%s'%(','.join(self.columns))) - if self.sort[1] is not None and not args.has_key(':sort'): - if self.sort[0] == '-': - val = '-'+self.sort[1] - else: - val = self.sort[1] - l.append(':sort=%s'%val) - if self.group[1] is not None and not args.has_key(':group'): - if self.group[0] == '-': - val = '-'+self.group[1] - else: - val = self.group[1] - l.append(':group=%s'%val) - if self.filter and not args.has_key(':columns'): - l.append(':filter=%s'%(','.join(self.filter))) - for k,v in self.filterspec.items(): - if not args.has_key(k): - l.append('%s=%s'%(k, ','.join(v))) - if self.search_text and not args.has_key(':search_text'): - l.append(':search_text=%s'%self.search_text) - if not args.has_key(':pagesize'): - l.append(':pagesize=%s'%self.pagesize) - if not args.has_key(':startwith'): - l.append(':startwith=%s'%self.startwith) - return '%s?%s'%(url, '&'.join(l)) - indexargs_href = indexargs_url - - def base_javascript(self): - return ''' -<script language="javascript"> -submitted = false; -function submit_once() { - if (submitted) { - alert("Your request is being processed.\\nPlease be patient."); - return 0; - } - submitted = true; - return 1; -} - -function help_window(helpurl, width, height) { - HelpWin = window.open('%s' + helpurl, 'RoundupHelpWindow', 'scrollbars=yes,resizable=yes,toolbar=no,height='+height+',width='+width); -} -</script> -'''%self.base - - def batch(self): - ''' Return a batch object for results from the "current search" - ''' - filterspec = self.filterspec - sort = self.sort - group = self.group - - # get the list of ids we're batching over - klass = self.client.db.getclass(self.classname) - if self.search_text: - matches = self.client.db.indexer.search( - re.findall(r'\b\w{2,25}\b', self.search_text), klass) - else: - matches = None - l = klass.filter(matches, filterspec, sort, group) - - # return the batch object, using IDs only - return Batch(self.client, l, self.pagesize, self.startwith, - classname=self.classname) - -# extend the standard ZTUtils Batch object to remove dependency on -# Acquisition and add a couple of useful methods -class Batch(ZTUtils.Batch): - ''' Use me to turn a list of items, or item ids of a given class, into a - series of batches. - - ========= ======================================================== - Parameter Usage - ========= ======================================================== - sequence a list of HTMLItems or item ids - classname if sequence is a list of ids, this is the class of item - size how big to make the sequence. - start where to start (0-indexed) in the sequence. - end where to end (0-indexed) in the sequence. - orphan if the next batch would contain less items than this - value, then it is combined with this batch - overlap the number of items shared between adjacent batches - ========= ======================================================== - - Attributes: Note that the "start" attribute, unlike the - argument, is a 1-based index (I know, lame). "first" is the - 0-based index. "length" is the actual number of elements in - the batch. - - "sequence_length" is the length of the original, unbatched, sequence. - ''' - def __init__(self, client, sequence, size, start, end=0, orphan=0, - overlap=0, classname=None): - self.client = client - self.last_index = self.last_item = None - self.current_item = None - self.classname = classname - self.sequence_length = len(sequence) - ZTUtils.Batch.__init__(self, sequence, size, start, end, orphan, - overlap) - - # overwrite so we can late-instantiate the HTMLItem instance - def __getitem__(self, index): - if index < 0: - if index + self.end < self.first: raise IndexError, index - return self._sequence[index + self.end] - - if index >= self.length: - raise IndexError, index - - # move the last_item along - but only if the fetched index changes - # (for some reason, index 0 is fetched twice) - if index != self.last_index: - self.last_item = self.current_item - self.last_index = index - - item = self._sequence[index + self.first] - if self.classname: - # map the item ids to instances - if self.classname == 'user': - item = HTMLUser(self.client, self.classname, item) - else: - item = HTMLItem(self.client, self.classname, item) - self.current_item = item - return item - - def propchanged(self, property): - ''' Detect if the property marked as being the group property - changed in the last iteration fetch - ''' - if (self.last_item is None or - self.last_item[property] != self.current_item[property]): - return 1 - return 0 - - # override these 'cos we don't have access to acquisition - def previous(self): - if self.start == 1: - return None - return Batch(self.client, self._sequence, self._size, - self.first - self._size + self.overlap, 0, self.orphan, - self.overlap) - - def next(self): - try: - self._sequence[self.end] - except IndexError: - return None - return Batch(self.client, self._sequence, self._size, - self.end - self.overlap, 0, self.orphan, self.overlap) - -class TemplatingUtils: - ''' Utilities for templating - ''' - def __init__(self, client): - self.client = client - def Batch(self, sequence, size, start, end=0, orphan=0, overlap=0): - return Batch(self.client, sequence, size, start, end, orphan, - overlap) -
--- a/roundup/date.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,473 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: date.py,v 1.32 2002-09-23 12:09:29 richard Exp $ - -__doc__ = """ -Date, time and time interval handling. -""" - -import time, re, calendar -from i18n import _ - -class Date: - ''' - As strings, date-and-time stamps are specified with the date in - international standard format (yyyy-mm-dd) joined to the time - (hh:mm:ss) by a period ("."). Dates in this form can be easily compared - and are fairly readable when printed. An example of a valid stamp is - "2000-06-24.13:03:59". We'll call this the "full date format". When - Timestamp objects are printed as strings, they appear in the full date - format with the time always given in GMT. The full date format is - always exactly 19 characters long. - - For user input, some partial forms are also permitted: the whole time - or just the seconds may be omitted; and the whole date may be omitted - or just the year may be omitted. If the time is given, the time is - interpreted in the user's local time zone. The Date constructor takes - care of these conversions. In the following examples, suppose that yyyy - is the current year, mm is the current month, and dd is the current day - of the month; and suppose that the user is on Eastern Standard Time. - - "2000-04-17" means <Date 2000-04-17.00:00:00> - "01-25" means <Date yyyy-01-25.00:00:00> - "2000-04-17.03:45" means <Date 2000-04-17.08:45:00> - "08-13.22:13" means <Date yyyy-08-14.03:13:00> - "11-07.09:32:43" means <Date yyyy-11-07.14:32:43> - "14:25" means <Date yyyy-mm-dd.19:25:00> - "8:47:11" means <Date yyyy-mm-dd.13:47:11> - "." means "right now" - - The Date class should understand simple date expressions of the form - stamp + interval and stamp - interval. When adding or subtracting - intervals involving months or years, the components are handled - separately. For example, when evaluating "2000-06-25 + 1m 10d", we - first add one month to get 2000-07-25, then add 10 days to get - 2000-08-04 (rather than trying to decide whether 1m 10d means 38 or 40 - or 41 days). - - Example usage: - >>> Date(".") - <Date 2000-06-26.00:34:02> - >>> _.local(-5) - "2000-06-25.19:34:02" - >>> Date(". + 2d") - <Date 2000-06-28.00:34:02> - >>> Date("1997-04-17", -5) - <Date 1997-04-17.00:00:00> - >>> Date("01-25", -5) - <Date 2000-01-25.00:00:00> - >>> Date("08-13.22:13", -5) - <Date 2000-08-14.03:13:00> - >>> Date("14:25", -5) - <Date 2000-06-25.19:25:00> - - The date format 'yyyymmddHHMMSS' (year, month, day, hour, - minute, second) is the serialisation format returned by the serialise() - method, and is accepted as an argument on instatiation. - ''' - def __init__(self, spec='.', offset=0): - """Construct a date given a specification and a time zone offset. - - 'spec' is a full date or a partial form, with an optional - added or subtracted interval. Or a date 9-tuple. - 'offset' is the local time zone offset from GMT in hours. - """ - if type(spec) == type(''): - self.set(spec, offset=offset) - else: - y,m,d,H,M,S,x,x,x = spec - ts = calendar.timegm((y,m,d,H+offset,M,S,0,0,0)) - self.year, self.month, self.day, self.hour, self.minute, \ - self.second, x, x, x = time.gmtime(ts) - - def addInterval(self, interval): - ''' Add the interval to this date, returning the date tuple - ''' - # do the basic calc - sign = interval.sign - year = self.year + sign * interval.year - month = self.month + sign * interval.month - day = self.day + sign * interval.day - hour = self.hour + sign * interval.hour - minute = self.minute + sign * interval.minute - second = self.second + sign * interval.second - - # now cope with under- and over-flow - # first do the time - while (second < 0 or second > 59 or minute < 0 or minute > 59 or - hour < 0 or hour > 59): - if second < 0: minute -= 1; second += 60 - elif second > 59: minute += 1; second -= 60 - if minute < 0: hour -= 1; minute += 60 - elif minute > 59: hour += 1; minute -= 60 - if hour < 0: day -= 1; hour += 24 - elif hour > 59: day += 1; hour -= 24 - - # fix up the month so we're within range - while month < 1 or month > 12: - if month < 1: year -= 1; month += 12 - if month > 12: year += 1; month -= 12 - - # now do the days, now that we know what month we're in - mdays = calendar.mdays - if month == 2 and calendar.isleap(year): month_days = 29 - else: month_days = mdays[month] - while month < 1 or month > 12 or day < 0 or day > month_days: - # now to day under/over - if day < 0: month -= 1; day += month_days - elif day > month_days: month += 1; day -= month_days - - # possibly fix up the month so we're within range - while month < 1 or month > 12: - if month < 1: year -= 1; month += 12 - if month > 12: year += 1; month -= 12 - - # re-figure the number of days for this month - if month == 2 and calendar.isleap(year): month_days = 29 - else: month_days = mdays[month] - return (year, month, day, hour, minute, second, 0, 0, 0) - - def applyInterval(self, interval): - ''' Apply the interval to this date - ''' - self.year, self.month, self.day, self.hour, self.minute, \ - self.second, x, x, x = self.addInterval(interval) - - def __add__(self, interval): - """Add an interval to this date to produce another date. - """ - return Date(self.addInterval(interval)) - - # deviates from spec to allow subtraction of dates as well - def __sub__(self, other): - """ Subtract: - 1. an interval from this date to produce another date. - 2. a date from this date to produce an interval. - """ - if isinstance(other, Interval): - other = Interval(other.get_tuple()) - other.sign *= -1 - return self.__add__(other) - - assert isinstance(other, Date), 'May only subtract Dates or Intervals' - - # TODO this code will fall over laughing if the dates cross - # leap years, phases of the moon, .... - a = calendar.timegm((self.year, self.month, self.day, self.hour, - self.minute, self.second, 0, 0, 0)) - b = calendar.timegm((other.year, other.month, other.day, - other.hour, other.minute, other.second, 0, 0, 0)) - diff = a - b - if diff < 0: - sign = 1 - diff = -diff - else: - sign = -1 - S = diff%60 - M = (diff/60)%60 - H = (diff/(60*60))%60 - if H>1: S = 0 - d = (diff/(24*60*60))%30 - if d>1: H = S = M = 0 - m = (diff/(30*24*60*60))%12 - if m>1: H = S = M = 0 - y = (diff/(365*24*60*60)) - if y>1: d = H = S = M = 0 - return Interval((y, m, d, H, M, S), sign=sign) - - def __cmp__(self, other): - """Compare this date to another date.""" - if other is None: - return 1 - for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'): - if not hasattr(other, attr): - return 1 - r = cmp(getattr(self, attr), getattr(other, attr)) - if r: return r - return 0 - - def __str__(self): - """Return this date as a string in the yyyy-mm-dd.hh:mm:ss format.""" - return '%4d-%02d-%02d.%02d:%02d:%02d'%(self.year, self.month, self.day, - self.hour, self.minute, self.second) - - def pretty(self): - ''' print up the date date using a pretty format... - ''' - str = time.strftime('%d %B %Y', (self.year, self.month, - self.day, self.hour, self.minute, self.second, 0, 0, 0)) - if str[0] == '0': return ' ' + str[1:] - return str - - def set(self, spec, offset=0, date_re=re.compile(r''' - (((?P<y>\d\d\d\d)-)?((?P<m>\d\d?)-(?P<d>\d\d?))?)? # yyyy-mm-dd - (?P<n>\.)? # . - (((?P<H>\d?\d):(?P<M>\d\d))?(:(?P<S>\d\d))?)? # hh:mm:ss - (?P<o>.+)? # offset - ''', re.VERBOSE), serialised_re=re.compile(r''' - (\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d) - ''', re.VERBOSE)): - ''' set the date to the value in spec - ''' - m = serialised_re.match(spec) - if m is not None: - # we're serialised - easy! - self.year, self.month, self.day, self.hour, self.minute, \ - self.second = map(int, m.groups()[:6]) - return - - # not serialised data, try usual format - m = date_re.match(spec) - if m is None: - raise ValueError, _('Not a date spec: [[yyyy-]mm-dd].' - '[[h]h:mm[:ss]][offset]') - - info = m.groupdict() - - # get the current date/time using the offset - y,m,d,H,M,S,x,x,x = time.gmtime(time.time()) - - # override year, month, day parts - if info['m'] is not None and info['d'] is not None: - m = int(info['m']) - d = int(info['d']) - if info['y'] is not None: y = int(info['y']) - H = M = S = 0 - - # override hour, minute, second parts - if info['H'] is not None and info['M'] is not None: - H = int(info['H']) - offset - M = int(info['M']) - S = 0 - if info['S'] is not None: S = int(info['S']) - - # now handle the adjustment of hour - ts = calendar.timegm((y,m,d,H,M,S,0,0,0)) - self.year, self.month, self.day, self.hour, self.minute, \ - self.second, x, x, x = time.gmtime(ts) - - if info.get('o', None): - self.applyInterval(Interval(info['o'])) - - def __repr__(self): - return '<Date %s>'%self.__str__() - - def local(self, offset): - """Return this date as yyyy-mm-dd.hh:mm:ss in a local time zone.""" - t = (self.year, self.month, self.day, self.hour + offset, self.minute, - self.second, 0, 0, 0) - self.year, self.month, self.day, self.hour, self.minute, \ - self.second, x, x, x = time.gmtime(calendar.timegm(t)) - - def get_tuple(self): - return (self.year, self.month, self.day, self.hour, self.minute, - self.second, 0, 0, 0) - - def serialise(self): - return '%4d%02d%02d%02d%02d%02d'%(self.year, self.month, - self.day, self.hour, self.minute, self.second) - -class Interval: - ''' - Date intervals are specified using the suffixes "y", "m", and "d". The - suffix "w" (for "week") means 7 days. Time intervals are specified in - hh:mm:ss format (the seconds may be omitted, but the hours and minutes - may not). - - "3y" means three years - "2y 1m" means two years and one month - "1m 25d" means one month and 25 days - "2w 3d" means two weeks and three days - "1d 2:50" means one day, two hours, and 50 minutes - "14:00" means 14 hours - "0:04:33" means four minutes and 33 seconds - - Example usage: - >>> Interval(" 3w 1 d 2:00") - <Interval 22d 2:00> - >>> Date(". + 2d") + Interval("- 3w") - <Date 2000-06-07.00:34:02> - - Intervals are added/subtracted in order of: - seconds, minutes, hours, years, months, days - - Calculations involving monts (eg '+2m') have no effect on days - only - days (or over/underflow from hours/mins/secs) will do that, and - days-per-month and leap years are accounted for. Leap seconds are not. - - The interval format 'syyyymmddHHMMSS' (sign, year, month, day, hour, - minute, second) is the serialisation format returned by the serialise() - method, and is accepted as an argument on instatiation. - - TODO: more examples, showing the order of addition operation - ''' - def __init__(self, spec, sign=1): - """Construct an interval given a specification.""" - if type(spec) == type(''): - self.set(spec) - else: - if len(spec) == 7: - self.sign, self.year, self.month, self.day, self.hour, \ - self.minute, self.second = spec - else: - # old, buggy spec form - self.sign = sign - self.year, self.month, self.day, self.hour, self.minute, \ - self.second = spec - - def __cmp__(self, other): - """Compare this interval to another interval.""" - if other is None: - return 1 - for attr in ('year', 'month', 'day', 'hour', 'minute', 'second'): - if not hasattr(other, attr): - return 1 - r = cmp(getattr(self, attr), getattr(other, attr)) - if r: return r - return 0 - - def __str__(self): - """Return this interval as a string.""" - sign = {1:'+', -1:'-'}[self.sign] - l = [sign] - if self.year: l.append('%sy'%self.year) - if self.month: l.append('%sm'%self.month) - if self.day: l.append('%sd'%self.day) - if self.second: - l.append('%d:%02d:%02d'%(self.hour, self.minute, self.second)) - elif self.hour or self.minute: - l.append('%d:%02d'%(self.hour, self.minute)) - return ' '.join(l) - - def set(self, spec, interval_re=re.compile(''' - \s*(?P<s>[-+])? # + or - - \s*((?P<y>\d+\s*)y)? # year - \s*((?P<m>\d+\s*)m)? # month - \s*((?P<w>\d+\s*)w)? # week - \s*((?P<d>\d+\s*)d)? # day - \s*(((?P<H>\d+):(?P<M>\d+))?(:(?P<S>\d+))?)? # time - \s*''', re.VERBOSE), serialised_re=re.compile(''' - (?P<s>[+-])(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2}) - (?P<H>\d{2})(?P<M>\d{2})(?P<S>\d{2})''', re.VERBOSE)): - ''' set the date to the value in spec - ''' - self.year = self.month = self.week = self.day = self.hour = \ - self.minute = self.second = 0 - self.sign = 1 - m = serialised_re.match(spec) - if not m: - m = interval_re.match(spec) - if not m: - raise ValueError, _('Not an interval spec: [+-] [#y] [#m] [#w] ' - '[#d] [[[H]H:MM]:SS]') - - info = m.groupdict() - for group, attr in {'y':'year', 'm':'month', 'w':'week', 'd':'day', - 'H':'hour', 'M':'minute', 'S':'second'}.items(): - if info.get(group, None) is not None: - setattr(self, attr, int(info[group])) - - if self.week: - self.day = self.day + self.week*7 - - if info['s'] is not None: - self.sign = {'+':1, '-':-1}[info['s']] - - def __repr__(self): - return '<Interval %s>'%self.__str__() - - def pretty(self): - ''' print up the date date using one of these nice formats.. - ''' - if self.year: - if self.year == 1: - return _('1 year') - else: - return _('%(number)s years')%{'number': self.year} - elif self.month or self.day > 13: - days = (self.month * 30) + self.day - if days > 28: - if int(days/30) > 1: - s = _('%(number)s months')%{'number': int(days/30)} - else: - s = _('1 month') - else: - s = _('%(number)s weeks')%{'number': int(days/7)} - elif self.day > 7: - s = _('1 week') - elif self.day > 1: - s = _('%(number)s days')%{'number': self.day} - elif self.day == 1 or self.hour > 12: - if self.sign > 0: - return _('tomorrow') - else: - return _('yesterday') - elif self.hour > 1: - s = _('%(number)s hours')%{'number': self.hour} - elif self.hour == 1: - if self.minute < 15: - s = _('an hour') - elif self.minute/15 == 2: - s = _('1 1/2 hours') - else: - s = _('1 %(number)s/4 hours')%{'number': self.minute/15} - elif self.minute < 1: - if self.sign > 0: - return _('in a moment') - else: - return _('just now') - elif self.minute == 1: - s = _('1 minute') - elif self.minute < 15: - s = _('%(number)s minutes')%{'number': self.minute} - elif int(self.minute/15) == 2: - s = _('1/2 an hour') - else: - s = _('%(number)s/4 hour')%{'number': int(self.minute/15)} - return s - - def get_tuple(self): - return (self.sign, self.year, self.month, self.day, self.hour, - self.minute, self.second) - - def serialise(self): - return '%s%4d%02d%02d%02d%02d%02d'%(self.sign, self.year, self.month, - self.day, self.hour, self.minute, self.second) - - -def test(): - intervals = (" 3w 1 d 2:00", " + 2d", "3w") - for interval in intervals: - print '>>> Interval("%s")'%interval - print `Interval(interval)` - - dates = (".", "2000-06-25.19:34:02", ". + 2d", "1997-04-17", "01-25", - "08-13.22:13", "14:25") - for date in dates: - print '>>> Date("%s")'%date - print `Date(date)` - - sums = ((". + 2d", "3w"), (".", " 3w 1 d 2:00")) - for date, interval in sums: - print '>>> Date("%s") + Interval("%s")'%(date, interval) - print `Date(date) + Interval(interval)` - -if __name__ == '__main__': - test() - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/hyperdb.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,610 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: hyperdb.py,v 1.82 2002-09-09 23:55:19 richard Exp $ - -__doc__ = """ -Hyperdatabase implementation, especially field types. -""" - -# standard python modules -import sys, os, time, re - -# roundup modules -import date, password - -# configure up the DEBUG and TRACE captures -class Sink: - def write(self, content): - pass -DEBUG = os.environ.get('HYPERDBDEBUG', '') -if DEBUG and __debug__: - if DEBUG == 'stdout': - DEBUG = sys.stdout - else: - DEBUG = open(DEBUG, 'a') -else: - DEBUG = Sink() -TRACE = os.environ.get('HYPERDBTRACE', '') -if TRACE and __debug__: - if TRACE == 'stdout': - TRACE = sys.stdout - else: - TRACE = open(TRACE, 'w') -else: - TRACE = Sink() -def traceMark(): - print >>TRACE, '**MARK', time.ctime() -del Sink - -# -# Types -# -class String: - """An object designating a String property.""" - def __init__(self, indexme='no'): - self.indexme = indexme == 'yes' - def __repr__(self): - ' more useful for dumps ' - return '<%s>'%self.__class__ - -class Password: - """An object designating a Password property.""" - def __repr__(self): - ' more useful for dumps ' - return '<%s>'%self.__class__ - -class Date: - """An object designating a Date property.""" - def __repr__(self): - ' more useful for dumps ' - return '<%s>'%self.__class__ - -class Interval: - """An object designating an Interval property.""" - def __repr__(self): - ' more useful for dumps ' - return '<%s>'%self.__class__ - -class Link: - """An object designating a Link property that links to a - node in a specified class.""" - def __init__(self, classname, do_journal='yes'): - ''' Default is to not journal link and unlink events - ''' - self.classname = classname - self.do_journal = do_journal == 'yes' - def __repr__(self): - ' more useful for dumps ' - return '<%s to "%s">'%(self.__class__, self.classname) - -class Multilink: - """An object designating a Multilink property that links - to nodes in a specified class. - - "classname" indicates the class to link to - - "do_journal" indicates whether the linked-to nodes should have - 'link' and 'unlink' events placed in their journal - """ - def __init__(self, classname, do_journal='yes'): - ''' Default is to not journal link and unlink events - ''' - self.classname = classname - self.do_journal = do_journal == 'yes' - def __repr__(self): - ' more useful for dumps ' - return '<%s to "%s">'%(self.__class__, self.classname) - -class Boolean: - """An object designating a boolean property""" - def __repr__(self): - 'more useful for dumps' - return '<%s>' % self.__class__ - -class Number: - """An object designating a numeric property""" - def __repr__(self): - 'more useful for dumps' - return '<%s>' % self.__class__ -# -# Support for splitting designators -# -class DesignatorError(ValueError): - pass -def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')): - ''' Take a foo123 and return ('foo', 123) - ''' - m = dre.match(designator) - if m is None: - raise DesignatorError, '"%s" not a node designator'%designator - return m.group(1), m.group(2) - -# -# the base Database class -# -class DatabaseError(ValueError): - '''Error to be raised when there is some problem in the database code - ''' - pass -class Database: - '''A database for storing records containing flexible data types. - -This class defines a hyperdatabase storage layer, which the Classes use to -store their data. - - -Transactions ------------- -The Database should support transactions through the commit() and -rollback() methods. All other Database methods should be transaction-aware, -using data from the current transaction before looking up the database. - -An implementation must provide an override for the get() method so that the -in-database value is returned in preference to the in-transaction value. -This is necessary to determine if any values have changed during a -transaction. - - -Implementation --------------- - -All methods except __repr__ and getnode must be implemented by a -concrete backend Class. - -''' - - # flag to set on retired entries - RETIRED_FLAG = '__hyperdb_retired' - - def __init__(self, config, journaltag=None): - """Open a hyperdatabase given a specifier to some storage. - - The 'storagelocator' is obtained from config.DATABASE. - The meaning of 'storagelocator' depends on the particular - implementation of the hyperdatabase. It could be a file name, - a directory path, a socket descriptor for a connection to a - database over the network, etc. - - The 'journaltag' is a token that will be attached to the journal - entries for any edits done on the database. If 'journaltag' is - None, the database is opened in read-only mode: the Class.create(), - Class.set(), and Class.retire() methods are disabled. - """ - raise NotImplementedError - - def post_init(self): - """Called once the schema initialisation has finished.""" - raise NotImplementedError - - def __getattr__(self, classname): - """A convenient way of calling self.getclass(classname).""" - raise NotImplementedError - - def addclass(self, cl): - '''Add a Class to the hyperdatabase. - ''' - raise NotImplementedError - - def getclasses(self): - """Return a list of the names of all existing classes.""" - raise NotImplementedError - - def getclass(self, classname): - """Get the Class object representing a particular class. - - If 'classname' is not a valid class name, a KeyError is raised. - """ - raise NotImplementedError - - def clear(self): - '''Delete all database contents. - ''' - raise NotImplementedError - - def getclassdb(self, classname, mode='r'): - '''Obtain a connection to the class db that will be used for - multiple actions. - ''' - raise NotImplementedError - - def addnode(self, classname, nodeid, node): - '''Add the specified node to its class's db. - ''' - raise NotImplementedError - - def serialise(self, classname, node): - '''Copy the node contents, converting non-marshallable data into - marshallable data. - ''' - return node - - def setnode(self, classname, nodeid, node): - '''Change the specified node. - ''' - raise NotImplementedError - - def unserialise(self, classname, node): - '''Decode the marshalled node data - ''' - return node - - def getnode(self, classname, nodeid, db=None, cache=1): - '''Get a node from the database. - ''' - raise NotImplementedError - - def hasnode(self, classname, nodeid, db=None): - '''Determine if the database has a given node. - ''' - raise NotImplementedError - - def countnodes(self, classname, db=None): - '''Count the number of nodes that exist for a particular Class. - ''' - raise NotImplementedError - - def getnodeids(self, classname, db=None): - '''Retrieve all the ids of the nodes for a particular Class. - ''' - raise NotImplementedError - - def storefile(self, classname, nodeid, property, content): - '''Store the content of the file in the database. - - The property may be None, in which case the filename does not - indicate which property is being saved. - ''' - raise NotImplementedError - - def getfile(self, classname, nodeid, property): - '''Store the content of the file in the database. - ''' - raise NotImplementedError - - def addjournal(self, classname, nodeid, action, params): - ''' Journal the Action - 'action' may be: - - 'create' or 'set' -- 'params' is a dictionary of property values - 'link' or 'unlink' -- 'params' is (classname, nodeid, propname) - 'retire' -- 'params' is None - ''' - raise NotImplementedError - - def getjournal(self, classname, nodeid): - ''' get the journal for id - ''' - raise NotImplementedError - - def pack(self, pack_before): - ''' pack the database - ''' - raise NotImplementedError - - def commit(self): - ''' Commit the current transactions. - - Save all data changed since the database was opened or since the - last commit() or rollback(). - ''' - raise NotImplementedError - - def rollback(self): - ''' Reverse all actions from the current transaction. - - Undo all the changes made since the database was opened or the last - commit() or rollback() was performed. - ''' - raise NotImplementedError - -# -# The base Class class -# -class Class: - """ The handle to a particular class of nodes in a hyperdatabase. - - All methods except __repr__ and getnode must be implemented by a - concrete backend Class. - """ - - def __init__(self, db, classname, **properties): - """Create a new class with a given name and property specification. - - 'classname' must not collide with the name of an existing class, - or a ValueError is raised. The keyword arguments in 'properties' - must map names to property objects, or a TypeError is raised. - """ - raise NotImplementedError - - def __repr__(self): - '''Slightly more useful representation - ''' - return '<hyperdb.Class "%s">'%self.classname - - # Editing nodes: - - def create(self, **propvalues): - """Create a new node of this class and return its id. - - The keyword arguments in 'propvalues' map property names to values. - - The values of arguments must be acceptable for the types of their - corresponding properties or a TypeError is raised. - - If this class has a key property, it must be present and its value - must not collide with other key strings or a ValueError is raised. - - Any other properties on this class that are missing from the - 'propvalues' dictionary are set to None. - - If an id in a link or multilink property does not refer to a valid - node, an IndexError is raised. - """ - raise NotImplementedError - - _marker = [] - def get(self, nodeid, propname, default=_marker, cache=1): - """Get the value of a property on an existing node of this class. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. 'propname' must be the name of a property - of this class or a KeyError is raised. - - 'cache' indicates whether the transaction cache should be queried - for the node. If the node has been modified and you need to - determine what its values prior to modification are, you need to - set cache=0. - """ - raise NotImplementedError - - def getnode(self, nodeid, cache=1): - ''' Return a convenience wrapper for the node. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. - - 'cache' indicates whether the transaction cache should be queried - for the node. If the node has been modified and you need to - determine what its values prior to modification are, you need to - set cache=0. - ''' - return Node(self, nodeid, cache=cache) - - def set(self, nodeid, **propvalues): - """Modify a property on an existing node of this class. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. - - Each key in 'propvalues' must be the name of a property of this - class or a KeyError is raised. - - All values in 'propvalues' must be acceptable types for their - corresponding properties or a TypeError is raised. - - If the value of the key property is set, it must not collide with - other key strings or a ValueError is raised. - - If the value of a Link or Multilink property contains an invalid - node id, a ValueError is raised. - """ - raise NotImplementedError - - def retire(self, nodeid): - """Retire a node. - - The properties on the node remain available from the get() method, - and the node's id is never reused. - - Retired nodes are not returned by the find(), list(), or lookup() - methods, and other nodes may reuse the values of their key properties. - """ - raise NotImplementedError - - def is_retired(self, nodeid): - '''Return true if the node is rerired - ''' - raise NotImplementedError - - def destroy(self, nodeid): - """Destroy a node. - - WARNING: this method should never be used except in extremely rare - situations where there could never be links to the node being - deleted - WARNING: use retire() instead - WARNING: the properties of this node will not be available ever again - WARNING: really, use retire() instead - - Well, I think that's enough warnings. This method exists mostly to - support the session storage of the cgi interface. - - The node is completely removed from the hyperdb, including all journal - entries. It will no longer be available, and will generally break code - if there are any references to the node. - """ - - def history(self, nodeid): - """Retrieve the journal of edits on a particular node. - - 'nodeid' must be the id of an existing node of this class or an - IndexError is raised. - - The returned list contains tuples of the form - - (date, tag, action, params) - - 'date' is a Timestamp object specifying the time of the change and - 'tag' is the journaltag specified when the database was opened. - """ - raise NotImplementedError - - # Locating nodes: - def hasnode(self, nodeid): - '''Determine if the given nodeid actually exists - ''' - raise NotImplementedError - - def setkey(self, propname): - """Select a String property of this class to be the key property. - - 'propname' must be the name of a String property of this class or - None, or a TypeError is raised. The values of the key property on - all existing nodes must be unique or a ValueError is raised. - """ - raise NotImplementedError - - def getkey(self): - """Return the name of the key property for this class or None.""" - raise NotImplementedError - - def labelprop(self, default_to_id=0): - ''' Return the property name for a label for the given node. - - This method attempts to generate a consistent label for the node. - It tries the following in order: - 1. key property - 2. "name" property - 3. "title" property - 4. first property from the sorted property name list - ''' - raise NotImplementedError - - def lookup(self, keyvalue): - """Locate a particular node by its key property and return its id. - - If this class has no key property, a TypeError is raised. If the - 'keyvalue' matches one of the values for the key property among - the nodes in this class, the matching node's id is returned; - otherwise a KeyError is raised. - """ - raise NotImplementedError - - def find(self, **propspec): - """Get the ids of nodes in this class which link to the given nodes. - - 'propspec' consists of keyword args propname={nodeid:1,} - 'propname' must be the name of a property in this class, or a - KeyError is raised. That property must be a Link or Multilink - property, or a TypeError is raised. - - Any node in this class whose 'propname' property links to any of the - nodeids will be returned. Used by the full text indexing, which knows - that "foo" occurs in msg1, msg3 and file7, so we have hits on these - issues: - - db.issue.find(messages={'1':1,'3':1}, files={'7':1}) - """ - raise NotImplementedError - - def filter(self, search_matches, filterspec, sort, group, - num_re = re.compile('^\d+$')): - ''' Return a list of the ids of the active nodes in this class that - match the 'filter' spec, sorted by the group spec and then the - sort spec - ''' - raise NotImplementedError - - def count(self): - """Get the number of nodes in this class. - - If the returned integer is 'numnodes', the ids of all the nodes - in this class run from 1 to numnodes, and numnodes+1 will be the - id of the next node to be created in this class. - """ - raise NotImplementedError - - # Manipulating properties: - def getprops(self, protected=1): - """Return a dictionary mapping property names to property objects. - If the "protected" flag is true, we include protected properties - - those which may not be modified. - """ - raise NotImplementedError - - def addprop(self, **properties): - """Add properties to this class. - - The keyword arguments in 'properties' must map names to property - objects, or a TypeError is raised. None of the keys in 'properties' - may collide with the names of existing properties, or a ValueError - is raised before any properties have been added. - """ - raise NotImplementedError - - def index(self, nodeid): - '''Add (or refresh) the node to search indexes - ''' - raise NotImplementedError - -class Node: - ''' A convenience wrapper for the given node - ''' - def __init__(self, cl, nodeid, cache=1): - self.__dict__['cl'] = cl - self.__dict__['nodeid'] = nodeid - self.__dict__['cache'] = cache - def keys(self, protected=1): - return self.cl.getprops(protected=protected).keys() - def values(self, protected=1): - l = [] - for name in self.cl.getprops(protected=protected).keys(): - l.append(self.cl.get(self.nodeid, name, cache=self.cache)) - return l - def items(self, protected=1): - l = [] - for name in self.cl.getprops(protected=protected).keys(): - l.append((name, self.cl.get(self.nodeid, name, cache=self.cache))) - return l - def has_key(self, name): - return self.cl.getprops().has_key(name) - def __getattr__(self, name): - if self.__dict__.has_key(name): - return self.__dict__[name] - try: - return self.cl.get(self.nodeid, name, cache=self.cache) - except KeyError, value: - # we trap this but re-raise it as AttributeError - all other - # exceptions should pass through untrapped - pass - # nope, no such attribute - raise AttributeError, str(value) - def __getitem__(self, name): - return self.cl.get(self.nodeid, name, cache=self.cache) - def __setattr__(self, name, value): - try: - return self.cl.set(self.nodeid, **{name: value}) - except KeyError, value: - raise AttributeError, str(value) - def __setitem__(self, name, value): - self.cl.set(self.nodeid, **{name: value}) - def history(self): - return self.cl.history(self.nodeid) - def retire(self): - return self.cl.retire(self.nodeid) - - -def Choice(name, db, *options): - '''Quick helper to create a simple class with choices - ''' - cl = Class(db, name, name=String(), order=String()) - for i in range(len(options)): - cl.create(name=options[i], order=i) - return hyperdb.Link(name) - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/mailgw.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,944 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# - -__doc__ = ''' -An e-mail gateway for Roundup. - -Incoming messages are examined for multiple parts: - . In a multipart/mixed message or part, each subpart is extracted and - examined. The text/plain subparts are assembled to form the textual - body of the message, to be stored in the file associated with a "msg" - class node. Any parts of other types are each stored in separate files - and given "file" class nodes that are linked to the "msg" node. - . In a multipart/alternative message or part, we look for a text/plain - subpart and ignore the other parts. - -Summary -------- -The "summary" property on message nodes is taken from the first non-quoting -section in the message body. The message body is divided into sections by -blank lines. Sections where the second and all subsequent lines begin with -a ">" or "|" character are considered "quoting sections". The first line of -the first non-quoting section becomes the summary of the message. - -Addresses ---------- -All of the addresses in the To: and Cc: headers of the incoming message are -looked up among the user nodes, and the corresponding users are placed in -the "recipients" property on the new "msg" node. The address in the From: -header similarly determines the "author" property of the new "msg" -node. The default handling for addresses that don't have corresponding -users is to create new users with no passwords and a username equal to the -address. (The web interface does not permit logins for users with no -passwords.) If we prefer to reject mail from outside sources, we can simply -register an auditor on the "user" class that prevents the creation of user -nodes with no passwords. - -Actions -------- -The subject line of the incoming message is examined to determine whether -the message is an attempt to create a new item or to discuss an existing -item. A designator enclosed in square brackets is sought as the first thing -on the subject line (after skipping any "Fwd:" or "Re:" prefixes). - -If an item designator (class name and id number) is found there, the newly -created "msg" node is added to the "messages" property for that item, and -any new "file" nodes are added to the "files" property for the item. - -If just an item class name is found there, we attempt to create a new item -of that class with its "messages" property initialized to contain the new -"msg" node and its "files" property initialized to contain any new "file" -nodes. - -Triggers --------- -Both cases may trigger detectors (in the first case we are calling the -set() method to add the message to the item's spool; in the second case we -are calling the create() method to create a new node). If an auditor raises -an exception, the original message is bounced back to the sender with the -explanatory message given in the exception. - -$Id: mailgw.py,v 1.92 2002-09-26 03:03:18 richard Exp $ -''' - -import string, re, os, mimetools, cStringIO, smtplib, socket, binascii, quopri -import time, random, sys -import traceback, MimeWriter -import hyperdb, date, password - -SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '') - -class MailGWError(ValueError): - pass - -class MailUsageError(ValueError): - pass - -class MailUsageHelp(Exception): - pass - -class Unauthorized(Exception): - """ Access denied """ - -def initialiseSecurity(security): - ''' Create some Permissions and Roles on the security object - - This function is directly invoked by security.Security.__init__() - as a part of the Security object instantiation. - ''' - security.addPermission(name="Email Registration", - description="Anonymous may register through e-mail") - p = security.addPermission(name="Email Access", - description="User may use the email interface") - security.addPermissionToRole('Admin', p) - -class Message(mimetools.Message): - ''' subclass mimetools.Message so we can retrieve the parts of the - message... - ''' - def getPart(self): - ''' Get a single part of a multipart message and return it as a new - Message instance. - ''' - boundary = self.getparam('boundary') - mid, end = '--'+boundary, '--'+boundary+'--' - s = cStringIO.StringIO() - while 1: - line = self.fp.readline() - if not line: - break - if line.strip() in (mid, end): - break - s.write(line) - if not s.getvalue().strip(): - return None - s.seek(0) - return Message(s) - -subject_re = re.compile(r'(?P<refwd>\s*\W?\s*(fwd|re|aw)\s*\W?\s*)*' - r'\s*(?P<quote>")?(\[(?P<classname>[^\d\s]+)(?P<nodeid>\d+)?\])?' - r'\s*(?P<title>[^[]+)?"?(\[(?P<args>.+?)\])?', re.I) - -class MailGW: - def __init__(self, instance, db): - self.instance = instance - self.db = db - - # should we trap exceptions (normal usage) or pass them through - # (for testing) - self.trapExceptions = 1 - - def do_pipe(self): - ''' Read a message from standard input and pass it to the mail handler. - ''' - self.main(sys.stdin) - return 0 - - def do_mailbox(self, filename): - ''' Read a series of messages from the specified unix mailbox file and - pass each to the mail handler. - ''' - # open the spool file and lock it - import fcntl, FCNTL - f = open(filename, 'r+') - fcntl.flock(f.fileno(), FCNTL.LOCK_EX) - - # handle and clear the mailbox - try: - from mailbox import UnixMailbox - mailbox = UnixMailbox(f, factory=Message) - # grab one message - message = mailbox.next() - while message: - # handle this message - self.handle_Message(message) - message = mailbox.next() - # nuke the file contents - os.ftruncate(f.fileno(), 0) - except: - import traceback - traceback.print_exc() - return 1 - fcntl.flock(f.fileno(), FCNTL.LOCK_UN) - return 0 - - def do_pop(self, server, user='', password=''): - '''Read a series of messages from the specified POP server. - ''' - import getpass, poplib, socket - try: - if not user: - user = raw_input(_('User: ')) - if not password: - password = getpass.getpass() - except (KeyboardInterrupt, EOFError): - # Ctrl C or D maybe also Ctrl Z under Windows. - print "\nAborted by user." - return 1 - - # open a connection to the server and retrieve all messages - try: - server = poplib.POP3(server) - except socket.error, message: - print "POP server error:", message - return 1 - server.user(user) - server.pass_(password) - numMessages = len(server.list()[1]) - for i in range(1, numMessages+1): - # retr: returns - # [ pop response e.g. '+OK 459 octets', - # [ array of message lines ], - # number of octets ] - lines = server.retr(i)[1] - s = cStringIO.StringIO('\n'.join(lines)) - s.seek(0) - self.handle_Message(Message(s)) - # delete the message - server.dele(i) - - # quit the server to commit changes. - server.quit() - return 0 - - def main(self, fp): - ''' fp - the file from which to read the Message. - ''' - return self.handle_Message(Message(fp)) - - def handle_Message(self, message): - '''Handle an RFC822 Message - - Handle the Message object by calling handle_message() and then cope - with any errors raised by handle_message. - This method's job is to make that call and handle any - errors in a sane manner. It should be replaced if you wish to - handle errors in a different manner. - ''' - # in some rare cases, a particularly stuffed-up e-mail will make - # its way into here... try to handle it gracefully - sendto = message.getaddrlist('from') - if sendto: - if not self.trapExceptions: - return self.handle_message(message) - try: - return self.handle_message(message) - except MailUsageHelp: - # bounce the message back to the sender with the usage message - fulldoc = '\n'.join(string.split(__doc__, '\n')[2:]) - sendto = [sendto[0][1]] - m = [''] - m.append('\n\nMail Gateway Help\n=================') - m.append(fulldoc) - m = self.bounce_message(message, sendto, m, - subject="Mail Gateway Help") - except MailUsageError, value: - # bounce the message back to the sender with the usage message - fulldoc = '\n'.join(string.split(__doc__, '\n')[2:]) - sendto = [sendto[0][1]] - m = [''] - m.append(str(value)) - m.append('\n\nMail Gateway Help\n=================') - m.append(fulldoc) - m = self.bounce_message(message, sendto, m) - except Unauthorized, value: - # just inform the user that he is not authorized - sendto = [sendto[0][1]] - m = [''] - m.append(str(value)) - m = self.bounce_message(message, sendto, m) - except: - # bounce the message back to the sender with the error message - sendto = [sendto[0][1], self.instance.config.ADMIN_EMAIL] - m = [''] - m.append('An unexpected error occurred during the processing') - m.append('of your message. The tracker administrator is being') - m.append('notified.\n') - m.append('---- traceback of failure ----') - s = cStringIO.StringIO() - import traceback - traceback.print_exc(None, s) - m.append(s.getvalue()) - m = self.bounce_message(message, sendto, m) - else: - # very bad-looking message - we don't even know who sent it - sendto = [self.instance.config.ADMIN_EMAIL] - m = ['Subject: badly formed message from mail gateway'] - m.append('') - m.append('The mail gateway retrieved a message which has no From:') - m.append('line, indicating that it is corrupt. Please check your') - m.append('mail gateway source. Failed message is attached.') - m.append('') - m = self.bounce_message(message, sendto, m, - subject='Badly formed message from mail gateway') - - # now send the message - if SENDMAILDEBUG: - open(SENDMAILDEBUG, 'w').write('From: %s\nTo: %s\n%s\n'%( - self.instance.config.ADMIN_EMAIL, ', '.join(sendto), - m.getvalue())) - else: - try: - smtp = smtplib.SMTP(self.instance.config.MAILHOST) - smtp.sendmail(self.instance.config.ADMIN_EMAIL, sendto, - m.getvalue()) - except socket.error, value: - raise MailGWError, "Couldn't send error email: "\ - "mailhost %s"%value - except smtplib.SMTPException, value: - raise MailGWError, "Couldn't send error email: %s"%value - - def bounce_message(self, message, sendto, error, - subject='Failed issue tracker submission'): - ''' create a message that explains the reason for the failed - issue submission to the author and attach the original - message. - ''' - msg = cStringIO.StringIO() - writer = MimeWriter.MimeWriter(msg) - writer.addheader('Subject', subject) - writer.addheader('From', '%s <%s>'% (self.instance.config.TRACKER_NAME, - self.instance.config.TRACKER_EMAIL)) - writer.addheader('To', ','.join(sendto)) - writer.addheader('MIME-Version', '1.0') - part = writer.startmultipartbody('mixed') - part = writer.nextpart() - body = part.startbody('text/plain') - body.write('\n'.join(error)) - - # reconstruct the original message - m = cStringIO.StringIO() - w = MimeWriter.MimeWriter(m) - # default the content_type, just in case... - content_type = 'text/plain' - # add the headers except the content-type - for header in message.headers: - header_name = header.split(':')[0] - if header_name.lower() == 'content-type': - content_type = message.getheader(header_name) - elif message.getheader(header_name): - w.addheader(header_name, message.getheader(header_name)) - # now attach the message body - body = w.startbody(content_type) - try: - message.rewindbody() - except IOError: - body.write("*** couldn't include message body: read from pipe ***") - else: - body.write(message.fp.read()) - - # attach the original message to the returned message - part = writer.nextpart() - part.addheader('Content-Disposition','attachment') - part.addheader('Content-Description','Message you sent') - part.addheader('Content-Transfer-Encoding', '7bit') - body = part.startbody('message/rfc822') - body.write(m.getvalue()) - - writer.lastpart() - return msg - - def get_part_data_decoded(self,part): - encoding = part.getencoding() - data = None - if encoding == 'base64': - # BUG: is base64 really used for text encoding or - # are we inserting zip files here. - data = binascii.a2b_base64(part.fp.read()) - elif encoding == 'quoted-printable': - # the quopri module wants to work with files - decoded = cStringIO.StringIO() - quopri.decode(part.fp, decoded) - data = decoded.getvalue() - elif encoding == 'uuencoded': - data = binascii.a2b_uu(part.fp.read()) - else: - # take it as text - data = part.fp.read() - return data - - def handle_message(self, message): - ''' message - a Message instance - - Parse the message as per the module docstring. - ''' - # handle the subject line - subject = message.getheader('subject', '') - - if subject.strip() == 'help': - raise MailUsageHelp - - m = subject_re.match(subject) - - # check for well-formed subject line - if m: - # get the classname - classname = m.group('classname') - if classname is None: - # no classname, fallback on the default - if hasattr(self.instance.config, 'MAIL_DEFAULT_CLASS') and \ - self.instance.config.MAIL_DEFAULT_CLASS: - classname = self.instance.config.MAIL_DEFAULT_CLASS - else: - # fail - m = None - - if not m: - raise MailUsageError, ''' -The message you sent to roundup did not contain a properly formed subject -line. The subject must contain a class name or designator to indicate the -"topic" of the message. For example: - Subject: [issue] This is a new issue - - this will create a new issue in the tracker with the title "This is - a new issue". - Subject: [issue1234] This is a followup to issue 1234 - - this will append the message's contents to the existing issue 1234 - in the tracker. - -Subject was: "%s" -'''%subject - - # get the class - try: - cl = self.db.getclass(classname) - except KeyError: - raise MailUsageError, ''' -The class name you identified in the subject line ("%s") does not exist in the -database. - -Valid class names are: %s -Subject was: "%s" -'''%(classname, ', '.join(self.db.getclasses()), subject) - - # get the optional nodeid - nodeid = m.group('nodeid') - - # title is optional too - title = m.group('title') - if title: - title = title.strip() - else: - title = '' - - # strip off the quotes that dumb emailers put around the subject, like - # Re: "[issue1] bla blah" - if m.group('quote') and title.endswith('"'): - title = title[:-1] - - # but we do need either a title or a nodeid... - if nodeid is None and not title: - raise MailUsageError, ''' -I cannot match your message to a node in the database - you need to either -supply a full node identifier (with number, eg "[issue123]" or keep the -previous subject title intact so I can match that. - -Subject was: "%s" -'''%subject - - # If there's no nodeid, check to see if this is a followup and - # maybe someone's responded to the initial mail that created an - # entry. Try to find the matching nodes with the same title, and - # use the _last_ one matched (since that'll _usually_ be the most - # recent...) - if nodeid is None and m.group('refwd'): - l = cl.stringFind(title=title) - if l: - nodeid = l[-1] - - # if a nodeid was specified, make sure it's valid - if nodeid is not None and not cl.hasnode(nodeid): - raise MailUsageError, ''' -The node specified by the designator in the subject of your message ("%s") -does not exist. - -Subject was: "%s" -'''%(nodeid, subject) - - # - # handle the users - # - # Don't create users if anonymous isn't allowed to register - create = 1 - anonid = self.db.user.lookup('anonymous') - if not self.db.security.hasPermission('Email Registration', anonid): - create = 0 - - # ok, now figure out who the author is - create a new user if the - # "create" flag is true - author = uidFromAddress(self.db, message.getaddrlist('from')[0], - create=create) - - # no author? means we're not author - if not author: - raise Unauthorized, ''' -You are not a registered user. - -Unknown address: %s -'''%message.getaddrlist('from')[0][1] - - # make sure the author has permission to use the email interface - if not self.db.security.hasPermission('Email Access', author): - raise Unauthorized, 'You are not permitted to access this tracker.' - - # make sure they're allowed to edit this class of information - if not self.db.security.hasPermission('Edit', author, classname): - raise Unauthorized, 'You are not permitted to edit %s.'%classname - - # the author may have been created - make sure the change is - # committed before we reopen the database - self.db.commit() - - # reopen the database as the author - username = self.db.user.get(author, 'username') - self.db.close() - self.db = self.instance.open(username) - - # re-get the class with the new database connection - cl = self.db.getclass(classname) - - # now update the recipients list - recipients = [] - tracker_email = self.instance.config.TRACKER_EMAIL.lower() - for recipient in message.getaddrlist('to') + message.getaddrlist('cc'): - r = recipient[1].strip().lower() - if r == tracker_email or not r: - continue - - # look up the recipient - create if necessary (and we're - # allowed to) - recipient = uidFromAddress(self.db, recipient, create) - - # if all's well, add the recipient to the list - if recipient: - recipients.append(recipient) - - # - # extract the args - # - subject_args = m.group('args') - - # - # handle the subject argument list - # - # figure what the properties of this Class are - properties = cl.getprops() - props = {} - args = m.group('args') - if args: - errors = [] - for prop in string.split(args, ';'): - # extract the property name and value - try: - propname, value = prop.split('=') - except ValueError, message: - errors.append('not of form [arg=value,' - 'value,...;arg=value,value...]') - break - - # ensure it's a valid property name - propname = propname.strip() - try: - proptype = properties[propname] - except KeyError: - errors.append('refers to an invalid property: ' - '"%s"'%propname) - continue - - # convert the string value to a real property value - if isinstance(proptype, hyperdb.String): - props[propname] = value.strip() - if isinstance(proptype, hyperdb.Password): - props[propname] = password.Password(value.strip()) - elif isinstance(proptype, hyperdb.Date): - try: - props[propname] = date.Date(value.strip()) - except ValueError, message: - errors.append('contains an invalid date for ' - '%s.'%propname) - elif isinstance(proptype, hyperdb.Interval): - try: - props[propname] = date.Interval(value) - except ValueError, message: - errors.append('contains an invalid date interval' - 'for %s.'%propname) - elif isinstance(proptype, hyperdb.Link): - linkcl = self.db.classes[proptype.classname] - propkey = linkcl.labelprop(default_to_id=1) - try: - props[propname] = linkcl.lookup(value) - except KeyError, message: - errors.append('"%s" is not a value for %s.'%(value, - propname)) - elif isinstance(proptype, hyperdb.Multilink): - # get the linked class - linkcl = self.db.classes[proptype.classname] - propkey = linkcl.labelprop(default_to_id=1) - if nodeid: - curvalue = cl.get(nodeid, propname) - else: - curvalue = [] - - # handle each add/remove in turn - # keep an extra list for all items that are - # definitely in the new list (in case of e.g. - # <propname>=A,+B, which should replace the old - # list with A,B) - set = 0 - newvalue = [] - for item in value.split(','): - item = item.strip() - - # handle +/- - remove = 0 - if item.startswith('-'): - remove = 1 - item = item[1:] - elif item.startswith('+'): - item = item[1:] - else: - set = 1 - - # look up the value - try: - item = linkcl.lookup(item) - except KeyError, message: - errors.append('"%s" is not a value for %s.'%(item, - propname)) - continue - - # perform the add/remove - if remove: - try: - curvalue.remove(item) - except ValueError: - errors.append('"%s" is not currently in ' - 'for %s.'%(item, propname)) - continue - else: - newvalue.append(item) - if item not in curvalue: - curvalue.append(item) - - # that's it, set the new Multilink property value, - # or overwrite it completely - if set: - props[propname] = newvalue - else: - props[propname] = curvalue - elif isinstance(proptype, hyperdb.Boolean): - value = value.strip() - props[propname] = value.lower() in ('yes', 'true', 'on', '1') - elif isinstance(proptype, hyperdb.Number): - value = value.strip() - props[propname] = int(value) - - # handle any errors parsing the argument list - if errors: - errors = '\n- '.join(errors) - raise MailUsageError, ''' -There were problems handling your subject line argument list: -- %s - -Subject was: "%s" -'''%(errors, subject) - - # - # handle message-id and in-reply-to - # - messageid = message.getheader('message-id') - inreplyto = message.getheader('in-reply-to') or '' - # generate a messageid if there isn't one - if not messageid: - messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(), - classname, nodeid, self.instance.config.MAIL_DOMAIN) - - # - # now handle the body - find the message - # - content_type = message.gettype() - attachments = [] - # General multipart handling: - # Take the first text/plain part, anything else is considered an - # attachment. - # multipart/mixed: multiple "unrelated" parts. - # multipart/signed (rfc 1847): - # The control information is carried in the second of the two - # required body parts. - # ACTION: Default, so if content is text/plain we get it. - # multipart/encrypted (rfc 1847): - # The control information is carried in the first of the two - # required body parts. - # ACTION: Not handleable as the content is encrypted. - # multipart/related (rfc 1872, 2112, 2387): - # The Multipart/Related content-type addresses the MIME - # representation of compound objects. - # ACTION: Default. If we are lucky there is a text/plain. - # TODO: One should use the start part and look for an Alternative - # that is text/plain. - # multipart/Alternative (rfc 1872, 1892): - # only in "related" ? - # multipart/report (rfc 1892): - # e.g. mail system delivery status reports. - # ACTION: Default. Could be ignored or used for Delivery Notification - # flagging. - # multipart/form-data: - # For web forms only. - if content_type == 'multipart/mixed': - # skip over the intro to the first boundary - part = message.getPart() - content = None - while 1: - # get the next part - part = message.getPart() - if part is None: - break - # parse it - subtype = part.gettype() - if subtype == 'text/plain' and not content: - # The first text/plain part is the message content. - content = self.get_part_data_decoded(part) - elif subtype == 'message/rfc822': - # handle message/rfc822 specially - the name should be - # the subject of the actual e-mail embedded here - i = part.fp.tell() - mailmess = Message(part.fp) - name = mailmess.getheader('subject') - part.fp.seek(i) - attachments.append((name, 'message/rfc822', part.fp.read())) - else: - # try name on Content-Type - name = part.getparam('name') - # this is just an attachment - data = self.get_part_data_decoded(part) - attachments.append((name, part.gettype(), data)) - if content is None: - raise MailUsageError, ''' -Roundup requires the submission to be plain text. The message parser could -not find a text/plain part to use. -''' - - elif content_type[:10] == 'multipart/': - # skip over the intro to the first boundary - message.getPart() - content = None - while 1: - # get the next part - part = message.getPart() - if part is None: - break - # parse it - if part.gettype() == 'text/plain' and not content: - content = self.get_part_data_decoded(part) - if content is None: - raise MailUsageError, ''' -Roundup requires the submission to be plain text. The message parser could -not find a text/plain part to use. -''' - - elif content_type != 'text/plain': - raise MailUsageError, ''' -Roundup requires the submission to be plain text. The message parser could -not find a text/plain part to use. -''' - - else: - content = self.get_part_data_decoded(message) - - # figure how much we should muck around with the email body - keep_citations = getattr(self.instance, 'EMAIL_KEEP_QUOTED_TEXT', - 'no') == 'yes' - keep_body = getattr(self.instance, 'EMAIL_LEAVE_BODY_UNCHANGED', - 'no') == 'yes' - - # parse the body of the message, stripping out bits as appropriate - summary, content = parseContent(content, keep_citations, - keep_body) - - # - # handle the attachments - # - files = [] - for (name, mime_type, data) in attachments: - if not name: - name = "unnamed" - files.append(self.db.file.create(type=mime_type, name=name, - content=data)) - - # - # create the message if there's a message body (content) - # - if content: - message_id = self.db.msg.create(author=author, - recipients=recipients, date=date.Date('.'), summary=summary, - content=content, files=files, messageid=messageid, - inreplyto=inreplyto) - - # attach the message to the node - if nodeid: - # add the message to the node's list - messages = cl.get(nodeid, 'messages') - messages.append(message_id) - props['messages'] = messages - else: - # pre-load the messages list - props['messages'] = [message_id] - - # set the title to the subject - if properties.has_key('title') and not props.has_key('title'): - props['title'] = title - - # - # perform the node change / create - # - try: - if nodeid: - cl.set(nodeid, **props) - else: - nodeid = cl.create(**props) - except (TypeError, IndexError, ValueError), message: - raise MailUsageError, ''' -There was a problem with the message you sent: - %s -'''%message - - # commit the changes to the DB - self.db.commit() - - return nodeid - -def extractUserFromList(userClass, users): - '''Given a list of users, try to extract the first non-anonymous user - and return that user, otherwise return None - ''' - if len(users) > 1: - for user in users: - # make sure we don't match the anonymous or admin user - if userClass.get(user, 'username') in ('admin', 'anonymous'): - continue - # first valid match will do - return user - # well, I guess we have no choice - return user[0] - elif users: - return users[0] - return None - -def uidFromAddress(db, address, create=1): - ''' address is from the rfc822 module, and therefore is (name, addr) - - user is created if they don't exist in the db already - ''' - (realname, address) = address - - # try a straight match of the address - user = extractUserFromList(db.user, db.user.stringFind(address=address)) - if user is not None: return user - - # try the user alternate addresses if possible - props = db.user.getprops() - if props.has_key('alternate_addresses'): - users = db.user.filter(None, {'alternate_addresses': address}, - [], []) - user = extractUserFromList(db.user, users) - if user is not None: return user - - # try to match the username to the address (for local - # submissions where the address is empty) - user = extractUserFromList(db.user, db.user.stringFind(username=address)) - - # couldn't match address or username, so create a new user - if create: - return db.user.create(username=address, address=address, - realname=realname, roles=db.config.NEW_EMAIL_USER_ROLES) - else: - return 0 - -def parseContent(content, keep_citations, keep_body, - blank_line=re.compile(r'[\r\n]+\s*[\r\n]+'), - eol=re.compile(r'[\r\n]+'), - signature=re.compile(r'^[>|\s]*[-_]+\s*$'), - original_message=re.compile(r'^[>|\s]*-----Original Message-----$')): - ''' The message body is divided into sections by blank lines. - Sections where the second and all subsequent lines begin with a ">" - or "|" character are considered "quoting sections". The first line of - the first non-quoting section becomes the summary of the message. - - If keep_citations is true, then we keep the "quoting sections" in the - content. - If keep_body is true, we even keep the signature sections. - ''' - # strip off leading carriage-returns / newlines - i = 0 - for i in range(len(content)): - if content[i] not in '\r\n': - break - if i > 0: - sections = blank_line.split(content[i:]) - else: - sections = blank_line.split(content) - - # extract out the summary from the message - summary = '' - l = [] - for section in sections: - #section = section.strip() - if not section: - continue - lines = eol.split(section) - if (lines[0] and lines[0][0] in '>|') or (len(lines) > 1 and - lines[1] and lines[1][0] in '>|'): - # see if there's a response somewhere inside this section (ie. - # no blank line between quoted message and response) - for line in lines[1:]: - if line and line[0] not in '>|': - break - else: - # we keep quoted bits if specified in the config - if keep_citations: - l.append(section) - continue - # keep this section - it has reponse stuff in it - if not summary: - # and while we're at it, use the first non-quoted bit as - # our summary - summary = line - lines = lines[lines.index(line):] - section = '\n'.join(lines) - - if not summary: - # if we don't have our summary yet use the first line of this - # section - summary = lines[0] - elif signature.match(lines[0]) and 2 <= len(lines) <= 10: - # lose any signature - break - elif original_message.match(lines[0]): - # ditch the stupid Outlook quoting of the entire original message - break - - # and add the section to the output - l.append(section) - - # Now reconstitute the message content minus the bits we don't care - # about. - if not keep_body: - content = '\n\n'.join(l) - - return summary, content - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/password.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,143 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: password.py,v 1.7 2002-09-26 13:38:35 gmcm Exp $ - -__doc__ = """ -Password handling (encoding, decoding). -""" - -import sha, re, string -try: - import crypt -except: - crypt = None - pass - -def encodePassword(plaintext, scheme, other=None): - '''Encrypt the plaintext password. - ''' - if scheme == 'SHA': - s = sha.sha(plaintext).hexdigest() - elif scheme == 'crypt' and crypt is not None: - if other is not None: - salt = other[:2] - else: - saltchars = './0123456789'+string.letters - salt = random.choice(saltchars) + random.choice(saltchars) - s = crypt.crypt(plaintext, salt) - elif scheme == 'plaintext': - s = plaintext - else: - raise ValueError, 'Unknown encryption scheme "%s"'%scheme - return s - -class Password: - '''The class encapsulates a Password property type value in the database. - - The encoding of the password is one if None, 'SHA' or 'plaintext'. The - encodePassword function is used to actually encode the password from - plaintext. The None encoding is used in legacy databases where no - encoding scheme is identified. - - The scheme is stored with the encoded data in the database: - {scheme}data - - Example usage: - >>> p = Password('sekrit') - >>> p == 'sekrit' - 1 - >>> p != 'not sekrit' - 1 - >>> 'sekrit' == p - 1 - >>> 'not sekrit' != p - 1 - ''' - - default_scheme = 'SHA' # new encryptions use this scheme - pwre = re.compile(r'{(\w+)}(.+)') - - def __init__(self, plaintext=None, scheme=None): - '''Call setPassword if plaintext is not None.''' - if scheme is None: - scheme = self.default_scheme - if plaintext is not None: - self.password = encodePassword(plaintext, self.default_scheme) - self.scheme = self.default_scheme - else: - self.password = None - self.scheme = self.default_scheme - - def unpack(self, encrypted): - '''Set the password info from the scheme:<encryted info> string - (the inverse of __str__) - ''' - m = self.pwre.match(encrypted) - if m: - self.scheme = m.group(1) - self.password = m.group(2) - else: - # currently plaintext - encrypt - self.password = encodePassword(encrypted, self.default_scheme) - self.scheme = self.default_scheme - - def setPassword(self, plaintext, scheme=None): - '''Sets encrypts plaintext.''' - if scheme is None: - scheme = self.default_scheme - self.password = encodePassword(plaintext, scheme) - - def __cmp__(self, other): - '''Compare this password against another password.''' - # check to see if we're comparing instances - if isinstance(other, Password): - if self.scheme != other.scheme: - return cmp(self.scheme, other.scheme) - return cmp(self.password, other.password) - - # assume password is plaintext - if self.password is None: - raise ValueError, 'Password not set' - return cmp(self.password, encodePassword(other, self.scheme, - self.password)) - - def __str__(self): - '''Stringify the encrypted password for database storage.''' - if self.password is None: - raise ValueError, 'Password not set' - return '{%s}%s'%(self.scheme, self.password) - -def test(): - # SHA - p = Password('sekrit') - assert p == 'sekrit' - assert p != 'not sekrit' - assert 'sekrit' == p - assert 'not sekrit' != p - - # crypt - p = Password('sekrit', 'crypt') - assert p == 'sekrit' - assert p != 'not sekrit' - assert 'sekrit' == p - assert 'not sekrit' != p - -if __name__ == '__main__': - test() - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/roundupdb.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,433 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: roundupdb.py,v 1.69 2002-09-20 01:20:31 richard Exp $ - -__doc__ = """ -Extending hyperdb with types specific to issue-tracking. -""" - -import re, os, smtplib, socket, time, random -import MimeWriter, cStringIO -import base64, quopri, mimetypes -# if available, use the 'email' module, otherwise fallback to 'rfc822' -try : - from email.Utils import dump_address_pair as straddr -except ImportError : - from rfc822 import dump_address_pair as straddr - -import hyperdb - -# set to indicate to roundup not to actually _send_ email -# this var must contain a file to write the mail to -SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '') - -class Database: - def getuid(self): - """Return the id of the "user" node associated with the user - that owns this connection to the hyperdatabase.""" - return self.user.lookup(self.journaltag) - -class MessageSendError(RuntimeError): - pass - -class DetectorError(RuntimeError): - ''' Raised by detectors that want to indicate that something's amiss - ''' - pass - -# deviation from spec - was called IssueClass -class IssueClass: - """ This class is intended to be mixed-in with a hyperdb backend - implementation. The backend should provide a mechanism that - enforces the title, messages, files, nosy and superseder - properties: - properties['title'] = hyperdb.String(indexme='yes') - properties['messages'] = hyperdb.Multilink("msg") - properties['files'] = hyperdb.Multilink("file") - properties['nosy'] = hyperdb.Multilink("user") - properties['superseder'] = hyperdb.Multilink(classname) - """ - - # New methods: - def addmessage(self, nodeid, summary, text): - """Add a message to an issue's mail spool. - - A new "msg" node is constructed using the current date, the user that - owns the database connection as the author, and the specified summary - text. - - The "files" and "recipients" fields are left empty. - - The given text is saved as the body of the message and the node is - appended to the "messages" field of the specified issue. - """ - - def nosymessage(self, nodeid, msgid, oldvalues): - """Send a message to the members of an issue's nosy list. - - The message is sent only to users on the nosy list who are not - already on the "recipients" list for the message. - - These users are then added to the message's "recipients" list. - """ - users = self.db.user - messages = self.db.msg - - # figure the recipient ids - sendto = [] - r = {} - recipients = messages.get(msgid, 'recipients') - for recipid in messages.get(msgid, 'recipients'): - r[recipid] = 1 - - # figure the author's id, and indicate they've received the message - authid = messages.get(msgid, 'author') - - # possibly send the message to the author, as long as they aren't - # anonymous - if (self.db.config.MESSAGES_TO_AUTHOR == 'yes' and - users.get(authid, 'username') != 'anonymous'): - sendto.append(authid) - r[authid] = 1 - - # now figure the nosy people who weren't recipients - nosy = self.get(nodeid, 'nosy') - for nosyid in nosy: - # Don't send nosy mail to the anonymous user (that user - # shouldn't appear in the nosy list, but just in case they - # do...) - if users.get(nosyid, 'username') == 'anonymous': - continue - # make sure they haven't seen the message already - if not r.has_key(nosyid): - # send it to them - sendto.append(nosyid) - recipients.append(nosyid) - - # generate a change note - if oldvalues: - note = self.generateChangeNote(nodeid, oldvalues) - else: - note = self.generateCreateNote(nodeid) - - # we have new recipients - if sendto: - # map userids to addresses - sendto = [users.get(i, 'address') for i in sendto] - - # update the message's recipients list - messages.set(msgid, recipients=recipients) - - # send the message - self.send_message(nodeid, msgid, note, sendto) - - # backwards compatibility - don't remove - sendmessage = nosymessage - - def send_message(self, nodeid, msgid, note, sendto): - '''Actually send the nominated message from this node to the sendto - recipients, with the note appended. - ''' - users = self.db.user - messages = self.db.msg - files = self.db.file - - # determine the messageid and inreplyto of the message - inreplyto = messages.get(msgid, 'inreplyto') - messageid = messages.get(msgid, 'messageid') - - # make up a messageid if there isn't one (web edit) - if not messageid: - # this is an old message that didn't get a messageid, so - # create one - messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(), - self.classname, nodeid, self.db.config.MAIL_DOMAIN) - messages.set(msgid, messageid=messageid) - - # send an email to the people who missed out - cn = self.classname - title = self.get(nodeid, 'title') or '%s message copy'%cn - # figure author information - authid = messages.get(msgid, 'author') - authname = users.get(authid, 'realname') - if not authname: - authname = users.get(authid, 'username') - authaddr = users.get(authid, 'address') - if authaddr: - authaddr = " <%s>" % straddr( ('',authaddr) ) - else: - authaddr = '' - - # make the message body - m = [''] - - # put in roundup's signature - if self.db.config.EMAIL_SIGNATURE_POSITION == 'top': - m.append(self.email_signature(nodeid, msgid)) - - # add author information - if len(self.get(nodeid,'messages')) == 1: - m.append("New submission from %s%s:"%(authname, authaddr)) - else: - m.append("%s%s added the comment:"%(authname, authaddr)) - m.append('') - - # add the content - m.append(messages.get(msgid, 'content')) - - # add the change note - if note: - m.append(note) - - # put in roundup's signature - if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom': - m.append(self.email_signature(nodeid, msgid)) - - # encode the content as quoted-printable - content = cStringIO.StringIO('\n'.join(m)) - content_encoded = cStringIO.StringIO() - quopri.encode(content, content_encoded, 0) - content_encoded = content_encoded.getvalue() - - # get the files for this message - message_files = messages.get(msgid, 'files') - - # make sure the To line is always the same (for testing mostly) - sendto.sort() - - # create the message - message = cStringIO.StringIO() - writer = MimeWriter.MimeWriter(message) - writer.addheader('Subject', '[%s%s] %s'%(cn, nodeid, title)) - writer.addheader('To', ', '.join(sendto)) - writer.addheader('From', straddr( - (authname, self.db.config.TRACKER_EMAIL) ) ) - writer.addheader('Reply-To', straddr( - (self.db.config.TRACKER_NAME, - self.db.config.TRACKER_EMAIL) ) ) - writer.addheader('MIME-Version', '1.0') - if messageid: - writer.addheader('Message-Id', messageid) - if inreplyto: - writer.addheader('In-Reply-To', inreplyto) - - # add a uniquely Roundup header to help filtering - writer.addheader('X-Roundup-Name', self.db.config.TRACKER_NAME) - - # attach files - if message_files: - part = writer.startmultipartbody('mixed') - part = writer.nextpart() - part.addheader('Content-Transfer-Encoding', 'quoted-printable') - body = part.startbody('text/plain') - body.write(content_encoded) - for fileid in message_files: - name = files.get(fileid, 'name') - mime_type = files.get(fileid, 'type') - content = files.get(fileid, 'content') - part = writer.nextpart() - if mime_type == 'text/plain': - part.addheader('Content-Disposition', - 'attachment;\n filename="%s"'%name) - part.addheader('Content-Transfer-Encoding', '7bit') - body = part.startbody('text/plain') - body.write(content) - else: - # some other type, so encode it - if not mime_type: - # this should have been done when the file was saved - mime_type = mimetypes.guess_type(name)[0] - if mime_type is None: - mime_type = 'application/octet-stream' - part.addheader('Content-Disposition', - 'attachment;\n filename="%s"'%name) - part.addheader('Content-Transfer-Encoding', 'base64') - body = part.startbody(mime_type) - body.write(base64.encodestring(content)) - writer.lastpart() - else: - writer.addheader('Content-Transfer-Encoding', 'quoted-printable') - body = writer.startbody('text/plain') - body.write(content_encoded) - - # now try to send the message - if SENDMAILDEBUG: - open(SENDMAILDEBUG, 'w').write('FROM: %s\nTO: %s\n%s\n'%( - self.db.config.ADMIN_EMAIL, - ', '.join(sendto),message.getvalue())) - else: - try: - # send the message as admin so bounces are sent there - # instead of to roundup - smtp = smtplib.SMTP(self.db.config.MAILHOST) - smtp.sendmail(self.db.config.ADMIN_EMAIL, sendto, - message.getvalue()) - except socket.error, value: - raise MessageSendError, \ - "Couldn't send confirmation email: mailhost %s"%value - except smtplib.SMTPException, value: - raise MessageSendError, \ - "Couldn't send confirmation email: %s"%value - - def email_signature(self, nodeid, msgid): - ''' Add a signature to the e-mail with some useful information - ''' - # simplistic check to see if the url is valid, - # then append a trailing slash if it is missing - base = self.db.config.TRACKER_WEB - if not isinstance(base , type('')) or not base.startswith('http://'): - base = "Configuration Error: TRACKER_WEB isn't a " \ - "fully-qualified URL" - elif base[-1] != '/' : - base += '/' - web = base + self.classname + nodeid - - # ensure the email address is properly quoted - email = straddr((self.db.config.TRACKER_NAME, - self.db.config.TRACKER_EMAIL)) - - line = '_' * max(len(web), len(email)) - return '%s\n%s\n%s\n%s'%(line, email, web, line) - - - def generateCreateNote(self, nodeid): - """Generate a create note that lists initial property values - """ - cn = self.classname - cl = self.db.classes[cn] - props = cl.getprops(protected=0) - - # list the values - m = [] - l = props.items() - l.sort() - for propname, prop in l: - value = cl.get(nodeid, propname, None) - # skip boring entries - if not value: - continue - if isinstance(prop, hyperdb.Link): - link = self.db.classes[prop.classname] - if value: - key = link.labelprop(default_to_id=1) - if key: - value = link.get(value, key) - else: - value = '' - elif isinstance(prop, hyperdb.Multilink): - if value is None: value = [] - l = [] - link = self.db.classes[prop.classname] - key = link.labelprop(default_to_id=1) - if key: - value = [link.get(entry, key) for entry in value] - value.sort() - value = ', '.join(value) - m.append('%s: %s'%(propname, value)) - m.insert(0, '----------') - m.insert(0, '') - return '\n'.join(m) - - def generateChangeNote(self, nodeid, oldvalues): - """Generate a change note that lists property changes - """ - if __debug__ : - if not isinstance(oldvalues, type({})) : - raise TypeError("'oldvalues' must be dict-like, not %s."% - type(oldvalues)) - - cn = self.classname - cl = self.db.classes[cn] - changed = {} - props = cl.getprops(protected=0) - - # determine what changed - for key in oldvalues.keys(): - if key in ['files','messages']: - continue - if key in ('activity', 'creator', 'creation'): - continue - new_value = cl.get(nodeid, key) - # the old value might be non existent - try: - old_value = oldvalues[key] - if type(new_value) is type([]): - new_value.sort() - old_value.sort() - if new_value != old_value: - changed[key] = old_value - except: - changed[key] = new_value - - # list the changes - m = [] - l = changed.items() - l.sort() - for propname, oldvalue in l: - prop = props[propname] - value = cl.get(nodeid, propname, None) - if isinstance(prop, hyperdb.Link): - link = self.db.classes[prop.classname] - key = link.labelprop(default_to_id=1) - if key: - if value: - value = link.get(value, key) - else: - value = '' - if oldvalue: - oldvalue = link.get(oldvalue, key) - else: - oldvalue = '' - change = '%s -> %s'%(oldvalue, value) - elif isinstance(prop, hyperdb.Multilink): - change = '' - if value is None: value = [] - if oldvalue is None: oldvalue = [] - l = [] - link = self.db.classes[prop.classname] - key = link.labelprop(default_to_id=1) - # check for additions - for entry in value: - if entry in oldvalue: continue - if key: - l.append(link.get(entry, key)) - else: - l.append(entry) - if l: - l.sort() - change = '+%s'%(', '.join(l)) - l = [] - # check for removals - for entry in oldvalue: - if entry in value: continue - if key: - l.append(link.get(entry, key)) - else: - l.append(entry) - if l: - l.sort() - change += ' -%s'%(', '.join(l)) - else: - change = '%s -> %s'%(oldvalue, value) - m.append('%s: %s'%(propname, change)) - if m: - m.insert(0, '----------') - m.insert(0, '') - return '\n'.join(m) - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/scripts/roundup_server.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,303 +0,0 @@ -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -""" HTTP Server that serves roundup. - -$Id: roundup_server.py,v 1.12 2002-09-23 06:48:35 richard Exp $ -""" - -# python version check -from roundup import version_check - -import sys, os, urllib, StringIO, traceback, cgi, binascii, getopt, imp -import BaseHTTPServer - -# Roundup modules of use here -from roundup.cgi import cgitb, client -import roundup.instance -from roundup.i18n import _ - -# -## Configuration -# - -# This indicates where the Roundup trackers live. They're given as NAME -> -# TRACKER_HOME, where the NAME part is used in the URL to select the -# appropriate reacker. -# Make sure the NAME part doesn't include any url-unsafe characters like -# spaces, as these confuse the cookie handling in browsers like IE. -TRACKER_HOMES = { - 'bar': '/tmp/bar', -} - -ROUNDUP_USER = None - - -# Where to log debugging information to. Use an instance of DevNull if you -# don't want to log anywhere. -# TODO: actually use this stuff -#class DevNull: -# def write(self, info): -# pass -#LOG = open('/var/log/roundup.cgi.log', 'a') -#LOG = DevNull() - -# -## end configuration -# - -class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): - TRACKER_HOMES = TRACKER_HOMES - ROUNDUP_USER = ROUNDUP_USER - - def run_cgi(self): - """ Execute the CGI command. Wrap an innner call in an error - handler so all errors can be caught. - """ - save_stdin = sys.stdin - sys.stdin = self.rfile - try: - self.inner_run_cgi() - except client.NotFound: - self.send_error(404, self.path) - except client.Unauthorised: - self.send_error(403, self.path) - except: - # it'd be nice to be able to detect if these are going to have - # any effect... - self.send_response(400) - self.send_header('Content-Type', 'text/html') - self.end_headers() - try: - reload(cgitb) - self.wfile.write(cgitb.breaker()) - self.wfile.write(cgitb.html()) - except: - self.wfile.write("<pre>") - s = StringIO.StringIO() - traceback.print_exc(None, s) - self.wfile.write(cgi.escape(s.getvalue())) - self.wfile.write("</pre>\n") - sys.stdin = save_stdin - - do_GET = do_POST = do_HEAD = send_head = run_cgi - - def index(self): - ''' Print up an index of the available instances - ''' - self.send_response(200) - self.send_header('Content-Type', 'text/html') - self.end_headers() - w = self.wfile.write - w(_('<html><head><title>Roundup instances index</title></head>\n')) - w(_('<body><h1>Roundup instances index</h1><ol>\n')) - for instance in self.TRACKER_HOMES.keys(): - w(_('<li><a href="%(instance_url)s/index">%(instance_name)s</a>\n')%{ - 'instance_url': urllib.quote(instance), - 'instance_name': cgi.escape(instance)}) - w(_('</ol></body></html>')) - - def inner_run_cgi(self): - ''' This is the inner part of the CGI handling - ''' - - rest = self.path - i = rest.rfind('?') - if i >= 0: - rest, query = rest[:i], rest[i+1:] - else: - query = '' - - # figure the instance - if rest == '/': - return self.index() - l_path = rest.split('/') - instance_name = urllib.unquote(l_path[1]) - if self.TRACKER_HOMES.has_key(instance_name): - instance_home = self.TRACKER_HOMES[instance_name] - instance = roundup.instance.open(instance_home) - else: - raise client.NotFound - - # figure out what the rest of the path is - if len(l_path) > 2: - rest = '/'.join(l_path[2:]) - else: - rest = '/' - - # Set up the CGI environment - env = {} - env['TRACKER_NAME'] = instance_name - env['REQUEST_METHOD'] = self.command - env['PATH_INFO'] = urllib.unquote(rest) - if query: - env['QUERY_STRING'] = query - host = self.address_string() - if self.headers.typeheader is None: - env['CONTENT_TYPE'] = self.headers.type - else: - env['CONTENT_TYPE'] = self.headers.typeheader - length = self.headers.getheader('content-length') - if length: - env['CONTENT_LENGTH'] = length - co = filter(None, self.headers.getheaders('cookie')) - if co: - env['HTTP_COOKIE'] = ', '.join(co) - env['SCRIPT_NAME'] = '' - env['SERVER_NAME'] = self.server.server_name - env['SERVER_PORT'] = str(self.server.server_port) - env['HTTP_HOST'] = self.headers['host'] - - decoded_query = query.replace('+', ' ') - - # do the roundup thang - c = instance.Client(instance, self, env) - c.main() - -def usage(message=''): - if message: - message = _('Error: %(error)s\n\n')%{'error': message} - print _('''%(message)sUsage: -roundup-server [-n hostname] [-p port] [-l file] [-d file] [name=instance home]* - - -n: sets the host name - -p: sets the port to listen on - -l: sets a filename to log to (instead of stdout) - -d: daemonize, and write the server's PID to the nominated file - - name=instance home - Sets the instance home(s) to use. The name is how the instance is - identified in the URL (it's the first part of the URL path). The - instance home is the directory that was identified when you did - "roundup-admin init". You may specify any number of these name=home - pairs on the command-line. For convenience, you may edit the - TRACKER_HOMES variable in the roundup-server file instead. - Make sure the name part doesn't include any url-unsafe characters like - spaces, as these confuse the cookie handling in browsers like IE. -''')%locals() - sys.exit(0) - -def daemonize(pidfile): - ''' Turn this process into a daemon. - - make sure the sys.std(in|out|err) are completely cut off - - make our parent PID 1 - - Write our new PID to the pidfile. - - From A.M. Kuuchling (possibly originally Greg Ward) with - modification from Oren Tirosh, and finally a small mod from me. - ''' - # Fork once - if os.fork() != 0: - os._exit(0) - - # Create new session - os.setsid() - - # Second fork to force PPID=1 - pid = os.fork() - if pid: - pidfile = open(pidfile, 'w') - pidfile.write(str(pid)) - pidfile.close() - os._exit(0) - - os.chdir("/") - os.umask(0) - - # close off sys.std(in|out|err), redirect to devnull so the file - # descriptors can't be used again - devnull = os.open('/dev/null', 0) - os.dup2(devnull, 0) - os.dup2(devnull, 1) - os.dup2(devnull, 2) - -def run(): - hostname = '' - port = 8080 - pidfile = None - logfile = None - try: - # handle the command-line args - try: - optlist, args = getopt.getopt(sys.argv[1:], 'n:p:u:d:l:') - except getopt.GetoptError, e: - usage(str(e)) - - user = ROUNDUP_USER - for (opt, arg) in optlist: - if opt == '-n': hostname = arg - elif opt == '-p': port = int(arg) - elif opt == '-u': user = arg - elif opt == '-d': pidfile = arg - elif opt == '-l': logfile = arg - elif opt == '-h': usage() - - if hasattr(os, 'getuid'): - # if root, setuid to the running user - if not os.getuid() and user is not None: - try: - import pwd - except ImportError: - raise ValueError, _("Can't change users - no pwd module") - try: - uid = pwd.getpwnam(user)[2] - except KeyError: - raise ValueError, _("User %(user)s doesn't exist")%locals() - os.setuid(uid) - elif os.getuid() and user is not None: - print _('WARNING: ignoring "-u" argument, not root') - - # People can remove this check if they're really determined - if not os.getuid() and user is None: - raise ValueError, _("Can't run as root!") - - # handle instance specs - if args: - d = {} - for arg in args: - try: - name, home = arg.split('=') - except ValueError: - raise ValueError, _("Instances must be name=home") - d[name] = home - RoundupRequestHandler.TRACKER_HOMES = d - except SystemExit: - raise - except: - exc_type, exc_value = sys.exc_info()[:2] - usage('%s: %s'%(exc_type, exc_value)) - - # we don't want the cgi module interpreting the command-line args ;) - sys.argv = sys.argv[:1] - address = (hostname, port) - - # fork? - if pidfile: - daemonize(pidfile) - - # redirect stdout/stderr to our logfile - if logfile: - sys.stdout = sys.stderr = open(logfile, 'a') - - httpd = BaseHTTPServer.HTTPServer(address, RoundupRequestHandler) - print _('Roundup server started on %(address)s')%locals() - httpd.serve_forever() - -if __name__ == '__main__': - run() - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/templates/classic/config.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,92 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: config.py,v 1.3 2002-09-11 01:18:42 richard Exp $ - -import os - -# roundup home is this package's directory -TRACKER_HOME=os.path.split(__file__)[0] - -# The SMTP mail host that roundup will use to send mail -MAILHOST = 'localhost' - -# The domain name used for email addresses. -MAIL_DOMAIN = 'your.tracker.email.domain.example' - -# This is the directory that the database is going to be stored in -DATABASE = os.path.join(TRACKER_HOME, 'db') - -# This is the directory that the HTML templates reside in -TEMPLATES = os.path.join(TRACKER_HOME, 'html') - -# A descriptive name for your roundup instance -TRACKER_NAME = 'Roundup issue tracker' - -# The email address that mail to roundup should go to -TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN - -# The web address that the instance is viewable at -TRACKER_WEB = 'http://your.tracker.url.example/' - -# The email address that roundup will complain to if it runs into trouble -ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN - -# Where to place the web filtering HTML on the index page -FILTER_POSITION = 'bottom' # one of 'top', 'bottom', 'top and bottom' - -# -# SECURITY DEFINITIONS -# -# define the Roles that a user gets when they register with the tracker -# these are a comma-separated string of role names (e.g. 'Admin,User') -NEW_WEB_USER_ROLES = 'User' -NEW_EMAIL_USER_ROLES = 'User' - -# Send nosy messages to the author of the message -MESSAGES_TO_AUTHOR = 'no' # either 'yes' or 'no' - -# Does the author of a message get placed on the nosy list automatically? -# If 'new' is used, then the author will only be added when a message -# creates a new issue. If 'yes', then the author will be added on followups -# too. If 'no', they're never added to the nosy. -ADD_AUTHOR_TO_NOSY = 'new' # one of 'yes', 'no', 'new' - -# Do the recipients (To:, Cc:) of a message get placed on the nosy list? -# If 'new' is used, then the recipients will only be added when a message -# creates a new issue. If 'yes', then the recipients will be added on followups -# too. If 'no', they're never added to the nosy. -ADD_RECIPIENTS_TO_NOSY = 'new' # either 'yes', 'no', 'new' - -# Where to place the email signature -EMAIL_SIGNATURE_POSITION = 'bottom' # one of 'top', 'bottom', 'none' - -# Keep email citations when accepting messages. Setting this to "no" strips -# out "quoted" text from the message. Signatures are also stripped. -EMAIL_KEEP_QUOTED_TEXT = 'yes' # either 'yes' or 'no' - -# Preserve the email body as is - that is, keep the citations _and_ -# signatures. -EMAIL_LEAVE_BODY_UNCHANGED = 'no' # either 'yes' or 'no' - -# Default class to use in the mailgw if one isn't supplied in email -# subjects. To disable, comment out the variable below or leave it blank. -# Examples: -MAIL_DEFAULT_CLASS = 'issue' # use "issue" class by default -#MAIL_DEFAULT_CLASS = '' # disable (or just comment the var out) - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/templates/classic/dbinit.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,193 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: dbinit.py,v 1.29 2002-09-13 03:31:18 richard Exp $ - -import os - -import config -from select_db import Database, Class, FileClass, IssueClass - -def open(name=None): - ''' as from the roundupdb method openDB - ''' - from roundup.hyperdb import String, Password, Date, Link, Multilink - - # open the database - db = Database(config, name) - - # - # Now initialise the schema. Must do this each time the database is - # opened. - # - - # Class automatically gets these properties: - # creation = Date() - # activity = Date() - # creator = Link('user') - pri = Class(db, "priority", - name=String(), order=String()) - pri.setkey("name") - - stat = Class(db, "status", - name=String(), order=String()) - stat.setkey("name") - - keyword = Class(db, "keyword", - name=String()) - keyword.setkey("name") - - query = Class(db, "query", - klass=String(), name=String(), - url=String()) - query.setkey("name") - - # Note: roles is a comma-separated string of Role names - user = Class(db, "user", - username=String(), password=Password(), - address=String(), realname=String(), - phone=String(), organisation=String(), - alternate_addresses=String(), - queries=Multilink('query'), roles=String()) - user.setkey("username") - - # FileClass automatically gets these properties: - # content = String() [saved to disk in <tracker home>/db/files/] - # (it also gets the Class properties creation, activity and creator) - msg = FileClass(db, "msg", - author=Link("user", do_journal='no'), - recipients=Multilink("user", do_journal='no'), - date=Date(), summary=String(), - files=Multilink("file"), - messageid=String(), inreplyto=String()) - - file = FileClass(db, "file", - name=String(), type=String()) - - # IssueClass automatically gets these properties: - # title = String() - # messages = Multilink("msg") - # files = Multilink("file") - # nosy = Multilink("user") - # superseder = Multilink("issue") - # (it also gets the Class properties creation, activity and creator) - issue = IssueClass(db, "issue", - assignedto=Link("user"), topic=Multilink("keyword"), - priority=Link("priority"), status=Link("status")) - - # - # SECURITY SETTINGS - # - # new permissions for this schema - for cl in 'issue', 'file', 'msg', 'user', 'query', 'keyword': - db.security.addPermission(name="Edit", klass=cl, - description="User is allowed to edit "+cl) - db.security.addPermission(name="View", klass=cl, - description="User is allowed to access "+cl) - - # Assign the access and edit permissions for issue, file and message - # to regular users now - for cl in 'issue', 'file', 'msg', 'query', 'keyword': - p = db.security.getPermission('View', cl) - db.security.addPermissionToRole('User', p) - p = db.security.getPermission('Edit', cl) - db.security.addPermissionToRole('User', p) - # and give the regular users access to the web and email interface - p = db.security.getPermission('Web Access') - db.security.addPermissionToRole('User', p) - p = db.security.getPermission('Email Access') - db.security.addPermissionToRole('User', p) - - # May users view other user information? Comment these lines out - # if you don't want them to - p = db.security.getPermission('View', 'user') - db.security.addPermissionToRole('User', p) - - # Assign the appropriate permissions to the anonymous user's Anonymous - # Role. Choices here are: - # - Allow anonymous users to register through the web - p = db.security.getPermission('Web Registration') - db.security.addPermissionToRole('Anonymous', p) - # - Allow anonymous (new) users to register through the email gateway - p = db.security.getPermission('Email Registration') - db.security.addPermissionToRole('Anonymous', p) - # - Allow anonymous users access to the "issue" class of data - # Note: this also grants access to related information like files, - # messages, statuses etc that are linked to issues - p = db.security.getPermission('View', 'issue') - db.security.addPermissionToRole('Anonymous', p) - # - Allow anonymous users access to edit the "issue" class of data - # Note: this also grants access to create related information like - # files and messages etc that are linked to issues - #p = db.security.getPermission('Edit', 'issue') - #db.security.addPermissionToRole('Anonymous', p) - - # oh, g'wan, let anonymous access the web interface too - p = db.security.getPermission('Web Access') - db.security.addPermissionToRole('Anonymous', p) - - import detectors - detectors.init(db) - - # schema is set up - run any post-initialisation - db.post_init() - return db - -def init(adminpw): - ''' as from the roundupdb method initDB - - Open the new database, and add new nodes - used for initialisation. You - can edit this before running the "roundup-admin initialise" command to - change the initial database entries. - ''' - dbdir = os.path.join(config.DATABASE, 'files') - if not os.path.isdir(dbdir): - os.makedirs(dbdir) - - db = open("admin") - db.clear() - - # - # INITIAL PRIORITY AND STATUS VALUES - # - pri = db.getclass('priority') - pri.create(name="critical", order="1") - pri.create(name="urgent", order="2") - pri.create(name="bug", order="3") - pri.create(name="feature", order="4") - pri.create(name="wish", order="5") - - stat = db.getclass('status') - stat.create(name="unread", order="1") - stat.create(name="deferred", order="2") - stat.create(name="chatting", order="3") - stat.create(name="need-eg", order="4") - stat.create(name="in-progress", order="5") - stat.create(name="testing", order="6") - stat.create(name="done-cbb", order="7") - stat.create(name="resolved", order="8") - - # create the two default users - user = db.getclass('user') - user.create(username="admin", password=adminpw, - address=config.ADMIN_EMAIL, roles='Admin') - user.create(username="anonymous", roles='Anonymous') - - db.commit() - -# vim: set filetype=python ts=4 sw=4 et si -
--- a/roundup/templates/classic/detectors/statusauditor.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,66 +0,0 @@ -# Copyright (c) 2002 ekit.com Inc (http://www.ekit-inc.com/) -# -# 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. -# -#$Id: statusauditor.py,v 1.2 2002-09-10 01:07:06 richard Exp $ - -def chatty(db, cl, nodeid, newvalues): - ''' If the issue is currently 'unread' or 'resolved', then set - it to 'chatting' - ''' - # don't fire if there's no new message (ie. chat) - if not newvalues.has_key('messages'): - return - if newvalues['messages'] == cl.get(nodeid, 'messages', cache=0): - return - - # determine the id of 'unread', 'resolved' and 'chatting' - unread_id = db.status.lookup('unread') - resolved_id = db.status.lookup('resolved') - chatting_id = db.status.lookup('chatting') - - # get the current value - current_status = cl.get(nodeid, 'status') - - # see if there's an explicit change in this transaction - if newvalues.has_key('status') and newvalues['status'] != current_status: - # yep, skip - return - - # ok, there's no explicit change, so do it manually - if current_status in (unread_id, resolved_id): - newvalues['status'] = chatting_id - - -def presetunread(db, cl, nodeid, newvalues): - ''' Make sure the status is set on new issues - ''' - if newvalues.has_key('status'): - return - - # ok, do it - newvalues['status'] = db.status.lookup('unread') - - -def init(db): - # fire before changes are made - db.issue.audit('set', chatty) - db.issue.audit('create', presetunread) - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/templates/classic/html/issue.index Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,110 +0,0 @@ -<!-- dollarId: issue.index,v 1.2 2001/07/29 04:07:37 richard Exp dollar--> -<tal:block metal:use-macro="templates/page/macros/icing"> -<title metal:fill-slot="head_title">List of issues</title> -<td class="page-header-top" metal:fill-slot="body_title"> - <h2>List of issues</h2> -</td> -<td class="content" metal:fill-slot="content"> - -<tal:block tal:condition="not:context/is_view_ok"> -You are not allowed to view this page. -</tal:block> - -<tal:block tal:define="batch request/batch" tal:condition="context/is_view_ok"> - <table class="list"> - <tr> - <th tal:condition="request/show/priority">Priority</th> - <th tal:condition="request/show/id">ID</th> - <th tal:condition="request/show/activity">Activity</th> - <th tal:condition="request/show/topic">Topic</th> - <th tal:condition="request/show/title">Title</th> - <th tal:condition="request/show/status">Status</th> - <th tal:condition="request/show/creator">Created By</th> - <th tal:condition="request/show/assignedto">Assigned To</th> - </tr> - <tal:block tal:repeat="i batch"> - <tr tal:condition="python:request.group[1] and - batch.propchanged(request.group[1])"> - <th tal:attributes="colspan python:len(request.columns)" - tal:content="python:i[request.group[1]]" class="group"> - </th> - </tr> - <tr tal:attributes="class python:['normal', 'alt'][repeat['i'].even()]"> - <td tal:condition="request/show/priority" tal:content="i/priority"></td> - <td tal:condition="request/show/id" tal:content="i/id"></td> - <td nowrap tal:condition="request/show/activity" - tal:content="i/activity/reldate"></td> - <td tal:condition="request/show/topic" tal:content="i/topic"></td> - <td tal:condition="request/show/title"> - <a tal:attributes="href string:issue${i/id}" - tal:content="python:str(i.title) or '[no title]'">title</a> - </td> - <td tal:condition="request/show/status" tal:content="i/status"></td> - <td tal:condition="request/show/creator" tal:content="i/creator"></td> - <td tal:condition="request/show/assignedto" tal:content="i/assignedto"></td> - </tr> - </tal:block> - <tr> - <td style="padding: 0" tal:attributes="colspan python:len(request.columns)"> - <table class="list"> - <tr><th style="text-align: left; border: 0"> - <a tal:define="prev batch/previous" tal:condition="prev" - tal:attributes="href python:request.indexargs_href(request.classname, - {':startwith':prev.first, ':pagesize':prev.size})"><< previous</a> - - </th> - <th style="text-align: right; border: 0"> - <a tal:define="next batch/next" tal:condition="next" - tal:attributes="href python:request.indexargs_href(request.classname, - {':startwith':next.first, ':pagesize':next.size})">next >></a> - - </th></tr> - </table> - </td> - </tr> -</table> - -<form method="GET" tal:attributes="action request/classname"> - <tal:block tal:replace="structure python:request.indexargs_form(sort=0, group=0)" /> - <table class="form"> - <tr tal:condition="batch"> - <th>Sort on:</th> - <td> - <select name=":sort"> - <option value="">- nothing -</option> - <option tal:repeat="col context/properties" - tal:attributes="value col/_name; - selected python:col._name == request.sort[1]" - tal:content="col/_name">column</option> - </select> - </td> - <th>Descending:</th> - <td><input type="checkbox" name=":sortdir" - tal:attributes="checked python:request.sort[0] == '-'"> - </td> - </tr> - <tr> - <th>Group on:</th> - <td> - <select name=":group"> - <option value="">- nothing -</option> - <option tal:repeat="col context/properties" - tal:attributes="value col/_name; - selected python:col._name == request.group[1]" - tal:content="col/_name">column</option> - </select> - </td> - <th>Descending:</th> - <td><input type="checkbox" name=":groupdir" - tal:attributes="checked python:request.group[0] == '-'"> - </td> - </tr> - <tr><td colspan="4"><input type="submit" value="Redisplay"></td></tr> - </table> -</form> - -</tal:block> - -</td> -</tal:block> -
--- a/roundup/templates/classic/html/issue.item Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,148 +0,0 @@ -<!-- dollarId: issue.item,v 1.4 2001/08/03 01:19:43 richard Exp dollar--> -<tal:block metal:use-macro="templates/page/macros/icing"> -<title metal:fill-slot="head_title">Issue editing</title> -<td class="page-header-top" metal:fill-slot="body_title"><h2>Issue Editing</h2> -</td> - -<td class="content" metal:fill-slot="content"> - -<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())"> -You are not allowed to view this page. -</span> - -<form method="POST" onSubmit="return submit_once()" - enctype="multipart/form-data" tal:condition="context/is_edit_ok"> - -<input type="hidden" name=":template" value="item"> -<input type="hidden" name=":required" value="title,priority"> - -<table class="form"> -<tr> - <th nowrap>Title</th> - <td colspan=3 tal:content="structure python:context.title.field(size=60)">title</td> -</tr> - -<tr> - <th nowrap>Priority</th> - <td tal:content="structure context/priority/menu">priority</td> - <th nowrap>Status</th> - <td tal:content="structure context/status/menu">status</td> -</tr> - -<tr> - <th nowrap>Superseder</th> - <td> - <span tal:replace="structure python:context.superseder.field(showid=1, size=20)" /> - <span tal:replace="structure python:db.issue.classhelp('id,title')" /> - <span tal:condition="context/superseder" tal:repeat="sup context/superseder"> - <br>View: <a tal:attributes="href string:issue${sup/id}" - tal:content="sup/id"></a> - </span> - </td> - <th nowrap>Nosy List</th> - <td> - <span tal:replace="structure context/nosy/field" /> - <span tal:replace="structure python:db.user.classhelp('username,realname,address,phone')" /> - </td> -</tr> - -<tr> - <th nowrap>Assigned To</th> - <td tal:content="structure context/assignedto/menu">assignedto menu</td> - <th nowrap>Topics</th> - <td> - <span tal:replace="structure context/topic/field" /> - <span tal:replace="structure db/keyword/classhelp" /> - </td> -</tr> - -<tr> - <th nowrap>Change Note</th> - <td colspan=3> - <textarea name=":note" wrap="hard" rows="5" cols="60"></textarea> - </td> -</tr> - -<tr> - <th nowrap>File</th> - <td colspan=3><input type="file" name=":file" size="40"></td> -</tr> - -<tr> - <td> </td> - <td colspan=3 tal:content="structure context/submit"> - submit button will go here - </td> -</tr> -</table> - -</form> - -<table class="form" tal:condition="context/is_only_view_ok"> -<tr> - <th nowrap>Title</th><td colspan=3 tal:content="context/title">title</td> -</tr> - -<tr> - <th nowrap>Priority</th><td tal:content="context/priority">priority</td> - <th nowrap>Status</th><td tal:content="context/status">status</td> -</tr> - -<tr> - <th nowrap>Superseder</th> - <td> - <span tal:condition="context/superseder" tal:repeat="sup context/superseder"> - <br>View: <a tal:attributes="href string:issue${sup/id}" - tal:content="sup/id"></a> - </span> - </td> - <th nowrap>Nosy List</th><td><span tal:replace="context/nosy" /></td> -</tr> - -<tr> - <th nowrap>Assigned To</th><td tal:content="context/assignedto"></td> - <th nowrap>Topics</th><td tal:content="structure context/topic"></td> -</tr> -</table> - -<tal:block tal:condition="python:context.id and context.is_view_ok()"> - - <p tal:content="structure string:Created on - <b>${context/creation}</b> by <b>${context/creator}</b>, last - changed <b>${context/activity}</b>.">activity info - </p> - - <table class="messages" tal:condition="context/messages"> - <tr><th colspan=4 class="header">Messages</th></tr> - <tr><th>Message</th><th>Author</th><th>Date</th><th>Summary</th></tr> - <tr tal:repeat="msg context/messages/reverse"> - <td><a tal:attributes="href string:msg${msg/id}" - tal:content="string:msg${msg/id}"></a></td> - <td tal:content="msg/author">author</td> - <td tal:content="msg/date">date</td> - <td tal:content="msg/summary">summary</td> - </tr> - </table> - - <table class="files" tal:condition="context/files"> - <tr><th colspan="2" class="header">Files</th></tr> - <tr><th>File name</th><th>Uploaded</th></tr> - <tr tal:repeat="file context/files"> - <td> - <a tal:attributes="href string:file${file/id}/${file/name}" - tal:content="file/name">dld link</a> - </td> - <td> - <span tal:content="file/creator">creator's name</span>, - <span tal:content="file/creation">creation date</span> - </td> - </tr> - </table> - - <tal:block tal:replace="structure context/history" /> - -</tal:block> - -</td> - -</tal:block>
--- a/roundup/templates/classic/html/issue.search Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,184 +0,0 @@ -<tal:block metal:use-macro="templates/page/macros/icing"> -<title metal:fill-slot="head_title">Issue searching</title> -<td class="page-header-top" metal:fill-slot="body_title"> - <h2>Issue searching</h2> -</td> -<td class="content" metal:fill-slot="content"> - -<form method="GET" tal:attributes="action request/classname"> -<input type="hidden" name=":action" value="search"> - -<table class="form" tal:define=" - cols python:'id activity priority title status assignedto'.split(); - defsort python:['activity']; - defgroup python:['priority']; - defdisp python:'id activity title status assignedto'.split()"> - -<tr> - <th class="header"> </th> - <th class="header">Filter on</th> - <th class="header">Display</th> - <th class="header">Sort on</th> - <th class="header">Group on</th> -</tr> - -<tr> - <th>All text*:</th> - <td><input name=":search_text" - tal:attributes="value request/form/:search_text/value | nothing"> - </td> - <td> </td> - <td> </td> - <td> </td> -</tr> - -<tr> - <th>Title:</th> - <td><input name="title"></td> - <td><input type="checkbox" name=":columns" value="title" checked></td> - <td><input type="radio" name=":sort" value="title"></td> - <td> </td> -</tr> - -<tr> - <th>Topic:</th> - <td> - <select name="topic"> - <option value="">don't care</option> - <option value="">------------</option> - <option tal:repeat="s db/keyword/list" tal:attributes="value s/name" - tal:content="s/name">topic to filter on</option> - </select> - </td> - <td><input type="checkbox" name=":columns" value="topic" checked></td> - <td><input type="radio" name=":sort" value="topic"></td> - <td><input type="radio" name=":group" value="topic"></td> -</tr> - -<tr> - <th>Created:</th> - <td><input name="activity"></td> - <td><input type="checkbox" name=":columns" value="created"></td> - <td><input type="radio" name=":sort" value="created"></td> - <td><input type="radio" name=":group" value="created"></td> -</tr> - -<tr> - <th>Creator:</th> - <td> - <select name="creator"> - <option value="">don't care</option> - <option tal:attributes="value request/user/id">created by me</option> - <option value="-1">------------</option> - <option tal:repeat="s db/user/list" tal:attributes="value s/id" - tal:content="s/username">user to filter on</option> - </select> - </td> - <td><input type="checkbox" name=":columns" value="creator" checked></td> - <td><input type="radio" name=":sort" value="creator"></td> - <td><input type="radio" name=":group" value="creator"></td> -</tr> - -<tr> - <th>Activity:</th> - <td><input name="activity"></td> - <td><input type="checkbox" name=":columns" value="activity" checked></td> - <td><input type="radio" name=":sort" value="activity"></td> - <td> </td> -</tr> - -<tr> - <th>Priority:</th> - <td> - <select name="priority"> - <option value="">don't care</option> - <option value="-1">not selected</option> - <option value="">------------</option> - <option tal:repeat="s db/priority/list" tal:attributes="value s/id" - tal:content="s/name">priority to filter on</option> - </select> - </td> - <td><input type="checkbox" name=":columns" value="priority"></td> - <td><input type="radio" name=":sort" value="priority"></td> - <td><input type="radio" name=":group" value="priority"></td> -</tr> - -<tr> - <th>Status:</th> - <td> - <select name="status"> - <option value="">don't care</option> - <option value="-1,1,2,3,4,5,6,7">not resolved</option> - <option value="-1">not selected</option> - <option value="">------------</option> - <option tal:repeat="s db/status/list" tal:attributes="value s/id" - tal:content="s/name">status to filter on</option> - </select> - </td> - <td><input type="checkbox" name=":columns" value="status" checked></td> - <td><input type="radio" name=":sort" value="status"></td> - <td><input type="radio" name=":group" value="status"></td> -</tr> - -<tr> - <th>Assigned To:</th> - <td> - <select name="assignedto"> - <option value="">don't care</option> - <option tal:attributes="value request/user/id">assigned to me</option> - <option value="-1">unassigned</option> - <option value="">------------</option> - <option tal:repeat="s db/user/list" tal:attributes="value s/id" - tal:content="s/username">user to filter on</option> - </select> - </td> - <td><input type="checkbox" name=":columns" value="assignedto" checked></td> - <td><input type="radio" name=":sort" value="assignedto"></td> - <td><input type="radio" name=":group" value="assignedto"></td> -</tr> - -<tr> -<th>Pagesize:</th> -<td><input type="text" name=":pagesize" size="3" value="50"></td> -</tr> - -<tr> -<th>Start With:</th> -<td><input type="text" name=":startwith" size="3" value="0"></td> -</tr> - -<tr> -<th>Sort Descending:</th> -<td><input type="checkbox" name=":sortdir" checked> -</td> - -<tr> -<th>Group Descending:</th> -<td><input type="checkbox" name=":groupdir"> -</td> -</tr> - -<tr> -<th>Query name**:</th> -<td><input name=":queryname" - tal:attributes="value request/form/:queryname/value | nothing"> -</td> -</tr> - -<tr><td> </td> -<td><input type="submit" value="Search"></td> -</tr> - -<tr><td> </td> - <td colspan="4" class="help"> - *: The "all text" field will look in message bodies and issue titles<br> - **: If you supply a name, the query will be saved off and available as a - link in the sidebar - </td> -</tr> -</table> - -</form> -</td> - -</tal:block>
--- a/roundup/templates/classic/html/query.item Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,16 +0,0 @@ -<tal:block metal:use-macro="templates/page/macros/icing"> -<title metal:fill-slot="head_title">Query editing</title> -<td class="page-header-top" metal:fill-slot="body_title"> - <h2>Query editing</h2> -</td> -<td class="content" metal:fill-slot="content"> -<span tal:condition="not:context/is_edit_ok"> -You are not allowed to view this page. -</span> - -<span tal:condition="context/is_edit_ok" - tal:content="structure context/renderQueryForm" /> - -</td> - -</tal:block>
--- a/roundup/templates/classic/html/style.css Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,305 +0,0 @@ -/* main page styles */ -body.body { - font-family: sans-serif, Arial, Helvetica; - color: #333333; -} -a[href]:hover { color:blue; text-decoration: underline; } -a[href]:link { color:blue; text-decoration: none; } -a[href] { color:blue; text-decoration: none; } - -table.body { - border: 0; - padding: 0; - border-spacing: 0px; - border-collapse: separate; -} - -td.page-header-left { - padding: 5px; - border-bottom: 1px solid #444444; -} - -td.page-header-top { - border-bottom: 1px solid #444444; - padding: 5px; -} - -td.sidebar { - padding: 1 0 0 1; -} - -td.sidebar p.classblock { - padding: 0 5 0 5; - margin: 1 1 1 1; - border: 1px solid #444444; - background-color: #eeeeee; -} - -td.sidebar p.userblock { - padding: 0 5 0 5; - margin: 1 1 1 1; - border: 1px solid #444444; - background-color: #eeeeff; -} - -td.content { - padding: 1 5 1 5; - vertical-align: top; -} - -p.ok-message { - background-color: #22bb22; - padding: 5 5 5 5; - color: white; - font-weight: bold; -} -p.error-message { - background-color: #bb2222; - padding: 5 5 5 5; - color: white; - font-weight: bold; -} - - -/* style for forms */ -table.form { - padding: 2; - border-spacing: 0px; - border-collapse: separate; -} - -table.form th { - font-weight: bold; - color: #333388; - text-align: right; - vertical-align: top; -} - -table.form th.header { - font-weight: bold; - color: #333388; - background-color: #eeeeff; - text-align: left; -} - -table.form td.optional { - font-weight: bold; - font-style: italic; - color: #333333; - empty-cells: show; -} - -table.form td { - color: #333333; - empty-cells: show; -} - -table.form td.html { - color: #777777; - empty-cells: show; -} - -/* style for lists */ -table.list { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.list th { - padding: 0 4 0 4; - color: #404070; - background-color: #eeeeff; - border-right: 1px solid #404070; - border-top: 1px solid #404070; - border-bottom: 1px solid #404070; - vertical-align: top; -} -table.list th a[href]:hover { color: #404070 } -table.list th a[href]:link { color: #404070 } -table.list th a[href] { color: #404070 } -table.list th.group { - background-color: #f4f4ff; - text-align: center; -} - -table.list td { - padding: 0 4 0 4; - border: 0 2 0 2; - border-right: 1px solid #404070; - color: #404070; - background-color: white; - vertical-align: top; -} - -table.list tr.normal td { - empty-cells: show; -} - -table.list tr.alt td { - background-color: #efefef; - empty-cells: show; -} - -table.list td:first-child { - border-left: 1px solid #404070; - border-right: 1px solid #404070; - empty-cells: show; -} - -table.list th:first-child { - border-left: 1px solid #404070; - border-right: 1px solid #404070; - empty-cells: show; -} - - -/* style for message displays */ -table.messages { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.messages th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; -} - -table.messages th { - font-weight: bold; - color: black; - text-align: left; -} - -table.messages td { - font-family: monospace; - background-color: #efefef; - border-top: 1px solid #afafaf; - border-bottom: 1px solid #afafaf; - color: black; - empty-cells: show; -} - -/* style for file displays */ -table.files { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.files th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; -} - -table.files th { - border-bottom: 1px solid #afafaf; - font-weight: bold; - text-align: left; -} - -table.files td { - font-family: monospace; - empty-cells: show; -} - -/* style for history displays */ -table.history { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.history th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; - font-size: 100%; -} - -table.history th { - border-bottom: 1px solid #afafaf; - font-weight: bold; - text-align: left; - font-size: 90%; -} - -table.history td { - font-size: 90%; - vertical-align: top; - empty-cells: show; -} - - -/* style for class list */ -table.classlist { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.classlist th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; -} - -table.classlist th { - font-weight: bold; - text-align: left; -} - - -/* style for class help display */ -table.classhelp { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.classhelp th { - font-weight: bold; - text-align: left; - color: #707040; -} - -table.classhelp td { - padding: 2 2 2 2; - border: 1px solid black; - text-align: left; - vertical-align: top; - empty-cells: show; -} - - -/* style for "other" displays */ -table.otherinfo { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.otherinfo th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; -} - -table.otherinfo th { - border-bottom: 1px solid #afafaf; - font-weight: bold; - text-align: left; -}
--- a/roundup/templates/classic/html/user.index Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,35 +0,0 @@ -<!-- dollarId: user.index,v 1.3 2002/07/09 05:29:51 richard Exp dollar--> -<tal:block metal:use-macro="templates/page/macros/icing"> -<title metal:fill-slot="head_title">User listing</title> -<td class="page-header-top" metal:fill-slot="body_title"> - <h2>User listing</h2> -</td> -<td class="content" metal:fill-slot="content"> - -<span tal:condition="not:context/is_view_ok"> -You are not allowed to view this page. -</span> - -<table width="100%" tal:condition="context/is_view_ok" class="list"> -<tr> - <th>Username</th> - <th>Real name</th> - <th>Organisation</th> - <th>Email address</th> - <th>Phone number</th> -</tr> -<tr tal:repeat="user context/list" - tal:attributes="class python:['row-normal', 'row-alt'][repeat['user'].even()]"> - <td> - <a tal:attributes="href string:user${user/id}" - tal:content="user/username">username</a> - </td> - <td tal:content="user/realname">realname</td> - <td tal:content="user/organisation">organisation</td> - <td tal:content="python:user.address.email()">address</td> - <td tal:content="user/phone">phone</td> -</tr> -</table> -</td> - -</tal:block>
--- a/roundup/templates/classic/html/user.item Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,105 +0,0 @@ -<!-- dollarId: user.item,v 1.7 2002/08/16 04:29:04 richard Exp dollar--> -<tal:block metal:use-macro="templates/page/macros/icing"> -<title metal:fill-slot="head_title">User editing</title> -<td class="page-header-top" metal:fill-slot="body_title"> - <h2>User editing</h2> -</td> -<td class="content" metal:fill-slot="content"> -<span tal:condition="python:not (context.is_view_ok() or context.is_edit_ok())"> -You are not allowed to view this page. -</span> - -<form method="POST" onSubmit="return submit_once()" - enctype="multipart/form-data" tal:condition="context/is_edit_ok"> - -<input type="hidden" name=":required" value="username,address"> - -<table class="form"> - <tr> - <th>Name</th> - <td tal:content="structure context/realname/field">realname</td> - </tr> - <tr> - <th>Login Name</th> - <td tal:content="structure context/username/field">username</td> - </tr> - <tr> - <th>Login Password</th> - <td tal:content="structure context/password/field">password</td> - </tr> - <tr> - <th>Confirm Password</th> - <td tal:content="structure context/password/confirm">password</td> - </tr> - <tr tal:condition="python:request.user.hasPermission('Web Roles')"> - <th>Roles</th> - <td tal:condition="context/id" - tal:content="structure context/roles/field">roles</td> - <td tal:condition="not:context/id"> - <input name="roles" tal:attributes="value db/config/NEW_WEB_USER_ROLES"> - </td> - </tr> - <tr> - <th>Phone</th> - <td tal:content="structure context/phone/field">phone</td> - </tr> - <tr> - <th>Organisation</th> - <td tal:content="structure context/organisation/field">organisation</td> - </tr> - <tr> - <th>E-mail address</th> - <td tal:content="structure context/address/field">address</td> - </tr> - <tr> - <th>Alternate E-mail addresses<br>One address per line</th> - <td tal:content="structure context/alternate_addresses/multiline">alternate_addresses</td> - </tr> - - <tr> - <td> </td> - <td tal:content="structure context/submit">submit button here</td> - </tr> -</table> -</form> - -<table class="otherinfo" tal:condition="context/queries"> - <tr><th colspan="2" class="header">Queries</th></tr> - <tr><th>Name</th><th>Display</th></tr> - <tr tal:repeat="query context/queries"> - <td><a tal:attributes="href string:query${query/id}" - tal:content="query/name"></a></td> - <td> - <a tal:attributes="href python:'%s%s'%(query['klass'], query['url'])">display</a> - </td> - </tr> -</table> - -<table class="form" tal:condition="context/is_only_view_ok"> - <tr> - <th colspan=2 class="header" tal:content="context/realname">realname</th> - </tr> - <tr> - <th>Login Name</th> - <td tal:content="context/username">username</td> - </tr> - <tr> - <th>Phone</th> - <td tal:content="context/phone">phone</td> - </tr> - <tr> - <th>Organisation</th> - <td tal:content="context/organisation">organisation</td> - </tr> - <tr> - <th>E-mail address</th> - <td tal:content="context/address/email">address</td> - </tr> -</table> - -<tal:block tal:condition="python:context.id and context.is_view_ok()" - tal:replace="structure context/history" /> - -</td> - -</tal:block>
--- a/roundup/templates/classic/interfaces.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,35 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: interfaces.py,v 1.15 2002-09-09 23:55:19 richard Exp $ - -from roundup import mailgw -from roundup.cgi import client - -class Client(client.Client): - ''' derives basic CGI implementation from the standard module, - with any specific extensions - ''' - pass - -class MailGW(mailgw.MailGW): - ''' derives basic mail gateway implementation from the standard module, - with any specific extensions - ''' - pass - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/templates/minimal/config.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,92 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: config.py,v 1.1 2002-09-26 04:15:06 richard Exp $ - -import os - -# roundup home is this package's directory -TRACKER_HOME=os.path.split(__file__)[0] - -# The SMTP mail host that roundup will use to send mail -MAILHOST = 'localhost' - -# The domain name used for email addresses. -MAIL_DOMAIN = 'your.tracker.email.domain.example' - -# This is the directory that the database is going to be stored in -DATABASE = os.path.join(TRACKER_HOME, 'db') - -# This is the directory that the HTML templates reside in -TEMPLATES = os.path.join(TRACKER_HOME, 'html') - -# A descriptive name for your roundup instance -TRACKER_NAME = 'Roundup issue tracker' - -# The email address that mail to roundup should go to -TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN - -# The web address that the instance is viewable at -TRACKER_WEB = 'http://your.tracker.url.example/' - -# The email address that roundup will complain to if it runs into trouble -ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN - -# Where to place the web filtering HTML on the index page -FILTER_POSITION = 'bottom' # one of 'top', 'bottom', 'top and bottom' - -# -# SECURITY DEFINITIONS -# -# define the Roles that a user gets when they register with the tracker -# these are a comma-separated string of role names (e.g. 'Admin,User') -NEW_WEB_USER_ROLES = 'User' -NEW_EMAIL_USER_ROLES = 'User' - -# Send nosy messages to the author of the message -MESSAGES_TO_AUTHOR = 'no' # either 'yes' or 'no' - -# Does the author of a message get placed on the nosy list automatically? -# If 'new' is used, then the author will only be added when a message -# creates a new issue. If 'yes', then the author will be added on followups -# too. If 'no', they're never added to the nosy. -ADD_AUTHOR_TO_NOSY = 'new' # one of 'yes', 'no', 'new' - -# Do the recipients (To:, Cc:) of a message get placed on the nosy list? -# If 'new' is used, then the recipients will only be added when a message -# creates a new issue. If 'yes', then the recipients will be added on followups -# too. If 'no', they're never added to the nosy. -ADD_RECIPIENTS_TO_NOSY = 'new' # either 'yes', 'no', 'new' - -# Where to place the email signature -EMAIL_SIGNATURE_POSITION = 'bottom' # one of 'top', 'bottom', 'none' - -# Keep email citations when accepting messages. Setting this to "no" strips -# out "quoted" text from the message. Signatures are also stripped. -EMAIL_KEEP_QUOTED_TEXT = 'yes' # either 'yes' or 'no' - -# Preserve the email body as is - that is, keep the citations _and_ -# signatures. -EMAIL_LEAVE_BODY_UNCHANGED = 'no' # either 'yes' or 'no' - -# Default class to use in the mailgw if one isn't supplied in email -# subjects. To disable, comment out the variable below or leave it blank. -# Examples: -MAIL_DEFAULT_CLASS = 'issue' # use "issue" class by default -#MAIL_DEFAULT_CLASS = '' # disable (or just comment the var out) - -# vim: set filetype=python ts=4 sw=4 et si
--- a/roundup/templates/minimal/dbinit.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,106 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: dbinit.py,v 1.1 2002-09-26 04:15:07 richard Exp $ - -import os - -import config -from select_db import Database, Class, FileClass, IssueClass - -def open(name=None): - ''' as from the roundupdb method openDB - ''' - from roundup.hyperdb import String, Password, Date, Link, Multilink - - # open the database - db = Database(config, name) - - # - # Now initialise the schema. Must do this each time the database is - # opened. - # - - # The "Minimal" template gets only one class, the required "user" - # class. That's it. And even that has the bare minimum of properties. - - # Note: roles is a comma-separated string of Role names - user = Class(db, "user", username=String(), password=Password(), - address=String(), alternate_addresses=String(), roles=String()) - user.setkey("username") - - # - # SECURITY SETTINGS - # - # new permissions for this schema - for cl in ('user', ): - db.security.addPermission(name="Edit", klass=cl, - description="User is allowed to edit "+cl) - db.security.addPermission(name="View", klass=cl, - description="User is allowed to access "+cl) - - # and give the regular users access to the web and email interface - p = db.security.getPermission('Web Access') - db.security.addPermissionToRole('User', p) - p = db.security.getPermission('Email Access') - db.security.addPermissionToRole('User', p) - - # May users view other user information? Comment these lines out - # if you don't want them to - p = db.security.getPermission('View', 'user') - db.security.addPermissionToRole('User', p) - - # Assign the appropriate permissions to the anonymous user's Anonymous - # Role. Choices here are: - # - Allow anonymous users to register through the web - p = db.security.getPermission('Web Registration') - db.security.addPermissionToRole('Anonymous', p) - # - Allow anonymous (new) users to register through the email gateway - p = db.security.getPermission('Email Registration') - db.security.addPermissionToRole('Anonymous', p) - - import detectors - detectors.init(db) - - # schema is set up - run any post-initialisation - db.post_init() - return db - -def init(adminpw): - ''' as from the roundupdb method initDB - - Open the new database, and add new nodes - used for initialisation. You - can edit this before running the "roundup-admin initialise" command to - change the initial database entries. - ''' - dbdir = os.path.join(config.DATABASE, 'files') - if not os.path.isdir(dbdir): - os.makedirs(dbdir) - - db = open("admin") - db.clear() - - # create the two default users - user = db.getclass('user') - user.create(username="admin", password=adminpw, - address=config.ADMIN_EMAIL, roles='Admin') - user.create(username="anonymous", roles='Anonymous') - - db.commit() - -# vim: set filetype=python ts=4 sw=4 et si -
--- a/roundup/templates/minimal/html/style.css Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,305 +0,0 @@ -/* main page styles */ -body.body { - font-family: sans-serif, Arial, Helvetica; - color: #333333; -} -a[href]:hover { color:blue; text-decoration: underline; } -a[href]:link { color:blue; text-decoration: none; } -a[href] { color:blue; text-decoration: none; } - -table.body { - border: 0; - padding: 0; - border-spacing: 0px; - border-collapse: separate; -} - -td.page-header-left { - padding: 5px; - border-bottom: 1px solid #444444; -} - -td.page-header-top { - border-bottom: 1px solid #444444; - padding: 5px; -} - -td.sidebar { - padding: 1 0 0 1; -} - -td.sidebar p.classblock { - padding: 0 5 0 5; - margin: 1 1 1 1; - border: 1px solid #444444; - background-color: #eeeeee; -} - -td.sidebar p.userblock { - padding: 0 5 0 5; - margin: 1 1 1 1; - border: 1px solid #444444; - background-color: #eeeeff; -} - -td.content { - padding: 1 5 1 5; - vertical-align: top; -} - -p.ok-message { - background-color: #22bb22; - padding: 5 5 5 5; - color: white; - font-weight: bold; -} -p.error-message { - background-color: #bb2222; - padding: 5 5 5 5; - color: white; - font-weight: bold; -} - - -/* style for forms */ -table.form { - padding: 2; - border-spacing: 0px; - border-collapse: separate; -} - -table.form th { - font-weight: bold; - color: #333388; - text-align: right; - vertical-align: top; -} - -table.form th.header { - font-weight: bold; - color: #333388; - background-color: #eeeeff; - text-align: left; -} - -table.form td.optional { - font-weight: bold; - font-style: italic; - color: #333333; - empty-cells: show; -} - -table.form td { - color: #333333; - empty-cells: show; -} - -table.form td.html { - color: #777777; - empty-cells: show; -} - -/* style for lists */ -table.list { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.list th { - padding: 0 4 0 4; - color: #404070; - background-color: #eeeeff; - border-right: 1px solid #404070; - border-top: 1px solid #404070; - border-bottom: 1px solid #404070; - vertical-align: top; -} -table.list th a[href]:hover { color: #404070 } -table.list th a[href]:link { color: #404070 } -table.list th a[href] { color: #404070 } -table.list th.group { - background-color: #f4f4ff; - text-align: center; -} - -table.list td { - padding: 0 4 0 4; - border: 0 2 0 2; - border-right: 1px solid #404070; - color: #404070; - background-color: white; - vertical-align: top; -} - -table.list tr.normal td { - empty-cells: show; -} - -table.list tr.alt td { - background-color: #efefef; - empty-cells: show; -} - -table.list td:first-child { - border-left: 1px solid #404070; - border-right: 1px solid #404070; - empty-cells: show; -} - -table.list th:first-child { - border-left: 1px solid #404070; - border-right: 1px solid #404070; - empty-cells: show; -} - - -/* style for message displays */ -table.messages { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.messages th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; -} - -table.messages th { - font-weight: bold; - color: black; - text-align: left; -} - -table.messages td { - font-family: monospace; - background-color: #efefef; - border-top: 1px solid #afafaf; - border-bottom: 1px solid #afafaf; - color: black; - empty-cells: show; -} - -/* style for file displays */ -table.files { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.files th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; -} - -table.files th { - border-bottom: 1px solid #afafaf; - font-weight: bold; - text-align: left; -} - -table.files td { - font-family: monospace; - empty-cells: show; -} - -/* style for history displays */ -table.history { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.history th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; - font-size: 100%; -} - -table.history th { - border-bottom: 1px solid #afafaf; - font-weight: bold; - text-align: left; - font-size: 90%; -} - -table.history td { - font-size: 90%; - vertical-align: top; - empty-cells: show; -} - - -/* style for class list */ -table.classlist { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.classlist th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; -} - -table.classlist th { - font-weight: bold; - text-align: left; -} - - -/* style for class help display */ -table.classhelp { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.classhelp th { - font-weight: bold; - text-align: left; - color: #707040; -} - -table.classhelp td { - padding: 2 2 2 2; - border: 1px solid black; - text-align: left; - vertical-align: top; - empty-cells: show; -} - - -/* style for "other" displays */ -table.otherinfo { - border-spacing: 0px; - border-collapse: separate; - width: 100%; -} - -table.otherinfo th.header{ - padding-top: 10px; - border-bottom: 1px solid gray; - font-weight: bold; - background-color: white; - color: #707040; -} - -table.otherinfo th { - border-bottom: 1px solid #afafaf; - font-weight: bold; - text-align: left; -}
--- a/roundup/templates/minimal/html/user.index Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,29 +0,0 @@ -<!-- dollarId: user.index,v 1.3 2002/07/09 05:29:51 richard Exp dollar--> -<tal:block metal:use-macro="templates/page/macros/icing"> -<title metal:fill-slot="head_title">User listing</title> -<td class="page-header-top" metal:fill-slot="body_title"> - <h2>User listing</h2> -</td> -<td class="content" metal:fill-slot="content"> - -<span tal:condition="not:context/is_view_ok"> -You are not allowed to view this page. -</span> - -<table width="100%" tal:condition="context/is_view_ok" class="list"> -<tr> - <th>Username</th> - <th>Email address</th> -</tr> -<tr tal:repeat="user context/list" - tal:attributes="class python:['row-normal', 'row-alt'][repeat['user'].even()]"> - <td> - <a tal:attributes="href string:user${user/id}" - tal:content="user/username">username</a> - </td> - <td tal:content="python:user.address.email()">address</td> -</tr> -</table> -</td> - -</tal:block>
--- a/roundup/templates/minimal/interfaces.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,35 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: interfaces.py,v 1.1 2002-09-26 04:15:07 richard Exp $ - -from roundup import mailgw -from roundup.cgi import client - -class Client(client.Client): - ''' derives basic CGI implementation from the standard module, - with any specific extensions - ''' - pass - -class MailGW(mailgw.MailGW): - ''' derives basic mail gateway implementation from the standard module, - with any specific extensions - ''' - pass - -# vim: set filetype=python ts=4 sw=4 et si
--- a/setup.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,193 +0,0 @@ -#! /usr/bin/env python -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: setup.py,v 1.39 2002-09-23 08:17:50 richard Exp $ - -from distutils.core import setup, Extension -from distutils.util import get_platform -from distutils.command.build_scripts import build_scripts - -import sys, os, string -from glob import glob - -from roundup.templates.builder import makeHtmlBase - - -############################################################################# -### Build script files -############################################################################# - -class build_scripts_create(build_scripts): - """ Overload the build_scripts command and create the scripts - from scratch, depending on the target platform. - - You have to define the name of your package in an inherited - class (due to the delayed instantiation of command classes - in distutils, this cannot be passed to __init__). - - The scripts are created in an uniform scheme: they start the - run() function in the module - - <packagename>.scripts.<mangled_scriptname> - - The mangling of script names replaces '-' and '/' characters - with '-' and '.', so that they are valid module paths. - """ - package_name = None - - def copy_scripts(self): - """ Create each script listed in 'self.scripts' - """ - if not self.package_name: - raise Exception("You have to inherit build_scripts_create and" - " provide a package name") - - to_module = string.maketrans('-/', '_.') - - self.mkpath(self.build_dir) - for script in self.scripts: - outfile = os.path.join(self.build_dir, os.path.basename(script)) - - #if not self.force and not newer(script, outfile): - # self.announce("not copying %s (up-to-date)" % script) - # continue - - if self.dry_run: - self.announce("would create %s" % outfile) - continue - - module = os.path.splitext(os.path.basename(script))[0] - module = string.translate(module, to_module) - script_vars = { - 'python': os.path.normpath(sys.executable), - 'package': self.package_name, - 'module': module, - } - - self.announce("creating %s" % outfile) - file = open(outfile, 'w') - - try: - if sys.platform == "win32": - file.write('@echo off\n' - 'if NOT "%%_4ver%%" == "" %(python)s -O -c "from %(package)s.scripts.%(module)s import run; run()" %%$\n' - 'if "%%_4ver%%" == "" %(python)s -O -c "from %(package)s.scripts.%(module)s import run; run()" %%*\n' - % script_vars) - else: - file.write('#! %(python)s -O\n' - 'from %(package)s.scripts.%(module)s import run\n' - 'run()\n' - % script_vars) - finally: - file.close() - os.chmod(outfile, 0755) - - -class build_scripts_roundup(build_scripts_create): - package_name = 'roundup' - - -def scriptname(path): - """ Helper for building a list of script names from a list of - module files. - """ - script = os.path.splitext(os.path.basename(path))[0] - script = string.replace(script, '_', '-') - if sys.platform == "win32": - script = script + ".bat" - return script - - - -############################################################################# -### Main setup stuff -############################################################################# - -def isTemplateDir(dir): - return dir[0] != '.' and dir != 'CVS' and os.path.isdir(dir) \ - and os.path.isfile(os.path.join(dir, '__init__.py')) - -# use that function to list all the templates -templates = map(os.path.basename, filter(isTemplateDir, - glob(os.path.join('roundup', 'templates', '*')))) - -def buildTemplates(): - for template in templates: - tdir = os.path.join('roundup', 'templates', template) - makeHtmlBase(tdir) - -if __name__ == '__main__': - # build list of scripts from their implementation modules - roundup_scripts = map(scriptname, glob('roundup/scripts/[!_]*.py')) - - # template munching - templates = map(os.path.basename, filter(isTemplateDir, - glob(os.path.join('roundup', 'templates', '*')))) - packagelist = [ - 'roundup', - 'roundup.cgi', - 'roundup.cgi.PageTemplates', - 'roundup.cgi.TAL', - 'roundup.cgi.ZTUtils', - 'roundup.backends', - 'roundup.scripts', - 'roundup.templates' - ] - installdatafiles = [ - ('share/roundup/cgi-bin', ['cgi-bin/roundup.cgi']), - ] - - # munge the template HTML into the htmlbase module - buildTemplates() - - # add the templates to the setup packages and data files lists - for template in templates: - tdir = os.path.join('roundup', 'templates', template) - - # add the template package and subpackage - packagelist.append('roundup.templates.%s' % template) - packagelist.append('roundup.templates.%s.detectors' % template) - - # scan for data files - tfiles = glob(os.path.join(tdir, 'html', '*')) - tfiles = filter(os.path.isfile, tfiles) - installdatafiles.append( - ('share/roundup/templates/%s/html' % template, tfiles) - ) - - # perform the setup action - from roundup import __version__ - setup( - name = "roundup", - version = __version__, - description = "Roundup issue tracking system.", - author = "Richard Jones", - author_email = "richard@users.sourceforge.net", - url = 'http://sourceforge.net/projects/roundup/', - packages = packagelist, - - # Override certain command classes with our own ones - cmdclass = { - 'build_scripts': build_scripts_roundup, - }, - scripts = roundup_scripts, - - data_files = installdatafiles - ) - -# vim: set filetype=python ts=4 sw=4 et si
--- a/test/test_dates.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,155 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: test_dates.py,v 1.13 2002-09-10 00:19:54 richard Exp $ - -import unittest, time - -from roundup.date import Date, Interval - -class DateTestCase(unittest.TestCase): - def testDateInterval(self): - ae = self.assertEqual - date = Date("2000-06-26.00:34:02 + 2d") - ae(str(date), '2000-06-28.00:34:02') - date = Date("2000-02-27 + 2d") - ae(str(date), '2000-02-29.00:00:00') - date = Date("2001-02-27 + 2d") - ae(str(date), '2001-03-01.00:00:00') - - def testDate(self): - ae = self.assertEqual - date = Date("2000-04-17") - ae(str(date), '2000-04-17.00:00:00') - date = Date("2000-4-7") - ae(str(date), '2000-04-07.00:00:00') - date = Date("2000-4-17") - ae(str(date), '2000-04-17.00:00:00') - date = Date("01-25") - y, m, d, x, x, x, x, x, x = time.gmtime(time.time()) - ae(str(date), '%s-01-25.00:00:00'%y) - date = Date("2000-04-17.03:45") - ae(str(date), '2000-04-17.03:45:00') - date = Date("08-13.22:13") - ae(str(date), '%s-08-13.22:13:00'%y) - date = Date("11-07.09:32:43") - ae(str(date), '%s-11-07.09:32:43'%y) - date = Date("14:25") - ae(str(date), '%s-%02d-%02d.14:25:00'%(y, m, d)) - date = Date("8:47:11") - ae(str(date), '%s-%02d-%02d.08:47:11'%(y, m, d)) - - def testOffset(self): - ae = self.assertEqual - date = Date("2000-04-17", -5) - ae(str(date), '2000-04-17.00:00:00') - date = Date("01-25", -5) - y, m, d, x, x, x, x, x, x = time.gmtime(time.time()) - ae(str(date), '%s-01-25.00:00:00'%y) - date = Date("2000-04-17.03:45", -5) - ae(str(date), '2000-04-17.08:45:00') - date = Date("08-13.22:13", -5) - ae(str(date), '%s-08-14.03:13:00'%y) - date = Date("11-07.09:32:43", -5) - ae(str(date), '%s-11-07.14:32:43'%y) - date = Date("14:25", -5) - ae(str(date), '%s-%02d-%02d.19:25:00'%(y, m, d)) - date = Date("8:47:11", -5) - ae(str(date), '%s-%02d-%02d.13:47:11'%(y, m, d)) - - # now check calculations - date = Date('2000-01-01') + Interval('- 2y 2m') - ae(str(date), '1997-11-01.00:00:00') - date = Date('2000-01-01 - 2y 2m') - ae(str(date), '1997-11-01.00:00:00') - date = Date('2000-01-01') + Interval('2m') - ae(str(date), '2000-03-01.00:00:00') - date = Date('2000-01-01 + 2m') - ae(str(date), '2000-03-01.00:00:00') - - date = Date('2000-01-01') + Interval('60d') - ae(str(date), '2000-03-01.00:00:00') - date = Date('2001-01-01') + Interval('60d') - ae(str(date), '2001-03-02.00:00:00') - - # time additions - date = Date('2000-02-28.23:59:59') + Interval('00:00:01') - ae(str(date), '2000-02-29.00:00:00') - date = Date('2001-02-28.23:59:59') + Interval('00:00:01') - ae(str(date), '2001-03-01.00:00:00') - - date = Date('2000-02-28.23:58:59') + Interval('00:01:01') - ae(str(date), '2000-02-29.00:00:00') - date = Date('2001-02-28.23:58:59') + Interval('00:01:01') - ae(str(date), '2001-03-01.00:00:00') - - date = Date('2000-02-28.22:58:59') + Interval('01:01:01') - ae(str(date), '2000-02-29.00:00:00') - date = Date('2001-02-28.22:58:59') + Interval('01:01:01') - ae(str(date), '2001-03-01.00:00:00') - - date = Date('2000-02-28.22:58:59') + Interval('00:00:3661') - ae(str(date), '2000-02-29.00:00:00') - date = Date('2001-02-28.22:58:59') + Interval('00:00:3661') - ae(str(date), '2001-03-01.00:00:00') - - # now subtractions - date = Date('2000-01-01') - Interval('- 2y 2m') - ae(str(date), '2002-03-01.00:00:00') - date = Date('2000-01-01') - Interval('2m') - ae(str(date), '1999-11-01.00:00:00') - - date = Date('2000-03-01') - Interval('60d') - ae(str(date), '2000-01-01.00:00:00') - date = Date('2001-03-02') - Interval('60d') - ae(str(date), '2001-01-01.00:00:00') - - date = Date('2000-02-29.00:00:00') - Interval('00:00:01') - ae(str(date), '2000-02-28.23:59:59') - date = Date('2001-03-01.00:00:00') - Interval('00:00:01') - ae(str(date), '2001-02-28.23:59:59') - - date = Date('2000-02-29.00:00:00') - Interval('00:01:01') - ae(str(date), '2000-02-28.23:58:59') - date = Date('2001-03-01.00:00:00') - Interval('00:01:01') - ae(str(date), '2001-02-28.23:58:59') - - date = Date('2000-02-29.00:00:00') - Interval('01:01:01') - ae(str(date), '2000-02-28.22:58:59') - date = Date('2001-03-01.00:00:00') - Interval('01:01:01') - ae(str(date), '2001-02-28.22:58:59') - - date = Date('2000-02-29.00:00:00') - Interval('00:00:3661') - ae(str(date), '2000-02-28.22:58:59') - date = Date('2001-03-01.00:00:00') - Interval('00:00:3661') - ae(str(date), '2001-02-28.22:58:59') - - def testInterval(self): - ae = self.assertEqual - ae(str(Interval('3y')), '+ 3y') - ae(str(Interval('2 y 1 m')), '+ 2y 1m') - ae(str(Interval('1m 25d')), '+ 1m 25d') - ae(str(Interval('-2w 3 d ')), '- 17d') - ae(str(Interval(' - 1 d 2:50 ')), '- 1d 2:50') - ae(str(Interval(' 14:00 ')), '+ 14:00') - ae(str(Interval(' 0:04:33 ')), '+ 0:04:33') - -def suite(): - return unittest.makeSuite(DateTestCase, 'test') - - -# vim: set filetype=python ts=4 sw=4 et si
--- a/test/test_db.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,795 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: test_db.py,v 1.56 2002-09-26 03:04:24 richard Exp $ - -import unittest, os, shutil, time - -from roundup.hyperdb import String, Password, Link, Multilink, Date, \ - Interval, DatabaseError, Boolean, Number -from roundup import date, password -from roundup.indexer import Indexer - -def setupSchema(db, create, module): - status = module.Class(db, "status", name=String()) - status.setkey("name") - user = module.Class(db, "user", username=String(), password=Password(), - assignable=Boolean(), age=Number(), roles=String()) - user.setkey("username") - file = module.FileClass(db, "file", name=String(), type=String(), - comment=String(indexme="yes"), fooz=Password()) - issue = module.IssueClass(db, "issue", title=String(indexme="yes"), - status=Link("status"), nosy=Multilink("user"), deadline=Date(), - foo=Interval(), files=Multilink("file"), assignedto=Link('user')) - session = module.Class(db, 'session', title=String()) - session.disableJournalling() - db.post_init() - if create: - user.create(username="admin", roles='Admin') - status.create(name="unread") - status.create(name="in-progress") - status.create(name="testing") - status.create(name="resolved") - db.commit() - -class MyTestCase(unittest.TestCase): - def tearDown(self): - self.db.close() - if hasattr(self, 'db2'): - self.db2.close() - if os.path.exists('_test_dir'): - shutil.rmtree('_test_dir') - -class config: - DATABASE='_test_dir' - MAILHOST = 'localhost' - MAIL_DOMAIN = 'fill.me.in.' - TRACKER_NAME = 'Roundup issue tracker' - TRACKER_EMAIL = 'issue_tracker@%s'%MAIL_DOMAIN - TRACKER_WEB = 'http://some.useful.url/' - ADMIN_EMAIL = 'roundup-admin@%s'%MAIL_DOMAIN - FILTER_POSITION = 'bottom' # one of 'top', 'bottom', 'top and bottom' - ANONYMOUS_ACCESS = 'deny' # either 'deny' or 'allow' - ANONYMOUS_REGISTER = 'deny' # either 'deny' or 'allow' - MESSAGES_TO_AUTHOR = 'no' # either 'yes' or 'no' - EMAIL_SIGNATURE_POSITION = 'bottom' - -class anydbmDBTestCase(MyTestCase): - def setUp(self): - from roundup.backends import anydbm - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - self.db = anydbm.Database(config, 'admin') - setupSchema(self.db, 1, anydbm) - self.db2 = anydbm.Database(config, 'admin') - setupSchema(self.db2, 0, anydbm) - - def testStringChange(self): - # test set & retrieve - self.db.issue.create(title="spam", status='1') - self.assertEqual(self.db.issue.get('1', 'title'), 'spam') - - # change and make sure we retrieve the correct value - self.db.issue.set('1', title='eggs') - self.assertEqual(self.db.issue.get('1', 'title'), 'eggs') - - # do some commit stuff - self.db.commit() - self.assertEqual(self.db.issue.get('1', 'title'), 'eggs') - self.db.issue.create(title="spam", status='1') - self.db.commit() - self.assertEqual(self.db.issue.get('2', 'title'), 'spam') - self.db.issue.set('2', title='ham') - self.assertEqual(self.db.issue.get('2', 'title'), 'ham') - self.db.commit() - self.assertEqual(self.db.issue.get('2', 'title'), 'ham') - - # make sure we can unset - self.db.issue.set('1', title=None) - self.assertEqual(self.db.issue.get('1', "title"), None) - - def testLinkChange(self): - self.db.issue.create(title="spam", status='1') - self.assertEqual(self.db.issue.get('1', "status"), '1') - self.db.issue.set('1', status='2') - self.assertEqual(self.db.issue.get('1', "status"), '2') - self.db.issue.set('1', status=None) - self.assertEqual(self.db.issue.get('1', "status"), None) - - def testMultilinkChange(self): - u1 = self.db.user.create(username='foo') - u2 = self.db.user.create(username='bar') - self.db.issue.create(title="spam", nosy=[u1]) - self.assertEqual(self.db.issue.get('1', "nosy"), [u1]) - self.db.issue.set('1', nosy=[]) - self.assertEqual(self.db.issue.get('1', "nosy"), []) - self.db.issue.set('1', nosy=[u1,u2]) - self.assertEqual(self.db.issue.get('1', "nosy"), [u1,u2]) - - def testDateChange(self): - self.db.issue.create(title="spam", status='1') - a = self.db.issue.get('1', "deadline") - self.db.issue.set('1', deadline=date.Date()) - b = self.db.issue.get('1', "deadline") - self.db.commit() - self.assertNotEqual(a, b) - self.assertNotEqual(b, date.Date('1970-1-1 00:00:00')) - self.db.issue.set('1', deadline=date.Date()) - self.db.issue.set('1', deadline=None) - self.assertEqual(self.db.issue.get('1', "deadline"), None) - - def testIntervalChange(self): - self.db.issue.create(title="spam", status='1') - a = self.db.issue.get('1', "foo") - self.db.issue.set('1', foo=date.Interval('-1d')) - self.assertNotEqual(self.db.issue.get('1', "foo"), a) - self.db.issue.set('1', foo=None) - self.assertEqual(self.db.issue.get('1', "foo"), None) - - def testBooleanChange(self): - userid = self.db.user.create(username='foo', assignable=1) - self.assertEqual(1, self.db.user.get(userid, 'assignable')) - self.db.user.set(userid, assignable=0) - self.assertEqual(self.db.user.get(userid, 'assignable'), 0) - self.db.user.set(userid, assignable=None) - self.assertEqual(self.db.user.get('1', "assignable"), None) - - def testNumberChange(self): - nid = self.db.user.create(username='foo', age=1) - self.assertEqual(1, self.db.user.get(nid, 'age')) - self.db.user.set('1', age=3) - self.assertNotEqual(self.db.user.get('1', 'age'), 1) - self.db.user.set('1', age=1.0) - self.db.user.set('1', age=None) - self.assertEqual(self.db.user.get('1', "age"), None) - - def testKeyValue(self): - newid = self.db.user.create(username="spam") - self.assertEqual(self.db.user.lookup('spam'), newid) - self.db.commit() - self.assertEqual(self.db.user.lookup('spam'), newid) - self.db.user.retire(newid) - self.assertRaises(KeyError, self.db.user.lookup, 'spam') - - def testNewProperty(self): - self.db.issue.create(title="spam", status='1') - self.db.issue.addprop(fixer=Link("user")) - # force any post-init stuff to happen - self.db.post_init() - props = self.db.issue.getprops() - keys = props.keys() - keys.sort() - self.assertEqual(keys, ['activity', 'assignedto', 'creation', - 'creator', 'deadline', 'files', 'fixer', 'foo', 'id', 'messages', - 'nosy', 'status', 'superseder', 'title']) - self.assertEqual(self.db.issue.get('1', "fixer"), None) - - def testRetire(self): - self.db.issue.create(title="spam", status='1') - b = self.db.status.get('1', 'name') - a = self.db.status.list() - self.db.status.retire('1') - # make sure the list is different - self.assertNotEqual(a, self.db.status.list()) - # can still access the node if necessary - self.assertEqual(self.db.status.get('1', 'name'), b) - self.db.commit() - self.assertEqual(self.db.status.get('1', 'name'), b) - self.assertNotEqual(a, self.db.status.list()) - - def testSerialisation(self): - self.db.issue.create(title="spam", status='1', - deadline=date.Date(), foo=date.Interval('-1d')) - self.db.commit() - assert isinstance(self.db.issue.get('1', 'deadline'), date.Date) - assert isinstance(self.db.issue.get('1', 'foo'), date.Interval) - self.db.user.create(username="fozzy", - password=password.Password('t. bear')) - self.db.commit() - assert isinstance(self.db.user.get('1', 'password'), password.Password) - - def testTransactions(self): - # remember the number of items we started - num_issues = len(self.db.issue.list()) - num_files = self.db.numfiles() - self.db.issue.create(title="don't commit me!", status='1') - self.assertNotEqual(num_issues, len(self.db.issue.list())) - self.db.rollback() - self.assertEqual(num_issues, len(self.db.issue.list())) - self.db.issue.create(title="please commit me!", status='1') - self.assertNotEqual(num_issues, len(self.db.issue.list())) - self.db.commit() - self.assertNotEqual(num_issues, len(self.db.issue.list())) - self.db.rollback() - self.assertNotEqual(num_issues, len(self.db.issue.list())) - self.db.file.create(name="test", type="text/plain", content="hi") - self.db.rollback() - self.assertEqual(num_files, self.db.numfiles()) - for i in range(10): - self.db.file.create(name="test", type="text/plain", - content="hi %d"%(i)) - self.db.commit() - num_files2 = self.db.numfiles() - self.assertNotEqual(num_files, num_files2) - self.db.file.create(name="test", type="text/plain", content="hi") - self.db.rollback() - self.assertNotEqual(num_files, self.db.numfiles()) - self.assertEqual(num_files2, self.db.numfiles()) - - def testDestroyNoJournalling(self): - self.innerTestDestroy(klass=self.db.session) - - def testDestroyJournalling(self): - self.innerTestDestroy(klass=self.db.issue) - - def innerTestDestroy(self, klass): - newid = klass.create(title='Mr Friendly') - n = len(klass.list()) - self.assertEqual(klass.get(newid, 'title'), 'Mr Friendly') - klass.destroy(newid) - self.assertRaises(IndexError, klass.get, newid, 'title') - self.assertNotEqual(len(klass.list()), n) - if klass.do_journal: - self.assertRaises(IndexError, klass.history, newid) - - # now with a commit - newid = klass.create(title='Mr Friendly') - n = len(klass.list()) - self.assertEqual(klass.get(newid, 'title'), 'Mr Friendly') - self.db.commit() - klass.destroy(newid) - self.assertRaises(IndexError, klass.get, newid, 'title') - self.db.commit() - self.assertRaises(IndexError, klass.get, newid, 'title') - self.assertNotEqual(len(klass.list()), n) - if klass.do_journal: - self.assertRaises(IndexError, klass.history, newid) - - # now with a rollback - newid = klass.create(title='Mr Friendly') - n = len(klass.list()) - self.assertEqual(klass.get(newid, 'title'), 'Mr Friendly') - self.db.commit() - klass.destroy(newid) - self.assertNotEqual(len(klass.list()), n) - self.assertRaises(IndexError, klass.get, newid, 'title') - self.db.rollback() - self.assertEqual(klass.get(newid, 'title'), 'Mr Friendly') - self.assertEqual(len(klass.list()), n) - if klass.do_journal: - self.assertNotEqual(klass.history(newid), []) - - def testExceptions(self): - # this tests the exceptions that should be raised - ar = self.assertRaises - - # - # class create - # - # string property - ar(TypeError, self.db.status.create, name=1) - # invalid property name - ar(KeyError, self.db.status.create, foo='foo') - # key name clash - ar(ValueError, self.db.status.create, name='unread') - # invalid link index - ar(IndexError, self.db.issue.create, title='foo', status='bar') - # invalid link value - ar(ValueError, self.db.issue.create, title='foo', status=1) - # invalid multilink type - ar(TypeError, self.db.issue.create, title='foo', status='1', - nosy='hello') - # invalid multilink index type - ar(ValueError, self.db.issue.create, title='foo', status='1', - nosy=[1]) - # invalid multilink index - ar(IndexError, self.db.issue.create, title='foo', status='1', - nosy=['10']) - - # - # key property - # - # key must be a String - ar(TypeError, self.db.file.setkey, 'fooz') - # key must exist - ar(KeyError, self.db.file.setkey, 'fubar') - - # - # class get - # - # invalid node id - ar(IndexError, self.db.issue.get, '99', 'title') - # invalid property name - ar(KeyError, self.db.status.get, '2', 'foo') - - # - # class set - # - # invalid node id - ar(IndexError, self.db.issue.set, '99', title='foo') - # invalid property name - ar(KeyError, self.db.status.set, '1', foo='foo') - # string property - ar(TypeError, self.db.status.set, '1', name=1) - # key name clash - ar(ValueError, self.db.status.set, '2', name='unread') - # set up a valid issue for me to work on - id = self.db.issue.create(title="spam", status='1') - # invalid link index - ar(IndexError, self.db.issue.set, id, title='foo', status='bar') - # invalid link value - ar(ValueError, self.db.issue.set, id, title='foo', status=1) - # invalid multilink type - ar(TypeError, self.db.issue.set, id, title='foo', status='1', - nosy='hello') - # invalid multilink index type - ar(ValueError, self.db.issue.set, id, title='foo', status='1', - nosy=[1]) - # invalid multilink index - ar(IndexError, self.db.issue.set, id, title='foo', status='1', - nosy=['10']) - # invalid number value - ar(TypeError, self.db.user.create, username='foo', age='a') - # invalid boolean value - ar(TypeError, self.db.user.create, username='foo', assignable='true') - nid = self.db.user.create(username='foo') - # invalid number value - ar(TypeError, self.db.user.set, nid, username='foo', age='a') - # invalid boolean value - ar(TypeError, self.db.user.set, nid, username='foo', assignable='true') - - def testJournals(self): - self.db.user.create(username="mary") - self.db.user.create(username="pete") - self.db.issue.create(title="spam", status='1') - self.db.commit() - - # journal entry for issue create - journal = self.db.getjournal('issue', '1') - self.assertEqual(1, len(journal)) - (nodeid, date_stamp, journaltag, action, params) = journal[0] - self.assertEqual(nodeid, '1') - self.assertEqual(journaltag, self.db.user.lookup('admin')) - self.assertEqual(action, 'create') - keys = params.keys() - keys.sort() - self.assertEqual(keys, ['assignedto', 'deadline', 'files', - 'foo', 'messages', 'nosy', 'status', 'superseder', 'title']) - self.assertEqual(None,params['deadline']) - self.assertEqual(None,params['foo']) - self.assertEqual([],params['nosy']) - self.assertEqual('1',params['status']) - self.assertEqual('spam',params['title']) - - # journal entry for link - journal = self.db.getjournal('user', '1') - self.assertEqual(1, len(journal)) - self.db.issue.set('1', assignedto='1') - self.db.commit() - journal = self.db.getjournal('user', '1') - self.assertEqual(2, len(journal)) - (nodeid, date_stamp, journaltag, action, params) = journal[1] - self.assertEqual('1', nodeid) - self.assertEqual('1', journaltag) - self.assertEqual('link', action) - self.assertEqual(('issue', '1', 'assignedto'), params) - - # journal entry for unlink - self.db.issue.set('1', assignedto='2') - self.db.commit() - journal = self.db.getjournal('user', '1') - self.assertEqual(3, len(journal)) - (nodeid, date_stamp, journaltag, action, params) = journal[2] - self.assertEqual('1', nodeid) - self.assertEqual('1', journaltag) - self.assertEqual('unlink', action) - self.assertEqual(('issue', '1', 'assignedto'), params) - - # test disabling journalling - # ... get the last entry - time.sleep(1) - entry = self.db.getjournal('issue', '1')[-1] - (x, date_stamp, x, x, x) = entry - self.db.issue.disableJournalling() - self.db.issue.set('1', title='hello world') - self.db.commit() - entry = self.db.getjournal('issue', '1')[-1] - (x, date_stamp2, x, x, x) = entry - # see if the change was journalled when it shouldn't have been - self.assertEqual(date_stamp, date_stamp2) - time.sleep(1) - self.db.issue.enableJournalling() - self.db.issue.set('1', title='hello world 2') - self.db.commit() - entry = self.db.getjournal('issue', '1')[-1] - (x, date_stamp2, x, x, x) = entry - # see if the change was journalled - self.assertNotEqual(date_stamp, date_stamp2) - - def testPack(self): - id = self.db.issue.create(title="spam", status='1') - self.db.commit() - self.db.issue.set(id, status='2') - self.db.commit() - - # sleep for at least a second, then get a date to pack at - time.sleep(1) - pack_before = date.Date('.') - - # wait another second and add one more entry - time.sleep(1) - self.db.issue.set(id, status='3') - self.db.commit() - jlen = len(self.db.getjournal('issue', id)) - - # pack - self.db.pack(pack_before) - - # we should have the create and last set entries now - self.assertEqual(jlen-1, len(self.db.getjournal('issue', id))) - - def testIDGeneration(self): - id1 = self.db.issue.create(title="spam", status='1') - id2 = self.db2.issue.create(title="eggs", status='2') - self.assertNotEqual(id1, id2) - - def testSearching(self): - self.db.file.create(content='hello', type="text/plain") - self.db.file.create(content='world', type="text/frozz", - comment='blah blah') - self.db.issue.create(files=['1', '2'], title="flebble plop") - self.db.issue.create(title="flebble frooz") - self.db.commit() - self.assertEquals(self.db.indexer.search(['hello'], self.db.issue), - {'1': {'files': ['1']}}) - self.assertEquals(self.db.indexer.search(['world'], self.db.issue), {}) - self.assertEquals(self.db.indexer.search(['frooz'], self.db.issue), - {'2': {}}) - self.assertEquals(self.db.indexer.search(['flebble'], self.db.issue), - {'2': {}, '1': {}}) - - def testReindexing(self): - self.db.issue.create(title="frooz") - self.db.commit() - self.assertEquals(self.db.indexer.search(['frooz'], self.db.issue), - {'1': {}}) - self.db.issue.set('1', title="dooble") - self.db.commit() - self.assertEquals(self.db.indexer.search(['dooble'], self.db.issue), - {'1': {}}) - self.assertEquals(self.db.indexer.search(['frooz'], self.db.issue), {}) - - def testForcedReindexing(self): - self.db.issue.create(title="flebble frooz") - self.db.commit() - self.assertEquals(self.db.indexer.search(['flebble'], self.db.issue), - {'1': {}}) - self.db.indexer.quiet = 1 - self.db.indexer.force_reindex() - self.db.post_init() - self.db.indexer.quiet = 9 - self.assertEquals(self.db.indexer.search(['flebble'], self.db.issue), - {'1': {}}) - - # - # searching tests follow - # - def testFind(self): - self.db.user.create(username='test') - ids = [] - ids.append(self.db.issue.create(status="1", nosy=['1'])) - oddid = self.db.issue.create(status="2", nosy=['2']) - ids.append(self.db.issue.create(status="1", nosy=['1','2'])) - self.db.issue.create(status="3", nosy=['1']) - ids.sort() - - # should match first and third - got = self.db.issue.find(status='1') - got.sort() - self.assertEqual(got, ids) - - # none - self.assertEqual(self.db.issue.find(status='4'), []) - - # should match first three - got = self.db.issue.find(status='1', nosy='2') - got.sort() - ids.append(oddid) - ids.sort() - self.assertEqual(got, ids) - - # none - self.assertEqual(self.db.issue.find(status='4', nosy='3'), []) - - def testStringFind(self): - ids = [] - ids.append(self.db.issue.create(title="spam")) - self.db.issue.create(title="not spam") - ids.append(self.db.issue.create(title="spam")) - ids.sort() - got = self.db.issue.stringFind(title='spam') - got.sort() - self.assertEqual(got, ids) - self.assertEqual(self.db.issue.stringFind(title='fubar'), []) - - def filteringSetup(self): - for user in ( - {'username': 'bleep'}, - {'username': 'blop'}, - {'username': 'blorp'}): - self.db.user.create(**user) - iss = self.db.issue - for issue in ( - {'title': 'issue one', 'status': '2'}, - {'title': 'issue two', 'status': '1'}, - {'title': 'issue three', 'status': '1', 'nosy': ['1','2']}): - self.db.issue.create(**issue) - self.db.commit() - return self.assertEqual, self.db.issue.filter - - def testFilteringString(self): - ae, filt = self.filteringSetup() - ae(filt(None, {'title': 'issue one'}, ('+','id'), (None,None)), ['1']) - ae(filt(None, {'title': 'issue'}, ('+','id'), (None,None)), - ['1','2','3']) - - def testFilteringLink(self): - ae, filt = self.filteringSetup() - ae(filt(None, {'status': '1'}, ('+','id'), (None,None)), ['2','3']) - - def testFilteringMultilink(self): - ae, filt = self.filteringSetup() - ae(filt(None, {'nosy': '2'}, ('+','id'), (None,None)), ['3']) - - def testFilteringMany(self): - ae, filt = self.filteringSetup() - ae(filt(None, {'nosy': '2', 'status': '1'}, ('+','id'), (None,None)), - ['3']) - -class anydbmReadOnlyDBTestCase(MyTestCase): - def setUp(self): - from roundup.backends import anydbm - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - db = anydbm.Database(config, 'admin') - setupSchema(db, 1, anydbm) - self.db = anydbm.Database(config) - setupSchema(self.db, 0, anydbm) - self.db2 = anydbm.Database(config, 'admin') - setupSchema(self.db2, 0, anydbm) - - def testExceptions(self): - # this tests the exceptions that should be raised - ar = self.assertRaises - - # this tests the exceptions that should be raised - ar(DatabaseError, self.db.status.create, name="foo") - ar(DatabaseError, self.db.status.set, '1', name="foo") - ar(DatabaseError, self.db.status.retire, '1') - - -class bsddbDBTestCase(anydbmDBTestCase): - def setUp(self): - from roundup.backends import bsddb - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - self.db = bsddb.Database(config, 'admin') - setupSchema(self.db, 1, bsddb) - self.db2 = bsddb.Database(config, 'admin') - setupSchema(self.db2, 0, bsddb) - -class bsddbReadOnlyDBTestCase(anydbmReadOnlyDBTestCase): - def setUp(self): - from roundup.backends import bsddb - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - db = bsddb.Database(config, 'admin') - setupSchema(db, 1, bsddb) - self.db = bsddb.Database(config) - setupSchema(self.db, 0, bsddb) - self.db2 = bsddb.Database(config, 'admin') - setupSchema(self.db2, 0, bsddb) - - -class bsddb3DBTestCase(anydbmDBTestCase): - def setUp(self): - from roundup.backends import bsddb3 - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - self.db = bsddb3.Database(config, 'admin') - setupSchema(self.db, 1, bsddb3) - self.db2 = bsddb3.Database(config, 'admin') - setupSchema(self.db2, 0, bsddb3) - -class bsddb3ReadOnlyDBTestCase(anydbmReadOnlyDBTestCase): - def setUp(self): - from roundup.backends import bsddb3 - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - db = bsddb3.Database(config, 'admin') - setupSchema(db, 1, bsddb3) - self.db = bsddb3.Database(config) - setupSchema(self.db, 0, bsddb3) - self.db2 = bsddb3.Database(config, 'admin') - setupSchema(self.db2, 0, bsddb3) - - -class gadflyDBTestCase(anydbmDBTestCase): - ''' Gadfly doesn't support multiple connections to the one local - database - ''' - def setUp(self): - from roundup.backends import gadfly - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - config.GADFLY_DATABASE = ('test', config.DATABASE) - os.makedirs(config.DATABASE + '/files') - self.db = gadfly.Database(config, 'admin') - setupSchema(self.db, 1, gadfly) - - def testIDGeneration(self): - id1 = self.db.issue.create(title="spam", status='1') - id2 = self.db.issue.create(title="eggs", status='2') - self.assertNotEqual(id1, id2) - - def testFilteringString(self): - ae, filt = self.filteringSetup() - ae(filt(None, {'title': 'issue one'}, ('+','id'), (None,None)), ['1']) - # XXX gadfly can't do substring LIKE searches - #ae(filt(None, {'title': 'issue'}, ('+','id'), (None,None)), - # ['1','2','3']) - -class gadflyReadOnlyDBTestCase(anydbmReadOnlyDBTestCase): - def setUp(self): - from roundup.backends import gadfly - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - config.GADFLY_DATABASE = ('test', config.DATABASE) - os.makedirs(config.DATABASE + '/files') - db = gadfly.Database(config, 'admin') - setupSchema(db, 1, gadfly) - self.db = gadfly.Database(config) - setupSchema(self.db, 0, gadfly) - - -class sqliteDBTestCase(anydbmDBTestCase): - def setUp(self): - from roundup.backends import sqlite - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - self.db = sqlite.Database(config, 'admin') - setupSchema(self.db, 1, sqlite) - - def testIDGeneration(self): - pass - -class sqliteReadOnlyDBTestCase(anydbmReadOnlyDBTestCase): - def setUp(self): - from roundup.backends import sqlite - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - db = sqlite.Database(config, 'admin') - setupSchema(db, 1, sqlite) - self.db = sqlite.Database(config) - setupSchema(self.db, 0, sqlite) - - -class metakitDBTestCase(anydbmDBTestCase): - def setUp(self): - from roundup.backends import metakit - import weakref - metakit._instances = weakref.WeakValueDictionary() - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - self.db = metakit.Database(config, 'admin') - setupSchema(self.db, 1, metakit) - - def testIDGeneration(self): - id1 = self.db.issue.create(title="spam", status='1') - id2 = self.db.issue.create(title="eggs", status='2') - self.assertNotEqual(id1, id2) - - def testTransactions(self): - # remember the number of items we started - num_issues = len(self.db.issue.list()) - self.db.issue.create(title="don't commit me!", status='1') - self.assertNotEqual(num_issues, len(self.db.issue.list())) - self.db.rollback() - self.assertEqual(num_issues, len(self.db.issue.list())) - self.db.issue.create(title="please commit me!", status='1') - self.assertNotEqual(num_issues, len(self.db.issue.list())) - self.db.commit() - self.assertNotEqual(num_issues, len(self.db.issue.list())) - self.db.rollback() - self.assertNotEqual(num_issues, len(self.db.issue.list())) - self.db.file.create(name="test", type="text/plain", content="hi") - self.db.rollback() - for i in range(10): - self.db.file.create(name="test", type="text/plain", - content="hi %d"%(i)) - self.db.commit() - # TODO: would be good to be able to ensure the file is not on disk after - # a rollback... - self.assertNotEqual(num_files, num_files2) - self.db.file.create(name="test", type="text/plain", content="hi") - self.db.rollback() - -class metakitReadOnlyDBTestCase(anydbmReadOnlyDBTestCase): - def setUp(self): - from roundup.backends import metakit - import weakref - metakit._instances = weakref.WeakValueDictionary() - # remove previous test, ignore errors - if os.path.exists(config.DATABASE): - shutil.rmtree(config.DATABASE) - os.makedirs(config.DATABASE + '/files') - db = metakit.Database(config, 'admin') - setupSchema(db, 1, metakit) - self.db = metakit.Database(config) - setupSchema(self.db, 0, metakit) - -def suite(): - l = [ - unittest.makeSuite(anydbmDBTestCase, 'test'), - unittest.makeSuite(anydbmReadOnlyDBTestCase, 'test') - ] -# return unittest.TestSuite(l) - - from roundup import backends - if hasattr(backends, 'gadfly'): - l.append(unittest.makeSuite(gadflyDBTestCase, 'test')) - l.append(unittest.makeSuite(gadflyReadOnlyDBTestCase, 'test')) - - if hasattr(backends, 'sqlite'): - l.append(unittest.makeSuite(sqliteDBTestCase, 'test')) - l.append(unittest.makeSuite(sqliteReadOnlyDBTestCase, 'test')) - - if hasattr(backends, 'bsddb'): - l.append(unittest.makeSuite(bsddbDBTestCase, 'test')) - l.append(unittest.makeSuite(bsddbReadOnlyDBTestCase, 'test')) - - if hasattr(backends, 'bsddb3'): - l.append(unittest.makeSuite(bsddb3DBTestCase, 'test')) - l.append(unittest.makeSuite(bsddb3ReadOnlyDBTestCase, 'test')) - - if hasattr(backends, 'metakit'): - l.append(unittest.makeSuite(metakitDBTestCase, 'test')) - l.append(unittest.makeSuite(metakitReadOnlyDBTestCase, 'test')) - - return unittest.TestSuite(l) - -# vim: set filetype=python ts=4 sw=4 et si
--- a/test/test_locking.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,51 +0,0 @@ -# Copyright (c) 2002 ekit.com Inc (http://www.ekit-inc.com/) -# -# 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. - -# $Id: test_locking.py,v 1.2 2002-09-10 00:19:54 richard Exp $ - -import os, unittest, tempfile - -from roundup.backends.locking import acquire_lock, release_lock - -class LockingTest(unittest.TestCase): - def setUp(self): - self.path = tempfile.mktemp() - open(self.path, 'w').write('hi\n') - - def test_basics(self): - f = acquire_lock(self.path) - try: - acquire_lock(self.path, block=0) - except: - pass - else: - raise AssertionError, 'no exception' - release_lock(f) - f = acquire_lock(self.path) - release_lock(f) - - def tearDown(self): - os.remove(self.path) - -def suite(): - return unittest.makeSuite(LockingTest) - - -# vim: set filetype=python ts=4 sw=4 et si
--- a/test/test_mailgw.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,827 +0,0 @@ -# -# Copyright (c) 2001 Richard Jones, richard@bofh.asn.au. -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# This module is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. -# -# $Id: test_mailgw.py,v 1.32 2002-09-26 03:04:24 richard Exp $ - -import unittest, cStringIO, tempfile, os, shutil, errno, imp, sys, difflib - -# Note: Should parse emails according to RFC2822 instead of performing a -# literal string comparision. Parsing the messages allows the tests to work for -# any legal serialization of an email. -#try : -# import email -#except ImportError : -# import rfc822 as email - -from roundup.mailgw import MailGW, Unauthorized -from roundup import init, instance - -# TODO: make this output only enough equal lines for context, not all of -# them -class DiffHelper: - def compareStrings(self, s2, s1): - '''Note the reversal of s2 and s1 - difflib.SequenceMatcher wants - the first to be the "original" but in the calls in this file, - the second arg is the original. Ho hum. - ''' - if s1 == s2: - return - - # under python2.[12] we allow a difference of one trailing empty line. - if sys.version_info[0:2] == (2,1): - if s1+'\n' == s2: - return - if sys.version_info[0:2] == (2,2): - if s1 == s2+'\n': - return - - l1=s1.split('\n') - l2=s2.split('\n') - s = difflib.SequenceMatcher(None, l1, l2) - res = ['Generated message not correct (diff follows):'] - for value, s1s, s1e, s2s, s2e in s.get_opcodes(): - if value == 'equal': - for i in range(s1s, s1e): - res.append(' %s'%l1[i]) - elif value == 'delete': - for i in range(s1s, s1e): - res.append('- %s'%l1[i]) - elif value == 'insert': - for i in range(s2s, s2e): - res.append('+ %s'%l2[i]) - elif value == 'replace': - for i, j in zip(range(s1s, s1e), range(s2s, s2e)): - res.append('- %s'%l1[i]) - res.append('+ %s'%l2[j]) - - raise AssertionError, '\n'.join(res) - -class MailgwTestCase(unittest.TestCase, DiffHelper): - count = 0 - schema = 'classic' - def setUp(self): - MailgwTestCase.count = MailgwTestCase.count + 1 - self.dirname = '_test_mailgw_%s'%self.count - try: - shutil.rmtree(self.dirname) - except OSError, error: - if error.errno not in (errno.ENOENT, errno.ESRCH): raise - # create the instance - init.install(self.dirname, 'classic', 'anydbm') - init.initialise(self.dirname, 'sekrit') - # check we can load the package - self.instance = instance.open(self.dirname) - # and open the database - self.db = self.instance.open('admin') - self.db.user.create(username='Chef', address='chef@bork.bork.bork', - roles='User') - self.db.user.create(username='richard', address='richard@test', - roles='User') - self.db.user.create(username='mary', address='mary@test', - roles='User') - self.db.user.create(username='john', address='john@test', - alternate_addresses='jondoe@test\njohn.doe@test', roles='User') - - def tearDown(self): - if os.path.exists(os.environ['SENDMAILDEBUG']): - os.remove(os.environ['SENDMAILDEBUG']) - self.db.close() - try: - shutil.rmtree(self.dirname) - except OSError, error: - if error.errno not in (errno.ENOENT, errno.ESRCH): raise - - def doNewIssue(self): - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: Chef <chef@bork.bork.bork> -To: issue_tracker@your.tracker.email.domain.example -Cc: richard@test -Message-Id: <dummy_test_message_id> -Subject: [issue] Testing... - -This is a test submission of a new issue. -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - nodeid = handler.main(message) - if os.path.exists(os.environ['SENDMAILDEBUG']): - error = open(os.environ['SENDMAILDEBUG']).read() - self.assertEqual('no error', error) - l = self.db.issue.get(nodeid, 'nosy') - l.sort() - self.assertEqual(l, ['3', '4']) - - def testNewIssue(self): - self.doNewIssue() - - def testNewIssueNosy(self): - self.instance.config.ADD_AUTHOR_TO_NOSY = 'yes' - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: Chef <chef@bork.bork.bork> -To: issue_tracker@your.tracker.email.domain.example -Cc: richard@test -Message-Id: <dummy_test_message_id> -Subject: [issue] Testing... - -This is a test submission of a new issue. -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - nodeid = handler.main(message) - if os.path.exists(os.environ['SENDMAILDEBUG']): - error = open(os.environ['SENDMAILDEBUG']).read() - self.assertEqual('no error', error) - l = self.db.issue.get(nodeid, 'nosy') - l.sort() - self.assertEqual(l, ['3', '4']) - - def testAlternateAddress(self): - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: John Doe <john.doe@test> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <dummy_test_message_id> -Subject: [issue] Testing... - -This is a test submission of a new issue. -''') - userlist = self.db.user.list() - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - if os.path.exists(os.environ['SENDMAILDEBUG']): - error = open(os.environ['SENDMAILDEBUG']).read() - self.assertEqual('no error', error) - self.assertEqual(userlist, self.db.user.list(), - "user created when it shouldn't have been") - - def testNewIssueNoClass(self): - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: Chef <chef@bork.bork.bork> -To: issue_tracker@your.tracker.email.domain.example -Cc: richard@test -Message-Id: <dummy_test_message_id> -Subject: Testing... - -This is a test submission of a new issue. -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - if os.path.exists(os.environ['SENDMAILDEBUG']): - error = open(os.environ['SENDMAILDEBUG']).read() - self.assertEqual('no error', error) - - def testNewIssueAuthMsg(self): - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: Chef <chef@bork.bork.bork> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <dummy_test_message_id> -Subject: [issue] Testing... [nosy=mary; assignedto=richard] - -This is a test submission of a new issue. -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - # TODO: fix the damn config - this is apalling - self.db.config.MESSAGES_TO_AUTHOR = 'yes' - handler.main(message) - - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork, mary@test, richard@test -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork, mary@test, richard@test -From: "Chef" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -New submission from Chef <chef@bork.bork.bork>: - -This is a test submission of a new issue. - - ----------- -assignedto: richard -messages: 1 -nosy: Chef, mary, richard -status: unread -title: Testing... -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ -''') - - # BUG - # def testMultipart(self): - # '''With more than one part''' - # see MultipartEnc tests: but if there is more than one part - # we return a multipart/mixed and the boundary contains - # the ip address of the test machine. - - # BUG should test some binary attamchent too. - - def testSimpleFollowup(self): - self.doNewIssue() - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: mary <mary@test> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... - -This is a second followup -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork, richard@test -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork, richard@test -From: "mary" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -mary <mary@test> added the comment: - -This is a second followup - - ----------- -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ -''') - - def testFollowup(self): - self.doNewIssue() - - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: richard <richard@test> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... [assignedto=mary; nosy=+john] - -This is a followup -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - l = self.db.issue.get('1', 'nosy') - l.sort() - self.assertEqual(l, ['3', '4', '5', '6']) - - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork, john@test, mary@test -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork, john@test, mary@test -From: "richard" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -richard <richard@test> added the comment: - -This is a followup - - ----------- -assignedto: -> mary -nosy: +john, mary -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ -''') - - def testFollowupTitleMatch(self): - self.doNewIssue() - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: richard <richard@test> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: Re: Testing... [assignedto=mary; nosy=+john] - -This is a followup -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork, john@test, mary@test -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork, john@test, mary@test -From: "richard" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -richard <richard@test> added the comment: - -This is a followup - - ----------- -assignedto: -> mary -nosy: +john, mary -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ -''') - - def testFollowupNosyAuthor(self): - self.doNewIssue() - self.db.config.ADD_AUTHOR_TO_NOSY = 'yes' - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: john@test -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... - -This is a followup -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork, richard@test -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork, richard@test -From: "john" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -john <john@test> added the comment: - -This is a followup - - ----------- -nosy: +john -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ - -''') - - def testFollowupNosyRecipients(self): - self.doNewIssue() - self.db.config.ADD_RECIPIENTS_TO_NOSY = 'yes' - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: richard@test -To: issue_tracker@your.tracker.email.domain.example -Cc: john@test -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... - -This is a followup -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork -From: "richard" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -richard <richard@test> added the comment: - -This is a followup - - ----------- -nosy: +john -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ - -''') - - def testFollowupNosyAuthorAndCopy(self): - self.doNewIssue() - self.db.config.ADD_AUTHOR_TO_NOSY = 'yes' - self.db.config.MESSAGES_TO_AUTHOR = 'yes' - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: john@test -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... - -This is a followup -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork, john@test, richard@test -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork, john@test, richard@test -From: "john" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -john <john@test> added the comment: - -This is a followup - - ----------- -nosy: +john -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ - -''') - - def testFollowupNoNosyAuthor(self): - self.doNewIssue() - self.instance.config.ADD_AUTHOR_TO_NOSY = 'no' - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: john@test -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... - -This is a followup -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork, richard@test -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork, richard@test -From: "john" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -john <john@test> added the comment: - -This is a followup - - ----------- -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ - -''') - - def testFollowupNoNosyRecipients(self): - self.doNewIssue() - self.instance.config.ADD_RECIPIENTS_TO_NOSY = 'no' - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: richard@test -To: issue_tracker@your.tracker.email.domain.example -Cc: john@test -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... - -This is a followup -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork -From: "richard" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -richard <richard@test> added the comment: - -This is a followup - - ----------- -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ - -''') - - def testNosyRemove(self): - self.doNewIssue() - - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: richard <richard@test> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... [nosy=-richard] - -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - l = self.db.issue.get('1', 'nosy') - l.sort() - self.assertEqual(l, ['3']) - - # NO NOSY MESSAGE SHOULD BE SENT! - self.assert_(not os.path.exists(os.environ['SENDMAILDEBUG'])) - - def testNewUserAuthor(self): - # first without the permission - # heh... just ignore the API for a second ;) - self.db.security.role['Anonymous'].permissions=[] - anonid = self.db.user.lookup('anonymous') - self.db.user.set(anonid, roles='Anonymous') - - self.db.security.hasPermission('Email Registration', anonid) - l = self.db.user.list() - l.sort() - s = '''Content-Type: text/plain; - charset="iso-8859-1" -From: fubar <fubar@bork.bork.bork> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <dummy_test_message_id> -Subject: [issue] Testing... - -This is a test submission of a new issue. -''' - message = cStringIO.StringIO(s) - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - self.assertRaises(Unauthorized, handler.main, message) - m = self.db.user.list() - m.sort() - self.assertEqual(l, m) - - # now with the permission - p = self.db.security.getPermission('Email Registration') - self.db.security.role['Anonymous'].permissions=[p] - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - message = cStringIO.StringIO(s) - handler.main(message) - m = self.db.user.list() - m.sort() - self.assertNotEqual(l, m) - - def testEnc01(self): - self.doNewIssue() - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: mary <mary@test> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... -Content-Type: text/plain; - charset="iso-8859-1" -Content-Transfer-Encoding: quoted-printable - -A message with encoding (encoded oe =F6) - -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork, richard@test -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork, richard@test -From: "mary" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -mary <mary@test> added the comment: - -A message with encoding (encoded oe =F6) - ----------- -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ -''') - - - def testMultipartEnc01(self): - self.doNewIssue() - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: mary <mary@test> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: [issue1] Testing... -Content-Type: multipart/mixed; - boundary="----_=_NextPart_000_01" - -This message is in MIME format. Since your mail reader does not understand -this format, some or all of this message may not be legible. - -------_=_NextPart_000_01 -Content-Type: text/plain; - charset="iso-8859-1" -Content-Transfer-Encoding: quoted-printable - -A message with first part encoded (encoded oe =F6) - -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork, richard@test -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork, richard@test -From: "mary" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -mary <mary@test> added the comment: - -A message with first part encoded (encoded oe =F6) - ----------- -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ -''') - - def testFollowupStupidQuoting(self): - self.doNewIssue() - - message = cStringIO.StringIO('''Content-Type: text/plain; - charset="iso-8859-1" -From: richard <richard@test> -To: issue_tracker@your.tracker.email.domain.example -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -Subject: Re: "[issue1] Testing... " - -This is a followup -''') - handler = self.instance.MailGW(self.instance, self.db) - handler.trapExceptions = 0 - handler.main(message) - - self.compareStrings(open(os.environ['SENDMAILDEBUG']).read(), -'''FROM: roundup-admin@your.tracker.email.domain.example -TO: chef@bork.bork.bork -Content-Type: text/plain -Subject: [issue1] Testing... -To: chef@bork.bork.bork -From: "richard" <issue_tracker@your.tracker.email.domain.example> -Reply-To: "Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -MIME-Version: 1.0 -Message-Id: <followup_dummy_id> -In-Reply-To: <dummy_test_message_id> -X-Roundup-Name: Roundup issue tracker -Content-Transfer-Encoding: quoted-printable - - -richard <richard@test> added the comment: - -This is a followup - - ----------- -status: unread -> chatting -_________________________________________________________________________ -"Roundup issue tracker" <issue_tracker@your.tracker.email.domain.example> -http://your.tracker.url.example/issue1 -_________________________________________________________________________ -''') - -def suite(): - l = [unittest.makeSuite(MailgwTestCase), - ] - return unittest.TestSuite(l) - - -# vim: set filetype=python ts=4 sw=4 et si
--- a/test/test_mailsplit.py Mon Sep 30 01:17:10 2002 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,215 +0,0 @@ -# -# Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/) -# This module is free software, and you may redistribute it and/or modify -# under the same terms as Python, so long as this copyright message and -# disclaimer are retained in their original form. -# -# IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR -# DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING -# OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE -# POSSIBILITY OF SUCH DAMAGE. -# -# BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, -# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS -# FOR A PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" -# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, -# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. -# -# $Id: test_mailsplit.py,v 1.11 2002-09-10 00:19:55 richard Exp $ - -import unittest, cStringIO - -from roundup.mailgw import parseContent - -class MailsplitTestCase(unittest.TestCase): - def testPreComment(self): - s = ''' -blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah -blah blah blah blah blah blah blah blah blah blah blah! - -issue_tracker@foo.com wrote: -> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah -> blah blah blah blah blah blah blah blah blah? blah blah blah blah blah -> blah blah blah blah blah blah blah... blah blah blah blah. blah blah -> blah blah blah blah? blah blah blah blah blah blah! blah blah! -> -> ------- -> nosy: userfoo, userken -> _________________________________________________ -> Roundup issue tracker -> issue_tracker@foo.com -> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/ - --- -blah blah blah signature -userfoo@foo.com -''' - summary, content = parseContent(s, 0, 0) - self.assertEqual(summary, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah') - self.assertEqual(content, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah\nblah blah blah blah blah blah blah blah blah blah blah!') - - - def testPostComment(self): - s = ''' -issue_tracker@foo.com wrote: -> blah blah blah blahblah blahblah blahblah blah blah blah blah blah -> blah -> blah blah blah blah blah blah blah blah blah? blah blah blah blah -> blah -> blah blah blah blah blah blah blah... blah blah blah blah. blah -> blah -> blah blah blah blah? blah blah blah blah blah blah! blah blah! -> -> ------- -> nosy: userfoo, userken -> _________________________________________________ -> Roundup issue tracker -> issue_tracker@foo.com -> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/ - -blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah -blah blah blah blah blah blah blah blah blah blah blah! - --- -blah blah blah signature -userfoo@foo.com -''' - summary, content = parseContent(s, 0, 0) - self.assertEqual(summary, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah') - self.assertEqual(content, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah\nblah blah blah blah blah blah blah blah blah blah blah!') - - - def testKeepCitation(self): - s = ''' -blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah -blah blah blah blah blah blah blah blah blah blah blah! - -issue_tracker@foo.com wrote: -> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah -> blah blah blah blah blah blah blah blah blah? blah blah blah blah blah -> blah blah blah blah blah blah blah... blah blah blah blah. blah blah -> blah blah blah blah? blah blah blah blah blah blah! blah blah! -> -> ------- -> nosy: userfoo, userken -> _________________________________________________ -> Roundup issue tracker -> issue_tracker@foo.com -> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/ - --- -blah blah blah signature -userfoo@foo.com -''' - summary, content = parseContent(s, 1, 0) - self.assertEqual(summary, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah') - self.assertEqual(content, '''\ -blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah -blah blah blah blah blah blah blah blah blah blah blah! - -issue_tracker@foo.com wrote: -> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah -> blah blah blah blah blah blah blah blah blah? blah blah blah blah blah -> blah blah blah blah blah blah blah... blah blah blah blah. blah blah -> blah blah blah blah? blah blah blah blah blah blah! blah blah! -> -> ------- -> nosy: userfoo, userken -> _________________________________________________ -> Roundup issue tracker -> issue_tracker@foo.com -> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/''') - - - def testKeepBody(self): - s = ''' -blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah -blah blah blah blah blah blah blah blah blah blah blah! - -issue_tracker@foo.com wrote: -> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah -> blah blah blah blah blah blah blah blah blah? blah blah blah blah blah -> blah blah blah blah blah blah blah... blah blah blah blah. blah blah -> blah blah blah blah? blah blah blah blah blah blah! blah blah! -> -> ------- -> nosy: userfoo, userken -> _________________________________________________ -> Roundup issue tracker -> issue_tracker@foo.com -> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/ - --- -blah blah blah signature -userfoo@foo.com -''' - summary, content = parseContent(s, 0, 1) - self.assertEqual(summary, 'blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah') - self.assertEqual(content, ''' -blah blah blah blah... blah blah? blah blah blah blah blah. blah blah blah -blah blah blah blah blah blah blah blah blah blah blah! - -issue_tracker@foo.com wrote: -> blah blah blah blahblah blahblah blahblah blah blah blah blah blah blah -> blah blah blah blah blah blah blah blah blah? blah blah blah blah blah -> blah blah blah blah blah blah blah... blah blah blah blah. blah blah -> blah blah blah blah? blah blah blah blah blah blah! blah blah! -> -> ------- -> nosy: userfoo, userken -> _________________________________________________ -> Roundup issue tracker -> issue_tracker@foo.com -> http://foo.com/cgi-bin/roundup.cgi/issue_tracker/ - --- -blah blah blah signature -userfoo@foo.com -''') - - - def testSimple(self): - s = '''testing''' - summary, content = parseContent(s, 0, 0) - self.assertEqual(summary, 'testing') - self.assertEqual(content, 'testing') - - def testParagraphs(self): - s = '''testing\n\ntesting\n\ntesting''' - summary, content = parseContent(s, 0, 0) - self.assertEqual(summary, 'testing') - self.assertEqual(content, 'testing\n\ntesting\n\ntesting') - - def testSimpleFollowup(self): - s = '''>hello\ntesting''' - summary, content = parseContent(s, 0, 0) - self.assertEqual(summary, 'testing') - self.assertEqual(content, 'testing') - - def testSimpleFollowupParas(self): - s = '''>hello\ntesting\n\ntesting\n\ntesting''' - summary, content = parseContent(s, 0, 0) - self.assertEqual(summary, 'testing') - self.assertEqual(content, 'testing\n\ntesting\n\ntesting') - - def testEmpty(self): - s = '' - summary, content = parseContent(s, 0, 0) - self.assertEqual(summary, '') - self.assertEqual(content, '') - - def testIndentationSummary(self): - s = ' Four space indent.\n\n Four space indent.\nNo indent.' - summary, content = parseContent(s, 0, 0) - self.assertEqual(summary, ' Four space indent.') - - def testIndentationContent(self): - s = ' Four space indent.\n\n Four space indent.\nNo indent.' - summary, content = parseContent(s, 0, 0) - self.assertEqual(content, s) - -def suite(): - return unittest.makeSuite(MailsplitTestCase, 'test') - - -# vim: set filetype=python ts=4 sw=4 et si
