implement (and basically test) update_only for import specs
authorTony Cook <tony@develop-help.com>
Tue, 2 Apr 2013 00:53:23 +0000 (11:53 +1100)
committerTony Cook <tony@develop-help.com>
Mon, 8 Apr 2013 00:54:35 +0000 (10:54 +1000)
MANIFEST
site/cgi-bin/modules/BSE/Importer.pm
site/cgi-bin/modules/BSE/Importer/Target/Article.pm
site/cgi-bin/modules/BSE/Importer/Target/Product.pm
t/130-importer/020-article.t [new file with mode: 0644]
t/130-importer/030-product.t [new file with mode: 0644]
t/data/importer/article-simple.csv [new file with mode: 0644]
t/data/importer/product-simple.csv [new file with mode: 0644]

index 8148985b3bb7f1c4bde294d68b7f249c48fd757d..e839289e61710643ad1e07b254cb79eec5744e8b 100644 (file)
--- a/MANIFEST
+++ b/MANIFEST
@@ -905,6 +905,8 @@ t/120-thumb/data/scale40x30fill.png
 t/120-thumb/data/simple.png
 t/130-importer/000-load.t
 t/130-importer/010-csv.t
+t/130-importer/020-article.t
+t/130-importer/030-product.t
 t/900-kwalitee/010-strict-warn.t
 t/900-kwalitee/020-checktemplates.t
 t/900-kwalitee/030-messages.t
@@ -916,7 +918,9 @@ t/cfg/cfg/99end.cfg
 t/cfg/isafile.cfg
 t/cfg/t/varinc.cfg
 t/data/govhouse.jpg
+t/data/importer/article-simple.csv
 t/data/importer/basic.csv
+t/data/importer/product-simple.csv
 t/data/known_pod_issues.txt
 t/data/t101.jpg
 t/t000load.t
index 8ab71c8172632bed1788599e82c0ac5b5acb422c..609a8761713d3f77f5d4fbb908084d68d16840ae 100644 (file)
@@ -2,7 +2,7 @@ package BSE::Importer;
 use strict;
 use Config;
 
-our $VERSION = "1.005";
+our $VERSION = "1.006";
 
 =head1 NAME
 
@@ -73,6 +73,11 @@ C<target> - the target object type, the target module name is this
 value with C<BSE::Importer::Target::> prepended, so a value of
 C<Product> will use the C<BSE::Importer::Target::Product> module.
 
+=item *
+
+C<update_only> - if true, the profile will only update existing
+records.  This may change which fields are required.
+
 =back
 
 The source and target module may include their own configuration in
@@ -103,6 +108,10 @@ C<cfg> - the BSE::Cfg object to use for configuration
 C<callback> - a sub ref to call for messages generated during
 processing.
 
+=item *
+
+C<listen> - a hashref of event handlers.
+
 =back
 
 If the profile is invalid, new() with die with a newline terminated
@@ -188,6 +197,7 @@ EOS
   }
   $self->{file_path} = \@file_path;
 
+  $self->{update_only} = $self->cfg_entry('update_only', 0);
 
   my $source_type = $self->cfg_entry("source", "XLS");
   $self->{source_class} = "BSE::Importer::Source::$source_type";
@@ -208,7 +218,6 @@ EOS
      opts => \%opts,
     );
 
-
   return $self;
 }
 
@@ -340,6 +349,7 @@ sub row {
       defined $value && $value =~ /\S/
        and push @parents, $value;
     }
+    $self->event(row => { entry => \%entry, parents => \@parents });
     $self->{target}->row($self, \%entry, \@parents);
   };
   if ($@) {
@@ -348,6 +358,7 @@ sub row {
     $error =~ tr/\n/ /s;
     push @{$self->{errors}}, $error;
     $self->warn("Error: $error");
+    $self->event(error => { msg => $error });
   }
 }
 
@@ -399,6 +410,21 @@ sub warn {
     and $self->{callback}->($self->{source}->rowid, ": @msg");
 }
 
