diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba63f9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +.php_cs.cache +composer.phar +/vendor/ diff --git a/.tx/config b/.tx/config new file mode 100644 index 0000000..0dbe2ff --- /dev/null +++ b/.tx/config @@ -0,0 +1,9 @@ +[main] +host = https://www.transifex.com + +[omeka-s.group] +file_filter = languages/.po +source_file = languages/template.pot +source_lang = en +minimum_perc = 10 +type = PO diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d2fdf60 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,517 @@ + CeCILL FREE SOFTWARE LICENSE AGREEMENT + +Version 2.1 dated 2013-06-21 + + + Notice + +This Agreement is a Free Software license agreement that is the result +of discussions between its authors in order to ensure compliance with +the two main principles guiding its drafting: + + * firstly, compliance with the principles governing the distribution + of Free Software: access to source code, broad rights granted to users, + * secondly, the election of a governing law, French law, with which it + is conformant, both as regards the law of torts and intellectual + property law, and the protection that it offers to both authors and + holders of the economic rights over software. + +The authors of the CeCILL (for Ce[a] C[nrs] I[nria] L[ogiciel] L[ibre]) +license are: + +Commissariat à l'énergie atomique et aux énergies alternatives - CEA, a +public scientific, technical and industrial research establishment, +having its principal place of business at 25 rue Leblanc, immeuble Le +Ponant D, 75015 Paris, France. + +Centre National de la Recherche Scientifique - CNRS, a public scientific +and technological establishment, having its principal place of business +at 3 rue Michel-Ange, 75794 Paris cedex 16, France. + +Institut National de Recherche en Informatique et en Automatique - +Inria, a public scientific and technological establishment, having its +principal place of business at Domaine de Voluceau, Rocquencourt, BP +105, 78153 Le Chesnay cedex, France. + + + Preamble + +The purpose of this Free Software license agreement is to grant users +the right to modify and redistribute the software governed by this +license within the framework of an open source distribution model. + +The exercising of this right is conditional upon certain obligations for +users so as to preserve this status for all subsequent redistributions. + +In consideration of access to the source code and the rights to copy, +modify and redistribute granted by the license, users are provided only +with a limited warranty and the software's author, the holder of the +economic rights, and the successive licensors only have limited liability. + +In this respect, the risks associated with loading, using, modifying +and/or developing or reproducing the software by the user are brought to +the user's attention, given its Free Software status, which may make it +complicated to use, with the result that its use is reserved for +developers and experienced professionals having in-depth computer +knowledge. Users are therefore encouraged to load and test the +suitability of the software as regards their requirements in conditions +enabling the security of their systems and/or data to be ensured and, +more generally, to use and operate it in the same conditions of +security. This Agreement may be freely reproduced and published, +provided it is not altered, and that no provisions are either added or +removed herefrom. + +This Agreement may apply to any or all software for which the holder of +the economic rights decides to submit the use thereof to its provisions. + +Frequently asked questions can be found on the official website of the +CeCILL licenses family (http://www.cecill.info/index.en.html) for any +necessary clarification. + + + Article 1 - DEFINITIONS + +For the purpose of this Agreement, when the following expressions +commence with a capital letter, they shall have the following meaning: + +Agreement: means this license agreement, and its possible subsequent +versions and annexes. + +Software: means the software in its Object Code and/or Source Code form +and, where applicable, its documentation, "as is" when the Licensee +accepts the Agreement. + +Initial Software: means the Software in its Source Code and possibly its +Object Code form and, where applicable, its documentation, "as is" when +it is first distributed under the terms and conditions of the Agreement. + +Modified Software: means the Software modified by at least one +Contribution. + +Source Code: means all the Software's instructions and program lines to +which access is required so as to modify the Software. + +Object Code: means the binary files originating from the compilation of +the Source Code. + +Holder: means the holder(s) of the economic rights over the Initial +Software. + +Licensee: means the Software user(s) having accepted the Agreement. + +Contributor: means a Licensee having made at least one Contribution. + +Licensor: means the Holder, or any other individual or legal entity, who +distributes the Software under the Agreement. + +Contribution: means any or all modifications, corrections, translations, +adaptations and/or new functions integrated into the Software by any or +all Contributors, as well as any or all Internal Modules. + +Module: means a set of sources files including their documentation that +enables supplementary functions or services in addition to those offered +by the Software. + +External Module: means any or all Modules, not derived from the +Software, so that this Module and the Software run in separate address +spaces, with one calling the other when they are run. + +Internal Module: means any or all Module, connected to the Software so +that they both execute in the same address space. + +GNU GPL: means the GNU General Public License version 2 or any +subsequent version, as published by the Free Software Foundation Inc. + +GNU Affero GPL: means the GNU Affero General Public License version 3 or +any subsequent version, as published by the Free Software Foundation Inc. + +EUPL: means the European Union Public License version 1.1 or any +subsequent version, as published by the European Commission. + +Parties: mean both the Licensee and the Licensor. + +These expressions may be used both in singular and plural form. + + + Article 2 - PURPOSE + +The purpose of the Agreement is the grant by the Licensor to the +Licensee of a non-exclusive, transferable and worldwide license for the +Software as set forth in Article 5 <#scope> hereinafter for the whole +term of the protection granted by the rights over said Software. + + + Article 3 - ACCEPTANCE + +3.1 The Licensee shall be deemed as having accepted the terms and +conditions of this Agreement upon the occurrence of the first of the +following events: + + * (i) loading the Software by any or all means, notably, by + downloading from a remote server, or by loading from a physical medium; + * (ii) the first time the Licensee exercises any of the rights granted + hereunder. + +3.2 One copy of the Agreement, containing a notice relating to the +characteristics of the Software, to the limited warranty, and to the +fact that its use is restricted to experienced users has been provided +to the Licensee prior to its acceptance as set forth in Article 3.1 +<#accepting> hereinabove, and the Licensee hereby acknowledges that it +has read and understood it. + + + Article 4 - EFFECTIVE DATE AND TERM + + + 4.1 EFFECTIVE DATE + +The Agreement shall become effective on the date when it is accepted by +the Licensee as set forth in Article 3.1 <#accepting>. + + + 4.2 TERM + +The Agreement shall remain in force for the entire legal term of +protection of the economic rights over the Software. + + + Article 5 - SCOPE OF RIGHTS GRANTED + +The Licensor hereby grants to the Licensee, who accepts, the following +rights over the Software for any or all use, and for the term of the +Agreement, on the basis of the terms and conditions set forth hereinafter. + +Besides, if the Licensor owns or comes to own one or more patents +protecting all or part of the functions of the Software or of its +components, the Licensor undertakes not to enforce the rights granted by +these patents against successive Licensees using, exploiting or +modifying the Software. If these patents are transferred, the Licensor +undertakes to have the transferees subscribe to the obligations set +forth in this paragraph. + + + 5.1 RIGHT OF USE + +The Licensee is authorized to use the Software, without any limitation +as to its fields of application, with it being hereinafter specified +that this comprises: + + 1. permanent or temporary reproduction of all or part of the Software + by any or all means and in any or all form. + + 2. loading, displaying, running, or storing the Software on any or all + medium. + + 3. entitlement to observe, study or test its operation so as to + determine the ideas and principles behind any or all constituent + elements of said Software. This shall apply when the Licensee + carries out any or all loading, displaying, running, transmission or + storage operation as regards the Software, that it is entitled to + carry out hereunder. + + + 5.2 ENTITLEMENT TO MAKE CONTRIBUTIONS + +The right to make Contributions includes the right to translate, adapt, +arrange, or make any or all modifications to the Software, and the right +to reproduce the resulting software. + +The Licensee is authorized to make any or all Contributions to the +Software provided that it includes an explicit notice that it is the +author of said Contribution and indicates the date of the creation thereof. + + + 5.3 RIGHT OF DISTRIBUTION + +In particular, the right of distribution includes the right to publish, +transmit and communicate the Software to the general public on any or +all medium, and by any or all means, and the right to market, either in +consideration of a fee, or free of charge, one or more copies of the +Software by any means. + +The Licensee is further authorized to distribute copies of the modified +or unmodified Software to third parties according to the terms and +conditions set forth hereinafter. + + + 5.3.1 DISTRIBUTION OF SOFTWARE WITHOUT MODIFICATION + +The Licensee is authorized to distribute true copies of the Software in +Source Code or Object Code form, provided that said distribution +complies with all the provisions of the Agreement and is accompanied by: + + 1. a copy of the Agreement, + + 2. a notice relating to the limitation of both the Licensor's warranty + and liability as set forth in Articles 8 and 9, + +and that, in the event that only the Object Code of the Software is +redistributed, the Licensee allows effective access to the full Source +Code of the Software for a period of at least three years from the +distribution of the Software, it being understood that the additional +acquisition cost of the Source Code shall not exceed the cost of the +data transfer. + + + 5.3.2 DISTRIBUTION OF MODIFIED SOFTWARE + +When the Licensee makes a Contribution to the Software, the terms and +conditions for the distribution of the resulting Modified Software +become subject to all the provisions of this Agreement. + +The Licensee is authorized to distribute the Modified Software, in +source code or object code form, provided that said distribution +complies with all the provisions of the Agreement and is accompanied by: + + 1. a copy of the Agreement, + + 2. a notice relating to the limitation of both the Licensor's warranty + and liability as set forth in Articles 8 and 9, + +and, in the event that only the object code of the Modified Software is +redistributed, + + 3. a note stating the conditions of effective access to the full source + code of the Modified Software for a period of at least three years + from the distribution of the Modified Software, it being understood + that the additional acquisition cost of the source code shall not + exceed the cost of the data transfer. + + + 5.3.3 DISTRIBUTION OF EXTERNAL MODULES + +When the Licensee has developed an External Module, the terms and +conditions of this Agreement do not apply to said External Module, that +may be distributed under a separate license agreement. + + + 5.3.4 COMPATIBILITY WITH OTHER LICENSES + +The Licensee can include a code that is subject to the provisions of one +of the versions of the GNU GPL, GNU Affero GPL and/or EUPL in the +Modified or unmodified Software, and distribute that entire code under +the terms of the same version of the GNU GPL, GNU Affero GPL and/or EUPL. + +The Licensee can include the Modified or unmodified Software in a code +that is subject to the provisions of one of the versions of the GNU GPL, +GNU Affero GPL and/or EUPL and distribute that entire code under the +terms of the same version of the GNU GPL, GNU Affero GPL and/or EUPL. + + + Article 6 - INTELLECTUAL PROPERTY + + + 6.1 OVER THE INITIAL SOFTWARE + +The Holder owns the economic rights over the Initial Software. Any or +all use of the Initial Software is subject to compliance with the terms +and conditions under which the Holder has elected to distribute its work +and no one shall be entitled to modify the terms and conditions for the +distribution of said Initial Software. + +The Holder undertakes that the Initial Software will remain ruled at +least by this Agreement, for the duration set forth in Article 4.2 <#term>. + + + 6.2 OVER THE CONTRIBUTIONS + +The Licensee who develops a Contribution is the owner of the +intellectual property rights over this Contribution as defined by +applicable law. + + + 6.3 OVER THE EXTERNAL MODULES + +The Licensee who develops an External Module is the owner of the +intellectual property rights over this External Module as defined by +applicable law and is free to choose the type of agreement that shall +govern its distribution. + + + 6.4 JOINT PROVISIONS + +The Licensee expressly undertakes: + + 1. not to remove, or modify, in any manner, the intellectual property + notices attached to the Software; + + 2. to reproduce said notices, in an identical manner, in the copies of + the Software modified or not. + +The Licensee undertakes not to directly or indirectly infringe the +intellectual property rights on the Software of the Holder and/or +Contributors, and to take, where applicable, vis-à-vis its staff, any +and all measures required to ensure respect of said intellectual +property rights of the Holder and/or Contributors. + + + Article 7 - RELATED SERVICES + +7.1 Under no circumstances shall the Agreement oblige the Licensor to +provide technical assistance or maintenance services for the Software. + +However, the Licensor is entitled to offer this type of services. The +terms and conditions of such technical assistance, and/or such +maintenance, shall be set forth in a separate instrument. Only the +Licensor offering said maintenance and/or technical assistance services +shall incur liability therefor. + +7.2 Similarly, any Licensor is entitled to offer to its licensees, under +its sole responsibility, a warranty, that shall only be binding upon +itself, for the redistribution of the Software and/or the Modified +Software, under terms and conditions that it is free to decide. Said +warranty, and the financial terms and conditions of its application, +shall be subject of a separate instrument executed between the Licensor +and the Licensee. + + + Article 8 - LIABILITY + +8.1 Subject to the provisions of Article 8.2, the Licensee shall be +entitled to claim compensation for any direct loss it may have suffered +from the Software as a result of a fault on the part of the relevant +Licensor, subject to providing evidence thereof. + +8.2 The Licensor's liability is limited to the commitments made under +this Agreement and shall not be incurred as a result of in particular: +(i) loss due the Licensee's total or partial failure to fulfill its +obligations, (ii) direct or consequential loss that is suffered by the +Licensee due to the use or performance of the Software, and (iii) more +generally, any consequential loss. In particular the Parties expressly +agree that any or all pecuniary or business loss (i.e. loss of data, +loss of profits, operating loss, loss of customers or orders, +opportunity cost, any disturbance to business activities) or any or all +legal proceedings instituted against the Licensee by a third party, +shall constitute consequential loss and shall not provide entitlement to +any or all compensation from the Licensor. + + + Article 9 - WARRANTY + +9.1 The Licensee acknowledges that the scientific and technical +state-of-the-art when the Software was distributed did not enable all +possible uses to be tested and verified, nor for the presence of +possible defects to be detected. In this respect, the Licensee's +attention has been drawn to the risks associated with loading, using, +modifying and/or developing and reproducing the Software which are +reserved for experienced users. + +The Licensee shall be responsible for verifying, by any or all means, +the suitability of the product for its requirements, its good working +order, and for ensuring that it shall not cause damage to either persons +or properties. + +9.2 The Licensor hereby represents, in good faith, that it is entitled +to grant all the rights over the Software (including in particular the +rights set forth in Article 5 <#scope>). + +9.3 The Licensee acknowledges that the Software is supplied "as is" by +the Licensor without any other express or tacit warranty, other than +that provided for in Article 9.2 <#good-faith> and, in particular, +without any warranty as to its commercial value, its secured, safe, +innovative or relevant nature. + +Specifically, the Licensor does not warrant that the Software is free +from any error, that it will operate without interruption, that it will +be compatible with the Licensee's own equipment and software +configuration, nor that it will meet the Licensee's requirements. + +9.4 The Licensor does not either expressly or tacitly warrant that the +Software does not infringe any third party intellectual property right +relating to a patent, software or any other property right. Therefore, +the Licensor disclaims any and all liability towards the Licensee +arising out of any or all proceedings for infringement that may be +instituted in respect of the use, modification and redistribution of the +Software. Nevertheless, should such proceedings be instituted against +the Licensee, the Licensor shall provide it with technical and legal +expertise for its defense. Such technical and legal expertise shall be +decided on a case-by-case basis between the relevant Licensor and the +Licensee pursuant to a memorandum of understanding. The Licensor +disclaims any and all liability as regards the Licensee's use of the +name of the Software. No warranty is given as regards the existence of +prior rights over the name of the Software or as regards the existence +of a trademark. + + + Article 10 - TERMINATION + +10.1 In the event of a breach by the Licensee of its obligations +hereunder, the Licensor may automatically terminate this Agreement +thirty (30) days after notice has been sent to the Licensee and has +remained ineffective. + +10.2 A Licensee whose Agreement is terminated shall no longer be +authorized to use, modify or distribute the Software. However, any +licenses that it may have granted prior to termination of the Agreement +shall remain valid subject to their having been granted in compliance +with the terms and conditions hereof. + + + Article 11 - MISCELLANEOUS + + + 11.1 EXCUSABLE EVENTS + +Neither Party shall be liable for any or all delay, or failure to +perform the Agreement, that may be attributable to an event of force +majeure, an act of God or an outside cause, such as defective +functioning or interruptions of the electricity or telecommunications +networks, network paralysis following a virus attack, intervention by +government authorities, natural disasters, water damage, earthquakes, +fire, explosions, strikes and labor unrest, war, etc. + +11.2 Any failure by either Party, on one or more occasions, to invoke +one or more of the provisions hereof, shall under no circumstances be +interpreted as being a waiver by the interested Party of its right to +invoke said provision(s) subsequently. + +11.3 The Agreement cancels and replaces any or all previous agreements, +whether written or oral, between the Parties and having the same +purpose, and constitutes the entirety of the agreement between said +Parties concerning said purpose. No supplement or modification to the +terms and conditions hereof shall be effective as between the Parties +unless it is made in writing and signed by their duly authorized +representatives. + +11.4 In the event that one or more of the provisions hereof were to +conflict with a current or future applicable act or legislative text, +said act or legislative text shall prevail, and the Parties shall make +the necessary amendments so as to comply with said act or legislative +text. All other provisions shall remain effective. Similarly, invalidity +of a provision of the Agreement, for any reason whatsoever, shall not +cause the Agreement as a whole to be invalid. + + + 11.5 LANGUAGE + +The Agreement is drafted in both French and English and both versions +are deemed authentic. + + + Article 12 - NEW VERSIONS OF THE AGREEMENT + +12.1 Any person is authorized to duplicate and distribute copies of this +Agreement. + +12.2 So as to ensure coherence, the wording of this Agreement is +protected and may only be modified by the authors of the License, who +reserve the right to periodically publish updates or new versions of the +Agreement, each with a separate number. These subsequent versions may +address new issues encountered by Free Software. + +12.3 Any Software distributed under a given version of the Agreement may +only be subsequently distributed under the same version of the Agreement +or a subsequent version, subject to the provisions of Article 5.3.4 +<#compatibility>. + + + Article 13 - GOVERNING LAW AND JURISDICTION + +13.1 The Agreement is governed by French law. The Parties agree to +endeavor to seek an amicable solution to any disagreements or disputes +that may arise during the performance of the Agreement. + +13.2 Failing an amicable solution within two (2) months as from their +occurrence, and unless emergency proceedings are necessary, the +disagreements or disputes shall be referred to the Paris Courts having +jurisdiction, by the more diligent Party. diff --git a/Module.php b/Module.php new file mode 100644 index 0000000..b9f1d85 --- /dev/null +++ b/Module.php @@ -0,0 +1,1233 @@ +addAclRules(); + + // Allows to manage batch processes. + $entityManager = $this->getServiceLocator()->get('Omeka\EntityManager'); + $entityManager->getEventManager()->addEventListener( + Events::preFlush, + new DetachOrphanGroupEntities + ); + } + + public function install(ServiceLocatorInterface $serviceLocator) + { + // @todo Replace the two tables "group_user" and "group_resource" by one + // "grouping" with one column "entity_type": it will simplify a lot of + // thing, but will this improve performance (search with a ternary key)? + // This will be checked if a new group of something is needed (for sites). + $sql = <<<'SQL' +CREATE TABLE groups ( + id INT AUTO_INCREMENT NOT NULL, + name VARCHAR(190) NOT NULL, + comment LONGTEXT DEFAULT NULL, + UNIQUE INDEX UNIQ_F06D39705E237E06 (name), + PRIMARY KEY(id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB; +CREATE TABLE group_resource ( + group_id INT NOT NULL, + resource_id INT NOT NULL, + INDEX IDX_B5A1B869FE54D947 (group_id), + INDEX IDX_B5A1B86989329D25 (resource_id), + PRIMARY KEY(group_id, resource_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB; +CREATE TABLE group_user ( + group_id INT NOT NULL, + user_id INT NOT NULL, + INDEX IDX_A4C98D39FE54D947 (group_id), + INDEX IDX_A4C98D39A76ED395 (user_id), + PRIMARY KEY(group_id, user_id) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB; +ALTER TABLE group_resource ADD CONSTRAINT FK_B5A1B869FE54D947 FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE CASCADE; +ALTER TABLE group_resource ADD CONSTRAINT FK_B5A1B86989329D25 FOREIGN KEY (resource_id) REFERENCES resource (id) ON DELETE CASCADE; +ALTER TABLE group_user ADD CONSTRAINT FK_A4C98D39FE54D947 FOREIGN KEY (group_id) REFERENCES groups (id) ON DELETE CASCADE; +ALTER TABLE group_user ADD CONSTRAINT FK_A4C98D39A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE; +SQL; + $connection = $serviceLocator->get('Omeka\Connection'); + $sqls = array_filter(array_map('trim', explode(';', $sql))); + foreach ($sqls as $sql) { + $connection->exec($sql); + } + } + + public function uninstall(ServiceLocatorInterface $serviceLocator) + { + $sql = <<<'SQL' +DROP TABLE IF EXISTS `group_user`; +DROP TABLE IF EXISTS `group_resource`; +DROP TABLE IF EXISTS `groups`; +SQL; + $connection = $serviceLocator->get('Omeka\Connection'); + $sqls = array_filter(array_map('trim', explode(';', $sql))); + foreach ($sqls as $sql) { + $connection->exec($sql); + } + } + + /** + * Add ACL rules for this module. + */ + protected function addAclRules() + { + $services = $this->getServiceLocator(); + $acl = $services->get('Omeka\Acl'); + + // Everybody can read groups, but not view them. + $roles = $acl->getRoles(); + $entityRights = ['read']; + $adapterRights = ['search', 'read']; + $acl->allow(null, Group::class, $entityRights); + $acl->allow(null, GroupUser::class, $entityRights); + $acl->allow(null, GroupResource::class, $entityRights); + // Deny access to the api for non admin. + // $acl->deny(null, [\Group\Api\Adapter\GroupAdapter::class], null); + + // Only admin can manage groups. + $adminRoles = [Acl::ROLE_GLOBAL_ADMIN, Acl::ROLE_SITE_ADMIN]; + $entityRights = ['read', 'create', 'update', 'delete']; + // The right "assign" is used to display the form or not. + $groupEntityRights = ['read', 'create', 'update', 'delete', 'assign']; + $adapterRights = ['search', 'read', 'create', 'update', 'delete']; + $controllerRights = ['show', 'browse', 'add', 'edit', 'delete', 'delete-confirm']; + $acl->allow($adminRoles, Group::class, $entityRights); + $acl->allow($adminRoles, GroupUser::class, $groupEntityRights); + $acl->allow($adminRoles, GroupResource::class, $groupEntityRights); + $acl->allow($adminRoles, GroupAdapter::class, $adapterRights); + $acl->allow($adminRoles, GroupController::class, $controllerRights); + } + + public function attachListeners(SharedEventManagerInterface $sharedEventManager) + { + $services = $this->getServiceLocator(); + $settings = $services->get('Omeka\Settings'); + $config = $services->get('Config'); + $recursiveItemSets = $config[strtolower(__NAMESPACE__)]['config']['group_recursive_item_sets']; + $recursiveItems = $config[strtolower(__NAMESPACE__)]['config']['group_recursive_items']; + + // Add the Group term definition. + $sharedEventManager->attach( + '*', + 'api.context', + function (Event $event) { + $context = $event->getParam('context'); + $context['o-module-group'] = 'http://omeka.org/s/vocabs/module/group#'; + $event->setParam('context', $context); + } + ); + + // Bypass the core filter for media (detach two events of Omeka\Module). + // The listeners can't be cleared without a module weighting system. + $listeners = $sharedEventManager->getListeners([MediaAdapter::class], 'api.search.query'); + $sharedEventManager->detach( + [$listeners[1][0][0], 'filterMedia'], + MediaAdapter::class + ); + $sharedEventManager->attach( + MediaAdapter::class, + 'api.search.query', + [$this, 'filterMedia'], + 100 + ); + $sharedEventManager->attach( + MediaAdapter::class, + 'api.find.query', + [$this, 'filterMedia'], + 100 + ); + + // Add the group part to the representation. + $representations = [ + UserRepresentation::class, + ItemSetRepresentation::class, + ItemRepresentation::class, + MediaRepresentation::class, + ]; + foreach ($representations as $representation) { + $sharedEventManager->attach( + $representation, + 'rep.resource.json', + [$this, 'filterEntityJsonLd'] + ); + } + + $adapters = [ + UserAdapter::class, + ItemSetAdapter::class, + ItemAdapter::class, + MediaAdapter::class, + ]; + foreach ($adapters as $adapter) { + // Add the group filter to the search. + $sharedEventManager->attach( + $adapter, + 'api.search.query', + [$this, 'searchQuery'] + ); + + // The event "api.*.post" is used to avoid some flush issues. + $sharedEventManager->attach( + $adapter, + 'api.create.post', + [$this, 'handleCreatePost'] + ); + $sharedEventManager->attach( + $adapter, + 'api.update.post', + [$this, 'handleUpdatePost'] + ); + // Required for partial batch, since requests are filtered by core. + $sharedEventManager->attach( + $adapter, + 'api.batch_update.post', + [$this, 'handleBatchUpdatePost'] + ); + } + // The deletion is managed automatically when not recursive. + if ($recursiveItemSets) { + $sharedEventManager->attach( + ItemSetAdapter::class, + 'api.delete.pre', + [$this, 'handleRecursiveDeleteItemSetPre'] + ); + } + + // Add headers to group views. + $sharedEventManager->attach( + GroupController::class, + 'view.show.before', + [$this, 'addHeadersAdmin'] + ); + $sharedEventManager->attach( + GroupController::class, + 'view.browse.before', + [$this, 'addHeadersAdmin'] + ); + + // Add the group element form to the user form. + $sharedEventManager->attach( + \Omeka\Form\UserForm::class, + 'form.add_elements', + [$this, 'addUserFormElement'] + ); + $sharedEventManager->attach( + \Omeka\Form\UserForm::class, + 'form.add_input_filters', + [$this, 'addUserFormFilter'] + ); + // FIXME Use the autoset of the values (in a fieldset) and remove this. + $sharedEventManager->attach( + 'Omeka\Controller\Admin\User', + 'view.edit.form.before', + [$this, 'addUserFormValue'] + ); + + // Add the show groups to the show admin pages. + $sharedEventManager->attach( + 'Omeka\Controller\Admin\User', + 'view.show.after', + [$this, 'viewShowAfterUser'] + ); + + // Add the groups form to the resource batch edit form. + $sharedEventManager->attach( + \Omeka\Form\UserBatchUpdateForm::class, + 'form.add_elements', + [$this, 'addBatchUpdateFormElement'] + ); + $sharedEventManager->attach( + \Omeka\Form\UserBatchUpdateForm::class, + 'form.add_input_filters', + [$this, 'addBatchUpdateFormFilter'] + ); + $sharedEventManager->attach( + \Omeka\Form\ResourceBatchUpdateForm::class, + 'form.add_elements', + [$this, 'addBatchUpdateFormElement'] + ); + $sharedEventManager->attach( + \Omeka\Form\ResourceBatchUpdateForm::class, + 'form.add_input_filters', + [$this, 'addBatchUpdateFormFilter'] + ); + + if ($recursiveItemSets) { + $controllers = [ + 'Omeka\Controller\Admin\ItemSet', + ]; + } elseif ($recursiveItems) { + $controllers = [ + 'Omeka\Controller\Admin\ItemSet', + 'Omeka\Controller\Admin\Item', + ]; + } else { + $controllers = [ + 'Omeka\Controller\Admin\ItemSet', + 'Omeka\Controller\Admin\Item', + 'Omeka\Controller\Admin\Media', + ]; + } + foreach ($controllers as $controller) { + // Add the group element form to the resource form. + $sharedEventManager->attach( + $controller, + 'view.add.section_nav', + [$this, 'addTab'] + ); + $sharedEventManager->attach( + $controller, + 'view.edit.section_nav', + [$this, 'addTab'] + ); + $sharedEventManager->attach( + $controller, + 'view.add.form.after', + [$this, 'displayGroupResourceForm'] + ); + $sharedEventManager->attach( + $controller, + 'view.edit.form.after', + [$this, 'displayGroupResourceForm'] + ); + } + + $controllers = [ + 'Omeka\Controller\Admin\ItemSet', + 'Omeka\Controller\Admin\Item', + 'Omeka\Controller\Admin\Media', + ]; + foreach ($controllers as $controller) { + // Add the show groups to the resource show admin pages. + $sharedEventManager->attach( + $controller, + 'view.show.section_nav', + [$this, 'addTab'] + ); + + // Add the show groups to the show admin pages. + $sharedEventManager->attach( + $controller, + 'view.show.after', + [$this, 'viewShowAfterResource'] + ); + } + + $controllers = [ + 'Omeka\Controller\Admin\User', + 'Omeka\Controller\Admin\ItemSet', + 'Omeka\Controller\Admin\Item', + 'Omeka\Controller\Admin\Media', + ]; + foreach ($controllers as $controller) { + // Add the show groups to the browse admin pages (details). + $sharedEventManager->attach( + $controller, + 'view.details', + [$this, 'viewDetails'] + ); + + // Filter the search filters for the advanced search pages. + $sharedEventManager->attach( + $controller, + 'view.search.filters', + [$this, 'filterSearchFilters'] + ); + } + } + + public function getConfigForm(PhpRenderer $renderer) + { + $services = $this->getServiceLocator(); + $t = $services->get('MvcTranslator'); + return $t->translate('The settings are available in the file module.config.php of the module. See readme.'); // @translate + } + + /** + * Filter media belonging to private items. + * + * @see \Omeka\Module::filterMedia() + * @param Event $event + */ + public function filterMedia(Event $event) + { + $services = $this->getServiceLocator(); + $acl = $services->get('Omeka\Acl'); + if ($acl->userIsAllowed('Omeka\Entity\Resource', 'view-all')) { + return; + } + + $adapter = $event->getTarget(); + $itemAlias = $adapter->createAlias(); + $qb = $event->getParam('queryBuilder'); + $qb->innerJoin('Omeka\Entity\Media.item', $itemAlias); + + // Users can view media they do not own that belong to public items. + $expression = $qb->expr()->eq("$itemAlias.isPublic", true); + + $identity = $services + ->get('Omeka\AuthenticationService')->getIdentity(); + + if ($identity) { + // Prepare the specific part to check groups. + $identityParam = $adapter->createNamedParameter($qb, $identity); + $groupResourceAlias = $adapter->createAlias(); + $groupUserAlias = $adapter->createAlias(); + $qb->leftJoin( + GroupResource::class, + $groupResourceAlias, + 'WITH', + "$groupResourceAlias.resource = $itemAlias.id" + ); + $qb->leftJoin( + GroupUser::class, + $groupUserAlias, + 'WITH', + "$groupUserAlias.user = $identityParam AND $groupResourceAlias.group = $groupUserAlias.group" + ); + + $expression = $qb->expr()->orX( + $expression, + // Users can view all media they own. + $qb->expr()->eq( + "$itemAlias.owner", + $identityParam + ), + // Users can view media with at least one group in common. + $qb->expr()->eq( + "$groupResourceAlias.group", + "$groupUserAlias.group" + ) + ); + } + $qb->andWhere($expression); + } + + /** + * Add the groups data to the resource JSON-LD. + * + * @param Event $event + */ + public function filterEntityJsonLd(Event $event) + { + // The groups are not shown to public. + $acl = $this->getServiceLocator()->get('Omeka\Acl'); + if (!$acl->userIsAllowed(GroupAdapter::class, 'search') + && !$acl->userIsAllowed(GroupAdapter::class, 'read') + ) { + return; + } + + $resource = $event->getTarget(); + $columnName = $this->columnNameOfRepresentation($resource); + $jsonLd = $event->getParam('jsonLd'); + $api = $this->getServiceLocator()->get('Omeka\ApiManager'); + $groups = $api + ->search('groups', [$columnName => $resource->id()], ['responseContent' => 'reference']) + ->getContent(); + $jsonLd['o-module-group:group'] = $groups; + $event->setParam('jsonLd', $jsonLd); + } + + public function searchQuery(Event $event) + { + $query = $event->getParam('request')->getContent(); + + if (!empty($query['has_groups'])) { + $qb = $event->getParam('queryBuilder'); + $adapter = $event->getTarget(); + $groupEntityAlias = $adapter->createAlias(); + $entityAlias = $adapter->getEntityClass(); + if ($adapter->getResourceName() === 'users') { + $groupEntity = GroupUser::class; + $groupEntityColumn = 'user'; + } else { + $groupEntity = GroupResource::class; + $groupEntityColumn = 'resource'; + } + $qb->innerJoin( + $groupEntity, + $groupEntityAlias, + 'WITH', + $qb->expr()->eq($groupEntityAlias . '.' . $groupEntityColumn, $entityAlias . '.id') + ); + } + + if (!empty($query['group'])) { + $groups = $this->cleanStrings($query['group']); + if (empty($groups)) { + return; + } + $isId = preg_match('~^\d+$~', reset($groups)); + $qb = $event->getParam('queryBuilder'); + $adapter = $event->getTarget(); + $entityAlias = $adapter->getEntityClass(); + if ($adapter->getResourceName() === 'users') { + $groupEntity = GroupUser::class; + $groupEntityColumn = 'user'; + } else { + $groupEntity = GroupResource::class; + $groupEntityColumn = 'resource'; + } + // All resources with any group ("OR"). + // TODO The resquest is working, but it needs a format for querying. + /* + $groupEntityAlias = $adapter->createAlias(); + $groupAlias = $adapter->createAlias(); + $qb + ->innerJoin( + $groupEntity, + $groupEntityAlias, + 'WITH', + "$groupEntityAlias.$groupEntityColumn = $entityAlias.id" + ); + if ($isId) { + $qb + ->andWhere($qb->expr()->in($groupEntityAlias . '.group', $groups)); + } else { + $qb + ->innerJoin( + Group::class, + $groupAlias, + 'WITH', + "$groupEntityAlias.group = $groupAlias.id" + ) + ->andWhere($qb->expr()->in($groupAlias . '.name', $groups)); + } + */ + // All resources with all groups ("AND"). + foreach ($groups as $group) { + $groupEntityAlias = $adapter->createAlias(); + $groupAlias = $adapter->createAlias(); + $qb + // Simulate a cross join, not managed by doctrine. + ->innerJoin( + Group::class, + $groupAlias, + 'WITH', '1 = 1' + ) + ->innerJoin( + $groupEntity, + $groupEntityAlias, + 'WITH', + $qb->expr()->andX( + $qb->expr()->eq($groupEntityAlias . '.' . $groupEntityColumn, $entityAlias . '.id'), + $qb->expr()->eq($groupEntityAlias . '.group', $groupAlias . '.id') + ) + ); + if ($isId) { + $qb + ->andWhere($qb->expr()->eq( + $groupAlias . '.id', + $adapter->createNamedParameter($qb, $group) + )); + } else { + $qb + ->andWhere($qb->expr()->eq( + $groupAlias . '.name', + $adapter->createNamedParameter($qb, $group) + )); + } + } + } + } + + /** + * Handle hydration for groups data after hydration of an entity. + * + * @todo Clarify and use acl only. + * @param Event $event + */ + public function handleCreatePost(Event $event) + { + $resourceAdapter = $event->getTarget(); + $resourceType = $resourceAdapter->getEntityClass(); + if (!$this->checkAcl($resourceType, 'create')) { + return; + } + + $response = $event->getParam('response'); + $request = $response->getRequest(); + $data = $request->getContent(); + if (!$resourceAdapter->shouldHydrate($request, 'o-module-group:group')) { + return; + } + + $resource = $response->getContent(); + $submittedGroups = $request->getValue('o-module-group:group') ?: []; + + $aboveGroups = $this->takeGroupsFromAbove($resourceType); + $recursive = $this->isRecursive($resourceType); + + $services = $this->getServiceLocator(); + $controllerPlugins = $services->get('ControllerPluginManager'); + $applyGroups = $controllerPlugins->get('applyGroups'); + $applyGroups($resource, $submittedGroups, 'append', $aboveGroups, $recursive); + + // Since we use api.*.post, the entity manager should be flushed. + $entityManager = $services->get('Omeka\EntityManager'); + $entityManager->flush(); + } + + /** + * Handle hydration for groups data after hydration of an entity. + * + * @todo Clarify and use acl only. + * @param Event $event + */ + public function handleUpdatePost(Event $event) + { + $resourceAdapter = $event->getTarget(); + $resourceType = $resourceAdapter->getEntityClass(); + if (!$this->checkAcl($resourceType, 'update')) { + return; + } + + $request = $event->getParam('request'); + + $aboveGroups = $this->takeGroupsFromAbove($resourceType); + $recursive = $this->isRecursive($resourceType); + + // Manage partial update (and avoid a batch issue, without clear). + if ($recursive) { + if (!$resourceAdapter->shouldHydrate($request, 'o:item_set')) { + return; + } + } else { + if (!$resourceAdapter->shouldHydrate($request, 'o-module-group:group')) { + return; + } + } + + $resource = $event->getParam('response')->getContent(); + $submittedGroups = $request->getValue('o-module-group:group') ?: []; + + $services = $this->getServiceLocator(); + $controllerPlugins = $services->get('ControllerPluginManager'); + $applyGroups = $controllerPlugins->get('applyGroups'); + $applyGroups($resource, $submittedGroups, 'replace', $aboveGroups, $recursive); + + // Since we use api.*.post, the entity manager should be flushed. + $entityManager = $services->get('Omeka\EntityManager'); + $entityManager->flush(); + } + + /** + * Handle hydration for groups data after batch update of an entity. + * + * @todo Clarify and use acl only. + * @param Event $event + */ + public function handleBatchUpdatePost(Event $event) + { + $resourceAdapter = $event->getTarget(); + $resourceType = $resourceAdapter->getEntityClass(); + if (!$this->checkAcl($resourceType, 'update')) { + return; + } + + $response = $event->getParam('response'); + $request = $response->getRequest(); + $data = $request->getContent(); + if (!empty($data['remove_groups'])) { + $groups = $data['remove_groups']; + $collectionAction = 'remove'; + } elseif (!empty($data['add_groups'])) { + $groups = $data['add_groups']; + $collectionAction = 'append'; + } else { + return; + } + + $aboveGroups = $this->takeGroupsFromAbove($resourceType); + $recursive = $this->isRecursive($resourceType); + + $resources = $event->getParam('response')->getContent(); + + $services = $this->getServiceLocator(); + $entityManager = $services->get('Omeka\EntityManager'); + $controllerPlugins = $services->get('ControllerPluginManager'); + $applyGroups = $controllerPlugins->get('applyGroups'); + foreach ($resources as $resource) { + // Resource cannot be managed directly by the entity manager, or + // there may be a risk of duplicate. Merge is not enough. + $resource = $entityManager->find($resourceType, $resource->getId()); + $applyGroups($resource, $groups, $collectionAction, $aboveGroups, $recursive); + } + + // Since we use api.*.post, the entity manager should be flushed. + $entityManager->flush(); + // The clear avoids issues when there are groups removed and appended + // during the same batch process, for item sets with recursive. + // TODO Check if entity manager clear is still useful. + if ($recursive && in_array($resourceType, [ItemSet::class, Item::class])) { + $entityManager->clear(); + } + } + + /** + * Handle recursive deletion for groups data before deletion of an entity. + * + * @todo Clarify and use acl only. + * @param Event $event + */ + public function handleRecursiveDeleteItemSetPre(Event $event) + { + $resourceAdapter = $event->getTarget(); + $resourceType = $resourceAdapter->getEntityClass(); + if (!$this->checkAcl($resourceType, 'delete')) { + return; + } + + $request = $event->getParam('request'); + if (!$resourceAdapter->shouldHydrate($request, 'o-module-group:group')) { + return; + } + + $resource = $resourceAdapter->findEntity($request->getId()); + + $services = $this->getServiceLocator(); + $controllerPlugins = $services->get('ControllerPluginManager'); + $applyGroups = $controllerPlugins->get('applyGroups'); + $applyGroups($resource, [], 'replace', false, true); + + // Since we use api.*.post, the entity manager should be flushed. + $entityManager = $services->get('Omeka\EntityManager'); + $entityManager->flush(); + } + + /** + * Add the headers for admin management. + * + * @param Event $event + */ + public function addHeadersAdmin(Event $event) + { + $view = $event->getTarget(); + $view->headLink()->appendStylesheet($view->assetUrl('css/group.css', 'Group')); + $view->headScript()->appendFile($view->assetUrl('js/group.js', 'Group')); + } + + public function addUserFormElement(Event $event) + { + if (!$this->checkAcl(User::class, 'update') || !$this->checkAcl(User::class, 'assign')) { + return; + } + + $form = $event->getTarget(); + $form->get('user-information')->add([ + 'name' => 'o-module-group:group', + 'type' => GroupSelect::class, + 'options' => [ + 'label' => 'Groups', // @translate + 'chosen' => true, + ], + 'attributes' => [ + 'multiple' => true, + ], + ]); + } + + public function addUserFormFilter(Event $event) + { + if (!$this->checkAcl(User::class, 'update') || !$this->checkAcl(User::class, 'assign')) { + return; + } + + // TODO Add a validator for the groups of user. + $inputFilter = $event->getParam('inputFilter'); + $inputFilter->get('user-information')->add([ + 'name' => 'o-module-group:group', + 'required' => false, + ]); + } + + public function addUserFormValue(Event $event) + { + $user = $event->getTarget()->vars()->user; + $form = $event->getParam('form'); + $values = $this->listGroups($user, 'reference'); + $form->get('user-information')->get('o-module-group:group') + ->setAttribute('value', array_keys($values)); + } + + public function addBatchUpdateFormElement(Event $event) + { + $form = $event->getTarget(); + $resourceType = $form->getOption('resource_type'); + if ($resourceType) { + $resourcesTypes = [ + 'itemSet' => ItemSet::class, + 'item' => Item::class, + 'media' => Media::class, + ]; + $resourceType = $resourcesTypes[$resourceType]; + } else { + $resourceType = User::class; + } + + $aboveGroups = $this->takeGroupsFromAbove($resourceType); + if ($aboveGroups) { + return; + } + + $services = $this->getServiceLocator(); + $isUser = $resourceType === User::class; + $groupEntityClass = $isUser ? GroupUser::class : GroupResource::class; + + $acl = $services->get('Omeka\Acl'); + if ($acl->userIsAllowed($groupEntityClass, 'delete')) { + $form->add([ + 'name' => 'remove_groups', + 'type' => GroupSelect::class, + 'options' => [ + 'label' => 'Remove groups', // @translate + 'chosen' => true, + ], + 'attributes' => [ + 'id' => 'remove-groups', + 'multiple' => true, + 'data-collection-action' => 'remove', + ], + ]); + } + if ($acl->userIsAllowed($groupEntityClass, 'create')) { + $form->add([ + 'name' => 'add_groups', + 'type' => GroupSelect::class, + 'options' => [ + 'label' => 'Add groups', // @translate + 'chosen' => true, + ], + 'attributes' => [ + 'id' => 'add-groups', + 'multiple' => true, + 'data-collection-action' => 'append', + ], + ]); + } + } + + public function addBatchUpdateFormFilter(Event $event) + { + $form = $event->getTarget(); + $resourceType = $form->getOption('resource_type'); + if ($resourceType) { + $resourcesTypes = [ + 'itemSet' => ItemSet::class, + 'item' => Item::class, + 'media' => Media::class, + ]; + $resourceType = $resourcesTypes[$resourceType]; + } else { + $resourceType = User::class; + } + + $aboveGroups = $this->takeGroupsFromAbove($resourceType); + if ($aboveGroups) { + return; + } + + $isUser = $resourceType === User::class; + $groupEntityClass = $isUser ? GroupUser::class : GroupResource::class; + + $services = $this->getServiceLocator(); + $acl = $services->get('Omeka\Acl'); + if ($acl->userIsAllowed($groupEntityClass, 'delete')) { + $inputFilter = $event->getParam('inputFilter'); + $inputFilter->add([ + 'name' => 'remove_groups', + 'required' => false, + ]); + } + if ($acl->userIsAllowed($groupEntityClass, 'create')) { + $inputFilter = $event->getParam('inputFilter'); + $inputFilter->add([ + 'name' => 'add_groups', + 'required' => false, + ]); + } + // TODO Add a validator for the groups of resource. + } + + /** + * Add the tab to section navigation. + * + * @param Event $event + */ + public function addTab(Event $event) + { + $sectionNav = $event->getParam('section_nav'); + $sectionNav['groups'] = 'Groups'; // @translate + $event->setParam('section_nav', $sectionNav); + } + + /** + * Display the grouping form. + * + * @todo Merge with user groups form. + * + * @param Event $event + */ + public function displayGroupResourceForm(Event $event) + { + $operation = $event->getName(); + if (!$this->checkAcl(Resource::class, $operation === 'view.add.form.after' ? 'create' : 'update') + || !$this->checkAcl(Resource::class, 'assign') + ) { + $this->viewShowAfterResource($event); + return; + } + + $vars = $event->getTarget()->vars(); + // Manage add/edit form. + if (isset($vars->item)) { + $vars->offsetSet('resource', $vars->item); + } elseif (isset($vars->itemSet)) { + $vars->offsetSet('resource', $vars->itemSet); + } elseif (isset($vars->media)) { + $vars->offsetSet('resource', $vars->media); + } else { + $vars->offsetSet('resource', null); + $vars->offsetSet('groups', []); + } + if ($vars->resource) { + $vars->offsetSet('groups', $this->listGroups($vars->resource, 'representation')); + } + + echo $event->getTarget()->partial( + 'common/admin/groups-resource-form' + ); + } + + /** + * Display the groups for a user. + * + * @param Event $event + */ + public function viewShowAfterUser(Event $event) + { + $resource = $event->getTarget()->vars()->user; + $this->displayViewAdmin($event, $resource, false); + } + + /** + * Display the groups for a resource. + * + * @param Event $event + */ + public function viewShowAfterResource(Event $event) + { + echo '
'; + $resource = $event->getTarget()->vars()->resource; + $this->displayViewAdmin($event, $resource, false); + echo '
'; + } + + /** + * Add details for a resource. + * + * @param Event $event + */ + public function viewDetails(Event $event) + { + $resource = $event->getTarget()->resource; + $this->displayViewAdmin($event, $resource, true); + } + + /** + * Display an admin view. + * + * @param Event $event + * @param AbstractEntityRepresentation $resource + * @param bool $listAsDiv Return the list with div, not ul. + */ + protected function displayViewAdmin( + Event $event, + AbstractEntityRepresentation $resource, + $listAsDiv = false + ) { + // TODO Add an acl check for right to view groups (controller level). + $isUser = $resource->getControllerName() === 'user'; + $groups = $this->listGroups($resource, 'representation'); + $partial = $listAsDiv + ? 'common/admin/groups-resource' + : 'common/admin/groups-resource-list'; + echo $event->getTarget()->partial( + $partial, + [ + 'resource' => $resource, + 'groups' => $groups, + 'isUser' => $isUser, + ] + ); + } + + /** + * Display the advanced search form via partial. + * + * @param Event $event + */ + public function displayAdvancedSearch(Event $event) + { + $services = $this->getServiceLocator(); + $formElementManager = $services->get('FormElementManager'); + $form = $formElementManager->get(SearchForm::class); + $form->init(); + + $view = $event->getTarget(); + $query = $event->getParam('query', []); + $resourceType = $event->getParam('resourceType'); + + $hasGroups = !empty($query['has_groups']); + $groups = empty($query['group']) ? '' : implode(', ', $this->cleanStrings($query['group'])); + + $formData = []; + $formData['has_groups'] = $hasGroups; + $formData['group'] = $groups; + $form->setData($formData); + + $vars = $view->vars(); + $vars->offsetSet('searchGroupForm', $form); + + echo $view->partial( + 'common/admin/groups-advanced-search' + ); + } + + /** + * Filter search filters. + * + * @param Event $event + */ + public function filterSearchFilters(Event $event) + { + $translate = $event->getTarget()->plugin('translate'); + $filters = $event->getParam('filters'); + $query = $event->getParam('query', []); + if (!empty($query['has_groups'])) { + $filterLabel = $translate('Has groups'); // @translate + $filterValue = $translate('true'); + $filters[$filterLabel][] = $filterValue; + } + if (!empty($query['group'])) { + $filterLabel = $translate('Group'); // @translate + $filterValue = $this->cleanStrings($query['group']); + $filters[$filterLabel] = $filterValue; + } + $event->setParam('filters', $filters); + } + + /** + * Helper to get the column id of a representation. + * + * Note: Resource representation have method resourceName(), but site page + * and user don't. Site page has no getControllerName(). + * + * @param AbstractEntityRepresentation $representation + * @return string + */ + protected function columnNameOfRepresentation(AbstractEntityRepresentation $representation) + { + $entityColumnNames = [ + 'item-set' => 'item_set_id', + 'item' => 'item_id', + 'media' => 'media_id', + 'user' => 'user_id', + ]; + $entityColumnName = $entityColumnNames[$representation->getControllerName()]; + return $entityColumnName; + } + + /** + * Check rights to manage groups. + * + * @param string $resourceClass + * @param string $privilege + * @return bool + */ + protected function checkAcl($resourceClass, $privilege) + { + $acl = $this->getServiceLocator()->get('Omeka\Acl'); + $groupEntity = $resourceClass == User::class + ? GroupUser::class + : GroupResource::class; + return $acl->userIsAllowed($groupEntity, $privilege); + } + + /** + * Check if groups are applied from above. + * + * @param string $resourceClass + * @return bool + */ + protected function takeGroupsFromAbove($resourceClass) + { + switch ($resourceClass) { + case ItemSet::class: + return false; + case Item::class: + return $groupSettings = $this->getServiceLocator() + ->get('Config')['group']['config']['group_recursive_item_sets']; + case Media::class: + return $groupSettings = $this->getServiceLocator() + ->get('Config')['group']['config']['group_recursive_items']; + case User::class: + default: + return false; + } + } + + /** + * Check if groups apply recursively for resources below. + * + * @param string $resourceClass + * @return bool + */ + protected function isRecursive($resourceClass) + { + switch ($resourceClass) { + case ItemSet::class: + return $this->getServiceLocator() + ->get('Config')['group']['config']['group_recursive_item_sets']; + case Item::class: + return $this->getServiceLocator() + ->get('Config')['group']['config']['group_recursive_items']; + case Media::class: + case User::class: + default: + return false; + } + } + + /** + * Helper to return groups of an entity. + * + * @param AbstractEntityRepresentation $resource + * @param string $contentType "json" (default), "representation" or "reference". + * @return array + */ + protected function listGroups(AbstractEntityRepresentation $resource, $contentType = null) + { + if (empty($resource->id())) { + return []; + } + $resourceJson = $resource->jsonSerialize(); + $list = empty($resourceJson['o-module-group:group']) + ? [] + : $resourceJson['o-module-group:group']; + + $result = []; + switch ($contentType) { + case 'reference': + foreach ($list as $entity) { + $result[$entity->name()] = $entity; + } + break; + case 'representation': + $api = $this->getServiceLocator()->get('Omeka\ApiManager'); + foreach ($list as $entity) { + $result[$entity->name()] = $api->read('groups', $entity->id())->getContent(); + } + break; + case 'json': + default: + $result = $list; + break; + } + return $result; + } + + /** + * Clean a list of alphanumeric strings, separated by a comma. + * + * @param array|string $strings + * @return array + */ + protected function cleanStrings($strings) + { + if (!is_array($strings)) { + $strings = explode(',', $strings); + } + return array_filter(array_map('trim', $strings)); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b79ff2 --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +Group (module for Omeka S) +========================== + +[Group] is a module for [Omeka S] that allows to set groups to users, as in many +authentication systems, and to set the same groups to any resources, so their +visibility can be managed in a more flexible way. + +In the admin interface, this module decreases the access of the identified users +to the resources, and, in the public interface, with the module [Guest User], it +increases the access of guest users to private resources. + +So, it doesn’t replace the main public/private rule, but adds another level of +rules for visibility. For example, a user who belongs to group "Alpha" can +access all items that have at least this group in common, and, of course, all +items that are public. + +The item sets, the items and the media without group follow the default rules. +The admins, the editors and the reviewers have always access to all resources. +The rules are not changed for visitors (access to public resources only). + +In practice, this module is usefull only for sites that need to manage finely +the access to resources for researchers, authors and guests. For other roles, +you have to unset the `view-all` right via another module or via a contribution. + + +Installation +------------ + +Uncompress files and rename module folder "Group". + +See general end user documentation for [Installing a module]. + + +Usage +----- + +The groups (names) are manageable directly in the admin view. They can be +assigned to users and resources (item sets, items, medias) in their respective +views. They are available via the api too. + +For the resources, the groups can be managed in three ways: + +- individually; +- recursively for medias (the rights of the item are applied to all medias); +- fully recursively (the rights of item sets will apply to all items and medias). + +By default, the groups are managed fully recursively, so when a group is +assigned to an item set, all items that belong to this item set will be assigned +to this group too. The same for items for media, and the same for unassignment. +When an item belong to multiple collections, all groups of all its collections +are assigned. + +A change to the settings applies only to newly saved resources. There is no bulk +tool to process existing resources, but the groups are updated each time they +are saved. To set this option, copy it with its direct hierarchy from the file +`config/module.config.php` of the module into your `config/local.config.php`: +`['group']['config']['group_recursive_item_sets']` and `['group']['config']['group_recursive_items']`. + + +Access rights +------------- + +- Visitors + - No access to private resources + - No access to any group of resources + - So access to public resources only (default acl rules) +- Guests, Researchers and Authors + - No access to private resources + - Except access to own resources (Authors) + - Except access to the resources when one of the resources groups match one + of their own groups + - Users without groups haven’t access to more resources + - Private resources without groups are not visible. +- Admins, editors and reviewers + - Access to all private resources (default acl rules "view-all") + +The rights of admin, editors and reviewers can be restricted too: simply remove +the right `view-all` for them in the access control lists (acl). In that case, +it is recommended not to change the rights of the admins, or to set a group +"staff", for example, and to assign this group to all admins and resources +before. + + +Warning +------- + +Use it at your own risk. + +It’s always recommended to backup your files and your databases and to check +your archives regularly so you can roll back if needed. + + +Troubleshooting +--------------- + +See online issues on the [module issues] page on GitHub. + + +License +------- + +This module is published under the [CeCILL v2.1] licence, compatible with +[GNU/GPL] and approved by [FSF] and [OSI]. + +This software is governed by the CeCILL license under French law and abiding by +the rules of distribution of free software. You can use, modify and/ or +redistribute the software under the terms of the CeCILL license as circulated by +CEA, CNRS and INRIA at the following URL "http://www.cecill.info". + +As a counterpart to the access to the source code and rights to copy, modify and +redistribute granted by the license, users are provided only with a limited +warranty and the software’s author, the holder of the economic rights, and the +successive licensors have only limited liability. + +In this respect, the user’s attention is drawn to the risks associated with +loading, using, modifying and/or developing or reproducing the software by the +user in light of its specific status of free software, that may mean that it is +complicated to manipulate, and that also therefore means that it is reserved for +developers and experienced professionals having in-depth computer knowledge. +Users are therefore encouraged to load and test the software’s suitability as +regards their requirements in conditions enabling the security of their systems +and/or data to be ensured and, more generally, to use and operate it in the same +conditions as regards security. + +The fact that you are presently reading this means that you have had knowledge +of the CeCILL license and that you accept its terms. + + +Contact +------- + +Current maintainers: + +* Daniel Berthereau (see [Daniel-KM] on GitHub) + + +Copyright +--------- + +* Copyright Daniel Berthereau, 2017-2018 + + +[Group]: https://github.com/Daniel-KM/Omeka-S-module-Group +[Omeka S]: https://omeka.org/s +[Guest User]: https://github.com/biblibre/omeka-s-module-GuestUser +[Installing a module]: http://dev.omeka.org/docs/s/user-manual/modules/#installing-modules +[module issues]: https://github.com/Daniel-KM/Omeka-S-module-Group/issues +[CeCILL v2.1]: https://www.cecill.info/licences/Licence_CeCILL_V2.1-en.html +[GNU/GPL]: https://www.gnu.org/licenses/gpl-3.0.html +[FSF]: https://www.fsf.org +[OSI]: http://opensource.org +[Daniel-KM]: https://github.com/Daniel-KM "Daniel Berthereau" diff --git a/asset/css/group.css b/asset/css/group.css new file mode 100644 index 0000000..5801211 --- /dev/null +++ b/asset/css/group.css @@ -0,0 +1,203 @@ +@media screen { + header nav .groups:before, + .groups .subhead::before, + #groups .no-resources:before, + .groups .no-resources:before { + content:""; + } + #groups .property { + display: inherit; + } + .groups .group .actions, + #group-resources .actions { + float: right; + } + #group-resources.empty, + #group-resources+.no-resources { + display:none; + } + #group-resources.empty+.no-resources { + display:block; + padding:0 24px; + } + #group-resources.empty+.no-resources:before { + content:""; + } + #group-resources tr.delete { + background-color:#fcc; + } + .groups .resource-details .group-internal-id { + font-size: 0.7em; + } + + .group .group-list { + margin: 0; + } + + .group .group-comment.no-comment, + .groups .group-comment.no-comment { + font-style: italic; + } + .resource-details .internal-id { + font-size: small; + font-style: italic; + } + + @media (min-width:641px) { + .groups.browse table th:nth-child(1n+2), + .groups.browse table td:nth-child(1n+2) { + text-align: right; + } + .groups.browse table th:nth-child(1n+2) *, + .groups.browse table td:nth-child(1n+2) * { + text-align: right; + float: right; + } + .groups.browse table th:first-child, + .groups.browse table td:first-child { + max-width: 40%; + } + .groups.browse table th:last-child, + .groups.browse table td:last-child { + min-width: 10%; + } + } + + .groups table .resource-name { + display: inline-block; + vertical-align: bottom; + margin-left: .5em; + opacity: .5; + } + + .groups .single.actions { + float: right; + } + .groups .single.actions a::before { + text-align: inherit; + } + .groups .single.actions .o-icon-transmit::before { + background: url('../img/waiting-mini.gif') no-repeat scroll 3px 3px transparent !important; + } + .groups .group-name[contenteditable=true].o-icon-transmit::before, + .groups .no-action::before { + width: 24px; + height: 24px; + line-height: 24px; + vertical-align: top; + border: 0; + box-shadow: none; + opacity: .5; + text-align: inherit; + } + .groups.meta-group .single.actions, + .groups.meta-group .no-action::before { + float: right; + } + + .groups.meta-group span, + .groups .meta-group span { + display: inline; + hyphens: auto; + word-wrap: break-word; + } + + .groups.meta-group::before, + .groups .status-toggle::before, + .groups .resource-name::before { + display: inline-block; + font-family:"FontAwesome"; + padding-left: 3px; + padding-right: 3px; + width: 24px; + } + .groups .resource-name.resource::before { + content: ""; + } + .groups .resource-name.no-resource::before { + content: "❌"; + } + .groups .resource-name.item-set::before { + content: ""; + } + .groups .resource-name.item::before { + content: ""; + } + .groups .resource-name.media::before { + content: ""; + } + .groups .o-icon-proposed::before { + color: #FFA500; + content: ""; + } + .groups .o-icon-allowed::before { + color: #FFA500; + content: ""; + } + .groups .o-icon-approved::before { + color: #00FF00; + content: ""; + } + .groups .o-icon-rejected::before { + color: #FF0000; + content: ""; + } + .groups .o-icon-undefined::before { + color: #FF0000; + content: ""; + } + .groups .o-icon-none::before { + color: #FFFFFF; + content: ""; + } + .groups .o-icon-transmit::before { + background: url('../img/waiting-mini.gif') no-repeat scroll 12px 3px transparent !important; + color: #000000; + content: ""; + cursor: progress; + } + + .groups .group-name[contenteditable="true"] { + background: inherit; + border: hidden; + display: inline-block; + min-height: unset; + max-height: 3em; + padding: inherit; + width: 84%; + } + .groups .group-name[contenteditable=true]:hover, + .groups .group-name[contenteditable=true]:focus { + outline: 1px dotted rgba(0,0,0,0.15); + } + .groups .group-name[contenteditable="true"]:active { + display: inline; + outline: 1px dashed rgba(0,0,0,0.15); + overflow: hidden; + white-space: nowrap; + } + .groups .group-name[contenteditable="true"]:active br { + display:none; + } + .groups .group-name[contenteditable="true"]:active * { + display:inline; + white-space:nowrap; + } + .groups .group-name[contenteditable=true].o-icon-transmit::before { + background: url('../img/waiting-mini.gif') no-repeat scroll 3px 3px transparent !important; + } + + form fieldset#groups td { + display: block; + } +} + +@media screen and (max-width: 640px) { + fieldset.section>#group-resources.empty+.no-resources { + display:none; + } + fieldset.section.mobile-active>#group-resources.empty+.no-resources { + display:block; + margin-top:48px; + } +} diff --git a/asset/img/waiting-mini.gif b/asset/img/waiting-mini.gif new file mode 100644 index 0000000..6a00d4e Binary files /dev/null and b/asset/img/waiting-mini.gif differ diff --git a/asset/js/group.js b/asset/js/group.js new file mode 100644 index 0000000..631961a --- /dev/null +++ b/asset/js/group.js @@ -0,0 +1,122 @@ +$(document).ready(function() { + +/* Group a resource. */ + +// Add the selected group to the edit panel. +$('#group-selector .selector-child').click(function(event) { + event.preventDefault(); + + $('#group-resources').removeClass('empty'); + var groupName = $(this).data('child-search'); + + if ($('#group-resources').find("input[value='" + groupName + "']").length) { + return; + } + + var row = $($('#group-template').data('template')); + row.children('td.group-name').text(groupName); + row.find('td > input').val(groupName); + $('#group-resources > tbody:last').append(row); +}); + +// Remove a group from the edit panel. +$('#group-resources').on('click', '.o-icon-delete', function(event) { + event.preventDefault(); + + var removeLink = $(this); + var groupRow = $(this).closest('tr'); + var groupInput = removeLink.closest('td').find('input'); + groupInput.prop('disabled', true); + + // Undo remove group link. + var undoRemoveLink = $('', { + href: '#', + class: 'fa fa-undo', + title: Omeka.jsTranslate('Undo remove group'), + click: function(event) { + event.preventDefault(); + groupRow.toggleClass('delete'); + groupInput.prop('disabled', false); + removeLink.show(); + $(this).remove(); + }, + }); + + groupRow.toggleClass('delete'); + undoRemoveLink.insertAfter(removeLink); + removeLink.hide(); +}); + +/* Update groups. */ + +// Update the name of a group. +$('.groups .o-icon-edit.contenteditable') + .on('click', function(e) { + e.preventDefault(); + var field = $(this).closest('td').find('.group-name'); + field.focus(); + }); + +// Update the name of a group. +$('.groups .group-name[contenteditable=true]') + .focus(function() { + var field = $(this); + field.data('original-text', field.text()); + }) + .blur(function(e) { + var field = $(this); + var oldText = field.data('original-text'); + var newText = $.trim(field.text().replace(/\s+/g,' ')); + $.removeData(field, 'original-text'); + if (newText.length > 0 && newText !== oldText) { + var url = field.data('update-url'); + $.post({ + url: url, + data: {text: newText}, + beforeSend: function() { + field.text(newText); + field.addClass('o-icon-transmit'); + } + }) + .done(function(data) { + var row = field.closest('tr'); + field.text(data.content.text); + field.data('update-url', data.content.urls.update); + row.find('[name="resource_ids[]"]').val(data.content.escaped); + row.find('.o-icon-delete').data('sidebar-content-url', data.content.urls.delete_confirm); + row.find('.o-icon-more').data('sidebar-content-url', data.content.urls.show_details); + }) + .fail(function(jqXHR, textStatus) { + var msg = jqXHR.hasOwnProperty('responseJSON') + && typeof jqXHR.responseJSON.error !== 'undefined' + ? jqXHR.responseJSON.error + : Omeka.jsTranslate('Something went wrong'); + alert(msg); + field.text(oldText); + }) + .always(function () { + field.removeClass('o-icon-transmit'); + field.parent().focus(); + }); + } else { + field.text(oldText); + } + }) + .keydown(function(e) { + if (e.keyCode === 13) { + e.preventDefault(); + } + }) + .keyup(function(e) { + if (e.keyCode === 13) { + $(this).blur(); + } else if (e.keyCode === 27) { + var field = $(this); + var oldText = field.data('original-text'); + $.removeData(field, 'original-text'); + field.text(oldText); + field.parent().focus(); + } + }); + +}); diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2c28b96 --- /dev/null +++ b/composer.json @@ -0,0 +1,10 @@ +{ + "require-dev": { + "biblibre/omeka-s-test-helper": "dev-master" + }, + "autoload-dev": { + "psr-4": { + "GroupUserTest\\": "test/GroupTest/" + } + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..564fbc2 --- /dev/null +++ b/composer.lock @@ -0,0 +1,55 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "This file is @generated automatically" + ], + "content-hash": "698e59fa020ec6b1a86762ee8e062a6d", + "packages": [], + "packages-dev": [ + { + "name": "biblibre/omeka-s-test-helper", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://github.com/biblibre/omeka-s-test-helper.git", + "reference": "a02c8dc4259365f7de1d09626a97347ba19cbf6e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/biblibre/omeka-s-test-helper/zipball/a02c8dc4259365f7de1d09626a97347ba19cbf6e", + "reference": "a02c8dc4259365f7de1d09626a97347ba19cbf6e", + "shasum": "" + }, + "require": { + "php": ">=5.5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "OmekaTestHelper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Test helpers for Omeka S", + "homepage": "https://github.com/biblibre/omeka-s-test-helper", + "keywords": [ + "omeka", + "test" + ], + "time": "2016-09-22T16:40:26+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": { + "biblibre/omeka-s-test-helper": 20 + }, + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/config/module.config.php b/config/module.config.php new file mode 100644 index 0000000..602e39b --- /dev/null +++ b/config/module.config.php @@ -0,0 +1,192 @@ + [ + 'acl_resources' => [ + Entity\GroupResource::class, + Entity\GroupUser::class, + ], + ], + 'api_adapters' => [ + 'invokables' => [ + 'groups' => Api\Adapter\GroupAdapter::class, + ], + ], + 'entity_manager' => [ + 'mapping_classes_paths' => [ + dirname(__DIR__) . '/src/Entity', + ], + 'proxy_paths' => [ + dirname(__DIR__) . '/data/doctrine-proxies', + ], + 'filters' => [ + 'resource_visibility' => Db\Filter\ResourceVisibilityFilter::class, + ], + ], + 'view_manager' => [ + 'template_path_stack' => [ + dirname(__DIR__) . '/view', + ], + 'strategies' => [ + 'ViewJsonStrategy', + ], + ], + 'csv_import' => [ + 'mappings' => [ + 'item_sets' => [ + Mapping\GroupMapping::class, + ], + 'items' => [ + Mapping\GroupMapping::class, + ], + 'media' => [ + Mapping\GroupMapping::class, + ], + 'resources' => [ + Mapping\GroupMapping::class, + ], + 'users' => [ + Mapping\GroupMapping::class, + ], + ], + 'automapping' => [ + 'group' => [ + 'name' => 'group', + 'value' => 1, + 'label' => 'Group', + 'class' => 'group-module', + ], + ], + 'user_settings' => [ + 'csv_import_automap_user_list' => [ + 'group' => 'group', + ], + ], + ], + 'view_helpers' => [ + 'invokables' => [ + 'groupSelector' => View\Helper\GroupSelector::class, + ], + 'factories' => [ + 'groupCount' => Service\ViewHelper\GroupCountFactory::class, + ], + ], + 'form_elements' => [ + 'invokables' => [ + Form\GroupForm::class => Form\GroupForm::class, + Form\SearchForm::class => Form\SearchForm::class, + ], + 'factories' => [ + Form\Element\GroupSelect::class => Service\Form\Element\GroupSelectFactory::class, + ], + ], + 'navigation' => [ + 'AdminGlobal' => [ + [ + 'label' => 'Groups', // @translate + 'class' => 'o-icon-users', + 'route' => 'admin/group', + 'controller' => 'group', + 'action' => 'browse', + 'resource' => Controller\Admin\GroupController::class, + 'privilege' => 'browse', + 'useRouteMatch' => true, + 'pages' => [ + [ + 'label' => 'Groups', // @translate + 'route' => 'admin/group', + 'controller' => 'group', + 'visible' => true, + ], + ], + ], + ], + ], + 'controllers' => [ + 'invokables' => [ + Controller\Admin\GroupController::class => Controller\Admin\GroupController::class, + ], + ], + 'controller_plugins' => [ + 'factories' => [ + 'applyGroups' => Service\ControllerPlugin\ApplyGroupsFactory::class, + ], + ], + 'router' => [ + 'routes' => [ + 'admin' => [ + 'child_routes' => [ + 'group' => [ + 'type' => 'Segment', + 'options' => [ + 'route' => '/group[/:action]', + 'constraints' => [ + 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', + ], + 'defaults' => [ + '__NAMESPACE__' => 'Group\Controller\Admin', + 'controller' => Controller\Admin\GroupController::class, + 'action' => 'browse', + ], + ], + ], + 'group-id' => [ + 'type' => 'Segment', + 'options' => [ + 'route' => '/group/:id[/:action]', + 'constraints' => [ + 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', + 'id' => '\d+', + ], + 'defaults' => [ + '__NAMESPACE__' => 'Group\Controller\Admin', + 'controller' => Controller\Admin\GroupController::class, + 'action' => 'show', + ], + ], + ], + 'group-name' => [ + 'type' => 'Segment', + 'options' => [ + // The action is required to avoid collision with admin/group. + // A validation is done in the adapter. + 'route' => '/group/:name/:action', + 'constraints' => [ + 'action' => '[a-zA-Z][a-zA-Z0-9_-]*', + 'name' => '[^\d]+.*', + ], + 'defaults' => [ + '__NAMESPACE__' => 'Group\Controller\Admin', + 'controller' => Controller\Admin\GroupController::class, + 'action' => 'show', + ], + ], + ], + ], + ], + ], + ], + 'translator' => [ + 'translation_file_patterns' => [ + [ + 'type' => 'gettext', + 'base_dir' => dirname(__DIR__) . '/language', + 'pattern' => '%s.mo', + 'text_domain' => null, + ], + ], + ], + 'js_translate_strings' => [ + 'Request too long to process.', // @translate + ], + 'group' => [ + 'config' => [ + // Apply the groups of item sets to items and medias. + 'group_recursive_item_sets' => true, + // Apply the item groups to medias. Implied and not taken in account + // when `group_recursive_item_sets` is true. + 'group_recursive_items' => true, + ], + ], +]; diff --git a/config/module.ini b/config/module.ini new file mode 100644 index 0000000..2cedbc8 --- /dev/null +++ b/config/module.ini @@ -0,0 +1,12 @@ +[info] +name = "Group" +description = "Add groups to users and resources to manage the access rights and the resource visibility in a more flexible way." +tags = "administration, group, rights" +license = "CeCILL v2.1" +author = "Daniel Berthereau" +author_link = "https://github.com/Daniel-KM" +module_link = "https://github.com/Daniel-KM/Omeka-S-module-Group" +support_link = "https://github.com/Daniel-KM/Omeka-S-module-Group/issues" +configurable = true +version = "3.0.1" +omeka_version_constraint = "^1.0.0" diff --git a/data/doctrine-proxies/__CG__GroupEntityGroup.php b/data/doctrine-proxies/__CG__GroupEntityGroup.php new file mode 100644 index 0000000..9d57b6d --- /dev/null +++ b/data/doctrine-proxies/__CG__GroupEntityGroup.php @@ -0,0 +1,290 @@ +__initializer__ = $initializer; + $this->__cloner__ = $cloner; + } + + + + + + + + /** + * + * @return array + */ + public function __sleep() + { + if ($this->__isInitialized__) { + return ['__isInitialized__', 'id', 'name', 'comment', 'users', 'groupUsers', 'resources', 'groupResources']; + } + + return ['__isInitialized__', 'id', 'name', 'comment', 'users', 'groupUsers', 'resources', 'groupResources']; + } + + /** + * + */ + public function __wakeup() + { + if ( ! $this->__isInitialized__) { + $this->__initializer__ = function (Group $proxy) { + $proxy->__setInitializer(null); + $proxy->__setCloner(null); + + $existingProperties = get_object_vars($proxy); + + foreach ($proxy->__getLazyProperties() as $property => $defaultValue) { + if ( ! array_key_exists($property, $existingProperties)) { + $proxy->$property = $defaultValue; + } + } + }; + + } + } + + /** + * + */ + public function __clone() + { + $this->__cloner__ && $this->__cloner__->__invoke($this, '__clone', []); + } + + /** + * Forces initialization of the proxy + */ + public function __load() + { + $this->__initializer__ && $this->__initializer__->__invoke($this, '__load', []); + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __isInitialized() + { + return $this->__isInitialized__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitialized($initialized) + { + $this->__isInitialized__ = $initialized; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitializer(\Closure $initializer = null) + { + $this->__initializer__ = $initializer; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __getInitializer() + { + return $this->__initializer__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setCloner(\Closure $cloner = null) + { + $this->__cloner__ = $cloner; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific cloning logic + */ + public function __getCloner() + { + return $this->__cloner__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + * @static + */ + public function __getLazyProperties() + { + return self::$lazyPropertiesDefaults; + } + + + /** + * {@inheritDoc} + */ + public function getId() + { + if ($this->__isInitialized__ === false) { + return (int) parent::getId(); + } + + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getId', []); + + return parent::getId(); + } + + /** + * {@inheritDoc} + */ + public function setName($name) + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'setName', [$name]); + + return parent::setName($name); + } + + /** + * {@inheritDoc} + */ + public function getName() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getName', []); + + return parent::getName(); + } + + /** + * {@inheritDoc} + */ + public function setComment($comment) + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'setComment', [$comment]); + + return parent::setComment($comment); + } + + /** + * {@inheritDoc} + */ + public function getComment() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getComment', []); + + return parent::getComment(); + } + + /** + * {@inheritDoc} + */ + public function getUsers() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getUsers', []); + + return parent::getUsers(); + } + + /** + * {@inheritDoc} + */ + public function getGroupUsers() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getGroupUsers', []); + + return parent::getGroupUsers(); + } + + /** + * {@inheritDoc} + */ + public function getResources() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getResources', []); + + return parent::getResources(); + } + + /** + * {@inheritDoc} + */ + public function getGroupResources() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getGroupResources', []); + + return parent::getGroupResources(); + } + + /** + * {@inheritDoc} + */ + public function getResourceId() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getResourceId', []); + + return parent::getResourceId(); + } + +} diff --git a/data/doctrine-proxies/__CG__GroupEntityGroupResource.php b/data/doctrine-proxies/__CG__GroupEntityGroupResource.php new file mode 100644 index 0000000..d60afa3 --- /dev/null +++ b/data/doctrine-proxies/__CG__GroupEntityGroupResource.php @@ -0,0 +1,209 @@ +__initializer__ = $initializer; + $this->__cloner__ = $cloner; + } + + + + + + + + /** + * + * @return array + */ + public function __sleep() + { + if ($this->__isInitialized__) { + return ['__isInitialized__', 'group', 'resource']; + } + + return ['__isInitialized__', 'group', 'resource']; + } + + /** + * + */ + public function __wakeup() + { + if ( ! $this->__isInitialized__) { + $this->__initializer__ = function (GroupResource $proxy) { + $proxy->__setInitializer(null); + $proxy->__setCloner(null); + + $existingProperties = get_object_vars($proxy); + + foreach ($proxy->__getLazyProperties() as $property => $defaultValue) { + if ( ! array_key_exists($property, $existingProperties)) { + $proxy->$property = $defaultValue; + } + } + }; + + } + } + + /** + * + */ + public function __clone() + { + $this->__cloner__ && $this->__cloner__->__invoke($this, '__clone', []); + } + + /** + * Forces initialization of the proxy + */ + public function __load() + { + $this->__initializer__ && $this->__initializer__->__invoke($this, '__load', []); + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __isInitialized() + { + return $this->__isInitialized__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitialized($initialized) + { + $this->__isInitialized__ = $initialized; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitializer(\Closure $initializer = null) + { + $this->__initializer__ = $initializer; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __getInitializer() + { + return $this->__initializer__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setCloner(\Closure $cloner = null) + { + $this->__cloner__ = $cloner; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific cloning logic + */ + public function __getCloner() + { + return $this->__cloner__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + * @static + */ + public function __getLazyProperties() + { + return self::$lazyPropertiesDefaults; + } + + + /** + * {@inheritDoc} + */ + public function getGroup() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getGroup', []); + + return parent::getGroup(); + } + + /** + * {@inheritDoc} + */ + public function getResource() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getResource', []); + + return parent::getResource(); + } + + /** + * {@inheritDoc} + */ + public function __toString() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, '__toString', []); + + return parent::__toString(); + } + +} diff --git a/data/doctrine-proxies/__CG__GroupEntityGroupUser.php b/data/doctrine-proxies/__CG__GroupEntityGroupUser.php new file mode 100644 index 0000000..2cda05a --- /dev/null +++ b/data/doctrine-proxies/__CG__GroupEntityGroupUser.php @@ -0,0 +1,209 @@ +__initializer__ = $initializer; + $this->__cloner__ = $cloner; + } + + + + + + + + /** + * + * @return array + */ + public function __sleep() + { + if ($this->__isInitialized__) { + return ['__isInitialized__', 'group', 'user']; + } + + return ['__isInitialized__', 'group', 'user']; + } + + /** + * + */ + public function __wakeup() + { + if ( ! $this->__isInitialized__) { + $this->__initializer__ = function (GroupUser $proxy) { + $proxy->__setInitializer(null); + $proxy->__setCloner(null); + + $existingProperties = get_object_vars($proxy); + + foreach ($proxy->__getLazyProperties() as $property => $defaultValue) { + if ( ! array_key_exists($property, $existingProperties)) { + $proxy->$property = $defaultValue; + } + } + }; + + } + } + + /** + * + */ + public function __clone() + { + $this->__cloner__ && $this->__cloner__->__invoke($this, '__clone', []); + } + + /** + * Forces initialization of the proxy + */ + public function __load() + { + $this->__initializer__ && $this->__initializer__->__invoke($this, '__load', []); + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __isInitialized() + { + return $this->__isInitialized__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitialized($initialized) + { + $this->__isInitialized__ = $initialized; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setInitializer(\Closure $initializer = null) + { + $this->__initializer__ = $initializer; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __getInitializer() + { + return $this->__initializer__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + */ + public function __setCloner(\Closure $cloner = null) + { + $this->__cloner__ = $cloner; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific cloning logic + */ + public function __getCloner() + { + return $this->__cloner__; + } + + /** + * {@inheritDoc} + * @internal generated method: use only when explicitly handling proxy specific loading logic + * @static + */ + public function __getLazyProperties() + { + return self::$lazyPropertiesDefaults; + } + + + /** + * {@inheritDoc} + */ + public function getGroup() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getGroup', []); + + return parent::getGroup(); + } + + /** + * {@inheritDoc} + */ + public function getUser() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, 'getUser', []); + + return parent::getUser(); + } + + /** + * {@inheritDoc} + */ + public function __toString() + { + + $this->__initializer__ && $this->__initializer__->__invoke($this, '__toString', []); + + return parent::__toString(); + } + +} diff --git a/language/template.base.pot b/language/template.base.pot new file mode 100644 index 0000000..5d640e6 --- /dev/null +++ b/language/template.base.pot @@ -0,0 +1,18 @@ +# Translation for the Group module for Omeka S. +# Copyright (C) 2011 Roy Rosenzweig Center for History and New Media +# This file is distributed under the same license as the Omeka package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Group\n" +"Report-Msgid-Bugs-To: http://github.com/Daniel-KM/Omeka-S-module-Group/issues\n" +"POT-Creation-Date: 2017-09-25 00:00-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" diff --git a/language/template.pot b/language/template.pot new file mode 100644 index 0000000..5d640e6 --- /dev/null +++ b/language/template.pot @@ -0,0 +1,18 @@ +# Translation for the Group module for Omeka S. +# Copyright (C) 2011 Roy Rosenzweig Center for History and New Media +# This file is distributed under the same license as the Omeka package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Group\n" +"Report-Msgid-Bugs-To: http://github.com/Daniel-KM/Omeka-S-module-Group/issues\n" +"POT-Creation-Date: 2017-09-25 00:00-0000\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" diff --git a/src/Api/Adapter/GroupAdapter.php b/src/Api/Adapter/GroupAdapter.php new file mode 100644 index 0000000..de1a59a --- /dev/null +++ b/src/Api/Adapter/GroupAdapter.php @@ -0,0 +1,304 @@ + 'id', + 'name' => 'name', + // "group" is an alias of "name". + 'group' => 'name', + 'comment' => 'comment', + // For info. + // 'count' => 'count', + // 'users' => 'users', + // 'resources' => 'resources', + // 'item_sets' => 'item_sets', + // 'items' => 'items', + // 'media' => 'media', + // 'recent' => 'recent', + ]; + + public function getResourceName() + { + return 'groups'; + } + + public function getRepresentationClass() + { + return GroupRepresentation::class; + } + + public function getEntityClass() + { + return Group::class; + } + + public function hydrate(Request $request, EntityInterface $entity, + ErrorStore $errorStore + ) { + if ($this->shouldHydrate($request, 'o:name')) { + $name = $request->getValue('o:name'); + if (!is_null($name)) { + $name = trim($name); + $entity->setName($name); + } + } + if ($this->shouldHydrate($request, 'o:comment')) { + $comment = $request->getValue('o:comment'); + if (!is_null($comment)) { + $comment = trim($comment); + $entity->setComment($comment); + } + } + } + + public function validateRequest(Request $request, ErrorStore $errorStore) + { + $data = $request->getContent(); + if (array_key_exists('o:name', $data)) { + $result = $this->validateName($data['o:name'], $errorStore); + } + } + + public function validateEntity(EntityInterface $entity, ErrorStore $errorStore) + { + $name = $entity->getName(); + $result = $this->validateName($name, $errorStore); + if (!$this->isUnique($entity, ['name' => $name])) { + $errorStore->addError('o:name', new Message( + 'The name "%s" is already taken.', // @translate + $name + )); + } + } + + /** + * Validate a name. + * + * @param string $name + * @param ErrorStore $errorStore + * @return bool + */ + protected function validateName($name, ErrorStore $errorStore) + { + $result = true; + $sanitized = $this->sanitizeLightString($name); + if (is_string($name) && $sanitized !== '') { + $name = $sanitized; + $sanitized = $this->sanitizeString($sanitized); + if ($name !== $sanitized) { + $errorStore->addError('o:name', new Message( + 'The name "%s" contains forbidden characters.', // @translate + $name + )); + $result = false; + } + if (preg_match('~^[\d]+$~', $name)) { + $errorStore->addError('o:name', 'A name can’t contain only numbers.'); // @translate + $result = false; + } + $reserved = [ + 'id', 'name', 'comment', + 'show', 'browse', 'add', 'edit', 'delete', 'delete-confirm', 'batch-edit', 'batch-edit-all', + ]; + if (in_array(strtolower($name), $reserved)) { + $errorStore->addError('o:name', 'A name cannot be a reserved word.'); // @translate + $result = false; + } + } else { + $errorStore->addError('o:name', 'A group must have a name.'); // @translate + $result = false; + } + return $result; + } + + public function buildQuery(QueryBuilder $qb, array $query) + { + if (isset($query['id'])) { + $this->buildQueryValuesItself($qb, $query['id'], 'id'); + } + + if (isset($query['name'])) { + $this->buildQueryValuesItself($qb, $query['name'], 'name'); + } + + if (isset($query['comment'])) { + $this->buildQueryValuesItself($qb, $query['comment'], 'comment'); + } + + // All groups for these entities ("OR"). If multiple, mixed with "AND", + // so, for mixed resources, use "resource_id". + $mapResourceTypes = [ + 'user_id' => User::class, + 'resource_id' => Resource::class, + 'item_set_id' => ItemSet::class, + 'item_id' => Item::class, + 'media_id' => Media::class, + ]; + $subQueryKeys = array_intersect_key($mapResourceTypes, $query); + foreach ($subQueryKeys as $queryKey => $resourceType) { + if ($queryKey === 'user_id') { + $groupEntity = GroupUser::class; + $groupEntityColumn = 'user'; + } else { + $groupEntity = GroupResource::class; + $groupEntityColumn = 'resource'; + } + $entities = is_array($query[$queryKey]) ? $query[$queryKey] : [$query[$queryKey]]; + $entities = array_filter($entities, 'is_numeric'); + if (empty($entities)) { + continue; + } + $groupEntityAlias = $this->createAlias(); + $entityAlias = $this->createAlias(); + $qb + // Note: This query may be used if the annotation is set in + // core on Resource. In place, the relation is recreated. + // ->innerJoin( + // $this->getEntityClass() . ($queryKey === 'user_id' ? '.users' : '.resources'), + // $entityAlias, 'WITH', + // $qb->expr()->in("$entityAlias.id", $this->createNamedParameter($qb, $entities)) + // ); + ->innerJoin( + $groupEntity, + $groupEntityAlias, + 'WITH', + $qb->expr()->andX( + $qb->expr()->eq($groupEntityAlias . '.group', $this->getEntityClass() . '.id'), + $qb->expr()->in( + $groupEntityAlias . '.' . $groupEntityColumn, + $this->createNamedParameter($qb, $entities) + ) + ) + ); + // This check avoids bad result for bad request mixed ids. + if (!in_array($queryKey, ['user_id', 'resource_id'])) { + $resourceAlias = $this->createAlias(); + $qb + ->innerJoin( + $resourceType, + $resourceAlias, + 'WITH', + $qb->expr()->eq( + $groupEntityAlias . '.resource', + $resourceAlias . '.id' + ) + ); + } + } + + if (array_key_exists('resource_type', $query)) { + $mapResourceTypes = [ + 'users' => User::class, + 'resources' => Resource::class, + 'item_sets' => ItemSet::class, + 'items' => Item::class, + 'media' => Media::class, + ]; + if (isset($mapResourceTypes[$query['resource_type']])) { + $entityJoinClass = $query['resource_type'] === 'users' + ? GroupUser::class + : GroupResource::class; + $entityJoinAlias = $this->createAlias(); + $qb + ->linnerJoin( + $entityJoinClass, + $entityJoinAlias, + 'WITH', + $qb->expr()->eq($entityJoinAlias . '.group', Group::class) + ); + if (!in_array($query['resource_type'], ['users', 'resources'])) { + $entityAlias = $this->createAlias(); + $qb + ->innerJoin( + $mapResourceTypes[$query['resource_type']], + $entityAlias, + 'WITH', + $qb->expr()->eq( + $entityJoinClass . '.resource', + $entityAlias . '.id' + ) + ); + } + } elseif ($query['resource_type'] !== '') { + $qb + ->andWhere('1 = 0'); + } + } + } + + public function sortQuery(QueryBuilder $qb, array $query) + { + if (is_string($query['sort_by'])) { + // TODO Use Doctrine native queries (here: ORM query builder). + switch ($query['sort_by']) { + // TODO Sort by count. + case 'count': + break; + // TODO Sort by user ids. + case 'users': + break; + // TODO Sort by resource ids. + case 'resources': + case 'item_sets': + case 'items': + case 'media': + break; + case 'group': + $query['sort_by'] = 'name'; + // No break. + default: + parent::sortQuery($qb, $query); + break; + } + } + } + + /** + * Returns a sanitized string. + * + * @param string $string The string to sanitize. + * @return string The sanitized string. + */ + protected function sanitizeString($string) + { + // Quote is allowed. + $string = strip_tags($string); + // The first character is a space and the last one is a no-break space. + $string = trim($string, ' /\\?<>:*%|"`&; ' . "\t\n\r"); + $string = preg_replace('/[\(\{]/', '[', $string); + $string = preg_replace('/[\)\}]/', ']', $string); + $string = preg_replace('/[[:cntrl:]\/\\\?<>\*\%\|\"`\&\;#+\^\$\s]/', ' ', $string); + return trim(preg_replace('/\s+/', ' ', $string)); + } + + /** + * Returns a light sanitized string. + * + * @param string $string The string to sanitize. + * @return string The sanitized string. + */ + protected function sanitizeLightString($string) + { + return trim(preg_replace('/\s+/', ' ', $string)); + } +} diff --git a/src/Api/Adapter/QueryBuilderTrait.php b/src/Api/Adapter/QueryBuilderTrait.php new file mode 100644 index 0000000..65e72b2 --- /dev/null +++ b/src/Api/Adapter/QueryBuilderTrait.php @@ -0,0 +1,265 @@ +buildQueryOneValue($qb, reset($values), $column); + } else { + $this->buildQueryMultipleValues($qb, $values, $column, $target); + } + } else { + $this->buildQueryOneValue($qb, $values, $column); + } + } + + /** + * Helper to search one value. + * + * @param QueryBuilder $qb + * @param mixed $value + * @param string $column + */ + protected function buildQueryOneValue(QueryBuilder $qb, $value, $column) + { + if (is_null($value)) { + $qb->andWhere($qb->expr()->isNull( + $this->getEntityClass() . '.' . $column + )); + } else { + $qb->andWhere($qb->expr()->eq( + $this->getEntityClass() . '.' . $column, + $this->createNamedParameter($qb, $value) + )); + } + } + + /** + * Helper to search multiple values ("OR"). + * + * @param QueryBuilder $qb + * @param array $values + * @param string $column + * @param string $target + */ + protected function buildQueryMultipleValues(QueryBuilder $qb, array $values, $column, $target) + { + $hasNull = in_array(null, $values, true); + $values = array_filter($values, function ($v) { + return !is_null($v); + }); + if ($values) { + $valueAlias = $this->createAlias(); + $qb->innerJoin( + $this->getEntityClass() . '.' . $column, + $valueAlias, + 'WITH', + $hasNull + ? $qb->expr()->orX( + $qb->expr()->in( + $valueAlias . '.' . $target, + $this->createNamedParameter($qb, $values) + ), + $qb->expr()->isNull( + $valueAlias . '.' . $target + ) + ) + : $qb->expr()->in( + $valueAlias . '.' . $target, + $this->createNamedParameter($qb, $values) + ) + ); + } + // Check no value only. + elseif ($hasNull) { + $qb->andWhere($qb->expr()->isNull( + $this->getEntityClass() . '.' . $column + )); + } + } + + /** + * Helper to search one or multiple ids. + * + * @internal There is no "0" for id, but "null" may be allowed. + * + * @param QueryBuilder $qb + * @param mixed $values One or multiple ids. + * @param string $column + * @param string $target + */ + protected function buildQueryIds(QueryBuilder $qb, $values, $column, $target = 'id') + { + if (is_array($values)) { + if (count($values) == 1) { + $this->buildQueryOneId($qb, reset($values), $column); + } else { + $this->buildQueryMultipleIds($qb, $values, $column, $target); + } + } else { + $this->buildQueryOneId($qb, $values, $column); + } + } + + /** + * Helper to search one id. + * + * @internal There is no "0" for id, but "null" may be allowed. + * + * @param QueryBuilder $qb + * @param mixed $value + * @param string $column + */ + protected function buildQueryOneId(QueryBuilder $qb, $value, $column) + { + $value = ($value && is_numeric($value)) ? $value : null; + $this->buildQueryOneValue($qb, $value, $column); + } + + /** + * Helper to search multiple ids. + * + * @internal There is no "0" for id, but "null" may be allowed. + * + * @param QueryBuilder $qb + * @param array $values Multiple ids. + * @param string $column + * @param string $target + */ + protected function buildQueryMultipleIds(QueryBuilder $qb, $values, $column, $target = 'id') + { + $hasEmpty = in_array(null, $values); + $values = array_filter($values, 'is_numeric'); + if ($hasEmpty) { + $values[] = null; + } + $this->buildQueryMultipleValues($qb, $values, $column, $target); + } + + /** + * Helper to search one or multiple values on the same entity. + * + * @param QueryBuilder $qb + * @param mixed $values One or multiple values. + * @param string $target + */ + protected function buildQueryValuesItself(QueryBuilder $qb, $values, $target) + { + if (is_array($values)) { + if (count($values) == 1) { + $this->buildQueryOneValue($qb, reset($values), $target); + } else { + $this->buildQueryMultipleValuesItself($qb, $values, $target); + } + } else { + $this->buildQueryOneValue($qb, $values, $target); + } + } + + /** + * Helper to search multiple values ("OR") on the same entity. + * + * @param QueryBuilder $qb + * @param array $values + * @param string $target + */ + protected function buildQueryMultipleValuesItself(QueryBuilder $qb, array $values, $target) + { + $hasNull = in_array(null, $values, true); + $values = array_filter($values, function ($v) { + return !is_null($v); + }); + if ($values) { + $valueAlias = $this->createAlias(); + $qb + ->innerJoin( + $this->getEntityClass(), + $valueAlias, + 'WITH', + $qb->expr()->eq( + $this->getEntityClass() . '.id', + $valueAlias . '.id' + ) + ) + ->andWhere( + $hasNull + ? $qb->expr()->orX( + $qb->expr()->in( + $valueAlias . '.' . $target, + $this->createNamedParameter($qb, $values) + ), + $qb->expr()->isNull( + $valueAlias . '.' . $target + ) + ) + : $qb->expr()->in( + $valueAlias . '.' . $target, + $this->createNamedParameter($qb, $values) + ) + ); + } + // Check no value only. + elseif ($hasNull) { + $qb->andWhere($qb->expr()->isNull( + $this->getEntityClass() . '.' . $target + )); + } + } + + /** + * Helper to search one or multiple ids on the same entity. + * + * @internal There is no "0" for id, but "null" may be allowed. + * + * @param QueryBuilder $qb + * @param mixed $values One or multiple ids. + * @param string $target + */ + protected function buildQueryIdsItself(QueryBuilder $qb, $values, $target = 'id') + { + if (is_array($values)) { + if (count($values) == 1) { + $this->buildQueryOneId($qb, reset($values), $target); + } else { + $this->buildQueryMultipleIdsItself($qb, $values, $target); + } + } else { + $this->buildQueryOneId($qb, $values, $target); + } + } + + /** + * Helper to search multiple ids on the same entity. + * + * @internal There is no "0" for id, but "null" may be allowed. + * + * @param QueryBuilder $qb + * @param array $values Multiple ids. + * @param string $target + */ + protected function buildQueryMultipleIdsItself(QueryBuilder $qb, $values, $target = 'id') + { + $hasEmpty = in_array(null, $values); + $values = array_filter($values, 'is_numeric'); + if ($hasEmpty) { + $values[] = null; + } + $this->buildQueryMultipleValuesItself($qb, $values, $target); + } +} diff --git a/src/Api/Representation/GroupReference.php b/src/Api/Representation/GroupReference.php new file mode 100644 index 0000000..99c9ff5 --- /dev/null +++ b/src/Api/Representation/GroupReference.php @@ -0,0 +1,34 @@ +name = $resource->getName(); + parent::__construct($resource, $adapter); + } + + public function name() + { + return $this->name; + } + + public function jsonSerialize() + { + return [ + '@id' => $this->apiUrl(), + 'o:id' => $this->id(), + 'o:name' => $this->name(), + ]; + } +} diff --git a/src/Api/Representation/GroupRepresentation.php b/src/Api/Representation/GroupRepresentation.php new file mode 100644 index 0000000..b35c81f --- /dev/null +++ b/src/Api/Representation/GroupRepresentation.php @@ -0,0 +1,164 @@ + $this->id(), + 'o:name' => $this->name(), + 'o:comment' => $this->comment(), + 'o:users' => $this->urlEntities('user'), + 'o:item_sets' => $this->urlEntities('item-set'), + 'o:items' => $this->urlEntities('item'), + 'o:media' => $this->urlEntities('media'), + ]; + } + + public function getReference() + { + return new GroupReference($this->resource, $this->getAdapter()); + } + + public function name() + { + return $this->resource->getName(); + } + + public function comment() + { + return $this->resource->getComment(); + } + + /** + * Get the resources associated with this group. + * + * @return array Array of AbstractResourceEntityRepresentation + */ + public function resources() + { + $result = []; + $adapter = $this->getAdapter('resources'); + // Note: Use a workaround because the reverse doctrine relation cannot + // be set. See the entity. + // TODO Fix entities for many to many relations. + // foreach ($this->resource->getResources() as $entity) { + foreach ($this->resource->getGroupResources() as $groupResourceEntity) { + $entity = $groupResourceEntity->getResource(); + $result[$entity->getId()] = $adapter->getRepresentation($entity); + } + return $result; + } + + /** + * Get the users associated with this group. + * + * @return array Array of UserRepresentation + */ + public function users() + { + $result = []; + $adapter = $this->getAdapter('users'); + // Note: Use a workaround because the reverse doctrine relation cannot + // be set. See the entity. + // TODO Fix entities for many to many relations. + // foreach ($this->resource->getUsers() as $entity) { + foreach ($this->resource->getGroupUsers() as $groupUserEntity) { + $entity = $groupUserEntity->getUser(); + $result[$entity->getId()] = $adapter->getRepresentation($entity); + } + return $result; + } + + /** + * Get this group's specific resource count. + * + * @param string $resourceType + * @return int + */ + public function count($resourceType = 'resources') + { + if (!isset($this->cacheCounts[$resourceType])) { + $response = $this->getServiceLocator()->get('Omeka\ApiManager') + ->search('groups', [ + 'id' => $this->id(), + 'resource_type' => $resourceType, + ]); + $this->cacheCounts[$resourceType] = $response->getTotalResults(); + } + return $this->cacheCounts[$resourceType]; + } + + public function adminUrl($action = null, $canonical = false) + { + $url = $this->getViewHelper('Url'); + return $url( + 'admin/group-name', + [ + 'action' => $action ?: 'show', + 'name' => $this->name(), + ], + ['force_canonical' => $canonical] + ); + } + + /** + * Return the admin URL to the resource browse page for the group. + * + * Similar to url(), but with the type of resources. + * + * @param string|null $resourceType May be "resource" (unsupported), + * "item-set", "item", "media" or "user". + * @param bool $canonical Whether to return an absolute URL + * @return string + */ + public function urlEntities($resourceType = null, $canonical = false) + { + $mapResource = [ + null => 'item', + 'resources' => 'resource', + 'items' => 'item', + 'item_sets' => 'item-set', + 'users' => 'user', + ]; + if (isset($mapResource[$resourceType])) { + $resourceType = $mapResource[$resourceType]; + } + $routeMatch = $this->getServiceLocator()->get('Application') + ->getMvcEvent()->getRouteMatch(); + $url = $this->getViewHelper('Url'); + return $url( + 'admin/default', + ['controller' => $resourceType, 'action' => 'browse'], + [ + 'query' => ['group' => $this->name()], + 'force_canonical' => $canonical, + ] + ); + } +} diff --git a/src/Controller/Admin/GroupController.php b/src/Controller/Admin/GroupController.php new file mode 100644 index 0000000..97e6700 --- /dev/null +++ b/src/Controller/Admin/GroupController.php @@ -0,0 +1,270 @@ +setBrowseDefaults('name', 'asc'); + $response = $this->api()->search('groups', $this->params()->fromQuery()); + $this->paginator($response->getTotalResults(), $this->params()->fromQuery('page')); + + $groups = $response->getContent(); + $groupCount = $this->viewHelpers()->get('groupCount'); + $groupCount = $groupCount($groups); + + $view = new ViewModel; + $view->setVariable('groups', $groups); + $view->setVariable('groupCount', $groupCount); + return $view; + } + + public function showAction() + { + $response = $this->apiReadFromIdOrName(); + + $view = new ViewModel; + $entity = $response->getContent(); + $view->setVariable('group', $entity); + $view->setVariable('resource', $entity); + return $view; + } + + public function showDetailsAction() + { + $response = $this->apiReadFromIdOrName(); + $group = $response->getContent(); + + $groupCount = $this->viewHelpers()->get('groupCount'); + $groupCount = $groupCount($group); + $groupCount = reset($groupCount); + + $view = new ViewModel; + $view->setTerminal(true); + $view->setVariable('resource', $group); + $view->setVariable('groupCount', $groupCount); + return $view; + } + + public function deleteAction() + { + if ($this->getRequest()->isPost()) { + $form = $this->getForm(ConfirmForm::class); + $form->setData($this->getRequest()->getPost()); + if ($form->isValid()) { + $entity = $this->apiReadFromIdOrName()->getContent(); + $response = $this->api($form)->delete('groups', $entity->id()); + if ($response) { + $this->messenger()->addSuccess('Group successfully deleted.'); // @translate + } + } else { + $this->messenger()->addFormErrors($form); + } + } + return $this->redirect()->toRoute('admin/group'); + } + + public function deleteConfirmAction() + { + $response = $this->apiReadFromIdOrName(); + $group = $response->getContent(); + + $groupCount = $this->viewHelpers()->get('groupCount'); + $groupCount = $groupCount($group); + $groupCount = reset($groupCount); + + $view = new ViewModel; + $view->setTerminal(true); + $view->setTemplate('common/delete-confirm-details'); + $view->setVariable('group', $group); + $view->setVariable('groupCount', $groupCount); + $view->setVariable('resource', $group); + $view->setVariable('resourceLabel', 'group'); + $view->setVariable('partialPath', 'group/admin/group/show-details'); + return $view; + } + + public function batchDeleteConfirmAction() + { + $form = $this->getForm(ConfirmForm::class); + $routeAction = $this->params()->fromQuery('all') ? 'batch-delete-all' : 'batch-delete'; + $form->setAttribute('action', $this->url()->fromRoute(null, ['action' => $routeAction], true)); + $form->setButtonLabel('Confirm delete'); // @translate + $form->setAttribute('id', 'batch-delete-confirm'); + $form->setAttribute('class', $routeAction); + + $view = new ViewModel; + $view->setTerminal(true); + $view->setVariable('form', $form); + return $view; + } + + public function batchDeleteAction() + { + if (!$this->getRequest()->isPost()) { + return $this->redirect()->toRoute(null, ['action' => 'browse'], true); + } + + $resourceIds = $this->params()->fromPost('resource_ids', []); + if (!$resourceIds) { + $this->messenger()->addError('You must select at least one group to batch delete.'); // @translate + return $this->redirect()->toRoute(null, ['action' => 'browse'], true); + } + + $form = $this->getForm(ConfirmForm::class); + $form->setData($this->getRequest()->getPost()); + if ($form->isValid()) { + $response = $this->api($form)->batchDelete('groups', $resourceIds, [], ['continueOnError' => true]); + if ($response) { + $this->messenger()->addSuccess('Groups successfully deleted.'); // @translate + } + } else { + $this->messenger()->addFormErrors($form); + } + return $this->redirect()->toRoute(null, ['action' => 'browse'], true); + } + + public function batchDeleteAllAction() + { + // TODO Support batch delete all. + $this->messenger()->addError('Delete of all groups is not supported currently.'); // @translate + } + + public function addAction() + { + $form = $this->getForm(GroupForm::class); + $form->setAttribute('action', $this->url()->fromRoute(null, [], true)); + $form->setAttribute('enctype', 'multipart/form-data'); + $form->setAttribute('id', 'add-group'); + if ($this->getRequest()->isPost()) { + $data = $this->params()->fromPost(); + $form->setData($data); + if ($form->isValid()) { + $response = $this->api($form)->create('groups', $data); + if ($response) { + $message = new Message( + 'Group successfully created. %s', // @translate + sprintf( + '%s', + htmlspecialchars($this->url()->fromRoute(null, [], true)), + $this->translate('Add another group?') // @translate + )); + $message->setEscapeHtml(false); + $this->messenger()->addSuccess($message); + return $this->redirect()->toUrl($response->getContent()->url()); + } + } else { + $this->messenger()->addFormErrors($form); + } + } + + $view = new ViewModel; + $view->setVariable('form', $form); + return $view; + } + + public function updateAction() + { + $response = $this->apiReadFromIdOrName(); + $group = $response->getContent(); + $id = $group->id(); + $name = $this->params()->fromPost('text'); + + $data = []; + $data['o:name'] = $name; + $response = $this->api()->update('groups', $id, $data, ['isPartial' => true]); + if (!$response) { + return $this->jsonErrorName(); + } + + $group = $response->getContent(); + $escape = $this->viewHelpers()->get('escapeHtml'); + return new JsonModel([ + 'content' => [ + 'text' => $group->name(), + 'escaped' => $escape($group->name()), + 'urls' => [ + 'update' => $group->url('update'), + 'show_details' => $group->url('show-details'), + 'delete_confirm' => $group->url('delete-confirm'), + 'users' => $group->urlEntities('user'), + 'item_sets' => $group->urlEntities('item-set'), + 'items' => $group->urlEntities('item'), + 'media' => $group->urlEntities('media'), + ], + ], + ]); + } + + protected function jsonErrorEmpty() + { + $response = $this->getResponse(); + $response->setStatusCode(Response::STATUS_CODE_400); + return new JsonModel(['error' => 'No groups submitted.']); // @translate + } + + protected function jsonErrorName() + { + $response = $this->getResponse(); + $response->setStatusCode(Response::STATUS_CODE_400); + return new JsonModel(['error' => 'This group is invalid: it is a duplicate or it contains forbidden characters.']); // @translate + } + + protected function jsonErrorUnauthorized() + { + $response = $this->getResponse(); + $response->setStatusCode(Response::STATUS_CODE_403); + return new JsonModel(['error' => 'Unauthorized access.']); // @translate + } + + protected function jsonErrorNotFound() + { + $response = $this->getResponse(); + $response->setStatusCode(Response::STATUS_CODE_404); + return new JsonModel(['error' => 'Group not found.']); // @translate + } + + protected function jsonErrorUpdate() + { + $response = $this->getResponse(); + $response->setStatusCode(Response::STATUS_CODE_500); + return new JsonModel(['error' => 'An internal error occurred.']); // @translate + } + + protected function apiReadFromIdOrName() + { + $id = $this->params('id'); + if ($id) { + $response = $this->api()->read('groups', $id); + } else { + $name = $this->params('name'); + $response = $this->api()->search('groups', [ + 'name' => $this->params('name'), + 'limit' => 1, + ]); + $content = $response->getContent(); + if (empty($content)) { + throw new \Omeka\Api\Exception\NotFoundException(new Message( + '%s entity with criteria {"%s":"%s"} not found.', // @translate + 'Group\Entity\Group', 'name', $name)); + } + $content = is_array($content) && count($content) ? $content[0] : null; + $response->setContent($content); + } + return $response; + } +} diff --git a/src/Db/Event/Listener/DetachOrphanGroupEntities.php b/src/Db/Event/Listener/DetachOrphanGroupEntities.php new file mode 100644 index 0000000..0812cbf --- /dev/null +++ b/src/Db/Event/Listener/DetachOrphanGroupEntities.php @@ -0,0 +1,45 @@ +getEntityManager(); + $uow = $em->getUnitOfWork(); + $identityMap = $uow->getIdentityMap(); + + if (isset($identityMap[GroupResource::class])) { + foreach ($identityMap[GroupResource::class] as $groupResource) { + $resource = $groupResource->getResource(); + if ($resource && !$em->contains($resource)) { + $em->detach($groupResource); + } + } + } + + if (isset($identityMap[GroupUser::class])) { + foreach ($identityMap[GroupUser::class] as $groupUser) { + $user = $groupUser->getUser(); + if ($user && !$em->contains($user)) { + $em->detach($groupUser); + } + } + } + } +} diff --git a/src/Db/Filter/ResourceVisibilityFilter.php b/src/Db/Filter/ResourceVisibilityFilter.php new file mode 100644 index 0000000..ea469a7 --- /dev/null +++ b/src/Db/Filter/ResourceVisibilityFilter.php @@ -0,0 +1,52 @@ +serviceLocator->get('Omeka\AuthenticationService')->getIdentity(); + + // Users can view private resources when they have at least one group in + // common. + if ($identity) { + // Because the groups are assigned recursively, by default, a simple + // check of the groups of the users and the groups of the resource + // allows to determine the rights. + // TODO Use a named query. + // TODO Add a join the resource type to improve the sub query (which alias? which id?) + // INNER JOIN item ON group_resource.resource_id = item.id LIMIT 1 + $constraints .= sprintf( + ' OR %s.id IN ( +SELECT group_resource.resource_id +FROM group_resource +INNER JOIN group_user ON group_resource.group_id = group_user.group_id AND group_user.user_id = %s +)', + $alias, + $this->getConnection()->quote($identity->getId(), Type::INTEGER)); + } + + return $constraints; + } +} diff --git a/src/Entity/Group.php b/src/Entity/Group.php new file mode 100644 index 0000000..5e57653 --- /dev/null +++ b/src/Entity/Group.php @@ -0,0 +1,190 @@ +users = new ArrayCollection(); + $this->groupUsers = new ArrayCollection(); + $this->resources = new ArrayCollection(); + $this->groupResources = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } + + public function setName($name) + { + $this->name = $name; + } + + public function getName() + { + return $this->name; + } + + public function setComment($comment) + { + $this->comment = $comment; + } + + public function getComment() + { + return $this->comment; + } + + public function getUsers() + { + return $this->users; + } + + public function getGroupUsers() + { + return $this->groupUsers; + } + + public function getResources() + { + return $this->resources; + } + + public function getGroupResources() + { + return $this->groupResources; + } +} diff --git a/src/Entity/GroupResource.php b/src/Entity/GroupResource.php new file mode 100644 index 0000000..bbb0e92 --- /dev/null +++ b/src/Entity/GroupResource.php @@ -0,0 +1,67 @@ +group = $group; + $this->resource = $resource; + } + + public function getGroup() + { + return $this->group; + } + + public function getResource() + { + return $this->resource; + } + + public function __toString() + { + return json_encode([ + 'group' => $this->getGroup()->getId(), + 'resource' => $this->getResource()->getId(), + ]); + } +} diff --git a/src/Entity/GroupUser.php b/src/Entity/GroupUser.php new file mode 100644 index 0000000..125ec17 --- /dev/null +++ b/src/Entity/GroupUser.php @@ -0,0 +1,67 @@ +group = $group; + $this->user = $user; + } + + public function getGroup() + { + return $this->group; + } + + public function getUser() + { + return $this->user; + } + + public function __toString() + { + return json_encode([ + 'group' => $this->getGroup()->getId(), + 'user' => $this->getUser()->getId(), + ]); + } +} diff --git a/src/Form/Element/GroupSelect.php b/src/Form/Element/GroupSelect.php new file mode 100644 index 0000000..6671e19 --- /dev/null +++ b/src/Form/Element/GroupSelect.php @@ -0,0 +1,102 @@ +getOption('query'); + if (!is_array($query)) { + $query = []; + } + if (!isset($query['sort_by'])) { + $query['sort_by'] = 'name'; + } + + $nameAsValue = $this->getOption('name_as_value', false); + + $valueOptions = []; + $response = $this->getApiManager()->search('groups', $query); + foreach ($response->getContent() as $representation) { + $name = $representation->name(); + $key = $nameAsValue ? $representation->id() : $name; + $valueOptions[$key] = $name; + } + + $prependValueOptions = $this->getOption('prepend_value_options'); + if (is_array($prependValueOptions)) { + $valueOptions = $prependValueOptions + $valueOptions; + } + return $valueOptions; + } + + public function setOptions($options) + { + if (!empty($options['chosen'])) { + $defaultOptions = [ + 'resource_value_options' => [ + 'resource' => 'groups', + 'query' => [], + 'option_text_callback' => function ($v) { + return $v->name(); + }, + ], + 'name_as_value' => false, + ]; + $options = $options + ? array_merge_recursive($defaultOptions, $options) + : $defaultOptions; + + $urlHelper = $this->getUrlHelper(); + $defaultAttributes = [ + 'class' => 'chosen-select', + 'data-placeholder' => 'Select groups', // @translate + 'data-api-base-url' => $urlHelper('api/default', ['resource' => 'groups']), + ]; + $this->setAttributes($defaultAttributes); + } + + return parent::setOptions($options); + } + + /** + * @param ApiManager $apiManager + */ + public function setApiManager(ApiManager $apiManager) + { + $this->apiManager = $apiManager; + } + + /** + * @return ApiManager + */ + public function getApiManager() + { + return $this->apiManager; + } + + /** + * @param Url $urlHelper + */ + public function setUrlHelper(Url $urlHelper) + { + $this->urlHelper = $urlHelper; + } + + /** + * @return Url + */ + public function getUrlHelper() + { + return $this->urlHelper; + } +} diff --git a/src/Form/GroupForm.php b/src/Form/GroupForm.php new file mode 100644 index 0000000..7195b99 --- /dev/null +++ b/src/Form/GroupForm.php @@ -0,0 +1,36 @@ +setAttribute('id', 'group-form'); + + $this->add([ + 'name' => 'o:name', + 'type' => 'Text', + 'options' => [ + 'label' => 'Name', // @translate + ], + 'attributes' => [ + 'id' => 'name', + 'required' => true, + ], + ]); + + $this->add([ + 'name' => 'o:comment', + 'type' => 'Text', + 'options' => [ + 'label' => 'Comment', // @translate + ], + 'attributes' => [ + 'id' => 'comment', + 'required' => false, + ], + ]); + } +} diff --git a/src/Form/SearchForm.php b/src/Form/SearchForm.php new file mode 100644 index 0000000..6839f47 --- /dev/null +++ b/src/Form/SearchForm.php @@ -0,0 +1,29 @@ +add([ + 'type' => Checkbox::class, + 'name' => 'has_groups', + 'options' => [ + 'label' => 'Has groups', // @translate + ], + ]); + + $this->add([ + 'type' => Text::class, + 'name' => 'group', + 'options' => [ + 'label' => 'Search by group', // @translate + 'info' => 'Multiple groups may be comma-separated.', // @translate + ], + ]); + } +} diff --git a/src/Mapping/GroupMapping.php b/src/Mapping/GroupMapping.php new file mode 100644 index 0000000..15e1f42 --- /dev/null +++ b/src/Mapping/GroupMapping.php @@ -0,0 +1,82 @@ +partial('common/admin/group-sidebar'); + } + + public function processRow(array $row) + { + // Reset the data and the map between rows. + $this->setHasErr(false); + $data = []; + $map = []; + + // First, pull in the global settings. + // Set columns. + if (isset($this->args['column-group'])) { + $map['group'] = $this->args['column-group']; + $data['o-module-group:group'] = []; + } + + // Set default values. + if (!empty($this->args['o-module-group:group'])) { + $data['o-module-group:group'] = []; + foreach ($this->args['o-module-group:group'] as $id) { + $isId = preg_match('~^\d+$~', $id); + $data['o-module-group:group'][] = $isId + ? ['o:id' => (int) $id] + : ['o:name' => $id]; + } + } + + // Second, map the row. + $multivalueMap = isset($this->args['column-multivalue']) ? $this->args['column-multivalue'] : []; + // TODO Allow to bypass the default multivalue separator for users and resources. + $multivalueSeparator = isset($this->args['multivalue_separator']) ? $this->args['multivalue_separator'] : ''; + foreach ($row as $index => $values) { + if (empty($multivalueMap[$index])) { + $values = [$values]; + } else { + $values = explode($multivalueSeparator, $values); + $values = array_map(function ($v) { + return trim($v, "\t\n\r   "); + }, $values); + } + $values = array_filter($values, 'strlen'); + if (isset($map['group'][$index])) { + foreach ($values as $value) { + $group = $this->findGroup($value); + if ($group) { + $data['o-module-group:group'][] = $group->id(); + } + } + } + } + + return $data; + } + + protected function findGroup($identifier) + { + $isId = preg_match('~^\d+$~', $identifier); + $response = $this->api->search('groups', [$isId ? 'id' : 'name' => $identifier]); + $result = $response->getContent(); + if (empty($result)) { + $this->logger->err(new Message('"%s" is not a valid group.', $identifier)); // @translate + $this->setHasErr(true); + return false; + } + return reset($result); + } +} diff --git a/src/Mvc/Controller/Plugin/ApplyGroups.php b/src/Mvc/Controller/Plugin/ApplyGroups.php new file mode 100644 index 0000000..19860d8 --- /dev/null +++ b/src/Mvc/Controller/Plugin/ApplyGroups.php @@ -0,0 +1,374 @@ +api = $api; + $this->acl = $acl; + $this->entityManager = $entityManager; + } + + /** + * Apply groups for an entity (user, item set, item or media), with optional + * recursivity. + * + * Recursivity: + * When assigning an item set, a check is done to all their items to set all + * their groups according to all their item sets. The same check is done + * when an item is saved. The groups for media are reset to the same values + * than the item, if the options are set accordingly. + * + * Entities are not flushed. + * + * @param Resource|User $entity No action is done with a user. + * @param array $groups A list of group ids, names or objects (no mix). + * @param string $collectionAction "replace" (default), "remove" or "append". + * @param bool $aboveGroups If true, items will take groups from the item + * sets they belong and medias will take groups from their item. + * @param bool $recursive If true, the groups of the current entity will be + * applied below (items for items sets, medias for items). + */ + public function __invoke( + AbstractEntity $entity, + array $groups, + $collectionAction = 'replace', + $aboveGroups = false, + $recursive = false + ) { + $this->isUser = $entity->getResourceId() === User::class; + $groupEntity = $this->isUser ? GroupUser::class : GroupResource::class; + switch ($collectionAction) { + case 'replace': + if (!$this->acl->userIsAllowed($groupEntity, 'update')) { + return; + } + break; + case 'append': + if (!$this->acl->userIsAllowed($groupEntity, 'create')) { + return; + } + break; + case 'remove': + if (!$this->acl->userIsAllowed($groupEntity, 'delete')) { + return; + } + break; + } + + $groups = $this->checkGroups($groups); + + switch ($entity->getResourceId()) { + case User::class: + // No groups above and nothing to recursive. + $this->applyGroupsToEntity($entity, $groups, $collectionAction); + break; + + case ItemSet::class: + // No groups above. + $this->applyGroupsToEntity($entity, $groups, $collectionAction); + if ($recursive) { + if (in_array($collectionAction, ['append', 'remove'])) { + $groupEntitiesRepository = $this->entityManager->getRepository(GroupResource::class); + $groupEntities = $groupEntitiesRepository->findBy(['resource' => $entity->getId()]); + $currentGroups = []; + foreach ($groupEntities as $groupEntity) { + $group = $groupEntity->getGroup(); + $currentGroups[$group->getId()] = $group; + } + // The repository is not up to date, so update directly. + $groups = $collectionAction === 'append' + ? array_replace($currentGroups, $groups) + : array_diff_key($currentGroups, $groups); + } + foreach ($entity->getItems() as $item) { + $this->applyGroupsToItemAndMedia($item, $groups, true, $entity); + } + } + break; + + case Item::class: + if ($aboveGroups) { + // When groups are item sets ones, the recursivity applies + // always on medias too. + $this->applyGroupsToItemAndMedia($entity, null, true, null); + } elseif ($recursive) { + $this->applyGroupsToItemAndMedia($entity, $groups, false, null); + } else { + $this->applyGroupsToEntity($entity, $groups, $collectionAction); + } + break; + + case Media::class: + if ($aboveGroups) { + // During a creation, the groups will be applied from the + // item, if set. + if ($entity->getId() && $entity->getItem()->getId()) { + $groups = $this->getItemGroups($entity->getItem()); + $this->applyGroupsToEntity($entity, $groups); + } + } else { + $this->applyGroupsToEntity($entity, $groups, $collectionAction); + } + // Nothing to recursive. + break; + } + } + + /** + * Get the list of groups of an item. + * + * @param Item $item + * @return array|null Associative array of groups with id as key. + */ + protected function getItemGroups(Item $item) + { + $itemId = $item->getId(); + if (empty($itemId)) { + return; + } + $groups = $this->api + ->search('groups', + ['item_id' => $itemId], + ['responseContent' => 'resource'] + ) + ->getContent(); + $groups = $this->listWithIdAsKey($groups); + return $groups; + } + + /** + * Get the list of recursive groups for an item. + * + * @param Item $item + * @param ItemSet $itemSet Used when processed recursively. + * @param array $itemSetGroups + * @return array Associative array of groups with id as key. + */ + protected function getItemGroupsFromItemSets(Item $item, ItemSet $itemSet = null, array $itemSetGroups = null) + { + $itemSets = $this->listWithIdAsKey($item->getItemSets()); + if ($itemSet) { + unset($itemSets[$itemSet->getId()]); + } + // This return avoids to set all groups when there are no item set. + if (empty($itemSets)) { + return $itemSet && $itemSetGroups ? $itemSetGroups : []; + } + $groups = $this->api + ->search('groups', + ['item_set_id' => array_keys($itemSets)], + ['responseContent' => 'resource'] + ) + ->getContent(); + $groups = $this->listWithIdAsKey($groups); + if ($itemSet && $itemSetGroups) { + $groups = array_replace($groups, $itemSetGroups); + ksort($groups); + } + return $groups; + } + + /** + * Apply groups to an item directly or from its item sets and to its medias. + * + * @param Item $item + * @param array $groups + * @param bool $aboveGroups + * @param ItemSet $itemSet + */ + protected function applyGroupsToItemAndMedia( + Item $item, + array $groups = null, + $aboveGroups = false, + ItemSet $itemSet = null + ) { + if ($aboveGroups) { + // Get all groups to apply, with id as key. + $newGroups = $this->getItemGroupsFromItemSets($item, $itemSet, $groups); + } else { + $newGroups = $groups ?: []; + } + + // Apply these groups to the item. + $this->applyGroupsToEntity($item, $newGroups); + + // Process all these groups to all the media of the item. + foreach ($item->getMedia() as $media) { + $this->applyGroupsToEntity($media, $newGroups); + } + } + + /** + * Apply a list of groups to an entity (add and remove). + * + * @param AbstractEntity $entity + * @param array $groups Associative array of groups with id as key. + * @param string $collectionAction "replace" (default), "remove" or "append". + * @return array Associative array of groups with id as key. + */ + protected function applyGroupsToEntity( + AbstractEntity $entity, + array $groups, + $collectionAction = 'replace' + ) { + if ($this->isUser) { + $groupEntitiesRepository = $this->entityManager->getRepository(GroupUser::class); + $column = 'user'; + } else { + $groupEntitiesRepository = $this->entityManager->getRepository(GroupResource::class); + $column = 'resource'; + } + + // Get the list of existing groups. + $currentGroupEntities = $groupEntitiesRepository->findBy([ + $column => $entity->getId(), + ]); + + switch ($collectionAction) { + case 'append': + case 'replace': + // Get the list of groups that are not already assigned. + $groupsToAssign = $groups; + foreach ($currentGroupEntities as $groupEntity) { + $group = $groupEntity->getGroup(); + if (isset($groups[$group->getId()])) { + unset($groupsToAssign[$group->getId()]); + } + } + + // Assign each remaining group. + foreach ($groupsToAssign as $group) { + // This check avoids a persist issue. + $currentGroupEntity = $groupEntitiesRepository->findBy([ + 'group' => $group->getId(), + $column => $entity->getId(), + ]); + if ($currentGroupEntity) { + continue; + } + + $groupEntity = $this->isUser + ? new GroupUser($group, $entity) + : new GroupResource($group, $entity); + $this->entityManager->persist($groupEntity); + } + + if ($collectionAction === 'append') { + break; + } + + // Unassign the groups that are not to be applied. + foreach ($currentGroupEntities as $groupEntity) { + $group = $groupEntity->getGroup(); + if (!isset($groups[$group->getId()])) { + $this->entityManager->remove($groupEntity); + } + } + break; + + case 'remove': + foreach ($currentGroupEntities as $groupEntity) { + $group = $groupEntity->getGroup(); + if (isset($groups[$group->getId()])) { + $this->entityManager->remove($groupEntity); + } + } + break; + } + } + + /** + * Get a list of group objects by id. + * + * If groups are names, check if they exist already via database requests to + * avoid issues between sql and php characters transliterating and casing. + * + * @param array $groups List of group ids, names or group objects (entities, + * representations or references). + * @return array Associative array of group entities with id as key. + */ + protected function checkGroups(array $groups) + { + if (empty($groups)) { + return []; + } + + $firstGroup = reset($groups); + if (is_object($firstGroup)) { + if ($firstGroup instanceof AbstractEntity) { + return $this->listWithIdAsKey($groups); + } + $groups = array_map(function ($v) { + return $v->id(); + }, $groups); + $firstGroup = reset($groups); + } + + $isId = preg_match('~^\d+$~', $firstGroup); + + $groups = $this->api + ->search('groups', + [$isId ? 'id' : 'name' => $groups], + ['responseContent' => 'resource'] + ) + ->getContent(); + return $this->listWithIdAsKey($groups); + } + + /** + * Helper to list entities with id as key (with implicite deduplication). + * + * @param array $entities A Doctrine\ORM\PersistentCollection can be passed. + * @return array Associative array of entities with id as key. + */ + protected function listWithIdAsKey($entities) + { + // The function array_map() is not available with PersistentCollection. + $result = []; + foreach ($entities as $entity) { + $result[$entity->getId()] = $entity; + } + return $result; + } +} diff --git a/src/Service/ControllerPlugin/ApplyGroupsFactory.php b/src/Service/ControllerPlugin/ApplyGroupsFactory.php new file mode 100644 index 0000000..260363b --- /dev/null +++ b/src/Service/ControllerPlugin/ApplyGroupsFactory.php @@ -0,0 +1,21 @@ +get('Omeka\ApiManager'); + $acl = $services->get('Omeka\Acl'); + $entityManager = $services->get('Omeka\EntityManager'); + return new ApplyGroups( + $api, + $acl, + $entityManager + ); + } +} diff --git a/src/Service/Form/Element/GroupSelectFactory.php b/src/Service/Form/Element/GroupSelectFactory.php new file mode 100644 index 0000000..04d1a21 --- /dev/null +++ b/src/Service/Form/Element/GroupSelectFactory.php @@ -0,0 +1,17 @@ +setApiManager($services->get('Omeka\ApiManager')); + $element->setUrlHelper($services->get('ViewHelperManager')->get('Url')); + return $element; + } +} diff --git a/src/Service/ViewHelper/GroupCountFactory.php b/src/Service/ViewHelper/GroupCountFactory.php new file mode 100644 index 0000000..e49d78f --- /dev/null +++ b/src/Service/ViewHelper/GroupCountFactory.php @@ -0,0 +1,16 @@ +get('Omeka\EntityManager'); + $conn = $entityManager->getConnection(); + return new GroupCount($conn); + } +} diff --git a/src/View/Helper/GroupCount.php b/src/View/Helper/GroupCount.php new file mode 100644 index 0000000..b839f34 --- /dev/null +++ b/src/View/Helper/GroupCount.php @@ -0,0 +1,210 @@ +connection = $connection; + } + + /** + * Return the count for a list of groups for a specified resource type. + * + * The stats are available directly as method of Group, so this helper is + * mainly used for performance (one query for all stats). + * + * @todo Use Doctrine native queries (here: DBAL query builder) or repositories. + * + * @param array|string $groups If empty, return an array of all groups. The + * group may be an entity, a representation, a name or an id (an name cannot + * be an integer). + * @param string $resourceName If empty returns the count of each resource + * (user, item set, item and media), and the total (resources and users). + * @param bool $usedOnly Returns only the used groups (default: all groups). + * @param string $orderBy Sort column and direction, for example "group.name" + * (default), "count asc", "item_sets", "items" or "media". + * @param bool $keyPair Returns a flat array of names and counts when a + * resource name is set. + * @return array Associative array with names as keys. + */ + public function __invoke( + $groups = [], + $resourceName = '', + $usedOnly = false, + $orderBy = '', + $keyPair = false + ) { + $qb = $this->connection->createQueryBuilder(); + + $select = []; + $select['name'] = 'groups.name'; + + $types = [ + 'users' => User::class, + 'resources' => Resource::class, + 'item_sets' => ItemSet::class, + 'items' => Item::class, + 'media' => Media::class, + 'user' => User::class, + 'resource' => Resource::class, + 'item_set' => ItemSet::class, + 'item' => Item::class, + User::class => User::class, + ItemSet::class => ItemSet::class, + Item::class => Item::class, + Media::class => Media::class, + Resource::class => Resource::class, + ]; + $resourceType = isset($types[$resourceName]) ? $types[$resourceName] : ''; + + $joinTable = $resourceType === User::class ? 'group_user' : 'group_resource'; + + $eqGroupGrouping = $qb->expr()->eq('groups.id', $joinTable . '.group_id'); + $eqResourceGrouping = $qb->expr()->eq('resource.id', $joinTable . '.resource_id'); + + // Select all types of resource separately and together. + if (empty($resourceType)) { + // The total of users and the full total is done separately below. + $select['resources'] = 'COUNT(resource.resource_type) AS "resources"'; + $select['item_sets'] = 'SUM(CASE WHEN resource.resource_type = "Omeka\\\\Entity\\\\ItemSet" THEN 1 ELSE 0 END) AS "item_sets"'; + $select['items'] = 'SUM(CASE WHEN resource.resource_type = "Omeka\\\\Entity\\\\Item" THEN 1 ELSE 0 END) AS "items"'; + $select['media'] = 'SUM(CASE WHEN resource.resource_type = "Omeka\\\\Entity\\\\Media" THEN 1 ELSE 0 END) AS "media"'; + if ($usedOnly) { + $qb + ->innerJoin('groups', 'group_resource', 'group_resource', $eqGroupGrouping) + ->innerJoin('group_resource', 'resource', 'resource', $eqResourceGrouping); + } else { + $qb + ->leftJoin('groups', 'group_resource', 'group_resource', $eqGroupGrouping) + ->leftJoin('group_resource', 'resource', 'resource', $eqResourceGrouping); + } + } + + // Select all users or all resources together. + elseif (in_array($resourceType, [User::class, Resource::class])) { + $select['count'] = 'COUNT(' . $joinTable . '.group_id) AS "count"'; + if ($usedOnly) { + $qb + ->innerJoin( + 'groups', + $joinTable, + $joinTable, + $qb->expr()->andX( + $eqGroupGrouping, + $qb->expr()->isNotNull($joinTable . '.resource_id') + )); + } else { + $qb + ->leftJoin('groups', $joinTable, $joinTable, $eqGroupGrouping); + } + } + + // Select one type of resource. + else { + $eqResourceType = $qb->expr()->eq('resource.resource_type', ':resource_type'); + $qb + ->setParameter('resource_type', $resourceType); + if ($usedOnly) { + $select['count'] = 'COUNT(group_resource.group_id) AS "count"'; + $qb + ->innerJoin('groups', 'group_resource', 'group_resource', $eqGroupGrouping) + ->innerJoin( + 'group_resource', + 'resource', + 'resource', + $qb->expr()->andX( + $eqResourceGrouping, + $eqResourceType + )); + } else { + $select['count'] = 'COUNT(resource.resource_type) AS "count"'; + $qb + ->leftJoin('groups', 'group_resource', 'group_resource', $eqGroupGrouping) + ->leftJoin( + 'group_resource', + 'resource', + 'resource', + $qb->expr()->andX( + $eqResourceGrouping, + $eqResourceType + )); + } + } + + if ($groups) { + // Get a list of group names from a various list of groups (entity, + // representation, names). + $groups = array_unique(array_map(function ($v) { + return is_object($v) ? ($v instanceof Group ? $v->getName() : $v->name()) : $v; + }, is_array($groups) || $groups instanceof ArrayCollection ? $groups : [$groups])); + + $isId = preg_match('~^\d+$~', reset($groups)); + if ($isId) { + $groups = array_map('intval', $groups); + $qb + ->andWhere($qb->expr()->in('groups.id', $groups)); + } else { + // TODO How to do a "WHERE IN" with doctrine and strings? + $quotedGroups = array_map([$this->connection, 'quote'], $groups); + $qb + ->andWhere($qb->expr()->in('groups.name', $quotedGroups)); + } + } + + $orderBy = trim($orderBy); + if (strpos($orderBy, ' ')) { + $order = explode(' ', $orderBy); + $orderBy = $orderBy[0]; + $orderDir = $orderBy[1]; + } else { + $orderBy = $orderBy ?: 'groups.name'; + $orderDir = 'ASC'; + } + + $qb + ->select($select) + ->from('groups', 'groups') + ->groupBy('groups.id') + ->orderBy($orderBy, $orderDir); + + $stmt = $this->connection->executeQuery($qb, $qb->getParameters()); + $fetchMode = $keyPair && $resourceType + ? PDO::FETCH_KEY_PAIR + : (PDO::FETCH_GROUP | PDO::FETCH_UNIQUE); + $result = $stmt->fetchAll($fetchMode); + + // Manage the exception (all counts of users and resources). + if (empty($resourceType)) { + $resultUsers = $this->__invoke($groups, User::class, $usedOnly, $orderBy, $keyPair); + foreach ($result as $groupName => &$values) { + $userCount = $resultUsers[$groupName]['count']; + $userValues = []; + $userValues['count'] = $userCount + $values['resources']; + $userValues['users'] = $userCount; + $values = $userValues + $values; + } + } + + return $result; + } +} diff --git a/src/View/Helper/GroupSelector.php b/src/View/Helper/GroupSelector.php new file mode 100644 index 0000000..68b0d88 --- /dev/null +++ b/src/View/Helper/GroupSelector.php @@ -0,0 +1,25 @@ +getView()->api()->search('groups', ['sort_by' => 'name']); + $groups = $response->getContent(); + return $this->getView()->partial( + 'common/admin/groups-selector', + [ + 'groups' => $groups, + 'totalGroupCount' => $response->getTotalResults(), + ] + ); + } +} diff --git a/test/bootstrap.php b/test/bootstrap.php new file mode 100644 index 0000000..833382e --- /dev/null +++ b/test/bootstrap.php @@ -0,0 +1,9 @@ + + + + + ./GroupTest + + + diff --git a/view/common/admin/group-sidebar.phtml b/view/common/admin/group-sidebar.phtml new file mode 100644 index 0000000..c7ebb04 --- /dev/null +++ b/view/common/admin/group-sidebar.phtml @@ -0,0 +1,14 @@ + diff --git a/view/common/admin/groups-advanced-search.phtml b/view/common/admin/groups-advanced-search.phtml new file mode 100644 index 0000000..f3464e5 --- /dev/null +++ b/view/common/admin/groups-advanced-search.phtml @@ -0,0 +1,6 @@ +prepare(); +$element = $searchGroupForm->get('has_groups'); +echo $this->formRow($element); +$element = $searchGroupForm->get('group'); +echo $this->formRow($element); diff --git a/view/common/admin/groups-resource-form.phtml b/view/common/admin/groups-resource-form.phtml new file mode 100644 index 0000000..bc30baf --- /dev/null +++ b/view/common/admin/groups-resource-form.phtml @@ -0,0 +1,76 @@ +headLink()->appendStylesheet($this->assetUrl('css/group.css', 'Group')); +$this->headScript()->appendFile($this->assetUrl('js/group.js', 'Group')); +$escape = $this->plugin('escapeHtml'); +$removeStr = $escape($this->translate('Remove group')); +$groupTemplate = ' + + + + + + +'; +?> +
+ translate('Groups'); ?> + + + + + + + + getControllerName(); + $group = reset($groups); + $updateRight = $group->userIsAllowed('update'); + ?> + + + + + + + +
translate('Group'); ?>
+ hyperlink($group->name(), $group->adminUrl()); ?> +
    +
  • + + + + + +
  • +
+ +
+
+

+ translate('There are no groups for this resource.'); ?> +
+ translate('Add existing groups using the interface to the right.'); ?> +

+
+ + + groupSelector(); ?> +
diff --git a/view/common/admin/groups-resource-list.phtml b/view/common/admin/groups-resource-list.phtml new file mode 100644 index 0000000..623b842 --- /dev/null +++ b/view/common/admin/groups-resource-list.phtml @@ -0,0 +1,63 @@ +headLink()->appendStylesheet($this->assetUrl('css/group.css', 'Group')); +$this->headScript()->appendFile($this->assetUrl('js/group.js', 'Group')); +?> + +
+ +

translate('Groups'); ?>

+ +
+ +
+

translate('There are no groups for this user.') + : $this->translate('There are no groups for this resource.'); + ?>

+
+ + plugin('escapeHtml'); + $resourceName = $resource->getControllerName(); + $updateRight = false; // $this->userIsAllowed(Group::class, 'update'); + ?> +
    + +
  • hyperlink($group->name(), $group->adminUrl()); ?> + + + + + + + +
  • + +
+ +
+
diff --git a/view/common/admin/groups-resource.phtml b/view/common/admin/groups-resource.phtml new file mode 100644 index 0000000..e35e476 --- /dev/null +++ b/view/common/admin/groups-resource.phtml @@ -0,0 +1,46 @@ + +plugin('escapeHtml'); +$resourceName = $resource->getControllerName(); +$updateRight = false; // $this->userIsAllowed(GroupUser::class, 'update'); +?> +
+

translate('Groups'); ?>

+ +
+ translate('No group.')); ?> +
+ + +
hyperlink($group->name(), $group->adminUrl()); ?> + + + + + + + +
+ + +
diff --git a/view/common/admin/groups-selector.phtml b/view/common/admin/groups-selector.phtml new file mode 100644 index 0000000..8768c50 --- /dev/null +++ b/view/common/admin/groups-selector.phtml @@ -0,0 +1,40 @@ +plugin('escapeHtml'); + +// Groups are already sorted. +$groupsByInitial = []; +if (extension_loaded('mbstring')) { + foreach ($groups as $group) { + $initial = mb_substr($group->name(), 0, 1); + $groupsByInitial[mb_strtolower($initial)][] = $group; + } +} else { + foreach ($groups as $group) { + $initial = substr($group->name(), 0, 1); + $groupsByInitial[strtolower($initial)][] = $group; + } +} +?> + diff --git a/view/group/admin/group/add.phtml b/view/group/admin/group/add.phtml new file mode 100644 index 0000000..5407abd --- /dev/null +++ b/view/group/admin/group/add.phtml @@ -0,0 +1,15 @@ +plugin('escapeHtml'); +$this->htmlElement('body')->appendAttribute('class', 'groups add'); +// $this->headScript()->appendFile($this->assetUrl('js/advanced-search.js', 'Omeka')); +$form->prepare(); +?> +pageTitle($this->translate('New group'), 1, $this->translate('Group')); ?> +form()->openTag($form); ?> + +
+ +
+ +formCollection($form,false); ?> +form()->closeTag(); ?> diff --git a/view/group/admin/group/batch-delete-confirm.phtml b/view/group/admin/group/batch-delete-confirm.phtml new file mode 100644 index 0000000..20db366 --- /dev/null +++ b/view/group/admin/group/batch-delete-confirm.phtml @@ -0,0 +1,5 @@ + diff --git a/view/group/admin/group/browse.phtml b/view/group/admin/group/browse.phtml new file mode 100644 index 0000000..b818e2f --- /dev/null +++ b/view/group/admin/group/browse.phtml @@ -0,0 +1,180 @@ +plugin('escapeHtml'); +$this->htmlElement('body')->appendAttribute('class', 'groups browse'); +$sortHeadings = [ + [ + 'label' => $this->translate('Name'), + 'value' => 'name' + ], + [ + 'label' => $this->translate('Total count'), + 'value' => 'count' + ], + [ + 'label' => $this->translate('Total users'), + 'value' => 'users' + ], + [ + 'label' => $this->translate('Total resources'), + 'value' => 'resources' + ], + [ + 'label' => $this->translate('Total item sets'), + 'value' => 'item_sets' + ], + [ + 'label' => $this->translate('Total items'), + 'value' => 'items' + ], + [ + 'label' => $this->translate('Total media'), + 'value' => 'media' + ], + [ + 'label' => $this->translate('Recent'), + 'value' => 'id' + ], +]; +$createRight = $this->userIsAllowed(GroupAdapter::class, 'create'); +$updateRight = $this->userIsAllowed(GroupAdapter::class, 'update'); +$deleteRight = $this->userIsAllowed(GroupAdapter::class, 'delete'); +?> + +pageTitle($this->translate('Groups')); ?> + +searchFilters(); ?> + +
+ pagination(); ?> + hyperlink($this->translate('Advanced search'), $this->url(null, ['action' => 'search'], ['query' => $this->params()->fromQuery()], true), ['class' => 'advanced-search']); ?> + sortSelector($sortHeadings); ?> +
+ +
+ +
+ + hyperlink($this->translate('Add new group'), $this->url('admin/group', ['action' => 'add'], true), ['class' => 'button']); ?> + + + + translate('Delete')); ?> + + +
+ +trigger('view.browse.before'); ?> + +
+

