add new requirements
[scpubgit/stemmatology.git] / lib / Text / Tradition / Directory.pm
CommitLineData
8d9a1cd8 1package Text::Tradition::Directory;
2
3use strict;
4use warnings;
5use Moose;
98a6cab2 6use DBI;
0a900793 7use Encode qw/ decode_utf8 /;
ad1291ee 8use KiokuDB::GC::Naive;
8d9a1cd8 9use KiokuDB::TypeMap;
10use KiokuDB::TypeMap::Entry::Naive;
861c3e27 11use Text::Tradition::Error;
8d9a1cd8 12
cf7e4e7b 13## users
14use KiokuX::User::Util qw(crypt_password);
f3f26624 15use Text::Tradition::Store;
cf7e4e7b 16use Text::Tradition::User;
f3f26624 17use Text::Tradition::TypeMap::Entry;
cf7e4e7b 18
8d9a1cd8 19extends 'KiokuX::Model';
20
12523041 21=head1 NAME
22
23Text::Tradition::Directory - a KiokuDB interface for storing and retrieving traditions
24
25=head1 SYNOPSIS
26
27 use Text::Tradition::Directory;
28 my $d = Text::Tradition::Directory->new(
29 'dsn' => 'dbi:SQLite:mytraditions.db',
30 'extra_args' => { 'create' => 1 },
31 );
32
33 my $tradition = Text::Tradition->new( @args );
9ba651b9 34 my $stemma = $tradition->add_stemma( dotfile => $dotfile );
12523041 35 $d->save_tradition( $tradition );
12523041 36
37 foreach my $id ( $d->traditions ) {
38 print $d->tradition( $id )->name;
12523041 39 }
770f7a2b 40
41 ## Users:
42 my $userstore = Text::Tradition::UserStore->new(dsn => 'dbi:SQLite:foo.db');
43 my $newuser = $userstore->add_user({ username => 'fred',
44 password => 'somepassword' });
45
46 my $fetchuser = $userstore->find_user({ username => 'fred' });
47 if($fetchuser->check_password('somepassword')) {
48 ## login user or .. whatever
49 }
50
51 my $user = $userstore->deactivate_user({ username => 'fred' });
52 if(!$user->active) {
53 ## shouldnt be able to login etc
54 }
12523041 55
56=head1 DESCRIPTION
57
58Text::Tradition::Directory is an interface for storing and retrieving text traditions and all their data, including an associated stemma hypothesis. It is an instantiation of a KiokuDB::Model, storing traditions and associated stemmas by UUID.
59
770f7a2b 60=head1 ATTRIBUTES
61
62=head2 MIN_PASS_LEN
63
64Constant for the minimum password length when validating passwords,
65defaults to "8".
66
67=cut
68
69has MIN_PASS_LEN => ( is => 'ro', isa => 'Num', default => sub { 8 } );
70
12523041 71=head1 METHODS
72
73=head2 new
74
56cf65bd 75Returns a Directory object.
12523041 76
98a6cab2 77=head2 traditionlist
12523041 78
98a6cab2 79Returns a hashref mapping of ID => name for all traditions in the directory.
12523041 80
81=head2 tradition( $id )
82
83Returns the Text::Tradition object of the given ID.
84
56cf65bd 85=head2 save( $tradition )
12523041 86
56cf65bd 87Writes the given tradition to the database, returning its ID.
12523041 88
d7ba60b4 89=head2 delete( $tradition )
90
91Deletes the given tradition object from the database.
92WARNING!! Garbage collection does not yet work. Use this sparingly.
93
12523041 94=begin testing
95
861c3e27 96use TryCatch;
12523041 97use File::Temp;
98use Text::Tradition;
12523041 99use_ok 'Text::Tradition::Directory';
100
101my $fh = File::Temp->new();
102my $file = $fh->filename;
103$fh->close;
104my $dsn = "dbi:SQLite:dbname=$file";
861c3e27 105my $uuid;
12523041 106my $t = Text::Tradition->new(
56cf65bd 107 'name' => 'inline',
108 'input' => 'Tabular',
109 'file' => 't/data/simple.txt',
110 );
56cf65bd 111
861c3e27 112{
113 my $d = Text::Tradition::Directory->new( 'dsn' => $dsn,
114 'extra_args' => { 'create' => 1 } );
115 is( ref $d, 'Text::Tradition::Directory', "Got directory object" );
116
117 my $scope = $d->new_scope;
118 $uuid = $d->save( $t );
119 ok( $uuid, "Saved test tradition" );
120
9ba651b9 121 my $s = $t->add_stemma( dotfile => 't/data/simple.dot' );
861c3e27 122 ok( $d->save( $t ), "Updated tradition with stemma" );
123 is( $d->tradition( $uuid ), $t, "Correct tradition returned for id" );
e0d617e6 124 is( $d->tradition( $uuid )->stemma(0), $s, "...and it has the correct stemma" );
861c3e27 125 try {
126 $d->save( $s );
127 } catch( Text::Tradition::Error $e ) {
128 is( $e->ident, 'database error', "Got exception trying to save stemma directly" );
129 like( $e->message, qr/Cannot directly save non-Tradition object/,
130 "Exception has correct message" );
131 }
132}
133my $nt = Text::Tradition->new(
134 'name' => 'CX',
135 'input' => 'CollateX',
136 'file' => 't/data/Collatex-16.xml',
137 );
138is( ref( $nt ), 'Text::Tradition', "Made new tradition" );
139
140{
141 my $f = Text::Tradition::Directory->new( 'dsn' => $dsn );
142 my $scope = $f->new_scope;
98a6cab2 143 is( scalar $f->traditionlist, 1, "Directory index has our tradition" );
861c3e27 144 my $nuuid = $f->save( $nt );
145 ok( $nuuid, "Stored second tradition" );
98a6cab2 146 my @tlist = $f->traditionlist;
147 is( scalar @tlist, 2, "Directory index has both traditions" );
861c3e27 148 my $tf = $f->tradition( $uuid );
98a6cab2 149 my( $tlobj ) = grep { $_->{'id'} eq $uuid } @tlist;
150 is( $tlobj->{'name'}, $tf->name, "Directory index has correct tradition name" );
861c3e27 151 is( $tf->name, $t->name, "Retrieved the tradition from a new directory" );
e0d617e6 152 my $sid = $f->object_to_id( $tf->stemma(0) );
861c3e27 153 try {
154 $f->tradition( $sid );
155 } catch( Text::Tradition::Error $e ) {
156 is( $e->ident, 'database error', "Got exception trying to fetch stemma directly" );
157 like( $e->message, qr/not a Text::Tradition/, "Exception has correct message" );
158 }
159 try {
160 $f->delete( $sid );
161 } catch( Text::Tradition::Error $e ) {
162 is( $e->ident, 'database error', "Got exception trying to delete stemma directly" );
163 like( $e->message, qr/Cannot directly delete non-Tradition object/,
164 "Exception has correct message" );
165 }
ad39942e 166
861c3e27 167 $f->delete( $uuid );
168 ok( !$f->exists( $uuid ), "Object is deleted from DB" );
169 ok( !$f->exists( $sid ), "Object stemma also deleted from DB" );
98a6cab2 170 is( scalar $f->traditionlist, 1, "Object is deleted from index" );
861c3e27 171}
172
d7ba60b4 173{
861c3e27 174 my $g = Text::Tradition::Directory->new( 'dsn' => $dsn );
175 my $scope = $g->new_scope;
98a6cab2 176 is( scalar $g->traditionlist, 1, "Now one object in new directory index" );
ad39942e 177 my $ntobj = $g->tradition( 'CX' );
09909f9d 178 my @w1 = sort { $a->sigil cmp $b->sigil } $ntobj->witnesses;
179 my @w2 = sort{ $a->sigil cmp $b->sigil } $nt->witnesses;
ad39942e 180 is_deeply( \@w1, \@w2, "Looked up remaining tradition by name" );
861c3e27 181}
12523041 182
183=end testing
184
185=cut
fc7b6388 186use Text::Tradition::TypeMap::Entry;
12523041 187
12523041 188has +typemap => (
fc7b6388 189 is => 'rw',
190 isa => 'KiokuDB::TypeMap',
191 default => sub {
192 KiokuDB::TypeMap->new(
193 isa_entries => {
f3f26624 194 # now that we fall back to YAML deflation, all attributes of
195 # Text::Tradition will be serialized to YAML as individual objects
196 # Except if we declare a specific entry type here
fc7b6388 197 "Text::Tradition" =>
f3f26624 198 KiokuDB::TypeMap::Entry::MOP->new(),
199 # We need users to be naive entries so that they hold
200 # references to the original tradition objects, not clones
201 "Text::Tradition::User" =>
202 KiokuDB::TypeMap::Entry::MOP->new(),
203 "Text::Tradition::Collation" =>
204 KiokuDB::TypeMap::Entry::MOP->new(),
205 "Text::Tradition::Witness" =>
206 KiokuDB::TypeMap::Entry::MOP->new(),
207 "Graph" => Text::Tradition::TypeMap::Entry->new()
7e17346f 208 "Set::Scalar" => Text::Tradition::TypeMap::Entry->new(),
fc7b6388 209 }
210 );
211 },
8d9a1cd8 212);
213
98a6cab2 214# Push some columns into the extra_args
215around BUILDARGS => sub {
216 my $orig = shift;
217 my $class = shift;
218 my $args;
219 if( @_ == 1 ) {
220 $args = $_[0];
221 } else {
222 $args = { @_ };
223 }
f3f26624 224 my @column_args;
98a6cab2 225 if( $args->{'dsn'} =~ /^dbi/ ) { # We're using Backend::DBI
f3f26624 226 @column_args = ( 'columns',
98a6cab2 227 [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 } ] );
98a6cab2 228 }
f3f26624 229 my $ea = $args->{'extra_args'};
230 if( ref( $ea ) eq 'ARRAY' ) {
231 push( @$ea, @column_args );
232 } elsif( ref( $ea ) eq 'HASH' ) {
233 $ea = { %$ea, @column_args };
234 } else {
235 $ea = { @column_args };
236 }
237 $args->{'extra_args'} = $ea;
238
98a6cab2 239 return $class->$orig( $args );
240};
241
f3f26624 242override _build_directory => sub {
243 my($self) = @_;
244 Text::Tradition::Store->connect(@{ $self->_connect_args },
245 resolver_constructor => sub {
246 my($class) = @_;
247 $class->new({ typemap => $self->directory->merged_typemap,
248 fallback_entry => Text::Tradition::TypeMap::Entry->new() });
249 });
250};
251
7cb56251 252## These checks don't cover store($id, $obj)
fc7b6388 253# before [ qw/ store update insert delete / ] => sub {
254before [ qw/ delete / ] => sub {
8d9a1cd8 255 my $self = shift;
861c3e27 256 my @nontrad;
257 foreach my $obj ( @_ ) {
cf7e4e7b 258 if( ref( $obj ) && ref( $obj ) ne 'Text::Tradition'
259 && ref ($obj) ne 'Text::Tradition::User' ) {
861c3e27 260 # Is it an id => Tradition hash?
261 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
262 my( $k ) = keys %$obj;
263 next if ref( $obj->{$k} ) eq 'Text::Tradition';
8d9a1cd8 264 }
861c3e27 265 push( @nontrad, $obj );
8d9a1cd8 266 }
12523041 267 }
861c3e27 268 if( @nontrad ) {
269 throw( "Cannot directly save non-Tradition object of type "
270 . ref( $nontrad[0] ) );
271 }
272};
12523041 273
d7ba60b4 274# TODO Garbage collection doesn't work. Suck it up and live with the
275# inflated DB.
d94224d9 276after delete => sub {
277 my $self = shift;
278 my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
279 $self->directory->backend->delete( $gc->garbage->members );
280};
56cf65bd 281
282sub save {
861c3e27 283 my $self = shift;
284 return $self->store( @_ );
12523041 285}
286
56cf65bd 287sub tradition {
288 my( $self, $id ) = @_;
289 my $obj = $self->lookup( $id );
ad39942e 290 unless( $obj ) {
291 # Try looking up by name.
292 foreach my $item ( $self->traditionlist ) {
293 if( $item->{'name'} eq $id ) {
294 $obj = $self->lookup( $item->{'id'} );
295 last;
296 }
297 }
298 }
299 if( $obj && ref( $obj ) ne 'Text::Tradition' ) {
861c3e27 300 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
12523041 301 }
56cf65bd 302 return $obj;
12523041 303}
8d9a1cd8 304
fefeeeda 305sub user_traditionlist {
306 my ($self, $user) = @_;
307
308 my @tlist;
7cb56251 309 if(ref $user && $user->is_admin) {
310 ## Admin sees all
311 return $self->traditionlist();
312 } elsif(ref $user) {
fefeeeda 313 ## We have a user object already, so just fetch its traditions and use tose
7d52d62b 314 foreach my $t (@{ $user->traditions }) {
fefeeeda 315 push( @tlist, { 'id' => $self->object_to_id( $t ),
316 'name' => $t->name } );
317 }
318 return @tlist;
7d52d62b 319 } elsif($user ne 'public') {
320 die "Passed neither a user object nor 'public' to user_traditionlist";
fefeeeda 321 }
322
323 ## Search for all traditions which allow public viewing
324 ## When they exist!
3724dfa7 325## This needs to be more sophisticated, probably needs Search::GIN
326# my $list = $self->search({ public => 1 });
fefeeeda 327
328 ## For now, just fetch all
329 ## (could use all_objects or grep down there?)
330 return $self->traditionlist();
331}
332
98a6cab2 333sub traditionlist {
861c3e27 334 my $self = shift;
fefeeeda 335 my ($user) = @_;
336
337 return $self->user_traditionlist($user) if($user);
338
339 my @tlist;
98a6cab2 340 # If we are using DBI, we can do it the easy way; if not, the hard way.
341 # Easy way still involves making a separate DBI connection. Ew.
0a900793 342 if( $self->dsn =~ /^dbi:(\w+):/ ) {
343 my $dbtype = $1;
98a6cab2 344 my @connection = @{$self->directory->backend->connect_info};
345 # Get rid of KiokuDB-specific arg
346 pop @connection if scalar @connection > 4;
0a900793 347 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
348 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
98a6cab2 349 my $dbh = DBI->connect( @connection );
350 my $q = $dbh->prepare( 'SELECT id, name from entries WHERE class = "Text::Tradition"' );
351 $q->execute();
352 while( my @row = $q->fetchrow_array ) {
0a900793 353 my( $id, $name ) = @row;
354 # Horrible horrible hack
355 $name = decode_utf8( $name ) if $dbtype eq 'mysql';
98a6cab2 356 push( @tlist, { 'id' => $row[0], 'name' => $row[1] } );
357 }
358 } else {
359 $self->scan( sub { my $o = shift;
360 push( @tlist, { 'id' => $self->object_to_id( $o ),
361 'name' => $o->name } ) } );
362 }
363 return @tlist;
861c3e27 364}
365
366sub throw {
367 Text::Tradition::Error->throw(
368 'ident' => 'database error',
369 'message' => $_[0],
370 );
371}
372
cf7e4e7b 373
374# has 'directory' => (
375# is => 'rw',
376# isa => 'KiokuX::Model',
377# handles => []
378# );
379
380## TODO: Some of these methods should probably optionally take $user objects
381## instead of hashrefs.
382
383## It also occurs to me that all these methods don't need to be named
384## XX_user, but leaving that way for now incase we merge this code
385## into ::Directory for one-store.
386
387## To die or not to die, on error, this is the question.
388
770f7a2b 389=head2 add_user
cf7e4e7b 390
391Takes a hashref of C<username>, C<password>.
392
393Create a new user object, store in the KiokuDB backend, and return it.
394
395=cut
396
397sub add_user {
398 my ($self, $userinfo) = @_;
10ef7653 399
400 my $username = $userinfo->{username};
cf7e4e7b 401 my $password = $userinfo->{password};
7cb56251 402 my $role = $userinfo->{role} || 'user';
cf7e4e7b 403
b77f6c1b 404 throw( "No username given" ) unless $username;
405 throw( "Invalid password - must be at least " . $self->MIN_PASS_LEN
406 . " characters long" )
407 unless ( $self->validate_password($password) || $username =~ /^https?:/ );
cf7e4e7b 408
409 my $user = Text::Tradition::User->new(
410 id => $username,
411 password => ($password ? crypt_password($password) : ''),
a528f0f6 412 email => ($userinfo->{email} ? $userinfo->{email} : $username),
7cb56251 413 role => $role,
cf7e4e7b 414 );
415
cf7e4e7b 416 $self->store($user->kiokudb_object_id, $user);
417
418 return $user;
419}
420
421sub create_user {
10ef7653 422 my ($self, $userinfo) = @_;
423
424 ## No username means probably an OpenID based user
425 if(!exists $userinfo->{username}) {
426 extract_openid_data($userinfo);
427 }
428
429 return $self->add_user($userinfo);
430}
431
432## Not quite sure where this method should be.. Auth /
433## Credential::OpenID just pass us back the chunk of extension data
434sub extract_openid_data {
435 my ($userinfo) = @_;
436
437 ## Spec says SHOULD use url as identifier
438 $userinfo->{username} = $userinfo->{url};
439
440 ## Use email addy as display if available
441 if(exists $userinfo->{extensions} &&
442 exists $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'} &&
443 defined $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'}) {
444 ## Somewhat ugly attribute extension reponse, contains
445 ## google-email string which we can use as the id
446
a528f0f6 447 $userinfo->{email} = $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'};
10ef7653 448 }
449
450 return;
cf7e4e7b 451}
452
770f7a2b 453=head2 find_user
cf7e4e7b 454
10ef7653 455Takes a hashref of C<username>, and possibly openIDish results from
456L<Net::OpenID::Consumer>.
cf7e4e7b 457
458Fetches the user object for the given username and returns it.
459
460=cut
461
462sub find_user {
463 my ($self, $userinfo) = @_;
10ef7653 464
465 ## No username means probably an OpenID based user
466 if(!exists $userinfo->{username}) {
467 extract_openid_data($userinfo);
468 }
469
470 my $username = $userinfo->{username};
cf7e4e7b 471
df8c12f0 472 ## No logins if user is deactivated (use lookup to fetch to re-activate)
473 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
10ef7653 474 return if(!$user || !$user->active);
475
c80a73ea 476# print STDERR "Found user, $username, email is :", $user->email, ":\n";
df8c12f0 477
478 return $user;
cf7e4e7b 479}
480
770f7a2b 481=head2 modify_user
cf7e4e7b 482
483Takes a hashref of C<username> and C<password> (same as add_user).
484
485Retrieves the user, and updates it with the new information. Username
486changing is not currently supported.
487
488Returns the updated user object, or undef if not found.
489
490=cut
491
492sub modify_user {
493 my ($self, $userinfo) = @_;
494 my $username = $userinfo->{username};
495 my $password = $userinfo->{password};
4d4c5789 496 my $role = $userinfo->{role};
cf7e4e7b 497
b77f6c1b 498 throw( "Missing username or bad password" )
499 unless $username && $self->validate_password($password);
cf7e4e7b 500
cf7e4e7b 501 my $user = $self->find_user({ username => $username });
b77f6c1b 502 throw( "Could not find user $username" ) unless $user;
cf7e4e7b 503
4d4c5789 504 if($password) {
505 $user->password(crypt_password($password));
506 }
507 if($role) {
508 $user->role($role);
509 }
cf7e4e7b 510
511 $self->update($user);
512
513 return $user;
514}
515
770f7a2b 516=head2 deactivate_user
cf7e4e7b 517
518Takes a hashref of C<username>.
519
520Sets the users C<active> flag to false (0), and sets all traditions
521assigned to them to non-public, updates the storage and returns the
522deactivated user.
523
524Returns undef if user not found.
525
526=cut
527
528sub deactivate_user {
529 my ($self, $userinfo) = @_;
530 my $username = $userinfo->{username};
531
b77f6c1b 532 throw( "Need to specify a username for deactivation" ) unless $username;
cf7e4e7b 533
534 my $user = $self->find_user({ username => $username });
b77f6c1b 535 throw( "User $username not found" ) unless $user;
cf7e4e7b 536
537 $user->active(0);
538 foreach my $tradition (@{ $user->traditions }) {
539 ## Not implemented yet
540 # $tradition->public(0);
541 }
cf7e4e7b 542
543 ## Should we be using Text::Tradition::Directory also?
544 $self->update(@{ $user->traditions });
545
546 $self->update($user);
547
548 return $user;
549}
550
770f7a2b 551=head2 reactivate_user
cf7e4e7b 552
553Takes a hashref of C<username>.
554
555Returns the user object if already activated. Activates (sets the
556active flag to true (1)), updates the storage and returns the user.
557
558Returns undef if the user is not found.
559
560=cut
561
562sub reactivate_user {
563 my ($self, $userinfo) = @_;
564 my $username = $userinfo->{username};
565
b77f6c1b 566 throw( "Need to specify a username for reactivation" ) unless $username;
cf7e4e7b 567
df8c12f0 568 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
b77f6c1b 569 throw( "User $username not found" ) unless $user;
cf7e4e7b 570
571 return $user if $user->active;
572
573 $user->active(1);
574 $self->update($user);
575
576 return $user;
577}
578
770f7a2b 579=head2 delete_user
cf7e4e7b 580
770f7a2b 581CAUTION: Deletes actual data!
cf7e4e7b 582
583Takes a hashref of C<username>.
584
585Returns undef if the user doesn't exist.
586
587Removes the user from the store and returns 1.
588
589=cut
590
591sub delete_user {
592 my ($self, $userinfo) = @_;
593 my $username = $userinfo->{username};
594
b77f6c1b 595 throw( "Need to specify a username for deletion" ) unless $username;
cf7e4e7b 596
cf7e4e7b 597 my $user = $self->find_user({ username => $username });
b77f6c1b 598 throw( "User $username not found" ) unless $user;
cf7e4e7b 599
600 ## Should we be using Text::Tradition::Directory for this bit?
601 $self->delete( @{ $user->traditions });
602
603 ## Poof, gone.
604 $self->delete($user);
605
606 return 1;
607}
608
770f7a2b 609=head2 validate_password
cf7e4e7b 610
611Takes a password string. Returns true if it is longer than
612L</MIN_PASS_LEN>, false otherwise.
613
614Used internally by L</add_user>.
615
616=cut
617
618sub validate_password {
619 my ($self, $password) = @_;
620
621 return if !$password;
622 return if length($password) < $self->MIN_PASS_LEN;
623
624 return 1;
625}
626
8d9a1cd8 6271;
12523041 628
027d819c 629=head1 LICENSE
630
631This package is free software and is provided "as is" without express
632or implied warranty. You can redistribute it and/or modify it under
633the same terms as Perl itself.
634
635=head1 AUTHOR
636
637Tara L Andrews E<lt>aurum@cpan.orgE<gt>