Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 0 additions & 135 deletions api/migrations/dev-data/data.sql

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions api/migrations/schema/Version20260124154000.php
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');");
}
}
51 changes: 51 additions & 0 deletions api/migrations/schema/Version20260124154500.php
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
Copy link
Contributor

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?

Copy link
Member Author

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.

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
}
}
7 changes: 4 additions & 3 deletions api/src/Entity/ChecklistItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
#[ORM\UniqueConstraint(name: 'checklistitem_checklistid_parentid_position_unique', columns: ['checklistid', 'parentid', 'position'])]
class ChecklistItem extends BaseEntity implements BelongsToCampInterface, CopyFromPrototypeInterface, HasParentInterface {
public const CHECKLIST_SUBRESOURCE_URI_TEMPLATE = '/checklists/{checklistId}/checklist_items{._format}';
public const MAX_NESTING_DEPTH = 3;

/**
* The Checklist this Item belongs to.
Expand All @@ -100,14 +101,14 @@ class ChecklistItem extends BaseEntity implements BelongsToCampInterface, CopyFr
* root of a ChecklistItem tree. For non-root ChecklistItems, the parent can be changed, as long
* as the new parent is in the same checklist as the old one.
*
* Nesting has maximum depth of 3 Levels (root - child - grandchild)
* Nesting has maximum depth of 3 levels (root - child - grandchild)
* => CurrentNesting + SubtreeDepth < 3
*/
#[AssertBelongsToSameChecklist]
#[AssertNoLoop]
#[Assert\Expression(
'(this.getNestingLevel() + this.getSubtreeDepth()) < 3',
'Nesting can be a maximum of 3 levels deep.'
'(this.getNestingLevel() + this.getSubtreeDepth()) < '.self::MAX_NESTING_DEPTH,
'Nesting can be a maximum of '.self::MAX_NESTING_DEPTH.' levels deep.'
)]
#[ApiProperty(example: '/checklist_items/1a2b3c4d')]
#[Gedmo\SortableGroup]
Expand Down
77 changes: 71 additions & 6 deletions api/src/Entity/ContentNode/ChecklistNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Copy link
Contributor

Choose a reason for hiding this comment

The 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),
but the parent checklistitem has another name, this will still lead to a match...

Copy link
Member Author

@carlobeltrame carlobeltrame Jan 25, 2026

Choose a reason for hiding this comment

The 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: testCopyFromPrototypeAcrossCampsSearchesForItemOfSameName

$result = $checklistItemsInCamp[$bestMatchIndex];
$entityMap->add($itemPrototype, $result);
$this->addChecklistItem($result);
}
}
}
}
}
51 changes: 45 additions & 6 deletions api/tests/Api/Activities/CreateActivityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -584,7 +585,7 @@ public function testCreateActivityFromCopySourceValidatesAccess() {
}

public function testCreateActivityFromCopySourceWithinSameCamp() {
static::createClientWithCredentials()->request(
$response = static::createClientWithCredentials()->request(
'POST',
'/activities',
['json' => $this->getExampleWritePayload(
Expand All @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The 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....

Copy link
Member Author

Choose a reason for hiding this comment

The 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() {
Expand All @@ -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);
}

/**
Expand Down
Loading
Loading