-
Notifications
You must be signed in to change notification settings - Fork 62
Fix checklist node copying #8925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a39b542
8f579ec
ff973f0
66c1d35
1643a02
527614e
e50735c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace DoctrineMigrations; | ||
|
|
||
| use Doctrine\DBAL\Schema\Schema; | ||
| use Doctrine\Migrations\AbstractMigration; | ||
|
|
||
| require_once __DIR__.'/checklists/helpers.php'; | ||
|
|
||
| /** | ||
| * Auto-generated Migration: Please modify to your needs! | ||
| */ | ||
| final class Version20260124154000 extends AbstractMigration { | ||
| #[\Override] | ||
| public function getDescription(): string { | ||
| return 'Disables ecamp-managed pbs checklist prototypes'; | ||
| } | ||
|
|
||
| public function up(Schema $schema): void { | ||
| $this->addSql("UPDATE public.checklist c SET isprototype = FALSE WHERE c.id IN ('000100000000', '000200000000', '000300000000', '000400000000');"); | ||
| } | ||
|
|
||
| #[\Override] | ||
| public function down(Schema $schema): void { | ||
| $this->addSql("UPDATE public.checklist c SET isprototype = TRUE WHERE c.id IN ('000100000000', '000200000000', '000300000000', '000400000000');"); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace DoctrineMigrations; | ||
|
|
||
| use Doctrine\DBAL\Schema\Schema; | ||
| use Doctrine\Migrations\AbstractMigration; | ||
|
|
||
| require_once __DIR__.'/checklists/helpers.php'; | ||
|
|
||
| /** | ||
| * Auto-generated Migration: Please modify to your needs! | ||
| */ | ||
| final class Version20260124154500 extends AbstractMigration { | ||
| #[\Override] | ||
| public function getDescription(): string { | ||
| return 'Delete invalid checklist node <-> checklist item connections (across camps)'; | ||
| } | ||
|
|
||
| public function up(Schema $schema): void { | ||
| $this->addSql(<<<'EOF' | ||
| DELETE FROM public.checklistnode_checklistitem | ||
| WHERE (checklistnode_checklistitem.checklistnode_id || '#' || checklistnode_checklistitem.checklistitem_id) IN | ||
| (SELECT (cnci.checklistnode_id || '#' || cnci.checklistitem_id) FROM checklistnode_checklistitem cnci | ||
| INNER JOIN content_node cn ON cn.id=cnci.checklistnode_id | ||
| INNER JOIN content_node root ON root.id=COALESCE(cn.rootid, cn.id) | ||
| INNER JOIN activity a ON a.rootcontentnodeid=root.id | ||
| INNER JOIN checklist_item ci ON ci.id=cnci.checklistitem_id | ||
| INNER JOIN checklist c ON ci.checklistid = c.id | ||
| WHERE c.campid != a.campid); | ||
| EOF); | ||
|
|
||
| $this->addSql(<<<'EOF' | ||
| DELETE FROM public.checklistnode_checklistitem | ||
| WHERE (checklistnode_checklistitem.checklistnode_id || '#' || checklistnode_checklistitem.checklistitem_id) IN | ||
| (SELECT (cnci.checklistnode_id || '#' || cnci.checklistitem_id) FROM checklistnode_checklistitem cnci | ||
| INNER JOIN content_node cn ON cn.id=cnci.checklistnode_id | ||
| INNER JOIN content_node root ON root.id=COALESCE(cn.rootid, cn.id) | ||
| INNER JOIN category cat ON cat.rootcontentnodeid=root.id | ||
| INNER JOIN checklist_item ci ON ci.id=cnci.checklistitem_id | ||
| INNER JOIN checklist c ON ci.checklistid = c.id | ||
| WHERE c.campid != cat.campid); | ||
| EOF); | ||
| } | ||
|
|
||
| #[\Override] | ||
| public function down(Schema $schema): void { | ||
| // not possible | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| use ApiPlatform\Metadata\GetCollection; | ||
| use ApiPlatform\Metadata\Patch; | ||
| use ApiPlatform\Metadata\Post; | ||
| use App\Entity\Checklist; | ||
| use App\Entity\ChecklistItem; | ||
| use App\Entity\ContentNode; | ||
| use App\Repository\ChecklistNodeRepository; | ||
|
|
@@ -102,12 +103,76 @@ public function removeChecklistItem(ChecklistItem $checklistItem) { | |
| public function copyFromPrototype($prototype, $entityMap): void { | ||
| parent::copyFromPrototype($prototype, $entityMap); | ||
|
|
||
| // copy all checklist-items | ||
| foreach ($prototype->checklistItems as $itemPrototype) { | ||
| /** @var ChecklistItem $itemPrototype */ | ||
| /** @var ChecklistItem $checklilstItem */ | ||
| $checklilstItem = $entityMap->get($itemPrototype); | ||
| $this->addChecklistItem($checklilstItem); | ||
| // copy the connections to checklist items | ||
| if ($entityMap->belongsToTargetCamp($prototype)) { | ||
| foreach ($prototype->checklistItems as $itemPrototype) { | ||
| /** @var ChecklistItem $itemPrototype */ | ||
| /** @var ChecklistItem $checklistItem */ | ||
| $checklistItem = $entityMap->get($itemPrototype); | ||
| $this->addChecklistItem($checklistItem); | ||
| } | ||
| } else { | ||
| /** @var ChecklistItem[] $checklistItemsInCamp */ | ||
| $checklistItemsInCamp = array_merge(...array_values(array_map(function (Checklist $checklist) { | ||
| return $checklist->getChecklistItems(); | ||
| }, $entityMap->getTargetCamp()->getChecklists()))); | ||
| foreach ($prototype->checklistItems as $itemPrototype) { | ||
| /** @var ChecklistItem $itemPrototype */ | ||
| /** @var ChecklistItem $knownEquivalent */ | ||
| // First, look up whether we already know a replacement | ||
| $knownEquivalent = $entityMap->get($itemPrototype); | ||
| if ($knownEquivalent !== $itemPrototype) { | ||
| $this->addChecklistItem($knownEquivalent); | ||
|
|
||
| continue; | ||
| } | ||
|
|
||
| // Calculate a score for how well each item in the target camp matches the prototype item | ||
| $matches = array_map(function (ChecklistItem $existingItem) use ($itemPrototype) { | ||
| $score = 0; | ||
| if ($existingItem->text !== $itemPrototype->text) { | ||
| return $score; | ||
| } | ||
| ++$score; | ||
|
|
||
| /** @var ChecklistItem $parent */ | ||
| $parent = $itemPrototype->getParent(); | ||
|
|
||
| /** @var ChecklistItem $existingParent */ | ||
| $existingParent = $existingItem->getParent(); | ||
| while (null !== $parent && null !== $existingParent && $score <= ChecklistItem::MAX_NESTING_DEPTH) { | ||
| if ($existingParent->text !== $parent->text) { | ||
| return $score; | ||
| } | ||
| ++$score; | ||
|
|
||
| /** @var ChecklistItem $parent */ | ||
| $parent = $parent->getParent(); | ||
| } | ||
|
|
||
| if ($existingItem->checklist->checklistPrototypeId !== $itemPrototype->checklist->checklistPrototypeId) { | ||
| return $score; | ||
| } | ||
| ++$score; | ||
|
|
||
| if ($existingItem->checklist->name !== $itemPrototype->checklist->name) { | ||
| return $score; | ||
| } | ||
|
|
||
| return $score + 1; | ||
| }, $checklistItemsInCamp); | ||
|
|
||
| // Use the checklist with the largest positive score, if any | ||
| $maxScore = max([0, ...$matches]); | ||
| $bestMatchIndex = array_find_key($matches, function ($match) use ($maxScore) { | ||
| return $match === $maxScore; | ||
| }); | ||
| if ($maxScore > 0 && null !== $bestMatchIndex) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If there are only matches with score 1 (leaf has the same text),
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, that is intended. This algorithm is supposed to give the best match available, not find a perfect match (because the exact wording of checklist items and their parents may vary slightly over time). I also wrote tests for this: |
||
| $result = $checklistItemsInCamp[$bestMatchIndex]; | ||
| $entityMap->add($itemPrototype, $result); | ||
| $this->addChecklistItem($result); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| use ApiPlatform\Metadata\Get; | ||
| use ApiPlatform\Metadata\Post; | ||
| use App\Entity\Activity; | ||
| use App\Entity\ContentNode\ChecklistNode; | ||
| use App\Entity\User; | ||
| use App\Tests\Api\ECampApiTestCase; | ||
| use App\Tests\Constraints\CompatibleHalResponse; | ||
|
|
@@ -584,7 +585,7 @@ public function testCreateActivityFromCopySourceValidatesAccess() { | |
| } | ||
|
|
||
| public function testCreateActivityFromCopySourceWithinSameCamp() { | ||
| static::createClientWithCredentials()->request( | ||
| $response = static::createClientWithCredentials()->request( | ||
| 'POST', | ||
| '/activities', | ||
| ['json' => $this->getExampleWritePayload( | ||
|
|
@@ -598,6 +599,34 @@ public function testCreateActivityFromCopySourceWithinSameCamp() { | |
|
|
||
| // Activity created | ||
| $this->assertResponseStatusCodeSame(201); | ||
| $responseArray = $response->toArray(); | ||
| $contentNodes = $responseArray['_embedded']['contentNodes']; | ||
|
|
||
| // Embedded MaterialNode -> MaterialItems | ||
| // Test MaterialList is not nulled | ||
| $materialNodes = array_filter($contentNodes, fn ($cn) => 'Material' == $cn['contentTypeName']); | ||
| $this->assertCount(1, $materialNodes); | ||
|
|
||
| $materialNode = reset($materialNodes); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not just use $materialNodes[0] or array_first? I tried to find the next access to $materialNodes and why you are resetting the pointer here....
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just copied that from another test written in 9056cda and tried to be consistent. Maybe @pmattmann knows why? |
||
| $materialItems = $materialNode['_embedded']['materialItems']; | ||
| $this->assertCount(1, $materialItems); | ||
|
|
||
| $materialItem = reset($materialItems); | ||
| $this->assertEquals($this->getIriFor('materialList1'), $materialItem['_links']['materialList']['href']); | ||
|
|
||
| // Embedded ChecklistNode -> ChecklistItems | ||
| // Test ChecklistItem connections are copied | ||
| $checklistNodes = array_filter($contentNodes, fn ($cn) => 'Checklist' == $cn['contentTypeName']); | ||
| $this->assertCount(1, $checklistNodes); | ||
|
|
||
| $checklistNodeId = reset($checklistNodes)['id']; | ||
| $checklistNode = $this->getEntityManager()->getRepository(ChecklistNode::class)->find($checklistNodeId); | ||
|
|
||
| $connectedChecklistItems = $checklistNode->getChecklistItems(); | ||
| $this->assertCount(1, $connectedChecklistItems); | ||
| $connectedChecklistItem = reset($connectedChecklistItems); | ||
|
|
||
| $this->assertEquals($this->getIriFor('checklistItem1_1_1'), $this->getIriFor($connectedChecklistItem)); | ||
| } | ||
|
|
||
| public function testCreateActivityFromCopySourceAcrossCamp() { | ||
|
|
@@ -622,21 +651,31 @@ public function testCreateActivityFromCopySourceAcrossCamp() { | |
|
|
||
| // Activity created | ||
| $this->assertResponseStatusCodeSame(201); | ||
|
|
||
| // Embedded MaterialNode -> MaterialItems | ||
| // Test MaterialList is nulled | ||
| $responseArray = $response->toArray(); | ||
| $contentNodes = $responseArray['_embedded']['contentNodes']; | ||
|
|
||
| // Embedded MaterialNode -> MaterialItems | ||
| // Test MaterialList is nulled | ||
| $materialNodes = array_filter($contentNodes, fn ($cn) => 'Material' == $cn['contentTypeName']); | ||
| $this->assertCount(1, $materialNodes); | ||
|
|
||
| $materialNode = reset($materialNodes); | ||
| $materialItems = $materialNode['_embedded']['materialItems']; | ||
| $this->assertCount(1, $materialItems); | ||
|
|
||
| $materailItem = reset($materialItems); | ||
| $this->assertNull($materailItem['_links']['materialList']); | ||
| $materialItem = reset($materialItems); | ||
| $this->assertNull($materialItem['_links']['materialList']); | ||
|
|
||
| // Embedded ChecklistNode -> ChecklistItems | ||
| // Test ChecklistItem connections are not copied because no matching checklist items are present in target camp | ||
| $checklistNodes = array_filter($contentNodes, fn ($cn) => 'Checklist' == $cn['contentTypeName']); | ||
| $this->assertCount(1, $checklistNodes); | ||
|
|
||
| $checklistNodeId = reset($checklistNodes)['id']; | ||
| $checklistNode = $this->getEntityManager()->getRepository(ChecklistNode::class)->find($checklistNodeId); | ||
|
|
||
| $connectedChecklistItems = $checklistNode->getChecklistItems(); | ||
| $this->assertCount(0, $connectedChecklistItems); | ||
| } | ||
|
|
||
| /** | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you run this statement as select statement in prod?
How long did it take?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. For activities, it takes 1 s 951 ms and gives the result of 49 faulty connections. For categories, it takes 648 ms and gives the result of 0.