translate('There are no groups.'); ?>

+
+trigger('view.browse.after'); ?> + + + + + + + + + + + + + + + + name(); + ?> + + + + + + + + + + + +
+ + + + translate('Group'); ?> + translate('Total count'); ?>translate('Users'); ?>translate('Resources'); ?>translate('Item sets'); ?>translate('Items'); ?>translate('Media'); ?>
+ + + + > +
    + +
  • + + +
  • + +
  • +
+
hyperlink( + $groupCount[$name]['users'], $group->urlEntities('user'), ['class' => 'group-browse-user'] + ); ?>hyperlink( + $groupCount[$name]['item_sets'], $group->urlEntities('item-set'), ['class' => 'group-browse-item-sets'] + ); ?>hyperlink( + $groupCount[$name]['items'], $group->urlEntities('item'), ['class' => 'group-browse-items'] + ); ?>hyperlink( + $groupCount[$name]['media'], $group->urlEntities('media'), ['class' => 'group-browse-media'] + ); ?>
+ +
+ +trigger('view.browse.after'); ?> +
+ pagination(); ?> +
+ + + + + diff --git a/view/group/admin/group/show-details.phtml b/view/group/admin/group/show-details.phtml new file mode 100644 index 0000000..3d3554d --- /dev/null +++ b/view/group/admin/group/show-details.phtml @@ -0,0 +1,42 @@ +plugin('escapeHtml'); ?> + +
+

