diff --git a/.env.example b/.env.example index 6304f222..bab23808 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,8 @@ TESTING_MENU=true GOOGLE_ANALYTICS_ID= INFLUUENT_API_KEY= WOS_TOKEN= +ORCID_TOKEN= +ACADEMIC_ANALYTICS_KEY= DB_CONNECTION=mysql DB_HOST= diff --git a/app/Console/Commands/AddDoiToExistingPublications.php b/app/Console/Commands/AddDoiToExistingPublications.php new file mode 100644 index 00000000..9480e75a --- /dev/null +++ b/app/Console/Commands/AddDoiToExistingPublications.php @@ -0,0 +1,227 @@ +argument('starting_character'); + + $profiles = Cache::remember( + "profiles-starting-with-{$starting_character}", + 15 * 60, + fn() => $this->profilesMissingDoi($starting_character) + ); + + $profiles_bar = $this->output->createProgressBar(count($profiles)); + $profiles_bar->setFormat('debug'); + $profiles_bar->start(); + + $this->lineAndLog("********** {$profiles->count()} Profiles found with publications without DOI ************ \n"); + + foreach ($profiles as $profile) { + + $profile_publications = $profile->data; + + $publications_bar = $this->output->createProgressBar(count($profile->data)); + $publications_bar->setFormat('debug'); + $publications_bar->start(); + + $pubs_service_provider = App::make(AAPublicationsApiServiceProvider::class); + $aa_publications = $pubs_service_provider + ->getCachedPublications($profile->id, $profile->academic_analytics_id); + + $publications_found_in_url = $publications_found_in_title = $publications_found_in_aa = $doi_not_found_counter = 0; + + $this->lineAndLog("********** {$profile_publications->count()} Publications found without DOI for {$profile->full_name} ************\n"); + + foreach ($profile_publications as $publication) { + + $this->lineAndLog( "--------- {$publication->id} -----------\n"); + + $doi = $aa_title = null; + + // Search in the URL + if (!empty($publication->url)) { + $this->lineAndLog("Searching for DOI in URL..."); + $doi = $this->validateDoiRegex($publication->url); + + $this->verifyAndSaveDoi($publication, $doi, 'url', $publications_found_in_url, $publications_found_in_title, $publications_found_in_aa); + + } + + // Search in the title + if (is_null($doi) && !empty($publication->title)) { + $this->lineAndLog("Searching for DOI in the title..."); + $doi = $this->validateDoiRegex(strip_tags(html_entity_decode($publication->title))); + + $this->verifyAndSaveDoi($publication, $doi, 'title', $publications_found_in_url, $publications_found_in_title, $publications_found_in_aa); + + } + + // Match the AA Publication + if (is_null($doi)) { + $this->lineAndLog("Searching for DOI in Academic Analytics..."); + + $aa_pub_found = ProfileData::searchPublicationByTitleAndYear( + $publication->title, + $publication->year, + $aa_publications ?? + $pubs_service_provider->getCachedPublications($profile->id, $profile->academic_analytics_id)); + + $doi = $aa_pub_found[0]; + $aa_title = $aa_pub_found[1]; + + $this->verifyAndSaveDoi($publication, $doi, 'Academic Analytics', $publications_found_in_url, $publications_found_in_title, $publications_found_in_aa, $aa_title); + } + + if (is_null($doi)) { $doi_not_found_counter++; } + + $publications_bar->advance(); + } + + $publications_bar->finish(); + + $this->lineAndLogResults($publications_found_in_url, $publications_found_in_title, $publications_found_in_aa, $doi_not_found_counter, $profile->full_name); + + $profiles_bar->advance(); + + } + + $profiles_bar->finish(); + + return Command::SUCCESS; + } + + /** + * Verify given doi. If the doi is not null, updates the profile_data record and increment the counter of doi's found. + */ + public function verifyAndSaveDoi($publication, $doi, $search_field, &$publications_found_in_url, &$publications_found_in_title, &$publications_found_in_aa, $aa_title = null) + { + + if (is_null($doi)) { + $this->lineAndLog("DOI not found in {$search_field}."); + } + else { + + $publication->updateData(['doi' => $doi]); + if (!is_null($aa_title)) { + $publication->insertData(['aa_title' => $aa_title]); + $this->log("Title {$publication->title} found as {$aa_title} in Academic Analytics Publications\n."); + } + + $publication->save(); + $this->lineAndLog("DOI {$doi} FOUND IN {$search_field} and updated."); + + switch ($search_field) + { + case 'title': + ++$publications_found_in_title; + break; + case 'url': + ++$publications_found_in_url; + break; + case 'Academic Analytics': + ++$publications_found_in_aa; + break; + } + + } + + } + + /** + * Validate DOI regex + * + * @param string $doi_expression + * @return string|null + */ + public function validateDoiRegex($doi_expression) + { + $doi_regex = config('app.doi_regex'); + + $doi = null; + + preg_match($doi_regex, $doi_expression, $matches); + + if (!empty($matches[1])) { + $doi = rtrim(trim($matches[1], "\xC2\xA0"), '.'); + } + + return $doi; + } + + /** + * Retrieves profiles with publications that have DOI missing + * + * @param string $starting_character + * @return Collection + */ + public function profilesMissingDoi(string $starting_character) + { + return Profile::LastNameStartWithCharacter($starting_character) + ->withWhereHas('data', function($q) { + $q->where('type', 'publications') + ->whereNull('data->doi'); + })->get(); + } + + /** + * Output total numbers for each profile processed to the console and log file + */ + public function lineAndLogResults($publications_found_in_url, $publications_found_in_title, $publications_found_in_aa, $doi_not_found_counter, $profile_full_name): void + { + $this->lineAndLog("TOTAL:"); + if ($publications_found_in_url > 0 ) { $this->lineAndLog("{$publications_found_in_url} DOI found by url and added successfully."); } + if ($publications_found_in_title > 0 ) { $this->lineAndLog("{$publications_found_in_title} DOI found by title and added successfully."); } + if ($publications_found_in_aa > 0 ) { $this->lineAndLog("{$publications_found_in_aa} DOI found in Academic Analytics and added successfully."); } + $this->lineAndLog("{$doi_not_found_counter} PUBLICATIONS NOT FOUND.", 'error'); + $this->lineAndLog("***************** End of Report for {$profile_full_name} ********************"); + } + + /** + * Output a message to the console and log file + */ + public function lineAndLog(string $message, string $type = 'info'): void + { + $this->line($message, $type); + Log::$type($message); + } + + /** + * Output a message to the log file + */ + public function Log(string $message, string $type = 'info'): void + { + Log::$type($message); + } +} diff --git a/app/Http/Controllers/ProfilesController.php b/app/Http/Controllers/ProfilesController.php index 4f4115df..e0e55b19 100644 --- a/app/Http/Controllers/ProfilesController.php +++ b/app/Http/Controllers/ProfilesController.php @@ -237,7 +237,6 @@ public function edit(Profile $profile, $section) ->route('profiles.show', $profile->slug) ->with('flash_message', 'Publications updated via ORCID.'); } - $data = $profile->data()->$section()->get(); // if no data, include one item to use as a template diff --git a/app/Http/Controllers/Testing/TestingController.php b/app/Http/Controllers/Testing/TestingController.php index 80716c62..c9a8b5dc 100644 --- a/app/Http/Controllers/Testing/TestingController.php +++ b/app/Http/Controllers/Testing/TestingController.php @@ -74,7 +74,7 @@ public function showLoginAsList(Request $request) * * @param Request $request * @param int $id - * @return \Illuminate\Http\RedirectResponse + * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse */ public function loginAs(Request $request, $id) { diff --git a/app/Http/Livewire/PublicationsImportModal.php b/app/Http/Livewire/PublicationsImportModal.php new file mode 100644 index 00000000..ca91029e --- /dev/null +++ b/app/Http/Livewire/PublicationsImportModal.php @@ -0,0 +1,86 @@ + 'showModal', 'addToEditor', 'removeFromEditor', 'addAllToEditor', 'removeAllFromEditor']; + public Profile $profile; + public bool $modalVisible = false; + public bool $allChecked = false; + public $importedPublications = []; + public $perPage = 10; + public $allPublicationsCount; + + public function showModal() + { + $this->modalVisible = true; + } + + public function addToEditor($publicationId) + { + array_push($this->importedPublications, $publicationId); + $this->emit( 'alert', "Added to the Editor!", 'success'); + } + + public function removeFromEditor($publicationId) + { + if (($key = array_search($publicationId, $this->importedPublications)) !== false) { + unset($this->importedPublications[$key]); + } + $this->emit('alert', "Removed from the Editor!", 'success'); + } + + public function addAllToEditor() + { + $pubs_to_import = App::call([$this, 'getNewPublications'])->whereIn('imported', false); + $this->importedPublications = $pubs_to_import->pluck('id')->all(); + $this->allChecked = true; + $this->emit('JSAddAllToEditor', $pubs_to_import); + } + + public function removeAllFromEditor() + { + $this->reset('importedPublications'); + $this->reset('allChecked'); + } + /** + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getNewPublications(AAPublicationsApiServiceProvider $pubServiceProvider) + { + $aaPublications = $pubServiceProvider->getCachedPublications($this->profile->id, $this->profile->academic_analytics_id); + + return $aaPublications + ->whereNotIn('doi', $this->profile->publications->pluck('data.doi')->filter()->values()); + } + + public function getPublicationsProperty() + { + $aaPublications = App::call([$this, 'getNewPublications']); + + $this->allPublicationsCount = count($aaPublications); + + $aaPublications + ->each(function($elem, $key) { + $elem->imported = (in_array($elem->id, $this->importedPublications)); + }); + + return $aaPublications->sortByDesc('sort_order')->paginate($this->perPage); + } + + public function render() + { + return view('livewire.publications-import-modal'); + } +} diff --git a/app/Http/Livewire/ShowModal.php b/app/Http/Livewire/ShowModal.php new file mode 100644 index 00000000..ef8257d0 --- /dev/null +++ b/app/Http/Livewire/ShowModal.php @@ -0,0 +1,18 @@ +emit('loadPublications'); + } +} diff --git a/app/Interfaces/PublicationsApiInterface.php b/app/Interfaces/PublicationsApiInterface.php new file mode 100644 index 00000000..5202ecdd --- /dev/null +++ b/app/Interfaces/PublicationsApiInterface.php @@ -0,0 +1,49 @@ + $this->id, 'type' => 'publications', 'data->title' => $record['work-summary'][0]['title']['title']['value'], 'sort_order' => $record['work-summary'][0]['publication-date']['year']['value'] ?? null, + 'data->doi' => $doi, ],[ 'data' => [ 'url' => $url, + 'doi' => $doi, 'title' => $record['work-summary'][0]['title']['title']['value'], 'year' => $record['work-summary'][0]['publication-date']['year']['value'] ?? null, 'type' => ucwords(strtolower(str_replace('_', ' ', $record['work-summary'][0]['type']))), @@ -292,7 +298,7 @@ public function updateDatum($section, $request) /** * Strips HTML tags from the specified data field. - * + * * This is only for output purposes and does not save. * * @param array $data_names : the names of data properties to strip tags from @@ -427,7 +433,7 @@ public function scopeContaining($query, $search, $type = null) /** * Query scope for Profiles that have the given tag (case-insensitive) - * + * * @param \Illuminate\Database\Eloquent\Builder $query * @param string $tag * @return \Illuminate\Database\Eloquent\Builder @@ -467,9 +473,9 @@ public function scopeFromSchoolId($query, $school_id) }); } /** - * Query scope for Profiles and eager load students whose application is pending review + * Query scope for Profiles and eager load students whose application is pending review * for a given semester. - * + * * @param \Illuminate\Database\Eloquent\Builder $query * @param string $semester * @return \Illuminate\Database\Eloquent\Builder @@ -484,9 +490,9 @@ public function scopeEagerStudentsPendingReviewWithSemester($query, $semester) } /** - * Query scope for Profiles with students whose application is pending review + * Query scope for Profiles with students whose application is pending review * for a given semester. - * + * * @param \Illuminate\Database\Eloquent\Builder $query * @param string $semester * @return \Illuminate\Database\Eloquent\Builder @@ -500,6 +506,17 @@ public function scopeStudentsPendingReviewWithSemester($query, $semester) }); } + /** + * Query Scope for Profiles with last name starting with a given character. + * + * @param string $starting_character + */ + public function scopeLastNameStartWithCharacter($query, string $starting_character) + { + return $query->where('last_name', 'like', strtolower($starting_character).'%') + ->orWhere('last_name', 'like', strtoupper($starting_character).'%'); + } + /////////////////////////////////// // Mutators & Virtual Attributes // /////////////////////////////////// @@ -518,7 +535,7 @@ public function getNameAttribute() /** * Get the full image URL. ($this->full_image_url) * - * @return string + * @return \Illuminate\Contracts\Routing\UrlGenerator|string */ public function getFullImageUrlAttribute() { @@ -528,7 +545,7 @@ public function getFullImageUrlAttribute() /** * Get the full image URL. ($this->large_image_url) * - * @return string + * @return \Illuminate\Contracts\Routing\UrlGenerator|string */ public function getLargeImageUrlAttribute() { @@ -538,7 +555,7 @@ public function getLargeImageUrlAttribute() /** * Get the image URL. ($this->image_url) * - * @return string + * @return \Illuminate\Contracts\Routing\UrlGenerator|string */ public function getImageUrlAttribute() { @@ -548,7 +565,7 @@ public function getImageUrlAttribute() /** * Get the image thumbnail URL. ($this->image_thumb_url) * - * @return string + * @return \Illuminate\Contracts\Routing\UrlGenerator|string */ public function getImageThumbUrlAttribute() { @@ -558,7 +575,7 @@ public function getImageThumbUrlAttribute() /** * Get the banner image thumbnail. ($this->banner_url) * - * @return string + * @return \Illuminate\Contracts\Routing\UrlGenerator|string */ public function getBannerUrlAttribute() { @@ -585,6 +602,25 @@ public function getApiUrlAttribute() return route('api.index', ['person' => $this->slug, 'with_data' => true]); } + /** + * Get the academic_analytics_id. ($this->academic_analytics_id) + * + * @return int + */ + public function getAcademicAnalyticsIdAttribute() + { + if (!isset($this->information->first()->data['academic_analytics_id'])) { + $domain = config('app.email_address_domain'); + $client_faculty_id = isset($this->user) ? "{$this->user->name}@{$domain}" : ''; + $academic_analytics_id = App::make(AAPublicationsApiServiceProvider::class)->getPersonId($client_faculty_id); + $this->information()->first()->updateData(['academic_analytics_id' => $academic_analytics_id]); + return $academic_analytics_id; + } + else { + return $this->information->first()->data['academic_analytics_id']; + } + } + /////////////// // Relations // /////////////// diff --git a/app/ProfileData.php b/app/ProfileData.php index 9685d6b7..674d2a4e 100644 --- a/app/ProfileData.php +++ b/app/ProfileData.php @@ -11,6 +11,7 @@ use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; +use Illuminate\Contracts\Routing\UrlGenerator; class ProfileData extends Model implements HasMedia, Auditable { @@ -172,6 +173,27 @@ public static function uniqueValuesFor(string $type, string $key) ->filter(); } + /** + * Search for a publication by year and title within a given publications collection + * Return array with DOI and matching title + * @return Array + */ + public static function searchPublicationByTitleAndYear($title, $year, $publications) + { + $title = strip_tags(html_entity_decode($title)); + $publication_found = $aa_doi = $aa_title = null; + $publication_found = $publications->filter(function ($item) use ($title, $year) { + return (str_contains(strtolower($title), strtolower(strip_tags(html_entity_decode($item['data']['title'])))) && $year==$item['data']['year']); + similar_text(strtolower($title), strtolower(strip_tags(html_entity_decode($item['data']['title']))), $percent); + return (($percent > 80) && ($year==$item['data']['year'])); + }); + if ($publication_found->count() == 1) { + $aa_doi = $publication_found->first()->doi; + $aa_title = $publication_found->first()->title; + } + return [$aa_doi, $aa_title]; + } + /////////////////////////////////// // Mutators & Virtual Attributes // /////////////////////////////////// @@ -184,7 +206,7 @@ public function getImageAttribute() /** * Get the image URL. ($this->image_url) * - * @return string + * @return \Illuminate\Contracts\Routing\UrlGenerator|string */ public function getImageUrlAttribute() { diff --git a/app/Providers/AAPublicationsApiServiceProvider.php b/app/Providers/AAPublicationsApiServiceProvider.php new file mode 100644 index 00000000..2d1952b5 --- /dev/null +++ b/app/Providers/AAPublicationsApiServiceProvider.php @@ -0,0 +1,117 @@ +client = New Client(); + } + + /** + * Receive an attribute to get from the API the identifier necessary to retrieve the publications + * @param string $client_faculty_id + * @return mixed|true + */ + public function getPersonId($client_faculty_id) + { + $url = "https://api.academicanalytics.com/person/GetPersonIdByClientFacultyId?clientFacultyId=$client_faculty_id"; + + $datum = $this->sendRequest($url); + + return !$datum ?: $datum['PersonId']; + } + + /** + * Retrieve the publications from the API to return a ProfileData model collection + * @param int $faculty_id + */ + public function getPublications(int $faculty_id) + { + /** @var Collection */ + $publications = new Collection(); + + $url = "https://api.academicanalytics.com/person/" . $faculty_id . "/articles"; + + $datum = $this->sendRequest($url); + + foreach($datum as $record) { + $url = NULL; + + if(isset($record['DOI'])) { + $doi = $record['DOI']; + $url = "http://doi.org/$doi"; + } + + $new_record = ProfileData::newModelInstance([ + 'type' => 'publications', + 'sort_order' => $record['ArticleYear'] ?? null, + 'data' => [ + 'doi' => $doi ?? null, + 'url' => $url ?? null, + 'title' => $record['ArticleTitle'], + 'year' => $record['ArticleYear'] ?? null, + 'type' => "JOURNAL_ARTICLE", //ucwords(strtolower(str_replace('_', ' ', $record['work-summary'][0]['type']))), + 'status' => 'Published' + ], + ]); + $new_record->id = $record['ArticleId']; + $new_record->imported = false; + $publications->push($new_record); + } + return $publications; + } + + /** + * Cache publications for the current profile + * @return \Illuminate\Database\Eloquent\Collection + */ + public function getCachedPublications($profile_id, $academic_analytics_id) + { + return Cache::remember( + "profile{$profile_id}-AA-pubs", + 15 * 60, + fn() => $this->getPublications($academic_analytics_id) + ); + } + + /** + * Make a get request to the API + */ + public function sendRequest(string $url): array|false + { + $response = $this->getHttpClient()->get($url, [ + 'headers' => [ + 'apikey' => config('app.academic_analytics_key'), + 'Accept' => 'application/json' + ], + 'http_errors' => false, // don't throw exceptions for 4xx,5xx responses + ]); + + if($response->getStatusCode() != 200){ + return false; + } + + return json_decode($response->getBody()->getContents(), true); + } + + /** + * Return the service provider http client + */ + public function getHttpClient(): Client + { + return $this->client; + } + +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5091b6af..2edfb4a0 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,6 +5,8 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Blade; use Illuminate\Pagination\Paginator; +use Illuminate\Support\Collection; +use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\View; use App\Setting; use Collective\Html\FormFacade as Form; @@ -24,6 +26,21 @@ public function boot() Paginator::defaultView('vendor.pagination.default'); Paginator::defaultSimpleView('vendor.pagination.simple-default'); + Collection::macro('paginate', function($perPage, $total = null, $page = null, $pageName = 'page') { + $page = $page ?: LengthAwarePaginator::resolveCurrentPage($pageName); + + return new LengthAwarePaginator( + $this->forPage($page, $perPage), + $total ?: $this->count(), + $perPage, + $page, + [ + 'path' => LengthAwarePaginator::resolveCurrentPath(), + 'pageName' => $pageName, + ] + ); + }); + Form::component('inlineErrors', 'errors.inline', ['field_name']); View::composer([ @@ -57,6 +74,7 @@ public function register() if ($this->app->environment(['local', 'dev'])) { $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class); $this->app->register(TelescopeServiceProvider::class); + $this->app->register(AAPublicationsApiServiceProvider::class); } } } diff --git a/config/app.php b/config/app.php index fe3b8316..00fe922f 100644 --- a/config/app.php +++ b/config/app.php @@ -67,6 +67,15 @@ /** API response cache-control headers */ 'api_cache_control' => env('API_CACHE_CONTROL', 'public;no_cache;etag'), + /** academic analytics api key */ + 'academic_analytics_key' => env('ACADEMIC_ANALYTICS_KEY', false), + + /** email address domain */ + 'email_address_domain' => env('EMAIL_ADDRESS_DOMAIN'), + + /** DOI - Digital Object Identifier regex */ + 'doi_regex' => env('DOI_REGEX', '/(10[.][0-9]{4,}[^\s"\/<>]*\/[^\s"<>]+)/'), + /* |-------------------------------------------------------------------------- | Application Debug Mode @@ -91,6 +100,7 @@ 'PUSHER_APP_KEY', 'PUSHER_APP_SECRET', 'AWS_SECRET', + 'ACADEMIC_ANALYTICS_KEY' ], '_SERVER' => [ 'APP_KEY', diff --git a/config/queue.php b/config/queue.php deleted file mode 100644 index 4d83ebd0..00000000 --- a/config/queue.php +++ /dev/null @@ -1,85 +0,0 @@ - env('QUEUE_DRIVER', 'sync'), - - /* - |-------------------------------------------------------------------------- - | Queue Connections - |-------------------------------------------------------------------------- - | - | Here you may configure the connection information for each server that - | is used by your application. A default configuration has been added - | for each back-end shipped with Laravel. You are free to add more. - | - */ - - 'connections' => [ - - 'sync' => [ - 'driver' => 'sync', - ], - - 'database' => [ - 'driver' => 'database', - 'table' => 'jobs', - 'queue' => 'default', - 'retry_after' => 90, - ], - - 'beanstalkd' => [ - 'driver' => 'beanstalkd', - 'host' => 'localhost', - 'queue' => 'default', - 'retry_after' => 90, - ], - - 'sqs' => [ - 'driver' => 'sqs', - 'key' => 'your-public-key', - 'secret' => 'your-secret-key', - 'prefix' => 'https://sqs.us-east-1.amazonaws.com/your-account-id', - 'queue' => 'your-queue-name', - 'region' => 'us-east-1', - ], - - 'redis' => [ - 'driver' => 'redis', - 'connection' => 'default', - 'queue' => 'default', - 'retry_after' => 90, - ], - - ], - - /* - |-------------------------------------------------------------------------- - | Failed Queue Jobs - |-------------------------------------------------------------------------- - | - | These options configure the behavior of failed queue job logging so you - | can control which database and table are used to store the jobs that - | have failed. You may change them to any database / table you wish. - | - */ - - 'failed' => [ - 'database' => env('DB_CONNECTION', 'mysql'), - 'table' => 'failed_jobs', - ], - -]; diff --git a/database/factories/ProfileDataFactory.php b/database/factories/ProfileDataFactory.php index d2e83897..0de6f4bc 100644 --- a/database/factories/ProfileDataFactory.php +++ b/database/factories/ProfileDataFactory.php @@ -44,6 +44,7 @@ public function definition() return [ 'email' => Profile::find($attributes['profile_id'])->user->email, 'title' => Profile::find($attributes['profile_id'])->user->title, + 'academic_analytics_id' => $this->faker->optional()->randomNumber(4), 'phone' => $this->faker->phoneNumber(), 'secondary_title' => '', 'tertiary_title' => '', @@ -57,7 +58,7 @@ public function definition() 'public' => 1, ]; } - + /** * Data Type "presentations"/"publications"/"projects"/"additionals" * @@ -71,6 +72,7 @@ public function general() 'url' => $this->faker->url(), 'title' => $this->faker->sentence(), 'year' => $this->faker->year(), + 'doi' => $this->faker->optional()->regexify(config('app.doi_regex')), ], ]; }); diff --git a/psalm-ignore.xml b/psalm-ignore.xml index e2100ab9..c5b9937b 100644 --- a/psalm-ignore.xml +++ b/psalm-ignore.xml @@ -1,5 +1,5 @@ - + shell_exec('git rev-parse --verify HEAD') @@ -35,6 +35,12 @@ user + + + count + forPage + + $this->hasMany(StudentFeedback::class)->where('type', 'feedback') diff --git a/public/js/app.js b/public/js/app.js index e62dae97..2d5260d3 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -96,6 +96,9 @@ var profiles = function ($, undefined) { var new_id = String(item_template.parentElement.dataset.nextRowId--); var new_item = item_template.cloneNode(true); new_item.dataset.rowId = new_id; + if ('customId' in options) { + new_item.dataset.customId = options.customId; + } (_new_item$querySelect = new_item.querySelectorAll('input:not([type="button"]), textarea, select')) === null || _new_item$querySelect === void 0 ? void 0 : _new_item$querySelect.forEach(function (el) { el.id = el.id.replace(old_id, new_id); el.setAttribute('name', el.name.replace(old_id, new_id)); @@ -144,6 +147,7 @@ var profiles = function ($, undefined) { item_template.parentElement.append(new_item); } $(new_item).slideDown(); + return new_item; } }; @@ -198,7 +202,7 @@ var profiles = function ($, undefined) { /** * Display a dynamic toast alert - * + * * @param {String} message - the message to display * @param {String} type - alert type, e.g. primary, success, warning, danger, and etc. */ @@ -231,7 +235,7 @@ var profiles = function ($, undefined) { /** * Deobfuscate an email address - * + * * @param {String} obfuscated_mail_address - the obfuscated * @see App\Helpers\Utils for obfuscation strategy */ @@ -342,7 +346,7 @@ var profiles = function ($, undefined) { /** * Registers and enables any profile pickers on the page - * + * * @return {void} */ var registerProfilePickers = function registerProfilePickers() { @@ -355,7 +359,7 @@ var profiles = function ($, undefined) { /** * Registers and enables any tag editors on the page. - * + * * @return {void} */ var registerTagEditors = function registerTagEditors() { @@ -416,7 +420,7 @@ var profiles = function ($, undefined) { /** * Posts updated tags to the API URL. - * + * * @param {jQuery} $select the select element containing the tags * @return {void} */ diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 52e06711..7e3b4202 100644 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -1,5 +1,5 @@ { - "/js/app.js": "/js/app.js?id=7016e570ea34a31f7e30f5120d91f289", + "/js/app.js": "/js/app.js?id=040567751801cbf3ac4b41bbc313a863", "/js/manifest.js": "/js/manifest.js?id=dc9ead3d7857b522d7de22d75063453c", "/css/app.css": "/css/app.css?id=5eb63529f3d8546e0526c6b37f8f3294", "/js/vendor.js": "/js/vendor.js?id=9238f5269fb159b66b64234c397b119b" diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js index 0ad11a30..626e3205 100644 --- a/resources/assets/js/app.js +++ b/resources/assets/js/app.js @@ -90,6 +90,10 @@ var profiles = (function ($, undefined) { let new_item = item_template.cloneNode(true); new_item.dataset.rowId = new_id; + if('customId' in options) { + new_item.dataset.customId = options.customId; + } + new_item.querySelectorAll('input:not([type="button"]), textarea, select')?.forEach((el) => { el.id = el.id.replace(old_id, new_id); el.setAttribute('name', el.name.replace(old_id, new_id)); @@ -134,6 +138,8 @@ var profiles = (function ($, undefined) { item_template.parentElement.append(new_item); } $(new_item).slideDown(); + + return new_item; } } @@ -190,7 +196,7 @@ var profiles = (function ($, undefined) { /** * Display a dynamic toast alert - * + * * @param {String} message - the message to display * @param {String} type - alert type, e.g. primary, success, warning, danger, and etc. */ @@ -211,7 +217,7 @@ var profiles = (function ($, undefined) { flash_message.innerHTML = message; flash_container.appendChild(flash_message); - + flash_message.addEventListener('click', (e) => {e.target.style.display = 'none'}); $(flash_message).animate({opacity: 0}, { duration: 5000, @@ -221,7 +227,7 @@ var profiles = (function ($, undefined) { /** * Deobfuscate an email address - * + * * @param {String} obfuscated_mail_address - the obfuscated * @see App\Helpers\Utils for obfuscation strategy */ @@ -320,7 +326,7 @@ var profiles = (function ($, undefined) { /** * Registers and enables any profile pickers on the page - * + * * @return {void} */ let registerProfilePickers = () => { @@ -333,7 +339,7 @@ var profiles = (function ($, undefined) { /** * Registers and enables any tag editors on the page. - * + * * @return {void} */ var registerTagEditors = function () { @@ -391,7 +397,7 @@ var profiles = (function ($, undefined) { /** * Posts updated tags to the API URL. - * + * * @param {jQuery} $select the select element containing the tags * @return {void} */ diff --git a/resources/views/livewire/partials/_import-publication.blade.php b/resources/views/livewire/partials/_import-publication.blade.php new file mode 100644 index 00000000..c48dc643 --- /dev/null +++ b/resources/views/livewire/partials/_import-publication.blade.php @@ -0,0 +1,108 @@ +
+ @if($pub->imported == true) + + @else + + @endif + + @pushOnce('row-scripts') + + @endPushOnce +
diff --git a/resources/views/livewire/publications-import-modal.blade.php b/resources/views/livewire/publications-import-modal.blade.php new file mode 100644 index 00000000..213e1790 --- /dev/null +++ b/resources/views/livewire/publications-import-modal.blade.php @@ -0,0 +1,83 @@ +
+ + + @push('scripts') + + @endpush + @stack('row-scripts') +
diff --git a/resources/views/profiles/edit/_autosort_info.blade.php b/resources/views/profiles/edit/_autosort_info.blade.php index 0233fba3..a2c149a4 100644 --- a/resources/views/profiles/edit/_autosort_info.blade.php +++ b/resources/views/profiles/edit/_autosort_info.blade.php @@ -9,5 +9,9 @@ To manually sort all entries, leave the Year field empty (and include it as a part of @if(isset($suggestion)) {{ $suggestion }} @else another field @endisset instead). +
  • + To review your publications available for import from external sources: + +
  • diff --git a/resources/views/profiles/edit/layout.blade.php b/resources/views/profiles/edit/layout.blade.php index 0c971312..45417710 100644 --- a/resources/views/profiles/edit/layout.blade.php +++ b/resources/views/profiles/edit/layout.blade.php @@ -11,7 +11,6 @@
    @yield('form') -
    @include('profiles.edit._insert_button', ['type' => 'append']) diff --git a/resources/views/profiles/edit/publications.blade.php b/resources/views/profiles/edit/publications.blade.php index 9ec8d4e2..5aadce99 100644 --- a/resources/views/profiles/edit/publications.blade.php +++ b/resources/views/profiles/edit/publications.blade.php @@ -11,14 +11,20 @@
    @include('profiles.edit._actions')
    -
    +
    +
    + + +
    +
    diff --git a/tests/Feature/ApiTest.php b/tests/Feature/ApiTest.php index 9617694d..0e27da0e 100644 --- a/tests/Feature/ApiTest.php +++ b/tests/Feature/ApiTest.php @@ -149,6 +149,7 @@ protected function profileInfoJsonFragment($profile_datum): array 'profile_id' => $profile_datum->profile_id, 'sort_order' => $profile_datum->sort_order, 'data' => [ + 'academic_analytics_id' => $profile_datum->academic_analytics_id, 'email' => $profile_datum->email, 'phone' => $profile_datum->phone, 'title' => $profile_datum->title, diff --git a/tests/Feature/Livewire/PublicationsImportModalTest.php b/tests/Feature/Livewire/PublicationsImportModalTest.php new file mode 100644 index 00000000..f1cb2b29 --- /dev/null +++ b/tests/Feature/Livewire/PublicationsImportModalTest.php @@ -0,0 +1,117 @@ +mockProfile(); + $aa_publications = $this->mockPublications(15, $title, $year); + $pub_service_provider_mock = $this->mockPublicationsServiceProvider($profile, $expected_aa_id, $aa_publications); + + $this->instance(AAPublicationsApiServiceProvider::class, $pub_service_provider_mock); + + $this->assertNotNull($profile->information()->first()->data['academic_analytics_id']); + + $route = route('profiles.edit', array_merge(['profile' => $profile->slug, 'section' => 'publications'])); + $user = $this->loginAsUserWithRole('profiles_editor'); + + $this->assertTrue($user->can('update', $profile)); + + $response = $this->followingRedirects()->get($route) + ->assertSessionHasNoErrors() + ->assertStatus(200) + ->assertViewIs('profiles.edit') + ->assertSeeLivewire('publications-import-modal'); + + $filtered_aa_publications = $aa_publications->whereNotIn('doi', $profile->publications->pluck('data.doi')->filter()->values()); + + $this->assertCount($filtered_aa_publications->count(), $aa_publications); + + $component = Livewire::test(PublicationsImportModal::class, ['profile' => $profile, 'modalVisible' => true]); + + $this->assertNotEmpty($component->publications); + $this->assertEquals($component->publications[0]->title, $aa_publications->sortByDesc('sort_order')[0]->title); + + $component + ->assertStatus(200) + ->assertSet('modalVisible', true) + ->assertSet('publications', $component->publications) + ->assertSee($aa_publications[0]->title) + ->assertSee($aa_publications[9]->title) + ->emit('addToEditor', $component->publications[2]->id) + ->assertHasNoErrors() + ->assertSeeHtml('emit('removeFromEditor', $component->publications[2]->id) + ->assertSeeHtml('assertNotContains($aa_publications[2]->id, $component->importedPublications); + $this->assertFalse($component->publications[2]->imported); + $this->assertNull($component->publications[10]); + + $component + ->call('nextPage') + ->assertSet('page', 2) + ->assertSee($aa_publications[10]->title) + ->assertSee($aa_publications[14]->title) + ->emit('addToEditor', $component->publications[10]->id) + ->assertHasNoErrors() + ->assertSeeHtml('assertCount(1, $component->importedPublications); + $this->assertContains($component->publications[10]->id, $component->importedPublications); + $this->assertTrue($component->publications[10]->imported); + $this->assertNull($component->publications[9]); + + $component + ->emit('removeFromEditor', $component->publications[10]->id) + ->assertSeeHtml('emit('addAllToEditor'); + + foreach($component->publications as $pub){ + $component->assertSeeHtml('assertTrue($pub->imported); + $this->assertContains($pub->id, $component->importedPublications); + } + + $component + ->emit('removeFromEditor', $component->publications[14]->id) + ->assertSeeHtml('emit('removeAllFromEditor'); + + foreach($component->publications as $pub){ + $component->assertSeeHtml('assertFalse($pub->imported); + $this->assertNotContains($pub->id, $component->importedPublications); + } + } + +} diff --git a/tests/Feature/PublicationsApiTest.php b/tests/Feature/PublicationsApiTest.php new file mode 100644 index 00000000..3fa04b72 --- /dev/null +++ b/tests/Feature/PublicationsApiTest.php @@ -0,0 +1,105 @@ +count(4) + ->hasData() + ->has(ProfileData::factory() + ->count(20) + ->sequence(['type' => 'publications']) + ->general(),'data') + ->create(['last_name' => 'Rogers']); + + $profiles_starting_with = Profile::LastNameStartWithCharacter("r")->get(); + + $this->assertCount($profiles->count(), $profiles_starting_with); + + $publications_with_missing_doi = ProfileData::where('type', 'publications') + ->whereNull('data->doi') + ->distinct('profile_id') + ->get('profile_id'); + + $profiles_with_missing_doi = Profile::LastNameStartWithCharacter("r") + ->withWhereHas('data', function($q) { + $q->where('type', 'publications') + ->whereNull('data->doi'); + })->get(); + + $this->assertCount($profiles_with_missing_doi->count(), $publications_with_missing_doi); + + $expected_aa_id = 1234; + + foreach($profiles_with_missing_doi as $profile) { + $this->loginAsUser($profile->user); + if (!isset($profile->information()->first()->data['academic_analytics_id'])) { + $mock_aa_id = $this->mockPublicationsServiceProvider($profile, $expected_aa_id)->getPersonId($profile->user->email); + $this->assertEquals($profile->information()->first()->data['academic_analytics_id'], $mock_aa_id); + } + $this->assertNotNull($profile->information()->first()->data['academic_analytics_id']); + + echo "\nAA ID is {$profile->information()->first()->data['academic_analytics_id']}\n"; + } + } + + /** @test */ + public function testSearchPublication() + { + $title = "ABC Hello, how is it going ? 123"; + $year = 2018; + $doi_regex = config('app.doi_regex') ?? '/(10[.][0-9]{4,}[^\s"\/<>]*\/[^\s"<>]+)/'; + + $mock_aa_publications = $this->mockPublications(5, $title, $year); + + $similar = $different = 0; + + foreach($mock_aa_publications as $item) { + $str_contains = str_contains(strtolower($title), strtolower(strip_tags(html_entity_decode($item->title)))) && $year==$item->year; + $similar_text =similar_text(strtolower($title), strtolower(strip_tags(html_entity_decode($item->title))), $percent); + $similar_text = (($percent > 80) && ($year==$item->year)); + + if ($similar_text || $str_contains) { + $this->assertTrue(($percent >= 80) && ($year == $item->year)); + $similar++; + } + if (($percent < 80) || ($year != $item->year)) { + $this->assertFalse($similar_text); + $this->assertFalse($str_contains); + $different++; + } + + preg_match($doi_regex, $item->doi, $matches); + + $this->assertNotEmpty($matches); + $this->assertEquals(1, preg_match($doi_regex, rtrim(trim($matches[1], "\xC2\xA0"), '.'))); + } + + $this->assertGreaterThanOrEqual(60, $similar/5*100); + $this->assertLessThanOrEqual(40, $different/5*100); + } +} diff --git a/tests/Feature/Traits/MockPublicationsApi.php b/tests/Feature/Traits/MockPublicationsApi.php new file mode 100644 index 00000000..5148f57b --- /dev/null +++ b/tests/Feature/Traits/MockPublicationsApi.php @@ -0,0 +1,95 @@ +makePartial() + ->shouldReceive('getPersonId') + ->andReturnUsing(function () use (&$profile, $expected_res) { + if (!isset($profile->information->first()->data['academic_analytics_id'])) { + $profile->information()->first()->updateData(['academic_analytics_id' => $expected_res]); + return $expected_res; + } + else { + return $profile->information()->first()->data['academic_analytics_id']; } + }) + ->shouldReceive('getCachedPublications') + ->andReturn($publications) + ->getMock(); + + return $pub_service_provider_mock; + } + + public function mockProfile() + { + $profile = Profile::factory() + ->hasData() + ->has(ProfileData::factory() + ->count(5) + ->state(function(){ + return ['type' => 'publications', 'data->doi' => '10.1000/123456']; + }) + ->general(),'data') + ->create(); + + $expected_res = 1234; + + $this->mockPublicationsServiceProvider($profile, $expected_res)->getPersonId($profile->user->email); + + return $profile; + } + + public function mockPublications($count, $title, $year) { + + return ProfileData::factory() + ->count($count) + ->sequence( + ['data->title' => $title], //Similar = True + ['data->title' => $title . ' ' . $this->faker->words(1, true)], //Similar = True + ['data->title' => $this->faker->words(1, true) . ' ' . $title], //Similar = True + ['data->title' => $this->faker->sentence()], //Similar = False + ['data->title' => $this->faker->words(2, true) . ' ' . $title . ' ' . $this->faker->words(2, true) ], //Similar = False + ) + ->sequence( + ['data->doi' => '10.1000/abc123xyz.24'], + ['data->doi' => '10.1038/issn.1476-4687'], + ['data->doi' => '10.1111/dome.12082'], + ['data->doi' => '10.1111/josi.12122'], + ) + ->state(new Sequence (fn ($sequence) => ['id' => $sequence->index])) + ->make([ + 'type' => 'publications', + 'sort_order' => $year, + 'data->url' => $this->faker->url(), + 'data->year' => $year, + 'data->type' => 'Journal', + 'data->status' => 'published', + 'imported' => false, + ]); + } + + /** + * Clean up the testing environment before the next test. + * + * @return void + */ + protected function tearDown(): void + { + // fix for the config() helper not resolving in tests using Mockery + $config = app('config'); + parent::tearDown(); + app()->instance('config', $config); + } +}