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