From ed8cfe606c09e37ed3408ead3c8c14c9280af6fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:19:46 +0000 Subject: [PATCH 01/54] Add SitePermissionManager service and wire issue #189 triggers - Create SitePermissionManager service with full site permission sync logic for all three trigger scenarios from issue #189 - Create SitePermissionManagerFactory, register in module.config.php - Inject SitePermissionManager into UpdateController via constructor; wire addTeamUser, removeTeamUser, updateRole, and site add/remove - Delegate Module::updateUserSites and updateAllUserSites to service - Wire siteCreate and siteUpdate to sync/remove site permissions - Add docs/service-layer-pattern.md with extended rationale Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- Module.php | 45 ++- config/module.config.php | 1 + docs/service-layer-pattern.md | 271 +++++++++++++++++ src/Controller/UpdateController.php | 30 +- src/Service/SitePermissionManager.php | 288 +++++++++++++++++++ src/Service/SitePermissionManagerFactory.php | 16 ++ src/Service/UpdateControllerFactory.php | 6 +- 7 files changed, 627 insertions(+), 30 deletions(-) create mode 100644 docs/service-layer-pattern.md create mode 100644 src/Service/SitePermissionManager.php create mode 100644 src/Service/SitePermissionManagerFactory.php diff --git a/Module.php b/Module.php index e4564db9..257bbda9 100644 --- a/Module.php +++ b/Module.php @@ -29,6 +29,7 @@ use Teams\Form\Element\BlankTeamSelect; use Teams\Form\Element\RoleSelect; use Teams\Form\Element\TeamSelect; +use Teams\Service\SitePermissionManager; use Omeka\Api\Adapter\ItemAdapter; use Omeka\Api\Adapter\ItemSetAdapter; use Omeka\Api\Adapter\MediaAdapter; @@ -194,12 +195,7 @@ public function upgrade($oldVersion, $newVersion, ServiceLocatorInterface $servi public function updateAllUserSites() { - $em = $this->getServiceLocator()->get('Omeka\EntityManager'); - $active_users = $em->getRepository('Teams\Entity\TeamUser')->findAllBy(['is_active' => true]); - - foreach ($active_users as $user) { - $this->updateUserSites($user->getUser()->getId()); - } + $this->getServiceLocator()->get(SitePermissionManager::class)->updateAllUserDefaultSites(); } public function handleConfigForm(AbstractController $controller) @@ -1039,27 +1035,7 @@ public function updateItemSites($item_id) public function updateUserSites($user_id) { - $em = $this->getServiceLocator()->get('Omeka\EntityManager'); - - $userSettings = $this->getServiceLocator()->get('Omeka\Settings\User'); - - $site_ids = []; - $settingId = 'default_item_sites'; - - $active_team = $em->getRepository('Teams\Entity\TeamUser') - ->findOneBy(['user' => $user_id, 'is_current' => true]); - if ($active_team) { - $active_team = $active_team->getTeam(); - - $team_sites = $active_team->getTeamSites(); - - foreach ($team_sites as $team_site): - $site_ids[] = $team_site->getSite()->getId(); - endforeach; - - //update default sites - $userSettings->set($settingId, $site_ids, $user_id); - } + $this->getServiceLocator()->get(SitePermissionManager::class)->updateUserDefaultSites($user_id); } /** @@ -1183,6 +1159,12 @@ public function siteCreate(Event $event) endforeach; endforeach; + // Sync Omeka site permissions for all users in each new team-site relationship. + $sitePermissionManager = $this->getServiceLocator()->get(SitePermissionManager::class); + foreach ($team_ids as $team_id): + $sitePermissionManager->syncSitePermissionsForTeamOnSiteAdded((int)$team_id, $site_id); + endforeach; + //update all item-site to include all items from the site's teams $siteAdapter = $this->getServiceLocator()->get('Omeka\ApiAdapterManager')->get('sites'); foreach ($all_team_resources as $team_resources): @@ -1445,6 +1427,15 @@ public function siteUpdate(Event $event) $this->updateItemSites($team_item->getResource()->getId()); } } + + // Sync Omeka site permissions for teams that gained or lost this site. + $sitePermissionManager = $this->getServiceLocator()->get(SitePermissionManager::class); + foreach ($added_teams as $team_id) { + $sitePermissionManager->syncSitePermissionsForTeamOnSiteAdded((int)$team_id, $site_id); + } + foreach ($removed_teams as $team_id) { + $sitePermissionManager->removeSitePermissionsForTeamOnSiteRemoved((int)$team_id, $site_id); + } } } } diff --git a/config/module.config.php b/config/module.config.php index f091c5f1..9e9b0b43 100644 --- a/config/module.config.php +++ b/config/module.config.php @@ -78,6 +78,7 @@ 'factories' => [ \Teams\Acl\TeamRolePermissionAssertion::class => \Teams\Service\Acl\TeamRolePermissionAssertionFactory::class, \Teams\Service\AclRuleManager::class => \Teams\Service\AclRuleManagerFactory::class, + \Teams\Service\SitePermissionManager::class => \Teams\Service\SitePermissionManagerFactory::class, ], ], 'form_elements' => [ diff --git a/docs/service-layer-pattern.md b/docs/service-layer-pattern.md new file mode 100644 index 00000000..ac18d0fc --- /dev/null +++ b/docs/service-layer-pattern.md @@ -0,0 +1,271 @@ +# Service Layer Pattern — Rationale and Guide + +## The Problem: A 2,778-Line Monolith + +`Module.php` is the heart of the Teams module. Every Omeka S module has one, and for +small modules that is fine. Teams is not a small module. At the time this document was +written, `Module.php` contained approximately **60 public methods** and nearly +**2,800 lines** in a single class. The methods covered: + +- ACL rule wiring +- Query filtering +- Event-driven entity sync (items, sites, users, assets, resource templates) +- View helpers and form injection +- Configuration handling +- Admin UI display logic + +When everything lives in one place, the following problems compound over time: + +1. **No single concern is traceable.** To understand how site permissions work, a reader + must grep for scattered method calls and follow a chain through `Module.php`, + `UpdateController.php`, and form events — none of which have a shared namespace or + naming convention. + +2. **Logic cannot be reused without copy-paste.** Because business logic is embedded in + event-handler methods (which receive raw `Event` objects), it cannot be called from + another entry point (e.g., a CLI command, a background job, or a second controller) + without duplicating the code. + +3. **Testing is impossible in practice.** An event handler that calls + `$this->getServiceLocator()` is tightly coupled to the Laminas service container. + There is no way to instantiate just the relevant behaviour for a unit test without + bootstrapping the entire framework. + +4. **The class grows without bound.** Every new feature becomes a new method on + `Module.php` because there is no obvious alternative home for it. This is the + definition of the *Big Ball of Mud* anti-pattern. + +--- + +## Why Service Layer Is the Right Pattern Here + +### What it is + +The **Service Layer** pattern (Fowler, *Patterns of Enterprise Application Architecture*, +2002) draws a boundary between the application's *entry points* (HTTP controllers, event +handlers, CLI commands) and its *domain logic* (business rules, entity manipulation, +cross-cutting sync). Domain logic is placed in dedicated **service classes** that are +injected into entry points as dependencies. + +In Laminas/Omeka S this is spelled out concretely: + +``` +Entry point Service Layer Domain / ORM +───────────────── ──────────────────────── ────────────────── +Module.php SitePermissionManager Doctrine EntityManager +UpdateController AclRuleManager Omeka\Entity\* + (future) ItemSyncManager Teams\Entity\* +``` + +### Why not one of the alternatives? + +**Repository pattern alone** — Repositories handle query building, not business rules. +They are the right home for "give me all TeamUsers for team X" but not for "given a +TeamUser change, update the matching SitePermissions". Mixing rules into repositories +produces the same coupling problem in a different class. + +**Fat controller** — Putting logic in controllers makes it unreachable from event +handlers, and vice versa. The existing code already shows the pain: `updateUserSites` +is defined on `Module.php` and called from both `siteCreate` and `siteUpdate` purely +because controllers cannot directly call `Module` methods. + +**Traits on Module** — PHP traits are a copy-paste mechanism, not a boundary. A trait +on `Module.php` still has access to `$this->getServiceLocator()`, still cannot be +constructed independently, and still cannot be tested in isolation. + +**Doctrine lifecycle listeners** — These are appropriate for generic, entity-level +concerns (timestamps, soft-delete flags). They are inappropriate for business logic +that is specific to the Teams module, because they introduce a hidden dependency +between the ORM layer and module-specific rules that would survive even if the module +were disabled. + +**Service Layer** fits because: + +- Services receive their dependencies through the **constructor**, not through a global + service locator. This is standard Laminas Dependency Injection and makes every + dependency explicit and mockable. +- A service class has a **single, nameable concern**. `SitePermissionManager` does + exactly one thing: keep Omeka site user permissions in sync with Teams role + assignments. A reader can open the file and understand its purpose in two minutes. +- The same service can be **called from any entry point**: an event handler in + `Module.php`, a controller action, a background job, a future REST endpoint, or a + unit test. +- Services are registered in the **Laminas service manager** via factory classes. + This is the established Omeka S convention for injectable objects — the same mechanism + used for API adapters, form elements, and authentication — so no new conventions need + to be introduced or learned. + +### Relationship to Omeka S conventions + +Omeka S itself follows this pattern. The core `application/Module.php` is thin; heavy +logic lives in dedicated classes (`SiteAdapter`, `ItemAdapter`, `Acl`, etc.). Official +Omeka S modules such as [CSVImport] and [BulkImport] extract complex processing into +service classes registered through factories. Adopting the same pattern keeps the Teams +module aligned with the ecosystem and makes it easier for Omeka-familiar developers to +contribute. + +--- + +## The Concrete Example: `SitePermissionManager` + +### Before + +`Module.php` contained `updateUserSites()`, a method that: +- called `$this->getServiceLocator()` to fetch `Omeka\EntityManager` and + `Omeka\Settings\User` — two separate service-locator calls buried inside the method + body +- was duplicated at three call sites inside `Module.php` and implicitly depended on + those call sites knowing to call `Module::updateUserSites()` instead of having + access to the logic directly +- could not be called from `UpdateController` without going back through the module + event system + +The new feature (issue #189 — auto-generate site permissions) would have required +adding *more* methods to `Module.php` with the same problems, and wiring them from +`UpdateController` was impossible without adding a second copy of the same code. + +### After: `Teams\Service\SitePermissionManager` + +`SitePermissionManager` is a plain PHP class: + +```php +class SitePermissionManager +{ + public function __construct( + EntityManager $entityManager, + UserSettings $userSettings + ) { … } + + public function syncSitePermissionsForUser(int $userId, int $teamId): void { … } + public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $siteId = null): void { … } + public function syncSitePermissionsForTeamOnSiteAdded(int $teamId, int $siteId): void { … } + public function removeSitePermissionsForTeamOnSiteRemoved(int $teamId, int $siteId): void { … } + public function updateUserDefaultSites(int $userId): void { … } + public function updateAllUserDefaultSites(): void { … } +} +``` + +`SitePermissionManagerFactory` creates it: + +```php +class SitePermissionManagerFactory implements FactoryInterface +{ + public function __invoke(ContainerInterface $container, …) + { + return new SitePermissionManager( + $container->get('Omeka\EntityManager'), + $container->get('Omeka\Settings\User') + ); + } +} +``` + +`config/module.config.php` registers it like any other Laminas service: + +```php +'service_manager' => [ + 'factories' => [ + SitePermissionManager::class => SitePermissionManagerFactory::class, + ], +], +``` + +**`Module.php`** now delegates in one line: + +```php +public function updateUserSites($user_id) +{ + $this->getServiceLocator()->get(SitePermissionManager::class) + ->updateUserDefaultSites($user_id); +} +``` + +**`UpdateController`** receives the service through its constructor: + +```php +public function __construct( + EntityManager $entityManager, + SitePermissionManager $sitePermissionManager +) { … } +``` + +And calls it directly after mutating team membership: + +```php +$this->sitePermissionManager->syncSitePermissionsForUser($user_id, $team_id); +``` + +--- + +## Design Decisions within `SitePermissionManager` + +### Multi-team safety + +A user can belong to multiple teams that share an Omeka site. Naively assigning the +role dictated by one team could silently downgrade a privilege granted by another team. +`SitePermissionManager` uses `resolveHighestRole()` to ensure that the role stored on +the `SitePermission` entity is always the *highest* role granted by any of the user's +current team memberships. When a membership is removed, `getHighestRoleFromOtherTeams()` +recalculates from the remaining memberships before deciding whether to downgrade or +remove the `SitePermission` entirely. + +### Role mapping + +Omeka S defines three site-level roles: `viewer`, `editor`, `admin`. The Teams module +maps: + +| TeamRole condition | Omeka site role | +|------------------------------|------------------------------| +| `can_add_site_pages = true` | `SitePermission::ROLE_ADMIN` | +| `can_add_site_pages = false` | `SitePermission::ROLE_VIEWER`| + +`ROLE_EDITOR` is not currently produced by the Teams module because TeamRole does not +have a finer-grained "can edit but not administer" concept. If that is added later, +only `SitePermissionManager` needs to be changed. + +### Flush discipline + +Each public method calls `$em->flush()` once at the end, after all entity mutations +for that operation are complete. This avoids partial writes and reduces round trips. +The exception is `updateAllUserDefaultSites`, which delegates to `updateUserDefaultSites` +per user; the settings API (`UserSettings::set`) has its own persistence and does not +require explicit flushes. + +--- + +## The Broader Roadmap + +`SitePermissionManager` and `AclRuleManager` are examples of the pattern. They are +not the finish line. The same extraction should be applied, incrementally, to other +cohesive groups of methods currently living in `Module.php`: + +| Candidate service | Methods to extract | +|------------------------------|------------------------------------------------------------------------------| +| `ItemSyncManager` | `updateItemSites`, `itemCreate`, `itemUpdate`, `itemDelete`, `itemBatchCreate`| +| `TeamQueryFilter` | `filterByTeam`, `getTeamContext`, `getOrphans` | +| `ResourceTemplateSyncManager`| `resourceTemplateCreate`, `resourceTemplateUpdate` | +| `AssetSyncManager` | `assetCreate`, `assetUpdate` | + +Each extraction follows the same four-step recipe: + +1. Create a service class in `src/Service/` with constructor-injected dependencies. +2. Create a factory in `src/Service/` that pulls those dependencies from the container. +3. Register the factory in `config/module.config.php`. +4. Replace the body of the `Module.php` method(s) with a single-line delegation call. + +No existing public API or event wiring needs to change. `Module.php` retains its +methods as thin delegators, so callers (including third-party modules that may be +listening to Teams events) are unaffected. + +--- + +## Summary + +| Concern | Before | After | +|----------------------|--------------------------------|--------------------------------| +| Where logic lives | `Module.php` method bodies | Named service class | +| Dependencies | `$this->getServiceLocator()` | Constructor injection | +| Reuse across entry points | Copy-paste or impossible | Call the service directly | +| Testability | Requires full framework boot | Plain PHP instantiation | +| Discoverability | Grep for scattered methods | Open the service class | +| Aligned with Omeka S | Partially | Fully (same factory pattern) | diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index b4248ad6..0df81366 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -14,6 +14,7 @@ use Teams\Form\TeamResourcesForm; use Teams\Form\TeamSitesAddRemoveForm; use Teams\Form\TeamDetailsForm; +use Teams\Service\SitePermissionManager; use Laminas\EventManager\Event; use Laminas\Mvc\Controller\AbstractActionController; use Laminas\Stdlib\ArrayObject; @@ -27,12 +28,19 @@ class UpdateController extends AbstractActionController */ protected $entityManager; + /** + * @var SitePermissionManager + */ + protected SitePermissionManager $sitePermissionManager; + /** * @param EntityManager $entityManager + * @param SitePermissionManager $sitePermissionManager */ - public function __construct(EntityManager $entityManager) + public function __construct(EntityManager $entityManager, SitePermissionManager $sitePermissionManager) { $this->entityManager = $entityManager; + $this->sitePermissionManager = $sitePermissionManager; } public function createNamedParameter( @@ -62,6 +70,9 @@ public function addTeamUser(int $team_id, int $user_id, int $role_id) //flushing here because this is a mini-form and we want to see the name pop up //more efficient solution would be to have JS handle the popping and batch update $this->entityManager->flush(); + + $this->sitePermissionManager->syncSitePermissionsForUser($user_id, $team_id); + return $team_user; } } @@ -74,6 +85,9 @@ public function removeTeamUser(int $team_id, int $user) $this->messenger()->addError("removed user"); $em = $this->entityManager; + + $this->sitePermissionManager->removeSitePermissionsForUser($user, $team_id); + $team_user = $em->find('Teams\Entity\TeamUser', ['team' => $team_id, 'user' => $user]); $em->remove($team_user); @@ -93,6 +107,8 @@ public function updateRole(int $team_id, int $user_id, int $role_id) $user_role = $em->find('Teams\Entity\TeamRole', $role_id); $team_user->setRole($user_role); $em->flush(); + + $this->sitePermissionManager->syncSitePermissionsForUser($user_id, $team_id); } } @@ -382,8 +398,10 @@ public function teamUpdateAction() $em = $this->entityManager; //handle new sites + $added_site_ids = []; foreach ($post_data['teamSites']['o:site'] as $site) { if (!in_array($site, $current_sites)) { + $added_site_ids[] = (int)$site; $site = $em->getRepository('Omeka\Entity\Site')->findOneBy(['id'=>$site]); $ts = new TeamSite($team, $site); $request = new Request('create', 'team_site'); @@ -398,8 +416,10 @@ public function teamUpdateAction() } //handle removed sites + $removed_site_ids = []; foreach ($current_sites as $site) { if (!in_array($site, $post_data['teamSites']['o:site'])) { + $removed_site_ids[] = (int)$site; $ts = $em->getRepository('Teams\Entity\TeamSite')->findOneBy(['team'=>$team_id, 'site'=>$site]); $request = new Request('delete', 'team_site'); $event = new Event('api.hydrate.pre', $this, [ @@ -411,6 +431,14 @@ public function teamUpdateAction() } } $em->flush(); + + // Sync Omeka site permissions for all team users when sites are added or removed. + foreach ($added_site_ids as $added_site_id) { + $this->sitePermissionManager->syncSitePermissionsForTeamOnSiteAdded($team_id, $added_site_id); + } + foreach ($removed_site_ids as $removed_site_id) { + $this->sitePermissionManager->removeSitePermissionsForTeamOnSiteRemoved($team_id, $removed_site_id); + } } $successMessage = sprintf("Successfully updated the %s team", $team->getName()); diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php new file mode 100644 index 00000000..8d086e23 --- /dev/null +++ b/src/Service/SitePermissionManager.php @@ -0,0 +1,288 @@ + 2, + SitePermission::ROLE_EDITOR => 1, + SitePermission::ROLE_VIEWER => 0, + ]; + + /** + * @var EntityManager + */ + private EntityManager $entityManager; + + /** + * @var UserSettings + */ + private UserSettings $userSettings; + + public function __construct(EntityManager $entityManager, UserSettings $userSettings) + { + $this->entityManager = $entityManager; + $this->userSettings = $userSettings; + } + + /** + * Sync Omeka site permissions for a user in a specific team. + * + * Assigns ROLE_ADMIN if the team role has `can_add_site_pages`, otherwise ROLE_VIEWER, + * for every site associated with the team. + * + * When the user already has a permission on a site (e.g. from another team), the + * higher of the two roles is kept, so no privilege is silently downgraded. + * + * @param int $userId + * @param int $teamId + */ + public function syncSitePermissionsForUser(int $userId, int $teamId): void + { + $em = $this->entityManager; + + $teamUser = $em->find('Teams\Entity\TeamUser', ['team' => $teamId, 'user' => $userId]); + if (!$teamUser) { + return; + } + + $user = $teamUser->getUser(); + $omekaRole = $teamUser->getRole()->getCanAddSitePages() + ? SitePermission::ROLE_ADMIN + : SitePermission::ROLE_VIEWER; + + $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['team' => $teamId]); + + foreach ($teamSites as $teamSite) { + $site = $teamSite->getSite(); + $sitePermissions = $site->getSitePermissions(); + + $criteria = Criteria::create()->where(Criteria::expr()->eq('user', $user)); + $existingPermission = $sitePermissions->matching($criteria)->first(); + + if ($existingPermission) { + // Keep the higher of the existing and new role (multi-team safety). + $existingPermission->setRole( + $this->resolveHighestRole($existingPermission->getRole(), $omekaRole) + ); + } else { + $sitePermission = new SitePermission(); + $sitePermission->setSite($site); + $sitePermission->setUser($user); + $sitePermission->setRole($omekaRole); + $em->persist($sitePermission); + $sitePermissions->add($sitePermission); + } + } + + $em->flush(); + } + + /** + * Remove or recalculate Omeka site permissions for a user leaving a team. + * + * If the user still belongs to other teams that share a given site, their + * permission is recalculated from those remaining memberships rather than + * simply removed, so a shared-site privilege is never accidentally revoked. + * + * @param int $userId + * @param int $teamId The team the user is being removed from. + * @param int|null $siteId If set, only process this one site (used when a + * single site is removed from a team). + */ + public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $siteId = null): void + { + $em = $this->entityManager; + + $user = $em->find('Omeka\Entity\User', $userId); + if (!$user) { + return; + } + + $criteria = ['team' => $teamId]; + if ($siteId !== null) { + $criteria['site'] = $siteId; + } + $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy($criteria); + + foreach ($teamSites as $teamSite) { + $site = $teamSite->getSite(); + $sitePermissions = $site->getSitePermissions(); + + $userCriteria = Criteria::create()->where(Criteria::expr()->eq('user', $user)); + $existingPermission = $sitePermissions->matching($userCriteria)->first(); + + if (!$existingPermission) { + continue; + } + + // Check whether any other team still grants this user access to this site. + $remainingRole = $this->getHighestRoleFromOtherTeams($userId, $teamId, $site->getId()); + + if ($remainingRole !== null) { + // User retains access via another team — update the role accordingly. + $existingPermission->setRole($remainingRole); + } else { + // No other team grants access; remove the permission entirely. + $sitePermissions->removeElement($existingPermission); + $em->remove($existingPermission); + } + } + + $em->flush(); + } + + /** + * Sync Omeka site permissions for all users in a team when a site is added. + * + * @param int $teamId + * @param int $siteId + */ + public function syncSitePermissionsForTeamOnSiteAdded(int $teamId, int $siteId): void + { + $teamUsers = $this->entityManager + ->getRepository('Teams\Entity\TeamUser') + ->findBy(['team' => $teamId]); + + foreach ($teamUsers as $teamUser) { + $this->syncSitePermissionsForUser($teamUser->getUser()->getId(), $teamId); + } + } + + /** + * Remove Omeka site permissions for all users in a team when a site is removed. + * + * @param int $teamId + * @param int $siteId + */ + public function removeSitePermissionsForTeamOnSiteRemoved(int $teamId, int $siteId): void + { + $teamUsers = $this->entityManager + ->getRepository('Teams\Entity\TeamUser') + ->findBy(['team' => $teamId]); + + foreach ($teamUsers as $teamUser) { + $this->removeSitePermissionsForUser($teamUser->getUser()->getId(), $teamId, $siteId); + } + } + + /** + * Update the `default_item_sites` user setting so the user's item-creation form + * defaults to the sites of their current active team. + * + * Extracted from Module::updateUserSites() to follow the service-layer pattern. + * + * @param int $userId + */ + public function updateUserDefaultSites(int $userId): void + { + $activeTeam = $this->entityManager + ->getRepository('Teams\Entity\TeamUser') + ->findOneBy(['user' => $userId, 'is_current' => true]); + + if (!$activeTeam) { + return; + } + + $siteIds = []; + foreach ($activeTeam->getTeam()->getTeamSites() as $teamSite) { + $siteIds[] = $teamSite->getSite()->getId(); + } + + $this->userSettings->set('default_item_sites', $siteIds, $userId); + } + + /** + * Update `default_item_sites` for all users who have an active team. + * + * Extracted from Module::updateAllUserSites(). + */ + public function updateAllUserDefaultSites(): void + { + $activeTeamUsers = $this->entityManager + ->getRepository('Teams\Entity\TeamUser') + ->findBy(['is_current' => true]); + + foreach ($activeTeamUsers as $teamUser) { + $this->updateUserDefaultSites($teamUser->getUser()->getId()); + } + } + + /** + * Determine the highest Omeka site role granted to a user by any team *other than* + * the one being removed. Returns null if no other team grants access to that site. + * + * @param int $userId + * @param int $excludeTeamId The team being removed, which should be ignored. + * @param int $siteId + * @return string|null + */ + private function getHighestRoleFromOtherTeams(int $userId, int $excludeTeamId, int $siteId): ?string + { + $em = $this->entityManager; + $teamUsers = $em->getRepository('Teams\Entity\TeamUser')->findBy(['user' => $userId]); + + $highestRole = null; + foreach ($teamUsers as $teamUser) { + if ($teamUser->getTeam()->getId() === $excludeTeamId) { + continue; + } + + $teamSite = $em->getRepository('Teams\Entity\TeamSite') + ->findOneBy(['team' => $teamUser->getTeam()->getId(), 'site' => $siteId]); + + if (!$teamSite) { + continue; + } + + $role = $teamUser->getRole()->getCanAddSitePages() + ? SitePermission::ROLE_ADMIN + : SitePermission::ROLE_VIEWER; + + $highestRole = $this->resolveHighestRole($highestRole, $role); + } + + return $highestRole; + } + + /** + * Return whichever of two Omeka site permission roles conveys higher privilege. + * + * @param string|null $current + * @param string $new + * @return string + */ + private function resolveHighestRole(?string $current, string $new): string + { + if ($current === null) { + return $new; + } + + $currentPriority = self::ROLE_PRIORITY[$current] ?? -1; + $newPriority = self::ROLE_PRIORITY[$new] ?? -1; + + return $newPriority > $currentPriority ? $new : $current; + } +} diff --git a/src/Service/SitePermissionManagerFactory.php b/src/Service/SitePermissionManagerFactory.php new file mode 100644 index 00000000..8aeff254 --- /dev/null +++ b/src/Service/SitePermissionManagerFactory.php @@ -0,0 +1,16 @@ +get('Omeka\EntityManager'), + $container->get('Omeka\Settings\User') + ); + } +} diff --git a/src/Service/UpdateControllerFactory.php b/src/Service/UpdateControllerFactory.php index 25e1febb..3df1ad5a 100644 --- a/src/Service/UpdateControllerFactory.php +++ b/src/Service/UpdateControllerFactory.php @@ -10,8 +10,10 @@ class UpdateControllerFactory implements FactoryInterface { public function __invoke(ContainerInterface $services, $requestedName, array $options = null) { - $addController = new UpdateController($services->get('Omeka\EntityManager')); - return $addController; + return new UpdateController( + $services->get('Omeka\EntityManager'), + $services->get(SitePermissionManager::class) + ); } } From 38b8fa522a1aa9f378602aff345c21e9d88163f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:20:01 +0000 Subject: [PATCH 02/54] Remove docs/service-layer-pattern.md (rationale kept in chat only) Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- docs/service-layer-pattern.md | 271 ---------------------------------- 1 file changed, 271 deletions(-) delete mode 100644 docs/service-layer-pattern.md diff --git a/docs/service-layer-pattern.md b/docs/service-layer-pattern.md deleted file mode 100644 index ac18d0fc..00000000 --- a/docs/service-layer-pattern.md +++ /dev/null @@ -1,271 +0,0 @@ -# Service Layer Pattern — Rationale and Guide - -## The Problem: A 2,778-Line Monolith - -`Module.php` is the heart of the Teams module. Every Omeka S module has one, and for -small modules that is fine. Teams is not a small module. At the time this document was -written, `Module.php` contained approximately **60 public methods** and nearly -**2,800 lines** in a single class. The methods covered: - -- ACL rule wiring -- Query filtering -- Event-driven entity sync (items, sites, users, assets, resource templates) -- View helpers and form injection -- Configuration handling -- Admin UI display logic - -When everything lives in one place, the following problems compound over time: - -1. **No single concern is traceable.** To understand how site permissions work, a reader - must grep for scattered method calls and follow a chain through `Module.php`, - `UpdateController.php`, and form events — none of which have a shared namespace or - naming convention. - -2. **Logic cannot be reused without copy-paste.** Because business logic is embedded in - event-handler methods (which receive raw `Event` objects), it cannot be called from - another entry point (e.g., a CLI command, a background job, or a second controller) - without duplicating the code. - -3. **Testing is impossible in practice.** An event handler that calls - `$this->getServiceLocator()` is tightly coupled to the Laminas service container. - There is no way to instantiate just the relevant behaviour for a unit test without - bootstrapping the entire framework. - -4. **The class grows without bound.** Every new feature becomes a new method on - `Module.php` because there is no obvious alternative home for it. This is the - definition of the *Big Ball of Mud* anti-pattern. - ---- - -## Why Service Layer Is the Right Pattern Here - -### What it is - -The **Service Layer** pattern (Fowler, *Patterns of Enterprise Application Architecture*, -2002) draws a boundary between the application's *entry points* (HTTP controllers, event -handlers, CLI commands) and its *domain logic* (business rules, entity manipulation, -cross-cutting sync). Domain logic is placed in dedicated **service classes** that are -injected into entry points as dependencies. - -In Laminas/Omeka S this is spelled out concretely: - -``` -Entry point Service Layer Domain / ORM -───────────────── ──────────────────────── ────────────────── -Module.php SitePermissionManager Doctrine EntityManager -UpdateController AclRuleManager Omeka\Entity\* - (future) ItemSyncManager Teams\Entity\* -``` - -### Why not one of the alternatives? - -**Repository pattern alone** — Repositories handle query building, not business rules. -They are the right home for "give me all TeamUsers for team X" but not for "given a -TeamUser change, update the matching SitePermissions". Mixing rules into repositories -produces the same coupling problem in a different class. - -**Fat controller** — Putting logic in controllers makes it unreachable from event -handlers, and vice versa. The existing code already shows the pain: `updateUserSites` -is defined on `Module.php` and called from both `siteCreate` and `siteUpdate` purely -because controllers cannot directly call `Module` methods. - -**Traits on Module** — PHP traits are a copy-paste mechanism, not a boundary. A trait -on `Module.php` still has access to `$this->getServiceLocator()`, still cannot be -constructed independently, and still cannot be tested in isolation. - -**Doctrine lifecycle listeners** — These are appropriate for generic, entity-level -concerns (timestamps, soft-delete flags). They are inappropriate for business logic -that is specific to the Teams module, because they introduce a hidden dependency -between the ORM layer and module-specific rules that would survive even if the module -were disabled. - -**Service Layer** fits because: - -- Services receive their dependencies through the **constructor**, not through a global - service locator. This is standard Laminas Dependency Injection and makes every - dependency explicit and mockable. -- A service class has a **single, nameable concern**. `SitePermissionManager` does - exactly one thing: keep Omeka site user permissions in sync with Teams role - assignments. A reader can open the file and understand its purpose in two minutes. -- The same service can be **called from any entry point**: an event handler in - `Module.php`, a controller action, a background job, a future REST endpoint, or a - unit test. -- Services are registered in the **Laminas service manager** via factory classes. - This is the established Omeka S convention for injectable objects — the same mechanism - used for API adapters, form elements, and authentication — so no new conventions need - to be introduced or learned. - -### Relationship to Omeka S conventions - -Omeka S itself follows this pattern. The core `application/Module.php` is thin; heavy -logic lives in dedicated classes (`SiteAdapter`, `ItemAdapter`, `Acl`, etc.). Official -Omeka S modules such as [CSVImport] and [BulkImport] extract complex processing into -service classes registered through factories. Adopting the same pattern keeps the Teams -module aligned with the ecosystem and makes it easier for Omeka-familiar developers to -contribute. - ---- - -## The Concrete Example: `SitePermissionManager` - -### Before - -`Module.php` contained `updateUserSites()`, a method that: -- called `$this->getServiceLocator()` to fetch `Omeka\EntityManager` and - `Omeka\Settings\User` — two separate service-locator calls buried inside the method - body -- was duplicated at three call sites inside `Module.php` and implicitly depended on - those call sites knowing to call `Module::updateUserSites()` instead of having - access to the logic directly -- could not be called from `UpdateController` without going back through the module - event system - -The new feature (issue #189 — auto-generate site permissions) would have required -adding *more* methods to `Module.php` with the same problems, and wiring them from -`UpdateController` was impossible without adding a second copy of the same code. - -### After: `Teams\Service\SitePermissionManager` - -`SitePermissionManager` is a plain PHP class: - -```php -class SitePermissionManager -{ - public function __construct( - EntityManager $entityManager, - UserSettings $userSettings - ) { … } - - public function syncSitePermissionsForUser(int $userId, int $teamId): void { … } - public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $siteId = null): void { … } - public function syncSitePermissionsForTeamOnSiteAdded(int $teamId, int $siteId): void { … } - public function removeSitePermissionsForTeamOnSiteRemoved(int $teamId, int $siteId): void { … } - public function updateUserDefaultSites(int $userId): void { … } - public function updateAllUserDefaultSites(): void { … } -} -``` - -`SitePermissionManagerFactory` creates it: - -```php -class SitePermissionManagerFactory implements FactoryInterface -{ - public function __invoke(ContainerInterface $container, …) - { - return new SitePermissionManager( - $container->get('Omeka\EntityManager'), - $container->get('Omeka\Settings\User') - ); - } -} -``` - -`config/module.config.php` registers it like any other Laminas service: - -```php -'service_manager' => [ - 'factories' => [ - SitePermissionManager::class => SitePermissionManagerFactory::class, - ], -], -``` - -**`Module.php`** now delegates in one line: - -```php -public function updateUserSites($user_id) -{ - $this->getServiceLocator()->get(SitePermissionManager::class) - ->updateUserDefaultSites($user_id); -} -``` - -**`UpdateController`** receives the service through its constructor: - -```php -public function __construct( - EntityManager $entityManager, - SitePermissionManager $sitePermissionManager -) { … } -``` - -And calls it directly after mutating team membership: - -```php -$this->sitePermissionManager->syncSitePermissionsForUser($user_id, $team_id); -``` - ---- - -## Design Decisions within `SitePermissionManager` - -### Multi-team safety - -A user can belong to multiple teams that share an Omeka site. Naively assigning the -role dictated by one team could silently downgrade a privilege granted by another team. -`SitePermissionManager` uses `resolveHighestRole()` to ensure that the role stored on -the `SitePermission` entity is always the *highest* role granted by any of the user's -current team memberships. When a membership is removed, `getHighestRoleFromOtherTeams()` -recalculates from the remaining memberships before deciding whether to downgrade or -remove the `SitePermission` entirely. - -### Role mapping - -Omeka S defines three site-level roles: `viewer`, `editor`, `admin`. The Teams module -maps: - -| TeamRole condition | Omeka site role | -|------------------------------|------------------------------| -| `can_add_site_pages = true` | `SitePermission::ROLE_ADMIN` | -| `can_add_site_pages = false` | `SitePermission::ROLE_VIEWER`| - -`ROLE_EDITOR` is not currently produced by the Teams module because TeamRole does not -have a finer-grained "can edit but not administer" concept. If that is added later, -only `SitePermissionManager` needs to be changed. - -### Flush discipline - -Each public method calls `$em->flush()` once at the end, after all entity mutations -for that operation are complete. This avoids partial writes and reduces round trips. -The exception is `updateAllUserDefaultSites`, which delegates to `updateUserDefaultSites` -per user; the settings API (`UserSettings::set`) has its own persistence and does not -require explicit flushes. - ---- - -## The Broader Roadmap - -`SitePermissionManager` and `AclRuleManager` are examples of the pattern. They are -not the finish line. The same extraction should be applied, incrementally, to other -cohesive groups of methods currently living in `Module.php`: - -| Candidate service | Methods to extract | -|------------------------------|------------------------------------------------------------------------------| -| `ItemSyncManager` | `updateItemSites`, `itemCreate`, `itemUpdate`, `itemDelete`, `itemBatchCreate`| -| `TeamQueryFilter` | `filterByTeam`, `getTeamContext`, `getOrphans` | -| `ResourceTemplateSyncManager`| `resourceTemplateCreate`, `resourceTemplateUpdate` | -| `AssetSyncManager` | `assetCreate`, `assetUpdate` | - -Each extraction follows the same four-step recipe: - -1. Create a service class in `src/Service/` with constructor-injected dependencies. -2. Create a factory in `src/Service/` that pulls those dependencies from the container. -3. Register the factory in `config/module.config.php`. -4. Replace the body of the `Module.php` method(s) with a single-line delegation call. - -No existing public API or event wiring needs to change. `Module.php` retains its -methods as thin delegators, so callers (including third-party modules that may be -listening to Teams events) are unaffected. - ---- - -## Summary - -| Concern | Before | After | -|----------------------|--------------------------------|--------------------------------| -| Where logic lives | `Module.php` method bodies | Named service class | -| Dependencies | `$this->getServiceLocator()` | Constructor injection | -| Reuse across entry points | Copy-paste or impossible | Call the service directly | -| Testability | Requires full framework boot | Plain PHP instantiation | -| Discoverability | Grep for scattered methods | Open the service class | -| Aligned with Omeka S | Partially | Fully (same factory pattern) | From 105169b0026afb0a7e4df94711adf6eb5c6663cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:39:00 +0000 Subject: [PATCH 03/54] Apply remaining changes Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Service/SitePermissionManager.php | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 8d086e23..6d4f284f 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -21,15 +21,6 @@ */ class SitePermissionManager { - /** - * Role priority for resolving conflicts when a user belongs to multiple teams - * that share a site. Higher number = higher privilege. - */ - private const ROLE_PRIORITY = [ - SitePermission::ROLE_ADMIN => 2, - SitePermission::ROLE_EDITOR => 1, - SitePermission::ROLE_VIEWER => 0, - ]; /** * @var EntityManager @@ -51,10 +42,8 @@ public function __construct(EntityManager $entityManager, UserSettings $userSett * Sync Omeka site permissions for a user in a specific team. * * Assigns ROLE_ADMIN if the team role has `can_add_site_pages`, otherwise ROLE_VIEWER, - * for every site associated with the team. - * - * When the user already has a permission on a site (e.g. from another team), the - * higher of the two roles is kept, so no privilege is silently downgraded. + * for every site associated with the team. The role is set unconditionally so that + * downgrading a team role is immediately reflected in the site permission. * * @param int $userId * @param int $teamId @@ -83,10 +72,7 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $existingPermission = $sitePermissions->matching($criteria)->first(); if ($existingPermission) { - // Keep the higher of the existing and new role (multi-team safety). - $existingPermission->setRole( - $this->resolveHighestRole($existingPermission->getRole(), $omekaRole) - ); + $existingPermission->setRole($omekaRole); } else { $sitePermission = new SitePermission(); $sitePermission->setSite($site); From 2c0ed29fe13033d24fe7dac0b3e29f1765f94275 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:36:28 +0000 Subject: [PATCH 04/54] fix: sync site permissions when team member roles are changed or removed via teamUpdateAction - In teamUpdateAction, call syncSitePermissionsForUser after updating an existing team user's role (flush first so the new role is persisted) and after creating a new team user. - In teamUpdateAction, call removeSitePermissionsForUser after deleting a team user, so the Omeka SitePermission row is cleaned up or recalculated from remaining team memberships. - Restore ROLE_PRIORITY constant in SitePermissionManager (was accidentally removed in a previous edit; still needed by resolveHighestRole for the removal path). - The addTeamUser/removeTeamUser/updateRole methods on UpdateController were dead code (never called); the sync logic they contained was never reachable from the team update form. Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Controller/UpdateController.php | 4 ++++ src/Service/SitePermissionManager.php | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 0df81366..ff91fd25 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -315,6 +315,7 @@ public function teamUpdateAction() foreach ($oldTeamUsers as $oldTeamUser) { if (!in_array($oldTeamUser,$formTeamUsers)) { $this->api()->delete('team-user',['team'=>$team_id, 'user'=>$oldTeamUser]); + $this->sitePermissionManager->removeSitePermissionsForUser((int)$oldTeamUser, (int)$team_id); } } //add team users or update permissions @@ -325,6 +326,8 @@ public function teamUpdateAction() if ($teamUserExists){ $role = $this->api()->read('team-role',['id'=>$teamUser['o:team_role']['o:id']])->getContent(); $teamUserExists[0]->getEntity()->setRole($role->getEntity()); + $this->entityManager->flush(); + $this->sitePermissionManager->syncSitePermissionsForUser((int)$teamUser['o:user']['o:id'], (int)$team_id); } else { $this->api() ->create('team-user', @@ -333,6 +336,7 @@ public function teamUpdateAction() 'user'=>$teamUser['o:user']['o:id'], 'role'=>$teamUser['o:team_role']['o:id'] ]); + $this->sitePermissionManager->syncSitePermissionsForUser((int)$teamUser['o:user']['o:id'], (int)$team_id); } } } diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 6d4f284f..7724f3cd 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -21,6 +21,15 @@ */ class SitePermissionManager { + /** + * Role priority for resolving conflicts when a user belongs to multiple teams + * that share a site. Higher number = higher privilege. + */ + private const ROLE_PRIORITY = [ + SitePermission::ROLE_ADMIN => 2, + SitePermission::ROLE_EDITOR => 1, + SitePermission::ROLE_VIEWER => 0, + ]; /** * @var EntityManager From 26bde25da608173f9616b8e16d0c6293c5f57478 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:40:41 +0000 Subject: [PATCH 05/54] fix: map can_add_site_pages to ROLE_MANAGER (not ROLE_ADMIN) and ROLE_VIEWER MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two team role → Omeka site permission mappings are: - can_add_site_pages = true → SitePermission::ROLE_MANAGER - can_add_site_pages = false → SitePermission::ROLE_VIEWER Updated all four references in SitePermissionManager (ROLE_PRIORITY constant, docblock, sync path, and removal/recalculation path). Removed ROLE_EDITOR from ROLE_PRIORITY since Teams only ever assigns viewer or manager. Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- Module.php | 7 ++++++ config/module.ini | 2 +- src/Service/SitePermissionManager.php | 31 ++++++++++++++++++++++----- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/Module.php b/Module.php index 257bbda9..16bbc7a2 100644 --- a/Module.php +++ b/Module.php @@ -191,6 +191,13 @@ public function upgrade($oldVersion, $newVersion, ServiceLocatorInterface $servi $globalSettings = $serviceLocator->get('Omeka\Settings'); $globalSettings->set('teams_filter_bypass_roles', ["global_admin"]); } + if (version_compare($oldVersion, '4.2.0', '<')) { + // Sync Omeka site permissions for all existing team members so that + // every user has the correct SitePermission row for every site their + // team is associated with. This backfills installations that were + // running before site-permission sync was introduced. + $serviceLocator->get(SitePermissionManager::class)->syncAllSitePermissions(); + } } public function updateAllUserSites() diff --git a/config/module.ini b/config/module.ini index 59f8d97d..bc400db7 100644 --- a/config/module.ini +++ b/config/module.ini @@ -1,6 +1,6 @@ [info] name = "Teams" -version = "4.1.0" +version = "4.2.0" author = "Alex Dryden" configurable = true description = "Assigns items and itemsets to teams so that only approved team mates see them" diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 7724f3cd..41d6eb1d 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -26,8 +26,7 @@ class SitePermissionManager * that share a site. Higher number = higher privilege. */ private const ROLE_PRIORITY = [ - SitePermission::ROLE_ADMIN => 2, - SitePermission::ROLE_EDITOR => 1, + SitePermission::ROLE_MANAGER => 1, SitePermission::ROLE_VIEWER => 0, ]; @@ -50,7 +49,7 @@ public function __construct(EntityManager $entityManager, UserSettings $userSett /** * Sync Omeka site permissions for a user in a specific team. * - * Assigns ROLE_ADMIN if the team role has `can_add_site_pages`, otherwise ROLE_VIEWER, + * Assigns ROLE_MANAGER if the team role has `can_add_site_pages`, otherwise ROLE_VIEWER, * for every site associated with the team. The role is set unconditionally so that * downgrading a team role is immediately reflected in the site permission. * @@ -68,7 +67,7 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $user = $teamUser->getUser(); $omekaRole = $teamUser->getRole()->getCanAddSitePages() - ? SitePermission::ROLE_ADMIN + ? SitePermission::ROLE_MANAGER : SitePermission::ROLE_VIEWER; $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['team' => $teamId]); @@ -225,6 +224,28 @@ public function updateAllUserDefaultSites(): void } } + /** + * Sync Omeka site permissions for every team-user-site combination. + * + * Iterates all TeamUser records and calls syncSitePermissionsForUser for + * each, ensuring every user has the correct SitePermission row for every + * site their team is associated with. Used by the module upgrade routine + * to bring existing installations into sync. + */ + public function syncAllSitePermissions(): void + { + $teamUsers = $this->entityManager + ->getRepository('Teams\Entity\TeamUser') + ->findAll(); + + foreach ($teamUsers as $teamUser) { + $this->syncSitePermissionsForUser( + $teamUser->getUser()->getId(), + $teamUser->getTeam()->getId() + ); + } + } + /** * Determine the highest Omeka site role granted to a user by any team *other than* * the one being removed. Returns null if no other team grants access to that site. @@ -253,7 +274,7 @@ private function getHighestRoleFromOtherTeams(int $userId, int $excludeTeamId, i } $role = $teamUser->getRole()->getCanAddSitePages() - ? SitePermission::ROLE_ADMIN + ? SitePermission::ROLE_MANAGER : SitePermission::ROLE_VIEWER; $highestRole = $this->resolveHighestRole($highestRole, $role); From 2ec5bbc760201391bbb05df90418cf9141b5c76f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:43:48 +0000 Subject: [PATCH 06/54] feat: site admin UI warning and team attribution; fix removal logic to compare team roles not site roles - SitePermissionManager: rewrite getHighestRoleFromOtherTeams to compare can_add_site_pages booleans across remaining team memberships (not Omeka site role strings). Any remaining team with can_add_site_pages=true yields ROLE_MANAGER; all false yields ROLE_VIEWER; no team-site relationship yields null (permission removed). Remove now-dead resolveHighestRole helper and ROLE_PRIORITY constant. - Module.php: add siteUsersTeamsInfo event handler attached to view.edit.after on Omeka\Controller\SiteAdmin\Index. Uses Messenger (native Omeka warning system) to display the managed-by-Teams warning. Also renders the users-teams-info partial with team attribution data. - view/teams/partial/site-admin/users-teams-info.phtml: new partial that injects team attribution labels into the #site-user-permissions table rows via JavaScript, annotating each row with the team(s) that granted the role. Warning is now handled by Messenger, not a custom div. Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- Module.php | 43 +++++++++++ src/Service/SitePermissionManager.php | 55 ++++++-------- .../partial/site-admin/users-teams-info.phtml | 71 +++++++++++++++++++ 3 files changed, 134 insertions(+), 35 deletions(-) create mode 100644 view/teams/partial/site-admin/users-teams-info.phtml diff --git a/Module.php b/Module.php index 16bbc7a2..2678e28f 100644 --- a/Module.php +++ b/Module.php @@ -1950,6 +1950,43 @@ public function siteEdit(Event $event) echo $view->partial('teams/partial/site-admin/edit', ['site_teams' => $site_teams, 'team_ids' => $team_ids]); } + /** + * Warns that site user roles are managed by Teams, and annotates each user + * row in the Omeka site-admin permissions table with the team(s) responsible. + * + * @param Event $event + */ + public function siteUsersTeamsInfo(Event $event) + { + $view = $event->getTarget(); + $site = $view->vars()->site; + if (!$site) { + return; + } + + $messenger = new Messenger(); + $messenger->addWarning( + 'User roles on this site are managed by the Teams module. ' + . 'Manual changes made here may be overwritten the next time team memberships or roles are updated.' + ); + + $em = $this->getServiceLocator()->get('Omeka\EntityManager'); + $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['site' => $site->id()]); + + // Build userId => [teamName, ...] for every user who has a team-managed + // permission on this site. + $teamManagedUsers = []; + foreach ($teamSites as $teamSite) { + $team = $teamSite->getTeam(); + $teamUsers = $em->getRepository('Teams\Entity\TeamUser')->findBy(['team' => $team->getId()]); + foreach ($teamUsers as $teamUser) { + $userId = $teamUser->getUser()->getId(); + $teamManagedUsers[$userId][] = $team->getName(); + } + } + + echo $view->partial('teams/partial/site-admin/users-teams-info', ['teamManagedUsers' => $teamManagedUsers]); + } public function getModules() { @@ -2540,6 +2577,12 @@ public function attachListeners(SharedEventManagerInterface $sharedEventManager) [$this, 'siteEdit'] ); + $sharedEventManager->attach( + 'Omeka\Controller\SiteAdmin\Index', + 'view.edit.after', + [$this, 'siteUsersTeamsInfo'] + ); + //put the roles data in the user page $sharedEventManager->attach( diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 41d6eb1d..ae13be23 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -21,15 +21,6 @@ */ class SitePermissionManager { - /** - * Role priority for resolving conflicts when a user belongs to multiple teams - * that share a site. Higher number = higher privilege. - */ - private const ROLE_PRIORITY = [ - SitePermission::ROLE_MANAGER => 1, - SitePermission::ROLE_VIEWER => 0, - ]; - /** * @var EntityManager */ @@ -247,8 +238,15 @@ public function syncAllSitePermissions(): void } /** - * Determine the highest Omeka site role granted to a user by any team *other than* - * the one being removed. Returns null if no other team grants access to that site. + * Determine the Omeka site role a user should hold on a site based on their + * remaining team memberships, excluding one team (the one being removed). + * + * Logic: + * - If the user has no other team that includes this site, returns null + * (the site permission should be removed entirely). + * - If any remaining team with this site has can_add_site_pages = true, + * returns ROLE_MANAGER. + * - Otherwise returns ROLE_VIEWER. * * @param int $userId * @param int $excludeTeamId The team being removed, which should be ignored. @@ -260,7 +258,9 @@ private function getHighestRoleFromOtherTeams(int $userId, int $excludeTeamId, i $em = $this->entityManager; $teamUsers = $em->getRepository('Teams\Entity\TeamUser')->findBy(['user' => $userId]); - $highestRole = null; + $hasAnySite = false; + $canManage = false; + foreach ($teamUsers as $teamUser) { if ($teamUser->getTeam()->getId() === $excludeTeamId) { continue; @@ -273,32 +273,17 @@ private function getHighestRoleFromOtherTeams(int $userId, int $excludeTeamId, i continue; } - $role = $teamUser->getRole()->getCanAddSitePages() - ? SitePermission::ROLE_MANAGER - : SitePermission::ROLE_VIEWER; - - $highestRole = $this->resolveHighestRole($highestRole, $role); + $hasAnySite = true; + if ($teamUser->getRole()->getCanAddSitePages()) { + $canManage = true; + break; // No need to check further — manager wins. + } } - return $highestRole; - } - - /** - * Return whichever of two Omeka site permission roles conveys higher privilege. - * - * @param string|null $current - * @param string $new - * @return string - */ - private function resolveHighestRole(?string $current, string $new): string - { - if ($current === null) { - return $new; + if (!$hasAnySite) { + return null; } - $currentPriority = self::ROLE_PRIORITY[$current] ?? -1; - $newPriority = self::ROLE_PRIORITY[$new] ?? -1; - - return $newPriority > $currentPriority ? $new : $current; + return $canManage ? SitePermission::ROLE_MANAGER : SitePermission::ROLE_VIEWER; } } diff --git a/view/teams/partial/site-admin/users-teams-info.phtml b/view/teams/partial/site-admin/users-teams-info.phtml new file mode 100644 index 00000000..e5c23eff --- /dev/null +++ b/view/teams/partial/site-admin/users-teams-info.phtml @@ -0,0 +1,71 @@ +teamManagedUsers array userId (int) => array of team names (string[]) + */ +$teamManagedUsers = $this->teamManagedUsers; +$translate = $this->plugin('translate'); +?> + From ee7d715779985e5b9eebd077cd5816c28b3791fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:03:36 +0000 Subject: [PATCH 07/54] Fix upgrade: require_once SitePermissionManager before direct instantiation Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- Module.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Module.php b/Module.php index 2678e28f..0b5714ac 100644 --- a/Module.php +++ b/Module.php @@ -196,7 +196,15 @@ public function upgrade($oldVersion, $newVersion, ServiceLocatorInterface $servi // every user has the correct SitePermission row for every site their // team is associated with. This backfills installations that were // running before site-permission sync was introduced. - $serviceLocator->get(SitePermissionManager::class)->syncAllSitePermissions(); + // NOTE: The service locator does not have module services available + // during upgrade, and the module autoloader is not yet registered, + // so we require the class file and instantiate it directly. + require_once __DIR__ . '/src/Service/SitePermissionManager.php'; + $sitePermissionManager = new SitePermissionManager( + $serviceLocator->get('Omeka\EntityManager'), + $serviceLocator->get('Omeka\Settings\User') + ); + $sitePermissionManager->syncAllSitePermissions(); } } From f90a766690965a2586e984e05de56710ac75ae67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:10:33 +0000 Subject: [PATCH 08/54] Remove syncAllSitePermissions from upgrade; expose as config form button Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- Module.php | 19 ++++++------------- src/Form/ConfigForm.php | 9 +++++++++ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Module.php b/Module.php index 0b5714ac..758a2520 100644 --- a/Module.php +++ b/Module.php @@ -192,19 +192,9 @@ public function upgrade($oldVersion, $newVersion, ServiceLocatorInterface $servi $globalSettings->set('teams_filter_bypass_roles', ["global_admin"]); } if (version_compare($oldVersion, '4.2.0', '<')) { - // Sync Omeka site permissions for all existing team members so that - // every user has the correct SitePermission row for every site their - // team is associated with. This backfills installations that were - // running before site-permission sync was introduced. - // NOTE: The service locator does not have module services available - // during upgrade, and the module autoloader is not yet registered, - // so we require the class file and instantiate it directly. - require_once __DIR__ . '/src/Service/SitePermissionManager.php'; - $sitePermissionManager = new SitePermissionManager( - $serviceLocator->get('Omeka\EntityManager'), - $serviceLocator->get('Omeka\Settings\User') - ); - $sitePermissionManager->syncAllSitePermissions(); + // Site permission sync cannot run here: module entities are not + // registered with Doctrine during the upgrade step. Use the + // "Sync Site Permissions" button on the module config page instead. } } @@ -224,6 +214,9 @@ public function handleConfigForm(AbstractController $controller) $globalSettings->set('teams_site_admin_make_user', $params['teams_site_admin_make_user']); $globalSettings->set('teams_filter_bypass_roles', $params['teams_filter_bypass_roles']); + if (!empty($params['teams_sync_site_permissions'])) { + $this->getServiceLocator()->get(SitePermissionManager::class)->syncAllSitePermissions(); + } } public function getConfigForm(PhpRenderer $renderer) diff --git a/src/Form/ConfigForm.php b/src/Form/ConfigForm.php index ebb72a95..1429bffe 100644 --- a/src/Form/ConfigForm.php +++ b/src/Form/ConfigForm.php @@ -70,6 +70,15 @@ public function init() ], ]); + $this->add([ + 'name' => 'teams_sync_site_permissions', + 'type' => 'submit', + 'attributes' => [ + 'value' => 'Sync Site Permissions', // @translate + 'id' => 'teams_sync_site_permissions', + ], + ]); + } public function setGlobalSettings($globalSettings) { From c322631ea46c01ff67309379500ecb7fbe307e7b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:25:39 +0000 Subject: [PATCH 09/54] Fix ROLE_ADMIN constant, remove exclusion logic, update sync language Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Service/SitePermissionManager.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index ae13be23..cf6a861e 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -123,8 +123,8 @@ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $sit continue; } - // Check whether any other team still grants this user access to this site. - $remainingRole = $this->getHighestRoleFromOtherTeams($userId, $teamId, $site->getId()); + // Check whether any team still grants this user access to this site. + $remainingRole = $this->getHighestRoleFromOtherTeams($userId, $site->getId()); if ($remainingRole !== null) { // User retains access via another team — update the role accordingly. From 93ec134f9a6d5223a211b4f0de0c6d3a79e0ba07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:26:25 +0000 Subject: [PATCH 10/54] Fix ROLE_ADMIN constant, remove exclusion logic, update language to reflect syncing Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Service/SitePermissionManager.php | 46 ++++++++++++--------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index cf6a861e..67e37a75 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -40,9 +40,9 @@ public function __construct(EntityManager $entityManager, UserSettings $userSett /** * Sync Omeka site permissions for a user in a specific team. * - * Assigns ROLE_MANAGER if the team role has `can_add_site_pages`, otherwise ROLE_VIEWER, + * Assigns ROLE_ADMIN if the team role has `can_add_site_pages`, otherwise ROLE_VIEWER, * for every site associated with the team. The role is set unconditionally so that - * downgrading a team role is immediately reflected in the site permission. + * any change to a team role is immediately reflected in the site permission. * * @param int $userId * @param int $teamId @@ -58,7 +58,7 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $user = $teamUser->getUser(); $omekaRole = $teamUser->getRole()->getCanAddSitePages() - ? SitePermission::ROLE_MANAGER + ? SitePermission::ROLE_ADMIN : SitePermission::ROLE_VIEWER; $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['team' => $teamId]); @@ -86,11 +86,12 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void } /** - * Remove or recalculate Omeka site permissions for a user leaving a team. + * Sync or remove Omeka site permissions for a user leaving a team. * * If the user still belongs to other teams that share a given site, their - * permission is recalculated from those remaining memberships rather than - * simply removed, so a shared-site privilege is never accidentally revoked. + * permission is recalculated across all team memberships so that the role + * reflects the current state — which may be unchanged, lowered, or elevated. + * If no team grants access to a site, the permission is removed entirely. * * @param int $userId * @param int $teamId The team the user is being removed from. @@ -123,14 +124,14 @@ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $sit continue; } - // Check whether any team still grants this user access to this site. - $remainingRole = $this->getHighestRoleFromOtherTeams($userId, $site->getId()); + // Recalculate the role across all team memberships after the change. + $syncedRole = $this->getHighestRoleFromTeams($userId, $site->getId()); - if ($remainingRole !== null) { - // User retains access via another team — update the role accordingly. - $existingPermission->setRole($remainingRole); + if ($syncedRole !== null) { + // User still has access — sync to the recalculated role. + $existingPermission->setRole($syncedRole); } else { - // No other team grants access; remove the permission entirely. + // No team grants access to this site; remove the permission. $sitePermissions->removeElement($existingPermission); $em->remove($existingPermission); } @@ -238,22 +239,21 @@ public function syncAllSitePermissions(): void } /** - * Determine the Omeka site role a user should hold on a site based on their - * remaining team memberships, excluding one team (the one being removed). + * Determine the Omeka site role a user should hold on a site based on all + * of their current team memberships. * * Logic: - * - If the user has no other team that includes this site, returns null + * - If no team the user belongs to includes this site, returns null * (the site permission should be removed entirely). - * - If any remaining team with this site has can_add_site_pages = true, - * returns ROLE_MANAGER. + * - If any team with this site has can_add_site_pages = true, + * returns ROLE_ADMIN. * - Otherwise returns ROLE_VIEWER. * * @param int $userId - * @param int $excludeTeamId The team being removed, which should be ignored. * @param int $siteId * @return string|null */ - private function getHighestRoleFromOtherTeams(int $userId, int $excludeTeamId, int $siteId): ?string + private function getHighestRoleFromTeams(int $userId, int $siteId): ?string { $em = $this->entityManager; $teamUsers = $em->getRepository('Teams\Entity\TeamUser')->findBy(['user' => $userId]); @@ -262,10 +262,6 @@ private function getHighestRoleFromOtherTeams(int $userId, int $excludeTeamId, i $canManage = false; foreach ($teamUsers as $teamUser) { - if ($teamUser->getTeam()->getId() === $excludeTeamId) { - continue; - } - $teamSite = $em->getRepository('Teams\Entity\TeamSite') ->findOneBy(['team' => $teamUser->getTeam()->getId(), 'site' => $siteId]); @@ -276,7 +272,7 @@ private function getHighestRoleFromOtherTeams(int $userId, int $excludeTeamId, i $hasAnySite = true; if ($teamUser->getRole()->getCanAddSitePages()) { $canManage = true; - break; // No need to check further — manager wins. + break; // No need to check further — admin wins. } } @@ -284,6 +280,6 @@ private function getHighestRoleFromOtherTeams(int $userId, int $excludeTeamId, i return null; } - return $canManage ? SitePermission::ROLE_MANAGER : SitePermission::ROLE_VIEWER; + return $canManage ? SitePermission::ROLE_ADMIN : SitePermission::ROLE_VIEWER; } } From 565999cc702c16224333cc54957be46c69f2287d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:33:45 +0000 Subject: [PATCH 11/54] Refresh TeamUser entity in syncSitePermissionsForUser to fix stale role on elevation Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Service/SitePermissionManager.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 67e37a75..f957b4a6 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -56,6 +56,10 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void return; } + // Refresh to ensure the role reflects the latest persisted state, + // not a potentially stale identity-map proxy. + $em->refresh($teamUser); + $user = $teamUser->getUser(); $omekaRole = $teamUser->getRole()->getCanAddSitePages() ? SitePermission::ROLE_ADMIN From cb06e0a6e8e0825ea6dac91314c0c783ff11a9da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:41:43 +0000 Subject: [PATCH 12/54] Use syncAllSitePermissions at end of team edit, matching sync button behavior exactly Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Controller/UpdateController.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index ff91fd25..00a0a0d7 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -327,7 +327,6 @@ public function teamUpdateAction() $role = $this->api()->read('team-role',['id'=>$teamUser['o:team_role']['o:id']])->getContent(); $teamUserExists[0]->getEntity()->setRole($role->getEntity()); $this->entityManager->flush(); - $this->sitePermissionManager->syncSitePermissionsForUser((int)$teamUser['o:user']['o:id'], (int)$team_id); } else { $this->api() ->create('team-user', @@ -336,7 +335,6 @@ public function teamUpdateAction() 'user'=>$teamUser['o:user']['o:id'], 'role'=>$teamUser['o:team_role']['o:id'] ]); - $this->sitePermissionManager->syncSitePermissionsForUser((int)$teamUser['o:user']['o:id'], (int)$team_id); } } } @@ -445,6 +443,10 @@ public function teamUpdateAction() } } + // Sync all site permissions now that all role and site changes are persisted, + // using the same logic as the sync button to guarantee correctness. + $this->sitePermissionManager->syncAllSitePermissions(); + $successMessage = sprintf("Successfully updated the %s team", $team->getName()); $this->messenger()->addSuccess($successMessage); From b699f7c90afbd79c19cdd61382ce82c365073421 Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 18:54:54 -0400 Subject: [PATCH 13/54] fix: only use the sync method --- src/Controller/UpdateController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 00a0a0d7..4f9829d6 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -86,7 +86,7 @@ public function removeTeamUser(int $team_id, int $user) $em = $this->entityManager; - $this->sitePermissionManager->removeSitePermissionsForUser($user, $team_id); + $this->sitePermissionManager->syncSitePermissionsForUser($user, $team_id); $team_user = $em->find('Teams\Entity\TeamUser', ['team' => $team_id, 'user' => $user]); $em->remove($team_user); @@ -315,7 +315,7 @@ public function teamUpdateAction() foreach ($oldTeamUsers as $oldTeamUser) { if (!in_array($oldTeamUser,$formTeamUsers)) { $this->api()->delete('team-user',['team'=>$team_id, 'user'=>$oldTeamUser]); - $this->sitePermissionManager->removeSitePermissionsForUser((int)$oldTeamUser, (int)$team_id); + $this->sitePermissionManager->syncSitePermissionsForUser((int)$oldTeamUser, (int)$team_id); } } //add team users or update permissions From 90793c062f916263b5f6160b8f75aa6ad394c6b4 Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 18:57:27 -0400 Subject: [PATCH 14/54] fix: return the devolop version and just add a single sync all --- src/Controller/UpdateController.php | 387 +++++++++++++++++----------- 1 file changed, 237 insertions(+), 150 deletions(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 4f9829d6..e2e20baa 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -2,25 +2,22 @@ namespace Teams\Controller; use Doctrine\ORM\EntityManager; -use Doctrine\ORM\NonUniqueResultException; -use Doctrine\ORM\OptimisticLockException; -use Doctrine\ORM\ORMException; use Doctrine\ORM\QueryBuilder; +use Omeka\Api\Exception\InvalidArgumentException; use Omeka\Api\Request; +use Teams\Entity\TeamAsset; +use Teams\Entity\TeamResource; +use Teams\Entity\TeamResourceTemplate; use Teams\Entity\TeamSite; use Teams\Entity\TeamUser; -use Teams\Form\SecondaryResourcesForm; use Teams\Form\TeamItemsetAddRemoveForm; -use Teams\Form\TeamResourcesForm; use Teams\Form\TeamSitesAddRemoveForm; -use Teams\Form\TeamDetailsForm; -use Teams\Service\SitePermissionManager; +use Teams\Form\TeamUpdateForm; use Laminas\EventManager\Event; use Laminas\Mvc\Controller\AbstractActionController; use Laminas\Stdlib\ArrayObject; use Laminas\View\Model\ViewModel; - class UpdateController extends AbstractActionController { /** @@ -28,19 +25,12 @@ class UpdateController extends AbstractActionController */ protected $entityManager; - /** - * @var SitePermissionManager - */ - protected SitePermissionManager $sitePermissionManager; - /** * @param EntityManager $entityManager - * @param SitePermissionManager $sitePermissionManager */ - public function __construct(EntityManager $entityManager, SitePermissionManager $sitePermissionManager) + public function __construct(EntityManager $entityManager) { $this->entityManager = $entityManager; - $this->sitePermissionManager = $sitePermissionManager; } public function createNamedParameter( @@ -70,9 +60,6 @@ public function addTeamUser(int $team_id, int $user_id, int $role_id) //flushing here because this is a mini-form and we want to see the name pop up //more efficient solution would be to have JS handle the popping and batch update $this->entityManager->flush(); - - $this->sitePermissionManager->syncSitePermissionsForUser($user_id, $team_id); - return $team_user; } } @@ -85,9 +72,6 @@ public function removeTeamUser(int $team_id, int $user) $this->messenger()->addError("removed user"); $em = $this->entityManager; - - $this->sitePermissionManager->syncSitePermissionsForUser($user, $team_id); - $team_user = $em->find('Teams\Entity\TeamUser', ['team' => $team_id, 'user' => $user]); $em->remove($team_user); @@ -107,8 +91,6 @@ public function updateRole(int $team_id, int $user_id, int $role_id) $user_role = $em->find('Teams\Entity\TeamRole', $role_id); $team_user->setRole($user_role); $em->flush(); - - $this->sitePermissionManager->syncSitePermissionsForUser($user_id, $team_id); } } @@ -136,14 +118,179 @@ public function processItemSets(int $item_set_id) return $resource_array; } - /** - * @throws ORMException - * @throws OptimisticLockException - * @throws NonUniqueResultException - */ - public function teamUpdateAction() + public function processResources($request, $team, $existing_resources, $existing_resources_templates, $existing_assets, bool $delete = false) { + $resource_array = array(); + $resource_template_array = array(); + $asset_array = array(); + + + if ($delete == false) { + $collection = 'addCollections'; + } else { + $collection = 'rmCollections'; + } + + //get ids of itemsets and their descendents + if (isset($request->getPost($collection)['o:itemset'])) { + foreach ($request->getPost($collection)['o:itemset'] as $item_set_id): + $resource_array += $this->processItemSets($item_set_id); + endforeach; + } + + //get ids of things the user owns + if (isset($request->getPost($collection)['o:user'])) { + foreach ($request->getPost($collection)['o:user'] as $user_id): + if ((int)$user_id > 0) { + $user_id = (int)$user_id; + foreach ($this->api()->search('items', ['owner_id' => $user_id, 'bypass_team_filter'=>true])->getContent() as $item): + + $resource_array += [$item->id() => true]; + + foreach ($this->api()->search('media', ['item_id'=>$item->id(), 'bypass_team_filter' => true])->getContent() as $media): + $resource_array += [$media->id() => true]; + endforeach; + + endforeach; + + //also get the users itemsets + foreach ($this->api()->search('item_sets', ['owner_id' => $user_id, 'bypass_team_filter'=>true])->getContent() as $itemSet): + $resource_array += $this->processItemSets($itemSet->id()); + endforeach; + + //also ge the user's resource templates + $rts = $this->entityManager->getRepository('Omeka\Entity\ResourceTemplate')->findBy(['owner'=>$user_id]); + foreach ($rts as $rt): + $resource_template_array[$rt->getId()] = true; + endforeach; + + //also get the user's assets + $assets = $this->entityManager->getRepository('Omeka\Entity\Asset')->findBy(['owner' => $user_id]); + foreach ($assets as $asset): + $asset_array[$asset->getId()] = true; + endforeach; + } + endforeach; + } + + if ($delete == false) { + //remove elements that are already part of the team to prevent integrity constraint violation + + //resources remove existing from the add list + foreach ($existing_resources as $resource): + $rid = $resource->getResource()->getId(); + if (array_key_exists($rid, $resource_array)) { + unset($resource_array[$rid]); + } + endforeach; + + //resource templates remove existing from the add list + foreach ($existing_resources_templates as $resource): + $rid = $resource->getResourceTemplate()->getId(); + if (array_key_exists($rid, $resource_template_array)) { + unset($resource_template_array[$rid]); + } + endforeach; + + //assets remove existing from the add list + foreach ($existing_assets as $asset): + $asset_id = $asset->getAsset()->getId(); + if (array_key_exists($asset_id, $asset_array)) { + unset($asset_array[$asset_id]); + } + endforeach; + + //add the resources to the team + foreach ($resource_array as $resource_id => $value): + $resource = $this->entityManager->getRepository('Omeka\Entity\Resource') + ->findOneBy(['id'=>$resource_id]); + if ($resource) { + $team_resource = new TeamResource($team, $resource); + $this->entityManager->persist($team_resource); + } else { + $this->logger()->err('The team resource could not be generated for this request because the resource could not be found'); + } + + endforeach; + + //add the resource templates to the team + foreach ($resource_template_array as $resource_id => $value): + $resource = $this->entityManager->getRepository('Omeka\Entity\ResourceTemplate') + ->findOneBy(['id'=>$resource_id]); + if ($resource) { + $team_resource = new TeamResourceTemplate($team, $resource); + $this->entityManager->persist($team_resource); + } else { + $this->logger()->err('The team resource template could not be generated for this request because the resource template could not be found'); + + } + + endforeach; + //add assets to the team + foreach ($asset_array as $asset_id => $value): + $asset = $this->entityManager->getRepository('Omeka\Entity\Asset') + ->findOneBy(['id' => $asset_id]); + if ($asset) { + $team_asset = new TeamAsset($team, $asset); + $this->entityManager->persist($team_asset); + } else { + $this->logger()->err('The team asset could not be generated for this request because the asset could not be found'); + } + + + endforeach; + + $this->entityManager->flush(); + } else { + //remove resources + foreach (array_keys($resource_array) as $resource_id): + $team_resource = $this->entityManager->getRepository('Teams\Entity\TeamResource') + ->findOneBy(['resource'=>$resource_id, 'team'=>$team]); + if ($team_resource) { + $this->entityManager->remove($team_resource); + } + endforeach; + + //remove resource templates + foreach (array_keys($resource_template_array) as $resource_id): + $team_resource_template = $this->entityManager->getRepository('Teams\Entity\TeamResourceTemplate') + ->findOneBy(['resource_template'=>$resource_id, 'team'=>$team]); + if ($team_resource_template) { + $this->entityManager->remove($team_resource_template); + } + endforeach; + $this->entityManager->flush(); + + //remove assets + foreach (array_keys($asset_array) as $asset_id): + $team_asset = $this->entityManager->getRepository('Teams\Entity\TeamAsset') + ->findOneBy(['asset'=>$asset_id, 'team'=>$team]); + if ($team_asset) { + $this->entityManager->remove($team_asset); + } + endforeach; + $this->entityManager->flush(); + + } + } + + public function processAssets($request, $team, $existing_resources, $existing_resources_templates, bool $delete = false) + { + + } + public function tempteamUpdateAction() + { + $id = $this->params()->fromRoute('id'); + $team_id = $this->params()->fromRoute('id'); + + $resource_form = $this->getForm(AvailableResourcesForm::class); + $sites_form = $this->getForm(AvailableSitesForm::class); + $user_form = $this->getForm(AvailableSitesForm::class); + } + + public function teamUpdateAction() + { $team_id = $this->params()->fromRoute('id'); $team = $this->entityManager->getRepository('Teams\Entity\Team')->findOneBy(['id' => $team_id]); $team_sites = $this->entityManager @@ -187,12 +334,11 @@ public function teamUpdateAction() $sites->setEmptyOption('None'); $sites->setValueOptions($valueOptions); - //set up the item set form $itemsetForm = $this->getForm(TeamItemsetAddRemoveForm::class); - $user = $this->identity(); + $userId = $this->identity()->getId(); //TODO rename this to TeamDetail form or find a way to string these all together - $teamDetailsForm = $this->getForm(TeamDetailsForm::class); + $form = $this->getForm(TeamUpdateForm::class); //TODO: get team with a one line entity manager call $criteria = ['id' => $team_id]; @@ -249,32 +395,21 @@ public function teamUpdateAction() $fill = new ArrayObject; $fill['o:name'] = $data->getJsonLd()['o:name']; $fill['o:description'] = $data->getJsonLd()['o:description']; - $teamDetailsForm->bind($fill); + $form->bind($fill); //is it a post request? //TODO (refactor) clean up this, only send what is needed $request = $this->getRequest(); - - $resourceForm = $this->getForm(TeamResourcesForm::class)->setAttribute('id', 'team-resources-form'); - $secondaryResourcesForm = $this->getForm(SecondaryResourcesForm::class, - ['team_id'=>$team_id] - ); - - $bypass_team_filter_roles = $this->settings()->get('teams_filter_bypass_roles'); - $view = new ViewModel([ - 'team'=>$team, - 'form' => $teamDetailsForm, - 'resourceForm' => $resourceForm, - 'secondaryResourcesForm' => $secondaryResourcesForm, - 'bypassTeamFilterRoles' => $bypass_team_filter_roles, - 'id' => $team_id, + $view = new ViewModel(['team'=>$team, + 'form' => $form, + 'id'=>$team_id, 'roles'=> $roles, 'roles_array' => $roles_array, 'all_u_collection' => $all_u_collection, 'team_u_collection' => $team_u_collection, 'team_u_array'=>$team_u_array, 'available_u_array'=>$available_u_array, - 'user' => $user, + 'ident' => $userId, 'itemsetForm' => $itemsetForm, 'sitesForm' => $sitesForm, ]); @@ -282,6 +417,29 @@ public function teamUpdateAction() return $view; } + $em = $this->entityManager; + $qb = $em->createQueryBuilder(); + $existing_resources = $qb->select('tr') + ->from('Teams\Entity\TeamResource', 'tr') + ->where('tr.team = :team_id') + ->setParameter('team_id', $team_id) + ->getQuery() + ->getResult(); + + $existing_resource_templates = $qb->select('trt') + ->from('Teams\Entity\TeamResourceTemplate', 'trt') + ->where('trt.team = :team_id') + ->setParameter('team_id', $team_id) + ->getQuery() + ->getResult(); + + $existing_assets = $qb->select('ta') + ->from('Teams\Entity\TeamAsset', 'ta') + ->where('ta.team = :team_id') + ->setParameter('team_id', $team_id) + ->getQuery() + ->getResult(); + $post_data = $request->getPost(); if (!$this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $team_id)) { @@ -300,110 +458,45 @@ public function teamUpdateAction() ->getQuery() ->execute(); } - if (!$this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $team_id)) { - $this->messenger()->addError("You aren't authorized to change team members"); - return $view; - } else { - $teamUsers = $request->getPost('o:team_users'); - //remove team users not in the form - $formTeamUsers = array(); - foreach ($teamUsers as $teamUser){ - $formTeamUsers[] = $teamUser['o:user']['o:id']; - } - $oldTeamUsers= $this->api()->search('team-user', ['team'=>$team_id], ['returnScalar'=>'user'])->getContent(); - foreach ($oldTeamUsers as $oldTeamUser) { - if (!in_array($oldTeamUser,$formTeamUsers)) { - $this->api()->delete('team-user',['team'=>$team_id, 'user'=>$oldTeamUser]); - $this->sitePermissionManager->syncSitePermissionsForUser((int)$oldTeamUser, (int)$team_id); - } - } - //add team users or update permissions - foreach ($teamUsers as $teamUser) { - //using search instead of read because read will throw a not found error instead of returning empty - $teamUserExists = $this->api()->search('team-user', ['team'=>$team_id, 'user'=>$teamUser['o:user']['o:id']])->getContent(); - - if ($teamUserExists){ - $role = $this->api()->read('team-role',['id'=>$teamUser['o:team_role']['o:id']])->getContent(); - $teamUserExists[0]->getEntity()->setRole($role->getEntity()); - $this->entityManager->flush(); - } else { - $this->api() - ->create('team-user', - [ - 'team'=>$team_id, - 'user'=>$teamUser['o:user']['o:id'], - 'role'=>$teamUser['o:team_role']['o:id'] - ]); + + if (!$this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $team_id)) { + $this->messenger()->addError("You aren't authorized to change team members"); + return $view; + } else { + $current_users = $this->entityManager->getRepository('Teams\Entity\TeamUser')->findBy(['team' => $team_id]); + + foreach ($current_users as $team_user) { + $this->entityManager->remove($team_user); } + $this->entityManager->flush(); + + foreach ($request->getPost('o:team_users') as $team_user): + $user = $this->entityManager->getRepository('Omeka\Entity\User') + ->findOneBy(['id' => (int)$team_user['o:user']['o:id']]); + $role = $this->entityManager->getRepository('Teams\Entity\TeamRole') + ->findOneBy(['id' => (int)$team_user['o:team_role']['o:id']]); + + $teamUser = new TeamUser($team, $user, $role); + $teamUser->setCurrent(null); + $this->entityManager->persist($teamUser); + endforeach; + $this->entityManager->flush(); + } - } - //TODO:need to update this for users who have the update and bypass_team_filter if (! $this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team', $team_id)){ $this->messenger()->addError("You aren't authorized to change this team"); return $view; } else { - //process items - $formData = $this->params()->fromPost(); - $resourceForm->setData($formData); - parse_str($formData['item_pool'], $itemPool); - if ($formData['item_assignment_action'] && $formData['item_assignment_action'] !== 'no_action') { - $this->jobDispatcher()->dispatch('Teams\Job\UpdateTeamResources', [ - 'teams' => [$team_id => $itemPool], - 'action' => $formData['item_assignment_action'], - ]); - $this->messenger()->addSuccess('Item assignment in progress. To see the new item count, refresh the page.'); // @translate - } - - //process item sets and resource templates - - //remove item sets - $secondaryResourcesForm->setData($formData); - - $recursive = $formData['recursive_item_sets'] ?? false; - if (isset($formData['remove_item_sets'])){ - $remove_item_sets = $formData['remove_item_sets']; - foreach ($remove_item_sets as $item_set_id) { - //todo: delete expects the id to be in the second parameter, for now just leaving empty because team resource uses a composite key - $this->api()->delete('team-resource', [], ['team' => $team_id, 'resource' => $item_set_id],['recursive'=>$recursive, 'syncSites' => true]); - } - } - //add item sets - if (isset($formData['item_sets'])) { - foreach ($formData['item_sets'] as $item_set_id) { - //todo: implement the read operation - $exists = $this->api()->search('team-resource', ['team' => $team_id, 'resource' => $item_set_id]); - if (count($exists->getContent()) < 1) { - $this->api()->create('team-resource', ['team' => $team_id, 'resource' => $item_set_id], [], ['recursive' => $recursive, 'syncSites' => true]); - } - } - } + //first delete then add resources to team + $this->processResources($request, $team, $existing_resources, $existing_resource_templates, $existing_assets, true); + $this->processResources($request, $team, $existing_resources, $existing_resource_templates, $existing_assets, false); - //remove resource templates - $secondaryResourcesForm->setData($formData); - if (isset($formData['remove_resource_templates'])){ - foreach ($formData['remove_resource_templates'] as $resource_template_id) { - //todo: delete expects the id to be in the second parameter, for now just leaving empty because team resource uses a composite key - $this->api()->delete('team-resource-template', [], ['team' => $team_id, 'resource-template' => $resource_template_id]); - } - } - //add resource templates - if (isset($formData['resource_templates'])){ - foreach ($formData['resource_templates'] as $resource_template_id) { - if (count($this->api()->search('team-resource-template', ['team'=>$team_id, 'resource-template'=>$resource_template_id])->getContent())<1){ - $this->api()->create('team-resource-template', ['team'=>$team_id, 'resource-template'=>$resource_template_id]); - } - } - } - - $em = $this->entityManager; //handle new sites - $added_site_ids = []; foreach ($post_data['teamSites']['o:site'] as $site) { if (!in_array($site, $current_sites)) { - $added_site_ids[] = (int)$site; $site = $em->getRepository('Omeka\Entity\Site')->findOneBy(['id'=>$site]); $ts = new TeamSite($team, $site); $request = new Request('create', 'team_site'); @@ -418,10 +511,8 @@ public function teamUpdateAction() } //handle removed sites - $removed_site_ids = []; foreach ($current_sites as $site) { if (!in_array($site, $post_data['teamSites']['o:site'])) { - $removed_site_ids[] = (int)$site; $ts = $em->getRepository('Teams\Entity\TeamSite')->findOneBy(['team'=>$team_id, 'site'=>$site]); $request = new Request('delete', 'team_site'); $event = new Event('api.hydrate.pre', $this, [ @@ -433,14 +524,6 @@ public function teamUpdateAction() } } $em->flush(); - - // Sync Omeka site permissions for all team users when sites are added or removed. - foreach ($added_site_ids as $added_site_id) { - $this->sitePermissionManager->syncSitePermissionsForTeamOnSiteAdded($team_id, $added_site_id); - } - foreach ($removed_site_ids as $removed_site_id) { - $this->sitePermissionManager->removeSitePermissionsForTeamOnSiteRemoved($team_id, $removed_site_id); - } } // Sync all site permissions now that all role and site changes are persisted, @@ -453,6 +536,10 @@ public function teamUpdateAction() return $this->redirect()->toRoute('admin/teams/detail',['id'=>$team_id]); } + public function roleUpdateAction() + { + } + public function userAction() { $request = $this->getRequest(); From a5b07652b361601a32bbbdf9a35f431f2674c35b Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 19:21:45 -0400 Subject: [PATCH 15/54] fix: single sync at the end --- src/Controller/UpdateController.php | 365 ++++++++++------------------ 1 file changed, 128 insertions(+), 237 deletions(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index e2e20baa..57bac863 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -2,22 +2,25 @@ namespace Teams\Controller; use Doctrine\ORM\EntityManager; +use Doctrine\ORM\NonUniqueResultException; +use Doctrine\ORM\OptimisticLockException; +use Doctrine\ORM\ORMException; use Doctrine\ORM\QueryBuilder; -use Omeka\Api\Exception\InvalidArgumentException; use Omeka\Api\Request; -use Teams\Entity\TeamAsset; -use Teams\Entity\TeamResource; -use Teams\Entity\TeamResourceTemplate; use Teams\Entity\TeamSite; use Teams\Entity\TeamUser; +use Teams\Form\SecondaryResourcesForm; use Teams\Form\TeamItemsetAddRemoveForm; +use Teams\Form\TeamResourcesForm; use Teams\Form\TeamSitesAddRemoveForm; -use Teams\Form\TeamUpdateForm; +use Teams\Form\TeamDetailsForm; +use Teams\Service\SitePermissionManager; use Laminas\EventManager\Event; use Laminas\Mvc\Controller\AbstractActionController; use Laminas\Stdlib\ArrayObject; use Laminas\View\Model\ViewModel; + class UpdateController extends AbstractActionController { /** @@ -25,12 +28,19 @@ class UpdateController extends AbstractActionController */ protected $entityManager; + /** + * @var SitePermissionManager + */ + protected SitePermissionManager $sitePermissionManager; + /** * @param EntityManager $entityManager + * @param SitePermissionManager $sitePermissionManager */ - public function __construct(EntityManager $entityManager) + public function __construct(EntityManager $entityManager, SitePermissionManager $sitePermissionManager) { $this->entityManager = $entityManager; + $this->sitePermissionManager = $sitePermissionManager; } public function createNamedParameter( @@ -118,179 +128,14 @@ public function processItemSets(int $item_set_id) return $resource_array; } - public function processResources($request, $team, $existing_resources, $existing_resources_templates, $existing_assets, bool $delete = false) - { - $resource_array = array(); - $resource_template_array = array(); - $asset_array = array(); - - - if ($delete == false) { - $collection = 'addCollections'; - } else { - $collection = 'rmCollections'; - } - - //get ids of itemsets and their descendents - if (isset($request->getPost($collection)['o:itemset'])) { - foreach ($request->getPost($collection)['o:itemset'] as $item_set_id): - $resource_array += $this->processItemSets($item_set_id); - endforeach; - } - - //get ids of things the user owns - if (isset($request->getPost($collection)['o:user'])) { - foreach ($request->getPost($collection)['o:user'] as $user_id): - if ((int)$user_id > 0) { - $user_id = (int)$user_id; - foreach ($this->api()->search('items', ['owner_id' => $user_id, 'bypass_team_filter'=>true])->getContent() as $item): - - $resource_array += [$item->id() => true]; - - foreach ($this->api()->search('media', ['item_id'=>$item->id(), 'bypass_team_filter' => true])->getContent() as $media): - $resource_array += [$media->id() => true]; - endforeach; - - endforeach; - - //also get the users itemsets - foreach ($this->api()->search('item_sets', ['owner_id' => $user_id, 'bypass_team_filter'=>true])->getContent() as $itemSet): - $resource_array += $this->processItemSets($itemSet->id()); - endforeach; - - //also ge the user's resource templates - $rts = $this->entityManager->getRepository('Omeka\Entity\ResourceTemplate')->findBy(['owner'=>$user_id]); - foreach ($rts as $rt): - $resource_template_array[$rt->getId()] = true; - endforeach; - - //also get the user's assets - $assets = $this->entityManager->getRepository('Omeka\Entity\Asset')->findBy(['owner' => $user_id]); - foreach ($assets as $asset): - $asset_array[$asset->getId()] = true; - endforeach; - } - endforeach; - } - - if ($delete == false) { - //remove elements that are already part of the team to prevent integrity constraint violation - - //resources remove existing from the add list - foreach ($existing_resources as $resource): - $rid = $resource->getResource()->getId(); - if (array_key_exists($rid, $resource_array)) { - unset($resource_array[$rid]); - } - endforeach; - - //resource templates remove existing from the add list - foreach ($existing_resources_templates as $resource): - $rid = $resource->getResourceTemplate()->getId(); - if (array_key_exists($rid, $resource_template_array)) { - unset($resource_template_array[$rid]); - } - endforeach; - - //assets remove existing from the add list - foreach ($existing_assets as $asset): - $asset_id = $asset->getAsset()->getId(); - if (array_key_exists($asset_id, $asset_array)) { - unset($asset_array[$asset_id]); - } - endforeach; - - //add the resources to the team - foreach ($resource_array as $resource_id => $value): - $resource = $this->entityManager->getRepository('Omeka\Entity\Resource') - ->findOneBy(['id'=>$resource_id]); - if ($resource) { - $team_resource = new TeamResource($team, $resource); - $this->entityManager->persist($team_resource); - } else { - $this->logger()->err('The team resource could not be generated for this request because the resource could not be found'); - } - - endforeach; - - //add the resource templates to the team - foreach ($resource_template_array as $resource_id => $value): - $resource = $this->entityManager->getRepository('Omeka\Entity\ResourceTemplate') - ->findOneBy(['id'=>$resource_id]); - if ($resource) { - $team_resource = new TeamResourceTemplate($team, $resource); - $this->entityManager->persist($team_resource); - } else { - $this->logger()->err('The team resource template could not be generated for this request because the resource template could not be found'); - - } - - endforeach; - - //add assets to the team - foreach ($asset_array as $asset_id => $value): - $asset = $this->entityManager->getRepository('Omeka\Entity\Asset') - ->findOneBy(['id' => $asset_id]); - if ($asset) { - $team_asset = new TeamAsset($team, $asset); - $this->entityManager->persist($team_asset); - } else { - $this->logger()->err('The team asset could not be generated for this request because the asset could not be found'); - } - - - endforeach; - - $this->entityManager->flush(); - } else { - //remove resources - foreach (array_keys($resource_array) as $resource_id): - $team_resource = $this->entityManager->getRepository('Teams\Entity\TeamResource') - ->findOneBy(['resource'=>$resource_id, 'team'=>$team]); - if ($team_resource) { - $this->entityManager->remove($team_resource); - } - endforeach; - - //remove resource templates - foreach (array_keys($resource_template_array) as $resource_id): - $team_resource_template = $this->entityManager->getRepository('Teams\Entity\TeamResourceTemplate') - ->findOneBy(['resource_template'=>$resource_id, 'team'=>$team]); - if ($team_resource_template) { - $this->entityManager->remove($team_resource_template); - } - endforeach; - $this->entityManager->flush(); - - //remove assets - foreach (array_keys($asset_array) as $asset_id): - $team_asset = $this->entityManager->getRepository('Teams\Entity\TeamAsset') - ->findOneBy(['asset'=>$asset_id, 'team'=>$team]); - if ($team_asset) { - $this->entityManager->remove($team_asset); - } - endforeach; - $this->entityManager->flush(); - - } - } - - public function processAssets($request, $team, $existing_resources, $existing_resources_templates, bool $delete = false) - { - - } - public function tempteamUpdateAction() - { - $id = $this->params()->fromRoute('id'); - $team_id = $this->params()->fromRoute('id'); - - $resource_form = $this->getForm(AvailableResourcesForm::class); - $sites_form = $this->getForm(AvailableSitesForm::class); - $user_form = $this->getForm(AvailableSitesForm::class); - } - + /** + * @throws ORMException + * @throws OptimisticLockException + * @throws NonUniqueResultException + */ public function teamUpdateAction() { + $team_id = $this->params()->fromRoute('id'); $team = $this->entityManager->getRepository('Teams\Entity\Team')->findOneBy(['id' => $team_id]); $team_sites = $this->entityManager @@ -334,11 +179,12 @@ public function teamUpdateAction() $sites->setEmptyOption('None'); $sites->setValueOptions($valueOptions); + //set up the item set form $itemsetForm = $this->getForm(TeamItemsetAddRemoveForm::class); - $userId = $this->identity()->getId(); + $user = $this->identity(); //TODO rename this to TeamDetail form or find a way to string these all together - $form = $this->getForm(TeamUpdateForm::class); + $teamDetailsForm = $this->getForm(TeamDetailsForm::class); //TODO: get team with a one line entity manager call $criteria = ['id' => $team_id]; @@ -395,21 +241,32 @@ public function teamUpdateAction() $fill = new ArrayObject; $fill['o:name'] = $data->getJsonLd()['o:name']; $fill['o:description'] = $data->getJsonLd()['o:description']; - $form->bind($fill); + $teamDetailsForm->bind($fill); //is it a post request? //TODO (refactor) clean up this, only send what is needed $request = $this->getRequest(); - $view = new ViewModel(['team'=>$team, - 'form' => $form, - 'id'=>$team_id, + + $resourceForm = $this->getForm(TeamResourcesForm::class)->setAttribute('id', 'team-resources-form'); + $secondaryResourcesForm = $this->getForm(SecondaryResourcesForm::class, + ['team_id'=>$team_id] + ); + + $bypass_team_filter_roles = $this->settings()->get('teams_filter_bypass_roles'); + $view = new ViewModel([ + 'team'=>$team, + 'form' => $teamDetailsForm, + 'resourceForm' => $resourceForm, + 'secondaryResourcesForm' => $secondaryResourcesForm, + 'bypassTeamFilterRoles' => $bypass_team_filter_roles, + 'id' => $team_id, 'roles'=> $roles, 'roles_array' => $roles_array, 'all_u_collection' => $all_u_collection, 'team_u_collection' => $team_u_collection, 'team_u_array'=>$team_u_array, 'available_u_array'=>$available_u_array, - 'ident' => $userId, + 'user' => $user, 'itemsetForm' => $itemsetForm, 'sitesForm' => $sitesForm, ]); @@ -417,29 +274,6 @@ public function teamUpdateAction() return $view; } - $em = $this->entityManager; - $qb = $em->createQueryBuilder(); - $existing_resources = $qb->select('tr') - ->from('Teams\Entity\TeamResource', 'tr') - ->where('tr.team = :team_id') - ->setParameter('team_id', $team_id) - ->getQuery() - ->getResult(); - - $existing_resource_templates = $qb->select('trt') - ->from('Teams\Entity\TeamResourceTemplate', 'trt') - ->where('trt.team = :team_id') - ->setParameter('team_id', $team_id) - ->getQuery() - ->getResult(); - - $existing_assets = $qb->select('ta') - ->from('Teams\Entity\TeamAsset', 'ta') - ->where('ta.team = :team_id') - ->setParameter('team_id', $team_id) - ->getQuery() - ->getResult(); - $post_data = $request->getPost(); if (!$this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $team_id)) { @@ -458,42 +292,103 @@ public function teamUpdateAction() ->getQuery() ->execute(); } + if (!$this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $team_id)) { + $this->messenger()->addError("You aren't authorized to change team members"); + return $view; + } else { + $teamUsers = $request->getPost('o:team_users'); + //remove team users not in the form + $formTeamUsers = array(); + foreach ($teamUsers as $teamUser){ + $formTeamUsers[] = $teamUser['o:user']['o:id']; + } - - if (!$this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $team_id)) { - $this->messenger()->addError("You aren't authorized to change team members"); - return $view; - } else { - $current_users = $this->entityManager->getRepository('Teams\Entity\TeamUser')->findBy(['team' => $team_id]); - - foreach ($current_users as $team_user) { - $this->entityManager->remove($team_user); + $oldTeamUsers= $this->api()->search('team-user', ['team'=>$team_id], ['returnScalar'=>'user'])->getContent(); + foreach ($oldTeamUsers as $oldTeamUser) { + if (!in_array($oldTeamUser,$formTeamUsers)) { + $this->api()->delete('team-user',['team'=>$team_id, 'user'=>$oldTeamUser]); } - $this->entityManager->flush(); - - foreach ($request->getPost('o:team_users') as $team_user): - $user = $this->entityManager->getRepository('Omeka\Entity\User') - ->findOneBy(['id' => (int)$team_user['o:user']['o:id']]); - $role = $this->entityManager->getRepository('Teams\Entity\TeamRole') - ->findOneBy(['id' => (int)$team_user['o:team_role']['o:id']]); - - $teamUser = new TeamUser($team, $user, $role); - $teamUser->setCurrent(null); - $this->entityManager->persist($teamUser); - endforeach; - $this->entityManager->flush(); - } + //add team users or update permissions + foreach ($teamUsers as $teamUser) { + //using search instead of read because read will throw a not found error instead of returning empty + $teamUserExists = $this->api()->search('team-user', ['team'=>$team_id, 'user'=>$teamUser['o:user']['o:id']])->getContent(); + + if ($teamUserExists){ + $role = $this->api()->read('team-role',['id'=>$teamUser['o:team_role']['o:id']])->getContent(); + $teamUserExists[0]->getEntity()->setRole($role->getEntity()); + } else { + $this->api() + ->create('team-user', + [ + 'team'=>$team_id, + 'user'=>$teamUser['o:user']['o:id'], + 'role'=>$teamUser['o:team_role']['o:id'] + ]); + } + } + } + //TODO:need to update this for users who have the update and bypass_team_filter if (! $this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team', $team_id)){ $this->messenger()->addError("You aren't authorized to change this team"); return $view; } else { - //first delete then add resources to team - $this->processResources($request, $team, $existing_resources, $existing_resource_templates, $existing_assets, true); - $this->processResources($request, $team, $existing_resources, $existing_resource_templates, $existing_assets, false); + //process items + $formData = $this->params()->fromPost(); + $resourceForm->setData($formData); + parse_str($formData['item_pool'], $itemPool); + if ($formData['item_assignment_action'] && $formData['item_assignment_action'] !== 'no_action') { + $this->jobDispatcher()->dispatch('Teams\Job\UpdateTeamResources', [ + 'teams' => [$team_id => $itemPool], + 'action' => $formData['item_assignment_action'], + ]); + $this->messenger()->addSuccess('Item assignment in progress. To see the new item count, refresh the page.'); // @translate + } + + //process item sets and resource templates + //remove item sets + $secondaryResourcesForm->setData($formData); + + $recursive = $formData['recursive_item_sets'] ?? false; + if (isset($formData['remove_item_sets'])){ + $remove_item_sets = $formData['remove_item_sets']; + foreach ($remove_item_sets as $item_set_id) { + //todo: delete expects the id to be in the second parameter, for now just leaving empty because team resource uses a composite key + $this->api()->delete('team-resource', [], ['team' => $team_id, 'resource' => $item_set_id],['recursive'=>$recursive, 'syncSites' => true]); + } + } + //add item sets + if (isset($formData['item_sets'])) { + foreach ($formData['item_sets'] as $item_set_id) { + //todo: implement the read operation + $exists = $this->api()->search('team-resource', ['team' => $team_id, 'resource' => $item_set_id]); + if (count($exists->getContent()) < 1) { + $this->api()->create('team-resource', ['team' => $team_id, 'resource' => $item_set_id], [], ['recursive' => $recursive, 'syncSites' => true]); + } + } + } + + //remove resource templates + $secondaryResourcesForm->setData($formData); + if (isset($formData['remove_resource_templates'])){ + foreach ($formData['remove_resource_templates'] as $resource_template_id) { + //todo: delete expects the id to be in the second parameter, for now just leaving empty because team resource uses a composite key + $this->api()->delete('team-resource-template', [], ['team' => $team_id, 'resource-template' => $resource_template_id]); + } + } + //add resource templates + if (isset($formData['resource_templates'])){ + foreach ($formData['resource_templates'] as $resource_template_id) { + if (count($this->api()->search('team-resource-template', ['team'=>$team_id, 'resource-template'=>$resource_template_id])->getContent())<1){ + $this->api()->create('team-resource-template', ['team'=>$team_id, 'resource-template'=>$resource_template_id]); + } + } + } + + $em = $this->entityManager; //handle new sites foreach ($post_data['teamSites']['o:site'] as $site) { if (!in_array($site, $current_sites)) { @@ -536,10 +431,6 @@ public function teamUpdateAction() return $this->redirect()->toRoute('admin/teams/detail',['id'=>$team_id]); } - public function roleUpdateAction() - { - } - public function userAction() { $request = $this->getRequest(); From eb4c007a8d99b348480790fb5201f810a6abbf30 Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 19:24:21 -0400 Subject: [PATCH 16/54] fix: restore to pre feat state --- src/Controller/UpdateController.php | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 57bac863..b4248ad6 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -14,7 +14,6 @@ use Teams\Form\TeamResourcesForm; use Teams\Form\TeamSitesAddRemoveForm; use Teams\Form\TeamDetailsForm; -use Teams\Service\SitePermissionManager; use Laminas\EventManager\Event; use Laminas\Mvc\Controller\AbstractActionController; use Laminas\Stdlib\ArrayObject; @@ -28,19 +27,12 @@ class UpdateController extends AbstractActionController */ protected $entityManager; - /** - * @var SitePermissionManager - */ - protected SitePermissionManager $sitePermissionManager; - /** * @param EntityManager $entityManager - * @param SitePermissionManager $sitePermissionManager */ - public function __construct(EntityManager $entityManager, SitePermissionManager $sitePermissionManager) + public function __construct(EntityManager $entityManager) { $this->entityManager = $entityManager; - $this->sitePermissionManager = $sitePermissionManager; } public function createNamedParameter( @@ -421,10 +413,6 @@ public function teamUpdateAction() $em->flush(); } - // Sync all site permissions now that all role and site changes are persisted, - // using the same logic as the sync button to guarantee correctness. - $this->sitePermissionManager->syncAllSitePermissions(); - $successMessage = sprintf("Successfully updated the %s team", $team->getName()); $this->messenger()->addSuccess($successMessage); From 2c4bea1e4a8a504cff91a675f8e29cada2fc9fe1 Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 19:33:41 -0400 Subject: [PATCH 17/54] fix: single sync at the end --- src/Controller/UpdateController.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index b4248ad6..5375ed5e 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -18,6 +18,7 @@ use Laminas\Mvc\Controller\AbstractActionController; use Laminas\Stdlib\ArrayObject; use Laminas\View\Model\ViewModel; +use Teams\Service\SitePermissionManager; class UpdateController extends AbstractActionController @@ -27,12 +28,19 @@ class UpdateController extends AbstractActionController */ protected $entityManager; + /** + * @var SitePermissionManager + */ + protected SitePermissionManager $sitePermissionManager; + /** * @param EntityManager $entityManager + * @param SitePermissionManager $sitePermissionManager */ - public function __construct(EntityManager $entityManager) + public function __construct(EntityManager $entityManager, SitePermissionManager $sitePermissionManager) { $this->entityManager = $entityManager; + $this->sitePermissionManager = $sitePermissionManager; } public function createNamedParameter( @@ -412,7 +420,7 @@ public function teamUpdateAction() } $em->flush(); } - + $this->sitePermissionManager->syncAllSitePermissions(); $successMessage = sprintf("Successfully updated the %s team", $team->getName()); $this->messenger()->addSuccess($successMessage); From ebe86eb184a14e6ed0459d75f99994f589401630 Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 20:29:47 -0400 Subject: [PATCH 18/54] fix: add logging --- src/Service/SitePermissionManager.php | 25 +++++++++++++++++--- src/Service/SitePermissionManagerFactory.php | 12 +++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index f957b4a6..3eaec264 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -4,6 +4,7 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\EntityManager; use Omeka\Entity\SitePermission; +use Omeka\Mvc\Controller\Plugin\Logger; use Omeka\Settings\UserSettings; /** @@ -31,10 +32,22 @@ class SitePermissionManager */ private UserSettings $userSettings; - public function __construct(EntityManager $entityManager, UserSettings $userSettings) + /** + * @var Logger + */ + protected $logger; + + /** + * @param EntityManager $entityManager + * @param UserSettings $userSettings + * @param Logger $logger + */ + + public function __construct(EntityManager $entityManager, UserSettings $userSettings, Logger $logger) { $this->entityManager = $entityManager; $this->userSettings = $userSettings; + $this->logger = $logger; } /** @@ -61,9 +74,15 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $em->refresh($teamUser); $user = $teamUser->getUser(); - $omekaRole = $teamUser->getRole()->getCanAddSitePages() + $omekaSiteRole = $teamUser->getRole()->getCanAddSitePages() ? SitePermission::ROLE_ADMIN : SitePermission::ROLE_VIEWER; + $this->logger->info(sprintf( + 'Syncing Omeka site permissions for user %d in team %d: setting role %s for all team sites.', + $userId, + $teamId, + $omekaSiteRole + )); $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['team' => $teamId]); @@ -75,7 +94,7 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $existingPermission = $sitePermissions->matching($criteria)->first(); if ($existingPermission) { - $existingPermission->setRole($omekaRole); + $existingPermission->setRole($omekaSiteRole); } else { $sitePermission = new SitePermission(); $sitePermission->setSite($site); diff --git a/src/Service/SitePermissionManagerFactory.php b/src/Service/SitePermissionManagerFactory.php index 8aeff254..704bf316 100644 --- a/src/Service/SitePermissionManagerFactory.php +++ b/src/Service/SitePermissionManagerFactory.php @@ -6,11 +6,21 @@ class SitePermissionManagerFactory implements FactoryInterface { + /** + * + * @param ContainerInterface $container + * @param $requestedName + * @param array|null $options + * @return SitePermissionManager + */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { + $logger = $container->get('Omeka\Logger'); + return new SitePermissionManager( $container->get('Omeka\EntityManager'), - $container->get('Omeka\Settings\User') + $container->get('Omeka\Settings\User'), + $container->get('Omeka\Logger') ); } } From 29682df05e2a9f386eb248f8de90a0b0c6b27310 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:50:02 +0000 Subject: [PATCH 19/54] Add detailed debug logging and fix $omekaRole typo in SitePermissionManager Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Service/SitePermissionManager.php | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 3eaec264..6cb37528 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -74,13 +74,18 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $em->refresh($teamUser); $user = $teamUser->getUser(); - $omekaSiteRole = $teamUser->getRole()->getCanAddSitePages() + $teamRole = $teamUser->getRole(); + $canAddSitePages = $teamRole->getCanAddSitePages(); + $omekaSiteRole = $canAddSitePages ? SitePermission::ROLE_ADMIN : SitePermission::ROLE_VIEWER; $this->logger->info(sprintf( - 'Syncing Omeka site permissions for user %d in team %d: setting role %s for all team sites.', + '[SitePermissionManager] syncSitePermissionsForUser: userId=%d, teamId=%d, teamRoleId=%d, teamRoleName="%s", canAddSitePages=%s => omekaSiteRole="%s"', $userId, $teamId, + $teamRole->getId(), + $teamRole->getName(), + $canAddSitePages ? 'true' : 'false', $omekaSiteRole )); @@ -94,12 +99,25 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $existingPermission = $sitePermissions->matching($criteria)->first(); if ($existingPermission) { + $this->logger->info(sprintf( + '[SitePermissionManager] siteId=%d: updating existing SitePermission from "%s" to "%s" for userId=%d', + $site->getId(), + $existingPermission->getRole(), + $omekaSiteRole, + $userId + )); $existingPermission->setRole($omekaSiteRole); } else { + $this->logger->info(sprintf( + '[SitePermissionManager] siteId=%d: creating new SitePermission with role "%s" for userId=%d', + $site->getId(), + $omekaSiteRole, + $userId + )); $sitePermission = new SitePermission(); $sitePermission->setSite($site); $sitePermission->setUser($user); - $sitePermission->setRole($omekaRole); + $sitePermission->setRole($omekaSiteRole); $em->persist($sitePermission); $sitePermissions->add($sitePermission); } From a060432b3b0b832de938c788b6d671e215fa89f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:50:53 +0000 Subject: [PATCH 20/54] Fix Logger type hint: use Laminas\Log\Logger instead of controller plugin Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Service/SitePermissionManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 6cb37528..65de0e3c 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -4,7 +4,7 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\EntityManager; use Omeka\Entity\SitePermission; -use Omeka\Mvc\Controller\Plugin\Logger; +use Laminas\Log\Logger; use Omeka\Settings\UserSettings; /** From 759efcc21ad9f74594589be2344073d534e78ba4 Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 21:00:00 -0400 Subject: [PATCH 21/54] chore: detailed logging --- src/Service/SitePermissionManager.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 65de0e3c..1979803a 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -76,17 +76,19 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $user = $teamUser->getUser(); $teamRole = $teamUser->getRole(); $canAddSitePages = $teamRole->getCanAddSitePages(); + $rawCanAddSitePages = $teamRole->getRawCanAddSitePages(); $omekaSiteRole = $canAddSitePages ? SitePermission::ROLE_ADMIN : SitePermission::ROLE_VIEWER; $this->logger->info(sprintf( - '[SitePermissionManager] syncSitePermissionsForUser: userId=%d, teamId=%d, teamRoleId=%d, teamRoleName="%s", canAddSitePages=%s => omekaSiteRole="%s"', + '[SitePermissionManager] syncSitePermissionsForUser: userId=%d, teamId=%d, teamRoleId=%d, teamRoleName="%s", canAddSitePages=%s => omekaSiteRole="%s", rawCanAddSitePages=%s', $userId, $teamId, $teamRole->getId(), $teamRole->getName(), $canAddSitePages ? 'true' : 'false', - $omekaSiteRole + $omekaSiteRole, + $rawCanAddSitePages )); $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['team' => $teamId]); From 9a0a4e8009813ac664bf570e5a41ea4af4f5612a Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 21:02:18 -0400 Subject: [PATCH 22/54] fix: typo --- src/Service/SitePermissionManager.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 1979803a..fd1cb150 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -76,7 +76,6 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $user = $teamUser->getUser(); $teamRole = $teamUser->getRole(); $canAddSitePages = $teamRole->getCanAddSitePages(); - $rawCanAddSitePages = $teamRole->getRawCanAddSitePages(); $omekaSiteRole = $canAddSitePages ? SitePermission::ROLE_ADMIN : SitePermission::ROLE_VIEWER; @@ -88,7 +87,7 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $teamRole->getName(), $canAddSitePages ? 'true' : 'false', $omekaSiteRole, - $rawCanAddSitePages + $canAddSitePages )); $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['team' => $teamId]); From ff455a5c3bc6ebc8291e10fb6c005ac6762392be Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 21:03:02 -0400 Subject: [PATCH 23/54] fix: typo --- src/Service/SitePermissionManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index fd1cb150..c5b7405b 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -79,7 +79,7 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $omekaSiteRole = $canAddSitePages ? SitePermission::ROLE_ADMIN : SitePermission::ROLE_VIEWER; - $this->logger->info(sprintf( + $this->logger->err(sprintf( '[SitePermissionManager] syncSitePermissionsForUser: userId=%d, teamId=%d, teamRoleId=%d, teamRoleName="%s", canAddSitePages=%s => omekaSiteRole="%s", rawCanAddSitePages=%s', $userId, $teamId, From 5fcd49ad7ca2551247396755d0622371826679d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:22:25 +0000 Subject: [PATCH 24/54] Fix partial DQL polluting TeamRole identity map with incomplete entities Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Controller/UpdateController.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 5375ed5e..fc32c9e3 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -232,9 +232,14 @@ public function teamUpdateAction() $available_u_array = array_diff($all_u_array, $team_u_array); //TODO (refactor) was trying to see if there was an easier way to get these objects into an array but consistency is more important - $role_query = $this->entityManager->createQuery('select partial r.{id, name} from Teams\Entity\TeamRole r'); - $roles = $role_query->getResult(); - $roles_array = $role_query->getResult(\Doctrine\ORM\Query::HYDRATE_ARRAY); + // Use HYDRATE_ARRAY so that TeamRole entities are not partially loaded + // into the Doctrine identity map. A partial select (partial r.{id, name}) + // caches TeamRole objects with only id/name populated; any later access + // via lazy-loading (e.g. getCanAddSitePages()) then returns the empty + // default instead of the real database value. + $role_query = $this->entityManager->createQuery('select r from Teams\Entity\TeamRole r'); + $roles_array = $role_query->getResult(\Doctrine\ORM\Query::HYDRATE_ARRAY); + $roles = $roles_array; //create an array object to hold the contents to pre-fill the form with //TODO (emulate) this is the procedure to use to populate forms. Copy this. From 0980830d73f2cfd5f7e21a873b3246a73d2ea3ab Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Mon, 10 Aug 2026 21:58:11 -0400 Subject: [PATCH 25/54] refactor: old, weird code with api calls mostly --- src/Controller/UpdateController.php | 120 +--------------------------- 1 file changed, 3 insertions(+), 117 deletions(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index fc32c9e3..a58665c4 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -185,32 +185,7 @@ public function teamUpdateAction() $user = $this->identity(); //TODO rename this to TeamDetail form or find a way to string these all together $teamDetailsForm = $this->getForm(TeamDetailsForm::class); - - //TODO: get team with a one line entity manager call - $criteria = ['id' => $team_id]; - $qb = $this->entityManager->createQueryBuilder(); - $entityClass = 'Teams\Entity\Team'; - - $qb->select('omeka_root')->from($entityClass, 'omeka_root'); - foreach ($criteria as $field => $value) { - $qb->andWhere($qb->expr()->eq( - "omeka_root.$field", - $this->createNamedParameter($qb, $value) - )); - } - $qb->setMaxResults(1); - - $entity = $qb->getQuery()->getOneOrNullResult(); - - $data = $this->api()->read('team', ['id'=>$team_id])->getContent(); - $request = new Request('update', 'team'); - $event = new Event('api.hydrate.pre', $this, [ - 'entity' => $entity, - 'request' => $request, - ]); - $this->getEventManager()->triggerEvent($event); - //TODO (refactor) this is probably a stupid way to do this @@ -231,15 +206,7 @@ public function teamUpdateAction() //get the users available to be added to the team $available_u_array = array_diff($all_u_array, $team_u_array); - //TODO (refactor) was trying to see if there was an easier way to get these objects into an array but consistency is more important - // Use HYDRATE_ARRAY so that TeamRole entities are not partially loaded - // into the Doctrine identity map. A partial select (partial r.{id, name}) - // caches TeamRole objects with only id/name populated; any later access - // via lazy-loading (e.g. getCanAddSitePages()) then returns the empty - // default instead of the real database value. - $role_query = $this->entityManager->createQuery('select r from Teams\Entity\TeamRole r'); - $roles_array = $role_query->getResult(\Doctrine\ORM\Query::HYDRATE_ARRAY); - $roles = $roles_array; + $roles = $this->api()->search('team-role')->getContent(); //create an array object to hold the contents to pre-fill the form with //TODO (emulate) this is the procedure to use to populate forms. Copy this. @@ -285,17 +252,7 @@ public function teamUpdateAction() $this->messenger()->addError("You aren't authorized to change the team details"); return $view; } else { - //first update the team name and description - $qb = $this->entityManager->createQueryBuilder(); - $qb->update('Teams\Entity\Team', 'team') - ->set('team.name', '?1') - ->set('team.description', '?2') - ->where('team.id = ?3') - ->setParameter(1, $post_data['o:name']) - ->setParameter(2, $post_data['o:description']) - ->setParameter(3, $team_id) - ->getQuery() - ->execute(); + $this->api()->update('team', $team_id, ['o:name' => $post_data['o:name'], 'o:description' => $post_data['o:description']]); } if (!$this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $team_id)) { $this->messenger()->addError("You aren't authorized to change team members"); @@ -320,8 +277,7 @@ public function teamUpdateAction() $teamUserExists = $this->api()->search('team-user', ['team'=>$team_id, 'user'=>$teamUser['o:user']['o:id']])->getContent(); if ($teamUserExists){ - $role = $this->api()->read('team-role',['id'=>$teamUser['o:team_role']['o:id']])->getContent(); - $teamUserExists[0]->getEntity()->setRole($role->getEntity()); + $this->api()->update('team-user', ['team' => $team_id, 'user' => $teamUser['o:user']['o:id']], ['role' => $teamUser['o:team_role']['o:id']]); } else { $this->api() ->create('team-user', @@ -431,74 +387,4 @@ public function teamUpdateAction() return $this->redirect()->toRoute('admin/teams/detail',['id'=>$team_id]); } - - public function userAction() - { - $request = $this->getRequest(); - if ($request->isPost()) { - $data = $request->getPost(); - $user_teams = $data['user-information']['o-module-teams:Team']; - //wrong!! not really able to get from the param, would need to extract from the return url - $user_id = $this->params('id'); - $em = $this->entityManager; - - foreach ($user_teams as $team_id): - $team = $em->getRepository('Teams\Entity\Team')->findOneBy(['id' => $team_id]); - $user = $em->getRepository('Omeka\Entity\User')->findOneBy(['id'=>$user_id]); - $role = $em->getRepository('Teams\Entity\TeamRole')->findOneBy(['id' => 1]); - $team_user = new TeamUser($team, $user, $role); - $team_user->setCurrent(null); - $em->persist($team_user); - endforeach; - $em->flush(); - $request = $this->getRequest(); - $return = $request->getHeader('referer'); -// return $this->redirect()->toUrl($data['return_url']); - return $this->redirect()->toUrl($return); - } - } - - public function currentTeamAction() - { - $user_id = $this->identity()->getId(); - $request = $this->getRequest(); - - if ($request->isPost()) { - $data = $request->getPost(); - - $em = $this->entityManager; - $team_user = $em->getRepository('Teams\Entity\TeamUser'); - $old_current = $team_user->findOneBy(['user' => $user_id, 'is_current' => 1]); - $new_current = $team_user->findOneBy(['user'=> $user_id, 'team'=>$data['team_id']]); - - if ($old_current) { - $old_current->setCurrent(null); - $em->flush(); - } - if ($new_current) { - $new_current->setCurrent(true); - $em->flush(); - $team = $new_current->getTeam(); - - //the sites for the team the user just switched to - - $team_sites = $team->getTeamSites(); - $site_ids = []; - foreach ($team_sites as $team_site): - $site_ids[] = strval($team_site->getSite()->getId()); - endforeach; - - //update so those are the user's default sites for items - $settingId = 'default_item_sites'; - $settingValue = $site_ids; - $this->userSettings()->set($settingId, $settingValue, $user_id); - } else { - $this->messenger()->addError("Team not found"); - } - - - - return $this->redirect()->toUrl($data['return_url']); - } - } } From 75e6cd9c0b3e9696eba93b207faa9eb93dab0e09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:16:59 +0000 Subject: [PATCH 26/54] Refactor controllers to use API; clean up dead imports and dead code Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Api/Adapter/AbstractTeamEntityAdapter.php | 2 +- src/Api/Adapter/TeamAdapter.php | 2 +- src/Api/Adapter/TeamRoleAdapter.php | 4 +- src/Api/Adapter/TeamSiteAdapter.php | 2 +- src/Api/Adapter/TeamUserAdapter.php | 19 ++- src/Controller/AddController.php | 19 +-- src/Controller/DeleteController.php | 31 +--- src/Controller/IndexController.php | 58 +------ src/Controller/UpdateController.php | 157 ++++++++---------- 9 files changed, 110 insertions(+), 184 deletions(-) diff --git a/src/Api/Adapter/AbstractTeamEntityAdapter.php b/src/Api/Adapter/AbstractTeamEntityAdapter.php index 5974b328..1daa6d34 100644 --- a/src/Api/Adapter/AbstractTeamEntityAdapter.php +++ b/src/Api/Adapter/AbstractTeamEntityAdapter.php @@ -301,7 +301,7 @@ public function batchDelete(Request $request) public function update(Request $request) { - AbstractAdapter::update($request); + return parent::update($request); } public function batchUpdate(Request $request) diff --git a/src/Api/Adapter/TeamAdapter.php b/src/Api/Adapter/TeamAdapter.php index 9918de6d..5715f295 100644 --- a/src/Api/Adapter/TeamAdapter.php +++ b/src/Api/Adapter/TeamAdapter.php @@ -183,7 +183,7 @@ public function batchCreate(Request $request) public function update(Request $request) { - AbstractAdapter::batchCreate($request); + return parent::update($request); } public function batchUpdate(Request $request) diff --git a/src/Api/Adapter/TeamRoleAdapter.php b/src/Api/Adapter/TeamRoleAdapter.php index 6702a03d..69c12f4d 100644 --- a/src/Api/Adapter/TeamRoleAdapter.php +++ b/src/Api/Adapter/TeamRoleAdapter.php @@ -224,7 +224,7 @@ public function batchCreate(Request $request) public function update(Request $request) { - AbstractAdapter::batchCreate($request); + return parent::update($request); } public function batchUpdate(Request $request) @@ -234,7 +234,7 @@ public function batchUpdate(Request $request) public function delete(Request $request) { - AbstractAdapter::delete($request); + return parent::delete($request); } public function batchDelete(Request $request) diff --git a/src/Api/Adapter/TeamSiteAdapter.php b/src/Api/Adapter/TeamSiteAdapter.php index 1417087e..cfbbd26a 100644 --- a/src/Api/Adapter/TeamSiteAdapter.php +++ b/src/Api/Adapter/TeamSiteAdapter.php @@ -120,7 +120,7 @@ public function batchCreate(Request $request) public function update(Request $request) { - AbstractTeamEntityAdapter::batchCreate($request); + return parent::update($request); } public function batchUpdate(Request $request) diff --git a/src/Api/Adapter/TeamUserAdapter.php b/src/Api/Adapter/TeamUserAdapter.php index afb0f491..6bf1a8d0 100644 --- a/src/Api/Adapter/TeamUserAdapter.php +++ b/src/Api/Adapter/TeamUserAdapter.php @@ -68,11 +68,18 @@ public function hydrate( } } - if ($this->shouldHydrate($request, 'o:role')) { - $role = $request->getValue('o:role'); - if (!is_null($role)) { - $role = trim($role); - $entity->setRole($role); + // Accept 'role' (plain key used by update) or 'o:role' (JSON-LD key). + // Both must resolve to a TeamRole entity before setting. + $roleId = null; + if ($this->shouldHydrate($request, 'role')) { + $roleId = $request->getValue('role'); + } elseif ($this->shouldHydrate($request, 'o:role')) { + $roleId = $request->getValue('o:role'); + } + if (!is_null($roleId)) { + $roleEntity = $this->getEntityManager()->find('Teams\Entity\TeamRole', (int) $roleId); + if ($roleEntity) { + $entity->setRole($roleEntity); } } @@ -184,7 +191,7 @@ public function batchCreate(Request $request) public function update(Request $request) { - AbstractAdapter::batchCreate($request); + return parent::update($request); } public function batchUpdate(Request $request) diff --git a/src/Controller/AddController.php b/src/Controller/AddController.php index 6c7bc523..e7dc122a 100644 --- a/src/Controller/AddController.php +++ b/src/Controller/AddController.php @@ -12,7 +12,6 @@ use Teams\Entity\TeamResource; use Teams\Entity\TeamResourceTemplate; use Teams\Entity\TeamSite; -use Teams\Entity\TeamUser; use Teams\Form\SecondaryResourcesForm; use Teams\Form\TeamItemSetForm; use Teams\Form\TeamResourcesForm; @@ -85,17 +84,13 @@ public function teamAddAction() $teamEntity = $this->entityManager->getRepository('Teams\Entity\Team') ->findOneBy(['id' => (int)$newTeam->getContent()->id()]); if ($request->getPost('o:team_users')) { - foreach ($request->getPost('o:team_users') as $team_user): - $user = $this->entityManager->getRepository('Omeka\Entity\User') - ->findOneBy(['id' => (int)$team_user['o:user']['o:id']]); - $role = $this->entityManager->getRepository('Teams\Entity\TeamRole') - ->findOneBy(['id' => (int)$team_user['o:team_role']['o:id']]); - - $teamUser = new TeamUser($teamEntity, $user, $role); - $teamUser->setCurrent(null); - $this->entityManager->persist($teamUser); - endforeach; - $this->entityManager->flush(); + foreach ($request->getPost('o:team_users') as $team_user) { + $this->api()->create('team-user', [ + 'team' => $newTeam->getContent()->id(), + 'user' => (int) $team_user['o:user']['o:id'], + 'role' => (int) $team_user['o:team_role']['o:id'], + ]); + } } //persist the sites (no possibility of duplicates, so don't need to save to associative array) diff --git a/src/Controller/DeleteController.php b/src/Controller/DeleteController.php index dd7040d5..b933dbb6 100644 --- a/src/Controller/DeleteController.php +++ b/src/Controller/DeleteController.php @@ -2,10 +2,7 @@ namespace Teams\Controller; use Doctrine\ORM\EntityManager; -use Doctrine\ORM\QueryBuilder; use Omeka\Api\Exception\InvalidArgumentException; -use Omeka\Api\Request; -use Laminas\EventManager\Event; use Laminas\Mvc\Controller\AbstractActionController; use Laminas\View\Model\ViewModel; @@ -23,17 +20,6 @@ public function __construct(EntityManager $entityManager) { $this->entityManager = $entityManager; } - public function createNamedParameter( - QueryBuilder $qb, - $value, - $prefix = 'omeka_' - ) { - $index = 0; - $placeholder = $prefix . $index; - $index++; - $qb->setParameter($placeholder, $value); - return ":$placeholder"; - } public function teamDeleteAction() { //is there an id? @@ -75,13 +61,10 @@ public function roleDeleteAction() { $user = $this->identity()->getRole(); $id = $this->params()->fromRoute('id'); - $role = $this->entityManager->getRepository('Teams\Entity\TeamRole') - ->findOneBy(['id'=> $id]); $request = $this->getRequest(); - //test to see if anyone has this role. If they do, don't delete it. $role_users = $this->entityManager->getRepository('Teams\Entity\TeamUser') - ->findBy(['role'=>$id]); + ->findBy(['role' => $id]); $view = new ViewModel( [ 'role_users' => $role_users, @@ -96,17 +79,17 @@ public function roleDeleteAction() $this->messenger()->addError('You are not authorized to delete roles'); return $view; } - if ($role_users){ + if ($role_users) { $this->messenger()->addError('This role can not be deleted while users are assigned to it'); return $view; } if ($request->getPost('confirm') == 'Delete') { - $this->entityManager->remove($role); - $this->entityManager->flush(); - $this->messenger()->addSuccess(sprintf('Successfully deleted role "%s"', $role->getName())); + $role = $this->entityManager->getRepository('Teams\Entity\TeamRole') + ->findOneBy(['id' => $id]); + $roleName = $role ? $role->getName() : $id; + $this->api()->delete('team-role', $id); + $this->messenger()->addSuccess(sprintf('Successfully deleted role "%s"', $roleName)); } return $this->redirect()->toRoute('admin/teams/roles'); - - } } diff --git a/src/Controller/IndexController.php b/src/Controller/IndexController.php index ac3174b4..181d4b5a 100644 --- a/src/Controller/IndexController.php +++ b/src/Controller/IndexController.php @@ -2,7 +2,6 @@ namespace Teams\Controller; use Doctrine\ORM\EntityManager; -use Omeka\Api\Request; use Omeka\Form\ConfirmForm; use Laminas\EventManager\Event; use Laminas\Form\Form; @@ -160,58 +159,19 @@ public function deleteAction() $form = $this->getForm(ConfirmForm::class); $form->setData($this->getRequest()->getPost()); if ($form->isValid()) { - $entityManager = $this->entityManager; $user_id = $this->identity()->getId(); - $team_id = $entityManager + $team_id = $this->entityManager ->getRepository('Teams\Entity\TeamUser') - ->findOneBy(['is_current'=>true, 'user'=>$user_id]) + ->findOneBy(['is_current' => true, 'user' => $user_id]) ->getTeam()->getId(); - //array of media ids - $media_ids = []; - - //date to update last modified - $datetime = new \DateTime('now'); - foreach ($this->api()->read('items', $this->params('id'))->getContent()->media() as $media): - $media_ids[] = $media->id(); - endforeach; - - $entity = $entityManager - ->getRepository('Teams\Entity\TeamResource') - ->findOneBy(['team'=>$team_id, 'resource'=> $this->params('id')]); - - $request = new Request('delete', 'team_resource'); - $event = new Event('api.hydrate.pre', $this, [ - 'entity' => $entity, - 'request' => $request, - ]); - $this->getEventManager()->triggerEvent($event); - if ($entity) { - $entity->getResource()->setModified($datetime); - $entityManager->remove($entity); - - //remove associated media from the team - foreach ($media_ids as $media_id): - $tr = $entityManager->getRepository('Teams\Entity\TeamResource') - ->findOneBy(['team' => $team_id, 'resource' => $media_id]); - if ($tr) { - $entityManager->remove($tr); - $this->messenger()->addSuccess('Associated Media successfully removed from your team.'); // @translate - $entityManager->getRepository('Omeka\Entity\Resource') - ->findOneBy(['id'=>$media_id])->setModified($datetime); - } - endforeach; - $entityManager->flush(); - $this->messenger()->addSuccess('Item successfully removed from your team. Item remains available to other teams if they are linked to it.'); // @translate - } else { - $this->messenger()->addError('something went wrong'); // @translate - } - $event = new Event('api.execute.post', $this, [ - 'entity' => $entity, - 'request' => $request, - ]); - $this->getEventManager()->triggerEvent($event); - + $this->api()->delete( + 'team-resource', + [], + ['team' => $team_id, 'resource' => $this->params('id')], + ['recursive' => true] + ); + $this->messenger()->addSuccess('Item successfully removed from your team. Item remains available to other teams if they are linked to it.'); // @translate } else { $this->messenger()->addFormErrors($form); } diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index a58665c4..94d1a218 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -5,7 +5,6 @@ use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\OptimisticLockException; use Doctrine\ORM\ORMException; -use Doctrine\ORM\QueryBuilder; use Omeka\Api\Request; use Teams\Entity\TeamSite; use Teams\Entity\TeamUser; @@ -43,91 +42,6 @@ public function __construct(EntityManager $entityManager, SitePermissionManager $this->sitePermissionManager = $sitePermissionManager; } - public function createNamedParameter( - QueryBuilder $qb, - $value, - $prefix = 'omeka_' - ) { - $index = 0; - $placeholder = $prefix . $index; - $index++; - $qb->setParameter($placeholder, $value); - return ":$placeholder"; - } - - public function addTeamUser(int $team_id, int $user_id, int $role_id) - { - if (! $this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team', $team_id)){ - $this->messenger()->addError("You aren't authorized to change this team"); - return null; - } else { - $team = $this->entityManager->find('Teams\Entity\Team', $team_id); - $user = $this->entityManager->find('Omeka\Entity\User', $user_id); - $role = $this->entityManager->find('Teams\Entity\TeamRole', $role_id); - $team_user = new TeamUser($team, $user, $role); - $this->entityManager->persist($team_user); - - //flushing here because this is a mini-form and we want to see the name pop up - //more efficient solution would be to have JS handle the popping and batch update - $this->entityManager->flush(); - return $team_user; - } - } - - public function removeTeamUser(int $team_id, int $user) - { - if (! $this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team', $team_id)){ - $this->messenger()->addError("You aren't authorized to change this team"); - } else { - $this->messenger()->addError("removed user"); - - $em = $this->entityManager; - $team_user = $em->find('Teams\Entity\TeamUser', ['team' => $team_id, 'user' => $user]); - $em->remove($team_user); - - //flushing here because this is a mini-form and we want to see the name pop up - //more efficient solution would be to have JS handle the popping and batch update - $em->flush(); - } - } - - public function updateRole(int $team_id, int $user_id, int $role_id) - { - if (! $this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team', $team_id)){ - $this->messenger()->addError("You aren't authorized to change this team"); - } else { - $em = $this->entityManager; - $team_user = $em->find('Teams\Entity\TeamUser', ['team' => $team_id, 'user'=>$user_id]); - $user_role = $em->find('Teams\Entity\TeamRole', $role_id); - $team_user->setRole($user_role); - $em->flush(); - } - - } - - public function processItemSets(int $item_set_id) - { - $resource_array = array(); - if ((int)$item_set_id>0) { - $item_set_id = (int)$item_set_id; - - //TODO: why isn't this a list? - //add all items belonging to itemset - foreach ($this->api()->search('items', ['item_set_id'=>$item_set_id, 'bypass_team_filter' => true])->getContent() as $item): - $resource_array += [$item->id() => true]; - - //add all media belonging to to the item - foreach ($this->api()->search('media', ['item_id'=>$item->id(), 'bypass_team_filter' => true])->getContent() as $media): - $resource_array += [$media->id()=>true]; - endforeach; - endforeach; - } - //add itemset itself - $resource_array += [$item_set_id => true]; - - return $resource_array; - } - /** * @throws ORMException * @throws OptimisticLockException @@ -206,7 +120,14 @@ public function teamUpdateAction() //get the users available to be added to the team $available_u_array = array_diff($all_u_array, $team_u_array); - $roles = $this->api()->search('team-role')->getContent(); + // Build a name => id map for the view's role selector. + // Using api()->search() avoids loading partial entities into the Doctrine + // identity map, which was the root cause of the canAddSitePages=false bug. + $roleRepresentations = $this->api()->search('team-role')->getContent(); + $roles = []; + foreach ($roleRepresentations as $roleRep) { + $roles[$roleRep->name()] = $roleRep->id(); + } //create an array object to hold the contents to pre-fill the form with //TODO (emulate) this is the procedure to use to populate forms. Copy this. @@ -233,7 +154,6 @@ public function teamUpdateAction() 'bypassTeamFilterRoles' => $bypass_team_filter_roles, 'id' => $team_id, 'roles'=> $roles, - 'roles_array' => $roles_array, 'all_u_collection' => $all_u_collection, 'team_u_collection' => $team_u_collection, 'team_u_array'=>$team_u_array, @@ -387,4 +307,65 @@ public function teamUpdateAction() return $this->redirect()->toRoute('admin/teams/detail',['id'=>$team_id]); } + + /** + * Adds a user to teams via the user-edit form. + * + * TODO: Role id=1 is hardcoded here. Should be made configurable or + * resolved to a named default role rather than relying on a magic integer. + */ + public function userAction() + { + $request = $this->getRequest(); + if ($request->isPost()) { + $data = $request->getPost(); + $user_teams = $data['user-information']['o-module-teams:Team']; + $user_id = $this->params('id'); + $em = $this->entityManager; + + foreach ($user_teams as $team_id): + $team = $em->getRepository('Teams\Entity\Team')->findOneBy(['id' => $team_id]); + $user = $em->getRepository('Omeka\Entity\User')->findOneBy(['id' => $user_id]); + // TODO: resolve a proper default role instead of hardcoding id=1 + $role = $em->getRepository('Teams\Entity\TeamRole')->findOneBy(['id' => 1]); + $team_user = new TeamUser($team, $user, $role); + $team_user->setCurrent(null); + $em->persist($team_user); + endforeach; + $em->flush(); + return $this->redirect()->toUrl($request->getHeader('referer')); + } + } + + /** + * Switches the user's active team and updates their default item sites. + */ + public function currentTeamAction() + { + $user_id = $this->identity()->getId(); + $request = $this->getRequest(); + + if ($request->isPost()) { + $data = $request->getPost(); + $em = $this->entityManager; + $teamUserRepo = $em->getRepository('Teams\Entity\TeamUser'); + + $old_current = $teamUserRepo->findOneBy(['user' => $user_id, 'is_current' => 1]); + $new_current = $teamUserRepo->findOneBy(['user' => $user_id, 'team' => $data['team_id']]); + + if ($old_current) { + $old_current->setCurrent(null); + $em->flush(); + } + if ($new_current) { + $new_current->setCurrent(true); + $em->flush(); + $this->sitePermissionManager->updateUserDefaultSites($user_id); + } else { + $this->messenger()->addError("Team not found"); + } + + return $this->redirect()->toUrl($data['return_url']); + } + } } From 84dfaa5121f4a3d8f84befc7a0075259f9808379 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:18:57 +0000 Subject: [PATCH 27/54] Fix review issues: logging level, unused var, XSS protection in inline scripts Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Service/SitePermissionManager.php | 2 +- src/Service/SitePermissionManagerFactory.php | 2 -- view/teams/partial/resource-template/edit.phtml | 2 +- view/teams/partial/site-admin/edit.phtml | 2 +- view/teams/partial/site-admin/users-teams-info.phtml | 2 +- view/teams/partial/team/add-user-and-role.phtml | 4 ++-- view/teams/partial/user/edit.phtml | 4 ++-- 7 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index c5b7405b..fd1cb150 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -79,7 +79,7 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void $omekaSiteRole = $canAddSitePages ? SitePermission::ROLE_ADMIN : SitePermission::ROLE_VIEWER; - $this->logger->err(sprintf( + $this->logger->info(sprintf( '[SitePermissionManager] syncSitePermissionsForUser: userId=%d, teamId=%d, teamRoleId=%d, teamRoleName="%s", canAddSitePages=%s => omekaSiteRole="%s", rawCanAddSitePages=%s', $userId, $teamId, diff --git a/src/Service/SitePermissionManagerFactory.php b/src/Service/SitePermissionManagerFactory.php index 704bf316..e22ddb5c 100644 --- a/src/Service/SitePermissionManagerFactory.php +++ b/src/Service/SitePermissionManagerFactory.php @@ -15,8 +15,6 @@ class SitePermissionManagerFactory implements FactoryInterface */ public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { - $logger = $container->get('Omeka\Logger'); - return new SitePermissionManager( $container->get('Omeka\EntityManager'), $container->get('Omeka\Settings\User'), diff --git a/view/teams/partial/resource-template/edit.phtml b/view/teams/partial/resource-template/edit.phtml index b62f88fb..34be274a 100644 --- a/view/teams/partial/resource-template/edit.phtml +++ b/view/teams/partial/resource-template/edit.phtml @@ -12,7 +12,7 @@ endforeach; window.addEventListener("load", function () { - let current_teams = ; + let current_teams = ; let select = document.getElementById('team'); diff --git a/view/teams/partial/site-admin/edit.phtml b/view/teams/partial/site-admin/edit.phtml index 8f4face4..a2bbcf60 100644 --- a/view/teams/partial/site-admin/edit.phtml +++ b/view/teams/partial/site-admin/edit.phtml @@ -3,7 +3,7 @@ window.addEventListener("load", function () { - let current_teams = team_ids)?>; + let current_teams = team_ids, JSON_HEX_TAG | JSON_HEX_AMP)?>; let select = document.getElementById('team_selected'); diff --git a/view/teams/partial/site-admin/users-teams-info.phtml b/view/teams/partial/site-admin/users-teams-info.phtml index e5c23eff..43f5f756 100644 --- a/view/teams/partial/site-admin/users-teams-info.phtml +++ b/view/teams/partial/site-admin/users-teams-info.phtml @@ -11,7 +11,7 @@ $translate = $this->plugin('translate'); ?> +if (empty($teamManagedUsers)) { + return; +} +?> +
+

+

+ + + + + + + + + $teams): ?> + + + + + + +
escapeHtml(implode(', ', $teams)); ?>
+
From c7b7a24104f249028f0e3f2204ec4cc5789cf4cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:35:21 +0000 Subject: [PATCH 30/54] fix: role edits, team-site domain error, null sites crash, and users page team info - manage-users.phtml: load Teams' own team-users.js instead of Omeka's site-users.js, which hard-codes o:site_permission field names and never updates the __index__ placeholder for o:team_users fields, causing role changes to be silently discarded on save. - TeamAuth.php: normalize 'team-site' and 'team_site' to the 'site' domain before validation, matching the existing pattern for resource domains. Without this, creating a team-site threw InvalidArgumentException. - UpdateController.php: guard the teamSites post value with ?? [] so that removing all sites from a team no longer crashes with a null-haystack TypeError on line 241. - view/omeka/site-admin/index/users.phtml: override the Omeka core template (copied from commit e107b1c1d993ea93f10b587330b1f3e1b88aebcc) to inject a 'Managed by Team(s)' column into each user row and add a warning banner, replacing the previous approach that attached to view.edit.after (the site details page) instead of the user-permissions page. - Module.php: remove the now-superseded view.edit.after / siteUsersTeamsInfo event attachment that was incorrectly rendering team info on the site details page rather than the user-permissions page. Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- Module.php | 6 -- src/Controller/UpdateController.php | 2 +- src/Mvc/Controller/Plugin/TeamAuth.php | 7 +- view/omeka/site-admin/index/users.phtml | 123 ++++++++++++++++++++++++ view/teams/team/manage-users.phtml | 2 +- 5 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 view/omeka/site-admin/index/users.phtml diff --git a/Module.php b/Module.php index 758a2520..db99db96 100644 --- a/Module.php +++ b/Module.php @@ -2578,12 +2578,6 @@ public function attachListeners(SharedEventManagerInterface $sharedEventManager) [$this, 'siteEdit'] ); - $sharedEventManager->attach( - 'Omeka\Controller\SiteAdmin\Index', - 'view.edit.after', - [$this, 'siteUsersTeamsInfo'] - ); - //put the roles data in the user page $sharedEventManager->attach( diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 6968c659..788eafd3 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -229,7 +229,7 @@ public function teamUpdateAction() } //handle new sites - $postSites = $post_data['teamSites']['o:site']; + $postSites = $post_data['teamSites']['o:site'] ?? []; foreach ($postSites as $site_id) { if (!in_array($site_id, $current_sites)) { $this->api()->create('team-site', ['team' => $team_id, 'site' => $site_id]); diff --git a/src/Mvc/Controller/Plugin/TeamAuth.php b/src/Mvc/Controller/Plugin/TeamAuth.php index 8074575d..8de8775a 100644 --- a/src/Mvc/Controller/Plugin/TeamAuth.php +++ b/src/Mvc/Controller/Plugin/TeamAuth.php @@ -59,10 +59,15 @@ public function teamAuthorized(User $user, string $action, string $domain, int $ $action = 'add'; } $resourceDomains = ['team_resource','team_resource_template','team_asset','team-resource','team-resource-template','team-asset']; - if (in_array($domain,$resourceDomains)) { + if (in_array($domain, $resourceDomains)) { $domain = 'resource'; } + $siteDomains = ['team_site', 'team-site']; + if (in_array($domain, $siteDomains)) { + $domain = 'site'; + } + //validate inputs if (!in_array($action, $this->actions)) { throw new InvalidArgumentException( diff --git a/view/omeka/site-admin/index/users.phtml b/view/omeka/site-admin/index/users.phtml new file mode 100644 index 00000000..18ab0dad --- /dev/null +++ b/view/omeka/site-admin/index/users.phtml @@ -0,0 +1,123 @@ +plugin('translate'); +$this->headScript()->appendFile($this->assetUrl('js/site-users.js', 'Omeka')); +$this->htmlElement('body')->appendAttribute('class', 'sites users'); +$form->prepare(); +$escape = $this->plugin('escapeHtml'); +$delete = $translate('Delete'); +$restore = $translate('Restore'); +$roles = [ + 'viewer' => $translate('Viewer'), + 'editor' => $translate('Creator'), + 'admin' => $translate('Manager'), +]; + +// Build userId => [teamName, ...] map for the teams column. +$teamManagedUsers = []; +try { + $services = $this->getHelperPluginManager()->getServiceLocator(); + $entityManager = $services->get('Omeka\EntityManager'); + $teamSites = $entityManager->getRepository('Teams\Entity\TeamSite') + ->findBy(['site' => $site->id()]); + foreach ($teamSites as $teamSite) { + $team = $teamSite->getTeam(); + $teamUsers = $entityManager->getRepository('Teams\Entity\TeamUser') + ->findBy(['team' => $team->getId()]); + foreach ($teamUsers as $teamUser) { + $userId = $teamUser->getUser()->getId(); + $teamManagedUsers[$userId][] = $team->getName(); + } + } +} catch (\Exception $e) { + // If Teams module data is unavailable, silently omit the column. + $teamManagedUsers = null; +} + +$userRowTemplate = ' + + + '; +if ($teamManagedUsers !== null) { + $userRowTemplate .= ' + '; +} +$userRowTemplate .= ' + +
    +
  • ' . $this->hyperlink('', '#', ['class' => 'o-icon-delete', 'title' => $delete]) . '
  • +
+ + + +'; +$sitePermissions = $site->sitePermissions(); +$users = []; +foreach ($sitePermissions as $sitePermission) { + $user = $sitePermission->user(); + $entry = [ + 'id' => $user->id(), + 'role' => $sitePermission->role(), + ]; + if ($teamManagedUsers !== null) { + $entry['teams-managed'] = !empty($teamManagedUsers[$user->id()]) + ? implode(', ', array_unique($teamManagedUsers[$user->id()])) + : ''; + } + $users[] = $entry; +} +?> + +pageTitle($translate('User permissions'), 1, $translate('Sites')); ?> + + +
+
+
+ + +form()->openTag($form); ?> +formCollection($form, false); ?> + +
+ cancelButton(); ?> + +
+ + + + + + + + + + + + + +
+
+

+
+ +userSelector($translate('Click on a user to add them to the site.')); ?> + +form()->closeTag(); ?> + + diff --git a/view/teams/team/manage-users.phtml b/view/teams/team/manage-users.phtml index 49fb33d4..ece44be2 100644 --- a/view/teams/team/manage-users.phtml +++ b/view/teams/team/manage-users.phtml @@ -1,6 +1,6 @@ plugin('translate'); -$this->headScript()->appendFile($this->assetUrl('js/site-users.js', 'Omeka')); +$this->headScript()->appendFile($this->assetUrl('js/team-users.js', 'Teams')); $this->htmlElement('body')->appendAttribute('class', 'sites users'); $escape = $this->plugin('escapeHtml'); $delete = $translate('Delete'); From 36d2d89a45fbf13436a84851b1f9b3a5ec6a5e41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:37:04 +0000 Subject: [PATCH 31/54] fix: show warning banner only when teams actually manage users on the site Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- view/omeka/site-admin/index/users.phtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/view/omeka/site-admin/index/users.phtml b/view/omeka/site-admin/index/users.phtml index 18ab0dad..09426d71 100644 --- a/view/omeka/site-admin/index/users.phtml +++ b/view/omeka/site-admin/index/users.phtml @@ -81,7 +81,7 @@ foreach ($sitePermissions as $sitePermission) { pageTitle($translate('User permissions'), 1, $translate('Sites')); ?> - +
Date: Tue, 11 Aug 2026 19:56:58 +0000 Subject: [PATCH 32/54] fix: normalise team/user keys before validation in AbstractTeamEntityAdapter validateRequest was checking for 'o:team'/'o:user' keys before remapping the plain 'team'/'user' aliases, so every api()->update('team-user', ...) and api()->delete('team-user', ...) call from the controller threw a ValidationException and silently discarded the role change. Fix: normalise both keys first, then validate. Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Api/Adapter/AbstractTeamEntityAdapter.php | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/Api/Adapter/AbstractTeamEntityAdapter.php b/src/Api/Adapter/AbstractTeamEntityAdapter.php index a4056306..f3527fa0 100644 --- a/src/Api/Adapter/AbstractTeamEntityAdapter.php +++ b/src/Api/Adapter/AbstractTeamEntityAdapter.php @@ -174,22 +174,21 @@ public function validateRequest(Request $request, ErrorStore $errorStore) $errorStore->addError('o:id', new Message('The %s id must be an array.', $this->getResourceName())); // @translate return; } - if (!array_key_exists('o:team',$data)){ - $errorStore->addError('o:team', 'The request lacks a team id.'); // @translate + // Normalise plain keys (team/user) to their JSON-LD equivalents (o:team/o:user) + // before validating, so callers using either convention are accepted. + if (!array_key_exists('o:team', $data) && array_key_exists('team', $data)) { + $data['o:team'] = $data['team']; } - if (!array_key_exists($entity_index,$data)){ - $errorStore->addError($entity_index, new Message('The request lacks a %s id.',$this->getMappedEntityName())); // @translate + if (!array_key_exists($entity_index, $data) && array_key_exists($this->getMappedEntityName(), $data)) { + $data[$entity_index] = $data[$this->getMappedEntityName()]; } - - //is that id a team - if ($data['team']){ - $data['o:team'] = $data['team']; + if (!array_key_exists('o:team', $data)){ + $errorStore->addError('o:team', 'The request lacks a team id.'); // @translate } - - if ($data[$this->getMappedEntityName()]){ - $data[$entity_index] = $data[$this->getMappedEntityName()]; + if (!array_key_exists($entity_index, $data)){ + $errorStore->addError($entity_index, new Message('The request lacks a %s id.', $this->getMappedEntityName())); // @translate } $team = $this->getEntityManager() From f7979e64631cf348ec01f33ae586f2b328c2410c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:17:20 +0000 Subject: [PATCH 33/54] Clean up change-referencing comments; keep only code-explaining comments Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- asset/js/team-users.js | 3 + src/Controller/AddController.php | 8 +- src/Controller/UpdateController.php | 195 ++++++++++++++++++++++++++-- src/Form/TeamForm.php | 55 +++++++- view/teams/team/manage-users.phtml | 9 +- view/teams/update/team-update.phtml | 98 +++++++------- 6 files changed, 298 insertions(+), 70 deletions(-) diff --git a/asset/js/team-users.js b/asset/js/team-users.js index 0fb6ad8b..2fec87ad 100644 --- a/asset/js/team-users.js +++ b/asset/js/team-users.js @@ -26,4 +26,7 @@ $(document).ready(function() { permissionsTable.on('appendRow', function() { updateRowIndex($('[name="o:team_users[__index__][o:user][o:id]"]').val()); }); + + // Initialize the user selector once the table is ready. + Omeka.initializeSelector('#site-user-permissions', '#user-selector'); }); diff --git a/src/Controller/AddController.php b/src/Controller/AddController.php index e7dc122a..b688322d 100644 --- a/src/Controller/AddController.php +++ b/src/Controller/AddController.php @@ -75,9 +75,13 @@ public function teamAddAction() return $view; } - $data = $request->getPost('team'); + $data = $request->getPost()->toArray(); + $teamData = [ + 'o:name' => $data['o:name'] ?? '', + 'o:description' => $data['o:description'] ?? '', + ]; - $newTeam = $this->api($form)->create('team', $data); + $newTeam = $this->api($form)->create('team', $teamData); //add the users, resources and sites to the team if ($newTeam) { diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 788eafd3..29c28e44 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -2,17 +2,12 @@ namespace Teams\Controller; use Doctrine\ORM\EntityManager; -use Doctrine\ORM\NonUniqueResultException; -use Doctrine\ORM\OptimisticLockException; -use Doctrine\ORM\ORMException; +use Laminas\Mvc\Controller\AbstractActionController; +use Laminas\View\Model\ViewModel; use Teams\Form\SecondaryResourcesForm; -use Teams\Form\TeamItemsetAddRemoveForm; +use Teams\Form\TeamForm; use Teams\Form\TeamResourcesForm; use Teams\Form\TeamSitesAddRemoveForm; -use Teams\Form\TeamDetailsForm; -use Laminas\Mvc\Controller\AbstractActionController; -use Laminas\Stdlib\ArrayObject; -use Laminas\View\Model\ViewModel; use Teams\Service\SitePermissionManager; @@ -38,6 +33,190 @@ public function __construct(EntityManager $entityManager, SitePermissionManager $this->sitePermissionManager = $sitePermissionManager; } + public function teamUpdateAction() + { + $teamId = $this->params()->fromRoute('id'); + + // Authorization check before any POST processing. + if (! $this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $teamId)) { + $this->messenger()->addError("You aren't authorized to edit this team"); + return $this->redirect()->toRoute('admin/teams/detail', ['id' => $teamId]); + } + + $teamRepresentation = $this->api()->read('team', ['id' => $teamId])->getContent(); + $team = $this->entityManager->getRepository('Teams\Entity\Team')->findOneBy(['id' => $teamId]); + $currentSites = $this->api()->search('team-site', ['team' => $teamId], ['returnScalar' => 'site'])->getContent(); + + /** @var TeamForm $form */ + $form = $this->getForm(TeamForm::class); + $form->setAttribute('id', 'team-update-form'); + $form->setAttribute('action', $this->url()->fromRoute('admin/teams/detail/update', ['id' => $teamId])); + + // Pre-populate the form with the current team data. + $form->setData($teamRepresentation->getJsonLd()); + + $resourceForm = $this->getForm(TeamResourcesForm::class)->setAttribute('id', 'team-resources-form'); + $secondaryResourcesForm = $this->getForm(SecondaryResourcesForm::class, ['team_id' => $teamId]); + $sitesForm = $this->getForm(TeamSitesAddRemoveForm::class, ['team_id' => $teamId]); + + $bypassTeamFilterRoles = $this->settings()->get('teams_filter_bypass_roles'); + + $view = new ViewModel([ + 'team' => $team, + 'teamRepresentation' => $teamRepresentation, + 'form' => $form, + 'resourceForm' => $resourceForm, + 'secondaryResourcesForm' => $secondaryResourcesForm, + 'sitesForm' => $sitesForm, + 'bypassTeamFilterRoles' => $bypassTeamFilterRoles, + 'id' => $teamId, + 'user' => $this->identity(), + ]); + + $request = $this->getRequest(); + if (! $request->isPost()) { + return $view; + } + + $postData = $this->params()->fromPost(); + + $form->setData($postData); + if (! $form->isValid()) { + $this->messenger()->addFormErrors($form); + return $view; + } + + $this->api($form)->update('team', $teamId, [ + 'o:name' => $postData['o:name'], + 'o:description' => $postData['o:description'], + ]); + + // Remove users no longer in the submitted list, then add or update the rest. + $teamUsers = $request->getPost('o:team_users', []); + $formTeamUserIds = array_column(array_column($teamUsers, 'o:user'), 'o:id'); + + $oldTeamUserIds = $this->api()->search('team-user', ['team' => $teamId], ['returnScalar' => 'user'])->getContent(); + foreach ($oldTeamUserIds as $oldUserId) { + if (! in_array($oldUserId, $formTeamUserIds)) { + $this->api()->delete('team-user', ['team' => $teamId, 'user' => $oldUserId]); + } + } + foreach ($teamUsers as $teamUser) { + $userId = $teamUser['o:user']['o:id']; + $roleId = $teamUser['o:team_role']['o:id']; + $exists = $this->api()->search('team-user', ['team' => $teamId, 'user' => $userId])->getContent(); + if ($exists) { + $this->api()->update('team-user', ['team' => $teamId, 'user' => $userId], ['role' => $roleId]); + } else { + $this->api()->create('team-user', ['team' => $teamId, 'user' => $userId, 'role' => $roleId]); + } + } + + // Dispatch a background job for item assignment if an action was requested. + $resourceForm->setData($postData); + parse_str($postData['item_pool'] ?? '', $itemPool); + if (! empty($postData['item_assignment_action']) && $postData['item_assignment_action'] !== 'no_action') { + $this->jobDispatcher()->dispatch('Teams\Job\UpdateTeamResources', [ + 'teams' => [$teamId => $itemPool], + 'action' => $postData['item_assignment_action'], + ]); + $this->messenger()->addSuccess('Item assignment in progress. To see the new item count, refresh the page.'); // @translate + } + + // Add or remove item sets and resource templates. + $secondaryResourcesForm->setData($postData); + $recursive = $postData['recursive_item_sets'] ?? false; + foreach ($postData['remove_item_sets'] ?? [] as $itemSetId) { + $this->api()->delete('team-resource', [], ['team' => $teamId, 'resource' => $itemSetId], ['recursive' => $recursive, 'syncSites' => true]); + } + foreach ($postData['item_sets'] ?? [] as $itemSetId) { + if (count($this->api()->search('team-resource', ['team' => $teamId, 'resource' => $itemSetId])->getContent()) < 1) { + $this->api()->create('team-resource', ['team' => $teamId, 'resource' => $itemSetId], [], ['recursive' => $recursive, 'syncSites' => true]); + } + } + + // Process resource templates. + foreach ($postData['remove_resource_templates'] ?? [] as $templateId) { + $this->api()->delete('team-resource-template', [], ['team' => $teamId, 'resource-template' => $templateId]); + } + foreach ($postData['resource_templates'] ?? [] as $templateId) { + if (count($this->api()->search('team-resource-template', ['team' => $teamId, 'resource-template' => $templateId])->getContent()) < 1) { + $this->api()->create('team-resource-template', ['team' => $teamId, 'resource-template' => $templateId]); + } + } + + // Add or remove site associations. + $postSites = $postData['teamSites']['o:site'] ?? []; + foreach ($postSites as $siteId) { + if (! in_array($siteId, $currentSites)) { + $this->api()->create('team-site', ['team' => $teamId, 'site' => $siteId]); + } + } + foreach ($currentSites as $siteId) { + if (! in_array($siteId, $postSites)) { + $this->api()->delete('team-site', ['team' => $teamId, 'site' => $siteId]); + } + } + + $this->sitePermissionManager->syncAllSitePermissions(); + $this->messenger()->addSuccess(sprintf("Successfully updated the %s team", $team->getName())); + + return $this->redirect()->toRoute('admin/teams/detail', ['id' => $teamId]); + } + + /** + * Switches the user's active team and updates their default item sites. + */ + public function currentTeamAction() + { + $user_id = $this->identity()->getId(); + $request = $this->getRequest(); + + if ($request->isPost()) { + $data = $request->getPost(); + $em = $this->entityManager; + $teamUserRepo = $em->getRepository('Teams\Entity\TeamUser'); + + $old_current = $teamUserRepo->findOneBy(['user' => $user_id, 'is_current' => 1]); + $new_current = $teamUserRepo->findOneBy(['user' => $user_id, 'team' => $data['team_id']]); + + if ($old_current) { + $old_current->setCurrent(null); + $em->flush(); + } + if ($new_current) { + $new_current->setCurrent(true); + $em->flush(); + $this->sitePermissionManager->updateUserDefaultSites($user_id); + } else { + $this->messenger()->addError("Team not found"); + } + + return $this->redirect()->toUrl($data['return_url']); + } + } +} + + /** + * @var EntityManager + */ + protected $entityManager; + + /** + * @var SitePermissionManager + */ + protected SitePermissionManager $sitePermissionManager; + + /** + * @param EntityManager $entityManager + * @param SitePermissionManager $sitePermissionManager + */ + public function __construct(EntityManager $entityManager, SitePermissionManager $sitePermissionManager) + { + $this->entityManager = $entityManager; + $this->sitePermissionManager = $sitePermissionManager; + } + /** * @throws ORMException * @throws OptimisticLockException diff --git a/src/Form/TeamForm.php b/src/Form/TeamForm.php index 4a8b1479..a743c6bd 100644 --- a/src/Form/TeamForm.php +++ b/src/Form/TeamForm.php @@ -8,8 +8,59 @@ class TeamForm extends Form public function init() { $this->add([ - 'name' => 'team', - 'type' => TeamFieldset::class, + 'name' => 'o:name', + 'type' => 'Text', + 'options' => [ + 'label' => 'Name', // @translate + ], + 'attributes' => [ + 'id' => 'name', + 'required' => true, + ], ]); + + $this->add([ + 'name' => 'o:description', + 'type' => 'Text', + 'options' => [ + 'label' => 'Description', // @translate + ], + 'attributes' => [ + 'id' => 'comment', + 'required' => false, + ], + ]); + + $this->addCsrf(); + } +} + + public function init() + { + $this->add([ + 'name' => 'o:name', + 'type' => 'Text', + 'options' => [ + 'label' => 'Name', // @translate + ], + 'attributes' => [ + 'id' => 'name', + 'required' => true, + ], + ]); + + $this->add([ + 'name' => 'o:description', + 'type' => 'Text', + 'options' => [ + 'label' => 'Description', // @translate + ], + 'attributes' => [ + 'id' => 'comment', + 'required' => false, + ], + ]); + + $this->addCsrf(); } } diff --git a/view/teams/team/manage-users.phtml b/view/teams/team/manage-users.phtml index ece44be2..a5173aea 100644 --- a/view/teams/team/manage-users.phtml +++ b/view/teams/team/manage-users.phtml @@ -1,6 +1,5 @@ plugin('translate'); -$this->headScript()->appendFile($this->assetUrl('js/team-users.js', 'Teams')); $this->htmlElement('body')->appendAttribute('class', 'sites users'); $escape = $this->plugin('escapeHtml'); $delete = $translate('Delete'); @@ -53,10 +52,4 @@ if ($team) {

-allUserSelector($translate('Click on a user to add them to the team.'),true,$this->bypass_teams_filter); ?> - - - - +allUserSelector($translate('Click on a user to add them to the team.'), true, $this->bypass_teams_filter); ?> diff --git a/view/teams/update/team-update.phtml b/view/teams/update/team-update.phtml index 1401083c..ca974694 100644 --- a/view/teams/update/team-update.phtml +++ b/view/teams/update/team-update.phtml @@ -1,55 +1,53 @@ - headScript()->appendFile($this->assetUrl('vendor/sortablejs/Sortable.min.js', 'Omeka')); $this->headScript()->appendFile($this->assetUrl('js/site-resources.js', 'Omeka')); $this->headScript()->appendFile($this->assetUrl('js/inject-removal-util-element.js', 'Teams')); +$this->headScript()->appendFile($this->assetUrl('js/team-users.js', 'Teams')); $translate = $this->plugin('translate'); $escape = $this->plugin('escapeHtml'); $this->htmlElement('body')->appendAttribute('class', 'items edit'); +$form = $this->form; +$form->setAttribute('id', 'team-update-form'); +$form->prepare(); + $sectionNavs = [ 'team-info' => $translate('Info'), 'team-members' => $translate('Users'), 'team-resources' => $translate('Resources'), 'team-secondary-resources' => $translate('Other Resources'), 'team-sites' => $translate('Sites'), -// 'team-assets' => $translate('Assets') ]; ?> -sectionNav($sectionNavs, 'view.show.section_nav'); ?> +pageTitle($this->teamRepresentation->name(), 1, $translate('Teams'), $translate('Edit')); ?> +trigger('view.edit.before'); ?> + +sectionNav($sectionNavs, 'view.edit.section_nav'); ?> -

Update Team for form->get('o:name')->getValue()?>

- cancelButton(); ?> - + cancelButton(); ?> +
-form; -$this->headScript()->appendFile($this->assetUrl('js/team-users.js', 'Teams')); +form()->openTag($form); ?> -$form->prepare(); - -echo $this->form()->openTag($form);?> - -
+
formCollection($form); ?>
-
-form()->openTag($form);?> + roleAuth()->teamAuthorized('update', 'team_user')): ?> -
- - partial('teams/team/manage-users', [ - 'team' => $this->team, - 'bypass_teams_filter' => true, - - ]); - ?> + partial('teams/team/manage-users', [ + 'team' => $this->team, + 'bypass_teams_filter' => true, + ]); ?> +
+ +
+

@@ -59,44 +57,41 @@ echo $this->form()->openTag($form);?>
-

You are only authorized to Manage Team Users

+

-roleAuth()->teamAuthorized('update', 'resource')): ?> +roleAuth()->teamAuthorized('update', 'resource')): ?>
- -

Select resource templates for this team

- getRole(), $bypassTeamFilterRoles) ): ?> -

You can only add resources from other teams where your team role has resource edit privileges.

-

Users with these roles have broader privileges:

-
    - -
  • - -
  • - -
- +

+ getRole(), $bypassTeamFilterRoles)): ?> +

+

+
    + +
  • + +
+ setLabel("Add and Remove Templates and Item Sets"); - $secondaryResourcesForm->get('recursive_item_sets')->setOptions(['label'=>'Recursively manage items from item sets?']); - $secondaryResourcesForm->get('recursive_item_sets')->setOptions(['info'=>'Add/remove all of the items from the item sets when you add/remove them']); - + $secondaryResourcesForm->setLabel($translate('Add and Remove Templates and Item Sets')); + $secondaryResourcesForm->get('recursive_item_sets')->setOptions(['label' => $translate('Recursively manage items from item sets?')]); + $secondaryResourcesForm->get('recursive_item_sets')->setOptions(['info' => $translate('Add/remove all of the items from the item sets when you add/remove them')]); echo $this->formCollection($secondaryResourcesForm); ?>
-
-

You are only authorized to Manage Team Users

+
+

+ roleAuth()->teamAuthorized('update', 'site')): ?>
@@ -104,19 +99,22 @@ echo $this->form()->openTag($form);?> sitesForm; $fs = $sitesForm->getFieldsets(); - $fs['teamSites']->setLabel('Add or Remove Sites '); + $fs['teamSites']->setLabel($translate('Add or Remove Sites')); $sitesForm->prepare(); - echo $this->form()->openTag($sitesForm); echo $this->formCollection($sitesForm); - echo $this->form()->closeTag($sitesForm) ?>
-

You are only authorized to Manage Team Users

+

+ +formElement($form->get('csrf')); ?> form()->closeTag(); ?> +deleteConfirm($this->teamRepresentation, 'team'); ?> + +trigger('view.edit.after'); ?> From ad77ff29dbfe58dbf3a018313efa183981b6b63d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:22:34 +0000 Subject: [PATCH 34/54] Fix PHP parse errors: remove duplicate code in TeamForm.php and UpdateController.php Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Controller/UpdateController.php | 265 ---------------------------- src/Form/TeamForm.php | 30 ---- 2 files changed, 295 deletions(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 29c28e44..18bacdf9 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -196,268 +196,3 @@ public function currentTeamAction() } } } - - /** - * @var EntityManager - */ - protected $entityManager; - - /** - * @var SitePermissionManager - */ - protected SitePermissionManager $sitePermissionManager; - - /** - * @param EntityManager $entityManager - * @param SitePermissionManager $sitePermissionManager - */ - public function __construct(EntityManager $entityManager, SitePermissionManager $sitePermissionManager) - { - $this->entityManager = $entityManager; - $this->sitePermissionManager = $sitePermissionManager; - } - - /** - * @throws ORMException - * @throws OptimisticLockException - * @throws NonUniqueResultException - */ - public function teamUpdateAction() - { - - $team_id = $this->params()->fromRoute('id'); - $team = $this->entityManager->getRepository('Teams\Entity\Team')->findOneBy(['id' => $team_id]); - $current_sites = $this->api()->search('team-site', ['team' => $team_id], ['returnScalar' => 'site'])->getContent(); - - $sitesForm = $this->getForm(TeamSitesAddRemoveForm::class, ['team_id' => $team_id]); - - - //set up the item set form - $itemsetForm = $this->getForm(TeamItemsetAddRemoveForm::class); - $user = $this->identity(); - //TODO rename this to TeamDetail form or find a way to string these all together - $teamDetailsForm = $this->getForm(TeamDetailsForm::class); - $data = $this->api()->read('team', ['id'=>$team_id])->getContent(); - - //TODO (refactor) this is probably a stupid way to do this - - //get all of the users and put them in an associative array id:name - $all_u_array = array(); - $all_u_collection = $this->api()->search('users')->getContent(); - foreach ($all_u_collection as $u): - $all_u_array[$u->id()] = $u->name(); - endforeach; - - //get the team's users and put them in an associative array id:name - $team_u_array = array(); - $team_u_collection = $this->api()->read('team', ['id'=>$team_id])->getContent()->teamUsers(); - foreach ($team_u_collection as $team_user): - $team_u_array[$team_user->getUser()->getId()] = $team_user->getUser()->getName(); - endforeach; - - //get the users available to be added to the team - $available_u_array = array_diff($all_u_array, $team_u_array); - - // Build a name => id map for the view's role selector. - // Using api()->search() avoids loading partial entities into the Doctrine - // identity map, which was the root cause of the canAddSitePages=false bug. - $roleRepresentations = $this->api()->search('team-role')->getContent(); - $roles = []; - foreach ($roleRepresentations as $roleRep) { - $roles[$roleRep->name()] = $roleRep->id(); - } - - //create an array object to hold the contents to pre-fill the form with - //TODO (emulate) this is the procedure to use to populate forms. Copy this. - $fill = new ArrayObject; - $fill['o:name'] = $data->getJsonLd()['o:name']; - $fill['o:description'] = $data->getJsonLd()['o:description']; - $teamDetailsForm->bind($fill); - - //is it a post request? - //TODO (refactor) clean up this, only send what is needed - $request = $this->getRequest(); - - $resourceForm = $this->getForm(TeamResourcesForm::class)->setAttribute('id', 'team-resources-form'); - $secondaryResourcesForm = $this->getForm(SecondaryResourcesForm::class, - ['team_id'=>$team_id] - ); - - $bypass_team_filter_roles = $this->settings()->get('teams_filter_bypass_roles'); - $view = new ViewModel([ - 'team'=>$team, - 'form' => $teamDetailsForm, - 'resourceForm' => $resourceForm, - 'secondaryResourcesForm' => $secondaryResourcesForm, - 'bypassTeamFilterRoles' => $bypass_team_filter_roles, - 'id' => $team_id, - 'roles'=> $roles, - 'all_u_collection' => $all_u_collection, - 'team_u_collection' => $team_u_collection, - 'team_u_array'=>$team_u_array, - 'available_u_array'=>$available_u_array, - 'user' => $user, - 'itemsetForm' => $itemsetForm, - 'sitesForm' => $sitesForm, - ]); - if (! $request->isPost()) { - return $view; - } - - - $post_data = $request->getPost(); - if (!$this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $team_id)) { - $this->messenger()->addError("You aren't authorized to change the team details"); - return $view; - } else { - $this->api()->update('team', $team_id, ['o:name' => $post_data['o:name'], 'o:description' => $post_data['o:description']]); - } - if (!$this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team_user', $team_id)) { - $this->messenger()->addError("You aren't authorized to change team members"); - return $view; - } else { - $teamUsers = $request->getPost('o:team_users'); - //remove team users not in the form - $formTeamUsers = array(); - foreach ($teamUsers as $teamUser){ - $formTeamUsers[] = $teamUser['o:user']['o:id']; - } - - $oldTeamUsers= $this->api()->search('team-user', ['team'=>$team_id], ['returnScalar'=>'user'])->getContent(); - foreach ($oldTeamUsers as $oldTeamUser) { - if (!in_array($oldTeamUser,$formTeamUsers)) { - $this->api()->delete('team-user',['team'=>$team_id, 'user'=>$oldTeamUser]); - } - } - //add team users or update permissions - foreach ($teamUsers as $teamUser) { - //using search instead of read because read will throw a not found error instead of returning empty - $teamUserExists = $this->api()->search('team-user', ['team'=>$team_id, 'user'=>$teamUser['o:user']['o:id']])->getContent(); - - if ($teamUserExists){ - $this->api()->update('team-user', ['team' => $team_id, 'user' => $teamUser['o:user']['o:id']], ['role' => $teamUser['o:team_role']['o:id']]); - } else { - $this->api() - ->create('team-user', - [ - 'team'=>$team_id, - 'user'=>$teamUser['o:user']['o:id'], - 'role'=>$teamUser['o:team_role']['o:id'] - ]); - } - } - } - - //TODO:need to update this for users who have the update and bypass_team_filter - if (! $this->teamAuth()->teamAuthorized($this->identity(), 'update', 'team', $team_id)){ - $this->messenger()->addError("You aren't authorized to change this team"); - return $view; - } else { - - //process items - $formData = $this->params()->fromPost(); - $resourceForm->setData($formData); - parse_str($formData['item_pool'], $itemPool); - if ($formData['item_assignment_action'] && $formData['item_assignment_action'] !== 'no_action') { - $this->jobDispatcher()->dispatch('Teams\Job\UpdateTeamResources', [ - 'teams' => [$team_id => $itemPool], - 'action' => $formData['item_assignment_action'], - ]); - $this->messenger()->addSuccess('Item assignment in progress. To see the new item count, refresh the page.'); // @translate - } - - //process item sets and resource templates - - //remove item sets - $secondaryResourcesForm->setData($formData); - - $recursive = $formData['recursive_item_sets'] ?? false; - if (isset($formData['remove_item_sets'])){ - $remove_item_sets = $formData['remove_item_sets']; - foreach ($remove_item_sets as $item_set_id) { - //todo: delete expects the id to be in the second parameter, for now just leaving empty because team resource uses a composite key - $this->api()->delete('team-resource', [], ['team' => $team_id, 'resource' => $item_set_id],['recursive'=>$recursive, 'syncSites' => true]); - } - } - //add item sets - if (isset($formData['item_sets'])) { - foreach ($formData['item_sets'] as $item_set_id) { - //todo: implement the read operation - $exists = $this->api()->search('team-resource', ['team' => $team_id, 'resource' => $item_set_id]); - if (count($exists->getContent()) < 1) { - $this->api()->create('team-resource', ['team' => $team_id, 'resource' => $item_set_id], [], ['recursive' => $recursive, 'syncSites' => true]); - } - } - } - - //remove resource templates - $secondaryResourcesForm->setData($formData); - if (isset($formData['remove_resource_templates'])){ - foreach ($formData['remove_resource_templates'] as $resource_template_id) { - //todo: delete expects the id to be in the second parameter, for now just leaving empty because team resource uses a composite key - $this->api()->delete('team-resource-template', [], ['team' => $team_id, 'resource-template' => $resource_template_id]); - } - } - //add resource templates - if (isset($formData['resource_templates'])){ - foreach ($formData['resource_templates'] as $resource_template_id) { - if (count($this->api()->search('team-resource-template', ['team'=>$team_id, 'resource-template'=>$resource_template_id])->getContent())<1){ - $this->api()->create('team-resource-template', ['team'=>$team_id, 'resource-template'=>$resource_template_id]); - } - } - } - - //handle new sites - $postSites = $post_data['teamSites']['o:site'] ?? []; - foreach ($postSites as $site_id) { - if (!in_array($site_id, $current_sites)) { - $this->api()->create('team-site', ['team' => $team_id, 'site' => $site_id]); - } - } - - //handle removed sites - foreach ($current_sites as $site_id) { - if (!in_array($site_id, $postSites)) { - $this->api()->delete('team-site', ['team' => $team_id, 'site' => $site_id]); - } - } - } - $this->sitePermissionManager->syncAllSitePermissions(); - $successMessage = sprintf("Successfully updated the %s team", $team->getName()); - $this->messenger()->addSuccess($successMessage); - - return $this->redirect()->toRoute('admin/teams/detail',['id'=>$team_id]); - } - - /** - * Switches the user's active team and updates their default item sites. - */ - public function currentTeamAction() - { - $user_id = $this->identity()->getId(); - $request = $this->getRequest(); - - if ($request->isPost()) { - $data = $request->getPost(); - $em = $this->entityManager; - $teamUserRepo = $em->getRepository('Teams\Entity\TeamUser'); - - $old_current = $teamUserRepo->findOneBy(['user' => $user_id, 'is_current' => 1]); - $new_current = $teamUserRepo->findOneBy(['user' => $user_id, 'team' => $data['team_id']]); - - if ($old_current) { - $old_current->setCurrent(null); - $em->flush(); - } - if ($new_current) { - $new_current->setCurrent(true); - $em->flush(); - $this->sitePermissionManager->updateUserDefaultSites($user_id); - } else { - $this->messenger()->addError("Team not found"); - } - - return $this->redirect()->toUrl($data['return_url']); - } - } -} diff --git a/src/Form/TeamForm.php b/src/Form/TeamForm.php index a743c6bd..202d7a16 100644 --- a/src/Form/TeamForm.php +++ b/src/Form/TeamForm.php @@ -34,33 +34,3 @@ public function init() $this->addCsrf(); } } - - public function init() - { - $this->add([ - 'name' => 'o:name', - 'type' => 'Text', - 'options' => [ - 'label' => 'Name', // @translate - ], - 'attributes' => [ - 'id' => 'name', - 'required' => true, - ], - ]); - - $this->add([ - 'name' => 'o:description', - 'type' => 'Text', - 'options' => [ - 'label' => 'Description', // @translate - ], - 'attributes' => [ - 'id' => 'comment', - 'required' => false, - ], - ]); - - $this->addCsrf(); - } -} From b4eb6f4566c3336733aed601508c57a428e95912 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:31:43 +0000 Subject: [PATCH 35/54] Fix addCsrf() call, Containerinterface typo, remove unused import; update linting instructions Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- .github/copilot-instructions.md | 31 ++++++++++++++++--- src/Controller/AddController.php | 1 - src/Form/TeamForm.php | 1 - .../Form/SecondaryResourceFormFactory.php | 2 +- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 77391679..b23f0ad6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -93,13 +93,36 @@ Omeka S Module (PHP-based) ## Build, Validation, and Testing +### Linting — Always Run + +**Always run `php -l` on every PHP file you create or modify before committing.** This catches parse errors immediately: + +```bash +php -l path/to/File.php +# or check all files at once: +find . -name "*.php" -print0 | xargs -0 php -l | grep -v "No syntax errors" +``` + +### Omeka S Coding Standards + +Omeka S ships a PHP_CodeSniffer ruleset. When the Omeka S codebase is present, run it against changed files: + +```bash +vendor/bin/phpcs --standard=vendor/omeka/omeka-s/coding-standards/Omeka/ruleset.xml src/Path/To/ChangedFile.php +``` + +Even without the full Omeka environment, apply these standards manually: +- No `addCsrf()` calls — Omeka S adds CSRF to **all** forms automatically via its `Omeka\Form\Initializer\Csrf` initializer. Never call `addCsrf()` manually. +- Forms extend `Laminas\Form\Form` (or an Omeka subclass); the initializer handles CSRF. +- Verify every `use` import is spelled correctly (case-sensitive on Linux) and actually resolves. +- Remove all unused `use` statements. + ### Current State -**No explicit build steps, CI/CD pipelines, or automated validation are currently present** in this repository. +**No CI/CD pipelines or automated test suites are currently present** in this repository beyond `php -l`. ### What This Means for Agents -- **Skip automatic build/validate steps** unless explicitly directed by task requirements -- **No linting, testing, or compilation** commands to run by default -- **Manual validation** may be performed by viewing files and inspecting code +- **Always run `php -l`** on every changed PHP file — this is mandatory, not optional +- **Manual validation** should also check method existence against Omeka S source (search GitHub omeka/omeka-s if needed) - **Future improvements**: Agents may recommend or prototype build/test infrastructure as part of code quality improvements ### Testing in Omeka Context diff --git a/src/Controller/AddController.php b/src/Controller/AddController.php index b688322d..84dc3e58 100644 --- a/src/Controller/AddController.php +++ b/src/Controller/AddController.php @@ -7,7 +7,6 @@ use Omeka\Form\ResourceForm; use Omeka\Media\Ingester\Manager; use Omeka\Stdlib\Message; -use phpDocumentor\Reflection\Types\This; use Teams\Entity\TeamAsset; use Teams\Entity\TeamResource; use Teams\Entity\TeamResourceTemplate; diff --git a/src/Form/TeamForm.php b/src/Form/TeamForm.php index 202d7a16..39de353a 100644 --- a/src/Form/TeamForm.php +++ b/src/Form/TeamForm.php @@ -31,6 +31,5 @@ public function init() ], ]); - $this->addCsrf(); } } diff --git a/src/Service/Form/SecondaryResourceFormFactory.php b/src/Service/Form/SecondaryResourceFormFactory.php index 02edcd0a..97b8fb0f 100644 --- a/src/Service/Form/SecondaryResourceFormFactory.php +++ b/src/Service/Form/SecondaryResourceFormFactory.php @@ -2,7 +2,7 @@ namespace Teams\Service\Form; -use Interop\Container\Containerinterface; +use Interop\Container\ContainerInterface; use Teams\Form\SecondaryResourcesForm; class SecondaryResourceFormFactory From 86247e43489b0c409c69964707562ccd06ee3f18 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:54:50 +0000 Subject: [PATCH 36/54] chore: planning full team add/edit refactor Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- composer.json | 14 ++++++++++++++ view/teams/update/team-update.phtml | 1 - 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 composer.json diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..efa5deb5 --- /dev/null +++ b/composer.json @@ -0,0 +1,14 @@ +{ + "name": "uiuclibrary/teams", + "description": "Omeka S module for team-based resource visibility and permission management", + "type": "omeka-module", + "license": "Apache-2.0", + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0" + }, + "config": { + "allow-plugins": { + "composer/package-versions-deprecated": true + } + } +} diff --git a/view/teams/update/team-update.phtml b/view/teams/update/team-update.phtml index ca974694..036f095b 100644 --- a/view/teams/update/team-update.phtml +++ b/view/teams/update/team-update.phtml @@ -112,7 +112,6 @@ $sectionNavs = [ -formElement($form->get('csrf')); ?> form()->closeTag(); ?> deleteConfirm($this->teamRepresentation, 'team'); ?> From c2b7310fcb0466f2ec7895a9bf0b7625d43952e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:58:45 +0000 Subject: [PATCH 37/54] Apply remaining changes Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Form/SecondaryResourcesForm.php | 4 +- src/Form/TeamSitesAddRemoveForm.php | 5 +- view/teams/add/team-add.phtml | 129 +++++++--------------------- view/teams/team/edit-form.phtml | 120 ++++++++++++++++++++++++++ 4 files changed, 156 insertions(+), 102 deletions(-) create mode 100644 view/teams/team/edit-form.phtml diff --git a/src/Form/SecondaryResourcesForm.php b/src/Form/SecondaryResourcesForm.php index 1ca2ed57..89dca78c 100644 --- a/src/Form/SecondaryResourcesForm.php +++ b/src/Form/SecondaryResourcesForm.php @@ -8,13 +8,13 @@ use Laminas\Form\Element\Select; use Omeka\Api\Manager as ApiManager; use Omeka\Api\Representation\AbstractResourceRepresentation; -use Laminas\Form\Form; +use Laminas\Form\Fieldset; use Omeka\Settings\Settings; use Teams\Form\Element\ItemSetTeamSelect; use Teams\Form\Element\ResourceTemplateTeamsSelect; -class SecondaryResourcesForm extends Form +class SecondaryResourcesForm extends Fieldset { protected $options; diff --git a/src/Form/TeamSitesAddRemoveForm.php b/src/Form/TeamSitesAddRemoveForm.php index d5d9ad37..200700f4 100644 --- a/src/Form/TeamSitesAddRemoveForm.php +++ b/src/Form/TeamSitesAddRemoveForm.php @@ -3,10 +3,10 @@ namespace Teams\Form; -use Laminas\Form\Form; +use Laminas\Form\Fieldset; use Omeka\Api\Manager as ApiManager; -class TeamSitesAddRemoveForm extends Form +class TeamSitesAddRemoveForm extends Fieldset { protected ?ApiManager $apiManager = null; @@ -23,6 +23,7 @@ public function init() ]); $siteSelect = $this->get('teamSites')->get('o:site'); + $siteSelect->setName('teamSites[o:site]'); $siteSelect->setAttribute('multiple', true); $siteSelect->setAttribute('id', 'sites'); $siteSelect->setEmptyOption('None'); diff --git a/view/teams/add/team-add.phtml b/view/teams/add/team-add.phtml index 2d829f49..0b49a82b 100644 --- a/view/teams/add/team-add.phtml +++ b/view/teams/add/team-add.phtml @@ -2,108 +2,41 @@ $this->headScript()->appendFile($this->assetUrl('vendor/sortablejs/Sortable.min.js', 'Omeka')); $this->headScript()->appendFile($this->assetUrl('js/site-resources.js', 'Omeka')); $this->headScript()->appendFile($this->assetUrl('js/inject-removal-util-element.js', 'Teams')); - -echo $this->pageTitle("Add new team"); -if ($this->roleAuth()->teamAuthorized('add', 'team')): - -$this->htmlElement('body')->appendAttribute('class', 'add'); $this->headLink()->prependStylesheet($this->assetUrl('css/teams.css', 'Teams')); $this->headScript()->appendFile($this->assetUrl('js/team-users.js', 'Teams')); -$formElement = $this->plugin('formElement'); -?> -
- -
-plugin('translate'); -$escape = $this->plugin('escapeHtml'); - -$sectionNavs = [ - 'team-info' => $translate('Info'), - 'team-members' => $translate('Users'), - 'team-resources' => $translate('Resources'), - 'team-secondary-resources' => $translate('Other Resources'), - 'team-sites' => $translate('Sites'), -]; -echo $this->sectionNav($sectionNavs, 'view.add.section_nav'); - $form = $this->form; - $form->setAttribute('id', 'new_team'); - $form->prepare(); - echo $this->form()->openTag($form); -?> -
- formCollection($form); ?> -
- -
- partial('teams/team/manage-users', [ - 'team' => [], - 'bypass_teams_filter' => true, - - ]); - ?> -
- -itemsetForm; -$itemsetForm->prepare(); -$formElement = $this->plugin('formElement'); -echo $this->form()->openTag($itemsetForm); -$resourceForm->get('item_assignment_action') - ->setValueOptions( - [ - 'no_action' => 'Do nothing', // @translate - 'add' => 'Add - assign items from a search query', // @translate - ] - )->setOptions(['label' => 'Add items to the team']) +$this->htmlElement('body')->appendAttribute('class', 'teams add'); ?> -
- formRow($resourceForm->get('item_assignment_action')); ?> - -
-
- -

Select resource templates for this team

- setLabel("Add and Remove Templates and Item Sets"); - echo $this->formCollection($secondaryResourcesForm); - ?> -
+pageTitle($translate('Add new team')); ?> +trigger('view.add.before'); ?> + +roleAuth()->teamAuthorized('add', 'team')): ?> + +partial('teams/team/edit-form', [ + 'form' => $this->form, + 'resourceForm' => $this->resourceForm, + 'secondaryResourcesForm' => $this->secondaryResourcesForm, + 'sitesForm' => $this->sitesForm, + 'team' => [], + 'bypassTeamFilterRoles' => [], + 'user' => $this->identity(), + 'isAdd' => true, +]); ?> + + +hyperlink( + $translate('global admins'), + $this->url( + 'admin/default', + ['controller' => 'user', 'action' => 'browse'], + ['query' => ['role' => 'global_admin', 'active' => '1']] + ) +); ?> + + +trigger('view.add.after'); ?> -
-
-

- Attach sites to team. -

-
- formCollection($itemsetForm->get('site')); ?> -
-
-
-form()->closeTag(); -else: ?> - hyperlink( - 'global admins', - $this->url( - 'admin/default', - [ 'controller' => 'user', 'action' => 'browse'], - [ - 'query' => [ - 'role' => 'global_admin', - 'active' => '1', - ], - ] - ) - ); ?> - -plugin('translate'); +$escape = $this->plugin('escapeHtml'); + +$sectionNavs = [ + 'team-info' => $translate('Info'), + 'team-members' => $translate('Users'), + 'team-resources' => $translate('Resources'), + 'team-secondary-resources' => $translate('Other Resources'), + 'team-sites' => $translate('Sites'), +]; +echo $this->sectionNav($sectionNavs, $isAdd ? 'view.add.section_nav' : 'view.edit.section_nav'); + +$form->prepare(); +echo $this->form()->openTag($form); +?> + +
+ cancelButton(); endif; ?> + +
+ +
+
+ formCollection($form); ?> +
+
+ +roleAuth()->teamAuthorized('update', 'team_user')): ?> +
+

+
+ +
+ partial('teams/team/manage-users', [ + 'team' => $team, + 'bypass_teams_filter' => true, + ]); ?> +
+ + +roleAuth()->teamAuthorized('update', 'resource')): ?> +
+

+
+ +
+ formRow($resourceForm->get('item_assignment_action')); ?> + +
+ + +roleAuth()->teamAuthorized('update', 'resource')): ?> +
+

+
+ +
+

+ getRole(), $bypassTeamFilterRoles ?? [])): ?> +

+

+
    + +
  • + +
+ + setLabel($translate('Add and Remove Templates and Item Sets')); + if (!$isAdd) { + $secondaryResourcesForm->get('recursive_item_sets') + ->setOptions(['label' => $translate('Recursively manage items from item sets?')]); + $secondaryResourcesForm->get('recursive_item_sets') + ->setOptions(['info' => $translate('Add/remove all of the items from the item sets when you add/remove them')]); + } + echo $this->formCollection($secondaryResourcesForm); + ?> +
+ + +roleAuth()->teamAuthorized('update', 'site')): ?> +
+

+
+ +
+
+
+ get('teamSites')->setLabel($translate('Add or Remove Sites')); + echo $this->formCollection($sitesForm); + ?> +
+
+
+ + +form()->closeTag(); ?> From 36579353760dfb1f4f6bbd62f14f19c6ef7da136 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:04:28 +0000 Subject: [PATCH 38/54] fix: restore TeamSitesAddRemoveForm to extend Form so prepare() works Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Form/TeamSitesAddRemoveForm.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Form/TeamSitesAddRemoveForm.php b/src/Form/TeamSitesAddRemoveForm.php index 200700f4..ebfcb2be 100644 --- a/src/Form/TeamSitesAddRemoveForm.php +++ b/src/Form/TeamSitesAddRemoveForm.php @@ -3,10 +3,10 @@ namespace Teams\Form; -use Laminas\Form\Fieldset; +use Laminas\Form\Form; use Omeka\Api\Manager as ApiManager; -class TeamSitesAddRemoveForm extends Fieldset +class TeamSitesAddRemoveForm extends Form { protected ?ApiManager $apiManager = null; From 880b04584a7106fbfc975af7a9c6f0cbf2da036d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:13:59 +0000 Subject: [PATCH 39/54] fix: remove unused setData() call on SecondaryResourcesForm (Fieldset) Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Controller/UpdateController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 18bacdf9..b392f8a6 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -124,7 +124,6 @@ public function teamUpdateAction() } // Add or remove item sets and resource templates. - $secondaryResourcesForm->setData($postData); $recursive = $postData['recursive_item_sets'] ?? false; foreach ($postData['remove_item_sets'] ?? [] as $itemSetId) { $this->api()->delete('team-resource', [], ['team' => $teamId, 'resource' => $itemSetId], ['recursive' => $recursive, 'syncSites' => true]); From d13bb63d71666a60901d38c99a4846e36deb80ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:21:02 +0000 Subject: [PATCH 40/54] refactor: follow Omeka S form pattern for team add/edit pages - Restore SecondaryResourcesForm to extend Form (reverts bad previous session change) - Restore $secondaryResourcesForm->setData($postData) in UpdateController - Convert team-update.phtml to thin wrapper delegating to edit-form.phtml partial, matching Omeka's edit.phtml pattern (pageTitle, trigger, partial, deleteConfirm, trigger) - Refactor edit-form.phtml to Omeka form.phtml pattern: explicit formRow for o:name/ o:description, page-actions at bottom inside form, CSRF rendered explicitly before closeTag, sectionNavEvent and action passed as variables - Fix AddController: add TeamSitesAddRemoveForm, pass sitesForm to ViewModel, replace direct-entity site persist with API create call using teamSites[o:site] POST key, remove now-unused TeamSite import - Update team-add.phtml to pass sectionNavEvent and action to shared partial Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Controller/AddController.php | 29 +++---- src/Controller/UpdateController.php | 1 + src/Form/SecondaryResourcesForm.php | 4 +- view/teams/add/team-add.phtml | 2 + view/teams/team/edit-form.phtml | 43 ++++++---- view/teams/update/team-update.phtml | 118 ++++------------------------ 6 files changed, 58 insertions(+), 139 deletions(-) diff --git a/src/Controller/AddController.php b/src/Controller/AddController.php index 84dc3e58..6140b6fc 100644 --- a/src/Controller/AddController.php +++ b/src/Controller/AddController.php @@ -10,12 +10,12 @@ use Teams\Entity\TeamAsset; use Teams\Entity\TeamResource; use Teams\Entity\TeamResourceTemplate; -use Teams\Entity\TeamSite; use Teams\Form\SecondaryResourcesForm; use Teams\Form\TeamItemSetForm; use Teams\Form\TeamResourcesForm; use Teams\Form\TeamRoleForm; use Teams\Form\TeamForm; +use Teams\Form\TeamSitesAddRemoveForm; use Teams\Form\TeamUserForm; use Laminas\Mvc\Controller\AbstractActionController; use Laminas\View\Model\ViewModel; @@ -44,15 +44,14 @@ public function teamAddAction() $userForm = $this->getForm(TeamUserForm::class); $itemsetForm = $this->getForm(TeamItemSetForm::class); $resourceForm = $this->getForm(TeamResourcesForm::class)->setAttribute('id', 'team-resources-form'); - $secondaryResourcesForm = $this->getForm(SecondaryResourcesForm::class, - ['team_id'=>0] - ); + $secondaryResourcesForm = $this->getForm(SecondaryResourcesForm::class, ['team_id' => 0]); + $sitesForm = $this->getForm(TeamSitesAddRemoveForm::class, ['team_id' => 0]); $view = new ViewModel( [ - 'form' => $form, - 'itemsetForm' => $itemsetForm, - 'resourceForm' => $resourceForm, + 'form' => $form, + 'resourceForm' => $resourceForm, 'secondaryResourcesForm' => $secondaryResourcesForm, + 'sitesForm' => $sitesForm, ] ); @@ -96,16 +95,12 @@ public function teamAddAction() } } - //persist the sites (no possibility of duplicates, so don't need to save to associative array) - if (isset($request->getPost('site')['site']['o:site'])) { - foreach ($request->getPost('site')['site']['o:site'] as $site_id): - $site_id = (int)$site_id; - $site = $this->entityManager->getRepository('Omeka\Entity\Site') - ->findOneBy(['id' => $site_id]); - $team_site = new TeamSite($teamEntity, $site); - $this->entityManager->persist($team_site); - endforeach; - $this->entityManager->flush(); + // Persist site associations via the API, matching the teamSites[o:site] form field. + foreach ($data['teamSites']['o:site'] ?? [] as $siteId) { + $this->api()->create('team-site', [ + 'team' => $newTeam->getContent()->id(), + 'site' => (int) $siteId, + ]); } $asset_array = array(); $formData = $this->params()->fromPost(); diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index b392f8a6..18bacdf9 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -124,6 +124,7 @@ public function teamUpdateAction() } // Add or remove item sets and resource templates. + $secondaryResourcesForm->setData($postData); $recursive = $postData['recursive_item_sets'] ?? false; foreach ($postData['remove_item_sets'] ?? [] as $itemSetId) { $this->api()->delete('team-resource', [], ['team' => $teamId, 'resource' => $itemSetId], ['recursive' => $recursive, 'syncSites' => true]); diff --git a/src/Form/SecondaryResourcesForm.php b/src/Form/SecondaryResourcesForm.php index 89dca78c..1ca2ed57 100644 --- a/src/Form/SecondaryResourcesForm.php +++ b/src/Form/SecondaryResourcesForm.php @@ -8,13 +8,13 @@ use Laminas\Form\Element\Select; use Omeka\Api\Manager as ApiManager; use Omeka\Api\Representation\AbstractResourceRepresentation; -use Laminas\Form\Fieldset; +use Laminas\Form\Form; use Omeka\Settings\Settings; use Teams\Form\Element\ItemSetTeamSelect; use Teams\Form\Element\ResourceTemplateTeamsSelect; -class SecondaryResourcesForm extends Fieldset +class SecondaryResourcesForm extends Form { protected $options; diff --git a/view/teams/add/team-add.phtml b/view/teams/add/team-add.phtml index 0b49a82b..f5196f72 100644 --- a/view/teams/add/team-add.phtml +++ b/view/teams/add/team-add.phtml @@ -22,6 +22,8 @@ $this->htmlElement('body')->appendAttribute('class', 'teams add'); 'bypassTeamFilterRoles' => [], 'user' => $this->identity(), 'isAdd' => true, + 'sectionNavEvent' => 'view.add.section_nav', + 'action' => 'add', ]); ?> diff --git a/view/teams/team/edit-form.phtml b/view/teams/team/edit-form.phtml index 333191f0..0487dd12 100644 --- a/view/teams/team/edit-form.phtml +++ b/view/teams/team/edit-form.phtml @@ -1,6 +1,7 @@ plugin('translate'); -$escape = $this->plugin('escapeHtml'); +$translate = $this->plugin('translate'); +$escape = $this->plugin('escapeHtml'); +$formElement = $this->plugin('formElement'); $sectionNavs = [ - 'team-info' => $translate('Info'), - 'team-members' => $translate('Users'), - 'team-resources' => $translate('Resources'), - 'team-secondary-resources' => $translate('Other Resources'), - 'team-sites' => $translate('Sites'), + 'team-info' => $translate('Info'), + 'team-members' => $translate('Users'), + 'team-resources' => $translate('Resources'), + 'team-secondary-resources' => $translate('Other Resources'), + 'team-sites' => $translate('Sites'), ]; -echo $this->sectionNav($sectionNavs, $isAdd ? 'view.add.section_nav' : 'view.edit.section_nav'); +echo $this->sectionNav($sectionNavs, $sectionNavEvent ?? ($isAdd ? 'view.add.section_nav' : 'view.edit.section_nav')); $form->prepare(); echo $this->form()->openTag($form); ?> -
- cancelButton(); endif; ?> - -
+trigger("view.{$action}.form.before", ['form' => $form]); ?>
- formCollection($form); ?> + formRow($form->get('o:name')); ?> + formRow($form->get('o:description')); ?>
@@ -46,8 +48,8 @@ echo $this->form()->openTag($form);
partial('teams/team/manage-users', [ - 'team' => $team, - 'bypass_teams_filter' => true, + 'team' => $team, + 'bypass_teams_filter' => true, ]); ?>
@@ -64,7 +66,7 @@ echo $this->form()->openTag($form); @@ -117,4 +119,13 @@ echo $this->form()->openTag($form); +trigger("view.{$action}.form.after", ['form' => $form]); ?> + +
+ cancelButton(); endif; ?> + +
+ +get('csrf')); ?> + form()->closeTag(); ?> diff --git a/view/teams/update/team-update.phtml b/view/teams/update/team-update.phtml index 036f095b..fc4ae8b2 100644 --- a/view/teams/update/team-update.phtml +++ b/view/teams/update/team-update.phtml @@ -3,116 +3,26 @@ $this->headScript()->appendFile($this->assetUrl('vendor/sortablejs/Sortable.min. $this->headScript()->appendFile($this->assetUrl('js/site-resources.js', 'Omeka')); $this->headScript()->appendFile($this->assetUrl('js/inject-removal-util-element.js', 'Teams')); $this->headScript()->appendFile($this->assetUrl('js/team-users.js', 'Teams')); +$this->headLink()->prependStylesheet($this->assetUrl('css/teams.css', 'Teams')); $translate = $this->plugin('translate'); -$escape = $this->plugin('escapeHtml'); -$this->htmlElement('body')->appendAttribute('class', 'items edit'); - -$form = $this->form; -$form->setAttribute('id', 'team-update-form'); -$form->prepare(); - -$sectionNavs = [ - 'team-info' => $translate('Info'), - 'team-members' => $translate('Users'), - 'team-resources' => $translate('Resources'), - 'team-secondary-resources' => $translate('Other Resources'), - 'team-sites' => $translate('Sites'), -]; +$this->htmlElement('body')->appendAttribute('class', 'teams edit'); ?> pageTitle($this->teamRepresentation->name(), 1, $translate('Teams'), $translate('Edit')); ?> trigger('view.edit.before'); ?> -sectionNav($sectionNavs, 'view.edit.section_nav'); ?> - -
- cancelButton(); ?> - -
- -form()->openTag($form); ?> - -
-
- formCollection($form); ?> -
-
- -roleAuth()->teamAuthorized('update', 'team_user')): ?> -
- partial('teams/team/manage-users', [ - 'team' => $this->team, - 'bypass_teams_filter' => true, - ]); ?> -
- -
-

-
- - -roleAuth()->teamAuthorized('update', 'resource')): ?> -
- formRow($resourceForm->get('item_assignment_action')); ?> - -
- -
-

-
- - -roleAuth()->teamAuthorized('update', 'resource')): ?> -
-

- getRole(), $bypassTeamFilterRoles)): ?> -

-

-
    - -
  • - -
- - setLabel($translate('Add and Remove Templates and Item Sets')); - $secondaryResourcesForm->get('recursive_item_sets')->setOptions(['label' => $translate('Recursively manage items from item sets?')]); - $secondaryResourcesForm->get('recursive_item_sets')->setOptions(['info' => $translate('Add/remove all of the items from the item sets when you add/remove them')]); - echo $this->formCollection($secondaryResourcesForm); - ?> -
- -
-

-
- - -roleAuth()->teamAuthorized('update', 'site')): ?> -
-
-
- sitesForm; - $fs = $sitesForm->getFieldsets(); - $fs['teamSites']->setLabel($translate('Add or Remove Sites')); - $sitesForm->prepare(); - echo $this->formCollection($sitesForm); - ?> -
-
-
- -
-

-
- - -form()->closeTag(); ?> +partial('teams/team/edit-form', [ + 'form' => $this->form, + 'resourceForm' => $this->resourceForm, + 'secondaryResourcesForm' => $this->secondaryResourcesForm, + 'sitesForm' => $this->sitesForm, + 'team' => $this->team, + 'bypassTeamFilterRoles' => $this->bypassTeamFilterRoles, + 'user' => $this->user, + 'isAdd' => false, + 'sectionNavEvent' => 'view.edit.section_nav', + 'action' => 'edit', +]); ?> deleteConfirm($this->teamRepresentation, 'team'); ?> From bb2889c3c443667d405cd974ccd3b69f77ee08f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:52:39 +0000 Subject: [PATCH 41/54] Fix role assignment bug, teams column, deprecation warning, and consolidate TeamForm Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- asset/js/team-users.js | 24 +- config/module.config.php | 5 +- src/Controller/AddController.php | 253 ++++++++--------- src/Controller/UpdateController.php | 16 +- src/Form/TeamForm.php | 255 +++++++++++++++++- src/Service/Form/TeamFormFactory.php | 27 ++ .../ViewHelper/TeamSiteUsersFactory.php | 13 + src/View/Helper/TeamSiteUsers.php | 56 ++++ view/omeka/site-admin/index/users.phtml | 58 ++-- view/teams/add/team-add.phtml | 9 +- view/teams/team/edit-form.phtml | 26 +- view/teams/update/team-update.phtml | 3 - 12 files changed, 523 insertions(+), 222 deletions(-) create mode 100644 src/Service/Form/TeamFormFactory.php create mode 100644 src/Service/ViewHelper/TeamSiteUsersFactory.php create mode 100644 src/View/Helper/TeamSiteUsers.php diff --git a/asset/js/team-users.js b/asset/js/team-users.js index 2fec87ad..0dbe049e 100644 --- a/asset/js/team-users.js +++ b/asset/js/team-users.js @@ -1,4 +1,6 @@ -//adapted from site-users.js v4.0.0 +// Adapted from omeka/omeka-s site-users.js. +// Key ordering requirement: Omeka.initializeSelector must run first so that +// the DOM rows exist before we try to set the role '; -if ($teamManagedUsers !== null) { +if ($hasTeamData) { $userRowTemplate .= ' - '; + '; } $userRowTemplate .= ' @@ -70,7 +61,7 @@ foreach ($sitePermissions as $sitePermission) { 'id' => $user->id(), 'role' => $sitePermission->role(), ]; - if ($teamManagedUsers !== null) { + if ($hasTeamData) { $entry['teams-managed'] = !empty($teamManagedUsers[$user->id()]) ? implode(', ', array_unique($teamManagedUsers[$user->id()])) : ''; @@ -81,7 +72,7 @@ foreach ($sitePermissions as $sitePermission) { pageTitle($translate('User permissions'), 1, $translate('Sites')); ?> - +
- + @@ -120,4 +111,21 @@ foreach ($sitePermissions as $sitePermission) { diff --git a/view/teams/add/team-add.phtml b/view/teams/add/team-add.phtml index f5196f72..cd49fb80 100644 --- a/view/teams/add/team-add.phtml +++ b/view/teams/add/team-add.phtml @@ -15,12 +15,9 @@ $this->htmlElement('body')->appendAttribute('class', 'teams add'); partial('teams/team/edit-form', [ 'form' => $this->form, - 'resourceForm' => $this->resourceForm, - 'secondaryResourcesForm' => $this->secondaryResourcesForm, - 'sitesForm' => $this->sitesForm, - 'team' => [], - 'bypassTeamFilterRoles' => [], - 'user' => $this->identity(), + 'team' => $this->team, + 'bypassTeamFilterRoles' => $this->bypassTeamFilterRoles, + 'user' => $this->user, 'isAdd' => true, 'sectionNavEvent' => 'view.add.section_nav', 'action' => 'add', diff --git a/view/teams/team/edit-form.phtml b/view/teams/team/edit-form.phtml index 0487dd12..9eddd8ff 100644 --- a/view/teams/team/edit-form.phtml +++ b/view/teams/team/edit-form.phtml @@ -4,10 +4,7 @@ * Follows the Omeka S form.phtml pattern: one form tag, CSRF at bottom. * * Expected variables: - * @var \Teams\Form\TeamForm $form - * @var \Teams\Form\TeamResourcesForm $resourceForm - * @var \Teams\Form\SecondaryResourcesForm $secondaryResourcesForm - * @var \Teams\Form\TeamSitesAddRemoveForm $sitesForm + * @var \Teams\Form\TeamForm $form The consolidated team form (all sections). * @var \Teams\Entity\Team|array|null $team Null / empty array on the add page. * @var array|null $bypassTeamFilterRoles * @var \Omeka\Entity\User $user @@ -60,13 +57,13 @@ echo $this->form()->openTag($form);
- formRow($resourceForm->get('item_assignment_action')); ?> + formRow($form->get('item_assignment_action')); ?> @@ -90,14 +87,17 @@ echo $this->form()->openTag($form); setLabel($translate('Add and Remove Templates and Item Sets')); if (!$isAdd) { - $secondaryResourcesForm->get('recursive_item_sets') + $form->get('recursive_item_sets') ->setOptions(['label' => $translate('Recursively manage items from item sets?')]); - $secondaryResourcesForm->get('recursive_item_sets') + $form->get('recursive_item_sets') ->setOptions(['info' => $translate('Add/remove all of the items from the item sets when you add/remove them')]); } - echo $this->formCollection($secondaryResourcesForm); + echo $this->formRow($form->get('recursive_item_sets')); + echo $this->formRow($form->get('item_sets')); + echo $this->formRow($form->get('resource_templates')); + echo $this->formRow($form->get('remove_item_sets')); + echo $this->formRow($form->get('remove_resource_templates')); ?>
@@ -111,8 +111,8 @@ echo $this->form()->openTag($form);
get('teamSites')->setLabel($translate('Add or Remove Sites')); - echo $this->formCollection($sitesForm); + $form->get('teamSites')->setLabel($translate('Add or Remove Sites')); + echo $this->formCollection($form->get('teamSites')); ?>
diff --git a/view/teams/update/team-update.phtml b/view/teams/update/team-update.phtml index fc4ae8b2..2954c6a9 100644 --- a/view/teams/update/team-update.phtml +++ b/view/teams/update/team-update.phtml @@ -13,9 +13,6 @@ $this->htmlElement('body')->appendAttribute('class', 'teams edit'); partial('teams/team/edit-form', [ 'form' => $this->form, - 'resourceForm' => $this->resourceForm, - 'secondaryResourcesForm' => $this->secondaryResourcesForm, - 'sitesForm' => $this->sitesForm, 'team' => $this->team, 'bypassTeamFilterRoles' => $this->bypassTeamFilterRoles, 'user' => $this->user, From a2b15671e6e145d17ed93a372461ae1fd8df198d Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Wed, 12 Aug 2026 11:28:30 -0400 Subject: [PATCH 42/54] fix: add allow empty for select fields This is a known issue with Laminas where certain conditions can trigger a "Value is required and can't be empty" validation error. The fix is to explicitly add an allow_empty to the validator. --- src/Form/TeamForm.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Form/TeamForm.php b/src/Form/TeamForm.php index 8e660d47..7578f41a 100644 --- a/src/Form/TeamForm.php +++ b/src/Form/TeamForm.php @@ -237,6 +237,9 @@ public function init(): void $inputFilter = $this->getInputFilter(); $inputFilter->add(['name' => 'item_assignment_action', 'allow_empty' => true]); $inputFilter->add(['name' => 'save_search', 'allow_empty' => true]); + $inputFilter->add(['name' => 'remove_resource_templates', 'allow_empty' => true]); + $inputFilter->add(['name' => 'remove_item_sets', 'allow_empty' => true]); + parent::init(); } From b0d0f4613a9bb25973c904d49d652eeca445b264 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:45:59 +0000 Subject: [PATCH 43/54] fix: normalize team_sites field name and fix o:site allow_empty validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename teamSites → team_sites in TeamForm, TeamSitesAddRemoveForm, both controllers, and the edit-form view to match snake_case convention used by all other fields - Add allow_empty + required=false for the nested o:site input filter so teams can be saved with no sites without a spurious validation error - Fix swapped str_contains arguments in userAllowedToAssign (haystack/needle were reversed for resource-template and media checks) - Remove history-referencing comments from TeamForm docblocks - Update copilot instructions with comment/docblock style guidelines Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- .github/copilot-instructions.md | 3 ++- src/Controller/AddController.php | 2 +- src/Controller/UpdateController.php | 2 +- src/Form/TeamForm.php | 34 +++++++++++------------------ src/Form/TeamSitesAddRemoveForm.php | 6 ++--- view/teams/team/edit-form.phtml | 4 ++-- 6 files changed, 22 insertions(+), 29 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b23f0ad6..c9c18848 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -75,7 +75,8 @@ Omeka S Module (PHP-based) ### PHP Standards - Follow PSR-12 coding standards where applicable - Use proper type hints and return types -- Document complex logic with clear comments +- Document complex logic with inline comments only where strictly needed; comments must describe the current code, never reference previous versions or prior fixes +- Class and method docblocks follow Python/Google style: a single summary sentence, followed by an optional blank line and longer description, `Args:`/`Returns:`/`Throws:` sections where appropriate, and `@param`/`@return` PHPDoc tags for type information - Prefer dependency injection over global state - Use Omeka S service manager patterns diff --git a/src/Controller/AddController.php b/src/Controller/AddController.php index 857d541c..46fe316b 100644 --- a/src/Controller/AddController.php +++ b/src/Controller/AddController.php @@ -79,7 +79,7 @@ public function teamAddAction() } // Sites - foreach ($postData['teamSites']['o:site'] ?? [] as $siteId) { + foreach ($postData['team_sites']['o:site'] ?? [] as $siteId) { $this->api()->create('team-site', [ 'team' => $teamId, 'site' => (int) $siteId, diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 52a35317..ddcf9350 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -134,7 +134,7 @@ public function teamUpdateAction() } // Add or remove site associations. - $postSites = $postData['teamSites']['o:site'] ?? []; + $postSites = $postData['team_sites']['o:site'] ?? []; foreach ($postSites as $siteId) { if (! in_array($siteId, $currentSites)) { $this->api()->create('team-site', ['team' => $teamId, 'site' => $siteId]); diff --git a/src/Form/TeamForm.php b/src/Form/TeamForm.php index 7578f41a..7c9060cf 100644 --- a/src/Form/TeamForm.php +++ b/src/Form/TeamForm.php @@ -11,16 +11,13 @@ use Teams\Form\Element\ResourceTemplateTeamsSelect; /** - * Single consolidated form for creating and editing a team. + * Form for creating and editing a team. * - * Replaces the previous collection of separate forms - * (TeamResourcesForm, SecondaryResourcesForm, TeamSitesAddRemoveForm) that - * the controllers assembled manually. Following the Omeka S ResourceForm - * pattern, all fields live here and are assembled by TeamFormFactory, which - * injects the required services before the form manager calls init(). + * All fields are assembled by TeamFormFactory, which injects the required + * services before the form manager calls init(). * - * Options (passed via $this->getForm(TeamForm::class, $options)): - * team_id int The id of the team being edited, or 0 / absent for a new team. + * Options: + * team_id (int): The id of the team being edited, or 0 / absent for a new team. */ class TeamForm extends Form { @@ -212,17 +209,15 @@ public function init(): void ], ]); - // --- Sites (originally TeamSitesAddRemoveForm) --- + // --- Sites --- $this->add([ - 'name' => 'teamSites', + 'name' => 'team_sites', 'type' => TeamSitesFieldset::class, ]); - // Flatten the nested element name so the POST key is teamSites[o:site][], - // pre-populate with the team's current sites, and add a blank option. - $siteSelect = $this->get('teamSites')->get('o:site'); - $siteSelect->setName('teamSites[o:site]'); + $siteSelect = $this->get('team_sites')->get('o:site'); + $siteSelect->setName('team_sites[o:site]'); $siteSelect->setAttribute('multiple', true); $siteSelect->setAttribute('id', 'sites'); $siteSelect->setEmptyOption('None'); @@ -233,22 +228,19 @@ public function init(): void $siteSelect->setValue(array_values($currentSiteIds)); } - // Input-filter adjustments inherited from SiteResourcesForm. $inputFilter = $this->getInputFilter(); $inputFilter->add(['name' => 'item_assignment_action', 'allow_empty' => true]); $inputFilter->add(['name' => 'save_search', 'allow_empty' => true]); $inputFilter->add(['name' => 'remove_resource_templates', 'allow_empty' => true]); $inputFilter->add(['name' => 'remove_item_sets', 'allow_empty' => true]); + $inputFilter->get('team_sites')->add(['name' => 'o:site', 'allow_empty' => true, 'required' => false]); parent::init(); } /** - * Determines whether the current user may assign the given resource to - * another team by checking whether they can edit it in any of their teams. - * - * Extracted from SecondaryResourcesForm::userAllowed() unchanged. + * Returns true if the current user may assign the given resource to another team. * * @param \Omeka\Api\Representation\AbstractResourceRepresentation $resource */ @@ -259,9 +251,9 @@ private function userAllowedToAssign($resource): bool ->search('team-user', ['user' => $user, 'can_delete_resources' => 1], ['returnScalar' => 'team']) ->getContent(); - if (str_contains('resource-template', $resource->getControllerName())) { + if (str_contains($resource->getControllerName(), 'resource-template')) { $resourceType = 'resource-template'; - } elseif (str_contains($resource->getControllerName(), 'item') || str_contains('media', $resource->getControllerName())) { + } elseif (str_contains($resource->getControllerName(), 'item') || str_contains($resource->getControllerName(), 'media')) { $resourceType = 'resource'; } else { return false; diff --git a/src/Form/TeamSitesAddRemoveForm.php b/src/Form/TeamSitesAddRemoveForm.php index ebfcb2be..84dd0222 100644 --- a/src/Form/TeamSitesAddRemoveForm.php +++ b/src/Form/TeamSitesAddRemoveForm.php @@ -18,12 +18,12 @@ public function __construct($name = null, $options = []) public function init() { $this->add([ - 'name' => 'teamSites', + 'name' => 'team_sites', 'type' => TeamSitesFieldset::class, ]); - $siteSelect = $this->get('teamSites')->get('o:site'); - $siteSelect->setName('teamSites[o:site]'); + $siteSelect = $this->get('team_sites')->get('o:site'); + $siteSelect->setName('team_sites[o:site]'); $siteSelect->setAttribute('multiple', true); $siteSelect->setAttribute('id', 'sites'); $siteSelect->setEmptyOption('None'); diff --git a/view/teams/team/edit-form.phtml b/view/teams/team/edit-form.phtml index 9eddd8ff..70c53866 100644 --- a/view/teams/team/edit-form.phtml +++ b/view/teams/team/edit-form.phtml @@ -111,8 +111,8 @@ echo $this->form()->openTag($form);
get('teamSites')->setLabel($translate('Add or Remove Sites')); - echo $this->formCollection($form->get('teamSites')); + $form->get('team_sites')->setLabel($translate('Add or Remove Sites')); + echo $this->formCollection($form->get('team_sites')); ?>
From 4bb1ec84081bdfc6d0e2ca3491d02c8fd4ca3fdc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:58:50 +0000 Subject: [PATCH 44/54] fix: replace TeamSitesFieldset with direct AllSiteSelect on TeamForm; remove dead form classes The fieldset wrapper caused a naming/nesting mismatch: the element name was manually set to `team_sites[o:site]` while Laminas' input filter still operated on the nested fieldset structure, silently dropping the submitted values. Replacing it with a direct AllSiteSelect element named `team_sites` makes POST data a flat array of site IDs, consistent with what the controllers already expected. Also removed 17 dead form/fieldset classes (TeamSitesFieldset, TeamSitesAddRemoveForm, SecondaryResourcesForm, TeamResourcesForm, TeamDetailsForm, TeamFieldset, TeamItemSetFieldset, TeamItemSetForm, TeamItemsetAddRemoveForm, AddSiteToTeamFieldset, AddSitesToTeam, AddMemberForm, TeamUserForm, TeamAddUserRole, TeamAddUserRoleFieldset) and their service factories, and cleaned up module.config.php accordingly. Fixed setOptions double-call in edit-form.phtml that silently dropped the label option. Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- config/module.config.php | 3 - src/Controller/AddController.php | 2 +- src/Controller/UpdateController.php | 2 +- src/Form/AddMemberForm.php | 29 -- src/Form/AddSiteToTeamFieldset.php | 29 -- src/Form/AddSitesToTeam.php | 21 -- src/Form/SecondaryResourcesForm.php | 252 ------------------ src/Form/TeamAddUserRole.php | 27 -- src/Form/TeamAddUserRoleFieldset.php | 38 --- src/Form/TeamDetailsForm.php | 78 ------ src/Form/TeamFieldset.php | 53 ---- src/Form/TeamForm.php | 34 +-- src/Form/TeamItemSetFieldset.php | 41 --- src/Form/TeamItemSetForm.php | 31 --- src/Form/TeamItemsetAddRemoveForm.php | 23 -- src/Form/TeamResourcesForm.php | 9 - src/Form/TeamSitesAddRemoveForm.php | 44 --- src/Form/TeamSitesFieldset.php | 27 -- src/Form/TeamUserForm.php | 17 -- .../Form/SecondaryResourceFormFactory.php | 20 -- .../Form/TeamSitesAddRemoveFormFactory.php | 16 -- view/teams/team/edit-form.phtml | 13 +- 22 files changed, 25 insertions(+), 784 deletions(-) delete mode 100644 src/Form/AddMemberForm.php delete mode 100644 src/Form/AddSiteToTeamFieldset.php delete mode 100644 src/Form/AddSitesToTeam.php delete mode 100644 src/Form/SecondaryResourcesForm.php delete mode 100644 src/Form/TeamAddUserRole.php delete mode 100644 src/Form/TeamAddUserRoleFieldset.php delete mode 100644 src/Form/TeamDetailsForm.php delete mode 100644 src/Form/TeamFieldset.php delete mode 100644 src/Form/TeamItemSetFieldset.php delete mode 100644 src/Form/TeamItemSetForm.php delete mode 100644 src/Form/TeamItemsetAddRemoveForm.php delete mode 100644 src/Form/TeamResourcesForm.php delete mode 100644 src/Form/TeamSitesAddRemoveForm.php delete mode 100644 src/Form/TeamSitesFieldset.php delete mode 100644 src/Form/TeamUserForm.php delete mode 100644 src/Service/Form/SecondaryResourceFormFactory.php delete mode 100644 src/Service/Form/TeamSitesAddRemoveFormFactory.php diff --git a/config/module.config.php b/config/module.config.php index fe966653..797ba447 100644 --- a/config/module.config.php +++ b/config/module.config.php @@ -83,7 +83,6 @@ ], 'form_elements' => [ 'invokables' => [ - Form\TeamSitesFieldset::class => Form\TeamSitesFieldset::class, ], 'factories' => [ Form\TeamForm::class => Service\Form\TeamFormFactory::class, @@ -96,8 +95,6 @@ Form\Element\AllSiteSelect::class => Service\Form\Element\AllSiteSelectFactory::class, Form\Element\AllSiteSelectOrdered::class => Service\Form\Element\AllSiteSelectOrderedFactory::class, Form\ConfigForm::class => Service\Form\ConfigFormFactory::class, - Form\SecondaryResourcesForm::class => Service\Form\SecondaryResourceFormFactory::class, - Form\TeamSitesAddRemoveForm::class => Service\Form\TeamSitesAddRemoveFormFactory::class, Form\Element\TeamName::class => Service\Form\Element\TeamNameFactory::class, Form\Element\RoleName::class => Service\Form\Element\RoleNameFactory::class, Form\Element\ItemSetTeamSelect::class => Service\Form\Element\ItemSetTeamSelectFactory::class, diff --git a/src/Controller/AddController.php b/src/Controller/AddController.php index 46fe316b..8ba82808 100644 --- a/src/Controller/AddController.php +++ b/src/Controller/AddController.php @@ -79,7 +79,7 @@ public function teamAddAction() } // Sites - foreach ($postData['team_sites']['o:site'] ?? [] as $siteId) { + foreach ($postData['team_sites'] ?? [] as $siteId) { $this->api()->create('team-site', [ 'team' => $teamId, 'site' => (int) $siteId, diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index ddcf9350..21154ed5 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -134,7 +134,7 @@ public function teamUpdateAction() } // Add or remove site associations. - $postSites = $postData['team_sites']['o:site'] ?? []; + $postSites = $postData['team_sites'] ?? []; foreach ($postSites as $siteId) { if (! in_array($siteId, $currentSites)) { $this->api()->create('team-site', ['team' => $teamId, 'site' => $siteId]); diff --git a/src/Form/AddMemberForm.php b/src/Form/AddMemberForm.php deleted file mode 100644 index 6c2d9493..00000000 --- a/src/Form/AddMemberForm.php +++ /dev/null @@ -1,29 +0,0 @@ -setAttribute('id', 'add-member-form'); - - $this->add([ - 'type' => 'hidden', - 'name' => 'user_id' - ]); - - - $this->add([ - 'type' => 'submit', - 'name' => 'submit', - 'attributes' => [ - 'value' => 'Add User to Team', - ], - ]); - } - -} diff --git a/src/Form/AddSiteToTeamFieldset.php b/src/Form/AddSiteToTeamFieldset.php deleted file mode 100644 index 1dc77df3..00000000 --- a/src/Form/AddSiteToTeamFieldset.php +++ /dev/null @@ -1,29 +0,0 @@ -add([ - 'name' => 'o:site', - 'type' => AllSiteSelect::class, - 'options' => [ - 'label' => 'Site', // @translate - 'chosen' => true, - ], - 'attributes' => [ - 'multiple' => true, - ], - - ]); - } -} diff --git a/src/Form/AddSitesToTeam.php b/src/Form/AddSitesToTeam.php deleted file mode 100644 index 3a40275f..00000000 --- a/src/Form/AddSitesToTeam.php +++ /dev/null @@ -1,21 +0,0 @@ -add([ - 'name' => 'site', - 'type' => AddSiteToTeamFieldset::class, - ]); -// $this->add([ -// 'name' => 'user', -// 'type' => TeamUserFieldset::class, -// ]); - } -} diff --git a/src/Form/SecondaryResourcesForm.php b/src/Form/SecondaryResourcesForm.php deleted file mode 100644 index 1ca2ed57..00000000 --- a/src/Form/SecondaryResourcesForm.php +++ /dev/null @@ -1,252 +0,0 @@ -settings->get('teams_filter_bypass_roles'))) { - $show_all_options = in_array($this->authenticationService->getIdentity()->getRole(), $this->settings->get('teams_filter_bypass_roles')); - } else { - $show_all_options = $this->authenticationService->getIdentity()->getRole() === 'global_admin'; - } - - $this->setAttribute('id', 'team-secondary-resources-form'); - - if (array_key_exists('team_id', $this->options)){ - $teamId = $this->options['team_id']; - } else { - $teamId = 0; - } - if ($teamId === 0) { - $disableGroupBy = true; - } else { - $disableGroupBy = false; - } - - $this->add([ - 'name' => 'recursive_item_sets', - 'type' => 'checkbox', - 'options' => [ - 'label' => 'Recursively add items of selected item sets?', // @translate - 'info' => 'This will also add all of the items inside the itemset', // @translate - ], - 'attributes' => [ - 'id' => 'o-modules-team-recursive_item_sets', - 'value' => true, - ], - - ]); - $this->add([ - 'name' => 'item_sets', - 'type' => ItemSetTeamSelect::class, - 'options' => [ - 'disable_group_by' => $disableGroupBy, - 'group_by' => ['team' => $teamId], - 'label' => 'Select Item Sets', - 'query' => ['bypass_team_filter' => true, 'all_user_teams'=>true], - 'filter_resource_representations' => $show_all_options ? "" : function ($itemsets) { - // The user must have permission to assign items to the site. - foreach ($itemsets as $index => $itemset) { - if (!$this->userAllowed($itemset)) { - unset($itemsets[$index]); - } - } - return $itemsets; - }, - - ], - 'attributes' => [ - 'value' => $teamId ? $this->apiManager->search('team-resource', ['team'=>$teamId], ['returnScalar' => 'resource'])->getContent():[], - 'id' => 'o-modules-team-item-sets', - 'class' => 'chosen-select', - 'multiple' => true, - 'data-placeholder' => 'Select item sets', // @translate - - ] - ]); - $this->add([ - 'name' => 'resource_templates', - 'type' => ResourceTemplateTeamsSelect::class, - 'options' => [ - 'disable_group_by' => $disableGroupBy, - 'group_by' => ['team' => $teamId], - 'label' => 'Select Resource Templates', - 'query' => ['bypass_team_filter' => true, 'all_user_teams'=>true], //get all the responses, then filter below as appropriate - 'class' => 'chosen-select', - 'filter_resource_representations' => $show_all_options ? "" : function ($templates) { - // The user must have permission to assign items to the site. - foreach ($templates as $index => $template) { - if (!$this->userAllowed($template)) { - unset($templates[$index]); - } - } - return $templates; - }, - ], - 'attributes' => [ - 'value' => $teamId ? $this->apiManager->search('team-resource-template', ['team'=>$teamId], ['returnScalar' => 'resource_template'])->getContent():[], - 'class' => 'chosen-select', - 'multiple' => true, - 'id' => 'o-modules-team-resource-templates', - 'data-placeholder' => 'Select resource templates', // @translate - - ] - ]); - $this->add([ - 'name' => 'remove_item_sets', - 'type' => Select::class, - 'options' => [ - 'label' => 'Item Sets to be removed', - ], - 'attributes' => [ - 'id' => 'o-modules-team-remove-item-sets', - 'class' => 'chosen-select', - 'multiple' => true, - 'data-placeholder' => 'None', // @translate - - ] - ]); - $this->add([ - 'name' => 'remove_resource_templates', - 'type' => Select::class, - 'options' => [ - 'label' => 'Resource Templates to be removed', - ], - 'attributes' => [ - 'id' => 'o-modules-team-remove-resource-templates', - 'class' => 'chosen-select', - 'multiple' => true, - 'data-placeholder' => 'None', // @translate - ] - ]); - - - - } - - /** - * Determines if the user should be able to add this resource to other teams by checking to see if the user has - * resource editing privileges in any team where the resource is a team_resource. I.e., is there any context where - * the user can edit this item? If so, then they can edit which teams it belongs in. - * - * @param AbstractResourceRepresentation $resource - * @return bool - * - */ - private function userAllowed(AbstractResourceRepresentation $resource): bool - { - $user = $this->authenticationService->getIdentity(); - $users_edit_roles = $this->apiManager->search('team-user', ['user'=> $user, 'can_delete_resources'=>1], ['returnScalar' => 'team'] )->getContent(); - if (str_contains('resource-template', $resource->getControllerName())) { - $resource_type = 'resource-template'; - } elseif(str_contains($resource->getControllerName(), 'item') || str_contains('media', $resource->getControllerName())) { - $resource_type = 'resource'; - } else - { - throw new InvalidArgumentException( - sprintf( - 'Cant create query for "%1$s" type resource.', - $resource->getControllerName() - ) - ); - } - - $resources_teams = $this->apiManager->search('team-' . $resource_type, [$resource_type => $resource->id()], ['returnScalar'=>'team'])->getContent(); - - if (!empty(array_intersect($users_edit_roles, $resources_teams))){ - return true; - } else { - return false; - } - - } - - - /** - * @param ApiManager $apiManager - * @return void - */ - public function setApiManager(ApiManager $apiManager) - { - $this->apiManager = $apiManager; - } - - /** - * @param AuthenticationService $authenticationService - * @return void - */ - public function setAuthService(AuthenticationService $authenticationService) - { - $this->authenticationService = $authenticationService; - } - - - /** - * @param EntityManager $entityManager - */ - public function setEntityManager(EntityManager $entityManager) - { - $this->entityManager = $entityManager; - } - - /** - * @param Settings $settings - */ - public function setSettings(Settings $settings) - { - $this->settings = $settings; - } - - /** - * @return Settings - */ - public function getSettings(): Settings - { - return $this->settings; - } - -} \ No newline at end of file diff --git a/src/Form/TeamAddUserRole.php b/src/Form/TeamAddUserRole.php deleted file mode 100644 index b0e94553..00000000 --- a/src/Form/TeamAddUserRole.php +++ /dev/null @@ -1,27 +0,0 @@ -add([ - 'name' => 'user and role', - 'type' => TeamAddUserRoleFieldset::class, - ]); - - - $this->add([ - 'name' => 'add', - 'type' => 'button', - 'options' => [ - 'label' => 'add' - ] - - ]); - } -} diff --git a/src/Form/TeamAddUserRoleFieldset.php b/src/Form/TeamAddUserRoleFieldset.php deleted file mode 100644 index 4accadc2..00000000 --- a/src/Form/TeamAddUserRoleFieldset.php +++ /dev/null @@ -1,38 +0,0 @@ -add([ - 'name' => 'o:user', - 'type' => UserSelect::class, - 'options' => [ - 'label' => 'User', // @translate - 'chosen' => true, - ], - 'attributes' => [ - ], - - ]); - - $this->add([ - 'name' => 'o:role', - 'type' => RoleSelect::class, - 'options' => [ - 'label' => 'Role', // @translate - 'chosen' => true, - ], - 'attributes' => [ - ], - - ]); - } -} diff --git a/src/Form/TeamDetailsForm.php b/src/Form/TeamDetailsForm.php deleted file mode 100644 index 4fec981f..00000000 --- a/src/Form/TeamDetailsForm.php +++ /dev/null @@ -1,78 +0,0 @@ -setAttribute('id', 'team-update-form'); - - $this->add([ - 'type' => 'hidden', - 'name' => 'id', - - ]); - - $this->add([ - //extremely important that this match what is in the API adapter: Teams\Api\Representation getJsonLd() - 'name' => 'o:name', - 'type' => 'Text', - 'options' => [ - 'label' => 'Name', // @translate - ], - 'attributes' => [ - 'id' => 'name', - 'required' => true, - ], - ]); - - $this->add([ - - //extremely important that this match what is in the API adapter: Teams\Api\Representation getJsonLd() - 'name' => 'o:description', - 'type' => 'Text', - 'options' => [ - 'label' => 'Description', // @translate - ], - 'attributes' => [ - 'id' => 'comment', - ], - ]); - - - //if the other option from the view won't work, this is a kinda clunky option. Better would be to use some JS - //to make make an interactive grab-and-put form -// $this->add( -// array('name' => 'o:user_add[]', -// 'type' => 'Select', -// 'options' => array( -// 'label' => 'Add a User to the Team', -// 'empty_option' => 'Select a User', -// 'value_options' => array() -// ) -// )); -// -// $this->add([ -// 'name' => 'o:user_remove', -// 'type' => 'Select', -// 'options' => array( -// 'label' => 'Remove a User from the Team', -// 'empty_option' => 'Select a User', -// 'value_options' => array() -// ) -// ]); - -// $this->add([ -// 'type' => 'submit', -// 'name' => 'submit', -// 'attributes' => [ -// 'value' => 'Update Team', -// ], -// ]); - } - -} diff --git a/src/Form/TeamFieldset.php b/src/Form/TeamFieldset.php deleted file mode 100644 index e96def62..00000000 --- a/src/Form/TeamFieldset.php +++ /dev/null @@ -1,53 +0,0 @@ -setAttribute('id', 'team-form'); - - $this->add([ - 'type' => 'hidden', - 'name' => 'id', - - ]); - - $this->add([ - //extremely important that this match what is in the API adapter: Teams\Api\Representation getJsonLd() - 'name' => 'o:name', - 'type' => TeamName::class, - 'options' => [ - 'label' => 'Name', // @translate - ], - 'attributes' => [ - 'id' => 'name', - 'required' => true, - ], - ]); - - $this->add([ - - //extremely important that this match what is in the API adapter: Teams\Api\Representation getJsonLd() - 'name' => 'o:description', - 'type' => 'Text', - 'options' => [ - 'label' => 'Description', // @translate - ], - 'attributes' => [ - 'id' => 'comment', - 'required' => false, - ], - ]); - } -} diff --git a/src/Form/TeamForm.php b/src/Form/TeamForm.php index 7c9060cf..4c2f72e0 100644 --- a/src/Form/TeamForm.php +++ b/src/Form/TeamForm.php @@ -7,6 +7,7 @@ use Omeka\Api\Manager as ApiManager; use Omeka\Form\Element as OmekaElement; use Omeka\Settings\Settings; +use Teams\Form\Element\AllSiteSelect; use Teams\Form\Element\ItemSetTeamSelect; use Teams\Form\Element\ResourceTemplateTeamsSelect; @@ -67,7 +68,7 @@ public function init(): void ], ]); - // --- Item management (originally TeamResourcesForm / SiteResourcesForm) --- + // --- Item management --- $this->add([ 'type' => 'radio', @@ -105,7 +106,7 @@ public function init(): void ], ]); - // --- Secondary resources (originally SecondaryResourcesForm) --- + // --- Secondary resources --- $bypassRoles = $this->settings->get('teams_filter_bypass_roles'); $showAllOptions = is_array($bypassRoles) @@ -213,27 +214,28 @@ public function init(): void $this->add([ 'name' => 'team_sites', - 'type' => TeamSitesFieldset::class, + 'type' => AllSiteSelect::class, + 'options' => [ + 'label' => 'Sites', // @translate + 'info' => 'The sites that should belong to this team', // @translate + ], + 'attributes' => [ + 'id' => 'sites', + 'multiple' => true, + 'value' => $isNewTeam + ? [] + : array_values($this->apiManager + ->search('team-site', ['team' => $teamId], ['returnScalar' => 'site']) + ->getContent()), + ], ]); - $siteSelect = $this->get('team_sites')->get('o:site'); - $siteSelect->setName('team_sites[o:site]'); - $siteSelect->setAttribute('multiple', true); - $siteSelect->setAttribute('id', 'sites'); - $siteSelect->setEmptyOption('None'); - if (!$isNewTeam) { - $currentSiteIds = $this->apiManager - ->search('team-site', ['team' => $teamId], ['returnScalar' => 'site']) - ->getContent(); - $siteSelect->setValue(array_values($currentSiteIds)); - } - $inputFilter = $this->getInputFilter(); $inputFilter->add(['name' => 'item_assignment_action', 'allow_empty' => true]); $inputFilter->add(['name' => 'save_search', 'allow_empty' => true]); $inputFilter->add(['name' => 'remove_resource_templates', 'allow_empty' => true]); $inputFilter->add(['name' => 'remove_item_sets', 'allow_empty' => true]); - $inputFilter->get('team_sites')->add(['name' => 'o:site', 'allow_empty' => true, 'required' => false]); + $inputFilter->add(['name' => 'team_sites', 'allow_empty' => true, 'required' => false]); parent::init(); diff --git a/src/Form/TeamItemSetFieldset.php b/src/Form/TeamItemSetFieldset.php deleted file mode 100644 index 3f8d7e89..00000000 --- a/src/Form/TeamItemSetFieldset.php +++ /dev/null @@ -1,41 +0,0 @@ -add([ - 'name' => 'o:itemset', - 'type' => AllItemSetSelect::class, - 'options' => [ - 'label' => 'Item Set', // @translate - 'chosen' => true, - ], - 'attributes' => [ - 'multiple' => true, - ], - - ]); - - $this->add([ - 'name' => 'o:user', - 'type' => UserSelect::class, - 'options' => [ - 'label' => 'Users', // @translate - 'chosen' => true, - ], - 'attributes' => [ - 'multiple' => true, - ], - - ]); - } -} diff --git a/src/Form/TeamItemSetForm.php b/src/Form/TeamItemSetForm.php deleted file mode 100644 index a718fadf..00000000 --- a/src/Form/TeamItemSetForm.php +++ /dev/null @@ -1,31 +0,0 @@ -add([ - 'name' => 'itemset', - 'type' => 'fieldset', - ]); - $this->add([ - 'name' => 'site', - 'type' => 'fieldset', - ]); - - $this->get('itemset')->add([ - 'name' => 'itemset', - 'type' => TeamItemSetFieldset::class, - ]); - - $this->get('site')->add([ - 'name' => 'site', - 'type' => AddSiteToTeamFieldset::class, - ]); - } -} diff --git a/src/Form/TeamItemsetAddRemoveForm.php b/src/Form/TeamItemsetAddRemoveForm.php deleted file mode 100644 index edac28ed..00000000 --- a/src/Form/TeamItemsetAddRemoveForm.php +++ /dev/null @@ -1,23 +0,0 @@ -add([ - 'name' => 'addCollections', - 'type' => TeamItemSetFieldset::class, - ]); - - $this->add([ - 'name' => 'rmCollections', - 'type' => TeamItemSetFieldset::class, - - ]); - } -} diff --git a/src/Form/TeamResourcesForm.php b/src/Form/TeamResourcesForm.php deleted file mode 100644 index da90f386..00000000 --- a/src/Form/TeamResourcesForm.php +++ /dev/null @@ -1,9 +0,0 @@ -add([ - 'name' => 'team_sites', - 'type' => TeamSitesFieldset::class, - ]); - - $siteSelect = $this->get('team_sites')->get('o:site'); - $siteSelect->setName('team_sites[o:site]'); - $siteSelect->setAttribute('multiple', true); - $siteSelect->setAttribute('id', 'sites'); - $siteSelect->setEmptyOption('None'); - - $teamId = $this->options['team_id'] ?? null; - if ($this->apiManager && $teamId) { - $currentSiteIds = $this->apiManager - ->search('team-site', ['team' => $teamId], ['returnScalar' => 'site']) - ->getContent(); - $siteSelect->setValue(array_values($currentSiteIds)); - } - } - - public function setApiManager(ApiManager $apiManager): void - { - $this->apiManager = $apiManager; - } -} diff --git a/src/Form/TeamSitesFieldset.php b/src/Form/TeamSitesFieldset.php deleted file mode 100644 index d0f40cd8..00000000 --- a/src/Form/TeamSitesFieldset.php +++ /dev/null @@ -1,27 +0,0 @@ -add([ - 'name' => 'o:site', - 'type' => AllSiteSelect::class, - 'options' => [ - 'label' => 'Sites', // @translate - 'chosen' => true, - 'info' => 'The sites that should belong to this team' - ], - 'attributes' => [ - 'multiple' => true, - ], - - ]); - } -} diff --git a/src/Form/TeamUserForm.php b/src/Form/TeamUserForm.php deleted file mode 100644 index f282236b..00000000 --- a/src/Form/TeamUserForm.php +++ /dev/null @@ -1,17 +0,0 @@ -add([ - 'name' => 'team', - 'type' => TeamUserFieldset::class, - ]); - } -} diff --git a/src/Service/Form/SecondaryResourceFormFactory.php b/src/Service/Form/SecondaryResourceFormFactory.php deleted file mode 100644 index 97b8fb0f..00000000 --- a/src/Service/Form/SecondaryResourceFormFactory.php +++ /dev/null @@ -1,20 +0,0 @@ -setApiManager($services->get('Omeka\ApiManager')); - $form->setAuthService($services->get('Omeka\AuthenticationService')); - $form->setEntityManager($services->get('Omeka\EntityManager')); - $form->setSettings($services->get('Omeka\Settings')); - return $form; - } -} \ No newline at end of file diff --git a/src/Service/Form/TeamSitesAddRemoveFormFactory.php b/src/Service/Form/TeamSitesAddRemoveFormFactory.php deleted file mode 100644 index 73dee1c0..00000000 --- a/src/Service/Form/TeamSitesAddRemoveFormFactory.php +++ /dev/null @@ -1,16 +0,0 @@ -setApiManager($services->get('Omeka\ApiManager')); - return $form; - } -} diff --git a/view/teams/team/edit-form.phtml b/view/teams/team/edit-form.phtml index 70c53866..1781aeed 100644 --- a/view/teams/team/edit-form.phtml +++ b/view/teams/team/edit-form.phtml @@ -88,10 +88,10 @@ echo $this->form()->openTag($form); get('recursive_item_sets') - ->setOptions(['label' => $translate('Recursively manage items from item sets?')]); - $form->get('recursive_item_sets') - ->setOptions(['info' => $translate('Add/remove all of the items from the item sets when you add/remove them')]); + $form->get('recursive_item_sets')->setOptions([ + 'label' => $translate('Recursively manage items from item sets?'), + 'info' => $translate('Add/remove all of the items from the item sets when you add/remove them'), + ]); } echo $this->formRow($form->get('recursive_item_sets')); echo $this->formRow($form->get('item_sets')); @@ -110,10 +110,7 @@ echo $this->form()->openTag($form);
- get('team_sites')->setLabel($translate('Add or Remove Sites')); - echo $this->formCollection($form->get('team_sites')); - ?> + formRow($form->get('team_sites')); ?>
From 4ce1a5bcbc6c25a120ac10337958daa3a96414e2 Mon Sep 17 00:00:00 2001 From: Alex Dryden Date: Wed, 12 Aug 2026 12:05:01 -0400 Subject: [PATCH 45/54] fix: restore chosen-select class to sites --- src/Form/TeamForm.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Form/TeamForm.php b/src/Form/TeamForm.php index 4c2f72e0..f29644f3 100644 --- a/src/Form/TeamForm.php +++ b/src/Form/TeamForm.php @@ -221,6 +221,7 @@ public function init(): void ], 'attributes' => [ 'id' => 'sites', + 'class' => 'chosen-select', 'multiple' => true, 'value' => $isNewTeam ? [] From 4d33a400d5d6a39b07e05ac34e4575b8c448c810 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:26:59 +0000 Subject: [PATCH 46/54] Apply remaining changes Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Api/Adapter/TeamAdapter.php | 165 ++++++++++++++++++++++++++++ src/Controller/UpdateController.php | 65 ++--------- src/Form/TeamForm.php | 33 ------ view/teams/team/edit-form.phtml | 2 - 4 files changed, 172 insertions(+), 93 deletions(-) diff --git a/src/Api/Adapter/TeamAdapter.php b/src/Api/Adapter/TeamAdapter.php index 5715f295..9a6bfb31 100644 --- a/src/Api/Adapter/TeamAdapter.php +++ b/src/Api/Adapter/TeamAdapter.php @@ -5,6 +5,10 @@ use Omeka\Api\Adapter\AbstractAdapter; use Teams\Api\Representation\TeamRepresentation; use Teams\Entity\Team; +use Teams\Entity\TeamResource; +use Teams\Entity\TeamResourceTemplate; +use Teams\Entity\TeamSite; +use Teams\Entity\TeamUser; use Omeka\Api\Adapter\AbstractEntityAdapter; use Omeka\Api\Request; use Omeka\Entity\EntityInterface; @@ -49,6 +53,167 @@ public function hydrate( $entity->setDescription($description); } } + + $em = $this->getEntityManager(); + + // Association diffs are only meaningful on UPDATE, when the entity + // already exists. The AddController handles these independently. + if (Request::UPDATE === $request->getOperation()) { + // Hydrate team users: accepts o:team_users as an array of + // {o:user: {o:id: N}, o:team_role: {o:id: N}} entries. + if ($this->shouldHydrate($request, 'o:team_users')) { + $teamUsers = $request->getValue('o:team_users', []); + $teamId = $entity->getId(); + + $submittedUserIds = array_column( + array_column($teamUsers, 'o:user'), + 'o:id' + ); + + $existingTeamUsers = $em->getRepository(TeamUser::class) + ->findBy(['team' => $teamId]); + + // Remove users no longer in the submitted list. + foreach ($existingTeamUsers as $existingTeamUser) { + $userId = $existingTeamUser->getUser()->getId(); + if (!in_array($userId, $submittedUserIds)) { + $em->remove($existingTeamUser); + } + } + + // Index existing by user id for quick lookup. + $existingByUserId = []; + foreach ($existingTeamUsers as $existingTeamUser) { + $existingByUserId[$existingTeamUser->getUser()->getId()] = $existingTeamUser; + } + + foreach ($teamUsers as $teamUserData) { + $userId = (int) $teamUserData['o:user']['o:id']; + $roleId = (int) $teamUserData['o:team_role']['o:id']; + + $userEntity = $em->find('Omeka\Entity\User', $userId); + $roleEntity = $em->find('Teams\Entity\TeamRole', $roleId); + + if (!$userEntity || !$roleEntity) { + continue; + } + + if (isset($existingByUserId[$userId])) { + // Update role if changed. + $existingByUserId[$userId]->setRole($roleEntity); + } else { + $newTeamUser = new TeamUser($entity, $userEntity, $roleEntity); + $em->persist($newTeamUser); + } + } + } + + // Hydrate site associations: accepts o:team_sites as an array of site IDs. + if ($this->shouldHydrate($request, 'o:team_sites')) { + $submittedSiteIds = array_map('intval', (array) $request->getValue('o:team_sites', [])); + $teamId = $entity->getId(); + + $existingTeamSites = $em->getRepository(TeamSite::class) + ->findBy(['team' => $teamId]); + + $existingSiteIds = array_map( + fn(TeamSite $ts) => $ts->getSite()->getId(), + $existingTeamSites + ); + + // Remove sites no longer in submitted list. + foreach ($existingTeamSites as $existingTeamSite) { + if (!in_array($existingTeamSite->getSite()->getId(), $submittedSiteIds)) { + $em->remove($existingTeamSite); + } + } + + // Add new sites. + foreach ($submittedSiteIds as $siteId) { + if (!in_array($siteId, $existingSiteIds)) { + $siteEntity = $em->find('Omeka\Entity\Site', $siteId); + if ($siteEntity) { + $em->persist(new TeamSite($entity, $siteEntity)); + } + } + } + } + + // Hydrate item-set associations: accepts o:item_sets as an array of + // resource IDs and o:recursive_item_sets as a boolean flag. + if ($this->shouldHydrate($request, 'o:item_sets')) { + $submittedItemSetIds = array_map('intval', (array) $request->getValue('o:item_sets', [])); + $recursive = (bool) $request->getValue('o:recursive_item_sets', false); + $teamId = $entity->getId(); + + $apiManager = $this->getServiceLocator()->get('Omeka\ApiManager'); + + $existingItemSetIds = $apiManager + ->search('team-resource', ['team' => $teamId], ['returnScalar' => 'resource']) + ->getContent(); + + // Remove item sets no longer in submitted list. + foreach ($existingItemSetIds as $existingId) { + if (!in_array((int) $existingId, $submittedItemSetIds)) { + $apiManager->delete( + 'team-resource', + [], + ['team' => $teamId, 'resource' => $existingId], + ['recursive' => $recursive, 'syncSites' => true, 'flushEntityManager' => false] + ); + } + } + + // Add new item sets. + foreach ($submittedItemSetIds as $itemSetId) { + if (!in_array($itemSetId, array_map('intval', $existingItemSetIds))) { + $apiManager->create( + 'team-resource', + ['team' => $teamId, 'resource' => $itemSetId], + [], + ['recursive' => $recursive, 'syncSites' => true, 'flushEntityManager' => false] + ); + } + } + } + + // Hydrate resource-template associations: accepts o:resource_templates + // as an array of resource-template IDs. + if ($this->shouldHydrate($request, 'o:resource_templates')) { + $submittedTemplateIds = array_map('intval', (array) $request->getValue('o:resource_templates', [])); + $teamId = $entity->getId(); + + $apiManager = $this->getServiceLocator()->get('Omeka\ApiManager'); + + $existingTemplateIds = $apiManager + ->search('team-resource-template', ['team' => $teamId], ['returnScalar' => 'resource_template']) + ->getContent(); + + // Remove templates no longer in submitted list. + foreach ($existingTemplateIds as $existingId) { + if (!in_array((int) $existingId, $submittedTemplateIds)) { + $apiManager->delete( + 'team-resource-template', + [], + ['team' => $teamId, 'resource-template' => $existingId], + ['flushEntityManager' => false] + ); + } + } + + // Add new templates. + foreach ($submittedTemplateIds as $templateId) { + if (!in_array($templateId, array_map('intval', $existingTemplateIds))) { + $apiManager->create( + 'team-resource-template', + ['team' => $teamId, 'resource-template' => $templateId], + [], + ['flushEntityManager' => false] + ); + } + } + } + } // end if (Request::UPDATE === ...) } public function buildQuery(QueryBuilder $qb, array $query) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 21154ed5..35fac179 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -42,7 +42,6 @@ public function teamUpdateAction() $teamRepresentation = $this->api()->read('team', ['id' => $teamId])->getContent(); $team = $this->entityManager->getRepository('Teams\Entity\Team')->findOneBy(['id' => $teamId]); - $currentSites = $this->api()->search('team-site', ['team' => $teamId], ['returnScalar' => 'site'])->getContent(); /** @var TeamForm $form */ $form = $this->getForm(TeamForm::class, ['team_id' => $teamId]); @@ -77,31 +76,15 @@ public function teamUpdateAction() } $this->api($form)->update('team', $teamId, [ - 'o:name' => $postData['o:name'], - 'o:description' => $postData['o:description'], + 'o:name' => $postData['o:name'], + 'o:description' => $postData['o:description'], + 'o:team_users' => $postData['o:team_users'] ?? [], + 'o:team_sites' => $postData['team_sites'] ?? [], + 'o:item_sets' => $postData['item_sets'] ?? [], + 'o:recursive_item_sets' => $postData['recursive_item_sets'] ?? false, + 'o:resource_templates' => $postData['resource_templates'] ?? [], ]); - // Remove users no longer in the submitted list, then add or update the rest. - $teamUsers = $request->getPost('o:team_users', []); - $formTeamUserIds = array_column(array_column($teamUsers, 'o:user'), 'o:id'); - - $oldTeamUserIds = $this->api()->search('team-user', ['team' => $teamId], ['returnScalar' => 'user'])->getContent(); - foreach ($oldTeamUserIds as $oldUserId) { - if (! in_array($oldUserId, $formTeamUserIds)) { - $this->api()->delete('team-user', ['team' => $teamId, 'user' => $oldUserId]); - } - } - foreach ($teamUsers as $teamUser) { - $userId = $teamUser['o:user']['o:id']; - $roleId = $teamUser['o:team_role']['o:id']; - $exists = $this->api()->search('team-user', ['team' => $teamId, 'user' => $userId])->getContent(); - if ($exists) { - $this->api()->update('team-user', ['team' => $teamId, 'user' => $userId], ['role' => $roleId]); - } else { - $this->api()->create('team-user', ['team' => $teamId, 'user' => $userId, 'role' => $roleId]); - } - } - // Dispatch a background job for item assignment if an action was requested. parse_str($postData['item_pool'] ?? '', $itemPool); if (! empty($postData['item_assignment_action']) && $postData['item_assignment_action'] !== 'no_action') { @@ -112,40 +95,6 @@ public function teamUpdateAction() $this->messenger()->addSuccess('Item assignment in progress. To see the new item count, refresh the page.'); // @translate } - // Add or remove item sets and resource templates. - $recursive = $postData['recursive_item_sets'] ?? false; - foreach ($postData['remove_item_sets'] ?? [] as $itemSetId) { - $this->api()->delete('team-resource', [], ['team' => $teamId, 'resource' => $itemSetId], ['recursive' => $recursive, 'syncSites' => true]); - } - foreach ($postData['item_sets'] ?? [] as $itemSetId) { - if (count($this->api()->search('team-resource', ['team' => $teamId, 'resource' => $itemSetId])->getContent()) < 1) { - $this->api()->create('team-resource', ['team' => $teamId, 'resource' => $itemSetId], [], ['recursive' => $recursive, 'syncSites' => true]); - } - } - - // Process resource templates. - foreach ($postData['remove_resource_templates'] ?? [] as $templateId) { - $this->api()->delete('team-resource-template', [], ['team' => $teamId, 'resource-template' => $templateId]); - } - foreach ($postData['resource_templates'] ?? [] as $templateId) { - if (count($this->api()->search('team-resource-template', ['team' => $teamId, 'resource-template' => $templateId])->getContent()) < 1) { - $this->api()->create('team-resource-template', ['team' => $teamId, 'resource-template' => $templateId]); - } - } - - // Add or remove site associations. - $postSites = $postData['team_sites'] ?? []; - foreach ($postSites as $siteId) { - if (! in_array($siteId, $currentSites)) { - $this->api()->create('team-site', ['team' => $teamId, 'site' => $siteId]); - } - } - foreach ($currentSites as $siteId) { - if (! in_array($siteId, $postSites)) { - $this->api()->delete('team-site', ['team' => $teamId, 'site' => $siteId]); - } - } - $this->sitePermissionManager->syncAllSitePermissions(); $this->messenger()->addSuccess(sprintf("Successfully updated the %s team", $team->getName())); diff --git a/src/Form/TeamForm.php b/src/Form/TeamForm.php index f29644f3..936a7138 100644 --- a/src/Form/TeamForm.php +++ b/src/Form/TeamForm.php @@ -2,7 +2,6 @@ namespace Teams\Form; use Laminas\Authentication\AuthenticationService; -use Laminas\Form\Element\Select; use Laminas\Form\Form; use Omeka\Api\Manager as ApiManager; use Omeka\Form\Element as OmekaElement; @@ -182,36 +181,6 @@ public function init(): void ], ]); - $this->add([ - 'name' => 'remove_item_sets', - 'type' => Select::class, - 'options' => [ - 'label' => 'Item Sets to be removed', // @translate - ], - 'attributes' => [ - 'id' => 'o-modules-team-remove-item-sets', - 'class' => 'chosen-select', - 'multiple' => true, - 'data-placeholder' => 'None', // @translate - ], - ]); - - $this->add([ - 'name' => 'remove_resource_templates', - 'type' => Select::class, - 'options' => [ - 'label' => 'Resource Templates to be removed', // @translate - ], - 'attributes' => [ - 'id' => 'o-modules-team-remove-resource-templates', - 'class' => 'chosen-select', - 'multiple' => true, - 'data-placeholder' => 'None', // @translate - ], - ]); - - // --- Sites --- - $this->add([ 'name' => 'team_sites', 'type' => AllSiteSelect::class, @@ -234,8 +203,6 @@ public function init(): void $inputFilter = $this->getInputFilter(); $inputFilter->add(['name' => 'item_assignment_action', 'allow_empty' => true]); $inputFilter->add(['name' => 'save_search', 'allow_empty' => true]); - $inputFilter->add(['name' => 'remove_resource_templates', 'allow_empty' => true]); - $inputFilter->add(['name' => 'remove_item_sets', 'allow_empty' => true]); $inputFilter->add(['name' => 'team_sites', 'allow_empty' => true, 'required' => false]); diff --git a/view/teams/team/edit-form.phtml b/view/teams/team/edit-form.phtml index 1781aeed..c4f14ab9 100644 --- a/view/teams/team/edit-form.phtml +++ b/view/teams/team/edit-form.phtml @@ -96,8 +96,6 @@ echo $this->form()->openTag($form); echo $this->formRow($form->get('recursive_item_sets')); echo $this->formRow($form->get('item_sets')); echo $this->formRow($form->get('resource_templates')); - echo $this->formRow($form->get('remove_item_sets')); - echo $this->formRow($form->get('remove_resource_templates')); ?>
From 398854bf6d83505ba56fa9a586bc180dee58200d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:55:47 +0000 Subject: [PATCH 47/54] fix: replace nested API calls in hydrate with direct EM ops; fix undefined $entity in TeamResourceAdapter::delete Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Api/Adapter/TeamAdapter.php | 176 +++++++++++++++++++----- src/Api/Adapter/TeamResourceAdapter.php | 1 + 2 files changed, 140 insertions(+), 37 deletions(-) diff --git a/src/Api/Adapter/TeamAdapter.php b/src/Api/Adapter/TeamAdapter.php index 9a6bfb31..94755d85 100644 --- a/src/Api/Adapter/TeamAdapter.php +++ b/src/Api/Adapter/TeamAdapter.php @@ -141,75 +141,129 @@ public function hydrate( // Hydrate item-set associations: accepts o:item_sets as an array of // resource IDs and o:recursive_item_sets as a boolean flag. + // Direct EntityManager operations are used here (not the API manager) + // to avoid nested API calls from within hydrate(), which would trigger + // Omeka's response-validation and cause a BadResponseException. if ($this->shouldHydrate($request, 'o:item_sets')) { $submittedItemSetIds = array_map('intval', (array) $request->getValue('o:item_sets', [])); $recursive = (bool) $request->getValue('o:recursive_item_sets', false); $teamId = $entity->getId(); - $apiManager = $this->getServiceLocator()->get('Omeka\ApiManager'); + $existingTeamResources = $em->getRepository(TeamResource::class) + ->findBy(['team' => $teamId]); + + $existingItemSetIds = array_map( + fn(TeamResource $tr) => $tr->getResource()->getId(), + $existingTeamResources + ); + + // Build a lookup of existing TeamResource entities by resource id. + $existingByResourceId = []; + foreach ($existingTeamResources as $tr) { + $existingByResourceId[$tr->getResource()->getId()] = $tr; + } - $existingItemSetIds = $apiManager - ->search('team-resource', ['team' => $teamId], ['returnScalar' => 'resource']) - ->getContent(); + // Collect team sites once if syncSites is needed. + $teamSiteEntities = $em->getRepository(TeamSite::class)->findBy(['team' => $teamId]); // Remove item sets no longer in submitted list. foreach ($existingItemSetIds as $existingId) { - if (!in_array((int) $existingId, $submittedItemSetIds)) { - $apiManager->delete( - 'team-resource', - [], - ['team' => $teamId, 'resource' => $existingId], - ['recursive' => $recursive, 'syncSites' => true, 'flushEntityManager' => false] - ); + if (!in_array($existingId, $submittedItemSetIds)) { + $resourceEntity = $em->find('Omeka\Entity\Resource', $existingId); + if ($resourceEntity) { + // Recursively remove child resources when requested. + if ($recursive && $resourceEntity->getResourceName() === 'item_sets') { + $childResources = $em->getRepository('Omeka\Entity\Resource') + ->createQueryBuilder('r') + ->join('Omeka\Entity\Item', 'i', 'WITH', 'i.id = r.id') + ->join('i.itemSets', 'is') + ->where('is.id = :itemSetId') + ->setParameter('itemSetId', $existingId) + ->getQuery() + ->getResult(); + foreach ($childResources as $childResource) { + $childTeamResource = $em->getRepository(TeamResource::class) + ->findOneBy(['team' => $teamId, 'resource' => $childResource->getId()]); + if ($childTeamResource) { + $this->removeSiteAssociations($childResource, $teamSiteEntities); + $em->remove($childTeamResource); + } + } + } + // Sync site associations. + $this->removeSiteAssociations($resourceEntity, $teamSiteEntities); + $em->remove($existingByResourceId[$existingId]); + } } } // Add new item sets. foreach ($submittedItemSetIds as $itemSetId) { - if (!in_array($itemSetId, array_map('intval', $existingItemSetIds))) { - $apiManager->create( - 'team-resource', - ['team' => $teamId, 'resource' => $itemSetId], - [], - ['recursive' => $recursive, 'syncSites' => true, 'flushEntityManager' => false] - ); + if (!in_array($itemSetId, $existingItemSetIds)) { + $resourceEntity = $em->find('Omeka\Entity\Resource', $itemSetId); + if (!$resourceEntity) { + continue; + } + $em->persist(new TeamResource($entity, $resourceEntity)); + // Sync site associations. + $this->addSiteAssociations($resourceEntity, $teamSiteEntities); + // Recursively add child resources when requested. + if ($recursive && $resourceEntity->getResourceName() === 'item_sets') { + $childResources = $em->getRepository('Omeka\Entity\Resource') + ->createQueryBuilder('r') + ->join('Omeka\Entity\Item', 'i', 'WITH', 'i.id = r.id') + ->join('i.itemSets', 'is') + ->where('is.id = :itemSetId') + ->setParameter('itemSetId', $itemSetId) + ->getQuery() + ->getResult(); + foreach ($childResources as $childResource) { + $alreadyExists = $em->getRepository(TeamResource::class) + ->findOneBy(['team' => $teamId, 'resource' => $childResource->getId()]); + if (!$alreadyExists) { + $em->persist(new TeamResource($entity, $childResource)); + $this->addSiteAssociations($childResource, $teamSiteEntities); + } + } + } } } } // Hydrate resource-template associations: accepts o:resource_templates - // as an array of resource-template IDs. + // as an array of resource-template IDs. Direct EM ops for same reason + // as item sets above. if ($this->shouldHydrate($request, 'o:resource_templates')) { $submittedTemplateIds = array_map('intval', (array) $request->getValue('o:resource_templates', [])); $teamId = $entity->getId(); - $apiManager = $this->getServiceLocator()->get('Omeka\ApiManager'); + $existingTeamTemplates = $em->getRepository(TeamResourceTemplate::class) + ->findBy(['team' => $teamId]); + + $existingTemplateIds = array_map( + fn(TeamResourceTemplate $trt) => $trt->getResourceTemplate()->getId(), + $existingTeamTemplates + ); - $existingTemplateIds = $apiManager - ->search('team-resource-template', ['team' => $teamId], ['returnScalar' => 'resource_template']) - ->getContent(); + $existingByTemplateId = []; + foreach ($existingTeamTemplates as $trt) { + $existingByTemplateId[$trt->getResourceTemplate()->getId()] = $trt; + } // Remove templates no longer in submitted list. foreach ($existingTemplateIds as $existingId) { - if (!in_array((int) $existingId, $submittedTemplateIds)) { - $apiManager->delete( - 'team-resource-template', - [], - ['team' => $teamId, 'resource-template' => $existingId], - ['flushEntityManager' => false] - ); + if (!in_array($existingId, $submittedTemplateIds)) { + $em->remove($existingByTemplateId[$existingId]); } } // Add new templates. foreach ($submittedTemplateIds as $templateId) { - if (!in_array($templateId, array_map('intval', $existingTemplateIds))) { - $apiManager->create( - 'team-resource-template', - ['team' => $teamId, 'resource-template' => $templateId], - [], - ['flushEntityManager' => false] - ); + if (!in_array($templateId, $existingTemplateIds)) { + $templateEntity = $em->find('Omeka\Entity\ResourceTemplate', $templateId); + if ($templateEntity) { + $em->persist(new TeamResourceTemplate($entity, $templateEntity)); + } } } } @@ -360,4 +414,52 @@ public function batchDelete(Request $request) { AbstractAdapter::batchDelete($request); } + + /** + * Adds team-site site memberships to a resource entity. + * + * Called when an item set or child item is added to a team so that the + * resource is also associated with every site the team belongs to. + * + * @param \Omeka\Entity\Resource $resource + * @param TeamSite[] $teamSites + */ + private function addSiteAssociations($resource, array $teamSites): void + { + if (!method_exists($resource, 'getSites') || empty($teamSites)) { + return; + } + $itemSites = $resource->getSites(); + $em = $this->getEntityManager(); + foreach ($teamSites as $teamSite) { + $siteEntity = $em->find('Omeka\Entity\Site', $teamSite->getSite()->getId()); + if ($siteEntity && !$itemSites->contains($siteEntity)) { + $itemSites->add($siteEntity); + } + } + } + + /** + * Removes team-site memberships from a resource entity. + * + * Called when an item set or child item is removed from a team so that + * the resource is disassociated from the team's sites. + * + * @param \Omeka\Entity\Resource $resource + * @param TeamSite[] $teamSites + */ + private function removeSiteAssociations($resource, array $teamSites): void + { + if (!method_exists($resource, 'getSites') || empty($teamSites)) { + return; + } + $itemSites = $resource->getSites(); + $em = $this->getEntityManager(); + foreach ($teamSites as $teamSite) { + $siteEntity = $em->find('Omeka\Entity\Site', $teamSite->getSite()->getId()); + if ($siteEntity) { + $itemSites->removeElement($siteEntity); + } + } + } } diff --git a/src/Api/Adapter/TeamResourceAdapter.php b/src/Api/Adapter/TeamResourceAdapter.php index a08136b4..899c4000 100644 --- a/src/Api/Adapter/TeamResourceAdapter.php +++ b/src/Api/Adapter/TeamResourceAdapter.php @@ -340,6 +340,7 @@ public function delete(Request $request) } } + $entity = null; $exists = $this->getServiceLocator()->get('Omeka\ApiManager') ->search('team-resource', ['team' => $request->getValue('team'), 'resource' => $request->getValue('resource')]) ->getContent(); From 227749ca3af3e837810cc77b0ff80f249da7eca2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:19:13 +0000 Subject: [PATCH 48/54] Fix DQL reserved keyword 'is' alias and item-set-only filtering in TeamAdapter Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Api/Adapter/TeamAdapter.php | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Api/Adapter/TeamAdapter.php b/src/Api/Adapter/TeamAdapter.php index 94755d85..04bd8195 100644 --- a/src/Api/Adapter/TeamAdapter.php +++ b/src/Api/Adapter/TeamAdapter.php @@ -149,8 +149,13 @@ public function hydrate( $recursive = (bool) $request->getValue('o:recursive_item_sets', false); $teamId = $entity->getId(); - $existingTeamResources = $em->getRepository(TeamResource::class) - ->findBy(['team' => $teamId]); + // Fetch only TeamResource entries whose resource is an item set, + // so that plain-item associations are not accidentally removed. + $existingTeamResources = $em->createQuery( + 'SELECT tr FROM Teams\Entity\TeamResource tr' + . ' JOIN Omeka\Entity\ItemSet iset WITH iset.id = tr.resource' + . ' WHERE tr.team = :teamId' + )->setParameter('teamId', $teamId)->getResult(); $existingItemSetIds = array_map( fn(TeamResource $tr) => $tr->getResource()->getId(), @@ -176,8 +181,8 @@ public function hydrate( $childResources = $em->getRepository('Omeka\Entity\Resource') ->createQueryBuilder('r') ->join('Omeka\Entity\Item', 'i', 'WITH', 'i.id = r.id') - ->join('i.itemSets', 'is') - ->where('is.id = :itemSetId') + ->join('i.itemSets', 'iset') + ->where('iset.id = :itemSetId') ->setParameter('itemSetId', $existingId) ->getQuery() ->getResult(); @@ -212,8 +217,8 @@ public function hydrate( $childResources = $em->getRepository('Omeka\Entity\Resource') ->createQueryBuilder('r') ->join('Omeka\Entity\Item', 'i', 'WITH', 'i.id = r.id') - ->join('i.itemSets', 'is') - ->where('is.id = :itemSetId') + ->join('i.itemSets', 'iset') + ->where('iset.id = :itemSetId') ->setParameter('itemSetId', $itemSetId) ->getQuery() ->getResult(); From 0c8ce251713a640ae915fa4f6b62267f5274f06b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:31:07 +0000 Subject: [PATCH 49/54] Fix deselection of item sets and resource templates in team form JS Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- asset/js/inject-removal-util-element.js | 32 ++++--------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/asset/js/inject-removal-util-element.js b/asset/js/inject-removal-util-element.js index c9f031f6..805bbaf3 100644 --- a/asset/js/inject-removal-util-element.js +++ b/asset/js/inject-removal-util-element.js @@ -1,25 +1,13 @@ $(window).on('load', function() { - $("#o-modules-team-remove-item-sets").parent().parent().css('visibility', 'hidden'); - $("#o-modules-team-remove-resource-templates").parent().parent().css('visibility', 'hidden'); - - //not ideal, but for some reason the chosen option from chosen-options.js are getting unset, so settin them here + // Initialize chosen on the item-sets and resource-templates multiselects. + // Chosen handles deselection natively: deselected values are removed from + // the select element, so they are omitted from the submitted form data and + // the adapter treats them as removed. $("#o-modules-team-item-sets").chosen({ allow_single_deselect: true, disable_search_threshold: 10, width: '100%', include_group_label_in_selected: true, - }).change( function(event, params) { - let $values = $("#o-modules-team-remove-item-sets").val(); - if (params.deselected){ - let $label = $("#o-modules-team-item-sets option[value='"+params.deselected+"']").text(); - $("#o-modules-team-remove-item-sets").append('').trigger("chosen:updated"); - $values.push(params.deselected); - console.log($values); - } - if (params.selected) { - $values.splice($.inArray(params.selected, $values), 1); - } - $("#o-modules-team-remove-item-sets").val($values).trigger("chosen:updated"); }); $("#o-modules-team-resource-templates").chosen({ @@ -27,17 +15,5 @@ $(window).on('load', function() { disable_search_threshold: 10, width: '100%', include_group_label_in_selected: true, - }).change( function(event, params) { - let $values = $("#o-modules-team-remove-resource-templates").val(); - if (params.deselected){ - let $label = $("#o-modules-team-resource-templates option[value='"+params.deselected+"']").text(); - $("#o-modules-team-remove-resource-templates").append('').trigger("chosen:updated"); - $values.push(params.deselected); - console.log($values); - } - if (params.selected) { - $values.splice($.inArray(params.selected, $values), 1); - } - $("#o-modules-team-remove-resource-templates").val($values).trigger("chosen:updated"); }); }); From 7a17b86f83e9d9af4f72aa790e06c745dbebdf78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:37:45 +0000 Subject: [PATCH 50/54] Replace syncAllSitePermissions with targeted sync calls in UpdateController Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Controller/UpdateController.php | 50 ++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index 35fac179..ffb2570c 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -75,6 +75,16 @@ public function teamUpdateAction() return $view; } + // Snapshot the pre-update state so we can compute targeted sync diffs. + $beforeUserRoles = []; + foreach ($team->getTeamUsers() as $teamUser) { + $beforeUserRoles[$teamUser->getUser()->getId()] = $teamUser->getRole()->getId(); + } + $beforeSiteIds = []; + foreach ($team->getTeamSites() as $teamSite) { + $beforeSiteIds[] = $teamSite->getSite()->getId(); + } + $this->api($form)->update('team', $teamId, [ 'o:name' => $postData['o:name'], 'o:description' => $postData['o:description'], @@ -95,7 +105,45 @@ public function teamUpdateAction() $this->messenger()->addSuccess('Item assignment in progress. To see the new item count, refresh the page.'); // @translate } - $this->sitePermissionManager->syncAllSitePermissions(); + // Compute targeted site-permission syncs by diffing the pre-update + // snapshot against submitted data instead of rebuilding all permissions. + + // Diff team users: added, removed, or role-changed. + $submittedTeamUsers = $postData['o:team_users'] ?? []; + $afterUserRoles = []; + foreach ($submittedTeamUsers as $entry) { + $uid = (int) $entry['o:user']['o:id']; + $rid = (int) $entry['o:team_role']['o:id']; + $afterUserRoles[$uid] = $rid; + } + + $addedUserIds = array_diff_key($afterUserRoles, $beforeUserRoles); + $removedUserIds = array_diff_key($beforeUserRoles, $afterUserRoles); + $keptUserIds = array_intersect_key($beforeUserRoles, $afterUserRoles); + + foreach (array_keys($addedUserIds) as $userId) { + $this->sitePermissionManager->syncSitePermissionsForUser($userId, $teamId); + } + foreach (array_keys($removedUserIds) as $userId) { + $this->sitePermissionManager->removeSitePermissionsForUser($userId, $teamId); + } + foreach (array_keys($keptUserIds) as $userId) { + if ($beforeUserRoles[$userId] !== $afterUserRoles[$userId]) { + $this->sitePermissionManager->syncSitePermissionsForUser($userId, $teamId); + } + } + + // Diff team sites: added and removed. + $afterSiteIds = array_map('intval', (array) ($postData['team_sites'] ?? [])); + $addedSiteIds = array_diff($afterSiteIds, $beforeSiteIds); + $removedSiteIds = array_diff($beforeSiteIds, $afterSiteIds); + + foreach ($addedSiteIds as $siteId) { + $this->sitePermissionManager->syncSitePermissionsForTeamOnSiteAdded($teamId, $siteId); + } + foreach ($removedSiteIds as $siteId) { + $this->sitePermissionManager->removeSitePermissionsForTeamOnSiteRemoved($teamId, $siteId); + } $this->messenger()->addSuccess(sprintf("Successfully updated the %s team", $team->getName())); return $this->redirect()->toRoute('admin/teams/detail', ['id' => $teamId]); From 96ea4d20cf21e75e66c4b70ae0237cd5fd642eb9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:40:57 +0000 Subject: [PATCH 51/54] refactor: move site-permission sync into TeamUserAdapter and TeamSiteAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All site-permission syncing now fires through the API adapter layer: - SitePermissionManager: add $flush param to all public sync methods; inner loops pass false and do a single flush at the end. - TeamUserAdapter::create — sync after persist+flush. - TeamUserAdapter::update — override to sync after parent::update (role changes). - TeamSiteAdapter::create — sync after persist+flush. - TeamSiteAdapter::delete — sync after remove+flush. - TeamAdapter::update — snapshot pre/post user+site state and run targeted syncs after parent::update so the TeamAdapter hydrate path is covered. - UpdateController::teamUpdateAction — remove redundant explicit sync diff block and pre-update snapshot (now handled by TeamAdapter::update). - Module::siteCreate — route through api->create('team-site'); removes manual sync. - Module::siteUpdate — route through api->create/delete('team-site'); removes manual sync. - Module::userCreate — route through api->create('team-user'); removes need for separate sync. - Module::userUpdate — route through api->create('team-user'); removes need for separate sync. Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- Module.php | 113 ++++++++------------------ src/Api/Adapter/TeamAdapter.php | 62 +++++++++++++- src/Api/Adapter/TeamSiteAdapter.php | 19 +++++ src/Api/Adapter/TeamUserAdapter.php | 23 +++++- src/Controller/UpdateController.php | 49 ----------- src/Service/SitePermissionManager.php | 49 +++++++---- 6 files changed, 169 insertions(+), 146 deletions(-) diff --git a/Module.php b/Module.php index db99db96..c48725bc 100644 --- a/Module.php +++ b/Module.php @@ -959,16 +959,18 @@ public function userCreate(Event $event) } } $role_id = $team_role_ids[$team_id]; - $role = $em->getRepository('Teams\Entity\TeamRole') - ->findOneBy(['id' => $role_id]); $team_user_exists = $em->getRepository('Teams\Entity\TeamUser') ->findOneBy(['team' => $team->getId(), 'user' => $user_id]); if (!$team_user_exists) { - $team_user = new TeamUser($team, $user, $role); - $em->persist($team_user); + // Create through the adapter so site-permission syncing is + // handled automatically. + $this->getServiceLocator()->get('Omeka\ApiManager')->create('team-user', [ + 'team' => $team->getId(), + 'user' => $user_id, + 'role' => (int) $role_id, + ]); } } - $em->flush(); if ($default_team) { $em->getRepository('Teams\Entity\TeamUser') ->findOneBy(['team' => $default_team, 'user' => $user_id]) @@ -1138,39 +1140,17 @@ public function siteCreate(Event $event) $team_ids = $request->getContent()['team']; - $all_teams_users = []; $all_team_resources = []; - //add team sites + // Create team-site associations through the adapter so that + // site-permission syncing is handled automatically. + $api = $this->getServiceLocator()->get('Omeka\ApiManager'); foreach ($team_ids as $team_id): $team = $teams->findOneBy(['id' => $team_id]); - $team_site = new TeamSite($team, $site); - $em->persist($team_site); - - //get team users - $all_teams_users[] = $team->getTeamUsers(); + $api->create('team-site', ['team' => $team_id, 'site' => $site_id]); //get team items $all_team_resources[] = $team->getTeamResources(); - - endforeach; - $em->flush(); - - //update current team users to include new site in their default sites - foreach ($all_teams_users as $team_users): - foreach ($team_users as $team_user): - if ($team_user->getCurrent()) { - $user_id = $team_user->getUser()->getId(); - $this->updateUserSites($user_id); - } - - endforeach; - endforeach; - - // Sync Omeka site permissions for all users in each new team-site relationship. - $sitePermissionManager = $this->getServiceLocator()->get(SitePermissionManager::class); - foreach ($team_ids as $team_id): - $sitePermissionManager->syncSitePermissionsForTeamOnSiteAdded((int)$team_id, $site_id); endforeach; //update all item-site to include all items from the site's teams @@ -1255,8 +1235,6 @@ public function userUpdate(Event $event) //get it this way because the roles are added dynamically as js and not part of pre-baked form $role_id = $request->getContent()['o-module-teams:TeamRole'][$team_id]; - $role = $em->getRepository('Teams\Entity\TeamRole') - ->findOneBy(['id' => $role_id]); $team_user_exists = $em->getRepository('Teams\Entity\TeamUser') ->findOneBy(['team' => $team->getId(), 'user' => $user_id]); @@ -1264,21 +1242,21 @@ public function userUpdate(Event $event) if ($team_user_exists) { echo $team_user_exists->getId(); } else { - $team_user = new TeamUser($team, $user, $role); - $em->persist($team_user); + // Create through the adapter so site-permission syncing is + // handled automatically. + $teamUserResponse = $this->getServiceLocator()->get('Omeka\ApiManager')->create('team-user', [ + 'team' => $team->getId(), + 'user' => $user_id, + 'role' => (int) $role_id, + ]); + $teamUserEntity = $teamUserResponse->getContent(); if ($team_id == $current_team_id) { - $team_user->setCurrent(true); + $teamUserEntity->setCurrent(true); + $em->flush(); } - $em->persist($team_user); - - //this is not ideal to flush each iteration, but it is how to check to make sure they didn't - //TODO: catch this in chosen-trigger.js instead - $em->flush(); } endforeach; - - $em->flush(); } if (array_key_exists('o-module-teams:DefaultTeam', $request->getContent())) { if ($current_user->getRole() == 'global_admin' or $current_user->getId() == $target_user) { @@ -1393,57 +1371,32 @@ public function siteUpdate(Event $event) $added_teams = array_diff($form_teams, $existing_teams); $removed_teams = array_diff($existing_teams, $form_teams); - foreach ($team_sites as $team_site): - if (in_array($team_site->getTeam()->getId(), $removed_teams)) { - $em->remove($team_site); - } - endforeach; - $em->flush(); + // Delete removed team-site associations through the adapter so that + // site-permission cleanup is handled automatically. + $api = $this->getServiceLocator()->get('Omeka\ApiManager'); + foreach ($removed_teams as $team_id) { + $api->delete('team-site', ['team' => $team_id, 'site' => $site_id]); + } - //add teams to the site for each new team listed in the form - foreach ($added_teams as $team): - $team_site = new TeamSite( - $em->getRepository('Teams\Entity\Team')->findOneBy(['id' => $team]), - $em->getRepository('Omeka\Entity\Site')->findOneBy(['id' => $site_id]) - ); - $em->persist($team_site); - endforeach; - $em->flush(); + // Add new team-site associations through the adapter so that + // site-permission syncing is handled automatically. + foreach ($added_teams as $team_id) { + $api->create('team-site', ['team' => $team_id, 'site' => $site_id]); + } - //get any items or users that need to be updated - //by either removing or adding item-sits or user default site + //get any items that need their site membership updated $delta_item_site = []; - $delta_user_site = []; foreach (array_merge($added_teams, $removed_teams) as $team_id) { $delta_item_site[] = $em->getRepository('Teams\Entity\Team') ->findOneBy(['id' => $team_id]) ->getTeamResources(); - $delta_user_site[] = $em->getRepository('Teams\Entity\Team') - ->findOneBy(['id' => $team_id]) - ->getTeamUsers(); } - //update current team users to include new site in their default sites - foreach ($delta_user_site as $team_users) { - foreach ($team_users as $team_user) { - $user_id = $team_user->getUser()->getId(); - $this->updateUserSites($user_id); - } - } foreach ($delta_item_site as $team_item_collection) { foreach ($team_item_collection as $team_item) { $this->updateItemSites($team_item->getResource()->getId()); } } - - // Sync Omeka site permissions for teams that gained or lost this site. - $sitePermissionManager = $this->getServiceLocator()->get(SitePermissionManager::class); - foreach ($added_teams as $team_id) { - $sitePermissionManager->syncSitePermissionsForTeamOnSiteAdded((int)$team_id, $site_id); - } - foreach ($removed_teams as $team_id) { - $sitePermissionManager->removeSitePermissionsForTeamOnSiteRemoved((int)$team_id, $site_id); - } } } } diff --git a/src/Api/Adapter/TeamAdapter.php b/src/Api/Adapter/TeamAdapter.php index 04bd8195..7a7d2460 100644 --- a/src/Api/Adapter/TeamAdapter.php +++ b/src/Api/Adapter/TeamAdapter.php @@ -14,6 +14,7 @@ use Omeka\Entity\EntityInterface; use Omeka\Stdlib\ErrorStore; use Omeka\Stdlib\Message; +use Teams\Service\SitePermissionManager; class TeamAdapter extends AbstractEntityAdapter { @@ -407,7 +408,66 @@ public function batchCreate(Request $request) public function update(Request $request) { - return parent::update($request); + $teamId = $request->getId(); + $em = $this->getEntityManager(); + + // Snapshot pre-update state so we can compute targeted sync diffs. + $team = $em->find(Team::class, $teamId); + $beforeUserRoles = []; + foreach ($team->getTeamUsers() as $teamUser) { + $beforeUserRoles[$teamUser->getUser()->getId()] = $teamUser->getRole()->getId(); + } + $beforeSiteIds = array_map( + fn(TeamSite $ts) => $ts->getSite()->getId(), + $team->getTeamSites()->toArray() + ); + + $response = parent::update($request); + + // Sync site permissions based on the user and site diffs. + $sitePermissionManager = $this->getServiceLocator()->get(SitePermissionManager::class); + + $team = $em->find(Team::class, $teamId); + $afterUserRoles = []; + foreach ($team->getTeamUsers() as $teamUser) { + $afterUserRoles[$teamUser->getUser()->getId()] = $teamUser->getRole()->getId(); + } + + $addedUserIds = array_diff_key($afterUserRoles, $beforeUserRoles); + $removedUserIds = array_diff_key($beforeUserRoles, $afterUserRoles); + $keptUserIds = array_intersect_key($beforeUserRoles, $afterUserRoles); + + foreach (array_keys($addedUserIds) as $userId) { + $sitePermissionManager->syncSitePermissionsForUser($userId, $teamId, false); + } + foreach (array_keys($removedUserIds) as $userId) { + $sitePermissionManager->removeSitePermissionsForUser($userId, $teamId, null, false); + } + foreach (array_keys($keptUserIds) as $userId) { + if ($beforeUserRoles[$userId] !== $afterUserRoles[$userId]) { + $sitePermissionManager->syncSitePermissionsForUser($userId, $teamId, false); + } + } + + $afterSiteIds = array_map( + fn(TeamSite $ts) => $ts->getSite()->getId(), + $team->getTeamSites()->toArray() + ); + $addedSiteIds = array_diff($afterSiteIds, $beforeSiteIds); + $removedSiteIds = array_diff($beforeSiteIds, $afterSiteIds); + + foreach ($addedSiteIds as $siteId) { + $sitePermissionManager->syncSitePermissionsForTeamOnSiteAdded($teamId, $siteId, false); + } + foreach ($removedSiteIds as $siteId) { + $sitePermissionManager->removeSitePermissionsForTeamOnSiteRemoved($teamId, $siteId, false); + } + + if ($addedUserIds || $removedUserIds || $keptUserIds || $addedSiteIds || $removedSiteIds) { + $em->flush(); + } + + return $response; } public function batchUpdate(Request $request) diff --git a/src/Api/Adapter/TeamSiteAdapter.php b/src/Api/Adapter/TeamSiteAdapter.php index cc5eae4a..bcc52a5d 100644 --- a/src/Api/Adapter/TeamSiteAdapter.php +++ b/src/Api/Adapter/TeamSiteAdapter.php @@ -12,6 +12,7 @@ use Omeka\Stdlib\ErrorStore; use Teams\Entity\TeamSite; use Teams\Api\Representation\TeamSiteRepresentation; +use Teams\Service\SitePermissionManager; class TeamSiteAdapter extends AbstractTeamEntityAdapter { @@ -134,6 +135,15 @@ public function create(Request $request) $this->getEntityManager()->flush(); $this->getEntityManager()->refresh($teamSite); } + + // Sync Omeka site permissions for all users in the team now that + // this site has been added to it. + $this->getServiceLocator()->get(SitePermissionManager::class) + ->syncSitePermissionsForTeamOnSiteAdded( + $teamEntity->getId(), + $siteEntity->getId() + ); + return new Response($teamSite); } @@ -170,6 +180,15 @@ public function delete(Request $request) if ($request->getOption('flushEntityManager', true)) { $this->getEntityManager()->flush(); } + + // Remove Omeka site permissions for all users in the team now that + // this site has been removed from it. + $this->getServiceLocator()->get(SitePermissionManager::class) + ->removeSitePermissionsForTeamOnSiteRemoved( + $id['team'], + $id['site'] + ); + return new Response($entity); } diff --git a/src/Api/Adapter/TeamUserAdapter.php b/src/Api/Adapter/TeamUserAdapter.php index 6bf1a8d0..454c94fb 100644 --- a/src/Api/Adapter/TeamUserAdapter.php +++ b/src/Api/Adapter/TeamUserAdapter.php @@ -10,6 +10,7 @@ use Omeka\Entity\User; use Teams\Api\Representation\TeamUserRepresentation; use Teams\Entity\TeamRole; +use Teams\Service\SitePermissionManager; use Teams\Entity\TeamUser; use Omeka\Api\Adapter\AbstractEntityAdapter; use Omeka\Api\Request; @@ -181,6 +182,14 @@ public function create(Request $request) // that have not been loaded. $this->getEntityManager()->refresh($teamResource); } + + // Sync Omeka site permissions for the user now that the TeamUser row exists. + $this->getServiceLocator()->get(SitePermissionManager::class) + ->syncSitePermissionsForUser( + $teamResource->getUser()->getId(), + $teamResource->getTeam()->getId() + ); + return new Response($teamResource); } @@ -191,7 +200,19 @@ public function batchCreate(Request $request) public function update(Request $request) { - return parent::update($request); + $response = parent::update($request); + + // Re-sync site permissions in case the role changed. + $teamUser = $response->getContent(); + if ($teamUser instanceof TeamUser) { + $this->getServiceLocator()->get(SitePermissionManager::class) + ->syncSitePermissionsForUser( + $teamUser->getUser()->getId(), + $teamUser->getTeam()->getId() + ); + } + + return $response; } public function batchUpdate(Request $request) diff --git a/src/Controller/UpdateController.php b/src/Controller/UpdateController.php index ffb2570c..1c948559 100644 --- a/src/Controller/UpdateController.php +++ b/src/Controller/UpdateController.php @@ -75,16 +75,6 @@ public function teamUpdateAction() return $view; } - // Snapshot the pre-update state so we can compute targeted sync diffs. - $beforeUserRoles = []; - foreach ($team->getTeamUsers() as $teamUser) { - $beforeUserRoles[$teamUser->getUser()->getId()] = $teamUser->getRole()->getId(); - } - $beforeSiteIds = []; - foreach ($team->getTeamSites() as $teamSite) { - $beforeSiteIds[] = $teamSite->getSite()->getId(); - } - $this->api($form)->update('team', $teamId, [ 'o:name' => $postData['o:name'], 'o:description' => $postData['o:description'], @@ -105,45 +95,6 @@ public function teamUpdateAction() $this->messenger()->addSuccess('Item assignment in progress. To see the new item count, refresh the page.'); // @translate } - // Compute targeted site-permission syncs by diffing the pre-update - // snapshot against submitted data instead of rebuilding all permissions. - - // Diff team users: added, removed, or role-changed. - $submittedTeamUsers = $postData['o:team_users'] ?? []; - $afterUserRoles = []; - foreach ($submittedTeamUsers as $entry) { - $uid = (int) $entry['o:user']['o:id']; - $rid = (int) $entry['o:team_role']['o:id']; - $afterUserRoles[$uid] = $rid; - } - - $addedUserIds = array_diff_key($afterUserRoles, $beforeUserRoles); - $removedUserIds = array_diff_key($beforeUserRoles, $afterUserRoles); - $keptUserIds = array_intersect_key($beforeUserRoles, $afterUserRoles); - - foreach (array_keys($addedUserIds) as $userId) { - $this->sitePermissionManager->syncSitePermissionsForUser($userId, $teamId); - } - foreach (array_keys($removedUserIds) as $userId) { - $this->sitePermissionManager->removeSitePermissionsForUser($userId, $teamId); - } - foreach (array_keys($keptUserIds) as $userId) { - if ($beforeUserRoles[$userId] !== $afterUserRoles[$userId]) { - $this->sitePermissionManager->syncSitePermissionsForUser($userId, $teamId); - } - } - - // Diff team sites: added and removed. - $afterSiteIds = array_map('intval', (array) ($postData['team_sites'] ?? [])); - $addedSiteIds = array_diff($afterSiteIds, $beforeSiteIds); - $removedSiteIds = array_diff($beforeSiteIds, $afterSiteIds); - - foreach ($addedSiteIds as $siteId) { - $this->sitePermissionManager->syncSitePermissionsForTeamOnSiteAdded($teamId, $siteId); - } - foreach ($removedSiteIds as $siteId) { - $this->sitePermissionManager->removeSitePermissionsForTeamOnSiteRemoved($teamId, $siteId); - } $this->messenger()->addSuccess(sprintf("Successfully updated the %s team", $team->getName())); return $this->redirect()->toRoute('admin/teams/detail', ['id' => $teamId]); diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index fd1cb150..c6ab4cbb 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -57,10 +57,12 @@ public function __construct(EntityManager $entityManager, UserSettings $userSett * for every site associated with the team. The role is set unconditionally so that * any change to a team role is immediately reflected in the site permission. * - * @param int $userId - * @param int $teamId + * @param int $userId + * @param int $teamId + * @param bool $flush Whether to flush the entity manager after syncing. Pass + * false when calling in a loop and flush once afterward. */ - public function syncSitePermissionsForUser(int $userId, int $teamId): void + public function syncSitePermissionsForUser(int $userId, int $teamId, bool $flush = true): void { $em = $this->entityManager; @@ -124,7 +126,9 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void } } - $em->flush(); + if ($flush) { + $em->flush(); + } } /** @@ -139,8 +143,9 @@ public function syncSitePermissionsForUser(int $userId, int $teamId): void * @param int $teamId The team the user is being removed from. * @param int|null $siteId If set, only process this one site (used when a * single site is removed from a team). + * @param bool $flush Whether to flush the entity manager after syncing. */ - public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $siteId = null): void + public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $siteId = null, bool $flush = true): void { $em = $this->entityManager; @@ -179,40 +184,52 @@ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $sit } } - $em->flush(); + if ($flush) { + $em->flush(); + } } /** * Sync Omeka site permissions for all users in a team when a site is added. * - * @param int $teamId - * @param int $siteId + * @param int $teamId + * @param int $siteId + * @param bool $flush Whether to flush the entity manager after syncing. */ - public function syncSitePermissionsForTeamOnSiteAdded(int $teamId, int $siteId): void + public function syncSitePermissionsForTeamOnSiteAdded(int $teamId, int $siteId, bool $flush = true): void { $teamUsers = $this->entityManager ->getRepository('Teams\Entity\TeamUser') ->findBy(['team' => $teamId]); foreach ($teamUsers as $teamUser) { - $this->syncSitePermissionsForUser($teamUser->getUser()->getId(), $teamId); + $this->syncSitePermissionsForUser($teamUser->getUser()->getId(), $teamId, false); + } + + if ($flush) { + $this->entityManager->flush(); } } /** * Remove Omeka site permissions for all users in a team when a site is removed. * - * @param int $teamId - * @param int $siteId + * @param int $teamId + * @param int $siteId + * @param bool $flush Whether to flush the entity manager after syncing. */ - public function removeSitePermissionsForTeamOnSiteRemoved(int $teamId, int $siteId): void + public function removeSitePermissionsForTeamOnSiteRemoved(int $teamId, int $siteId, bool $flush = true): void { $teamUsers = $this->entityManager ->getRepository('Teams\Entity\TeamUser') ->findBy(['team' => $teamId]); foreach ($teamUsers as $teamUser) { - $this->removeSitePermissionsForUser($teamUser->getUser()->getId(), $teamId, $siteId); + $this->removeSitePermissionsForUser($teamUser->getUser()->getId(), $teamId, $siteId, false); + } + + if ($flush) { + $this->entityManager->flush(); } } @@ -275,9 +292,11 @@ public function syncAllSitePermissions(): void foreach ($teamUsers as $teamUser) { $this->syncSitePermissionsForUser( $teamUser->getUser()->getId(), - $teamUser->getTeam()->getId() + $teamUser->getTeam()->getId(), + false ); } + $this->entityManager->flush(); } /** From c357dfa0e2dd7bc1686a70e5778da5444ce7f2e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:08:48 +0000 Subject: [PATCH 52/54] Fix three site permission bugs: remove uses stale TeamSite row, sync uses highest role, name display injected directly Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Service/SitePermissionManager.php | 40 ++++++++++++++++++------- src/View/Helper/TeamSiteUsers.php | 30 +++++++++++++++++-- view/omeka/site-admin/index/users.phtml | 18 ++++++----- 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index c6ab4cbb..62b031b3 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -96,6 +96,13 @@ public function syncSitePermissionsForUser(int $userId, int $teamId, bool $flush foreach ($teamSites as $teamSite) { $site = $teamSite->getSite(); + + // Compute the highest role this user should hold on this site + // across ALL of their team memberships, not just the current team. + // This prevents a role downgrade in one team from overwriting a + // higher role granted by another team. + $finalRole = $this->getHighestRoleFromTeams($userId, $site->getId()) ?? $omekaSiteRole; + $sitePermissions = $site->getSitePermissions(); $criteria = Criteria::create()->where(Criteria::expr()->eq('user', $user)); @@ -106,21 +113,21 @@ public function syncSitePermissionsForUser(int $userId, int $teamId, bool $flush '[SitePermissionManager] siteId=%d: updating existing SitePermission from "%s" to "%s" for userId=%d', $site->getId(), $existingPermission->getRole(), - $omekaSiteRole, + $finalRole, $userId )); - $existingPermission->setRole($omekaSiteRole); + $existingPermission->setRole($finalRole); } else { $this->logger->info(sprintf( '[SitePermissionManager] siteId=%d: creating new SitePermission with role "%s" for userId=%d', $site->getId(), - $omekaSiteRole, + $finalRole, $userId )); $sitePermission = new SitePermission(); $sitePermission->setSite($site); $sitePermission->setUser($user); - $sitePermission->setRole($omekaSiteRole); + $sitePermission->setRole($finalRole); $em->persist($sitePermission); $sitePermissions->add($sitePermission); } @@ -142,7 +149,10 @@ public function syncSitePermissionsForUser(int $userId, int $teamId, bool $flush * @param int $userId * @param int $teamId The team the user is being removed from. * @param int|null $siteId If set, only process this one site (used when a - * single site is removed from a team). + * single site is removed from a team). When the + * TeamSite row has already been deleted, pass the + * site ID here so the site entity is fetched + * directly rather than through TeamSite. * @param bool $flush Whether to flush the entity manager after syncing. */ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $siteId = null, bool $flush = true): void @@ -154,14 +164,24 @@ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $sit return; } - $criteria = ['team' => $teamId]; + // When a specific site is being removed from the team, the TeamSite row + // has already been deleted by the time this method is called. Look up the + // site entity directly so we still process the permission cleanup. if ($siteId !== null) { - $criteria['site'] = $siteId; + $site = $em->find('Omeka\Entity\Site', $siteId); + if (!$site) { + if ($flush) { + $em->flush(); + } + return; + } + $sites = [$site]; + } else { + $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['team' => $teamId]); + $sites = array_map(fn($ts) => $ts->getSite(), $teamSites); } - $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy($criteria); - foreach ($teamSites as $teamSite) { - $site = $teamSite->getSite(); + foreach ($sites as $site) { $sitePermissions = $site->getSitePermissions(); $userCriteria = Criteria::create()->where(Criteria::expr()->eq('user', $user)); diff --git a/src/View/Helper/TeamSiteUsers.php b/src/View/Helper/TeamSiteUsers.php index 988b8a25..0c7a13e3 100644 --- a/src/View/Helper/TeamSiteUsers.php +++ b/src/View/Helper/TeamSiteUsers.php @@ -26,16 +26,20 @@ public function __construct(EntityManager $entityManager) /** * @param int $siteId - * @return array userId => list of team names, or empty array on error + * @return array userId => list of team names granting the highest role, or empty array on error */ public function __invoke(int $siteId): array { try { - $teamManagedUsers = []; $teamSites = $this->entityManager ->getRepository('Teams\Entity\TeamSite') ->findBy(['site' => $siteId]); + // Collect per-user: highest role flag and which team(s) grant it. + // Role priority: admin (can_add_site_pages=true) > viewer. + $userHighestCanManage = []; // userId => bool + $userTeamsByRole = []; // userId => ['admin' => [], 'viewer' => []] + foreach ($teamSites as $teamSite) { $team = $teamSite->getTeam(); $teamUsers = $this->entityManager @@ -44,10 +48,30 @@ public function __invoke(int $siteId): array foreach ($teamUsers as $teamUser) { $userId = $teamUser->getUser()->getId(); - $teamManagedUsers[$userId][] = $team->getName(); + $canManage = (bool) $teamUser->getRole()->getCanAddSitePages(); + + if (!isset($userHighestCanManage[$userId])) { + $userHighestCanManage[$userId] = false; + $userTeamsByRole[$userId] = ['admin' => [], 'viewer' => []]; + } + + if ($canManage) { + $userHighestCanManage[$userId] = true; + $userTeamsByRole[$userId]['admin'][] = $team->getName(); + } else { + $userTeamsByRole[$userId]['viewer'][] = $team->getName(); + } } } + // Return only the team(s) that grant the user's highest role. + $teamManagedUsers = []; + foreach ($userHighestCanManage as $userId => $canManage) { + $teamManagedUsers[$userId] = $canManage + ? $userTeamsByRole[$userId]['admin'] + : $userTeamsByRole[$userId]['viewer']; + } + return $teamManagedUsers; } catch (\Exception $e) { return []; diff --git a/view/omeka/site-admin/index/users.phtml b/view/omeka/site-admin/index/users.phtml index 4f53f598..b5c64ca3 100644 --- a/view/omeka/site-admin/index/users.phtml +++ b/view/omeka/site-admin/index/users.phtml @@ -59,6 +59,7 @@ foreach ($sitePermissions as $sitePermission) { $user = $sitePermission->user(); $entry = [ 'id' => $user->id(), + 'name' => $user->name(), 'role' => $sitePermission->role(), ]; if ($hasTeamData) { @@ -111,21 +112,22 @@ foreach ($sitePermissions as $sitePermission) { From 059a2b14aba155f72b47e5a74a02b9cac0b1b347 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:21:03 +0000 Subject: [PATCH 53/54] Refactor SitePermissionManager to use API; build template rows server-side Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- src/Service/SitePermissionManager.php | 213 ++++++++++--------- src/Service/SitePermissionManagerFactory.php | 1 + view/omeka/site-admin/index/users.phtml | 86 ++++---- 3 files changed, 163 insertions(+), 137 deletions(-) diff --git a/src/Service/SitePermissionManager.php b/src/Service/SitePermissionManager.php index 62b031b3..e9bd2cfd 100644 --- a/src/Service/SitePermissionManager.php +++ b/src/Service/SitePermissionManager.php @@ -3,6 +3,7 @@ use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\EntityManager; +use Omeka\Api\Manager as ApiManager; use Omeka\Entity\SitePermission; use Laminas\Log\Logger; use Omeka\Settings\UserSettings; @@ -16,12 +17,18 @@ * - Updating the `default_item_sites` user setting that controls which sites appear * in the item-creation form for a given user. * - * Following the pattern established by AclRuleManager, business logic is extracted - * from the monolithic Module.php into this injectable, testable service class. - * Module.php and UpdateController remain thin coordinators that delegate here. + * All Teams entity reads go through the Omeka API (with bypass_team_filter so admin + * operations are never restricted by the current user's active team). The entity + * manager is used only for SitePermission writes, since Omeka exposes no API + * resource for individual site permissions. */ class SitePermissionManager { + /** + * @var ApiManager + */ + private ApiManager $api; + /** * @var EntityManager */ @@ -35,16 +42,17 @@ class SitePermissionManager /** * @var Logger */ - protected $logger; + protected Logger $logger; /** + * @param ApiManager $api * @param EntityManager $entityManager - * @param UserSettings $userSettings - * @param Logger $logger + * @param UserSettings $userSettings + * @param Logger $logger */ - - public function __construct(EntityManager $entityManager, UserSettings $userSettings, Logger $logger) + public function __construct(ApiManager $api, EntityManager $entityManager, UserSettings $userSettings, Logger $logger) { + $this->api = $api; $this->entityManager = $entityManager; $this->userSettings = $userSettings; $this->logger = $logger; @@ -54,8 +62,9 @@ public function __construct(EntityManager $entityManager, UserSettings $userSett * Sync Omeka site permissions for a user in a specific team. * * Assigns ROLE_ADMIN if the team role has `can_add_site_pages`, otherwise ROLE_VIEWER, - * for every site associated with the team. The role is set unconditionally so that - * any change to a team role is immediately reflected in the site permission. + * for every site associated with the team. The final role is the highest across all + * of the user's team memberships so that a downgrade in one team never overrides a + * higher privilege granted by another. * * @param int $userId * @param int $teamId @@ -66,45 +75,49 @@ public function syncSitePermissionsForUser(int $userId, int $teamId, bool $flush { $em = $this->entityManager; - $teamUser = $em->find('Teams\Entity\TeamUser', ['team' => $teamId, 'user' => $userId]); - if (!$teamUser) { + $teamUserReps = $this->api->search('team-user', [ + 'team' => $teamId, + 'user' => $userId, + 'bypass_team_filter' => true, + ])->getContent(); + + if (empty($teamUserReps)) { return; } - // Refresh to ensure the role reflects the latest persisted state, - // not a potentially stale identity-map proxy. - $em->refresh($teamUser); - - $user = $teamUser->getUser(); - $teamRole = $teamUser->getRole(); + $teamUserRep = $teamUserReps[0]; + $user = $teamUserRep->user(); // Omeka\Entity\User + $teamRole = $teamUserRep->role(); // Teams\Entity\TeamRole $canAddSitePages = $teamRole->getCanAddSitePages(); $omekaSiteRole = $canAddSitePages ? SitePermission::ROLE_ADMIN : SitePermission::ROLE_VIEWER; + $this->logger->info(sprintf( - '[SitePermissionManager] syncSitePermissionsForUser: userId=%d, teamId=%d, teamRoleId=%d, teamRoleName="%s", canAddSitePages=%s => omekaSiteRole="%s", rawCanAddSitePages=%s', + '[SitePermissionManager] syncSitePermissionsForUser: userId=%d, teamId=%d, teamRoleId=%d, teamRoleName="%s", canAddSitePages=%s => omekaSiteRole="%s"', $userId, $teamId, $teamRole->getId(), $teamRole->getName(), $canAddSitePages ? 'true' : 'false', - $omekaSiteRole, - $canAddSitePages + $omekaSiteRole )); - $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['team' => $teamId]); + $teamSiteReps = $this->api->search('team-site', [ + 'team' => $teamId, + 'bypass_team_filter' => true, + ])->getContent(); - foreach ($teamSites as $teamSite) { - $site = $teamSite->getSite(); + foreach ($teamSiteReps as $teamSiteRep) { + $site = $em->find('Omeka\Entity\Site', $teamSiteRep->resource()); + if (!$site) { + continue; + } - // Compute the highest role this user should hold on this site - // across ALL of their team memberships, not just the current team. - // This prevents a role downgrade in one team from overwriting a - // higher role granted by another team. + // Use the highest role across all of the user's teams for this site. $finalRole = $this->getHighestRoleFromTeams($userId, $site->getId()) ?? $omekaSiteRole; $sitePermissions = $site->getSitePermissions(); - $criteria = Criteria::create()->where(Criteria::expr()->eq('user', $user)); $existingPermission = $sitePermissions->matching($criteria)->first(); @@ -142,18 +155,15 @@ public function syncSitePermissionsForUser(int $userId, int $teamId, bool $flush * Sync or remove Omeka site permissions for a user leaving a team. * * If the user still belongs to other teams that share a given site, their - * permission is recalculated across all team memberships so that the role - * reflects the current state — which may be unchanged, lowered, or elevated. - * If no team grants access to a site, the permission is removed entirely. + * permission is recalculated across all team memberships. If no team grants + * access to a site, the permission is removed entirely. * * @param int $userId - * @param int $teamId The team the user is being removed from. - * @param int|null $siteId If set, only process this one site (used when a - * single site is removed from a team). When the - * TeamSite row has already been deleted, pass the - * site ID here so the site entity is fetched - * directly rather than through TeamSite. - * @param bool $flush Whether to flush the entity manager after syncing. + * @param int $teamId The team the user is being removed from. + * @param int|null $siteId If set, only process this one site. When the TeamSite + * row has already been deleted, the site entity is fetched + * directly by ID rather than through TeamSite. + * @param bool $flush Whether to flush the entity manager after syncing. */ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $siteId = null, bool $flush = true): void { @@ -164,10 +174,9 @@ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $sit return; } - // When a specific site is being removed from the team, the TeamSite row - // has already been deleted by the time this method is called. Look up the - // site entity directly so we still process the permission cleanup. if ($siteId !== null) { + // The TeamSite row has already been deleted by the time this method is + // called. Fetch the site entity directly so permission cleanup still runs. $site = $em->find('Omeka\Entity\Site', $siteId); if (!$site) { if ($flush) { @@ -177,13 +186,18 @@ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $sit } $sites = [$site]; } else { - $teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['team' => $teamId]); - $sites = array_map(fn($ts) => $ts->getSite(), $teamSites); + $teamSiteReps = $this->api->search('team-site', [ + 'team' => $teamId, + 'bypass_team_filter' => true, + ])->getContent(); + $sites = array_filter(array_map( + fn($rep) => $em->find('Omeka\Entity\Site', $rep->resource()), + $teamSiteReps + )); } foreach ($sites as $site) { $sitePermissions = $site->getSitePermissions(); - $userCriteria = Criteria::create()->where(Criteria::expr()->eq('user', $user)); $existingPermission = $sitePermissions->matching($userCriteria)->first(); @@ -191,14 +205,12 @@ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $sit continue; } - // Recalculate the role across all team memberships after the change. + // Recalculate the role across all remaining team memberships. $syncedRole = $this->getHighestRoleFromTeams($userId, $site->getId()); if ($syncedRole !== null) { - // User still has access — sync to the recalculated role. $existingPermission->setRole($syncedRole); } else { - // No team grants access to this site; remove the permission. $sitePermissions->removeElement($existingPermission); $em->remove($existingPermission); } @@ -218,12 +230,13 @@ public function removeSitePermissionsForUser(int $userId, int $teamId, ?int $sit */ public function syncSitePermissionsForTeamOnSiteAdded(int $teamId, int $siteId, bool $flush = true): void { - $teamUsers = $this->entityManager - ->getRepository('Teams\Entity\TeamUser') - ->findBy(['team' => $teamId]); + $teamUserReps = $this->api->search('team-user', [ + 'team' => $teamId, + 'bypass_team_filter' => true, + ])->getContent(); - foreach ($teamUsers as $teamUser) { - $this->syncSitePermissionsForUser($teamUser->getUser()->getId(), $teamId, false); + foreach ($teamUserReps as $teamUserRep) { + $this->syncSitePermissionsForUser($teamUserRep->user()->getId(), $teamId, false); } if ($flush) { @@ -240,12 +253,13 @@ public function syncSitePermissionsForTeamOnSiteAdded(int $teamId, int $siteId, */ public function removeSitePermissionsForTeamOnSiteRemoved(int $teamId, int $siteId, bool $flush = true): void { - $teamUsers = $this->entityManager - ->getRepository('Teams\Entity\TeamUser') - ->findBy(['team' => $teamId]); + $teamUserReps = $this->api->search('team-user', [ + 'team' => $teamId, + 'bypass_team_filter' => true, + ])->getContent(); - foreach ($teamUsers as $teamUser) { - $this->removeSitePermissionsForUser($teamUser->getUser()->getId(), $teamId, $siteId, false); + foreach ($teamUserReps as $teamUserRep) { + $this->removeSitePermissionsForUser($teamUserRep->user()->getId(), $teamId, $siteId, false); } if ($flush) { @@ -257,62 +271,61 @@ public function removeSitePermissionsForTeamOnSiteRemoved(int $teamId, int $site * Update the `default_item_sites` user setting so the user's item-creation form * defaults to the sites of their current active team. * - * Extracted from Module::updateUserSites() to follow the service-layer pattern. - * * @param int $userId */ public function updateUserDefaultSites(int $userId): void { - $activeTeam = $this->entityManager - ->getRepository('Teams\Entity\TeamUser') - ->findOneBy(['user' => $userId, 'is_current' => true]); + $teamUserReps = $this->api->search('team-user', [ + 'user' => $userId, + 'current' => true, + 'bypass_team_filter' => true, + ])->getContent(); - if (!$activeTeam) { + if (empty($teamUserReps)) { return; } - $siteIds = []; - foreach ($activeTeam->getTeam()->getTeamSites() as $teamSite) { - $siteIds[] = $teamSite->getSite()->getId(); - } + $activeTeamId = $teamUserReps[0]->team()->getId(); + $teamSiteReps = $this->api->search('team-site', [ + 'team' => $activeTeamId, + 'bypass_team_filter' => true, + ])->getContent(); + + $siteIds = array_map(fn($rep) => $rep->resource(), $teamSiteReps); $this->userSettings->set('default_item_sites', $siteIds, $userId); } /** * Update `default_item_sites` for all users who have an active team. - * - * Extracted from Module::updateAllUserSites(). */ public function updateAllUserDefaultSites(): void { - $activeTeamUsers = $this->entityManager - ->getRepository('Teams\Entity\TeamUser') - ->findBy(['is_current' => true]); + $teamUserReps = $this->api->search('team-user', [ + 'current' => true, + 'bypass_team_filter' => true, + ])->getContent(); - foreach ($activeTeamUsers as $teamUser) { - $this->updateUserDefaultSites($teamUser->getUser()->getId()); + foreach ($teamUserReps as $teamUserRep) { + $this->updateUserDefaultSites($teamUserRep->user()->getId()); } } /** * Sync Omeka site permissions for every team-user-site combination. * - * Iterates all TeamUser records and calls syncSitePermissionsForUser for - * each, ensuring every user has the correct SitePermission row for every - * site their team is associated with. Used by the module upgrade routine - * to bring existing installations into sync. + * Used by the module upgrade routine to bring existing installations into sync. */ public function syncAllSitePermissions(): void { - $teamUsers = $this->entityManager - ->getRepository('Teams\Entity\TeamUser') - ->findAll(); + $teamUserReps = $this->api->search('team-user', [ + 'bypass_team_filter' => true, + ])->getContent(); - foreach ($teamUsers as $teamUser) { + foreach ($teamUserReps as $teamUserRep) { $this->syncSitePermissionsForUser( - $teamUser->getUser()->getId(), - $teamUser->getTeam()->getId(), + $teamUserRep->user()->getId(), + $teamUserRep->team()->getId(), false ); } @@ -323,12 +336,9 @@ public function syncAllSitePermissions(): void * Determine the Omeka site role a user should hold on a site based on all * of their current team memberships. * - * Logic: - * - If no team the user belongs to includes this site, returns null - * (the site permission should be removed entirely). - * - If any team with this site has can_add_site_pages = true, - * returns ROLE_ADMIN. - * - Otherwise returns ROLE_VIEWER. + * Returns null if no team the user belongs to includes this site (the + * permission should be removed entirely). Returns ROLE_ADMIN if any qualifying + * team has can_add_site_pages = true, otherwise ROLE_VIEWER. * * @param int $userId * @param int $siteId @@ -336,24 +346,29 @@ public function syncAllSitePermissions(): void */ private function getHighestRoleFromTeams(int $userId, int $siteId): ?string { - $em = $this->entityManager; - $teamUsers = $em->getRepository('Teams\Entity\TeamUser')->findBy(['user' => $userId]); + $teamUserReps = $this->api->search('team-user', [ + 'user' => $userId, + 'bypass_team_filter' => true, + ])->getContent(); $hasAnySite = false; $canManage = false; - foreach ($teamUsers as $teamUser) { - $teamSite = $em->getRepository('Teams\Entity\TeamSite') - ->findOneBy(['team' => $teamUser->getTeam()->getId(), 'site' => $siteId]); + foreach ($teamUserReps as $teamUserRep) { + $teamSiteReps = $this->api->search('team-site', [ + 'team' => $teamUserRep->team()->getId(), + 'site' => $siteId, + 'bypass_team_filter' => true, + ])->getContent(); - if (!$teamSite) { + if (empty($teamSiteReps)) { continue; } $hasAnySite = true; - if ($teamUser->getRole()->getCanAddSitePages()) { + if ($teamUserRep->role()->getCanAddSitePages()) { $canManage = true; - break; // No need to check further — admin wins. + break; // Admin wins; no need to check further. } } diff --git a/src/Service/SitePermissionManagerFactory.php b/src/Service/SitePermissionManagerFactory.php index e22ddb5c..050a8ebd 100644 --- a/src/Service/SitePermissionManagerFactory.php +++ b/src/Service/SitePermissionManagerFactory.php @@ -16,6 +16,7 @@ class SitePermissionManagerFactory implements FactoryInterface public function __invoke(ContainerInterface $container, $requestedName, array $options = null) { return new SitePermissionManager( + $container->get('Omeka\ApiManager'), $container->get('Omeka\EntityManager'), $container->get('Omeka\Settings\User'), $container->get('Omeka\Logger') diff --git a/view/omeka/site-admin/index/users.phtml b/view/omeka/site-admin/index/users.phtml index b5c64ca3..562f151b 100644 --- a/view/omeka/site-admin/index/users.phtml +++ b/view/omeka/site-admin/index/users.phtml @@ -7,13 +7,12 @@ * (application/view/omeka/site-admin/index/users.phtml) and modified to inject * Teams module data. * - * The teamSiteUsers view helper is used instead of an inline getServiceLocator() - * call to avoid the Laminas v3 deprecation warning. - * - * The "Managed by Teams" cell is populated by inline JavaScript after - * Omeka.initializeSelector() creates the rows, because initializeSelector reads - * cell content from sidebar DOM attributes — not from data-existing-rows — so the - * teams-managed value must be injected in a separate pass. + * Existing permissions are rendered server-side so user names are always visible, + * even for users filtered out of the team-scoped sidebar selector. The row template + * (data-row-template) is retained for new users added via the sidebar. Omeka's + * initializeSelector skips rows whose .resource-id value already exists in the table, + * so the PHP-rendered rows are not duplicated. site-users.js iterates data-existing-rows + * to replace __index__ placeholders with real indices and confirm role selections. */ $translate = $this->plugin('translate'); $this->headScript()->appendFile($this->assetUrl('js/site-users.js', 'Omeka')); @@ -32,6 +31,7 @@ $roles = [ $teamManagedUsers = $this->teamSiteUsers($site->id()); $hasTeamData = !empty($teamManagedUsers); +// Row template used by initializeSelector for *new* users added from the sidebar. $userRowTemplate = ' @@ -53,21 +53,18 @@ $userRowTemplate .= ' '; + $sitePermissions = $site->sitePermissions(); -$users = []; + +// data-existing-rows is consumed by site-users.js to assign indices and confirm +// role selections on the PHP-rendered rows. Names and team data are rendered +// directly in PHP, so they are not needed here. +$existingRowsData = []; foreach ($sitePermissions as $sitePermission) { - $user = $sitePermission->user(); - $entry = [ - 'id' => $user->id(), - 'name' => $user->name(), + $existingRowsData[] = [ + 'id' => $sitePermission->user()->id(), 'role' => $sitePermission->role(), ]; - if ($hasTeamData) { - $entry['teams-managed'] = !empty($teamManagedUsers[$user->id()]) - ? implode(', ', array_unique($teamManagedUsers[$user->id()])) - : ''; - } - $users[] = $entry; } ?> @@ -89,7 +86,7 @@ foreach ($sitePermissions as $sitePermission) { - +
@@ -100,7 +97,36 @@ foreach ($sitePermissions as $sitePermission) { - + + user(); + $teamsManaged = $hasTeamData + ? (!empty($teamManagedUsers[$user->id()]) + ? implode(', ', array_unique($teamManagedUsers[$user->id()])) + : '') + : ''; + ?> + + + + + + + + + +
name()); ?> + + +
    +
  • hyperlink('', '#', ['class' => 'o-icon-delete', 'title' => $delete]); ?>
  • +
+ +

@@ -112,22 +138,6 @@ foreach ($sitePermissions as $sitePermission) { + + From 4af3fc760f43a6d110e5e0347fad399595f614bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:24:00 +0000 Subject: [PATCH 54/54] Use real PHP loop indices in rendered permission rows Co-authored-by: alexdryden <47127862+alexdryden@users.noreply.github.com> --- view/omeka/site-admin/index/users.phtml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/view/omeka/site-admin/index/users.phtml b/view/omeka/site-admin/index/users.phtml index 562f151b..f255590d 100644 --- a/view/omeka/site-admin/index/users.phtml +++ b/view/omeka/site-admin/index/users.phtml @@ -98,7 +98,7 @@ foreach ($sitePermissions as $sitePermission) { - $sitePermission): $user = $sitePermission->user(); $teamsManaged = $hasTeamData ? (!empty($teamManagedUsers[$user->id()]) @@ -109,7 +109,7 @@ foreach ($sitePermissions as $sitePermission) { name()); ?> - $roleLabel): ?> @@ -122,7 +122,7 @@ foreach ($sitePermissions as $sitePermission) {
  • hyperlink('', '#', ['class' => 'o-icon-delete', 'title' => $delete]); ?>
- +