link($group->name()); ?> id()); ?>

+
+

translate('Group')); ?>

+
+ comment()): ?> + + + translate('No comment.')); ?> + +
+
+
+

translate('Stats')); ?>

+
translate('Total')), + $groupCount['count']); + ?>
+
translate('Users')), + $this->hyperlink($groupCount['users'], $group->urlEntities('users'))); + ?>
+
translate('Resources')), + $groupCount['resources']); + ?>
+
translate('Item sets')), + $this->hyperlink($groupCount['item_sets'], $group->urlEntities('item-set'))); + ?>
+
translate('Items')), + $this->hyperlink($groupCount['items'], $group->urlEntities('item'))); + ?>
+
translate('Medias')), + $this->hyperlink($groupCount['media'], $group->urlEntities('media'))); + ?>
+
+
diff --git a/view/group/admin/group/show.phtml b/view/group/admin/group/show.phtml new file mode 100644 index 0000000..b307ce6 --- /dev/null +++ b/view/group/admin/group/show.phtml @@ -0,0 +1,31 @@ +htmlElement('body')->appendAttribute('class', 'groups show'); +$escape = $this->plugin('escapeHtml'); +?> + +pageTitle($resource->name(), 1, $this->translate('Groups')); ?> + +
+
+ + +trigger('view.show.before'); ?> +
+

translate('Name')); ?>

+
name()); ?>
+
+
+

translate('Comment')); ?>

+
comment()); ?>
+
+
+

translate('Internal id')); ?>

+
id()); ?>
+
+trigger('view.show.after'); ?> + +

+ translate('Groups are editable on the browse page.')); ?> +