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