Add support for modifying users to add/change roles
[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) = @_;
370 my $username = $userinfo->{url} || $userinfo->{username};
371 my $password = $userinfo->{password};
7cb56251 372 my $role = $userinfo->{role} || 'user';
cf7e4e7b 373
374 return unless ($username =~ /^https?:/
375 || ($username && $self->validate_password($password))) ;
376
377 my $user = Text::Tradition::User->new(
378 id => $username,
379 password => ($password ? crypt_password($password) : ''),
7cb56251 380 role => $role,
cf7e4e7b 381 );
382
cf7e4e7b 383 $self->store($user->kiokudb_object_id, $user);
384
385 return $user;
386}
387
388sub create_user {
389 my $self = shift;
390 return $self->add_user(@_);
391}
392
770f7a2b 393=head2 find_user
cf7e4e7b 394
395Takes a hashref of C<username>, optionally C<openid_identifier>.
396
397Fetches the user object for the given username and returns it.
398
399=cut
400
401sub find_user {
402 my ($self, $userinfo) = @_;
403 ## url or display?
404 # 'display' => 'castaway.myopenid.com',
405 # 'url' => 'http://castaway.myopenid.com/',
406 my $username = $userinfo->{url} || $userinfo->{username};
407
df8c12f0 408 ## No logins if user is deactivated (use lookup to fetch to re-activate)
409 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
410 return if($user && !$user->active);
411
412 return $user;
cf7e4e7b 413
414}
415
770f7a2b 416=head2 modify_user
cf7e4e7b 417
418Takes a hashref of C<username> and C<password> (same as add_user).
419
420Retrieves the user, and updates it with the new information. Username
421changing is not currently supported.
422
423Returns the updated user object, or undef if not found.
424
425=cut
426
427sub modify_user {
428 my ($self, $userinfo) = @_;
429 my $username = $userinfo->{username};
430 my $password = $userinfo->{password};
4d4c5789 431 my $role = $userinfo->{role};
cf7e4e7b 432
4d4c5789 433 return unless $username;
434 return if($password && !$self->validate_password($password));
cf7e4e7b 435
cf7e4e7b 436 my $user = $self->find_user({ username => $username });
437 return unless $user;
438
4d4c5789 439 if($password) {
440 $user->password(crypt_password($password));
441 }
442 if($role) {
443 $user->role($role);
444 }
cf7e4e7b 445
446 $self->update($user);
447
448 return $user;
449}
450
770f7a2b 451=head2 deactivate_user
cf7e4e7b 452
453Takes a hashref of C<username>.
454
455Sets the users C<active> flag to false (0), and sets all traditions
456assigned to them to non-public, updates the storage and returns the
457deactivated user.
458
459Returns undef if user not found.
460
461=cut
462
463sub deactivate_user {
464 my ($self, $userinfo) = @_;
465 my $username = $userinfo->{username};
466
467 return if !$username;
468
469 my $user = $self->find_user({ username => $username });
470 return if !$user;
471
472 $user->active(0);
473 foreach my $tradition (@{ $user->traditions }) {
474 ## Not implemented yet
475 # $tradition->public(0);
476 }
cf7e4e7b 477
478 ## Should we be using Text::Tradition::Directory also?
479 $self->update(@{ $user->traditions });
480
481 $self->update($user);
482
483 return $user;
484}
485
770f7a2b 486=head2 reactivate_user
cf7e4e7b 487
488Takes a hashref of C<username>.
489
490Returns the user object if already activated. Activates (sets the
491active flag to true (1)), updates the storage and returns the user.
492
493Returns undef if the user is not found.
494
495=cut
496
497sub reactivate_user {
498 my ($self, $userinfo) = @_;
499 my $username = $userinfo->{username};
500
501 return if !$username;
502
df8c12f0 503 my $user = $self->lookup(Text::Tradition::User->id_for_user($username));
cf7e4e7b 504 return if !$user;
505
506 return $user if $user->active;
507
508 $user->active(1);
509 $self->update($user);
510
511 return $user;
512}
513
770f7a2b 514=head2 delete_user
cf7e4e7b 515
770f7a2b 516CAUTION: Deletes actual data!
cf7e4e7b 517
518Takes a hashref of C<username>.
519
520Returns undef if the user doesn't exist.
521
522Removes the user from the store and returns 1.
523
524=cut
525
526sub delete_user {
527 my ($self, $userinfo) = @_;
528 my $username = $userinfo->{username};
529
530 return if !$username;
531
cf7e4e7b 532 my $user = $self->find_user({ username => $username });
533 return if !$user;
534
535 ## Should we be using Text::Tradition::Directory for this bit?
536 $self->delete( @{ $user->traditions });
537
538 ## Poof, gone.
539 $self->delete($user);
540
541 return 1;
542}
543
770f7a2b 544=head2 validate_password
cf7e4e7b 545
546Takes a password string. Returns true if it is longer than
547L</MIN_PASS_LEN>, false otherwise.
548
549Used internally by L</add_user>.
550
551=cut
552
553sub validate_password {
554 my ($self, $password) = @_;
555
556 return if !$password;
557 return if length($password) < $self->MIN_PASS_LEN;
558
559 return 1;
560}
561
8d9a1cd8 5621;
12523041 563
027d819c 564=head1 LICENSE
565
566This package is free software and is provided "as is" without express
567or implied warranty. You can redistribute it and/or modify it under
568the same terms as Perl itself.
569
570=head1 AUTHOR
571
572Tara L Andrews E<lt>aurum@cpan.orgE<gt>