+=item event()
+
+Called by various parts of the system to report events.  These are
+intended for tools.
+
+=cut
+
+sub event {
+  my ($self, $event, $args) = @_;
+
+  if ($self->{listen}{$event}) {
+    $self->{listen}{$event}->($event, $args);
+  }
+}
+
 =item find_file()
 
   my $fullname = $imp->find_file($filename)
@@ -477,6 +503,16 @@ sub cfg_entry {
   return $self->{cfg}->entry($self->{section}, $key, $default);
 }
 
+=item update_only
+
+Returns true if only performing updates.
+
+=cut
+
+sub update_only {
+  $_[0]{update_only};
+}
+
 1;
 
 =back
index 100595e4154d9abfcce498529b48208687e51c8f..fffef43d2be392d8cbb186c459782e7ef316f3f6 100644 (file)
@@ -6,7 +6,7 @@ use Articles;
 use Products;
 use OtherParents;
 
-our $VERSION = "1.002";
+our $VERSION = "1.003";
 
 =head1 NAME
 
@@ -37,7 +37,10 @@ BSE::Importer::Target::Article - import target for articles.
 
 Provides a target for importing BSE articles.
 
-The import profile must provide a C<title> mapping.
+C<update_only> profiles must provide a mapping for one of C<id> or
+C<linkAlias>.
+
+Non-C<update_only> profiles must provide a mapping for C<title>.
 
 =head1 CONFIGURATION
 
@@ -48,7 +51,8 @@ The following extra configuration can be set in the import profile:
 =item *
 
 C<codes> - set to true to use the configured C<code_field> to update
-existing articles rather than creating new articles.
+existing articles rather than creating new articles.  This is forced
+on when the import profile enables C<update_only>.
 
 =item *
 
