Add 'list' to admin
[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
861c3e27 224before [ qw/ store update insert delete / ] => sub {
8d9a1cd8 225 my $self = shift;
861c3e27 226 my @nontrad;
227 foreach my $obj ( @_ ) {
cf7e4e7b 228# if( ref( $obj ) && ref( $obj ) ne 'Text::Tradition' ) {
229
230 if( ref( $obj ) && ref( $obj ) ne 'Text::Tradition'
231 && ref ($obj) ne 'Text::Tradition::User' ) {
861c3e27 232 # Is it an id => Tradition hash?
233 if( ref( $obj ) eq 'HASH' && keys( %$obj ) == 1 ) {
234 my( $k ) = keys %$obj;
235 next if ref( $obj->{$k} ) eq 'Text::Tradition';
8d9a1cd8 236 }
861c3e27 237 push( @nontrad, $obj );
8d9a1cd8 238 }
12523041 239 }
861c3e27 240 if( @nontrad ) {
241 throw( "Cannot directly save non-Tradition object of type "
242 . ref( $nontrad[0] ) );
243 }
244};
12523041 245
d7ba60b4 246# TODO Garbage collection doesn't work. Suck it up and live with the
247# inflated DB.
248# after delete => sub {
249# my $self = shift;
250# my $gc = KiokuDB::GC::Naive->new( backend => $self->directory->backend );
251# $self->directory->backend->delete( $gc->garbage->members );
252# };
56cf65bd 253
254sub save {
861c3e27 255 my $self = shift;
256 return $self->store( @_ );
12523041 257}
258
56cf65bd 259sub tradition {
260 my( $self, $id ) = @_;
261 my $obj = $self->lookup( $id );
ad39942e 262 unless( $obj ) {
263 # Try looking up by name.
264 foreach my $item ( $self->traditionlist ) {
265 if( $item->{'name'} eq $id ) {
266 $obj = $self->lookup( $item->{'id'} );
267 last;
268 }
269 }
270 }
271 if( $obj && ref( $obj ) ne 'Text::Tradition' ) {
861c3e27 272 throw( "Retrieved object is a " . ref( $obj ) . ", not a Text::Tradition" );
12523041 273 }
56cf65bd 274 return $obj;
12523041 275}
8d9a1cd8 276
fefeeeda 277sub user_traditionlist {
278 my ($self, $user) = @_;
279
280 my @tlist;
7d52d62b 281 if(ref $user) {
fefeeeda 282 ## We have a user object already, so just fetch its traditions and use tose
7d52d62b 283 foreach my $t (@{ $user->traditions }) {
fefeeeda 284 push( @tlist, { 'id' => $self->object_to_id( $t ),
285 'name' => $t->name } );
286 }
287 return @tlist;
7d52d62b 288 } elsif($user ne 'public') {
289 die "Passed neither a user object nor 'public' to user_traditionlist";
fefeeeda 290 }
291
292 ## Search for all traditions which allow public viewing
293 ## When they exist!
294 # $self->search({ public => 1 });
295
296 ## For now, just fetch all
297 ## (could use all_objects or grep down there?)
298 return $self->traditionlist();
299}
300
98a6cab2 301sub traditionlist {
861c3e27 302 my $self = shift;
fefeeeda 303 my ($user) = @_;
304
305 return $self->user_traditionlist($user) if($user);
306
307 my @tlist;
98a6cab2 308 # If we are using DBI, we can do it the easy way; if not, the hard way.
309 # Easy way still involves making a separate DBI connection. Ew.
0a900793 310 if( $self->dsn =~ /^dbi:(\w+):/ ) {
311 my $dbtype = $1;
98a6cab2 312 my @connection = @{$self->directory->backend->connect_info};
313 # Get rid of KiokuDB-specific arg
314 pop @connection if scalar @connection > 4;
0a900793 315 $connection[3]->{'sqlite_unicode'} = 1 if $dbtype eq 'SQLite';
316 $connection[3]->{'pg_enable_utf8'} = 1 if $dbtype eq 'Pg';
98a6cab2 317 my $dbh = DBI->connect( @connection );
318 my $q = $dbh->prepare( 'SELECT id, name from entries WHERE class = "Text::Tradition"' );
319 $q->execute();
320 while( my @row = $q->fetchrow_array ) {
0a900793 321 my( $id, $name ) = @row;
322 # Horrible horrible hack
323 $name = decode_utf8( $name ) if $dbtype eq 'mysql';
98a6cab2 324 push( @tlist, { 'id' => $row[0], 'name' => $row[1] } );
325 }
326 } else {
327 $self->scan( sub { my $o = shift;
328 push( @tlist, { 'id' => $self->object_to_id( $o ),
329 'name' => $o->name } ) } );
330 }
331 return @tlist;
861c3e27 332}
333
334sub throw {
335 Text::Tradition::Error->throw(
336 'ident' => 'database error',
337 'message' => $_[0],
338 );
339}
340
cf7e4e7b 341
342# has 'directory' => (
343# is => 'rw',
344# isa => 'KiokuX::Model',
345# handles => []
346# );
347
348## TODO: Some of these methods should probably optionally take $user objects
349## instead of hashrefs.
350
351## It also occurs to me that all these methods don't need to be named
352## XX_user, but leaving that way for now incase we merge this code
353## into ::Directory for one-store.
354
355## To die or not to die, on error, this is the question.
356
770f7a2b 357=head2 add_user
cf7e4e7b 358
359Takes a hashref of C<username>, C<password>.
360
361Create a new user object, store in the KiokuDB backend, and return it.
362
363=cut
364
365sub add_user {
366 my ($self, $userinfo) = @_;
367 my $username = $userinfo->{url} || $userinfo->{username};
368 my $password = $userinfo->{password};
369
370 return unless ($username =~ /^https?:/
371 || ($username && $self->validate_password($password))) ;
372
373 my $user = Text::Tradition::User->new(
374 id => $username,
375 password => ($password ? crypt_password($password) : ''),
376 );
377
cf7e4e7b 378 $self->store($user->kiokudb_object_id, $user);
379
380 return $user;
381}
382
383sub create_user {
384 my $self = shift;
385 return $self->add_user(@_);
386}
387
770f7a2b 388=head2 find_user
cf7e4e7b 389
390Takes a hashref of C<username>, optionally C<openid_identifier>.
391
392Fetches the user object for the given username and returns it.
393
394=cut
395
396sub find_user {
397 my ($self, $userinfo) = @_;
398 ## url or display?
399 # 'display' => 'castaway.myopenid.com',
400 # 'url' => 'http://castaway.myopenid.com/',
401 my $username = $userinfo->{url} || $userinfo->{username};
402
df8c12f0 403 ## No logins if user is deactivated (use lookup to fetch to re-activate)
404 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
405 return if($user && !$user->active);
406
407 return $user;
cf7e4e7b 408
409}
410
770f7a2b 411=head2 modify_user
cf7e4e7b 412
413Takes a hashref of C<username> and C<password> (same as add_user).
414
415Retrieves the user, and updates it with the new information. Username
416changing is not currently supported.
417
418Returns the updated user object, or undef if not found.
419
420=cut
421
422sub modify_user {
423 my ($self, $userinfo) = @_;
424 my $username = $userinfo->{username};
425 my $password = $userinfo->{password};
426
427 return unless $username && $self->validate_password($password);
428
cf7e4e7b 429 my $user = $self->find_user({ username => $username });
430 return unless $user;
431
432 $user->password(crypt_password($password));
433
434 $self->update($user);
435
436 return $user;
437}
438
770f7a2b 439=head2 deactivate_user
cf7e4e7b 440
441Takes a hashref of C<username>.
442
443Sets the users C<active> flag to false (0), and sets all traditions
444assigned to them to non-public, updates the storage and returns the
445deactivated user.
446
447Returns undef if user not found.
448
449=cut
450
451sub deactivate_user {
452 my ($self, $userinfo) = @_;
453 my $username = $userinfo->{username};
454
455 return if !$username;
456
457 my $user = $self->find_user({ username => $username });
458 return if !$user;
459
460 $user->active(0);
461 foreach my $tradition (@{ $user->traditions }) {
462 ## Not implemented yet
463 # $tradition->public(0);
464 }
cf7e4e7b 465
466 ## Should we be using Text::Tradition::Directory also?
467 $self->update(@{ $user->traditions });
468
469 $self->update($user);
470
471 return $user;
472}
473
770f7a2b 474=head2 reactivate_user
cf7e4e7b 475
476Takes a hashref of C<username>.
477
478Returns the user object if already activated. Activates (sets the
479active flag to true (1)), updates the storage and returns the user.
480
481Returns undef if the user is not found.
482
483=cut
484
485sub reactivate_user {
486 my ($self, $userinfo) = @_;
487 my $username = $userinfo->{username};
488
489 return if !$username;
490
df8c12f0 491 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
cf7e4e7b 492 return if !$user;
493
494 return $user if $user->active;
495
496 $user->active(1);
497 $self->update($user);
498
499 return $user;
500}
501
770f7a2b 502=head2 delete_user
cf7e4e7b 503
770f7a2b 504CAUTION: Deletes actual data!
cf7e4e7b 505
506Takes a hashref of C<username>.
507
508Returns undef if the user doesn't exist.
509
510Removes the user from the store and returns 1.
511
512=cut
513
514sub delete_user {
515 my ($self, $userinfo) = @_;
516 my $username = $userinfo->{username};
517
518 return if !$username;
519
cf7e4e7b 520 my $user = $self->find_user({ username => $username });
521 return if !$user;
522
523 ## Should we be using Text::Tradition::Directory for this bit?
524 $self->delete( @{ $user->traditions });
525
526 ## Poof, gone.
527 $self->delete($user);
528
529 return 1;
530}
531
770f7a2b 532=head2 validate_password
cf7e4e7b 533
534Takes a password string. Returns true if it is longer than
535L</MIN_PASS_LEN>, false otherwise.
536
537Used internally by L</add_user>.
538
539=cut
540
541sub validate_password {
542 my ($self, $password) = @_;
543
544 return if !$password;
545 return if length($password) < $self->MIN_PASS_LEN;
546
547 return 1;
548}
549
8d9a1cd8 5501;
12523041 551
027d819c 552=head1 LICENSE
553
554This package is free software and is provided "as is" without express
555or implied warranty. You can redistribute it and/or modify it under
556the same terms as Perl itself.
557
558=head1 AUTHOR
559
560Tara L Andrews E<lt>aurum@cpan.orgE<gt>