User store functionality actually in Directory; migrate changes there
[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
b77f6c1b 379 throw( "No username given" ) unless $username;
380 throw( "Invalid password - must be at least " . $self->MIN_PASS_LEN
381 . " characters long" )
382 unless ( $self->validate_password($password) || $username =~ /^https?:/ );
cf7e4e7b 383
384 my $user = Text::Tradition::User->new(
385 id => $username,
386 password => ($password ? crypt_password($password) : ''),
a528f0f6 387 email => ($userinfo->{email} ? $userinfo->{email} : $username),
7cb56251 388 role => $role,
cf7e4e7b 389 );
390
cf7e4e7b 391 $self->store($user->kiokudb_object_id, $user);
392
393 return $user;
394}
395
396sub create_user {
10ef7653 397 my ($self, $userinfo) = @_;
398
399 ## No username means probably an OpenID based user
400 if(!exists $userinfo->{username}) {
401 extract_openid_data($userinfo);
402 }
403
404 return $self->add_user($userinfo);
405}
406
407## Not quite sure where this method should be.. Auth /
408## Credential::OpenID just pass us back the chunk of extension data
409sub extract_openid_data {
410 my ($userinfo) = @_;
411
412 ## Spec says SHOULD use url as identifier
413 $userinfo->{username} = $userinfo->{url};
414
415 ## Use email addy as display if available
416 if(exists $userinfo->{extensions} &&
417 exists $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'} &&
418 defined $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'}) {
419 ## Somewhat ugly attribute extension reponse, contains
420 ## google-email string which we can use as the id
421
a528f0f6 422 $userinfo->{email} = $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'};
10ef7653 423 }
424
425 return;
cf7e4e7b 426}
427
770f7a2b 428=head2 find_user
cf7e4e7b 429
10ef7653 430Takes a hashref of C<username>, and possibly openIDish results from
431L<Net::OpenID::Consumer>.
cf7e4e7b 432
433Fetches the user object for the given username and returns it.
434
435=cut
436
437sub find_user {
438 my ($self, $userinfo) = @_;
10ef7653 439
440 ## No username means probably an OpenID based user
441 if(!exists $userinfo->{username}) {
442 extract_openid_data($userinfo);
443 }
444
445 my $username = $userinfo->{username};
cf7e4e7b 446
df8c12f0 447 ## No logins if user is deactivated (use lookup to fetch to re-activate)
448 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
10ef7653 449 return if(!$user || !$user->active);
450
a528f0f6 451 print STDERR "Found user, $username, email is :", $user->email, ":\n";
df8c12f0 452
453 return $user;
cf7e4e7b 454}
455
770f7a2b 456=head2 modify_user
cf7e4e7b 457
458Takes a hashref of C<username> and C<password> (same as add_user).
459
460Retrieves the user, and updates it with the new information. Username
461changing is not currently supported.
462
463Returns the updated user object, or undef if not found.
464
465=cut
466
467sub modify_user {
468 my ($self, $userinfo) = @_;
469 my $username = $userinfo->{username};
470 my $password = $userinfo->{password};
4d4c5789 471 my $role = $userinfo->{role};
cf7e4e7b 472
b77f6c1b 473 throw( "Missing username or bad password" )
474 unless $username && $self->validate_password($password);
cf7e4e7b 475
cf7e4e7b 476 my $user = $self->find_user({ username => $username });
b77f6c1b 477 throw( "Could not find user $username" ) unless $user;
cf7e4e7b 478
4d4c5789 479 if($password) {
480 $user->password(crypt_password($password));
481 }
482 if($role) {
483 $user->role($role);
484 }
cf7e4e7b 485
486 $self->update($user);
487
488 return $user;
489}
490
770f7a2b 491=head2 deactivate_user
cf7e4e7b 492
493Takes a hashref of C<username>.
494
495Sets the users C<active> flag to false (0), and sets all traditions
496assigned to them to non-public, updates the storage and returns the
497deactivated user.
498
499Returns undef if user not found.
500
501=cut
502
503sub deactivate_user {
504 my ($self, $userinfo) = @_;
505 my $username = $userinfo->{username};
506
b77f6c1b 507 throw( "Need to specify a username for deactivation" ) unless $username;
cf7e4e7b 508
509 my $user = $self->find_user({ username => $username });
b77f6c1b 510 throw( "User $username not found" ) unless $user;
cf7e4e7b 511
512 $user->active(0);
513 foreach my $tradition (@{ $user->traditions }) {
514 ## Not implemented yet
515 # $tradition->public(0);
516 }
cf7e4e7b 517
518 ## Should we be using Text::Tradition::Directory also?
519 $self->update(@{ $user->traditions });
520
521 $self->update($user);
522
523 return $user;
524}
525
770f7a2b 526=head2 reactivate_user
cf7e4e7b 527
528Takes a hashref of C<username>.
529
530Returns the user object if already activated. Activates (sets the
531active flag to true (1)), updates the storage and returns the user.
532
533Returns undef if the user is not found.
534
535=cut
536
537sub reactivate_user {
538 my ($self, $userinfo) = @_;
539 my $username = $userinfo->{username};
540
b77f6c1b 541 throw( "Need to specify a username for reactivation" ) unless $username;
cf7e4e7b 542
df8c12f0 543 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
b77f6c1b 544 throw( "User $username not found" ) unless $user;
cf7e4e7b 545
546 return $user if $user->active;
547
548 $user->active(1);
549 $self->update($user);
550
551 return $user;
552}
553
770f7a2b 554=head2 delete_user
cf7e4e7b 555
770f7a2b 556CAUTION: Deletes actual data!
cf7e4e7b 557
558Takes a hashref of C<username>.
559
560Returns undef if the user doesn't exist.
561
562Removes the user from the store and returns 1.
563
564=cut
565
566sub delete_user {
567 my ($self, $userinfo) = @_;
568 my $username = $userinfo->{username};
569
b77f6c1b 570 throw( "Need to specify a username for deletion" ) unless $username;
cf7e4e7b 571
cf7e4e7b 572 my $user = $self->find_user({ username => $username });
b77f6c1b 573 throw( "User $username not found" ) unless $user;
cf7e4e7b 574
575 ## Should we be using Text::Tradition::Directory for this bit?
576 $self->delete( @{ $user->traditions });
577
578 ## Poof, gone.
579 $self->delete($user);
580
581 return 1;
582}
583
770f7a2b 584=head2 validate_password
cf7e4e7b 585
586Takes a password string. Returns true if it is longer than
587L</MIN_PASS_LEN>, false otherwise.
588
589Used internally by L</add_user>.
590
591=cut
592
593sub validate_password {
594 my ($self, $password) = @_;
595
596 return if !$password;
597 return if length($password) < $self->MIN_PASS_LEN;
598
599 return 1;
600}
601
8d9a1cd8 6021;
12523041 603
027d819c 604=head1 LICENSE
605
606This package is free software and is provided "as is" without express
607or implied warranty. You can redistribute it and/or modify it under
608the same terms as Perl itself.
609
610=head1 AUTHOR
611
612Tara L Andrews E<lt>aurum@cpan.orgE<gt>