@@ -121,12 +125,33 @@ sub new {
 
   my $importer = delete $opts{importer};
 
+  $self->{use_codes} = $importer->cfg_entry('codes', 0);
   my $map = $importer->maps;
-  defined $map->{title}
-    or die "No title mapping found\n";
+  if ($importer->update_only) {
+    my $def_code;
+    my $found_key = 0;
+  KEYS:
+    for my $key ($self->key_fields) {
+      if ($map->{$key}) {
+       $found_key = 1;
+       $def_code = $key;
+       last KEYS;
+      }
+    }
+    $found_key
+      or die "No key field (", join(",", $self->key_fields),
+       ") mapping found\n";
 
-  $self->{use_codes} = $importer->cfg_entry('codes', 0);
-  $self->{code_field} = $importer->cfg_entry("code_field", $self->default_code_field);
+    $self->{code_field} = $importer->cfg_entry("code_field", $def_code);
+    $self->{use_codes} = 1;
+  }
+  else {
+    defined $map->{title}
+      or die "No title mapping found\n";
+
+    $self->{code_field} = $importer->cfg_entry("code_field", $self->default_code_field);
+
+  }
 
   $self->{parent} = $importer->cfg_entry("parent", $self->default_parent);
 
@@ -164,18 +189,26 @@ sub row {
   my ($self, $importer, $entry, $parents) = @_;
 
   $self->xform_entry($importer, $entry);
-  
-  $entry->{parentid} = $self->_find_parent($importer, $self->{parent}, @$parents);
+
+  if (!$importer->update_only || @$parents) {
+    $entry->{parentid} = $self->_find_parent($importer, $self->{parent}, @$parents);
+  }
+
   my $leaf;
   if ($self->{use_codes}) {
     my $leaf_id = $entry->{$self->{code_field}};
-    
-    $leaf = $self->find_leaf($leaf_id);
+
+    if ($importer->{update_only}) {
+      $leaf_id =~ /\S/
+       or die "$self->{code_field} blank for update_only profile\n";
+    }
+
+    $leaf = $self->find_leaf($leaf_id, $importer);
   }
   if ($leaf) {
     @{$leaf}{keys %$entry} = values %$entry;
     $leaf->save;
-    $importer->info("Updated $leaf->{id}: $entry->{title}");
+    $importer->info("Updated $leaf->{id}: ".$leaf->title);
     if ($self->{reset_images}) {
       $leaf->remove_images($importer->cfg);
       $importer->info(" $leaf->{id}: Reset images");
@@ -187,7 +220,7 @@ sub row {
       }
     }
   }
-  else {
+  elsif (!$importer->update_only) {
     $leaf = $self->make_leaf
       (
        $importer, 
@@ -196,6 +229,9 @@ sub row {
       );
     $importer->info("Added $leaf->{id}: $entry->{title}");
   }
+  else {
+    die "No leaf found for $entry->{$self->{code_field}} for update_only profile\n";
+  }
   for my $image_index (1 .. 10) {
     my $file = $entry->{"image${image_index}_file"};
     $file
@@ -240,6 +276,8 @@ sub row {
   }
   $self->fill_leaf($importer, $leaf, %$entry);
   push @{$self->{leaves}}, $leaf;
+
+  $importer->event(endrow => { leaf => $leaf });
 }
 
 =item xform_entry()
@@ -254,17 +292,21 @@ values of C<summary>, C<description> and C<body> to the title.
 sub xform_entry {
   my ($self, $importer, $entry) = @_;
 
-  $entry->{title} =~ /\S/
-    or die "title blank\n";
-
-  $entry->{title} =~ /\n/
-    and die "Title may not contain newlines";
-  $entry->{summary}
-    or $entry->{summary} = $entry->{title};
-  $entry->{description}
-    or $entry->{description} = $entry->{title};
-  $entry->{body}
-    or $entry->{body} = $entry->{title};
+  if (exists $entry->{title}) {
+    $entry->{title} =~ /\S/
+      or die "title blank\n";
+
+    $entry->{title} =~ /\n/
+      and die "Title may not contain newlines";
+  }
+  unless ($importer->update_only) {
+    $entry->{summary}
+      or $entry->{summary} = $entry->{title};
+    $entry->{description}
+      or $entry->{description} = $entry->{title};
+    $entry->{body}
+      or $entry->{body} = $entry->{title};
+  }
 }
 
 =item children_of()
@@ -300,13 +342,16 @@ Find a leave article based on the supplied code.
 =cut
 
 sub find_leaf {
-  my ($self, $leaf_id) = @_;
+  my ($self, $leaf_id, $importer) = @_;
 
-  $leaf_id =~ tr/A-Za-z0-9_/_/cds;
+  $leaf_id =~ s/\A\s+//;
+  $leaf_id =~ s/\s+\z//;
 
   my ($leaf) = Articles->getBy($self->{code_field}, $leaf_id)
     or return;
 
+  $importer->event(find_leaf => { id => $leaf_id, leaf => $leaf });
+
   return $leaf;
 }
 
@@ -321,7 +366,11 @@ Overridden in the product importer to create products.
 sub make_leaf {
   my ($self, $importer, %entry) = @_;
 
-  return bse_make_article(%entry);
+  my $leaf = bse_make_article(%entry);
+
+  $importer->event(make_leaf => { leaf => $leaf });
+
+  return $leaf;
 }
 
 =item fill_leaf()
@@ -407,7 +456,7 @@ sub default_parent { -1 }
 
 Return the default code field.
 
-Overridden by the produuct target to return the C<product_code> field.
+Overridden by the product target to return the C<product_code> field.
 
 =cut
 
@@ -433,6 +482,16 @@ sub parents {
   return @{$_[0]{parents}}
 }
 
+=item key_fields()
+
+Columns that can act as keys.
+
+=cut
+
+sub key_fields {
+  return qw(id linkAlias);
+}
+
 1;
 
 =back
index 0cae338964535ad2bfda3a6ae6ea25e6526db2c5..231351bfc678b59d92dd82c9bd64b0e6ff541176 100644 (file)
@@ -8,7 +8,7 @@ use BSE::TB::ProductOptions;
 use BSE::TB::ProductOptionValues;
 use BSE::TB::PriceTiers;
 
-our $VERSION = "1.002";
+our $VERSION = "1.003";
 
 =head1 NAME
 
@@ -125,8 +125,10 @@ sub new {
   $self->{reset_prodopts} = $importer->cfg_entry("reset_prodopts", 1);
 
   my $map = $importer->maps;
-  defined $map->{retailPrice}
-    or die "No retailPrice mapping found\n";
+  unless ($importer->update_only) {
+    defined $map->{retailPrice}
+      or die "No retailPrice mapping found\n";
+  }
 
   $self->{price_tiers} = +{ map { $_->id => $_ } BSE::TB::PriceTiers->all };
 
@@ -150,18 +152,21 @@ sub xform_entry {
   $self->SUPER::xform_entry($importer, $entry);
 
   if ($self->{use_codes}) {
-    $entry->{product_code} =~ /\S/
-      or die "product_code blank with use_codes\n";
+    $entry->{$self->{code_field}} =~ /\S/
+      or die "$self->{code_field} blank with use_codes\n";
   }
-  $entry->{retailPrice} =~ s/\$//; # in case
 
-  if ($entry->{retailPrice} =~ /\d/) {
-    $self->{price_dollar}
-      and $entry->{retailPrice} *= 100;
-  }
-  else {
-    $importer->warn("Warning: no price");
-    $entry->{retailPrice} = 0;
+  if (exists $entry->{retailPrice}) {
+    $entry->{retailPrice} =~ s/\$//; # in case
+
+    if ($entry->{retailPrice} =~ /\d/) {
+      $self->{price_dollar}
+       and $entry->{retailPrice} *= 100;
+    }
+    else {
+      $importer->warn("Warning: no price");
+      $entry->{retailPrice} = 0;
+    }
   }
 }
 
@@ -195,11 +200,13 @@ Find an existing product matching the code.
 =cut
 
 sub find_leaf {
-  my ($self, $leaf_id) = @_;
+  my ($self, $leaf_id, $importer) = @_;
 
   my ($leaf) = Products->getBy($self->{code_field}, $leaf_id)
     or return;
 
+  $importer->event(find_leaf => { id => $leaf_id, leaf => $leaf });
+
   if ($self->{reset_prodopts}) {
     my @options = $leaf->db_options;
     for my $option (@options) {
@@ -219,7 +226,11 @@ Make a new product.
 sub make_leaf {
   my ($self, $importer, %entry) = @_;
 
-  return bse_make_product(%entry);
+  my $leaf = bse_make_product(%entry);
+
+  $importer->event(make_leaf => { leaf => $leaf });
+
+  return $leaf;
 }
 
 =item fill_leaf()
@@ -290,6 +301,18 @@ Overrides the default code field.
 
 sub default_code_field { "product_code" }
 
+=item key_fields
+
+Fields that can act as key fields.
+
+=cut
+
+sub key_fields {
+  my ($class) = @_;
+
+  return ( $class->SUPER::key_fields(), "product_code" );
+}
+
 1;
 
 =back
diff --git a/t/130-importer/020-article.t b/t/130-importer/020-article.t
new file mode 100644 (file)
index 0000000..3ce249c
--- /dev/null
@@ -0,0 +1,74 @@
+#!perl -w
+use strict;
+use BSE::Test qw(base_url);
+use File::Spec;
+use File::Temp;
+
+use Test::More tests => 5;
+
+BEGIN {
+  unshift @INC, File::Spec->catdir(BSE::Test::base_dir(), "cgi-bin", "modules");
+}
+
+use BSE::Importer;
+use BSE::API qw(bse_init bse_make_article);
+
+my $base_cgi = File::Spec->catdir(BSE::Test::base_dir(), "cgi-bin");
+ok(bse_init($base_cgi),   "initialize api")
+  or print "# failed to bse_init in $base_cgi\n";
+
+my $when = time;
+
+my $cfg = BSE::Cfg->new(path => $base_cgi, extra_text => <<CFG);
+[import profile simple$when]
+map_title=1
+source=CSV
+target=Article
+
+[import profile simpleupdate$when]
+map_linkAlias=1
+map_body=2
+source=CSV
+target=Article
+update_only=1
+sep_char=\\t
+CFG
+
+{
+  my @added;
+
+  my $imp = BSE::Importer->new(cfg => $cfg, profile => "simple$when", callback => sub { note @_ });
+  $imp->process("t/data/importer/article-simple.csv");
+  @added = sort { $a->title cmp $b->title } $imp->leaves;
+
+  is(@added, 2, "imported two articles");
+  is($added[0]->title, "test1", "check title of first import");
+  is($added[1]->title, "test2", "check title of second import");
+
+  END {
+    $_->remove($cfg) for @added;
+  }
+}
+
+{
+  my $testa = bse_make_article(cfg => $cfg, title => "test updates",
+                              linkAlias => "alias$when");
+
+  my $fh = File::Temp->new;
+  my $filename = $fh->filename;
+  print $fh <<EOS;
+linkAlias\tbody
+"alias$when"\t"This is the body text with multiple lines
+
+Yes, multiple lines with CSV!"
+EOS
+  close $fh;
+  my $imp = BSE::Importer->new(cfg => $cfg, profile => "simpleupdate$when", callback => sub { note @_ });
+  $imp->process($filename);
+  my $testb = Articles->getByPkey($testa->id);
+  like($testb->body, qr/This is the body/, "check the body is updated");
+
+  END {
+    $testa->remove($cfg) if $testa;
+  }
+}
diff --git a/t/130-importer/030-product.t b/t/130-importer/030-product.t
new file mode 100644 (file)
index 0000000..af61ccc
--- /dev/null
@@ -0,0 +1,80 @@
+#!perl -w
+use strict;
+use BSE::Test qw(base_url);
+use File::Spec;
+use File::Temp;
+
+use Test::More tests => 7;
+
+BEGIN {
+  unshift @INC, File::Spec->catdir(BSE::Test::base_dir(), "cgi-bin", "modules");
+}
+
+use BSE::Importer;
+use BSE::API qw(bse_init bse_make_product);
+
+my $base_cgi = File::Spec->catdir(BSE::Test::base_dir(), "cgi-bin");
+ok(bse_init($base_cgi), "initialize api")
+  or print "# failed to bse_init in $base_cgi\n";
+
+my $when = time;
+
+my $cfg = BSE::Cfg->new(path => $base_cgi, extra_text => <<CFG);
+[import profile simple$when]
+map_title=1
+map_retailPrice=2
+source=CSV
+price_dollar=1
+
+[import profile simpleupdate$when]
+map_linkAlias=1
+map_body=2
+source=CSV
+target=Product
+update_only=1
+sep_char=\\t
+code_field=linkAlias
+CFG
+
+{
+  my @added;
+
+  my $imp = BSE::Importer->new(cfg => $cfg, profile => "simple$when",
+                              callback => sub { note @_ });
+  $imp->process("t/data/importer/product-simple.csv");
+  @added = sort { $a->title cmp $b->title } $imp->leaves;
+
+  is(@added, 2, "imported two products");
+  is($added[0]->title, "test1", "check title of first import");
+  is($added[0]->retailPrice, 1000, "check price of first import");
+  is($added[1]->title, "test2", "check title of second import");
+  is($added[1]->retailPrice, 800, "check price of second import");
+
+  END {
+    $_->remove($cfg) for @added;
+  }
+}
+
+{
+  my $testa = bse_make_product(cfg => $cfg, title => "test updates",
+                              linkAlias => "P$when", retailPrice => 500);
+
+  my $fh = File::Temp->new;
+  my $filename = $fh->filename;
+  print $fh <<EOS;
+linkAlias\tbody
+"P$when"\t"This is the body text with multiple lines
+
+Yes, multiple lines with CSV!"
+EOS
+  close $fh;
+  my $imp = BSE::Importer->new(cfg => $cfg, profile => "simpleupdate$when",
+                              callback => sub { note @_ });
+  $imp->process($filename);
+  my $testb = Articles->getByPkey($testa->id);
+  like($testb->body, qr/This is the body/, "check the body is updated");
+
+  END {
+    $testa->remove($cfg) if $testa;
+  }
+}
diff --git a/t/data/importer/article-simple.csv b/t/data/importer/article-simple.csv
new file mode 100644 (file)
index 0000000..7de1502
--- /dev/null
@@ -0,0 +1,3 @@
+title
+test1
+test2
diff --git a/t/data/importer/product-simple.csv b/t/data/importer/product-simple.csv
new file mode 100644 (file)
index 0000000..3e100a8
--- /dev/null
@@ -0,0 +1,3 @@
+title,retailPrice
+test1,10.00
+test2,$8.00