Bookbag enhancements in TTOPAC
authorLebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Fri, 16 Sep 2011 22:31:56 +0000 (18:31 -0400)
committerMike Rylander <mrylander@gmail.com>
Mon, 19 Sep 2011 15:28:39 +0000 (11:28 -0400)
Bookbags have descriptions now (and they're reflected in feeds).
Bookbag item notes are editable.
Bookbags can now be sorted by title or author using QP tricks.
You can export a bookbag as CSV.

Signed-off-by: Lebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Signed-off-by: Mike Rylander <mrylander@gmail.com>
17 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/perlmods/lib/OpenILS/Application/Actor/Container.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Reactor.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Reactor/ContainerCSV.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/Account.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/SuperCat.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/SuperCat/Feed.pm
Open-ILS/src/sql/Pg/070.schema.container.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/YYYY.schema.bookbag-goodies.sql [new file with mode: 0644]
Open-ILS/src/templates/opac/myopac/list/print.tt2 [new file with mode: 0644]
Open-ILS/src/templates/opac/myopac/lists.tt2
Open-ILS/src/templates/opac/parts/advanced/search.tt2
Open-ILS/src/templates/opac/parts/filtersort.tt2
Open-ILS/web/css/skin/default/opac/style.css

index 8c587e3..97875ee 100644 (file)
@@ -3648,6 +3648,7 @@ SELECT  usr,
                        <field name="btype" reporter:datatype="text"/>
                        <field name="id" reporter:datatype="id" />
                        <field name="name" reporter:datatype="text"/>
+                       <field name="description" reporter:datatype="text"/>
                        <field name="owner" reporter:datatype="link"/>
                        <field name="pub" reporter:datatype="bool"/>
                        <field name="create_time" reporter:datatype="timestamp" />
@@ -4605,6 +4606,7 @@ SELECT  usr,
                        <field name="btype" reporter:datatype="text"/>
                        <field name="id" reporter:datatype="id" />
                        <field name="name"  reporter:datatype="text"/>
+                       <field name="description" reporter:datatype="text"/>
                        <field name="owner" reporter:datatype="link"/>
                        <field name="pub" reporter:datatype="bool"/>
                        <field name="create_time" reporter:datatype="timestamp" />
@@ -4711,6 +4713,7 @@ SELECT  usr,
                        <field name="btype" reporter:datatype="text"/>
                        <field name="id" reporter:datatype="id" />
                        <field name="name"  reporter:datatype="text"/>
+                       <field name="description" reporter:datatype="text"/>
                        <field name="owner" reporter:datatype="link"/>
                        <field name="pub" reporter:datatype="bool"/>
                        <field name="create_time" reporter:datatype="timestamp" />
@@ -5400,6 +5403,7 @@ SELECT  usr,
                        <field name="btype" reporter:datatype="text"/>
                        <field name="id" reporter:datatype="id" />
                        <field name="name" reporter:datatype="text"/>
+                       <field name="description" reporter:datatype="text"/>
                        <field name="owner" reporter:datatype="link"/>
                        <field name="pub" reporter:datatype="bool"/>
                        <field name="create_time" reporter:datatype="timestamp" />
index ec381ab..537f35f 100644 (file)
@@ -131,29 +131,53 @@ __PACKAGE__->register_method(
 
 sub item_note_cud {
     my($self, $conn, $auth, $class, $note) = @_;
+
+    return new OpenILS::Event("BAD_PARAMS") unless
+        $note->class_name =~ /bucket_item_note$/;
+
     my $e = new_editor(authtoken => $auth, xact => 1);
     return $e->die_event unless $e->checkauth;
 
-    my $meth = 'retrieve_' . $ctypes{$class};
-    my $nclass = $note->class_name;
-    (my $iclass = $nclass) =~ s/n$//og;
+    my $meat = $ctypes{$class} . "_item_note";
+    my $meth = "retrieve_$meat";
 
-    my $db_note = $e->$meth($note->id, {
-        flesh => 2,
-        flesh_fields => {
-            $nclass => ['item'],
-            $iclass => ['bucket']
-        }
-    });
+    my $item_meat = $ctypes{$class} . "_item";
+    my $item_meth = "retrieve_$item_meat";
+
+    my $nhint = $Fieldmapper::fieldmap->{$note->class_name}->{hint};
+    (my $ihint = $nhint) =~ s/n$//og;
+
+    my ($db_note, $item);
+
+    if ($note->isnew) {
+        $db_note = $note;
+
+        $item = $e->$item_meth([
+            $note->item, {
+                flesh => 1, flesh_fields => {$ihint => ["bucket"]}
+            }
+        ]) or return $e->die_event;
+    } else {
+        $db_note = $e->$meth([
+            $note->id, {
+                flesh => 2,
+                flesh_fields => {
+                    $nhint => ['item'],
+                    $ihint => ['bucket']
+                }
+            }
+        ]) or return $e->die_event;
+
+        $item = $db_note->item;
+    }
 
-    if($db_note->item->bucket->owner ne $e->requestor->id) {
-        return $e->die_event unless 
-            $e->allowed('UPDATE_CONTAINER', $db_note->item->bucket);
+    if($item->bucket->owner ne $e->requestor->id) {
+        return $e->die_event unless $e->allowed("UPDATE_CONTAINER");
     }
 
-    $meth = 'create_' . $ctypes{$class} if $note->isnew;
-    $meth = 'update_' . $ctypes{$class} if $note->ischanged;
-    $meth = 'delete_' . $ctypes{$class} if $note->isdeleted;
+    $meth = 'create_' . $meat if $note->isnew;
+    $meth = 'update_' . $meat if $note->ischanged;
+    $meth = 'delete_' . $meat if $note->isdeleted;
     return $e->die_event unless $e->$meth($note);
     $e->commit;
 }
index d31bc2a..c0dbf7b 100644 (file)
@@ -1854,5 +1854,57 @@ sub get_bre_attrs {
     return $attrs;
 }
 
+sub bib_container_items_via_search {
+    my ($class, $container_id, $search_query, $search_args) = @_;
+
+    # First, Use search API to get container items sorted in any way that crad
+    # sorters support.
+    my $search_result = $class->simplereq(
+        "open-ils.search", "open-ils.search.biblio.multiclass.query",
+        $search_args, $search_query
+    );
+    unless ($search_result) {
+        # empty result sets won't cause this, but actual errors should.
+        $logger->warn("bib_container_items_via_search() got nothing from search");
+        return;
+    }
+
+    # Throw away other junk from search, keeping only bib IDs.
+    my $id_list = [ map { pop @$_ } @{$search_result->{ids}} ];
+
+    return [] unless @$id_list;
+
+    # Now get the bib container items themselves...
+    my $e = new OpenILS::Utils::CStoreEditor;
+    unless ($e) {
+        $logger->warn("bib_container_items_via_search() couldn't get cstoreeditor");
+        return;
+    }
+
+    my $items = $e->search_container_biblio_record_entry_bucket_item([
+        {
+            "target_biblio_record_entry" => $id_list,
+            "bucket" => $container_id
+        }, {
+            flesh => 1,
+            flesh_fields => {"cbrebi" => [qw/notes target_biblio_record_entry/]}
+        }
+    ]);
+    unless ($items) {
+        $logger->warn(
+            "bib_container_items_via_search() couldn't get bucket items: " .
+            $e->die_event->{textcode}
+        );
+        return;
+    }
+
+    $e->disconnect;
+
+    # ... and put them in the same order that the search API said they
+    # should be in.
+    my %ordering_hash = map { $_->target_biblio_record_entry->id, $_ } @$items;
+    return [map { $ordering_hash{$_} } @$id_list];
+}
+
 1;
 
index cfeeca6..4214cab 100644 (file)
@@ -5,6 +5,7 @@ use Template;
 use DateTime;
 use DateTime::Format::ISO8601;
 use Unicode::Normalize;
+use XML::LibXML;
 use OpenSRF::Utils qw/:datetime/;
 use OpenSRF::Utils::Logger qw(:logger);
 use OpenILS::Application::AppUtils;
@@ -199,6 +200,22 @@ my $_TT_helpers = {
         return;
     },
 
+    csv_datum => sub {
+        my ($str) = @_;
+
+        if ($str =~ /\,/ || $str =~ /"/) {
+            $str =~ s/"/""/g;
+            $str = '"' . $str . '"';
+        }
+
+        return $str;
+    },
+
+    xml_doc => sub {
+        my ($str) = @_;
+        return $str ? (new XML::LibXML)->parse_string($str) : undef;
+    }
+
 };
 
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Reactor/ContainerCSV.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Trigger/Reactor/ContainerCSV.pm
new file mode 100644 (file)
index 0000000..28a3419
--- /dev/null
@@ -0,0 +1,49 @@
+package OpenILS::Application::Trigger::Reactor::ContainerCSV;
+use base "OpenILS::Application::Trigger::Reactor";
+use strict;
+use warnings;
+use OpenSRF::Utils::Logger qw/:logger/;
+use Data::Dumper;
+$Data::Dumper::Indent = 0;
+my $U = "OpenILS::Application::AppUtils";
+
+sub ABOUT {
+    return q|
+
+The ContainerCSV Reactor Module processes the configured template after
+fetching the items from the bookbag refererred to in $env->{target}
+by using the search api with the query in $env->{params}{search}.  It's
+the event-creator's responsibility to build a correct search query and check
+permissions and do that sort of thing.
+
+open-ils.trigger is not a public service, so that should be ok.
+
+The output, like all processed templates, is stored in the event_output table.
+
+|;
+}
+
+sub handler {
+    my ($self, $env) = @_;
+
+    # get items for bookbags (bib containers of btype bookbag)
+    if ($env->{user_data}{item_search}) {
+        # use the search api for bib container items
+        my $items = $U->bib_container_items_via_search(
+            $env->{target}->id, $env->{user_data}{item_search}
+        ) or return 0;  # TODO build error output for db?
+
+        $env->{items} = $items;
+    } else {
+        # XXX TODO If we're going to support other types of containers here,
+        # we'll probably just want to flesh those containers' items directly,
+        # not involve the search API.
+
+        $logger->warn("ContainerCSV reactor used without item_search, doesn't know what to do."); # XXX
+    }
+
+    return 1 if $self->run_TT($env);
+    return 0;
+}
+
+1;
index 651ac48..b6281ce 100644 (file)
@@ -145,6 +145,7 @@ sub load {
     return $self->load_myopac_update_password if $path =~ m|opac/myopac/update_password|;
     return $self->load_myopac_update_username if $path =~ m|opac/myopac/update_username|;
     return $self->load_myopac_bookbags if $path =~ m|opac/myopac/lists|;
+    return $self->load_myopac_bookbag_print if $path =~ m|opac/myopac/list/print|;
     return $self->load_myopac_bookbag_update if $path =~ m|opac/myopac/list/update|;
     return $self->load_myopac_circ_history if $path =~ m|opac/myopac/circ_history|;
     return $self->load_myopac_hold_history if $path =~ m|opac/myopac/hold_history|;
index 92858eb..4b0477e 100644 (file)
@@ -225,6 +225,34 @@ sub _load_user_with_prefs {
     return undef;
 }
 
+sub _get_bookbag_sort_params {
+    my ($self) = @_;
+
+    # The interface that feeds this cgi parameter will provide a single
+    # argument for a QP sort filter, and potentially a modifier after a period.
+    # In practice this means the "sort" parameter will be something like
+    # "titlesort" or "authorsort.descending".
+    my $sorter = $self->cgi->param("sort") || "";
+    my $modifier;
+    if ($sorter) {
+        $sorter =~ s/^(.*?)\.(.*)/$1/;
+        $modifier = $2 || undef;
+    }
+
+    return ($sorter, $modifier);
+}
+
+sub _prepare_bookbag_container_query {
+    my ($self, $container_id, $sorter, $modifier) = @_;
+
+    return sprintf(
+        "container(bre,bookbag,%d,%s)%s%s",
+        $container_id, $self->editor->authtoken,
+        ($sorter ? " sort($sorter)" : ""),
+        ($modifier ? "#$modifier" : "")
+    );
+}
+
 sub load_myopac_prefs_settings {
     my $self = shift;
 
@@ -1214,6 +1242,7 @@ sub load_myopac_bookbags {
     my $e = $self->editor;
     my $ctx = $self->ctx;
 
+    my ($sorter, $modifier) = $self->_get_bookbag_sort_params;
     $e->xact_begin; # replication...
 
     my $rv = $self->load_mylist;
@@ -1222,17 +1251,14 @@ sub load_myopac_bookbags {
         return $rv;
     }
 
-    my $args = {
-        order_by => {cbreb => 'name'},
-        limit => $self->cgi->param('limit') || 10,
-        offset => $self->cgi->param('offset') || 0
-    };
-
     $ctx->{bookbags} = $e->search_container_biblio_record_entry_bucket(
         [
-            {owner => $self->editor->requestor->id, btype => 'bookbag'},
-            {"flesh" => 1, "flesh_fields" => {"cbreb" => ["items"]}, %$args}
-        ], 
+            {owner => $e->requestor->id, btype => 'bookbag'}, {
+                order_by => {cbreb => 'name'},
+                limit => $self->cgi->param('limit') || 10,
+                offset => $self->cgi->param('offset') || 0
+            }
+        ],
         {substream => 1}
     );
 
@@ -1241,17 +1267,33 @@ sub load_myopac_bookbags {
         return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
     }
     
-    # get unique record IDs
-    my %rec_ids = ();
-    foreach my $bbag (@{$ctx->{bookbags}}) {
-        foreach my $rec_id (
-            map { $_->target_biblio_record_entry } @{$bbag->items}
-        ) {
-            $rec_ids{$rec_id} = 1;
+    # Here is the loop that uses search to find the bib records in each
+    # bookbag.  XXX This should be parallelized.  Should this be done
+    # with OpenSRF::MultiSession, or is it enough to use OpenSRF::AppSession
+    # and call ->request() without calling ->gather() on any of those objects
+    # until all the requests have been issued?
+
+    foreach my $bookbag (@{$ctx->{bookbags}}) {
+        my $query = $self->_prepare_bookbag_container_query(
+            $bookbag->id, $sorter, $modifier
+        );
+
+        # XXX we need to limit the number of records per bbag; use third arg
+        # of bib_container_items_via_search() i think.
+        my $items = $U->bib_container_items_via_search($bookbag->id, $query)
+            or return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
+
+        # Maybe save a little memory by creating only one XML::LibXML::Document
+        # instance for each record, even if record is repeated across bookbags.
+
+        foreach my $rec (map { $_->target_biblio_record_entry } @$items) {
+            next if $ctx->{bookbags_marc_xml}{$rec->id};
+            $ctx->{bookbags_marc_xml}{$rec->id} =
+                (new XML::LibXML)->parse_string($rec->marc);
         }
-    }
 
-    $ctx->{bookbags_marc_xml} = $self->fetch_marc_xml_by_id([keys %rec_ids]);
+        $bookbag->items($items);
+    }
 
     $e->rollback;
     return Apache2::Const::OK;
@@ -1265,19 +1307,33 @@ sub load_myopac_bookbag_update {
     my $e = $self->editor;
     my $cgi = $self->cgi;
 
+    # save_notes is effectively another action, but is passed in a separate
+    # CGI parameter for what are really just layout reasons.
+    $action = 'save_notes' if $cgi->param('save_notes');
     $action ||= $cgi->param('action');
+
     $list_id ||= $cgi->param('list');
 
     my @add_rec = $cgi->param('add_rec') || $cgi->param('record');
     my @selected_item = $cgi->param('selected_item');
     my $shared = $cgi->param('shared');
     my $name = $cgi->param('name');
+    my $description = $cgi->param('description');
     my $success = 0;
     my $list;
 
-    if($action eq 'create') {
+    # This url intentionally leaves off the edit_notes parameter, but
+    # may need to add some back in for paging.
+
+    my $url = "https://" . $self->apache->hostname .
+        $self->ctx->{opac_root} . "/myopac/lists?";
+
+    $url .= 'sort=' . uri_escape($cgi->param("sort")) if $cgi->param("sort");
+
+    if ($action eq 'create') {
         $list = Fieldmapper::container::biblio_record_entry_bucket->new;
         $list->name($name);
+        $list->description($description);
         $list->owner($e->requestor->id);
         $list->btype('bookbag');
         $list->pub($shared ? 't' : 'f');
@@ -1352,13 +1408,144 @@ sub load_myopac_bookbag_update {
             );
             last unless $success;
         }
+    } elsif ($action eq 'save_notes') {
+        $success = $self->update_bookbag_item_notes;
     }
 
-    return $self->generic_redirect if $success;
+    return $self->generic_redirect($url) if $success;
+
+    # XXX FIXME Bucket failure doesn't have a page to show the user anything
+    # right now. User just sees a 404 currently.
 
     $self->ctx->{bucket_action} = $action;
     $self->ctx->{bucket_action_failed} = 1;
     return Apache2::Const::OK;
 }
 
-1
+sub update_bookbag_item_notes {
+    my ($self) = @_;
+    my $e = $self->editor;
+
+    my @note_keys = grep /^note-\d+/, keys(%{$self->cgi->Vars});
+    my @item_keys = grep /^item-\d+/, keys(%{$self->cgi->Vars});
+
+    # We're going to leverage an API call that's already been written to check
+    # permissions appropriately.
+
+    my $a = create OpenSRF::AppSession("open-ils.actor");
+    my $method = "open-ils.actor.container.item_note.cud";
+
+    for my $note_key (@note_keys) {
+        my $note;
+
+        my $id = ($note_key =~ /(\d+)/)[0];
+
+        if (!($note =
+            $e->retrieve_container_biblio_record_entry_bucket_item_note($id))) {
+            my $event = $e->die_event;
+            $self->apache->log->warn(
+                "error retrieving cbrebin id $id, got event " .
+                $event->{textcode}
+            );
+            $a->kill_me;
+            $self->ctx->{bucket_action_event} = $event;
+            return;
+        }
+
+        if (length($self->cgi->param($note_key))) {
+            $note->ischanged(1);
+            $note->note($self->cgi->param($note_key));
+        } else {
+            $note->isdeleted(1);
+        }
+
+        my $r = $a->request($method, $e->authtoken, "biblio", $note)->gather(1);
+
+        if (defined $U->event_code($r)) {
+            $self->apache->log->warn(
+                "attempt to modify cbrebin " . $note->id .
+                " returned event " .  $r->{textcode}
+            );
+            $e->rollback;
+            $a->kill_me;
+            $self->ctx->{bucket_action_event} = $r;
+            return;
+        }
+    }
+
+    for my $item_key (@item_keys) {
+        my $id = int(($item_key =~ /(\d+)/)[0]);
+        my $text = $self->cgi->param($item_key);
+
+        chomp $text;
+        next unless length $text;
+
+        my $note = new Fieldmapper::container::biblio_record_entry_bucket_item_note;
+        $note->isnew(1);
+        $note->item($id);
+        $note->note($text);
+
+        my $r = $a->request($method, $e->authtoken, "biblio", $note)->gather(1);
+
+        if (defined $U->event_code($r)) {
+            $self->apache->log->warn(
+                "attempt to create cbrebin for item " . $note->item .
+                " returned event " .  $r->{textcode}
+            );
+            $e->rollback;
+            $a->kill_me;
+            $self->ctx->{bucket_action_event} = $r;
+            return;
+        }
+    }
+
+    $a->kill_me;
+    return 1;   # success
+}
+
+sub load_myopac_bookbag_print {
+    my ($self) = @_;
+
+    $self->apache->content_type("text/plain; encoding=utf8");
+
+    my $id = int($self->cgi->param("list"));
+
+    my ($sorter, $modifier) = $self->_get_bookbag_sort_params;
+
+    my $item_search =
+        $self->_prepare_bookbag_container_query($id, $sorter, $modifier);
+
+    my $bbag;
+
+    # Get the bookbag object itself, assuming we're allowed to.
+    if ($self->editor->allowed("VIEW_CONTAINER")) {
+
+        $bbag = $self->editor->retrieve_container_biblio_record_entry_bucket($id) or return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
+    } else {
+        my $bookbags = $self->editor->search_container_biblio_record_entry_bucket(
+            {
+                "id" => $id,
+                "-or" => {
+                    "owner" => $self->editor->requestor->id,
+                    "pub" => "t"
+                }
+            }
+        ) or return Apache2::Const::HTTP_INTERNAL_SERVER_ERROR;
+
+        $bbag = pop @$bookbags;
+    }
+
+    # If we have a bookbag we're allowed to look at, issue the A/T event
+    # to get CSV, passing as a user param that search query we built before.
+    if ($bbag) {
+        $self->ctx->{csv} = $U->fire_object_event(
+            undef, "container.biblio_record_entry_bucket.csv",
+            $bbag, $self->editor->requestor->home_ou,
+            undef, {"item_search" => $item_search}
+        );
+    }
+
+    return Apache2::Const::OK;
+}
+
+1;
index e148565..4ca4b9d 100644 (file)
@@ -1024,6 +1024,7 @@ sub bookbag_feed {
     $feed->id($bucket_tag);
 
     $feed->title("Items in Book Bag [".$bucket->name."]");
+    $feed->description($bucket->description || ("Items in Book Bag [".$bucket->name."]"));
     $feed->creator($host);
     $feed->update_ts();
 
index cd11a75..fde04eb 100644 (file)
@@ -322,8 +322,11 @@ sub title {
        my $self = shift;
        my $text = shift;
        $self->_create_node('/rss/channel',undef,'title', $text);
-       # RSS2 demands a /channel/description element; just dupe title until we give
-       # users the ability to provide a description for their bookbags
+}
+
+sub description {
+       my $self = shift;
+       my $text = shift;
        $self->_create_node('/rss/channel',undef,'description', $text);
 }
 
index 6bbd17c..0e21c5f 100644 (file)
@@ -35,6 +35,7 @@ CREATE TABLE container.copy_bucket (
                                                                INITIALLY DEFERRED,
        name            TEXT                            NOT NULL,
        btype           TEXT                            NOT NULL DEFAULT 'misc' REFERENCES container.copy_bucket_type (code) DEFERRABLE INITIALLY DEFERRED,
+       description TEXT,
        pub             BOOL                            NOT NULL DEFAULT FALSE,
        create_time     TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
        CONSTRAINT cb_name_once_per_owner UNIQUE (owner,name,btype)
@@ -88,6 +89,7 @@ CREATE TABLE container.call_number_bucket (
                                INITIALLY DEFERRED,
        name    TEXT    NOT NULL,
        btype   TEXT    NOT NULL DEFAULT 'misc' REFERENCES container.call_number_bucket_type (code) DEFERRABLE INITIALLY DEFERRED,
+       description TEXT,
        pub     BOOL    NOT NULL DEFAULT FALSE,
        create_time     TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
        CONSTRAINT cnb_name_once_per_owner UNIQUE (owner,name,btype)
@@ -142,6 +144,7 @@ CREATE TABLE container.biblio_record_entry_bucket (
                                INITIALLY DEFERRED,
        name    TEXT    NOT NULL,
        btype   TEXT    NOT NULL DEFAULT 'misc' REFERENCES container.biblio_record_entry_bucket_type (code) DEFERRABLE INITIALLY DEFERRED,
+       description TEXT,
        pub     BOOL    NOT NULL DEFAULT FALSE,
        create_time     TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
        CONSTRAINT breb_name_once_per_owner UNIQUE (owner,name,btype)
@@ -194,6 +197,7 @@ CREATE TABLE container.user_bucket (
                                INITIALLY DEFERRED,
        name    TEXT    NOT NULL,
        btype   TEXT    NOT NULL DEFAULT 'misc' REFERENCES container.user_bucket_type (code) DEFERRABLE INITIALLY DEFERRED,
+       description TEXT,
        pub     BOOL    NOT NULL DEFAULT FALSE,
        create_time     TIMESTAMP WITH TIME ZONE        NOT NULL DEFAULT NOW(),
        CONSTRAINT ub_name_once_per_owner UNIQUE (owner,name,btype)
index 0dd096c..42e029c 100644 (file)
@@ -10046,6 +10046,56 @@ INSERT INTO action_trigger.environment ( event_def, path) VALUES (
     ,( 47, 'record.queue.owner')
 ;
 
+INSERT INTO action_trigger.hook (key, core_type, description, passive)
+VALUES (
+    'container.biblio_record_entry_bucket.csv',
+    'cbreb',
+    oils_i18n_gettext(
+        'container.biblio_record_entry_bucket.csv',
+        'Produce a CSV file representing a bookbag',
+        'ath',
+        'description'
+    ),
+    FALSE
+);
+
+INSERT INTO action_trigger.reactor (module, description)
+VALUES (
+    'ContainerCSV',
+    oils_i18n_gettext(
+        'ContainerCSV',
+        'Facilitates produce a CSV file representing a bookbag by introducing an "items" variable into the TT environment, sorted as dictated according to user params',
+        'atr',
+        'description'
+    )
+);
+
+INSERT INTO action_trigger.event_definition (
+    id, active, owner,
+    name, hook, reactor,
+    validator, template
+) VALUES (
+    48, TRUE, 1,
+    'Bookbag CSV', 'container.biblio_record_entry_bucket.csv', 'ContainerCSV',
+    'NOOP_True',
+$$
+[%-
+# target is the bookbag itself. The 'items' variable does not need to be in
+# the environment because a special reactor will take care of filling it in.
+
+FOR item IN items;
+    bibxml = helpers.xml_doc(item.target_biblio_record_entry.marc);
+    title = "";
+    FOR part IN bibxml.findnodes('//*[@tag="245"]/*[@code="a" or @code="b"]');
+        title = title _ part.textContent;
+    END;
+    author = bibxml.findnodes('//*[@tag="100"]/*[@code="a"]').textContent;
+
+    helpers.csv_datum(title) %],[% helpers.csv_datum(author) %],[% FOR note IN item.notes; helpers.csv_datum(note.note); ","; END; "\n";
+END -%]
+$$
+);
+
 SELECT SETVAL('authority.control_set_id_seq'::TEXT, 100);
 SELECT SETVAL('authority.control_set_authority_field_id_seq'::TEXT, 1000);
 SELECT SETVAL('authority.control_set_bib_field_id_seq'::TEXT, 1000);
diff --git a/Open-ILS/src/sql/Pg/upgrade/YYYY.schema.bookbag-goodies.sql b/Open-ILS/src/sql/Pg/upgrade/YYYY.schema.bookbag-goodies.sql
new file mode 100644 (file)
index 0000000..df4e456
--- /dev/null
@@ -0,0 +1,69 @@
+-- Evergreen DB patch YYYY.schema.bookbag-goodies.sql
+
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('YYYY', :eg_version);
+
+ALTER TABLE container.biblio_record_entry_bucket
+    ADD COLUMN description TEXT;
+
+ALTER TABLE container.call_number_bucket
+    ADD COLUMN description TEXT;
+
+ALTER TABLE container.copy_bucket
+    ADD COLUMN description TEXT;
+
+ALTER TABLE container.user_bucket
+    ADD COLUMN description TEXT;
+
+INSERT INTO action_trigger.hook (key, core_type, description, passive)
+VALUES (
+    'container.biblio_record_entry_bucket.csv',
+    'cbreb',
+    oils_i18n_gettext(
+        'container.biblio_record_entry_bucket.csv',
+        'Produce a CSV file representing a bookbag',
+        'ath',
+        'description'
+    ),
+    FALSE
+);
+
+INSERT INTO action_trigger.reactor (module, description)
+VALUES (
+    'ContainerCSV',
+    oils_i18n_gettext(
+        'ContainerCSV',
+        'Facilitates produce a CSV file representing a bookbag by introducing an "items" variable into the TT environment, sorted as dictated according to user params',
+        'atr',
+        'description'
+    )
+);
+
+INSERT INTO action_trigger.event_definition (
+    id, active, owner,
+    name, hook, reactor,
+    validator, template
+) VALUES (
+    48, TRUE, 1,
+    'Bookbag CSV', 'container.biblio_record_entry_bucket.csv', 'ContainerCSV',
+    'NOOP_True',
+$$
+[%-
+# target is the bookbag itself. The 'items' variable does not need to be in
+# the environment because a special reactor will take care of filling it in.
+
+FOR item IN items;
+    bibxml = helpers.xml_doc(item.target_biblio_record_entry.marc);
+    title = "";
+    FOR part IN bibxml.findnodes('//*[@tag="245"]/*[@code="a" or @code="b"]');
+        title = title _ part.textContent;
+    END;
+    author = bibxml.findnodes('//*[@tag="100"]/*[@code="a"]').textContent;
+
+    helpers.csv_datum(title) %],[% helpers.csv_datum(author) %],[% FOR note IN item.notes; helpers.csv_datum(note.note); ","; END; "\n";
+END -%]
+$$
+);
+
+COMMIT;
diff --git a/Open-ILS/src/templates/opac/myopac/list/print.tt2 b/Open-ILS/src/templates/opac/myopac/list/print.tt2
new file mode 100644 (file)
index 0000000..fa3988f
--- /dev/null
@@ -0,0 +1 @@
+[%- ctx.csv.template_output.data -%]
index 0c22f6d..863bb1a 100644 (file)
@@ -27,6 +27,8 @@
             <tr>
                 <td>
                     <label for="list_create_name">[% l('Enter the name of the new list:') %]</label>
+                </td>
+                <td>
                     <input id="list_create_name" type="text" name="name" />
                     <input type="hidden" name="action" value="create" />
                 </td>
                         class="opac-button" />
                 </td>
             </tr>
+            <tr>
+                <td class="text-right-top">
+                    <label for="list_description">[% l("List description (optional):") %]</label>
+                </td>
+                <td colspan="3">
+                    <textarea cols="40" rows="3" name="description"
+                        id="list_description"></textarea>
+                </td>
         </table>
     </form>
 
+    <h2>[% l("Your existing lists") %]</h2>
+    <p>
+        <form method="GET">
+            <label for="opac.result.sort">[% l("Sort list items by: ") %]</label>
+            [% INCLUDE "opac/parts/filtersort.tt2"
+                value=CGI.param('sort') %]
+            <input type="submit" value="[% l('Sort') %]" />
+        </form>
+    </p>
+
     [% INCLUDE "opac/parts/anon_list.tt2" %]
     [% IF ctx.bookbags.size %]
     <div id='acct_lists_prime'>
                         <input type="submit" value="[% l('Delete List') %]" />
                     </div>
                 </form>
+                <form action="[% ctx.opac_root %]/myopac/list/print" method="POST" target="_blank">
+                    <div class="bookbag-controls">
+                        <input type="hidden" name="list" value="[% bbag.id %]" />
+                        <input type="hidden" name="sort" value="[% CGI.param('sort') | html %]" />
+                        <input type="submit" value="[% l('Download CSV') %]" />
+                    </div>
+                </form>
                 <div class="bookbag-controls">
                     <big><strong>
                     [% IF bbag.pub == 't' %]
                     [% bbag.name | html %]
                     [% END %]
                     </strong></big>
+                    [% IF bbag.description %]<br /><em>[% bbag.description | html %]</em>[% END %]
                 </div>
                 <div class="bookbag-controls">
                     [% IF bbag.pub == 't'; %]
             </div>
             <form action="[% ctx.opac_root %]/myopac/list/update" method="POST">
             <input type="hidden" name="list" value="[% bbag.id %]" />
+            <input type="hidden" name="sort" value="[% CGI.param('sort') | uri %]" />
             <table cellpadding='0' cellspacing='0' border='0'>
                 <thead id="acct_list_header">
                     <tr>
                                     inputs[i].checked = this.checked;}"/>
 
                         </td>
-                        <td width="49%" style="padding-left: 5px;">[% l('Title') %]</td>
-                        <td width="49%">[% l('Author(s)') %]</td>
+                        <td width="32%" style="padding-left: 5px;">
+                            <a href="[% ctx.opac_root %]/myopac/lists?sort=titlesort">[% l('Title') %]</a>
+                        </td>
+                        <td width="33%">
+                            <a href="[% ctx.opac_root %]/myopac/lists?sort=authorsort">[% l('Author(s)') %]</a>
+                        </td>
+                        <td width="32%">
+                            [% l('Notes') %]
+                            [% IF CGI.param("edit_notes") != bbag.id %]
+                            | <a href="[% ctx.opac_root %]/myopac/lists?[% IF CGI.param('sort'); 'sort='; (CGI.param('sort')) | uri; '&amp;'; END %]edit_notes=[% bbag.id %]">[% l('Edit') %]</a>
+                            [% END %]
+                        </td>
                         <td width="1%" class="nowrap">
                             <select class="selector_actions_for_list" name="action">
                                 <option>[% l('-- Actions for this list --') %]</option>
                     </td></tr>
                     [% END %]
                     [% FOR item IN bbag.items;
-                        rec_id = item.target_biblio_record_entry;
+                        rec_id = item.target_biblio_record_entry.id;
                         attrs = {marc_xml => ctx.bookbags_marc_xml.$rec_id};
                         PROCESS get_marc_attrs args=attrs %]
-                    <tr>
+                    <tr class="bookbag-item-row">
                         <td class="item_list_padding" style="padding-left: 10px;">
                             <input type="checkbox" name="selected_item" value="[% item.id %]" bbag='[% bbag.id %]'/>
                         </td>
                                 authorquery = attrs.author | replace('[,\.:;]', '');
                                 mkurl(ctx.opac_root _ '/results', {qtype => 'author', query => authorquery}, ['page'])
                                 -%]">[% attrs.author | html %]</a>
+                        [% IF CGI.param("edit_notes") == bbag.id %]
+                        <td class="opac-auto-097b">
+                            [% FOR note IN item.notes %]
+                            <input type="text" name="note-[% note.id %]" value="[% note.note | html %]" />
+                            [% END %]
+                            <input type="text" name="item-[% item.id %]" />
+                        </td>
+                        [% ELSE %]
+                        <td class="opac-auto-097b">
+                            [% FOR note IN item.notes %]
+                            <div>[% note.note | html %]</div>
+                            [% END %]
+                        </td>
+                        [% END %]
+                    </tr>
+                    [% END %]
+                    [% IF CGI.param("edit_notes") == bbag.id %]
+                    <tr>
+                        <td colspan="3"><!-- All space left of notes column --></td>
+                        <td>
+                            <input type="submit" name="save_notes" value="[% l('Save Notes') %]" />
                         </td>
                     </tr>
                     [% END %]
                 </tbody>
             </table>
             </form>
-            <br /><br />
+            <hr /><br />
         </div>
         [% END %]
     </div>
index 6b97540..1c28bb5 100644 (file)
@@ -51,7 +51,7 @@
                 <tr>
                     <td align='center' width='100%'>
                         [% INCLUDE "opac/parts/filtersort.tt2"
-                            value=CGI.param('sort') %]
+                            value=CGI.param('sort') class='results_header_sel' %]
                     </td>
                 </tr>
               </table>
index 664be17..74d9c8a 100644 (file)
@@ -1,16 +1,15 @@
-<select class="results_header_sel" id='opac.result.sort' name="sort"
-    [% IF submit_on_change %]onchange='this.form.submit()'[% END %]>
+<select [% class ? ('class="' _ class _ '"') : '' %] id='opac.result.sort' name="[% name || 'sort' %]" [% IF submit_on_change %]onchange='this.form.submit()'[% END %]>
     <option value=''>[% l("Sort by Relevance") %]</option>
     <optgroup label='[% l("Sort by Title") %]'>
         <option value='titlesort'[% value == 'titlesort' ? ' selected="selected"' : '' %]>[% l("Title: A to Z") %]</option>
-        <option value='titlesort.desc'[% value == 'titlesort.desc' ? ' selected="selected"' : '' %]>[% l("Title: Z to A") %]</option>
+        <option value='titlesort.descending'[% value == 'titlesort.descending' ? ' selected="selected"' : '' %]>[% l("Title: Z to A") %]</option>
     </optgroup>
     <optgroup label='[% l("Sort by Author") %]'>
         <option value='authorsort'[% value == 'authorsort' ? ' selected="selected"' : '' %]>[% l("Author: A to Z") %]</option>
-        <option value='authorsort.desc'[% value == 'authorsort.desc' ? ' selected="selected"' : '' %]>[% l("Author: Z to A") %]</option>
+        <option value='authorsort.descending'[% value == 'authorsort.descending' ? ' selected="selected"' : '' %]>[% l("Author: Z to A") %]</option>
     </optgroup>
     <optgroup label='[% l("Sort by Publication Date") %]'>
-        <option value='pubdate.desc'[% value == 'pubdate.desc' ? ' selected="selected"' : '' %]>[% l("Date: Newest to Oldest") %]</option>
+        <option value='pubdate.descending'[% value == 'pubdate.descending' ? ' selected="selected"' : '' %]>[% l("Date: Newest to Oldest") %]</option>
         <option value='pubdate'[% value == 'pubdate' ? ' selected="selected"' : '' %]>[% l("Date: Oldest to Newest") %]</option>
     </optgroup>
 </select>
index 3664dff..6a0cec6 100644 (file)
@@ -966,6 +966,7 @@ a.dash-link:hover { text-decoration: underline !important; }
 .hold-editor-controls a { padding-left: 2em; }
 
 .text-right { text-align: right; }
+.text-right-top { text-align: right; vertical-align: top; }
 .rdetail-author-div { padding-bottom: 10px; }
 
 .invisible { visibility: hidden; }
@@ -1030,3 +1031,4 @@ a.opac-button {
 }
 
 
+.bookbag-item-row td { vertical-align: top; }