1 package Text::Tradition::Directory;
7 use Encode qw/ decode_utf8 /;
8 use KiokuDB::GC::Naive;
10 use KiokuDB::TypeMap::Entry::Naive;
12 use Text::Tradition::Error;
15 use KiokuX::User::Util qw(crypt_password);
16 use Text::Tradition::Store;
17 use Text::Tradition::User;
18 use Text::Tradition::TypeMap::Entry;
20 extends 'KiokuX::Model';
22 use vars qw/ $VERSION /;
27 Text::Tradition::Directory - a KiokuDB interface for storing and retrieving
28 traditions and their owners
32 use Text::Tradition::Directory;
33 my $d = Text::Tradition::Directory->new(
34 'dsn' => 'dbi:SQLite:mytraditions.db',
35 'extra_args' => { 'create' => 1 },
38 my $tradition = Text::Tradition->new( @args );
39 my $stemma = $tradition->add_stemma( dotfile => $dotfile ); # if Analysis module installed
40 $d->save_tradition( $tradition );
42 foreach my $id ( $d->traditions ) {
43 print $d->tradition( $id )->name;
47 my $userstore = Text::Tradition::UserStore->new(dsn => 'dbi:SQLite:foo.db');
48 my $newuser = $userstore->add_user({ username => 'fred',
49 password => 'somepassword' });
51 my $fetchuser = $userstore->find_user({ username => 'fred' });
52 if($fetchuser->check_password('somepassword')) {
53 ## login user or .. whatever
56 my $user = $userstore->deactivate_user({ username => 'fred' });
58 ## shouldnt be able to login etc
63 Text::Tradition::Directory is an interface for storing and retrieving text
64 traditions and all their data, including an associated stemma hypothesis
65 and a user who has ownership rights to the tradition data. It is an
66 instantiation of a KiokuDB::Model, storing traditions and associated
69 The Text::Tradition::Directory package also includes the
70 L<Text::Tradition::User> class for user objects, and the
71 L<Text::Tradition::Ownership> role which extends the Text::Tradition class
72 to handle user ownership.
78 Constant for the minimum password length when validating passwords,
83 has MIN_PASS_LEN => ( is => 'ro', isa => 'Num', default => sub { 8 } );
89 Returns a Directory object.
93 Returns a hashref mapping of ID => name for all traditions in the directory.
95 =head2 tradition( $id )
97 Returns the Text::Tradition object of the given ID.
99 =head2 save( $tradition )
101 Writes the given tradition to the database, returning its ID.
103 =head2 delete( $tradition )
105 Deletes the given tradition object from the database.
106 WARNING!! Garbage collection does not yet work. Use this sparingly.
114 use_ok 'Text::Tradition::Directory';
116 my $fh = File::Temp->new();
117 my $file = $fh->filename;
119 my $dsn = "dbi:SQLite:dbname=$file";
121 my $t = Text::Tradition->new(
123 'input' => 'Tabular',
124 'file' => 't/data/simple.txt',
126 my $stemma_enabled = $t->can( 'add_stemma' );
129 my $d = Text::Tradition::Directory->new( 'dsn' => $dsn,
130 'extra_args' => { 'create' => 1 } );
131 ok( $d->$_isa('Text::Tradition::Directory'), "Got directory object" );
133 my $scope = $d->new_scope;
134 $uuid = $d->save( $t );
135 ok( $uuid, "Saved test tradition" );
138 skip "Analysis package not installed", 5 unless $stemma_enabled;
139 my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
140 ok( $d->save( $t ), "Updated tradition with stemma" );
141 is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
142 is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
145 } catch( Text::Tradition::Error $e ) {
146 is( $e->ident, 'database error', "Got exception trying to save stemma directly" );
147 like( $e->message, qr/Cannot directly save non-Tradition object/,
148 "Exception has correct message" );
152 my $nt = Text::Tradition->new(
154 'input' => 'CollateX',
155 'file' => 't/data/Collatex-16.xml',
157 ok( $nt->$_isa('Text::Tradition'), "Made new tradition" );
160 my $f = Text::Tradition::Directory->new( 'dsn' => $dsn );
161 my $scope = $f->new_scope;
162 is( scalar $f->traditionlist, 1, "Directory index has our tradition" );
163 my $nuuid = $f->save( $nt );
164 ok( $nuuid, "Stored second tradition" );
165 my @tlist = $f->traditionlist;
166 is( scalar @tlist, 2, "Directory index has both traditions" );
167 my $tf = $f->tradition( $uuid );
168 my( $tlobj ) = grep { $_->{'id'} eq $uuid } @tlist;
169 is( $tlobj->{'name'}, $tf->name, "Directory index has correct tradition name" );
170 is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
173 skip "Analysis package not installed", 4 unless $stemma_enabled;
174 $sid = $f->object_to_id( $tf->stemma(0) );
176 $f->tradition( $sid );
177 } catch( Text::Tradition::Error $e ) {
178 is( $e->ident, 'database error', "Got exception trying to fetch stemma directly" );
179 like( $e->message, qr/not a Text::Tradition/, "Exception has correct message" );
183 } catch( Text::Tradition::Error $e ) {
184 is( $e->ident, 'database error', "Got exception trying to delete stemma directly" );
185 like( $e->message, qr/Cannot directly delete non-Tradition object/,
186 "Exception has correct message" );
191 ok( !$f->exists( $uuid ), "Object is deleted from DB" );
192 ok( !$f->exists( $sid ), "Object stemma also deleted from DB" ) if $stemma_enabled;
193 is( scalar $f->traditionlist, 1, "Object is deleted from index" );
197 todo_skip "Deletion conflicts with Analysis package", 2
198 if $t->does('Text::Tradition::HasStemma');
199 my $g = Text::Tradition::Directory->new( 'dsn' => $dsn );
200 my $scope = $g->new_scope;
201 is( scalar $g->traditionlist, 1, "Now one object in new directory index" );
202 my $ntobj = $g->tradition( 'CX' );
203 my @w1 = sort { $a->sigil cmp $b->sigil } $ntobj->witnesses;
204 my @w2 = sort{ $a->sigil cmp $b->sigil } $nt->witnesses;
205 is_deeply( \@w1, \@w2, "Looked up remaining tradition by name" );
211 use Text::Tradition::TypeMap::Entry;
215 isa => 'KiokuDB::TypeMap',
217 KiokuDB::TypeMap->new(
219 # now that we fall back to YAML deflation, all attributes of
220 # Text::Tradition will be serialized to YAML as individual objects
221 # Except if we declare a specific entry type here
223 KiokuDB::TypeMap::Entry::MOP->new(),
224 # We need users to be naive entries so that they hold
225 # references to the original tradition objects, not clones
226 "Text::Tradition::User" =>
227 KiokuDB::TypeMap::Entry::MOP->new(),
228 "Text::Tradition::Collation" =>
229 KiokuDB::TypeMap::Entry::MOP->new(),
230 "Text::Tradition::Witness" =>
231 KiokuDB::TypeMap::Entry::MOP->new(),
232 "Graph" => Text::Tradition::TypeMap::Entry->new(),
233 "Set::Scalar" => Text::Tradition::TypeMap::Entry->new(),
239 # Push some columns into the extra_args
240 around BUILDARGS => sub {
250 if( $args->{'dsn'} =~ /^dbi/ ) { # We're using Backend::DBI
251 @column_args = ( 'columns',
252 [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 },
253 'public' => { 'data_type' => 'bool', 'is_nullable' => 1 } ] );
255 my $ea = $args->{'extra_args'};
256 if( ref( $ea ) eq 'ARRAY' ) {
257 push( @$ea, @column_args );
258 } elsif( ref( $ea ) eq 'HASH' ) {
259 $ea = { %$ea, @column_args };
261 $ea = { @column_args };
263 $args->{'extra_args'} = $ea;
265 return $class->$orig( $args );
268 override _build_directory => sub {
270 Text::Tradition::Store->connect(@{ $self->_connect_args },
271 resolver_constructor => sub {
273 $class->new({ typemap => $self->directory->merged_typemap,
274 fallback_entry => Text::Tradition::TypeMap::Entry->new() });
278 ## These checks don't cover store($id, $obj)
279 # before [ qw/ store update insert delete / ] => sub {
280 before [ qw/ delete / ] => sub {
283 foreach my $obj ( @_ ) {
284 if( ref( $obj ) && !$obj->$_isa( 'Text::Tradition' )
285 && !$obj->$_isa('Text::Tradition::User') ) {
286 # Is it an id => Tradition hash?
287 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
288 my( $k ) = keys %$obj;
289 next if $obj->{$k}->$_isa('Text::Tradition');
291 push( @nontrad, $obj );
295 throw( "Cannot directly save non-Tradition object of type "
296 . ref( $nontrad[0] ) );
300 # TODO Garbage collection doesn't work. Suck it up and live with the
302 after delete => sub {
304 my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
305 $self->directory->backend->delete( $gc->garbage->members );
310 return $self->store( @_ );
314 my( $self, $id ) = @_;
315 my $obj = $self->lookup( $id );
317 # Try looking up by name.
318 foreach my $item ( $self->traditionlist ) {
319 if( $item->{'name'} eq $id ) {
320 $obj = $self->lookup( $item->{'id'} );
325 if( $obj && !$obj->$_isa('Text::Tradition') ) {
326 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
335 return $self->user_traditionlist($user) if($user);
338 # If we are using DBI, we can do it the easy way; if not, the hard way.
339 # Easy way still involves making a separate DBI connection. Ew.
340 if( $self->dsn =~ /^dbi:(\w+):/ ) {
342 my @connection = @{$self->directory->backend->connect_info};
343 # Get rid of KiokuDB-specific arg
344 pop @connection if scalar @connection > 4;
345 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
346 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
347 my $dbh = DBI->connect( @connection );
348 my $q = $dbh->prepare( 'SELECT id, name, public from entries WHERE class = "Text::Tradition"' );
350 while( my @row = $q->fetchrow_array ) {
351 my( $id, $name ) = @row;
352 # Horrible horrible hack
353 $name = decode_utf8( $name ) if $dbtype eq 'mysql';
354 push( @tlist, { 'id' => $row[0], 'name' => $row[1], 'public' => $row[2] } );
357 $self->scan( sub { my $o = shift;
358 push( @tlist, { 'id' => $self->object_to_id( $o ),
360 'public' => $o->public } ) } );
366 Text::Tradition::Error->throw(
367 'ident' => 'database error',
373 # has 'directory' => (
375 # isa => 'KiokuX::Model',
379 ## TODO: Some of these methods should probably optionally take $user objects
380 ## instead of hashrefs.
382 ## It also occurs to me that all these methods don't need to be named
383 ## XX_user, but leaving that way for now incase we merge this code
384 ## into ::Directory for one-store.
386 =head1 USER DIRECTORY METHODS
388 =head2 add_user( $userinfo )
390 Takes a hashref of C<username>, C<password>.
392 Create a new user object, store in the KiokuDB backend, and return it.
397 my ($self, $userinfo) = @_;
399 my $username = $userinfo->{username};
400 my $password = $userinfo->{password};
401 my $role = $userinfo->{role} || 'user';
403 throw( "No username given" ) unless $username;
404 throw( "Invalid password - must be at least " . $self->MIN_PASS_LEN
405 . " characters long" )
406 unless ( $self->validate_password($password) || $username =~ /^https?:/ );
408 my $user = Text::Tradition::User->new(
410 password => ($password ? crypt_password($password) : ''),
411 email => ($userinfo->{email} ? $userinfo->{email} : $username),
415 $self->store($user->kiokudb_object_id, $user);
420 =head2 create_user( $userinfo )
422 Takes a hashref that can either be suitable for add_user (see above) or be
423 a hash of OpenID user information from Credential::OpenID.
428 my ($self, $userinfo) = @_;
430 ## No username means probably an OpenID based user
431 if(!exists $userinfo->{username}) {
432 _extract_openid_data($userinfo);
435 return $self->add_user($userinfo);
438 ## Not quite sure where this method should be.. Auth /
439 ## Credential::OpenID just pass us back the chunk of extension data
440 sub _extract_openid_data {
443 ## Spec says SHOULD use url as identifier
444 $userinfo->{username} = $userinfo->{url};
446 ## Use email addy as display if available
447 if(exists $userinfo->{extensions} &&
448 exists $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'} &&
449 defined $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'}) {
450 ## Somewhat ugly attribute extension reponse, contains
451 ## google-email string which we can use as the id
453 $userinfo->{email} = $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'};
459 =head2 find_user( $userinfo )
461 Takes a hashref of C<username> or C<email>, and possibly openIDish results from
462 L<Net::OpenID::Consumer>.
464 Fetches the user object for the given username and returns it.
469 my ($self, $userinfo) = @_;
471 ## A URL field means probably an OpenID based user
472 if( exists $userinfo->{url} ) {
473 _extract_openid_data($userinfo);
477 if( exists $userinfo->{username} ) {
478 my $username = $userinfo->{username};
479 ## No logins if user is deactivated (use lookup to fetch to re-activate)
480 $user = $self->lookup(Text::Tradition::User->id_for_user($username));
481 ## If there is an inactive user, skip it
482 return if( $user && !$user->active );
483 } elsif( exists $userinfo->{email} ) {
484 ## Scan the users looking for a matching email
486 $self->scan( sub { push( @matches, @_ )
487 if $_[0]->isa('Text::Tradition::User')
488 && $_[0]->email eq $userinfo->{email} } );
489 $user = shift @matches;
491 # print STDERR "Found user, $username, email is :", $user->email, ":\n";
495 =head2 modify_user( $userinfo )
497 Takes a hashref of C<username> and C<password> (same as add_user).
499 Retrieves the user, and updates it with the new information. Username
500 changing is not currently supported.
502 Returns the updated user object, or undef if not found.
507 my ($self, $userinfo) = @_;
508 my $username = $userinfo->{username};
509 my $password = $userinfo->{password};
510 my $role = $userinfo->{role};
512 throw( "Missing username" ) unless $username;
514 my $user = $self->find_user({ username => $username });
515 throw( "Could not find user $username" ) unless $user;
518 throw( "Bad password" ) unless $self->validate_password($password);
519 $user->password(crypt_password($password));
525 $self->update($user);
530 =head2 deactivate_user( $userinfo )
532 Takes a hashref of C<username>.
534 Sets the users C<active> flag to false (0), and sets all traditions
535 assigned to them to non-public, updates the storage and returns the
538 Returns undef if user not found.
542 sub deactivate_user {
543 my ($self, $userinfo) = @_;
544 my $username = $userinfo->{username};
546 throw( "Need to specify a username for deactivation" ) unless $username;
548 my $user = $self->find_user({ username => $username });
549 throw( "User $username not found" ) unless $user;
552 foreach my $tradition (@{ $user->traditions }) {
553 ## Not implemented yet
554 # $tradition->public(0);
557 ## Should we be using Text::Tradition::Directory also?
558 $self->update(@{ $user->traditions });
560 $self->update($user);
565 =head2 reactivate_user( $userinfo )
567 Takes a hashref of C<username>.
569 Returns the user object if already activated. Activates (sets the
570 active flag to true (1)), updates the storage and returns the user.
572 Returns undef if the user is not found.
576 sub reactivate_user {
577 my ($self, $userinfo) = @_;
578 my $username = $userinfo->{username};
580 throw( "Need to specify a username for reactivation" ) unless $username;
582 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
583 throw( "User $username not found" ) unless $user;
585 return $user if $user->active;
588 $self->update($user);
593 =head2 delete_user( $userinfo )
595 CAUTION: Deletes actual data!
597 Takes a hashref of C<username>.
599 Returns undef if the user doesn't exist.
601 Removes the user from the store and returns 1.
606 my ($self, $userinfo) = @_;
607 my $username = $userinfo->{username};
609 throw( "Need to specify a username for deletion" ) unless $username;
611 my $user = $self->find_user({ username => $username });
612 throw( "User $username not found" ) unless $user;
614 ## Should we be using Text::Tradition::Directory for this bit?
615 $self->delete( @{ $user->traditions });
618 $self->delete($user);
623 =head2 validate_password( $password )
625 Takes a password string. Returns true if it is longer than
626 L</MIN_PASS_LEN>, false otherwise.
628 Used internally by L</add_user>.
632 sub validate_password {
633 my ($self, $password) = @_;
635 return if !$password;
636 return if length($password) < $self->MIN_PASS_LEN;
641 =head2 user_traditionlist( $user )
643 Returns a tradition list (see specification above) but containing only
644 those traditions visible to the specified user. If $user is the string
645 'public', returns only publicly-viewable traditions.
649 sub user_traditionlist {
650 my ($self, $user) = @_;
653 if(ref $user && $user->is_admin) {
655 return $self->traditionlist();
657 ## We have a user object already, so just fetch its traditions and use tose
658 foreach my $t (@{ $user->traditions }) {
659 push( @tlist, { 'id' => $self->object_to_id( $t ),
660 'name' => $t->name } );
663 } elsif($user ne 'public') {
664 die "Passed neither a user object nor 'public' to user_traditionlist";
667 ## Search for all traditions which allow public viewing
668 my @list = grep { $_->{public} } $self->traditionlist();
676 This package is free software and is provided "as is" without express
677 or implied warranty. You can redistribute it and/or modify it under
678 the same terms as Perl itself.
682 Tara L Andrews E<lt>aurum@cpan.orgE<gt> (initial release)
684 Shadowcat Systems L<http://www.scsys.co.uk/> (user functionality; making it all work)