diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e7178..802e9f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ CHANGELOG ========= +2.5.0 +----- + +* Added rel="differences" link to the NRPS membership service +* Added time-based membership differences exposure to the NRPS membership service +* Added members' soft delete + 2.4.7 ----- diff --git a/config/services.yaml b/config/services.yaml index 8f1b544..dc08ce0 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -8,7 +8,7 @@ parameters: application_env: '%env(resolve:APP_ENV)%' application_api_key: '%env(resolve:APP_API_KEY)%' application_vendors: '%kernel.project_dir%/vendor/composer/installed.php' - application_version: '2.4.7' + application_version: '2.5.0' container.dumper.inline_factories: true cache.redis.namespace: '%env(default:cache.redis.namespace.default:REDIS_CACHE_NAMESPACE)%' cache.redis.namespace.default: 'devkit' diff --git a/src/Action/Api/Platform/Nrps/UpdateMembershipAction.php b/src/Action/Api/Platform/Nrps/UpdateMembershipAction.php index 01eb4cb..9349ed1 100644 --- a/src/Action/Api/Platform/Nrps/UpdateMembershipAction.php +++ b/src/Action/Api/Platform/Nrps/UpdateMembershipAction.php @@ -24,9 +24,7 @@ use App\Action\Api\ApiActionInterface; use App\Generator\UrlGenerator; -use App\Nrps\MembershipRepository; -use OAT\Library\Lti1p3Nrps\Factory\Member\MemberFactoryInterface; -use OAT\Library\Lti1p3Nrps\Model\Member\MemberCollection; +use App\Nrps\MembershipService; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -36,22 +34,17 @@ class UpdateMembershipAction implements ApiActionInterface { - /** @var MemberFactoryInterface */ - private $factory; - - /** @var MembershipRepository */ - private $repository; + /** @var MembershipService */ + private $service; /** @var UrlGenerator */ private $generator; public function __construct( - MemberFactoryInterface $factory, - MembershipRepository $repository, + MembershipService $service, UrlGenerator $generator ) { - $this->factory = $factory; - $this->repository = $repository; + $this->service = $service; $this->generator = $generator; } @@ -66,7 +59,7 @@ public function __invoke(Request $request, string $membershipIdentifier): Respon throw new AccessDeniedHttpException('the membership with identifier default cannot be updated'); } - $membership = $this->repository->find($membershipIdentifier); + $membership = $this->service->findMembership($membershipIdentifier); if (null === $membership) { throw new NotFoundHttpException( @@ -90,18 +83,10 @@ public function __invoke(Request $request, string $membershipIdentifier): Respon $membership->setContext($context); - if(array_key_exists('members', $data)) { - $memberCollection = new MemberCollection(); - - foreach ($data['members'] as $member) { - $memberCollection->add($this->factory->create($member)); - } - - $membership->setMembers($memberCollection); + if (array_key_exists('members', $data)) { + $this->service->updateMembership($membership, $data['members']); } - $this->repository->save($membership); - return new JsonResponse( [ 'membership' => $membership, diff --git a/src/Action/Platform/Nrps/EditMembershipAction.php b/src/Action/Platform/Nrps/EditMembershipAction.php index cd8ffb2..a20256a 100644 --- a/src/Action/Platform/Nrps/EditMembershipAction.php +++ b/src/Action/Platform/Nrps/EditMembershipAction.php @@ -23,10 +23,10 @@ namespace App\Action\Platform\Nrps; use App\Form\Platform\Nrps\MembershipType; -use App\Nrps\MembershipRepository; +use App\Nrps\MembershipService; use OAT\Library\Lti1p3Nrps\Factory\Member\MemberFactoryInterface; use OAT\Library\Lti1p3Nrps\Model\Context\Context; -use OAT\Library\Lti1p3Nrps\Model\Member\MemberCollection; +use OAT\Library\Lti1p3Nrps\Model\Member\MemberInterface; use Symfony\Component\Form\FormFactoryInterface; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -42,8 +42,8 @@ class EditMembershipAction /** @var FlashBagInterface */ private $flashBag; - /** @var MembershipRepository */ - private $repository; + /** @var MembershipService */ + private $service; /** @var Environment */ private $twig; @@ -54,28 +54,24 @@ class EditMembershipAction /** @var RouterInterface */ private $router; - /** @var MemberFactoryInterface */ - private $memberFactory; - public function __construct( FlashBagInterface $flashBag, - MembershipRepository $repository, + MembershipService $service, Environment $twig, FormFactoryInterface $factory, RouterInterface $router, MemberFactoryInterface $memberFactory ) { $this->flashBag = $flashBag; - $this->repository = $repository; + $this->service = $service; $this->twig = $twig; $this->factory = $factory; $this->router = $router; - $this->memberFactory = $memberFactory; } public function __invoke(Request $request, string $membershipIdentifier): Response { - $membership = $this->repository->find($membershipIdentifier); + $membership = $this->service->findMembership($membershipIdentifier); if (null === $membership) { throw new NotFoundHttpException( @@ -84,7 +80,16 @@ public function __invoke(Request $request, string $membershipIdentifier): Respon } if ($membership->getMembers()->count() !== 0) { - $members = json_encode($membership->getMembers()); + $members = json_encode( + array_values( + array_filter( + $membership->getMembers()->all(), + static function (MemberInterface $member) { + return $member->getStatus() !== MemberInterface::STATUS_DELETED; + } + ) + ) + ); } else { $members = ''; } @@ -106,7 +111,6 @@ public function __invoke(Request $request, string $membershipIdentifier): Respon $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $formData = $form->getData(); $membership->setContext( @@ -117,23 +121,16 @@ public function __invoke(Request $request, string $membershipIdentifier): Respon ) ); - $memberCollection = new MemberCollection(); - + $members = []; if ($formData['members']) { $members = json_decode($formData['members'], true); if (JSON_ERROR_NONE !== json_last_error()) { throw new BadRequestHttpException(sprintf('json_decode error: %s', json_last_error_msg())); } - - foreach ($members as $member) { - $memberCollection->add($this->memberFactory->create($member)); - } } - $membership->setMembers($memberCollection); - - $this->repository->save($membership); + $this->service->updateMembership($membership, $members); $this->flashBag->add('success', sprintf('Membership %s edition success', $formData['membership_id'])); diff --git a/src/Nrps/MembershipRepository.php b/src/Nrps/MembershipRepository.php index 7579008..f33b638 100644 --- a/src/Nrps/MembershipRepository.php +++ b/src/Nrps/MembershipRepository.php @@ -22,6 +22,8 @@ namespace App\Nrps; +use OAT\Library\Lti1p3Nrps\Model\Member\MemberCollection; +use OAT\Library\Lti1p3Nrps\Model\Member\MemberInterface; use OAT\Library\Lti1p3Nrps\Model\Membership\MembershipInterface; use Psr\Cache\CacheItemPoolInterface; @@ -37,19 +39,36 @@ public function __construct(CacheItemPoolInterface $cache) $this->cache = $cache; } - public function find(string $identifier): ?MembershipInterface - { + public function find( + string $identifier, + ?array $statuses = [MemberInterface::STATUS_ACTIVE, MemberInterface::STATUS_INACTIVE] + ): ?MembershipInterface { + $membership = null; $cache = $this->cache->getItem(self::CACHE_KEY); if ($cache->isHit()) { $memberships = $cache->get(); if (array_key_exists($identifier, $memberships)) { - return $memberships[$identifier]; + /** @var MembershipInterface $membership */ + $membership = $memberships[$identifier]; + + if (null !== $statuses) { + $membership->setMembers( + new MemberCollection( + array_filter( + $membership->getMembers()->all(), + static function (MemberInterface $member) use ($statuses) { + return in_array($member->getStatus(), $statuses, true); + } + ) + ) + ); + } } } - return null; + return $membership; } public function findAll(): array diff --git a/src/Nrps/MembershipService.php b/src/Nrps/MembershipService.php new file mode 100644 index 0000000..966c256 --- /dev/null +++ b/src/Nrps/MembershipService.php @@ -0,0 +1,101 @@ + + */ + +declare(strict_types=1); + +namespace App\Nrps; + +use OAT\Library\Lti1p3Nrps\Factory\Member\MemberFactoryInterface; +use OAT\Library\Lti1p3Nrps\Model\Member\MemberCollection; +use OAT\Library\Lti1p3Nrps\Model\Member\MemberInterface; +use OAT\Library\Lti1p3Nrps\Model\Membership\MembershipInterface; + +class MembershipService +{ + public const UPDATED_AT_FIELD = 'updated_at'; + + /** @var MembershipRepository */ + private $repository; + + /** @var MemberFactoryInterface */ + private $memberFactory; + + public function __construct(MembershipRepository $repository, MemberFactoryInterface $memberFactory) + { + $this->repository = $repository; + $this->memberFactory = $memberFactory; + } + + public function findMembership(string $identifier, array $statuses = null): ?MembershipInterface + { + return $this->repository->find($identifier, $statuses); + } + + public function updateMembership(MembershipInterface $membership, array $rawMembers): void + { + $memberCollection = new MemberCollection(); + $now = time(); + + foreach ($rawMembers as $member) { + unset($member[static::UPDATED_AT_FIELD]); + $newMember = $this->memberFactory->create($member); + + if (!$membership->getMembers()->has($newMember->getUserIdentity()->getIdentifier())) { + $this->setUpdatedAtToMember($newMember, $now); + } + + $memberCollection->add($newMember); + } + + /** @var MemberInterface $existingMember */ + foreach ($membership->getMembers() as $existingMember) { + $existingMemberUpdatedAt = $existingMember->getProperties()->get(static::UPDATED_AT_FIELD); + $existingMember->getProperties()->remove(static::UPDATED_AT_FIELD); + $memberId = $existingMember->getUserIdentity()->getIdentifier(); + + if (!$memberCollection->has($memberId)) { + $this->setUpdatedAtToMember($existingMember, $now); + $existingMember->setStatus(MemberInterface::STATUS_DELETED) + ->getProperties()->add(['status' => MemberInterface::STATUS_DELETED]); + + $memberCollection->add($existingMember); + } else { + $member = $memberCollection->get($memberId); + $this->setUpdatedAtToMember( + $member, + $existingMemberUpdatedAt && $member == $existingMember + ? $existingMemberUpdatedAt + : $now + ); + } + } + + $membership->setMembers($memberCollection); + + $this->repository->save($membership); + } + + private function setUpdatedAtToMember(MemberInterface $member, int $now): void + { + $member->getProperties()->add([static::UPDATED_AT_FIELD => $now]); + } +} diff --git a/src/Nrps/MembershipServiceServerBuilder.php b/src/Nrps/MembershipServiceServerBuilder.php index 414ee2d..4c5d1e9 100644 --- a/src/Nrps/MembershipServiceServerBuilder.php +++ b/src/Nrps/MembershipServiceServerBuilder.php @@ -27,7 +27,6 @@ use OAT\Library\Lti1p3Nrps\Model\Member\MemberInterface; use OAT\Library\Lti1p3Nrps\Model\Membership\MembershipInterface; use OAT\Library\Lti1p3Nrps\Service\Server\Builder\MembershipServiceServerBuilderInterface; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -36,19 +35,19 @@ class MembershipServiceServerBuilder implements MembershipServiceServerBuilderIn /** @var RequestStack */ private $requestStack; - /** @var MembershipRepository */ - private $repository; + /** @var MembershipService */ + private $service; /** @var DefaultMembershipFactory */ private $factory; public function __construct( RequestStack $requestStack, - MembershipRepository $repository, + MembershipService $service, DefaultMembershipFactory $factory ) { $this->requestStack = $requestStack; - $this->repository = $repository; + $this->service = $service; $this->factory = $factory; } @@ -76,13 +75,18 @@ private function build(string $role = null, int $limit = null, int $offset = nul $request = $this->requestStack->getCurrentRequest(); $routeParameters = $request->attributes->get('_route_params'); + $parsedUrl = parse_url(urldecode($request->getUri())); + parse_str($parsedUrl['query'] ?? '', $parsedQuery); + + $since = isset($parsedQuery['since']) ? (int)$parsedQuery['since'] : null; + $membershipIdentifier = $routeParameters['membershipIdentifier'] ?? null; $contextIdentifier = $routeParameters['contextIdentifier'] ?? null; if ($membershipIdentifier === 'default') { $membership = $this->factory->create(); } else { - $membership = $this->repository->find($membershipIdentifier); + $membership = $this->service->findMembership($membershipIdentifier); } if (null === $membership || $membership->getContext()->getIdentifier() !== $contextIdentifier) { @@ -93,55 +97,65 @@ private function build(string $role = null, int $limit = null, int $offset = nul $filteredMembers = array_filter( $membership->getMembers()->all(), - static function (MemberInterface $member) use ($role) { - if (null === $role) { - return true; - } - - return in_array($role, $member->getRoles()); + static function (MemberInterface $member) use ($role, $since) { + return ($role === null || in_array($role, $member->getRoles(), true)) + && ( + $since === null + || $member->getProperties()->get(MembershipService::UPDATED_AT_FIELD, 0) > $since + ) + && ($since !== null || $member->getStatus() !== MemberInterface::STATUS_DELETED); } ); return $membership ->setMembers(new MemberCollection(array_slice($filteredMembers, $offset ?? 0, $limit ?? null))) - ->setRelationLink($this->buildRelationLink($request, sizeof($filteredMembers), $role, $limit, $offset)); + ->setRelationLink( + $this->buildRelationLink($parsedUrl, sizeof($filteredMembers), $role, $limit, $offset, $since) + ); } private function buildRelationLink( - Request $request, + array $parsedUrl, int $totalCount, string $role = null, int $limit = null, - int $offset = null + int $offset = null, + int $since = null ): ?string { - if ($limit && (($limit ?? 0) + ($offset ?? 0)) < $totalCount) { - $parsedUrl = parse_url(urldecode($request->getUri())); - parse_str($parsedUrl['query'] ?? '', $parsedQuery); - - $nextOffset = $limit ?? 0; - - if ($parsedQuery['offset'] ?? false) { - $nextOffset += $parsedQuery['offset']; - } + $linkUrl = sprintf( + '%s://%s%s%s', + $parsedUrl['scheme'], + $parsedUrl['host'], + $parsedUrl['port'] ?? false ? ':' . $parsedUrl['port'] : '', + $parsedUrl['path'] + ); - $relUrl = sprintf( - '%s://%s%s%s', - $parsedUrl['scheme'], - $parsedUrl['host'], - $parsedUrl['port'] ?? false ? ':' . $parsedUrl['port'] : '', - $parsedUrl['path'] - ); + $linkQueryParameters = [ + 'differences' => array_filter( + [ + 'role' => $role, + 'limit' => $limit, + 'since' => time() + ] + ), + ]; - $relUrlQuery = array_filter( + if ($limit && ($nextOffset = $limit + ($offset ?? 0)) < $totalCount) { + $linkQueryParameters['next'] = array_filter( [ 'role' => $role, - 'offset' => $nextOffset + 'limit' => $limit, + 'offset' => $nextOffset, + 'since' => $since ] ); + } - return '<' . $relUrl . '?' . http_build_query($relUrlQuery) . '>; rel="next"'; + $links = []; + foreach ($linkQueryParameters as $link => $queryParameters) { + $links[] = sprintf('<%s?%s>; rel="%s"', $linkUrl, http_build_query($queryParameters), $link); } - return null; + return implode(', ', $links); } }