diff --git a/app/Events/Dataset/DatasetUpdated.php b/app/Events/Dataset/DatasetUpdated.php new file mode 100644 index 0000000..b724f6e --- /dev/null +++ b/app/Events/Dataset/DatasetUpdated.php @@ -0,0 +1,30 @@ +dataset = $dataset; + } +} diff --git a/app/Events/Event.php b/app/Events/Event.php index d59f769..6e88b5b 100755 --- a/app/Events/Event.php +++ b/app/Events/Event.php @@ -1,7 +1,7 @@ -load('titles'); $dataset->load('abstracts'); - $authors = $dataset->authors() + $authors = $dataset->persons() + ->wherePivot('role', 'author') ->orderBy('link_documents_persons.sort_order', 'desc') ->get(); - $contributors = $dataset->contributors() + $contributors = $dataset->persons() + ->wherePivot('role', 'contributor') ->orderBy('link_documents_persons.sort_order', 'desc') ->get(); diff --git a/app/Http/Controllers/Oai/RequestController.php b/app/Http/Controllers/Oai/RequestController.php index fc8ed6e..bc9744e 100644 --- a/app/Http/Controllers/Oai/RequestController.php +++ b/app/Http/Controllers/Oai/RequestController.php @@ -449,7 +449,8 @@ class RequestController extends Controller $xmlModel = new \App\Library\Xml\XmlModel(); $xmlModel->setModel($dataset); $xmlModel->excludeEmptyFields(); - $xmlModel->setXmlCache(new \App\Models\XmlCache()); + $cache = ($dataset->xmlCache) ? $dataset->xmlCache : new \App\Models\XmlCache(); + $xmlModel->setXmlCache($cache); return $xmlModel->getDomDocument()->getElementsByTagName('Rdr_Dataset')->item(0); } diff --git a/app/Http/Controllers/Settings/DatasetController.php b/app/Http/Controllers/Settings/DatasetController.php index 2aa8bff..fbc7638 100644 --- a/app/Http/Controllers/Settings/DatasetController.php +++ b/app/Http/Controllers/Settings/DatasetController.php @@ -12,6 +12,7 @@ use Illuminate\View\View; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\DB; use Illuminate\Http\Request; +use App\Exceptions\GeneralException; class DatasetController extends Controller { @@ -22,7 +23,6 @@ class DatasetController extends Controller public function index(Request $request) : View { - $searchType = $request->input('searchtype'); $builder = Dataset::query(); //$registers = array(); @@ -155,8 +155,11 @@ class DatasetController extends Controller { $dataset = Dataset::findOrFail($id); //$input = $request->all(); - $input = $request->except('licenses', 'titles'); - $dataset->update($input); + $input = $request->except('abstracts', 'licenses', 'titles', '_method', '_token'); + // foreach ($input as $key => $value) { + // $dataset[$key] = $value; + // } + //$dataset->update($input); // $dataset->type = $input['type']; // $dataset->thesis_year_accepted = $input['thesis_year_accepted']; // $dataset->project_id = $input['project_id']; @@ -188,8 +191,17 @@ class DatasetController extends Controller } } - session()->flash('flash_message', 'You have updated 1 dataset!'); - return redirect()->route('settings.document'); + if (! $dataset->isDirty(dataset::UPDATED_AT)) { + $time = new \Illuminate\Support\Carbon(); + $dataset->setUpdatedAt($time); + } + // $dataset->save(); + if ($dataset->update($input)) { + //event(new DatasetUpdated($dataset)); + session()->flash('flash_message', 'You have updated 1 dataset!'); + return redirect()->route('settings.document'); + } + throw new GeneralException(trans('exceptions.backend.dataset.update_error')); } diff --git a/app/Library/Search/SolariumAdapter.php b/app/Library/Search/SolariumAdapter.php index 5af5516..9691e4d 100644 --- a/app/Library/Search/SolariumAdapter.php +++ b/app/Library/Search/SolariumAdapter.php @@ -3,9 +3,12 @@ namespace App\Library\Search; //use App\Library\Util\SolrSearchQuery; -use App\Library\Util\SearchParameter; use App\Library\Search\SearchResult; +use App\Library\Util\SearchParameter; use Illuminate\Support\Facades\Log; +use App\Library\Search\SolariumDocument; +use App\Models\Dataset; +use \Solarium\QueryType\Select\Query\Query; class SolariumAdapter { @@ -50,7 +53,55 @@ class SolariumAdapter return 'solr'; } - public function createQuery() : SearchParameter + public function addDatasetsToIndex($datasets) + { + $datasets = $this->normalizeDocuments($datasets); + $builder = new SolariumDocument($this->options); + + $slices = array_chunk($datasets, 16); + // update documents of every chunk in a separate request + foreach ($slices as $slice) { + $update = $this->client->createUpdate(); + + $updateDocs = array_map(function ($rdrDoc) use ($builder, $update) { + return $builder->toSolrUpdateDocument($rdrDoc, $update->createDocument()); + }, $slice); + + // adding the document to the update query + $update->addDocuments($updateDocs); + // Then commit the update: + $update->addCommit(); + $result = $this->client->update($update); + + //$this->execute($update, 'failed updating slice of documents'); + } + + // finally commit all updates + // $update = $this->client->createUpdate(); + + // $update->addCommit(); + + // $this->execute($update, 'failed committing update of documents'); + + return $this; + } + + protected function normalizeDocuments($documents) + { + if (!is_array($documents)) { + $documents = array($documents); + } + + foreach ($documents as $document) { + if (!($document instanceof Dataset)) { + throw new InvalidArgumentException("invalid dataset in provided set"); + } + } + + return $documents; + } + + public function createQuery(): SearchParameter { return new SearchParameter(); } @@ -63,13 +114,14 @@ class SolariumAdapter return $searchResult; } - protected function applyParametersToSolariumQuery(\Solarium\QueryType\Select\Query\Query $query, SearchParameter $parameters = null, $preferOriginalQuery = false) + protected function applyParametersToSolariumQuery(Query $query, SearchParameter $parameters, $preferOriginalQuery) { if ($parameters) { //$subfilters = $parameters->getSubFilters(); //if ( $subfilters !== null ) { // foreach ( $subfilters as $name => $subfilter ) { - // if ( $subfilter instanceof Opus_Search_Solr_Filter_Raw || $subfilter instanceof Opus_Search_Solr_Solarium_Filter_Complex ) { + // if ( $subfilter instanceof Opus_Search_Solr_Filter_Raw + //|| $subfilter instanceof Opus_Search_Solr_Solarium_Filter_Complex ) { // $query->createFilterQuery( $name ) // ->setQuery( $subfilter->compile( $query ) ); // } @@ -87,14 +139,13 @@ class SolariumAdapter // } // } - $filter = $parameters->getFilter();//"aa" all: '*:*' + $filter = $parameters->getFilter(); //"aa" all: '*:*' if ($filter !== null) { //$query->setStart( intval( $start ) ); //$query->setQuery('%P1%', array($filter)); $query->setQuery($filter); } - $start = $parameters->getStart(); if ($start !== null) { $query->setStart(intval($start)); @@ -154,7 +205,7 @@ class SolariumAdapter // } } - protected function processQuery(\Solarium\QueryType\Select\Query\Query $query) : SearchResult + protected function processQuery(\Solarium\QueryType\Select\Query\Query $query): SearchResult { // send search query to service $request = $this->execute($query, 'failed querying search engine'); diff --git a/app/Library/Search/SolariumDocument.php b/app/Library/Search/SolariumDocument.php new file mode 100644 index 0000000..6203401 --- /dev/null +++ b/app/Library/Search/SolariumDocument.php @@ -0,0 +1,34 @@ +doc[0]; + + $solrDoc->clear(); + foreach ($solrXmlDoc->field as $field) { + $solrDoc->addField(strval($field['name']), strval($field)); + } + + return $solrDoc; + } +} diff --git a/app/Library/Search/SolrDocumentXslt.php b/app/Library/Search/SolrDocumentXslt.php new file mode 100644 index 0000000..3237cbf --- /dev/null +++ b/app/Library/Search/SolrDocumentXslt.php @@ -0,0 +1,79 @@ +load($options['xsltfile']); + + $this->processor = new \XSLTProcessor; + $this->processor->importStyleSheet($xslt); + } catch (Exception $e) { + throw new Exception('invalid XSLT file for deriving Solr documents', 0, $e); + } + } + + public function toSolrDocument(Dataset $rdrDataset, \DOMDocument $solrDoc) + { + if (!($solrDoc instanceof \DOMDocument)) { + throw new Exception('provided Solr document must be instance of DOMDocument'); + } + + $modelXml = $this->getModelXml($rdrDataset);//->saveXML(); + + $solrDoc->preserveWhiteSpace = false; + $solrDoc->loadXML($this->processor->transformToXML($modelXml)); + + // if (Opus_Config::get()->log->prepare->xml) { + // $modelXml->formatOutput = true; + // Opus_Log::get()->debug("input xml\n" . $modelXml->saveXML()); + // $solrDoc->formatOutput = true; + // Opus_Log::get()->debug("transformed solr xml\n" . $solrDoc->saveXML()); + // } + + return $solrDoc; + } + + /** + * Retrieves XML describing model data of provided RDR dataset. + * + * @param Dataset $rdrDataset + * @return DOMDocument + */ + protected function getModelXml(Dataset $rdrDataset) + { + $rdrDataset->fetchValues(); + // Set up caching xml-model and get XML representation of document. + $xmlModel = new \App\Library\Xml\XmlModel(); + //$caching_xml_model = new Opus_Model_Xml; + + //$caching_xml_model->setModel($opusDoc); + $xmlModel->setModel($rdrDataset); + $xmlModel->excludeEmptyFields(); + //$xmlModel->setStrategy(new Opus_Model_Xml_Version1); + //$cache = new Opus_Model_Xml_Cache($opusDoc->hasPlugin('Opus_Document_Plugin_Index')); + //$xmlModel->setXmlCache($cache); + $cache = ($rdrDataset->xmlCache) ? $rdrDataset->xmlCache : new \App\Models\XmlCache(); + $xmlModel->setXmlCache($cache); + + $modelXml = $xmlModel->getDomDocument(); + + // extract fulltext from file and append it to the generated xml. + //$this->attachFulltextToXml($modelXml, $opusDoc->getFile(), $opusDoc->getId()); + + return $modelXml; + } +} diff --git a/app/Library/Xml/XmlModel.php b/app/Library/Xml/XmlModel.php index 7d9b375..9d99f7c 100644 --- a/app/Library/Xml/XmlModel.php +++ b/app/Library/Xml/XmlModel.php @@ -133,12 +133,21 @@ class XmlModel return $domDocument; } else { //create cache relation - $this->cache->fill(array( - 'document_id' => $dataset->id, - 'xml_version' => (int)$this->strategy->getVersion(), - 'server_date_modified' => $dataset->server_date_modified, - 'xml_data' => $domDocument->saveXML() - )); + // $this->cache->updateOrCreate(array( + // 'document_id' => $dataset->id, + // 'xml_version' => (int)$this->strategy->getVersion(), + // 'server_date_modified' => $dataset->server_date_modified, + // 'xml_data' => $domDocument->saveXML() + // )); + + if (!$this->cache->document_id) { + $this->cache->document_id = $dataset->id; + } + + $this->cache->xml_version = (int)$this->strategy->getVersion(); + $this->cache->server_date_modified = $dataset->server_date_modified; + $this->cache->xml_data = $domDocument->saveXML(); + $this->cache->save(); Log::debug(__METHOD__ . ' cache refreshed for ' . get_class($dataset) . '#' . $dataset->id); @@ -161,20 +170,35 @@ class XmlModel Log::debug(__METHOD__ . ' skipping cache for ' . get_class($dataset)); return null; } - //$cached = $this->cache->hasValidEntry( - // $dataset->id, - // (int) $this->strategy->getVersion(), - // $dataset->server_date_modified - //); - - //$cached = false; - $cache = XmlCache::where('document_id', $dataset->id) - ->first();// model or null - if (!$cache) { + $actuallyCached = $this->cache->hasValidEntry( + $dataset->id, + $dataset->server_date_modified + ); + //no actual cache + if (true !== $actuallyCached) { Log::debug(__METHOD__ . ' cache miss for ' . get_class($dataset) . '#' . $dataset->id); return null; - } else { - return $cache->getDomDocument(); } + //cache is actual return it for oai: + Log::debug(__METHOD__ . ' cache hit for ' . get_class($dataset) . '#' . $dataset->id); + try { + //return $this->_cache->get($model->getId(), (int) $this->_strategy->getVersion()); + $cache = XmlCache::where('document_id', $dataset->id)->first(); + return $cache->getDomDocument(); + } catch (Exception $e) { + Log::warning(__METHOD__ . " Access to XML cache failed on " . get_class($dataset) . '#' . $dataset->id . ". Trying to recover."); + } + + return null; + + + // // $cache = XmlCache::where('document_id', $dataset->id) + // // ->first();// model or null + // if (!$cache) { + // Log::debug(__METHOD__ . ' cache miss for ' . get_class($dataset) . '#' . $dataset->id); + // return null; + // } else { + // return $cache->getDomDocument(); + // } } } diff --git a/app/Listeners/DatasetUpdated.php b/app/Listeners/DatasetUpdated.php new file mode 100644 index 0000000..4b7f58e --- /dev/null +++ b/app/Listeners/DatasetUpdated.php @@ -0,0 +1,68 @@ +dataset; + // only index Opus_Document instances + if (false === ($dataset instanceof Dataset)) { + return; + } + if ($dataset->server_state !== 'published') { + // if ($dataset->getServerState() !== 'temporary') { + // $this->removeDocumentFromIndexById($model->getId()); + // } + return; + } + + $this->addDatasetToIndex($dataset); + } + + /** + * Helper method to add dataset to index. + * + * @param Opus_Document $document + * @return void + */ + private function addDatasetToIndex(Dataset $dataset) + { + $datasetId = $dataset->id; + Log::debug(__METHOD__ . ': ' . 'Adding index job for dataset ' . $datasetId . '.'); + + try { + // Opus_Search_Service::selectIndexingService('onDocumentChange') + $service = new SolariumAdapter("solr", config('solarium')); + $service->addDatasetsToIndex($dataset); + } catch (Opus_Search_Exception $e) { + Log::debug(__METHOD__ . ': ' . 'Indexing document ' . $documentId . ' failed: ' . $e->getMessage()); + } catch (InvalidArgumentException $e) { + Log::warning(__METHOD__ . ': ' . $e->getMessage()); + } + } +} diff --git a/app/Models/Dataset.php b/app/Models/Dataset.php index f1d0844..67e0128 100644 --- a/app/Models/Dataset.php +++ b/app/Models/Dataset.php @@ -26,16 +26,17 @@ class Dataset extends Model const UPDATED_AT = 'server_date_modified'; const PUBLISHED_AT = 'server_date_published'; - protected $fillable = [ - 'type', - 'language', - 'server_state', - 'creating_corporation', - 'project_id', - 'embargo_date', - 'belongs_to_bibliography', - ]; - /** + // protected $fillable = [ + // 'type', + // 'language', + // 'server_state', + // 'creating_corporation', + // 'project_id', + // 'embargo_date', + // 'belongs_to_bibliography', + // ]; + protected $guarded = []; + /** * The attributes that should be mutated to dates. * * @var array @@ -54,6 +55,11 @@ class Dataset extends Model // $this->_init(); } + // public function setUpdatedAt($value) + // { + // $this->{static::UPDATED_AT} = $value; + // } + /** * Get the geolocation that owns the dataset. */ @@ -62,6 +68,8 @@ class Dataset extends Model return $this->hasOne(GeolocationBox::class, 'dataset_id', 'id'); } + + /** * Get the project that the dataset belongs to. */ diff --git a/app/Models/XmlCache.php b/app/Models/XmlCache.php index f35bcd0..833edc0 100644 --- a/app/Models/XmlCache.php +++ b/app/Models/XmlCache.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use App\Models\Dataset; +use Illuminate\Support\Facades\DB; class XmlCache extends Model { @@ -13,7 +14,7 @@ class XmlCache extends Model * @var string */ protected $table = 'document_xml_cache'; - public $timestamps = false; + public $timestamps = false; /** @@ -22,7 +23,8 @@ class XmlCache extends Model * @var integer * @access protected */ - protected $primaryKey = null; + //protected $primaryKey = null; + public $primaryKey = 'document_id'; public $incrementing = false; /** @@ -61,22 +63,18 @@ class XmlCache extends Model * @param mixed $serverDateModified * @return bool Returns true on cached hit else false. */ - //public function scopeHasValidEntry($query, $datasetId, $xmlVersion, $serverDateModified) - //{ - // //$select = $this->_table->select()->from($this->_table); - // $query->where('document_id = ?', $datasetId) - // ->where('xml_version = ?', $xmlVersion) - // ->where('server_date_modified = ?', $serverDateModified); + public function hasValidEntry($datasetId, $serverDateModified) + { + $select = DB::table('document_xml_cache'); + $select->where('document_id', '=', $datasetId) + ->where('server_date_modified', '=', $serverDateModified); + + $row = $select->first(); - // $row = $query->get(); - - // if (null === $row) - // { - // return false; - // } - // else - // { - // return true; - // } - //} + if (null === $row) { + return false; + } else { + return true; + } + } } diff --git a/app/Observers/DatasetObserver.php b/app/Observers/DatasetObserver.php index d0b646a..c929517 100644 --- a/app/Observers/DatasetObserver.php +++ b/app/Observers/DatasetObserver.php @@ -5,6 +5,7 @@ namespace App\Observers; //php artisan make:observer DatasetObserver --model=Models\Dataset use App\Models\Dataset; use Illuminate\Support\Facades\Log; +use App\Library\Search\SolariumAdapter; class DatasetObserver { @@ -33,12 +34,12 @@ class DatasetObserver if (false === ($dataset instanceof Dataset)) { return; } - // if ($dataset->getServerState() !== 'published') { - // if ($model->getServerState() !== 'temporary') { - // $this->removeDocumentFromIndexById($model->getId()); - // } - // return; - // } + if ($dataset->server_state !== 'published') { + // if ($dataset->getServerState() !== 'temporary') { + // $this->removeDocumentFromIndexById($model->getId()); + // } + return; + } $this->addDatasetToIndex($dataset); } @@ -85,6 +86,16 @@ class DatasetObserver private function addDatasetToIndex(Dataset $dataset) { $datasetId = $dataset->id; - Log::debug(__METHOD__ . ': ' . 'Adding index job for document ' . $datasetId . '.'); + Log::debug(__METHOD__ . ': ' . 'Adding index job for dataset ' . $datasetId . '.'); + + try { + // Opus_Search_Service::selectIndexingService('onDocumentChange') + $service = new SolariumAdapter("solr", config('solarium')); + $service->addDatasetsToIndex($dataset); + } catch (Opus_Search_Exception $e) { + Log::debug(__METHOD__ . ': ' . 'Indexing document ' . $documentId . ' failed: ' . $e->getMessage()); + } catch (InvalidArgumentException $e) { + Log::warning(__METHOD__ . ': ' . $e->getMessage()); + } } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 7b1ddbd..ca973a3 100755 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -14,6 +14,9 @@ class EventServiceProvider extends ServiceProvider 'App\Events\Event' => [ 'App\Listeners\EventListener', ], + \App\Events\Dataset\DatasetUpdated::class => [ + \App\Listeners\DatasetUpdated::class, + ], ]; /** diff --git a/composer.lock b/composer.lock index 47ddb11..6948635 100755 --- a/composer.lock +++ b/composer.lock @@ -427,26 +427,26 @@ }, { "name": "felixkiss/uniquewith-validator", - "version": "3.1.2", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/felixkiss/uniquewith-validator.git", - "reference": "a27d5823bcf52dac6760638c8d987760212dbf5c" + "reference": "11e3c12758f8f1c335618ab8eabecd338985aff9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/felixkiss/uniquewith-validator/zipball/a27d5823bcf52dac6760638c8d987760212dbf5c", - "reference": "a27d5823bcf52dac6760638c8d987760212dbf5c", + "url": "https://api.github.com/repos/felixkiss/uniquewith-validator/zipball/11e3c12758f8f1c335618ab8eabecd338985aff9", + "reference": "11e3c12758f8f1c335618ab8eabecd338985aff9", "shasum": "" }, "require": { "illuminate/support": "5.*", "illuminate/validation": "5.*", - "php": ">=5.4.0" + "php": ">=5.6.0" }, "require-dev": { - "bossa/phpspec2-expect": "dev-phpspec-3.2", - "phpspec/phpspec": "^3.2" + "bossa/phpspec2-expect": "^2.3", + "phpspec/phpspec": "^3.4" }, "type": "library", "extra": { @@ -476,7 +476,7 @@ "keywords": [ "laravel" ], - "time": "2017-08-06T23:28:53+00:00" + "time": "2019-02-12T18:30:56+00:00" }, { "name": "fideloper/proxy", @@ -2792,16 +2792,16 @@ }, { "name": "yajra/laravel-datatables-oracle", - "version": "v8.13.4", + "version": "v8.13.5", "source": { "type": "git", "url": "https://github.com/yajra/laravel-datatables.git", - "reference": "8aedd88a02599d5d0a4a1a2bb5aac849397dd594" + "reference": "a97a173a52f2b60075f310dac39932faa377fb4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yajra/laravel-datatables/zipball/8aedd88a02599d5d0a4a1a2bb5aac849397dd594", - "reference": "8aedd88a02599d5d0a4a1a2bb5aac849397dd594", + "url": "https://api.github.com/repos/yajra/laravel-datatables/zipball/a97a173a52f2b60075f310dac39932faa377fb4f", + "reference": "a97a173a52f2b60075f310dac39932faa377fb4f", "shasum": "" }, "require": { @@ -2859,7 +2859,7 @@ "jquery", "laravel" ], - "time": "2019-01-29T03:12:37+00:00" + "time": "2019-02-13T01:34:34+00:00" }, { "name": "zizaco/entrust", diff --git a/config/solarium.php b/config/solarium.php index 87086ed..7804099 100644 --- a/config/solarium.php +++ b/config/solarium.php @@ -8,5 +8,6 @@ return [ 'path' => env('SOLR_PATH', '/solr/'), 'core' => env('SOLR_CORE', 'opus4') ] - ] + ], + 'xsltfile' => "solr.xslt" ]; diff --git a/public/solr.xslt b/public/solr.xslt new file mode 100644 index 0000000..c4b1862 --- /dev/null +++ b/public/solr.xslt @@ -0,0 +1,361 @@ + + + + + + + + + + + + + + + + + id + + + + + + + + + + + + + + + + + year + + + + + + + + year_inverted + : + + + + + + + server_date_published + + + + + + + + server_date_modified + + + + + + + + language + + + + + + + title + + + + + title_output + + + + + + + + + abstract + + + + + abstract_output + + + + + + + + + author + + , + + + + + + + author_sort + + + + + + + + + + + + fulltext + + + + + + + has_fulltext + + + + + + + fulltext_id_success + + + + + + + + fulltext_id_failure + + + + + + + + referee + + + + + + + + + + + persons + + + + + + + + + + doctype + + + + + + + subject + + + + + + + + subject + + + + + + + belongs_to_bibliography + + + false + + + true + + + + + + + + + + project + + + + app_area + + + + + + institute + + + + + + + collection_ids + + + + + + + + title_parent + + + + + + + + title_sub + + + + + + + + title_additional + + + + + + + + series_ids + + + + + + series_number_for_id_ + + + + + + + doc_sort_order_for_seriesid_ + + + + + + + + + creating_corporation + + + + + + + + contributing_corporation + + + + + + + + publisher_name + + + + + + + + publisher_place + + + + + + + + identifier + + + + + + + + + + \ No newline at end of file diff --git a/resources/views/frontend/dataset/show.blade.php b/resources/views/frontend/dataset/show.blade.php index e68c5d8..fc0c9be 100644 --- a/resources/views/frontend/dataset/show.blade.php +++ b/resources/views/frontend/dataset/show.blade.php @@ -28,11 +28,11 @@
@endforeach @foreach ($contributors as $contributor) - Contributor: {{ $contributors->full_name }} + Contributor: {{ $contributor->full_name }}
@endforeach @foreach ($submitters as $submitter) - Contributor: {{ $submitter->full_name }} + Submitter: {{ $submitter->full_name }}
@endforeach