display is now 'email', allowing ::Auth to email after register.
[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
184
12523041 185has +typemap => (
8d9a1cd8 186 is => 'rw',
187 isa => 'KiokuDB::TypeMap',
188 default => sub {
189 KiokuDB::TypeMap->new(
190 isa_entries => {
8d9a1cd8 191 "Graph" => KiokuDB::TypeMap::Entry::Naive->new,
192 "Graph::AdjacencyMap" => KiokuDB::TypeMap::Entry::Naive->new,
193 }
194 );
195 },
196);
197
98a6cab2 198# Push some columns into the extra_args
199around BUILDARGS => sub {
200 my $orig = shift;
201 my $class = shift;
202 my $args;
203 if( @_ == 1 ) {
204 $args = $_[0];
205 } else {
206 $args = { @_ };
207 }
208 if( $args->{'dsn'} =~ /^dbi/ ) { # We're using Backend::DBI
209 my @column_args = ( 'columns',
210 [ 'name' => { 'data_type' => 'varchar', 'is_nullable' => 1 } ] );
211 my $ea = $args->{'extra_args'};
212 if( ref( $ea ) eq 'ARRAY' ) {
213 push( @$ea, @column_args );
214 } elsif( ref( $ea ) eq 'HASH' ) {
215 $ea = { %$ea, @column_args };
216 } else {
217 $ea = { @column_args };
218 }
219 $args->{'extra_args'} = $ea;
220 }
221 return $class->$orig( $args );
222};
223
7cb56251 224## These checks don't cover store($id, $obj)
861c3e27 225before [ qw/ store update insert delete / ] => sub {
8d9a1cd8 226 my $self = shift;
861c3e27 227 my @nontrad;
228 foreach my $obj ( @_ ) {
cf7e4e7b 229 if( ref( $obj ) && ref( $obj ) ne 'Text::Tradition'
230 && ref ($obj) ne 'Text::Tradition::User' ) {
861c3e27 231 # Is it an id => Tradition hash?
232 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
233 my( $k ) = keys %$obj;
234 next if ref( $obj->{$k} ) eq 'Text::Tradition';
8d9a1cd8 235 }
861c3e27 236 push( @nontrad, $obj );
8d9a1cd8 237 }
12523041 238 }
861c3e27 239 if( @nontrad ) {
240 throw( "Cannot directly save non-Tradition object of type "
241 . ref( $nontrad[0] ) );
242 }
243};
12523041 244
d7ba60b4 245# TODO Garbage collection doesn't work. Suck it up and live with the
246# inflated DB.
247# after delete => sub {
248# my $self = shift;
249# my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
250# $self->directory->backend->delete( $gc->garbage->members );
251# };
56cf65bd 252
253sub save {
861c3e27 254 my $self = shift;
255 return $self->store( @_ );
12523041 256}
257
56cf65bd 258sub tradition {
259 my( $self, $id ) = @_;
260 my $obj = $self->lookup( $id );
ad39942e 261 unless( $obj ) {
262 # Try looking up by name.
263 foreach my $item ( $self->traditionlist ) {
264 if( $item->{'name'} eq $id ) {
265 $obj = $self->lookup( $item->{'id'} );
266 last;
267 }
268 }
269 }
270 if( $obj && ref( $obj ) ne 'Text::Tradition' ) {
861c3e27 271 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
12523041 272 }
56cf65bd 273 return $obj;
12523041 274}
8d9a1cd8 275
fefeeeda 276sub user_traditionlist {
277 my ($self, $user) = @_;
278
279 my @tlist;
7cb56251 280 if(ref $user && $user->is_admin) {
281 ## Admin sees all
282 return $self->traditionlist();
283 } elsif(ref $user) {
fefeeeda 284 ## We have a user object already, so just fetch its traditions and use tose
7d52d62b 285 foreach my $t (@{ $user->traditions }) {
fefeeeda 286 push( @tlist, { 'id' => $self->object_to_id( $t ),
287 'name' => $t->name } );
288 }
289 return @tlist;
7d52d62b 290 } elsif($user ne 'public') {
291 die "Passed neither a user object nor 'public' to user_traditionlist";
fefeeeda 292 }
293
294 ## Search for all traditions which allow public viewing
295 ## When they exist!
3724dfa7 296## This needs to be more sophisticated, probably needs Search::GIN
297# my $list = $self->search({ public => 1 });
fefeeeda 298
299 ## For now, just fetch all
300 ## (could use all_objects or grep down there?)
301 return $self->traditionlist();
302}
303
98a6cab2 304sub traditionlist {
861c3e27 305 my $self = shift;
fefeeeda 306 my ($user) = @_;
307
308 return $self->user_traditionlist($user) if($user);
309
310 my @tlist;
98a6cab2 311 # If we are using DBI, we can do it the easy way; if not, the hard way.
312 # Easy way still involves making a separate DBI connection. Ew.
0a900793 313 if( $self->dsn =~ /^dbi:(\w+):/ ) {
314 my $dbtype = $1;
98a6cab2 315 my @connection = @{$self->directory->backend->connect_info};
316 # Get rid of KiokuDB-specific arg
317 pop @connection if scalar @connection > 4;
0a900793 318 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
319 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
98a6cab2 320 my $dbh = DBI->connect( @connection );
321 my $q = $dbh->prepare( 'SELECT id, name from entries WHERE class = "Text::Tradition"' );
322 $q->execute();
323 while( my @row = $q->fetchrow_array ) {
0a900793 324 my( $id, $name ) = @row;
325 # Horrible horrible hack
326 $name = decode_utf8( $name ) if $dbtype eq 'mysql';
98a6cab2 327 push( @tlist, { 'id' => $row[0], 'name' => $row[1] } );
328 }
329 } else {
330 $self->scan( sub { my $o = shift;
331 push( @tlist, { 'id' => $self->object_to_id( $o ),
332 'name' => $o->name } ) } );
333 }
334 return @tlist;
861c3e27 335}
336
337sub throw {
338 Text::Tradition::Error->throw(
339 'ident' => 'database error',
340 'message' => $_[0],
341 );
342}
343
cf7e4e7b 344
345# has 'directory' => (
346# is => 'rw',
347# isa => 'KiokuX::Model',
348# handles => []
349# );
350
351## TODO: Some of these methods should probably optionally take $user objects
352## instead of hashrefs.
353
354## It also occurs to me that all these methods don't need to be named
355## XX_user, but leaving that way for now incase we merge this code
356## into ::Directory for one-store.
357
358## To die or not to die, on error, this is the question.
359
770f7a2b 360=head2 add_user
cf7e4e7b 361
362Takes a hashref of C<username>, C<password>.
363
364Create a new user object, store in the KiokuDB backend, and return it.
365
366=cut
367
368sub add_user {
369 my ($self, $userinfo) = @_;
10ef7653 370
371 my $username = $userinfo->{username};
cf7e4e7b 372 my $password = $userinfo->{password};
7cb56251 373 my $role = $userinfo->{role} || 'user';
cf7e4e7b 374
375 return unless ($username =~ /^https?:/
376 || ($username && $self->validate_password($password))) ;
377
378 my $user = Text::Tradition::User->new(
379 id => $username,
380 password => ($password ? crypt_password($password) : ''),
a528f0f6 381 email => ($userinfo->{email} ? $userinfo->{email} : $username),
7cb56251 382 role => $role,
cf7e4e7b 383 );
384
cf7e4e7b 385 $self->store($user->kiokudb_object_id, $user);
386
387 return $user;
388}
389
390sub create_user {
10ef7653 391 my ($self, $userinfo) = @_;
392
393 ## No username means probably an OpenID based user
394 if(!exists $userinfo->{username}) {
395 extract_openid_data($userinfo);
396 }
397
398 return $self->add_user($userinfo);
399}
400
401## Not quite sure where this method should be.. Auth /
402## Credential::OpenID just pass us back the chunk of extension data
403sub extract_openid_data {
404 my ($userinfo) = @_;
405
406 ## Spec says SHOULD use url as identifier
407 $userinfo->{username} = $userinfo->{url};
408
409 ## Use email addy as display if available
410 if(exists $userinfo->{extensions} &&
411 exists $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'} &&
412 defined $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'}) {
413 ## Somewhat ugly attribute extension reponse, contains
414 ## google-email string which we can use as the id
415
a528f0f6 416 $userinfo->{email} = $userinfo->{extensions}{'http://openid.net/srv/ax/1.0'}{'value.email'};
10ef7653 417 }
418
419 return;
cf7e4e7b 420}
421
770f7a2b 422=head2 find_user
cf7e4e7b 423
10ef7653 424Takes a hashref of C<username>, and possibly openIDish results from
425L<Net::OpenID::Consumer>.
cf7e4e7b 426
427Fetches the user object for the given username and returns it.
428
429=cut
430
431sub find_user {
432 my ($self, $userinfo) = @_;
10ef7653 433
434 ## No username means probably an OpenID based user
435 if(!exists $userinfo->{username}) {
436 extract_openid_data($userinfo);
437 }
438
439 my $username = $userinfo->{username};
cf7e4e7b 440
df8c12f0 441 ## No logins if user is deactivated (use lookup to fetch to re-activate)
442 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
10ef7653 443 return if(!$user || !$user->active);
444
a528f0f6 445 print STDERR "Found user, $username, email is :", $user->email, ":\n";
df8c12f0 446
447 return $user;
cf7e4e7b 448}
449
770f7a2b 450=head2 modify_user
cf7e4e7b 451
452Takes a hashref of C<username> and C<password> (same as add_user).
453
454Retrieves the user, and updates it with the new information. Username
455changing is not currently supported.
456
457Returns the updated user object, or undef if not found.
458
459=cut
460
461sub modify_user {
462 my ($self, $userinfo) = @_;
463 my $username = $userinfo->{username};
464 my $password = $userinfo->{password};
4d4c5789 465 my $role = $userinfo->{role};
cf7e4e7b 466
4d4c5789 467 return unless $username;
468 return if($password && !$self->validate_password($password));
cf7e4e7b 469
cf7e4e7b 470 my $user = $self->find_user({ username => $username });
471 return unless $user;
472
4d4c5789 473 if($password) {
474 $user->password(crypt_password($password));
475 }
476 if($role) {
477 $user->role($role);
478 }
cf7e4e7b 479
480 $self->update($user);
481
482 return $user;
483}
484
770f7a2b 485=head2 deactivate_user
cf7e4e7b 486
487Takes a hashref of C<username>.
488
489Sets the users C<active> flag to false (0), and sets all traditions
490assigned to them to non-public, updates the storage and returns the
491deactivated user.
492
493Returns undef if user not found.
494
495=cut
496
497sub deactivate_user {
498 my ($self, $userinfo) = @_;
499 my $username = $userinfo->{username};
500
501 return if !$username;
502
503 my $user = $self->find_user({ username => $username });
504 return if !$user;
505
506 $user->active(0);
507 foreach my $tradition (@{ $user->traditions }) {
508 ## Not implemented yet
509 # $tradition->public(0);
510 }
cf7e4e7b 511
512 ## Should we be using Text::Tradition::Directory also?
513 $self->update(@{ $user->traditions });
514
515 $self->update($user);
516
517 return $user;
518}
519
770f7a2b 520=head2 reactivate_user
cf7e4e7b 521
522Takes a hashref of C<username>.
523
524Returns the user object if already activated. Activates (sets the
525active flag to true (1)), updates the storage and returns the user.
526
527Returns undef if the user is not found.
528
529=cut
530
531sub reactivate_user {
532 my ($self, $userinfo) = @_;
533 my $username = $userinfo->{username};
534
535 return if !$username;
536
df8c12f0 537 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
cf7e4e7b 538 return if !$user;
539
540 return $user if $user->active;
541
542 $user->active(1);
543 $self->update($user);
544
545 return $user;
546}
547
770f7a2b 548=head2 delete_user
cf7e4e7b 549
770f7a2b 550CAUTION: Deletes actual data!
cf7e4e7b 551
552Takes a hashref of C<username>.
553
554Returns undef if the user doesn't exist.
555
556Removes the user from the store and returns 1.
557
558=cut
559
560sub delete_user {
561 my ($self, $userinfo) = @_;
562 my $username = $userinfo->{username};
563
564 return if !$username;
565
cf7e4e7b 566 my $user = $self->find_user({ username => $username });
567 return if !$user;
568
569 ## Should we be using Text::Tradition::Directory for this bit?
570 $self->delete( @{ $user->traditions });
571
572 ## Poof, gone.
573 $self->delete($user);
574
575 return 1;
576}
577
770f7a2b 578=head2 validate_password
cf7e4e7b 579
580Takes a password string. Returns true if it is longer than
581L</MIN_PASS_LEN>, false otherwise.
582
583Used internally by L</add_user>.
584
585=cut
586
587sub validate_password {
588 my ($self, $password) = @_;
589
590 return if !$password;
591 return if length($password) < $self->MIN_PASS_LEN;
592
593 return 1;
594}
595
8d9a1cd8 5961;
12523041 597
027d819c 598=head1 LICENSE
599
600This package is free software and is provided "as is" without express
601or implied warranty. You can redistribute it and/or modify it under
602the same terms as Perl itself.
603
604=head1 AUTHOR
605
606Tara L Andrews E<lt>aurum@cpan.orgE<gt>