From 707d106f731d9c33a468084b5bf2bb9b6a2f258b Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:50:56 +0100 Subject: [PATCH 01/95] Add content_review behavior --- .../intranet/behaviors/configure.zcml | 7 ++ .../intranet/behaviors/content_review.py | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 backend/src/kitconcept/intranet/behaviors/content_review.py diff --git a/backend/src/kitconcept/intranet/behaviors/configure.zcml b/backend/src/kitconcept/intranet/behaviors/configure.zcml index 74299710..ee0851b4 100644 --- a/backend/src/kitconcept/intranet/behaviors/configure.zcml +++ b/backend/src/kitconcept/intranet/behaviors/configure.zcml @@ -47,6 +47,13 @@ description="Add the intranet-specific person fields" provides=".person.IPersonBehavior" /> + + Date: Fri, 13 Feb 2026 16:53:25 +0100 Subject: [PATCH 02/95] Add vocabularies --- .../intranet/vocabularies/configure.zcml | 9 ++++++++ .../intranet/vocabularies/content_review.py | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 backend/src/kitconcept/intranet/vocabularies/content_review.py diff --git a/backend/src/kitconcept/intranet/vocabularies/configure.zcml b/backend/src/kitconcept/intranet/vocabularies/configure.zcml index 91910089..324b829b 100644 --- a/backend/src/kitconcept/intranet/vocabularies/configure.zcml +++ b/backend/src/kitconcept/intranet/vocabularies/configure.zcml @@ -34,6 +34,15 @@ name="kitconcept.intranet.vocabularies.responsibilities" component=".responsibilities.ResponsibilitiesVocabularyFactory" /> + + + diff --git a/backend/src/kitconcept/intranet/vocabularies/content_review.py b/backend/src/kitconcept/intranet/vocabularies/content_review.py new file mode 100644 index 00000000..30527883 --- /dev/null +++ b/backend/src/kitconcept/intranet/vocabularies/content_review.py @@ -0,0 +1,22 @@ +from zope.interface import provider +from zope.schema.interfaces import IVocabularyFactory +from zope.schema.vocabulary import SimpleTerm +from zope.schema.vocabulary import SimpleVocabulary + + +INTERVALS = [] + + +@provider(IVocabularyFactory) +def review_intervals_vocabulary(context) -> SimpleVocabulary: + terms = [] + for token, title in INTERVALS: + terms.append(SimpleTerm(token, token, title)) + return SimpleVocabulary(terms) + + +@provider(IVocabularyFactory) +def review_users_vocabulary(context): + acl_users = context.acl_users + reviewers = acl_users.getGroupById("Reviewers") + return reviewers.getMemberIds() From a991d9b46822f17bc91c8da31c8a768588b01b8d Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:43:28 +0100 Subject: [PATCH 03/95] Fix fieldset not showing --- backend/src/kitconcept/intranet/behaviors/content_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index f680760f..7c27ec27 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -10,8 +10,8 @@ class IContentReview(model.Schema): """Content Review behavior""" - model.Fieldset( - "", + model.fieldset( + "Content Review", label=_("label_review_fieldset", "Content Review"), fields=[ "review_status", From c77642d74cc89028c0727c6727c9c5758d2d6f6c Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:26:07 +0100 Subject: [PATCH 04/95] Wording & stuff --- .../intranet/behaviors/content_review.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index 7c27ec27..a4a0f4eb 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -23,15 +23,17 @@ class IContentReview(model.Schema): ) review_status = schema.Choice( - title=_("label_review_status", default="Review status"), - values=("up-to-date", "due", "changes requested"), - default="up-to-date", + title=_("label_review_status", default="Status"), + values=("Up-to-date", "Due", "Changes requested"), + default="Up-to-date", required=False, readonly=True, ) - review_interval = schema.Choice( - title=_("label_review_interval", default="Review interval") + review_interval = schema.List( + title=_("label_review_interval", default="Interval"), + value_type=schema.TextLine(), + required=False, ) directives.widget( @@ -44,18 +46,18 @@ class IContentReview(model.Schema): ) review_due_date = schema.Date( - title=_("label_review_due_date", default="Review due date"), + title=_("label_review_due_date", default="Due date"), required=False, ) review_completed_date = schema.Date( - title=_("label_review_completed_date", default="Review completed date"), + title=_("label_review_completed_date", default="Completed date"), required=False, readonly=True, ) - review_assignee = schema.Tuple( - title=_("label_review_assignee", default="Review assignee"), + review_assignee = schema.List( + title=_("label_review_assignee", default="Assignee"), value_type=schema.TextLine(), required=False, ) From 848b12fee691563d5cb9bb652dff641098ff2088 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:32:20 +0100 Subject: [PATCH 05/95] Add behavior to all default content types --- .../src/kitconcept/intranet/profiles/default/types/Document.xml | 1 + backend/src/kitconcept/intranet/profiles/default/types/Event.xml | 1 + backend/src/kitconcept/intranet/profiles/default/types/File.xml | 1 + backend/src/kitconcept/intranet/profiles/default/types/Image.xml | 1 + backend/src/kitconcept/intranet/profiles/default/types/Link.xml | 1 + .../src/kitconcept/intranet/profiles/default/types/Location.xml | 1 + .../src/kitconcept/intranet/profiles/default/types/News_Item.xml | 1 + .../intranet/profiles/default/types/Organisational_Unit.xml | 1 + .../src/kitconcept/intranet/profiles/default/types/Person.xml | 1 + 9 files changed, 9 insertions(+) diff --git a/backend/src/kitconcept/intranet/profiles/default/types/Document.xml b/backend/src/kitconcept/intranet/profiles/default/types/Document.xml index c581edeb..221b13c5 100644 --- a/backend/src/kitconcept/intranet/profiles/default/types/Document.xml +++ b/backend/src/kitconcept/intranet/profiles/default/types/Document.xml @@ -16,6 +16,7 @@ /> + diff --git a/backend/src/kitconcept/intranet/profiles/default/types/Event.xml b/backend/src/kitconcept/intranet/profiles/default/types/Event.xml index 9d12a8fc..3d37b9f6 100644 --- a/backend/src/kitconcept/intranet/profiles/default/types/Event.xml +++ b/backend/src/kitconcept/intranet/profiles/default/types/Event.xml @@ -17,6 +17,7 @@ + diff --git a/backend/src/kitconcept/intranet/profiles/default/types/File.xml b/backend/src/kitconcept/intranet/profiles/default/types/File.xml index 22958760..4b97741e 100644 --- a/backend/src/kitconcept/intranet/profiles/default/types/File.xml +++ b/backend/src/kitconcept/intranet/profiles/default/types/File.xml @@ -10,6 +10,7 @@ + diff --git a/backend/src/kitconcept/intranet/profiles/default/types/Image.xml b/backend/src/kitconcept/intranet/profiles/default/types/Image.xml index 2097be02..b65f3293 100644 --- a/backend/src/kitconcept/intranet/profiles/default/types/Image.xml +++ b/backend/src/kitconcept/intranet/profiles/default/types/Image.xml @@ -10,6 +10,7 @@ + diff --git a/backend/src/kitconcept/intranet/profiles/default/types/Link.xml b/backend/src/kitconcept/intranet/profiles/default/types/Link.xml index dac2c7d1..e643f316 100644 --- a/backend/src/kitconcept/intranet/profiles/default/types/Link.xml +++ b/backend/src/kitconcept/intranet/profiles/default/types/Link.xml @@ -9,5 +9,6 @@ > + diff --git a/backend/src/kitconcept/intranet/profiles/default/types/Location.xml b/backend/src/kitconcept/intranet/profiles/default/types/Location.xml index 825c48a2..fb4bf425 100644 --- a/backend/src/kitconcept/intranet/profiles/default/types/Location.xml +++ b/backend/src/kitconcept/intranet/profiles/default/types/Location.xml @@ -56,6 +56,7 @@ + diff --git a/backend/src/kitconcept/intranet/profiles/default/types/News_Item.xml b/backend/src/kitconcept/intranet/profiles/default/types/News_Item.xml index 2cd44a9a..26169668 100644 --- a/backend/src/kitconcept/intranet/profiles/default/types/News_Item.xml +++ b/backend/src/kitconcept/intranet/profiles/default/types/News_Item.xml @@ -10,6 +10,7 @@ + diff --git a/backend/src/kitconcept/intranet/profiles/default/types/Organisational_Unit.xml b/backend/src/kitconcept/intranet/profiles/default/types/Organisational_Unit.xml index be5b7611..db23d8d8 100644 --- a/backend/src/kitconcept/intranet/profiles/default/types/Organisational_Unit.xml +++ b/backend/src/kitconcept/intranet/profiles/default/types/Organisational_Unit.xml @@ -56,6 +56,7 @@ + diff --git a/backend/src/kitconcept/intranet/profiles/default/types/Person.xml b/backend/src/kitconcept/intranet/profiles/default/types/Person.xml index 0dd6d87f..065cb8a6 100644 --- a/backend/src/kitconcept/intranet/profiles/default/types/Person.xml +++ b/backend/src/kitconcept/intranet/profiles/default/types/Person.xml @@ -25,5 +25,6 @@ + From 94e4902e307152189381d73ccacc6b110adace66 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:32:48 +0100 Subject: [PATCH 06/95] Add upgrade step --- .../intranet/profiles/default/metadata.xml | 2 +- .../kitconcept/intranet/upgrades/configure.zcml | 1 + .../intranet/upgrades/v20260217001/__init__.py | 0 .../intranet/upgrades/v20260217001/configure.zcml | 15 +++++++++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 backend/src/kitconcept/intranet/upgrades/v20260217001/__init__.py create mode 100644 backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml diff --git a/backend/src/kitconcept/intranet/profiles/default/metadata.xml b/backend/src/kitconcept/intranet/profiles/default/metadata.xml index b5363d1d..c1e55107 100644 --- a/backend/src/kitconcept/intranet/profiles/default/metadata.xml +++ b/backend/src/kitconcept/intranet/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 20260122001 + 20260217001 plone.app.discussion:default diff --git a/backend/src/kitconcept/intranet/upgrades/configure.zcml b/backend/src/kitconcept/intranet/upgrades/configure.zcml index 132b5b14..28a1d199 100644 --- a/backend/src/kitconcept/intranet/upgrades/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/configure.zcml @@ -86,5 +86,6 @@ + diff --git a/backend/src/kitconcept/intranet/upgrades/v20260217001/__init__.py b/backend/src/kitconcept/intranet/upgrades/v20260217001/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml new file mode 100644 index 00000000..ce46188f --- /dev/null +++ b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file From d70117f8ffb87f5bbba00ee5bbb9c32606c97b0e Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:48:17 +0100 Subject: [PATCH 07/95] make format --- backend/src/kitconcept/intranet/behaviors/configure.zcml | 2 +- .../intranet/upgrades/v20260217001/configure.zcml | 2 +- backend/src/kitconcept/intranet/vocabularies/configure.zcml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/src/kitconcept/intranet/behaviors/configure.zcml b/backend/src/kitconcept/intranet/behaviors/configure.zcml index ee0851b4..c90cb100 100644 --- a/backend/src/kitconcept/intranet/behaviors/configure.zcml +++ b/backend/src/kitconcept/intranet/behaviors/configure.zcml @@ -47,7 +47,7 @@ description="Add the intranet-specific person fields" provides=".person.IPersonBehavior" /> - + - \ No newline at end of file + diff --git a/backend/src/kitconcept/intranet/vocabularies/configure.zcml b/backend/src/kitconcept/intranet/vocabularies/configure.zcml index 324b829b..3955358c 100644 --- a/backend/src/kitconcept/intranet/vocabularies/configure.zcml +++ b/backend/src/kitconcept/intranet/vocabularies/configure.zcml @@ -34,13 +34,13 @@ name="kitconcept.intranet.vocabularies.responsibilities" component=".responsibilities.ResponsibilitiesVocabularyFactory" /> - - - From 6d47850b961b6947881b79e979d8276119abd7d2 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:51:08 +0100 Subject: [PATCH 08/95] Add content_review_default_interval setting to intranet controlpanel --- backend/src/kitconcept/intranet/controlpanels/intranet.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/src/kitconcept/intranet/controlpanels/intranet.py b/backend/src/kitconcept/intranet/controlpanels/intranet.py index c558a0a3..2d41fa3f 100644 --- a/backend/src/kitconcept/intranet/controlpanels/intranet.py +++ b/backend/src/kitconcept/intranet/controlpanels/intranet.py @@ -1,3 +1,4 @@ +import kitconcept.intranet.vocabularies.location from kitconcept.intranet import _ from kitconcept.intranet.interfaces import IBrowserLayer from plone.app.registry.browser.controlpanel import ControlPanelFormWrapper @@ -87,6 +88,12 @@ class IIntranetSettings(Interface): defaultFactory=list, ) + content_review_default_interval = schema.Choice( + title=_("Default review interval"), + vocabulary="kitconcept.intranet.vocabularies.content_review_intervals", + required=False, + ) + class IntranetSettingsEditForm(RegistryEditForm): schema = IIntranetSettings From 417568ccb01377773439e94ead3241ad57fc3b3b Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:52:34 +0100 Subject: [PATCH 09/95] Directly access vocabularies without directives.widget --- .../intranet/behaviors/content_review.py | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index a4a0f4eb..0a2f2345 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -1,5 +1,4 @@ from kitconcept.intranet import _ -from plone.autoform import directives from plone.autoform.interfaces import IFormFieldProvider from plone.supermodel import model from zope import schema @@ -30,21 +29,12 @@ class IContentReview(model.Schema): readonly=True, ) - review_interval = schema.List( + review_interval = schema.Choice( title=_("label_review_interval", default="Interval"), - value_type=schema.TextLine(), + vocabulary="kitconcept.intranet.vocabularies.content_review_intervals", required=False, ) - directives.widget( - "review_interval", - vocabulary="kitconcept.intranet.vocabularies.review_intervals", - frontendOptions={ - "widget": "autocomplete", - "widgetProps": {"isMulti": False}, - }, - ) - review_due_date = schema.Date( title=_("label_review_due_date", default="Due date"), required=False, @@ -56,17 +46,8 @@ class IContentReview(model.Schema): readonly=True, ) - review_assignee = schema.List( + review_assignee = schema.Choice( title=_("label_review_assignee", default="Assignee"), - value_type=schema.TextLine(), + vocabulary="plone.app.vocabularies.Users", required=False, ) - - directives.widget( - "review_assignee", - vocabulary="kitconcept.intranet.vocabularies.review_users", - frontendOptions={ - "widget": "autocomplete", - "widgetProps": {"isMulti": False}, - }, - ) From 1137d824c1cf3e2b7cea7fbf820270cdc3d72e4f Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 18 Feb 2026 16:54:04 +0100 Subject: [PATCH 10/95] Add basic review endpoint --- .../intranet/services/configure.zcml | 8 ++++ .../kitconcept/intranet/services/review.py | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 backend/src/kitconcept/intranet/services/review.py diff --git a/backend/src/kitconcept/intranet/services/configure.zcml b/backend/src/kitconcept/intranet/services/configure.zcml index 91c3ac60..87a4f715 100644 --- a/backend/src/kitconcept/intranet/services/configure.zcml +++ b/backend/src/kitconcept/intranet/services/configure.zcml @@ -61,4 +61,12 @@ name="@vote" /> + + diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py new file mode 100644 index 00000000..f0a1641e --- /dev/null +++ b/backend/src/kitconcept/intranet/services/review.py @@ -0,0 +1,45 @@ +from kitconcept.intranet.behaviors.content_review import IContentReview +from plone.restapi.deserializer import json_body +from plone.restapi.services import Service +from zExceptions import BadRequest +from zope.interface import implementer +from zope.publisher.interfaces import IPublishTraverse + + +@implementer(IPublishTraverse) +class ReviewPost(Service): + """@review endpoint.""" + + def __init__(self, context, request): + super().__init__(context, request) + self.params = [] + + def publishTraverse(self, request, name): + # Treat any path segments after /@review as parameters + self.params.append(name) + return self + + def reply(self): + if not self.params: + raise BadRequest("Missing parameter") + + if len(self.params) > 1: + raise BadRequest("Too many parameters") + + if not IContentReview.providedBy(self.context): + raise BadRequest("Endpoint not supported in this context") + + param = self.params[0] + match param: + case "approve": + # update review_status + status = IContentReview["review_status"] + status.readonly = False + status.set("Up-to-date") + status.readonly = True + # TODO: update review_completed_date & review_due_date + case "delegate": + data = json_body(self.request) + case "postpone": + # TODO: handle postpone + data = json_body(self.request) From 8a5b166f5e882233e67f952129123d765fe1e590 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:52:23 +0100 Subject: [PATCH 11/95] Update service permission & allowed context --- backend/src/kitconcept/intranet/services/configure.zcml | 4 ++-- backend/src/kitconcept/intranet/services/review.py | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/src/kitconcept/intranet/services/configure.zcml b/backend/src/kitconcept/intranet/services/configure.zcml index 87a4f715..4080213a 100644 --- a/backend/src/kitconcept/intranet/services/configure.zcml +++ b/backend/src/kitconcept/intranet/services/configure.zcml @@ -64,8 +64,8 @@ diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index f0a1641e..5956cfc2 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -26,9 +26,6 @@ def reply(self): if len(self.params) > 1: raise BadRequest("Too many parameters") - if not IContentReview.providedBy(self.context): - raise BadRequest("Endpoint not supported in this context") - param = self.params[0] match param: case "approve": From 7341f1053233fa13990eba6d1a60f680ac5022ca Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:53:06 +0100 Subject: [PATCH 12/95] Add plone.app.registry to upgrade step --- .../kitconcept/intranet/upgrades/v20260217001/configure.zcml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml index d85c9765..f0156758 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml @@ -9,7 +9,7 @@ > From d382f08494fdedd1d06555654ac6644a6d7e656f Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:53:49 +0100 Subject: [PATCH 13/95] Remove unused import --- backend/src/kitconcept/intranet/controlpanels/intranet.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/controlpanels/intranet.py b/backend/src/kitconcept/intranet/controlpanels/intranet.py index 2d41fa3f..72f2b7cb 100644 --- a/backend/src/kitconcept/intranet/controlpanels/intranet.py +++ b/backend/src/kitconcept/intranet/controlpanels/intranet.py @@ -1,4 +1,3 @@ -import kitconcept.intranet.vocabularies.location from kitconcept.intranet import _ from kitconcept.intranet.interfaces import IBrowserLayer from plone.app.registry.browser.controlpanel import ControlPanelFormWrapper From ca048881dc02b2ecb7a909e4fed323e3592f14c0 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:45:44 +0100 Subject: [PATCH 14/95] Fix context & update error messages --- .../intranet/services/configure.zcml | 2 +- .../kitconcept/intranet/services/review.py | 20 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/backend/src/kitconcept/intranet/services/configure.zcml b/backend/src/kitconcept/intranet/services/configure.zcml index 4080213a..ff11a9b0 100644 --- a/backend/src/kitconcept/intranet/services/configure.zcml +++ b/backend/src/kitconcept/intranet/services/configure.zcml @@ -64,7 +64,7 @@ diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index 5956cfc2..a53769ae 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -21,22 +21,30 @@ def publishTraverse(self, request, name): def reply(self): if not self.params: - raise BadRequest("Missing parameter") + raise BadRequest( + "Missing action: expected /@review/approve, " + "/@review/delegate, or /@review/postpone" + ) if len(self.params) > 1: - raise BadRequest("Too many parameters") + raise BadRequest( + "Too many path segments: expected /@review/approve, " + "/@review/delegate, or /@review/postpone" + ) param = self.params[0] match param: case "approve": # update review_status - status = IContentReview["review_status"] - status.readonly = False - status.set("Up-to-date") - status.readonly = True + self.context.review_status = "Up-to-date" # TODO: update review_completed_date & review_due_date case "delegate": data = json_body(self.request) case "postpone": # TODO: handle postpone data = json_body(self.request) + case _: + raise BadRequest( + "Unknown action: expected /@review/approve, " + "/@review/delegate, or /@review/postpone" + ) From 8d7dbb4eb5f65e31c2dba009705c15f5b68f56ea Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:46:24 +0100 Subject: [PATCH 15/95] Remove unused import --- backend/src/kitconcept/intranet/services/review.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index a53769ae..e6da7799 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -1,4 +1,3 @@ -from kitconcept.intranet.behaviors.content_review import IContentReview from plone.restapi.deserializer import json_body from plone.restapi.services import Service from zExceptions import BadRequest From 1ac5bb65185f5b98b44d110e4fba3fe86bc34066 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:48:26 +0100 Subject: [PATCH 16/95] Add review_status and review_due_date indexes --- .../intranet/profiles/default/catalog.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/src/kitconcept/intranet/profiles/default/catalog.xml b/backend/src/kitconcept/intranet/profiles/default/catalog.xml index ae75e079..e59ebf34 100644 --- a/backend/src/kitconcept/intranet/profiles/default/catalog.xml +++ b/backend/src/kitconcept/intranet/profiles/default/catalog.xml @@ -41,4 +41,17 @@ + + + + + + + + + From 4d939ea6ccb47946feb1e0855e8fb800c530e5da Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:48:38 +0100 Subject: [PATCH 17/95] Update upgrade step --- .../upgrades/v20260217001/configure.zcml | 6 ++++- .../intranet/upgrades/v20260217001/upgrade.py | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py diff --git a/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml index f0156758..5403c10b 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml @@ -9,7 +9,11 @@ > + diff --git a/backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py b/backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py new file mode 100644 index 00000000..087aae63 --- /dev/null +++ b/backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py @@ -0,0 +1,26 @@ +from kitconcept.intranet.behaviors.content_review import IContentReview +from plone import api + +import logging +import transaction + + +logger = logging.getLogger("kitconcept.intranet") + + +def add_review_status_indexer(context): + catalog = api.portal.get_tool("portal_catalog") + indexes = catalog.indexes() + if "review_status" in indexes: + catalog.addIndex("review_status", "FieldIndex") + logger.info("Added review_status index.") + brains = catalog(object_provides=IContentReview) + total = len(brains) + for index, brain in enumerate(brains): + obj = brain.getObject() + obj.reindexObject(idxs=["review_status"], update_metadata=0) + logger.info(f"Reindexing object {brain.getPath()}.") + if index % 250 == 0: + logger.info(f"Reindexed {index}/{total} objects.") + transaction.commit() + transaction.commit() From 4696fef5eb0b53cd7105752ffb9b7476c24f2c71 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:18:46 +0100 Subject: [PATCH 18/95] Add querystring criterion --- .../profiles/default/registry/querystring.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml b/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml index 7b4cc15b..d11fd041 100644 --- a/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml +++ b/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml @@ -66,4 +66,23 @@ >Taxonomy + + + + Review status + Filter by review status + True + False + + plone.app.querystring.operation.selection.any + + Taxonomy + + From d04b534f637eee82d659d5dcdcdad21e821cfe1f Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:51:34 +0100 Subject: [PATCH 19/95] Fix upgrade step after merging main --- .../kitconcept/intranet/upgrades/v20260217001/configure.zcml | 2 +- .../src/kitconcept/intranet/upgrades/v20260217001/upgrade.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml index 5403c10b..d76ebb5a 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml @@ -4,7 +4,7 @@ > Date: Fri, 20 Feb 2026 17:15:39 +0100 Subject: [PATCH 20/95] Add import step for review_due_date indexer --- .../upgrades/v20260217001/configure.zcml | 4 ++++ .../intranet/upgrades/v20260217001/upgrade.py | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml index d76ebb5a..d86cded4 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/v20260217001/configure.zcml @@ -15,5 +15,9 @@ title="Add and update review_status index" handler=".upgrade.add_review_status_indexer" /> + diff --git a/backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py b/backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py index 75569173..4a92dbb7 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py +++ b/backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py @@ -24,3 +24,21 @@ def add_review_status_indexer(context): logger.info(f"Reindexed {index}/{total} objects.") transaction.commit() transaction.commit() + + +def add_review_due_date_indexer(context): + catalog = api.portal.get_tool("portal_catalog") + indexes = catalog.indexes() + if "review_due_date" not in indexes: + catalog.addIndex("review_due_date", "FieldIndex") + logger.info("Added review_due_date index.") + brains = catalog(object_provides=IContentReview) + total = len(brains) + for index, brain in enumerate(brains): + obj = brain.getObject() + obj.reindexObject(idxs=["review_status"], update_metadata=0) + logger.info(f"Reindexing object {brain.getPath()}.") + if index % 250 == 0: + logger.info(f"Reindexed {index}/{total} objects.") + transaction.commit() + transaction.commit() From bd7bf938eec67d1510a1fd9d29979c42d4ec231a Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:37:44 +0100 Subject: [PATCH 21/95] Add invariant & review_enabled, title & description in fields --- .../intranet/behaviors/content_review.py | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index 0a2f2345..13a0eb7d 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -2,6 +2,8 @@ from plone.autoform.interfaces import IFormFieldProvider from plone.supermodel import model from zope import schema +from zope.interface import Invalid +from zope.interface import invariant from zope.interface import provider @@ -10,17 +12,27 @@ class IContentReview(model.Schema): """Content Review behavior""" model.fieldset( - "Content Review", - label=_("label_review_fieldset", "Content Review"), + "Content Review & Reminders", + label=_("label_review_fieldset", "Content Review & Reminders"), fields=[ + "review_enabled", "review_status", "review_interval", + "review_assignee", "review_due_date", "review_completed_date", - "review_assignee", ], ) + review_enabled = schema.Bool( + title=_( + "label_review_enabled", + default="Timeless content - exclude from review reminders", + ), + required=False, + default=False, + ) + review_status = schema.Choice( title=_("label_review_status", default="Status"), values=("Up-to-date", "Due", "Changes requested"), @@ -30,11 +42,21 @@ class IContentReview(model.Schema): ) review_interval = schema.Choice( - title=_("label_review_interval", default="Interval"), + title=_("label_review_interval", default="Review Interval"), vocabulary="kitconcept.intranet.vocabularies.content_review_intervals", required=False, ) + review_assignee = schema.Choice( + title=_("label_review_assignee", default="Reviewer"), + description=_( + "help_review_assignee", + default="... will be notified as soon as the review is due. Unless otherwise specified, the content owner is considered the responsible person.", + ), + vocabulary="plone.app.vocabularies.Users", + required=False, + ) + review_due_date = schema.Date( title=_("label_review_due_date", default="Due date"), required=False, @@ -46,8 +68,11 @@ class IContentReview(model.Schema): readonly=True, ) - review_assignee = schema.Choice( - title=_("label_review_assignee", default="Assignee"), - vocabulary="plone.app.vocabularies.Users", - required=False, - ) + @invariant + def validate_due_date_field(data): + is_reviewable = getattr(data, "review_enabled", False) + has_due_date = getattr(data, "review_due_date", None) + if not is_reviewable and not has_due_date: + raise Invalid("You have to set a due date if the content is not timeless") + elif is_reviewable and has_due_date: + raise Invalid("Cannot set due date if content is timeless") From cb4ab7f9dbb9e3c127267332ae8dc79ab0966eae Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:38:58 +0100 Subject: [PATCH 22/95] Fix querystring thingie --- .../intranet/profiles/default/registry/querystring.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml b/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml index df64fd0e..4fdb6a51 100644 --- a/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml +++ b/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml @@ -81,9 +81,11 @@ kitconcept.intranet.queryparser._currentUser - + - + From 4d846073cf41bc09806c17c7828300acbc5abd52 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 24 Feb 2026 16:11:40 +0100 Subject: [PATCH 23/95] Fix querystring again --- .../intranet/profiles/default/registry/querystring.xml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml b/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml index 4fdb6a51..c972be4b 100644 --- a/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml +++ b/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml @@ -88,8 +88,7 @@ > - Review status + >Review status Filter by review status From 636ff24e07bad30c0b15ee574fa60714978e2fd5 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:16:58 +0100 Subject: [PATCH 24/95] Add default review intervals --- .../kitconcept/intranet/vocabularies/content_review.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/vocabularies/content_review.py b/backend/src/kitconcept/intranet/vocabularies/content_review.py index 30527883..255bb985 100644 --- a/backend/src/kitconcept/intranet/vocabularies/content_review.py +++ b/backend/src/kitconcept/intranet/vocabularies/content_review.py @@ -1,10 +1,16 @@ +from kitconcept.intranet import _ from zope.interface import provider from zope.schema.interfaces import IVocabularyFactory from zope.schema.vocabulary import SimpleTerm from zope.schema.vocabulary import SimpleVocabulary -INTERVALS = [] +INTERVALS = [ + ("2w", _("Every 2 weeks")), + ("1m", _("Every month")), + ("6m", _("Every 6 months")), + ("1y", _("Every year")), +] @provider(IVocabularyFactory) From e615c825b0e9e554fbedfeff158e0c5a824fa779 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:17:13 +0100 Subject: [PATCH 25/95] Set default review intervals --- .../intranet/behaviors/content_review.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index 13a0eb7d..1590733a 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -1,10 +1,23 @@ from kitconcept.intranet import _ +from plone import api from plone.autoform.interfaces import IFormFieldProvider from plone.supermodel import model from zope import schema from zope.interface import Invalid from zope.interface import invariant from zope.interface import provider +from zope.schema.interfaces import IContextAwareDefaultFactory + + +@provider(IContextAwareDefaultFactory) +def default_review_interval(context) -> str: + record = api.portal.get_registry_record( + "kitconcept.intranet.content_review_default_interval" + ) + if record is None: + # TODO: maybe send an email that a default has to be set? + pass + return record @provider(IFormFieldProvider) @@ -45,6 +58,7 @@ class IContentReview(model.Schema): title=_("label_review_interval", default="Review Interval"), vocabulary="kitconcept.intranet.vocabularies.content_review_intervals", required=False, + defaultFactory=default_review_interval, ) review_assignee = schema.Choice( From 62fd42e4b894f2b9c5ef33cf3d4e211a0719b8ca Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:20:35 +0100 Subject: [PATCH 26/95] Implement "approve", "delegate" and "postpone" action in review endpoint, add review_comment field --- .../intranet/behaviors/content_review.py | 5 +++ .../kitconcept/intranet/services/review.py | 41 ++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index 1590733a..52b89a8a 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -82,6 +82,11 @@ class IContentReview(model.Schema): readonly=True, ) + review_comment = schema.Text( + title=_("label_review_comment", default="Comment"), + required=False, + ) + @invariant def validate_due_date_field(data): is_reviewable = getattr(data, "review_enabled", False) diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index e6da7799..3ec33c28 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -1,8 +1,13 @@ +from datetime import date +from dateutil.relativedelta import relativedelta +from kitconcept.intranet.behaviors.content_review import IContentReview from plone.restapi.deserializer import json_body from plone.restapi.services import Service from zExceptions import BadRequest +from zope.component import getUtility from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse +from zope.schema.interfaces import IVocabularyFactory @implementer(IPublishTraverse) @@ -18,6 +23,17 @@ def publishTraverse(self, request, name): self.params.append(name) return self + def _calc_due_date(self, interval: str) -> date: + mapping = { + "d": "days", + "w": "weeks", + "m": "months", + "y": "years", + } + unit = mapping.get(interval[-1]) + amount = int(interval[:-1]) + return date.today() + relativedelta(**{unit: amount}) + def reply(self): if not self.params: raise BadRequest( @@ -36,12 +52,33 @@ def reply(self): case "approve": # update review_status self.context.review_status = "Up-to-date" - # TODO: update review_completed_date & review_due_date + # update review_due_date + interval = self.context.review_interval + if not interval: + # TODO: send an email that the default still has to be set? + pass + self.context.review_due_date = self._calc_due_date(interval) + # update review_completed_date + self.context.review_completed_date = date.today() case "delegate": + field = IContentReview["review_assignee"] + vocabularyName = field.vocabularyName + factory = getUtility(IVocabularyFactory, name=vocabularyName) + vocabulary = factory(self.context) data = json_body(self.request) + if comment := data.get("comment"): + self.context.review_comment = comment + assignee = data.get("assignee", None) + if assignee not in vocabulary: + raise BadRequest(f"Assignee not found in vocabulary: {vocabulary}") case "postpone": - # TODO: handle postpone + self.context.review_status = "Up-to-date" data = json_body(self.request) + if comment := data.get("comment"): + self.context.review_comment = comment + due_date = data.get("due_date", None) + if due_date: + self.context.review_due_date = date.fromisoformat(due_date) case _: raise BadRequest( "Unknown action: expected /@review/approve, " From 18f51ae0e827519a66de5e53f52b8d5c46b3bc3f Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:23:00 +0100 Subject: [PATCH 27/95] First batch of tests --- .../tests/services/review/test_review_post.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 backend/tests/services/review/test_review_post.py diff --git a/backend/tests/services/review/test_review_post.py b/backend/tests/services/review/test_review_post.py new file mode 100644 index 00000000..e62c96b3 --- /dev/null +++ b/backend/tests/services/review/test_review_post.py @@ -0,0 +1,29 @@ +import pytest + + +@pytest.fixture(scope="class") +def portal(portal_class): + yield portal_class + + +class TestReviewPost: + @pytest.fixture(autouse=True) + def _setup(self, api_manager_request, api_anon_request): + self.api_session = api_manager_request + self.anon_api_session = api_anon_request + + def test_response_not_reviewable(self): + resp = self.api_session.post("/@review") + assert resp.status_code == 404 + + def test_response_no_action(self): + resp = self.api_session.post("/aktuelles/@review") + assert resp.status_code == 400 + + def test_response_anonymous(self): + resp = self.anon_api_session.post("/aktuelles/@review") + assert resp.status_code == 401 + + def test_response_unknown_action(self): + resp = self.api_session.post("/aktuelles/@review/foobar") + assert resp.status_code == 400 From 7cfd16901d154bf2486364ddc8f4e8ea8fd3e849 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:32:34 +0100 Subject: [PATCH 28/95] Set review_comment to readonly --- backend/src/kitconcept/intranet/behaviors/content_review.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index 52b89a8a..41f230b3 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -85,6 +85,7 @@ class IContentReview(model.Schema): review_comment = schema.Text( title=_("label_review_comment", default="Comment"), required=False, + readonly=True, ) @invariant From 3a5ec4aa6fe9070cd9d221fa6656b71432eb16f3 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:34:11 +0100 Subject: [PATCH 29/95] Require review_interval, set default & default to setting --- .../src/kitconcept/intranet/controlpanels/intranet.py | 3 ++- backend/src/kitconcept/intranet/services/review.py | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/src/kitconcept/intranet/controlpanels/intranet.py b/backend/src/kitconcept/intranet/controlpanels/intranet.py index 72f2b7cb..456c1887 100644 --- a/backend/src/kitconcept/intranet/controlpanels/intranet.py +++ b/backend/src/kitconcept/intranet/controlpanels/intranet.py @@ -90,7 +90,8 @@ class IIntranetSettings(Interface): content_review_default_interval = schema.Choice( title=_("Default review interval"), vocabulary="kitconcept.intranet.vocabularies.content_review_intervals", - required=False, + required=True, + default="6m", ) diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index 3ec33c28..b234c2c7 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -1,6 +1,7 @@ from datetime import date from dateutil.relativedelta import relativedelta from kitconcept.intranet.behaviors.content_review import IContentReview +from plone import api from plone.restapi.deserializer import json_body from plone.restapi.services import Service from zExceptions import BadRequest @@ -53,10 +54,10 @@ def reply(self): # update review_status self.context.review_status = "Up-to-date" # update review_due_date - interval = self.context.review_interval - if not interval: - # TODO: send an email that the default still has to be set? - pass + default_interval = api.portal.get_registry_record( + "kitconcept.intranet.content_review_default_interval" + ) + interval = self.context.review_interval or default_interval self.context.review_due_date = self._calc_due_date(interval) # update review_completed_date self.context.review_completed_date = date.today() From 120b14d0b46b87565c14d04e8c0c728d80021189 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 26 Feb 2026 10:44:54 +0100 Subject: [PATCH 30/95] Bind to behavior & get vocab from field --- backend/src/kitconcept/intranet/services/review.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index b234c2c7..c837df4e 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -5,10 +5,8 @@ from plone.restapi.deserializer import json_body from plone.restapi.services import Service from zExceptions import BadRequest -from zope.component import getUtility from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse -from zope.schema.interfaces import IVocabularyFactory @implementer(IPublishTraverse) @@ -62,10 +60,8 @@ def reply(self): # update review_completed_date self.context.review_completed_date = date.today() case "delegate": - field = IContentReview["review_assignee"] - vocabularyName = field.vocabularyName - factory = getUtility(IVocabularyFactory, name=vocabularyName) - vocabulary = factory(self.context) + field = IContentReview["review_assignee"].bind(self.context) + vocabulary = field.vocabulary data = json_body(self.request) if comment := data.get("comment"): self.context.review_comment = comment From c2cdb6f5c6bbf4b113c788668c6af83d50ba9969 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:45:53 +0100 Subject: [PATCH 31/95] Fix fti test --- backend/tests/content_types/test_fti.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/backend/tests/content_types/test_fti.py b/backend/tests/content_types/test_fti.py index 228c6d03..f483c2f1 100644 --- a/backend/tests/content_types/test_fti.py +++ b/backend/tests/content_types/test_fti.py @@ -39,6 +39,7 @@ def _setup(self, portal, get_fti): "kitconcept.intranet.organisational_unit", "kitconcept.intranet.clm", "kitconcept.intranet.votes", + "kitconcept.intranet.content_review", ), ), ("Event", "title", "Event"), @@ -72,6 +73,7 @@ def _setup(self, portal, get_fti): "kitconcept.intranet.location", "kitconcept.intranet.organisational_unit", "kitconcept.intranet.votes", + "kitconcept.intranet.content_review", ), ), ("File", "title", "File"), @@ -95,6 +97,7 @@ def _setup(self, portal, get_fti): "kitconcept.intranet.location", "kitconcept.intranet.organisational_unit", "kitconcept.intranet.votes", + "kitconcept.intranet.content_review", ), ), ("Image", "title", "Image"), @@ -117,6 +120,7 @@ def _setup(self, portal, get_fti): "kitconcept.intranet.location", "kitconcept.intranet.organisational_unit", "kitconcept.intranet.votes", + "kitconcept.intranet.content_review", ), ), ("Link", "title", "Link"), @@ -141,6 +145,7 @@ def _setup(self, portal, get_fti): "plone.allowdiscussion", "kitconcept.intranet.location", "kitconcept.intranet.organisational_unit", + "kitconcept.intranet.content_review", ), ), ("Location", "title", "Location"), @@ -165,6 +170,7 @@ def _setup(self, portal, get_fti): "plone.versioning", "plone.locking", "plone.translatable", + "kitconcept.intranet.content_review", ), ), ("News Item", "title", "News Item"), @@ -193,6 +199,7 @@ def _setup(self, portal, get_fti): "kitconcept.intranet.location", "kitconcept.intranet.organisational_unit", "kitconcept.intranet.votes", + "kitconcept.intranet.content_review", ), ), ("Organisational Unit", "title", "Organisational Unit"), @@ -217,6 +224,7 @@ def _setup(self, portal, get_fti): "plone.versioning", "plone.locking", "plone.translatable", + "kitconcept.intranet.content_review", ), ), ("Person", "title", "Person"), @@ -243,6 +251,7 @@ def _setup(self, portal, get_fti): "plone.translatable", "kitconcept.intranet.location", "kitconcept.intranet.organisational_unit", + "kitconcept.intranet.content_review", ), ), ("Plone Site", "title", "Plone Site"), From 44a4f6ab382231a54a016a5f889e8d295bc7ea24 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:46:09 +0100 Subject: [PATCH 32/95] Add test for content_review_intervals vocabulary --- .../test_vocab_content_review_intervals.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/tests/vocabularies/test_vocab_content_review_intervals.py diff --git a/backend/tests/vocabularies/test_vocab_content_review_intervals.py b/backend/tests/vocabularies/test_vocab_content_review_intervals.py new file mode 100644 index 00000000..4b417419 --- /dev/null +++ b/backend/tests/vocabularies/test_vocab_content_review_intervals.py @@ -0,0 +1,31 @@ +from plone.app.vocabularies import SimpleVocabulary + + +import pytest + + +class TestVocab: + name: str = "kitconcept.intranet.vocabularies.content_review_intervals" + vocab_type = SimpleVocabulary + + @pytest.fixture(autouse=True) + def _setup(self, portal, get_vocabulary): + self.portal = portal + self.vocab = get_vocabulary(self.name, portal) + + def test_vocabulary_type(self): + assert isinstance(self.vocab, self.vocab_type) + + @pytest.mark.parametrize( + "token,title", + [ + ("2w", "Every 2 weeks"), + ("1m", "Every month"), + ("6m", "Every 6 months"), + ("1y", "Every year"), + ], + ) + def test_vocab_terms(self, token: str, title: str): + term = self.vocab.getTermByToken(token) + assert term.title == title + assert term.token == token From d7e99c5bb9a1fb5a8f0e49952b250b2eb37f97ac Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:59:03 +0100 Subject: [PATCH 33/95] How-To guide (wip) --- docs/docs/_static/default-review-interval.png | Bin 0 -> 112243 bytes docs/docs/how-to-guides/content-review.md | 30 ++++++++++++++++++ docs/docs/how-to-guides/index.md | 1 + 3 files changed, 31 insertions(+) create mode 100644 docs/docs/_static/default-review-interval.png create mode 100644 docs/docs/how-to-guides/content-review.md diff --git a/docs/docs/_static/default-review-interval.png b/docs/docs/_static/default-review-interval.png new file mode 100644 index 0000000000000000000000000000000000000000..a6d6eb4522d4e4d1ddc9d4991895b068262f8894 GIT binary patch literal 112243 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 Date: Thu, 5 Mar 2026 15:29:18 +0100 Subject: [PATCH 34/95] Fix upgrade step after merge --- .../intranet/upgrades/configure.zcml | 1 + .../upgrades/v20260305001/__init__.py | 0 .../upgrades/v20260305001/configure.zcml | 23 +++++++++++++++++++ .../{v20260217001 => v20260305001}/upgrade.py | 0 4 files changed, 24 insertions(+) create mode 100644 backend/src/kitconcept/intranet/upgrades/v20260305001/__init__.py create mode 100644 backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml rename backend/src/kitconcept/intranet/upgrades/{v20260217001 => v20260305001}/upgrade.py (100%) diff --git a/backend/src/kitconcept/intranet/upgrades/configure.zcml b/backend/src/kitconcept/intranet/upgrades/configure.zcml index 90d34735..b80a7c18 100644 --- a/backend/src/kitconcept/intranet/upgrades/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/configure.zcml @@ -88,5 +88,6 @@ + diff --git a/backend/src/kitconcept/intranet/upgrades/v20260305001/__init__.py b/backend/src/kitconcept/intranet/upgrades/v20260305001/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml new file mode 100644 index 00000000..d86cded4 --- /dev/null +++ b/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py b/backend/src/kitconcept/intranet/upgrades/v20260305001/upgrade.py similarity index 100% rename from backend/src/kitconcept/intranet/upgrades/v20260217001/upgrade.py rename to backend/src/kitconcept/intranet/upgrades/v20260305001/upgrade.py From 92747f952dcf0d59e872daf5168d42ad474d719a Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 5 Mar 2026 16:16:17 +0100 Subject: [PATCH 35/95] Fix profile version --- backend/src/kitconcept/intranet/profiles/default/metadata.xml | 2 +- .../kitconcept/intranet/upgrades/v20260305001/configure.zcml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/kitconcept/intranet/profiles/default/metadata.xml b/backend/src/kitconcept/intranet/profiles/default/metadata.xml index c1e55107..02dc1946 100644 --- a/backend/src/kitconcept/intranet/profiles/default/metadata.xml +++ b/backend/src/kitconcept/intranet/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 20260217001 + 20260305001 plone.app.discussion:default diff --git a/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml index d86cded4..3ada5028 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml @@ -4,8 +4,8 @@ > Date: Fri, 6 Mar 2026 13:44:30 +0100 Subject: [PATCH 36/95] Combine upgrade steps & move calc_due_date to utils --- .../kitconcept/intranet/services/review.py | 15 +---- .../upgrades/v20260305001/configure.zcml | 8 +-- .../intranet/upgrades/v20260305001/upgrade.py | 62 ++++++++++--------- .../intranet/utils/calc_due_date.py | 21 +++++++ 4 files changed, 59 insertions(+), 47 deletions(-) create mode 100644 backend/src/kitconcept/intranet/utils/calc_due_date.py diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index c837df4e..d5d662de 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -1,6 +1,6 @@ from datetime import date -from dateutil.relativedelta import relativedelta from kitconcept.intranet.behaviors.content_review import IContentReview +from kitconcept.intranet.utils.calc_due_date import calc_due_date from plone import api from plone.restapi.deserializer import json_body from plone.restapi.services import Service @@ -22,17 +22,6 @@ def publishTraverse(self, request, name): self.params.append(name) return self - def _calc_due_date(self, interval: str) -> date: - mapping = { - "d": "days", - "w": "weeks", - "m": "months", - "y": "years", - } - unit = mapping.get(interval[-1]) - amount = int(interval[:-1]) - return date.today() + relativedelta(**{unit: amount}) - def reply(self): if not self.params: raise BadRequest( @@ -56,7 +45,7 @@ def reply(self): "kitconcept.intranet.content_review_default_interval" ) interval = self.context.review_interval or default_interval - self.context.review_due_date = self._calc_due_date(interval) + self.context.review_due_date = calc_due_date(interval=interval) # update review_completed_date self.context.review_completed_date = date.today() case "delegate": diff --git a/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml index 3ada5028..e5d537e7 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml @@ -12,12 +12,8 @@ import_steps="typeinfo plone.app.registry catalog" /> - diff --git a/backend/src/kitconcept/intranet/upgrades/v20260305001/upgrade.py b/backend/src/kitconcept/intranet/upgrades/v20260305001/upgrade.py index 4a92dbb7..7651d89d 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260305001/upgrade.py +++ b/backend/src/kitconcept/intranet/upgrades/v20260305001/upgrade.py @@ -1,5 +1,7 @@ -from kitconcept.intranet.behaviors.content_review import IContentReview +from datetime import date +from kitconcept.intranet.utils.calc_due_date import calc_due_date from plone import api +from Products.GenericSetup.tool import SetupTool import logging import transaction @@ -8,37 +10,41 @@ logger = logging.getLogger("kitconcept.intranet") -def add_review_status_indexer(context): - catalog = api.portal.get_tool("portal_catalog") - indexes = catalog.indexes() - if "review_status" not in indexes: - catalog.addIndex("review_status", "FieldIndex") - logger.info("Added review_status index.") - brains = catalog(object_provides=IContentReview) - total = len(brains) - for index, brain in enumerate(brains): - obj = brain.getObject() - obj.reindexObject(idxs=["review_status"], update_metadata=0) - logger.info(f"Reindexing object {brain.getPath()}.") - if index % 250 == 0: - logger.info(f"Reindexed {index}/{total} objects.") - transaction.commit() - transaction.commit() +def update_existing_content(tool: SetupTool): + types_we_want = [] + types_tool = api.portal.get_tool("portal_types") + all_types = types_tool.objectIds() + for type_id in all_types: + fti = types_tool[type_id] + try: + behaviors = fti.behaviors + if "kitconcept.intranet.content_review" in behaviors: + types_we_want.append(type_id) + except AttributeError: + continue - -def add_review_due_date_indexer(context): catalog = api.portal.get_tool("portal_catalog") indexes = catalog.indexes() - if "review_due_date" not in indexes: - catalog.addIndex("review_due_date", "FieldIndex") - logger.info("Added review_due_date index.") - brains = catalog(object_provides=IContentReview) + for idx in ("review_status", "review_due_date"): + if idx not in indexes: + catalog.addIndex(idx, "FieldIndex") + logger.info(f"Added {idx} index") + + brains = api.content.find(portal_type=types_we_want) total = len(brains) - for index, brain in enumerate(brains): + logger.info(f"Found {total} objects to update") + for idx, brain in enumerate(brains): obj = brain.getObject() - obj.reindexObject(idxs=["review_status"], update_metadata=0) - logger.info(f"Reindexing object {brain.getPath()}.") - if index % 250 == 0: - logger.info(f"Reindexed {index}/{total} objects.") + modified = obj.modified().asdatetime().date() + obj.review_due_date = calc_due_date(base_date=modified) + review_due_date = obj.review_due_date + if review_due_date > date.today(): + obj.review_status = "Up-to-date" + else: + obj.review_status = "Due" + obj.reindexObject(idxs=["review_status", "review_due_date"], update_metadata=0) + if idx % 250 == 0: + logger.info(f"Updated {idx}/{total} objects") transaction.commit() + logger.info(f"Updated {total}/{total} objects") transaction.commit() diff --git a/backend/src/kitconcept/intranet/utils/calc_due_date.py b/backend/src/kitconcept/intranet/utils/calc_due_date.py new file mode 100644 index 00000000..5c784d9c --- /dev/null +++ b/backend/src/kitconcept/intranet/utils/calc_due_date.py @@ -0,0 +1,21 @@ +from datetime import date +from dateutil.relativedelta import relativedelta +from plone import api + + +def calc_due_date(base_date: date | None = None, interval: str | None = None) -> date: + if not interval: + interval = api.portal.get_registry_record( + "kitconcept.intranet.content_review_default_interval" + ) + if not base_date: + base_date = date.today() + mapping = { + "d": "days", + "w": "weeks", + "m": "months", + "y": "years", + } + unit = mapping.get(interval[-1]) + amount = int(interval[:-1]) + return base_date + relativedelta(**{unit: amount}) From 6dbdd79e971a76f2e1af509ad0842f90bf4ddc4a Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:36:24 +0100 Subject: [PATCH 37/95] Formatting --- .../tests/vocabularies/test_vocab_content_review_intervals.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/tests/vocabularies/test_vocab_content_review_intervals.py b/backend/tests/vocabularies/test_vocab_content_review_intervals.py index 4b417419..b3cdbed4 100644 --- a/backend/tests/vocabularies/test_vocab_content_review_intervals.py +++ b/backend/tests/vocabularies/test_vocab_content_review_intervals.py @@ -1,6 +1,5 @@ from plone.app.vocabularies import SimpleVocabulary - import pytest From c5ea73c7377920e85964844ad4e554cd8dd85b3e Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:36:47 +0100 Subject: [PATCH 38/95] Add script to send a mail if a content objects is due to review --- backend/scripts/review_reminder.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 backend/scripts/review_reminder.py diff --git a/backend/scripts/review_reminder.py b/backend/scripts/review_reminder.py new file mode 100644 index 00000000..ca62ead4 --- /dev/null +++ b/backend/scripts/review_reminder.py @@ -0,0 +1,19 @@ +from datetime import date +from plone import api +from zope.component.hooks import setSite + + +portal = app.Plone +setSite(portal) + +with api.env.adopt_roles(["Manager"]): + brains = portal.portal_catalog.unrestrictedSearchResults( + review_due_date=date.today() + ) + for brain in brains: + obj = brain.getObject() + reviewer = api.user.get( + obj.review_assignee if obj.review_assignee else obj.Creator() + ) + breakpoint() + # TODO: send mail to assignee or content owner From 5aa11df25960a8f3a08f14c9683a66b71ac02067 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Mar 2026 10:47:58 +0100 Subject: [PATCH 39/95] Fix version after merge --- backend/.python-version | 1 + backend/news/+content_review.feature | 0 .../intranet/profiles/default/metadata.xml | 2 +- .../src/kitconcept/intranet/upgrades/configure.zcml | 1 + .../vocabularies/test_vocab_content_review_users.py | 13 +++++++++++++ 5 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 backend/.python-version create mode 100644 backend/news/+content_review.feature create mode 100644 backend/tests/vocabularies/test_vocab_content_review_users.py diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/news/+content_review.feature b/backend/news/+content_review.feature new file mode 100644 index 00000000..e69de29b diff --git a/backend/src/kitconcept/intranet/profiles/default/metadata.xml b/backend/src/kitconcept/intranet/profiles/default/metadata.xml index d5456953..58b9684f 100644 --- a/backend/src/kitconcept/intranet/profiles/default/metadata.xml +++ b/backend/src/kitconcept/intranet/profiles/default/metadata.xml @@ -1,6 +1,6 @@ - 20260304001 + 20260305001 plone.app.discussion:default profile-kitconcept.contactblock:default diff --git a/backend/src/kitconcept/intranet/upgrades/configure.zcml b/backend/src/kitconcept/intranet/upgrades/configure.zcml index 00d69bad..cb5d7325 100644 --- a/backend/src/kitconcept/intranet/upgrades/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/configure.zcml @@ -89,6 +89,7 @@ + diff --git a/backend/tests/vocabularies/test_vocab_content_review_users.py b/backend/tests/vocabularies/test_vocab_content_review_users.py new file mode 100644 index 00000000..cc8e099f --- /dev/null +++ b/backend/tests/vocabularies/test_vocab_content_review_users.py @@ -0,0 +1,13 @@ +from plone.app.vocabularies import SimpleVocabulary + +import pytest + + +class TestVocab: + name: str = "kitconcept.intranet.vocabularies.content_review_intervals" + vocab_type = SimpleVocabulary + + @pytest.fixture(autouse=True) + def _setup(self, portal, get_vocabulary): + self.portal = portal + self.vocab = get_vocabulary(self.name, portal) From 95a98615c41f22b2de5ea34095baba799afdd574 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:58:51 +0100 Subject: [PATCH 40/95] Fix upgrade step --- .../kitconcept/intranet/upgrades/v20260305001/configure.zcml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml index e5d537e7..ab03188c 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/v20260305001/configure.zcml @@ -4,7 +4,7 @@ > Date: Tue, 17 Mar 2026 14:59:17 +0100 Subject: [PATCH 41/95] Send mail in review_reminder script --- backend/scripts/review_reminder.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/scripts/review_reminder.py b/backend/scripts/review_reminder.py index ca62ead4..84068c52 100644 --- a/backend/scripts/review_reminder.py +++ b/backend/scripts/review_reminder.py @@ -15,5 +15,13 @@ reviewer = api.user.get( obj.review_assignee if obj.review_assignee else obj.Creator() ) + breakpoint() - # TODO: send mail to assignee or content owner + mail_subject = "Lorem Ipsum" + mail_body = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." + + api.portal.send_email( + recipient=reviewer.getProperty("email"), + subject=mail_subject, + body=mail_body, + ) From 30792fcd5a8a0d8362f9ce35da559cfde4b4e168 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:00:04 +0100 Subject: [PATCH 42/95] Set default review_due_date to today --- backend/src/kitconcept/intranet/behaviors/content_review.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index 41f230b3..3f9b325d 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -1,3 +1,4 @@ +from datetime import date from kitconcept.intranet import _ from plone import api from plone.autoform.interfaces import IFormFieldProvider @@ -73,6 +74,7 @@ class IContentReview(model.Schema): review_due_date = schema.Date( title=_("label_review_due_date", default="Due date"), + default=date.today(), required=False, ) From 96837ecc0a2ac709af51bd962a33187b7888e95f Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:50:21 +0100 Subject: [PATCH 43/95] Add changelog entries --- backend/news/+content_review.feature | 0 backend/news/330.feature | 1 + news/330.documentation | 1 + 3 files changed, 2 insertions(+) delete mode 100644 backend/news/+content_review.feature create mode 100644 backend/news/330.feature create mode 100644 news/330.documentation diff --git a/backend/news/+content_review.feature b/backend/news/+content_review.feature deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/news/330.feature b/backend/news/330.feature new file mode 100644 index 00000000..770d7750 --- /dev/null +++ b/backend/news/330.feature @@ -0,0 +1 @@ +Implemented content review & reminders features. @jntpk diff --git a/news/330.documentation b/news/330.documentation new file mode 100644 index 00000000..924e1d72 --- /dev/null +++ b/news/330.documentation @@ -0,0 +1 @@ +Added documentation for content review & reminders. @jnptk From e40467dc143658e868c66c2f01b9d5518f282076 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:51:01 +0100 Subject: [PATCH 44/95] Fix [at]review endpoint tests --- .../tests/services/review/test_review_post.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/backend/tests/services/review/test_review_post.py b/backend/tests/services/review/test_review_post.py index e62c96b3..0d46f064 100644 --- a/backend/tests/services/review/test_review_post.py +++ b/backend/tests/services/review/test_review_post.py @@ -1,4 +1,7 @@ +from plone import api + import pytest +import transaction @pytest.fixture(scope="class") @@ -6,9 +9,17 @@ def portal(portal_class): yield portal_class +@pytest.fixture(scope="class") +def contents(portal): + with api.env.adopt_roles(["Manager"]): + doc = api.content.create(portal, type="Document", id="foobar") + api.content.transition(obj=doc, transition="publish") + transaction.commit() + + class TestReviewPost: @pytest.fixture(autouse=True) - def _setup(self, api_manager_request, api_anon_request): + def _setup(self, contents, api_manager_request, api_anon_request): self.api_session = api_manager_request self.anon_api_session = api_anon_request @@ -17,13 +28,13 @@ def test_response_not_reviewable(self): assert resp.status_code == 404 def test_response_no_action(self): - resp = self.api_session.post("/aktuelles/@review") + resp = self.api_session.post("/foobar/@review") assert resp.status_code == 400 def test_response_anonymous(self): - resp = self.anon_api_session.post("/aktuelles/@review") + resp = self.anon_api_session.post("/foobar/@review") assert resp.status_code == 401 def test_response_unknown_action(self): - resp = self.api_session.post("/aktuelles/@review/foobar") + resp = self.api_session.post("/foobar/@review/foobar") assert resp.status_code == 400 From eedb6f22c3841b0be6a5763d943303b2ca714a77 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:07:10 +0100 Subject: [PATCH 45/95] Properly calculate review_due_date --- .../src/kitconcept/intranet/behaviors/content_review.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index 3f9b325d..a3d75aca 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -1,5 +1,6 @@ from datetime import date from kitconcept.intranet import _ +from kitconcept.intranet.utils.calc_due_date import calc_due_date from plone import api from plone.autoform.interfaces import IFormFieldProvider from plone.supermodel import model @@ -21,6 +22,11 @@ def default_review_interval(context) -> str: return record +@provider(IContextAwareDefaultFactory) +def default_review_due_date(context) -> date: + return calc_due_date() + + @provider(IFormFieldProvider) class IContentReview(model.Schema): """Content Review behavior""" @@ -74,8 +80,8 @@ class IContentReview(model.Schema): review_due_date = schema.Date( title=_("label_review_due_date", default="Due date"), - default=date.today(), required=False, + defaultFactory=default_review_due_date, ) review_completed_date = schema.Date( From 884c7e364d22883ab6975e07f7fa7180388c6d0c Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:08:08 +0100 Subject: [PATCH 46/95] Fix [at]review endpoint to set values persistently --- backend/src/kitconcept/intranet/services/review.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index d5d662de..0125cf1f 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -8,6 +8,8 @@ from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse +import transaction + @implementer(IPublishTraverse) class ReviewPost(Service): @@ -48,6 +50,7 @@ def reply(self): self.context.review_due_date = calc_due_date(interval=interval) # update review_completed_date self.context.review_completed_date = date.today() + transaction.commit() case "delegate": field = IContentReview["review_assignee"].bind(self.context) vocabulary = field.vocabulary @@ -57,6 +60,8 @@ def reply(self): assignee = data.get("assignee", None) if assignee not in vocabulary: raise BadRequest(f"Assignee not found in vocabulary: {vocabulary}") + self.context.review_assignee = assignee + transaction.commit() case "postpone": self.context.review_status = "Up-to-date" data = json_body(self.request) @@ -65,6 +70,7 @@ def reply(self): due_date = data.get("due_date", None) if due_date: self.context.review_due_date = date.fromisoformat(due_date) + transaction.commit() case _: raise BadRequest( "Unknown action: expected /@review/approve, " From e0c31db55e34216f08f9d395b1f9fc9b71bc2808 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:08:19 +0100 Subject: [PATCH 47/95] More test for [at]review endpoint --- .../tests/services/review/test_review_post.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/backend/tests/services/review/test_review_post.py b/backend/tests/services/review/test_review_post.py index 0d46f064..f1af8bfa 100644 --- a/backend/tests/services/review/test_review_post.py +++ b/backend/tests/services/review/test_review_post.py @@ -1,3 +1,5 @@ +from datetime import date +from kitconcept.intranet.utils.calc_due_date import calc_due_date from plone import api import pytest @@ -14,6 +16,7 @@ def contents(portal): with api.env.adopt_roles(["Manager"]): doc = api.content.create(portal, type="Document", id="foobar") api.content.transition(obj=doc, transition="publish") + api.user.create(email="jdoe@example.org", username="jdoe") transaction.commit() @@ -22,6 +25,9 @@ class TestReviewPost: def _setup(self, contents, api_manager_request, api_anon_request): self.api_session = api_manager_request self.anon_api_session = api_anon_request + self.default_review_interval = api.portal.get_registry_record( + "kitconcept.intranet.content_review_default_interval" + ) def test_response_not_reviewable(self): resp = self.api_session.post("/@review") @@ -38,3 +44,51 @@ def test_response_anonymous(self): def test_response_unknown_action(self): resp = self.api_session.post("/foobar/@review/foobar") assert resp.status_code == 400 + + def test_action_approve(self): + doc = api.content.get("/foobar") + doc.review_status = "Due" + doc.review_due_date = date(2002, 12, 30) + transaction.commit() + + resp = self.api_session.post("/foobar/@review/approve") + assert resp.status_code == 200 + + interval = doc.review_interval or self.default_review_interval + resp = self.api_session.get("/foobar").json() + assert resp["review_status"]["token"] == "Up-to-date" # noqa: S105 + assert resp["review_due_date"] == calc_due_date(interval=interval).isoformat() + assert resp["review_completed_date"] == date.today().isoformat() + + def test_action_delegate(self): + comment = "Lorem Ipsum dolor sit amet" + resp = self.api_session.post( + "/foobar/@review/delegate", + json={"assignee": "jdoe", "comment": comment}, + ) + assert resp.status_code == 200 + + resp = self.api_session.get("/foobar").json() + assert resp["review_assignee"]["token"] == "jdoe" # noqa: S105 + assert resp["review_comment"] == comment + + def test_action_delegate_unknown_assignee(self): + resp = self.api_session.post( + "/foobar/@review/delegate", + json={"assignee": "f.bar"}, + ) + assert resp.status_code == 400 + + def test_action_postpone(self): + doc = api.content.get("/foobar") + due_date = calc_due_date(base_date=doc.review_due_date) + comment = "Lorem Ipsum dolor sit amet" + resp = self.api_session.post( + "/foobar/@review/postpone", + json={"due_date": due_date.isoformat(), "comment": comment}, + ) + assert resp.status_code == 200 + + resp = self.api_session.get("/foobar").json() + assert resp["review_due_date"] == due_date.isoformat() + assert resp["review_comment"] == comment From 6287e39d5145dd71ffd8e9b44fb89c0b98d2e0ac Mon Sep 17 00:00:00 2001 From: David Glick Date: Wed, 1 Apr 2026 16:15:32 -0700 Subject: [PATCH 48/95] Remove .python-version (duplicates requires-python in pyproject.toml) --- backend/.python-version | 1 - 1 file changed, 1 deletion(-) delete mode 100644 backend/.python-version diff --git a/backend/.python-version b/backend/.python-version deleted file mode 100644 index e4fba218..00000000 --- a/backend/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 From ab526b97dea997eda876edaa2b6997156ebf176e Mon Sep 17 00:00:00 2001 From: David Glick Date: Wed, 1 Apr 2026 16:24:22 -0700 Subject: [PATCH 49/95] fix bad merge of upgrade steps --- backend/src/kitconcept/intranet/upgrades/configure.zcml | 2 +- .../kitconcept/intranet/upgrades/v20260401001/configure.zcml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/kitconcept/intranet/upgrades/configure.zcml b/backend/src/kitconcept/intranet/upgrades/configure.zcml index b44c307c..6e3ac000 100644 --- a/backend/src/kitconcept/intranet/upgrades/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/configure.zcml @@ -89,7 +89,7 @@ - + diff --git a/backend/src/kitconcept/intranet/upgrades/v20260401001/configure.zcml b/backend/src/kitconcept/intranet/upgrades/v20260401001/configure.zcml index 9911d30a..eaa943bf 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260401001/configure.zcml +++ b/backend/src/kitconcept/intranet/upgrades/v20260401001/configure.zcml @@ -4,7 +4,7 @@ > Date: Thu, 2 Apr 2026 10:20:54 +0200 Subject: [PATCH 50/95] add review querystring criteria --- .../intranet/profiles/default/registry/querystring.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml b/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml index c972be4b..24b27dd4 100644 --- a/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml +++ b/backend/src/kitconcept/intranet/profiles/default/registry/querystring.xml @@ -99,7 +99,7 @@ Taxonomy + >Review From 624e3118322b6325e6b958808063e0f6371a75dc Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:51:50 +0200 Subject: [PATCH 51/95] return record directly in default_review_interval --- backend/src/kitconcept/intranet/behaviors/content_review.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index a3d75aca..35490899 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -13,13 +13,9 @@ @provider(IContextAwareDefaultFactory) def default_review_interval(context) -> str: - record = api.portal.get_registry_record( + return api.portal.get_registry_record( "kitconcept.intranet.content_review_default_interval" ) - if record is None: - # TODO: maybe send an email that a default has to be set? - pass - return record @provider(IContextAwareDefaultFactory) From 7b2443da46533366c2195d640be1e4b9c5fb50c3 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:52:45 +0200 Subject: [PATCH 52/95] off source reminder email for better testability --- backend/scripts/review_reminder.py | 21 +-------- .../intranet/utils/review_due_notifier.py | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+), 19 deletions(-) create mode 100644 backend/src/kitconcept/intranet/utils/review_due_notifier.py diff --git a/backend/scripts/review_reminder.py b/backend/scripts/review_reminder.py index 84068c52..3edd8b58 100644 --- a/backend/scripts/review_reminder.py +++ b/backend/scripts/review_reminder.py @@ -1,27 +1,10 @@ -from datetime import date from plone import api from zope.component.hooks import setSite +from kitconcept.intranet.utils.review_due_notifier import nofity_reviewer portal = app.Plone setSite(portal) with api.env.adopt_roles(["Manager"]): - brains = portal.portal_catalog.unrestrictedSearchResults( - review_due_date=date.today() - ) - for brain in brains: - obj = brain.getObject() - reviewer = api.user.get( - obj.review_assignee if obj.review_assignee else obj.Creator() - ) - - breakpoint() - mail_subject = "Lorem Ipsum" - mail_body = "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." - - api.portal.send_email( - recipient=reviewer.getProperty("email"), - subject=mail_subject, - body=mail_body, - ) + nofity_reviewer(portal) diff --git a/backend/src/kitconcept/intranet/utils/review_due_notifier.py b/backend/src/kitconcept/intranet/utils/review_due_notifier.py new file mode 100644 index 00000000..5afe3692 --- /dev/null +++ b/backend/src/kitconcept/intranet/utils/review_due_notifier.py @@ -0,0 +1,47 @@ +from datetime import date +from plone import api + +import logging + +logger = logging.getLogger("kitconcept.intranet") + + +def nofity_reviewer(portal): + brains = portal.portal_catalog.unrestrictedSearchResults( + review_due_date=date.today() + ) + for brain in brains: + obj = brain.getObject() + reviewer = api.user.get( + obj.review_assignee if obj.review_assignee else obj.Creator() + ) + + if not reviewer: + logger.warning("Couldn't find user. No mail will be sent.") + return + + breakpoint() + mail_subject = f"🔔 Reminder: Content review due for {obj.Title()}" + mail_body = ( + f"Hello {reviewer.getProperty('fullname') or reviewer.getUserName()}," + f"The content item “{obj.Title()}” is due for review." + "Please check whether the information is still accurate and up to date." + f"Last updated: {obj}" + "Next review date (after completion): will be recalculated automatically" + "You can open the content here:" + f"👉 {obj.absolute_url()}" + "Available actions:" + "- ✅ Review the content and mark as reviewed" + "- 🕓 Postpone next review (e.g., by 3 or 6 months)" + "- 📝 Mark as “changes required” if updates are needed" + "Thank you for keeping our content accurate and relevant." + "Kind regards," + "Your intranet team" + ) + + api.portal.send_email( + recipient=reviewer.getProperty("email"), + subject=mail_subject, + body=mail_body, + immediate=True, + ) From 5f84081af47e4e15d86293a0edf9ee95355e7ac9 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:17:08 +0200 Subject: [PATCH 53/95] update default review intervals based of lo-fi design --- .../src/kitconcept/intranet/vocabularies/content_review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/kitconcept/intranet/vocabularies/content_review.py b/backend/src/kitconcept/intranet/vocabularies/content_review.py index 255bb985..4c776587 100644 --- a/backend/src/kitconcept/intranet/vocabularies/content_review.py +++ b/backend/src/kitconcept/intranet/vocabularies/content_review.py @@ -6,10 +6,10 @@ INTERVALS = [ - ("2w", _("Every 2 weeks")), - ("1m", _("Every month")), + ("3m", _("Every 3 months")), ("6m", _("Every 6 months")), ("1y", _("Every year")), + ("2y", _("Every 2 years")), ] From 2df56dbc56b8c2ee32ab2dc487d2270dceaa0b12 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:17:32 +0200 Subject: [PATCH 54/95] shorten [at]review reply --- .../kitconcept/intranet/services/review.py | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index 0125cf1f..64f27f89 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -25,21 +25,8 @@ def publishTraverse(self, request, name): return self def reply(self): - if not self.params: - raise BadRequest( - "Missing action: expected /@review/approve, " - "/@review/delegate, or /@review/postpone" - ) - - if len(self.params) > 1: - raise BadRequest( - "Too many path segments: expected /@review/approve, " - "/@review/delegate, or /@review/postpone" - ) - - param = self.params[0] - match param: - case "approve": + match self.params: + case ["approve"]: # update review_status self.context.review_status = "Up-to-date" # update review_due_date @@ -51,7 +38,7 @@ def reply(self): # update review_completed_date self.context.review_completed_date = date.today() transaction.commit() - case "delegate": + case ["delegate"]: field = IContentReview["review_assignee"].bind(self.context) vocabulary = field.vocabulary data = json_body(self.request) @@ -62,7 +49,7 @@ def reply(self): raise BadRequest(f"Assignee not found in vocabulary: {vocabulary}") self.context.review_assignee = assignee transaction.commit() - case "postpone": + case ["postpone"]: self.context.review_status = "Up-to-date" data = json_body(self.request) if comment := data.get("comment"): From bf140a6240e7e750165997b12be20ac23258b97e Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:20:19 +0200 Subject: [PATCH 55/95] remove content_review_users vocab test --- .../vocabularies/test_vocab_content_review_users.py | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 backend/tests/vocabularies/test_vocab_content_review_users.py diff --git a/backend/tests/vocabularies/test_vocab_content_review_users.py b/backend/tests/vocabularies/test_vocab_content_review_users.py deleted file mode 100644 index cc8e099f..00000000 --- a/backend/tests/vocabularies/test_vocab_content_review_users.py +++ /dev/null @@ -1,13 +0,0 @@ -from plone.app.vocabularies import SimpleVocabulary - -import pytest - - -class TestVocab: - name: str = "kitconcept.intranet.vocabularies.content_review_intervals" - vocab_type = SimpleVocabulary - - @pytest.fixture(autouse=True) - def _setup(self, portal, get_vocabulary): - self.portal = portal - self.vocab = get_vocabulary(self.name, portal) From b306b25846941a420a7aef03f26481c66efabd2f Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:25:22 +0200 Subject: [PATCH 56/95] assert full vocab in test --- .../test_vocab_content_review_intervals.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/backend/tests/vocabularies/test_vocab_content_review_intervals.py b/backend/tests/vocabularies/test_vocab_content_review_intervals.py index b3cdbed4..98b2160a 100644 --- a/backend/tests/vocabularies/test_vocab_content_review_intervals.py +++ b/backend/tests/vocabularies/test_vocab_content_review_intervals.py @@ -15,16 +15,10 @@ def _setup(self, portal, get_vocabulary): def test_vocabulary_type(self): assert isinstance(self.vocab, self.vocab_type) - @pytest.mark.parametrize( - "token,title", - [ - ("2w", "Every 2 weeks"), - ("1m", "Every month"), + def test_vocab_terms(self): + assert [(term.token, term.title) for term in self.vocab._terms] == [ + ("3m", "Every 3 months"), ("6m", "Every 6 months"), ("1y", "Every year"), - ], - ) - def test_vocab_terms(self, token: str, title: str): - term = self.vocab.getTermByToken(token) - assert term.title == title - assert term.token == token + ("2y", "Every 2 years"), + ] From 808c8f4e5fbb775566057aab12877b94baea4ae9 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:25:33 +0200 Subject: [PATCH 57/95] make format --- backend/scripts/review_reminder.py | 2 +- backend/src/kitconcept/intranet/behaviors/location.py | 2 +- .../src/kitconcept/intranet/behaviors/organisational_unit.py | 2 +- backend/src/kitconcept/intranet/serializers/person.py | 4 ++-- .../src/kitconcept/intranet/upgrades/v20260318001/__init__.py | 3 +-- backend/src/kitconcept/intranet/utils/review_due_notifier.py | 1 + backend/tests/subscribers/test_sub_vocabulary.py | 2 +- backend/tests/vocabularies/test_voc_base.py | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/scripts/review_reminder.py b/backend/scripts/review_reminder.py index 3edd8b58..38862cf8 100644 --- a/backend/scripts/review_reminder.py +++ b/backend/scripts/review_reminder.py @@ -1,6 +1,6 @@ +from kitconcept.intranet.utils.review_due_notifier import nofity_reviewer from plone import api from zope.component.hooks import setSite -from kitconcept.intranet.utils.review_due_notifier import nofity_reviewer portal = app.Plone diff --git a/backend/src/kitconcept/intranet/behaviors/location.py b/backend/src/kitconcept/intranet/behaviors/location.py index fdb9e387..f1d0371a 100644 --- a/backend/src/kitconcept/intranet/behaviors/location.py +++ b/backend/src/kitconcept/intranet/behaviors/location.py @@ -3,8 +3,8 @@ from plone.autoform.directives import order_after from plone.autoform.interfaces import IFormFieldProvider from plone.supermodel import model -from zope.interface import provider from zope import schema +from zope.interface import provider @provider(IFormFieldProvider) diff --git a/backend/src/kitconcept/intranet/behaviors/organisational_unit.py b/backend/src/kitconcept/intranet/behaviors/organisational_unit.py index 43a95787..c335e585 100644 --- a/backend/src/kitconcept/intranet/behaviors/organisational_unit.py +++ b/backend/src/kitconcept/intranet/behaviors/organisational_unit.py @@ -3,8 +3,8 @@ from plone.autoform.directives import order_after from plone.autoform.interfaces import IFormFieldProvider from plone.supermodel import model -from zope.interface import provider from zope import schema +from zope.interface import provider @provider(IFormFieldProvider) diff --git a/backend/src/kitconcept/intranet/serializers/person.py b/backend/src/kitconcept/intranet/serializers/person.py index e336d580..eb697fa7 100644 --- a/backend/src/kitconcept/intranet/serializers/person.py +++ b/backend/src/kitconcept/intranet/serializers/person.py @@ -1,10 +1,10 @@ from collective.person.content.person import IPerson from kitconcept.intranet.interfaces import IBrowserLayer +from plone import api from plone.restapi.interfaces import ISerializeToJson +from plone.restapi.serializer.dxcontent import SerializeToJson from zope.component import adapter from zope.interface import implementer -from plone.restapi.serializer.dxcontent import SerializeToJson -from plone import api @implementer(ISerializeToJson) diff --git a/backend/src/kitconcept/intranet/upgrades/v20260318001/__init__.py b/backend/src/kitconcept/intranet/upgrades/v20260318001/__init__.py index 083128bd..606c7464 100644 --- a/backend/src/kitconcept/intranet/upgrades/v20260318001/__init__.py +++ b/backend/src/kitconcept/intranet/upgrades/v20260318001/__init__.py @@ -1,4 +1,5 @@ from BTrees.OIBTree import OIBTree +from kitconcept.intranet import logger from kitconcept.intranet.behaviors.location import ILocationBehavior from kitconcept.intranet.behaviors.organisational_unit import ( IOrganisationalUnitBehavior, @@ -7,8 +8,6 @@ from zc.relation.interfaces import ICatalog from zope.component import getUtility -from kitconcept.intranet import logger - def init_vocabulary_cache(context): """Initialize the vocabulary cache BTree on the portal.""" diff --git a/backend/src/kitconcept/intranet/utils/review_due_notifier.py b/backend/src/kitconcept/intranet/utils/review_due_notifier.py index 5afe3692..ecebba67 100644 --- a/backend/src/kitconcept/intranet/utils/review_due_notifier.py +++ b/backend/src/kitconcept/intranet/utils/review_due_notifier.py @@ -3,6 +3,7 @@ import logging + logger = logging.getLogger("kitconcept.intranet") diff --git a/backend/tests/subscribers/test_sub_vocabulary.py b/backend/tests/subscribers/test_sub_vocabulary.py index df55259d..73c60067 100644 --- a/backend/tests/subscribers/test_sub_vocabulary.py +++ b/backend/tests/subscribers/test_sub_vocabulary.py @@ -1,5 +1,5 @@ -from kitconcept.intranet.subscribers.vocabulary import cache_buster from kitconcept.intranet.subscribers.vocabulary import KEYS +from kitconcept.intranet.subscribers.vocabulary import cache_buster from kitconcept.intranet.vocabularies.base import get_vocabulary_counter from plone import api from zope.lifecycleevent import ObjectModifiedEvent diff --git a/backend/tests/vocabularies/test_voc_base.py b/backend/tests/vocabularies/test_voc_base.py index ec2432bd..394234db 100644 --- a/backend/tests/vocabularies/test_voc_base.py +++ b/backend/tests/vocabularies/test_voc_base.py @@ -1,6 +1,6 @@ +from kitconcept.intranet.vocabularies.base import VocabularyCounter from kitconcept.intranet.vocabularies.base import get_vocabulary_counter from kitconcept.intranet.vocabularies.base import invalidate_vocabulary_cache -from kitconcept.intranet.vocabularies.base import VocabularyCounter from plone import api import pytest From 25c20c6c77e3b8d2d902700c567a7eac82c749ed Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:21:56 +0200 Subject: [PATCH 58/95] fixes for notify_reviewer --- .../intranet/utils/review_due_notifier.py | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/backend/src/kitconcept/intranet/utils/review_due_notifier.py b/backend/src/kitconcept/intranet/utils/review_due_notifier.py index ecebba67..e9248f5a 100644 --- a/backend/src/kitconcept/intranet/utils/review_due_notifier.py +++ b/backend/src/kitconcept/intranet/utils/review_due_notifier.py @@ -19,30 +19,55 @@ def nofity_reviewer(portal): if not reviewer: logger.warning("Couldn't find user. No mail will be sent.") - return - - breakpoint() - mail_subject = f"🔔 Reminder: Content review due for {obj.Title()}" - mail_body = ( - f"Hello {reviewer.getProperty('fullname') or reviewer.getUserName()}," - f"The content item “{obj.Title()}” is due for review." - "Please check whether the information is still accurate and up to date." - f"Last updated: {obj}" - "Next review date (after completion): will be recalculated automatically" - "You can open the content here:" - f"👉 {obj.absolute_url()}" - "Available actions:" - "- ✅ Review the content and mark as reviewed" - "- 🕓 Postpone next review (e.g., by 3 or 6 months)" - "- 📝 Mark as “changes required” if updates are needed" - "Thank you for keeping our content accurate and relevant." - "Kind regards," - "Your intranet team" - ) + continue + + owner_name = reviewer.getProperty("fullname") or reviewer.getUserName() + + mail_subject = { + "de": f"🔔 Erinnerung: Inhaltsprüfung fällig für „{obj.Title()}“", + "en": f"🔔 Reminder: Content review due for “{obj.Title()}”", + } + mail_body = { + "de": ( + f"Hallo {owner_name}," + f"der Inhalt „{obj.Title()}“ ist zur Überprüfung fällig." + "Bitte prüfen Sie, ob die Informationen noch aktuell und korrekt sind." + f"Letzte Aktualisierung: {obj}" + "Nächste Kontrolle (nach Prüfung): wird automatisch neu berechnet" + f"Sie können den Inhalt hier aufrufen:" + f"👉 {obj.absolute_url()}" + "Ihre Optionen:" + "- ✅ Inhalt prüfen und als „geprüft“ markieren" + "- 🕓 Nächste Kontrolle verschieben (z. B. in 3 oder 6 Monaten)" + "- 📝 Inhalt als „Überarbeitung erforderlich“ markieren, falls Änderungen notwendig sind" + "Vielen Dank, dass Sie dafür sorgen, dass unsere Inhalte aktuell bleiben." + "Mit freundlichen Grüßen," + "Ihr Intranet-Team" + ), + "en": ( + f"Hello {owner_name}," + f"The content item “{obj.Title()}” is due for review." + "Please check whether the information is still accurate and up to date." + f"Last updated: {obj}" + "Next review date (after completion): will be recalculated automatically" + "You can open the content here:" + f"👉 {obj.absolute_url()}" + "Available actions:" + "- ✅ Review the content and mark as reviewed" + "- 🕓 Postpone next review (e.g., by 3 or 6 months)" + "- 📝 Mark as “changes required” if updates are needed" + "Thank you for keeping our content accurate and relevant." + "Kind regards," + "Your intranet team" + ), + } + + languages = api.portal.get_tool("portal_languages") + lang = obj.language or languages.getDefaultLanguage() api.portal.send_email( recipient=reviewer.getProperty("email"), - subject=mail_subject, - body=mail_body, + subject=mail_subject.get(lang, mail_subject.get("en")), + body=mail_body.get(lang, mail_body.get("en")), immediate=True, ) From 881f23b7096479d33e59dac0513b5027cb7d858a Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:24:16 +0200 Subject: [PATCH 59/95] add path to warning if no user was found --- backend/src/kitconcept/intranet/utils/review_due_notifier.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/utils/review_due_notifier.py b/backend/src/kitconcept/intranet/utils/review_due_notifier.py index e9248f5a..7746c60a 100644 --- a/backend/src/kitconcept/intranet/utils/review_due_notifier.py +++ b/backend/src/kitconcept/intranet/utils/review_due_notifier.py @@ -18,7 +18,9 @@ def nofity_reviewer(portal): ) if not reviewer: - logger.warning("Couldn't find user. No mail will be sent.") + logger.warning( + f"Couldn't find user for {brain.getPath()}. No mail will be sent." + ) continue owner_name = reviewer.getProperty("fullname") or reviewer.getUserName() From 2741b547716fbb08c7e531a7d97318ed6b1da012 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:57:07 +0200 Subject: [PATCH 60/95] test calc_due_date --- backend/tests/utils/test_calc_due_date.py | 92 +++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 backend/tests/utils/test_calc_due_date.py diff --git a/backend/tests/utils/test_calc_due_date.py b/backend/tests/utils/test_calc_due_date.py new file mode 100644 index 00000000..b8ddc0fb --- /dev/null +++ b/backend/tests/utils/test_calc_due_date.py @@ -0,0 +1,92 @@ +from datetime import date +from plone import api +from kitconcept.intranet.utils.calc_due_date import calc_due_date + + +def test_1_day(): + result = calc_due_date(date(2024, 1, 1), "1d") + assert result == date(2024, 1, 2) + + +def test_7_days(): + result = calc_due_date(date(2024, 1, 1), "7d") + assert result == date(2024, 1, 8) + + +def test_days_across_month_boundary(): + result = calc_due_date(date(2024, 1, 30), "5d") + assert result == date(2024, 2, 4) + + +def test_days_across_year_boundary(): + result = calc_due_date(date(2023, 12, 30), "3d") + assert result == date(2024, 1, 2) + + +def test_1_week(): + result = calc_due_date(date(2024, 1, 1), "1w") + assert result == date(2024, 1, 8) + + +def test_2_weeks(): + result = calc_due_date(date(2024, 1, 1), "2w") + assert result == date(2024, 1, 15) + + +def test_weeks_across_month_boundary(): + result = calc_due_date(date(2024, 1, 25), "2w") + assert result == date(2024, 2, 8) + + +def test_1_month(): + result = calc_due_date(date(2024, 1, 15), "1m") + assert result == date(2024, 2, 15) + + +def test_6_months(): + result = calc_due_date(date(2024, 1, 15), "6m") + assert result == date(2024, 7, 15) + + +def test_month_end_clamping(): + """Jan 31 + 1 month → Feb 29 (2024 is a leap year).""" + result = calc_due_date(date(2024, 1, 31), "1m") + assert result == date(2024, 2, 29) + + +def test_months_across_year_boundary(): + result = calc_due_date(date(2024, 11, 1), "3m") + assert result == date(2025, 2, 1) + + +def test_1_year(): + result = calc_due_date(date(2024, 3, 10), "1y") + assert result == date(2025, 3, 10) + + +def test_2_years(): + result = calc_due_date(date(2024, 3, 10), "2y") + assert result == date(2026, 3, 10) + + +def test_leap_day_year_handling(): + """Feb 29 + 1 year → Feb 28 (2025 is not a leap year).""" + result = calc_due_date(date(2024, 2, 29), "1y") + assert result == date(2025, 2, 28) + + +def test_falls_back_to_registry_interval(portal): + result = calc_due_date(date(2024, 1, 1)) + assert result == date(2024, 7, 1) + + +def test_none_interval_triggers_registry(portal): + result = calc_due_date(base_date=date(2024, 1, 1), interval=None) + assert result == date(2024, 7, 1) + + +def test_registry_record_key_is_correct(portal): + result = api.portal.get_registry_record( + "kitconcept.intranet.content_review_default_interval" + ) + assert result == "6m" From 231d72b94031c9a3e3e59f39157159e34c1f329d Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:58:58 +0200 Subject: [PATCH 61/95] remove context aware factory (no context needed = no factory needed) --- .../src/kitconcept/intranet/behaviors/content_review.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index 35490899..abf20a75 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -18,11 +18,6 @@ def default_review_interval(context) -> str: ) -@provider(IContextAwareDefaultFactory) -def default_review_due_date(context) -> date: - return calc_due_date() - - @provider(IFormFieldProvider) class IContentReview(model.Schema): """Content Review behavior""" @@ -77,7 +72,7 @@ class IContentReview(model.Schema): review_due_date = schema.Date( title=_("label_review_due_date", default="Due date"), required=False, - defaultFactory=default_review_due_date, + defaultFactory=calc_due_date(), ) review_completed_date = schema.Date( From d1a6400366ed55a7d38f5d30d6b1673b00d5bcf5 Mon Sep 17 00:00:00 2001 From: David Glick Date: Tue, 7 Apr 2026 12:15:31 -0700 Subject: [PATCH 62/95] fix defaultFactory --- backend/src/kitconcept/intranet/behaviors/content_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index abf20a75..f8759fd9 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -72,7 +72,7 @@ class IContentReview(model.Schema): review_due_date = schema.Date( title=_("label_review_due_date", default="Due date"), required=False, - defaultFactory=calc_due_date(), + defaultFactory=calc_due_date, ) review_completed_date = schema.Date( From 58a3bd57e2e26772556cc7b9262da64c42abb120 Mon Sep 17 00:00:00 2001 From: David Glick Date: Tue, 7 Apr 2026 13:42:07 -0700 Subject: [PATCH 63/95] Hacky override to get the UI working, we can make it better later --- .../intranet/behaviors/content_review.py | 17 +- backend/tests/utils/test_calc_due_date.py | 2 +- .../volto/components/manage/Form/Form.jsx | 1271 +++++++++++++++++ frontend/pnpm-lock.yaml | 28 - 4 files changed, 1280 insertions(+), 38 deletions(-) create mode 100644 frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Form/Form.jsx diff --git a/backend/src/kitconcept/intranet/behaviors/content_review.py b/backend/src/kitconcept/intranet/behaviors/content_review.py index f8759fd9..73b038a5 100644 --- a/backend/src/kitconcept/intranet/behaviors/content_review.py +++ b/backend/src/kitconcept/intranet/behaviors/content_review.py @@ -1,4 +1,3 @@ -from datetime import date from kitconcept.intranet import _ from kitconcept.intranet.utils.calc_due_date import calc_due_date from plone import api @@ -26,7 +25,7 @@ class IContentReview(model.Schema): "Content Review & Reminders", label=_("label_review_fieldset", "Content Review & Reminders"), fields=[ - "review_enabled", + "review_timeless", "review_status", "review_interval", "review_assignee", @@ -35,9 +34,9 @@ class IContentReview(model.Schema): ], ) - review_enabled = schema.Bool( + review_timeless = schema.Bool( title=_( - "label_review_enabled", + "label_review_timeless", default="Timeless content - exclude from review reminders", ), required=False, @@ -70,13 +69,13 @@ class IContentReview(model.Schema): ) review_due_date = schema.Date( - title=_("label_review_due_date", default="Due date"), + title=_("label_review_due_date", default="Next review on"), required=False, defaultFactory=calc_due_date, ) review_completed_date = schema.Date( - title=_("label_review_completed_date", default="Completed date"), + title=_("label_review_completed_date", default="Last review on"), required=False, readonly=True, ) @@ -89,9 +88,9 @@ class IContentReview(model.Schema): @invariant def validate_due_date_field(data): - is_reviewable = getattr(data, "review_enabled", False) + is_timeless = getattr(data, "review_timeless", False) has_due_date = getattr(data, "review_due_date", None) - if not is_reviewable and not has_due_date: + if not is_timeless and not has_due_date: raise Invalid("You have to set a due date if the content is not timeless") - elif is_reviewable and has_due_date: + elif is_timeless and has_due_date: raise Invalid("Cannot set due date if content is timeless") diff --git a/backend/tests/utils/test_calc_due_date.py b/backend/tests/utils/test_calc_due_date.py index b8ddc0fb..3ea3ff42 100644 --- a/backend/tests/utils/test_calc_due_date.py +++ b/backend/tests/utils/test_calc_due_date.py @@ -1,6 +1,6 @@ from datetime import date -from plone import api from kitconcept.intranet.utils.calc_due_date import calc_due_date +from plone import api def test_1_day(): diff --git a/frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Form/Form.jsx b/frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Form/Form.jsx new file mode 100644 index 00000000..bf9196a2 --- /dev/null +++ b/frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Form/Form.jsx @@ -0,0 +1,1271 @@ +/* Customized to add conditional logic for content review fieldset */ + +/** + * Form component. + * @module components/manage/Form/Form + */ + +import Icon from '@plone/volto/components/theme/Icon/Icon'; +import Toast from '@plone/volto/components/manage/Toast/Toast'; +import { Field, BlocksForm } from '@plone/volto/components/manage/Form'; +import BlocksToolbar from '@plone/volto/components/manage/Form/BlocksToolbar'; +import UndoToolbar from '@plone/volto/components/manage/Form/UndoToolbar'; +import { difference } from '@plone/volto/helpers/Utils/Utils'; +import withSaveAsDraft from '@plone/volto/helpers/Utils/withSaveAsDraft'; +import FormValidation from '@plone/volto/helpers/FormValidation/FormValidation'; +import { + getBlocksFieldname, + getBlocksLayoutFieldname, + hasBlocksData, +} from '@plone/volto/helpers/Blocks/Blocks'; +import { applySchemaEnhancer } from '@plone/volto/helpers/Extensions/withBlockSchemaEnhancer'; +import { messages } from '@plone/volto/helpers/MessageLabels/MessageLabels'; +import aheadSVG from '@plone/volto/icons/ahead.svg'; +import clearSVG from '@plone/volto/icons/clear.svg'; +import upSVG from '@plone/volto/icons/up-key.svg'; +import downSVG from '@plone/volto/icons/down-key.svg'; +import findIndex from 'lodash/findIndex'; +import isEmpty from 'lodash/isEmpty'; +import isEqual from 'lodash/isEqual'; +import keys from 'lodash/keys'; +import map from 'lodash/map'; +import mapValues from 'lodash/mapValues'; +import pickBy from 'lodash/pickBy'; +import without from 'lodash/without'; +import cloneDeep from 'lodash/cloneDeep'; +import xor from 'lodash/xor'; +import isBoolean from 'lodash/isBoolean'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { injectIntl } from 'react-intl'; +import { createPortal } from 'react-dom'; +import { connect } from 'react-redux'; +import { + Accordion, + Button, + Container as SemanticContainer, + Form as UiForm, + Message, + Segment, + Tab, +} from 'semantic-ui-react'; +import { v4 as uuid } from 'uuid'; +import { toast } from 'react-toastify'; +import { + setMetadataFieldsets, + resetMetadataFocus, + setSidebarTab, +} from '@plone/volto/actions/sidebar/sidebar'; +import { setFormData, setUIState } from '@plone/volto/actions/form/form'; +import { compose } from 'redux'; +import config from '@plone/volto/registry'; +import SlotRenderer from '@plone/volto/components/theme/SlotRenderer/SlotRenderer'; + +/** + * Form container class. + * @class Form + * @extends Component + */ +class Form extends Component { + /** + * Property types. + * @property {Object} propTypes Property types. + * @static + */ + static propTypes = { + schema: PropTypes.shape({ + fieldsets: PropTypes.arrayOf( + PropTypes.shape({ + fields: PropTypes.arrayOf(PropTypes.string), + id: PropTypes.string, + title: PropTypes.string, + }), + ), + properties: PropTypes.objectOf(PropTypes.any), + definitions: PropTypes.objectOf(PropTypes.any), + required: PropTypes.arrayOf(PropTypes.string), + }), + widgets: PropTypes.objectOf(PropTypes.any), + component: PropTypes.any, + formData: PropTypes.objectOf(PropTypes.any), + globalData: PropTypes.objectOf(PropTypes.any), + metadataFieldsets: PropTypes.arrayOf(PropTypes.string), + metadataFieldFocus: PropTypes.string, + pathname: PropTypes.string, + onSubmit: PropTypes.func, + onCancel: PropTypes.func, + submitLabel: PropTypes.string, + cancelLabel: PropTypes.string, + textButtons: PropTypes.bool, + buttonComponent: PropTypes.any, + resetAfterSubmit: PropTypes.bool, + resetOnCancel: PropTypes.bool, + isEditForm: PropTypes.bool, + isAdminForm: PropTypes.bool, + title: PropTypes.string, + error: PropTypes.shape({ + message: PropTypes.string, + }), + loading: PropTypes.bool, + hideActions: PropTypes.bool, + description: PropTypes.string, + visual: PropTypes.bool, + blocks: PropTypes.arrayOf(PropTypes.object), + isFormSelected: PropTypes.bool, + onSelectForm: PropTypes.func, + editable: PropTypes.bool, + onChangeFormData: PropTypes.func, + requestError: PropTypes.string, + allowedBlocks: PropTypes.arrayOf(PropTypes.string), + showRestricted: PropTypes.bool, + global: PropTypes.bool, + }; + + /** + * Default properties. + * @property {Object} defaultProps Default properties. + * @static + */ + static defaultProps = { + formData: null, + widgets: null, + component: null, + onSubmit: null, + onCancel: null, + submitLabel: null, + cancelLabel: null, + textButtons: false, + buttonComponent: null, + resetAfterSubmit: false, + resetOnCancel: false, + isEditForm: false, + isAdminForm: false, + title: null, + description: null, + error: null, + loading: null, + hideActions: false, + visual: false, + blocks: [], + pathname: '', + schema: {}, + isFormSelected: true, + onSelectForm: null, + editable: true, + requestError: null, + allowedBlocks: null, + global: false, + }; + + /** + * Constructor + * @method constructor + * @param {Object} props Component properties + * @constructs Form + */ + constructor(props) { + super(props); + const ids = { + title: uuid(), + text: uuid(), + }; + let { formData, schema: originalSchema } = props; + const blocksFieldname = getBlocksFieldname(formData); + const blocksLayoutFieldname = getBlocksLayoutFieldname(formData); + + const schema = this.removeBlocksLayoutFields(originalSchema); + + this.props.setMetadataFieldsets( + schema?.fieldsets ? schema.fieldsets.map((fieldset) => fieldset.id) : [], + ); + + if (!props.isEditForm) { + // It's a normal (add form), get defaults from schema + formData = { + ...mapValues(props.schema.properties, 'default'), + ...formData, + }; + } + + // We initialize the formData snapshot in here, before the initial data checks + const initialFormData = cloneDeep(formData); + + // Adding fallback in case the fields are empty, so we are sure that the edit form + // shows at least the default blocks + if ( + formData?.hasOwnProperty(blocksFieldname) && + formData?.hasOwnProperty(blocksLayoutFieldname) + ) { + if ( + !formData[blocksLayoutFieldname] || + isEmpty(formData[blocksLayoutFieldname].items) + ) { + formData[blocksLayoutFieldname] = { + items: [ids.title, ids.text], + }; + } + if (!formData[blocksFieldname] || isEmpty(formData[blocksFieldname])) { + formData[blocksFieldname] = { + [ids.title]: { + '@type': 'title', + }, + [ids.text]: { + '@type': config.settings.defaultBlockType, + }, + }; + } + } + + let selectedBlock = null; + if ( + formData?.hasOwnProperty(blocksLayoutFieldname) && + formData[blocksLayoutFieldname].items.length > 0 + ) { + if (config.blocks?.initialBlocksFocus === null) { + selectedBlock = null; + } else if (this.props.type in config.blocks?.initialBlocksFocus) { + // Default selected is not the first block, but the one from config. + // TODO Select first block and not an arbitrary one. + Object.keys(formData[blocksFieldname]).forEach((b_key) => { + if ( + formData[blocksFieldname][b_key]['@type'] === + config.blocks?.initialBlocksFocus?.[this.props.type] + ) { + selectedBlock = b_key; + } + }); + } else { + selectedBlock = formData[blocksLayoutFieldname].items[0]; + } + } + + // Sync state to global state + if (this.props.global) { + this.props.setFormData(formData); + } + + this.props.setUIState({ + selected: selectedBlock, + multiSelected: [], + hovered: null, + }); + + // Set initial state + this.state = { + formData, + initialFormData, + errors: {}, + isClient: false, + // Ensure focus remain in field after change + inFocus: {}, + sidebarMetadataIsAvailable: false, + }; + this.onChangeField = this.onChangeField.bind(this); + this.onSelectBlock = this.onSelectBlock.bind(this); + this.onSubmit = this.onSubmit.bind(this); + this.onCancel = this.onCancel.bind(this); + this.onTabChange = this.onTabChange.bind(this); + this.onBlurField = this.onBlurField.bind(this); + this.onClickInput = this.onClickInput.bind(this); + this.onToggleMetadataFieldset = this.onToggleMetadataFieldset.bind(this); + this.updateFormDataWithSaved = this.updateFormDataWithSaved.bind(this); + } + + /** + * Function sent as callback to saveAsDraft when user + * choses to load local data + * @param {Object} savedFormData + */ + updateFormDataWithSaved(savedFormData) { + if (savedFormData) { + this.setState({ formData: savedFormData }); + } + } + + /** + * On updates caused by props change + * if errors from Backend come, these will be shown to their corresponding Fields + * also the first Tab to have any errors will be selected + * @param {Object} prevProps + */ + async componentDidUpdate(prevProps, prevState) { + let { requestError } = this.props; + let errors = {}; + let activeIndex = 0; + + if (!prevProps.schema && this.props.schema) { + this.props.checkSavedDraft( + this.state.formData, + this.updateFormDataWithSaved, + ); + } + if (!this.props.isFormSelected && prevProps.isFormSelected) { + this.props.setUIState({ + selected: null, + }); + } + + if (requestError && prevProps.requestError !== requestError) { + errors = + FormValidation.giveServerErrorsToCorrespondingFields(requestError); + activeIndex = FormValidation.showFirstTabWithErrors({ + errors, + schema: this.props.schema, + }); + + this.setState({ + errors, + activeIndex, + }); + } + + if (this.props.onChangeFormData) { + if (!isEqual(prevState?.formData, this.state.formData)) { + this.props.onChangeFormData(this.state.formData); + } + } + // on each formData update it will save the form to the localStorage + if (!isEqual(prevState?.formData, this.state.formData)) { + this.props.onSaveDraft(this.state.formData); + } + if ( + this.props.global && + !isEqual(this.props.globalData, prevProps.globalData) + ) { + this.setState({ + formData: this.props.globalData, + }); + } + + if (!isEqual(prevProps.schema, this.props.schema)) { + this.props.setMetadataFieldsets( + this.removeBlocksLayoutFields(this.props.schema).fieldsets.map( + (fieldset) => fieldset.id, + ), + ); + } + + if ( + this.props.metadataFieldFocus !== '' && + !isEqual(prevProps.metadataFieldFocus, this.props.metadataFieldFocus) + ) { + // Scroll into view + document + .querySelector(`.field-wrapper-${this.props.metadataFieldFocus}`) + .scrollIntoView(); + + // Set focus to first input if available + document + .querySelector(`.field-wrapper-${this.props.metadataFieldFocus} input`) + ?.focus(); + + // Reset focus field + this.props.resetMetadataFocus(); + } + + if ( + !this.state.sidebarMetadataIsAvailable && + document.getElementById('sidebar-metadata') + ) { + this.setState(() => ({ sidebarMetadataIsAvailable: true })); + } + } + + /** + * Tab selection is done only by setting activeIndex in state + */ + onTabChange(e, { activeIndex }) { + const defaultFocus = this.props.schema.fieldsets[activeIndex].fields[0]; + this.setState({ + activeIndex, + ...(defaultFocus ? { inFocus: { [defaultFocus]: true } } : {}), + }); + } + + /** + * If user clicks on input, the form will be not considered pristine + * this will avoid onBlur effects without interaction with the form + * @param {Object} e event + */ + onClickInput(e) { + this.setState({ isFormPristine: false }); + } + + /** + * Validate fields on blur + * @method onBlurField + * @param {string} id Id of the field + * @param {*} value Value of the field + * @returns {undefined} + */ + onBlurField(id, value) { + if (!this.state.isFormPristine) { + const errors = FormValidation.validateFieldsPerFieldset({ + schema: this.props.schema, + formData: this.state.formData, + formatMessage: this.props.intl.formatMessage, + touchedField: { [id]: value }, + }); + + this.setState({ + errors, + }); + } + } + + /** + * Component did mount + * @method componentDidMount + * @returns {undefined} + */ + componentDidMount() { + this.setState({ isClient: true }); + if (this.props.schema) { + this.props.checkSavedDraft( + this.state.formData, + this.updateFormDataWithSaved, + ); + return; + } + } + + /* START CUSTOMIZATION */ + /** + * Get fields to render, filtering content review fields based on review_timeless + * @method getFieldsToRender + * @param {Array} fields Original fields array + * @param {Object} formData Current form data + * @returns {Array} Filtered fields array + */ + getFieldsToRender(fields, formData) { + if (formData.review_timeless) { + return fields.filter( + (field) => field === 'review_timeless' || !field.startsWith('review_'), + ); + } + return fields; + } + + /** + * Calculate review due date based on interval + * @method calcDueDate + * @param {string} interval Interval string like "1m", "2w" + * @returns {string} ISO date string + */ + calcDueDate(interval) { + if (!interval) return null; + const today = new Date(); + const mapping = { + d: 'days', + w: 'weeks', + m: 'months', + y: 'years', + }; + const unit = mapping[interval.slice(-1)]; + const amount = parseInt(interval.slice(0, -1)); + const dueDate = new Date(today); + if (unit === 'days') dueDate.setDate(today.getDate() + amount); + else if (unit === 'weeks') dueDate.setDate(today.getDate() + amount * 7); + else if (unit === 'months') dueDate.setMonth(today.getMonth() + amount); + else if (unit === 'years') + dueDate.setFullYear(today.getFullYear() + amount); + return dueDate.toISOString().split('T')[0]; + } + /* END CUSTOMIZATION */ + + /** + * Change field handler + * Remove errors for changed field + * @method onChangeField + * @param {string} id Id of the field + * @param {*} value Value of the field + * @returns {undefined} + */ + onChangeField(id, value) { + this.setState((prevState) => { + const { errors, formData } = prevState; + let newFormData = { + ...formData, + // We need to catch also when the value equals false this fixes #888 + [id]: value || (value !== undefined && isBoolean(value)) ? value : null, + }; + + // START CUSTOMIZATION: Handle content review field changes + if (id === 'review_interval') { + // Update review_due_date when review_interval changes + newFormData.review_due_date = this.calcDueDate(value); + } else if (id === 'review_due_date') { + newFormData.review_interval = null; + } + // END CUSTOMIZATION + + delete errors[id]; + if (this.props.global) { + this.props.setFormData(newFormData); + } + return { + errors, + formData: newFormData, + // Changing the form data re-renders the select widget which causes the + // focus to get lost. To circumvent this, we set the focus back to + // the input. + // This could fix other widgets too but currently targeted + // against the select widget only. + // Ensure field to be in focus after the change + inFocus: { [id]: true }, + }; + }); + } + + /** + * Select block handler + * @method onSelectBlock + * @param {string} id Id of the field + * @param {string} isMultipleSelection true if multiple blocks are selected + * @returns {undefined} + */ + onSelectBlock(id, isMultipleSelection, event) { + let multiSelected = []; + let selected = id; + const formData = this.state.formData; + + if (isMultipleSelection) { + selected = null; + const blocksLayoutFieldname = getBlocksLayoutFieldname(formData); + + const blocks_layout = formData[blocksLayoutFieldname].items; + + if (event.shiftKey) { + const anchor = + this.props.uiState.multiSelected.length > 0 + ? blocks_layout.indexOf(this.props.uiState.multiSelected[0]) + : blocks_layout.indexOf(this.props.uiState.selected); + const focus = blocks_layout.indexOf(id); + + if (anchor === focus) { + multiSelected = [id]; + } else if (focus > anchor) { + multiSelected = [...blocks_layout.slice(anchor, focus + 1)]; + } else { + multiSelected = [...blocks_layout.slice(focus, anchor + 1)]; + } + window.getSelection().empty(); + } + + if ((event.ctrlKey || event.metaKey) && !event.shiftKey) { + multiSelected = this.props.uiState.multiSelected || []; + if (!this.props.uiState.multiSelected.includes(this.state.selected)) { + multiSelected = [...multiSelected, this.props.uiState.selected]; + selected = null; + } + if (this.props.uiState.multiSelected.includes(id)) { + selected = null; + multiSelected = without(multiSelected, id); + } else { + multiSelected = [...multiSelected, id]; + } + } + } + + this.props.setUIState({ + selected, + multiSelected, + gridSelected: null, + }); + + if (this.props.onSelectForm) { + if (event) event.nativeEvent.stopImmediatePropagation(); + this.props.onSelectForm(); + } + } + + /** + * Cancel handler + * It prevents event from triggering submit, reset form if props.resetAfterSubmit + * and calls this.props.onCancel + * @method onCancel + * @param {Object} event Event object. + * @returns {undefined} + */ + onCancel(event) { + if (event) { + event.preventDefault(); + } + if (this.props.resetOnCancel || this.props.resetAfterSubmit) { + this.setState({ + formData: this.props.formData, + }); + if (this.props.global) { + this.props.setFormData(this.props.formData); + } + } + this.props.onCancel(event); + } + + /** + * Submit handler also validate form and collect errors + * @method onSubmit + * @param {Object} event Event object. + * @returns {undefined} + */ + onSubmit(event) { + const formData = this.state.formData; + + if (event) { + event.preventDefault(); + } + + const errors = this.props.schema + ? FormValidation.validateFieldsPerFieldset({ + schema: this.props.schema, + formData, + formatMessage: this.props.intl.formatMessage, + }) + : {}; + + let blocksErrors = {}; + + if (hasBlocksData(formData)) { + // Validate blocks + const blocks = this.state.formData[getBlocksFieldname(formData)]; + const blocksLayout = + this.state.formData[getBlocksLayoutFieldname(formData)]; + const defaultSchema = { + properties: {}, + fieldsets: [], + required: [], + }; + blocksLayout.items.forEach((block) => { + let blockSchema = + config.blocks.blocksConfig[blocks[block]['@type']].blockSchema || + defaultSchema; + if (typeof blockSchema === 'function') { + blockSchema = blockSchema({ + intl: this.props.intl, + formData: blocks[block], + }); + } + + if (config.blocks.blocksConfig[blocks[block]['@type']].blockSchema) { + blockSchema = applySchemaEnhancer({ + schema: blockSchema, + formData: blocks[block], + intl: this.props.intl, + blocksConfig: config.blocks.blocksConfig, + navRoot: this.props.navRoot, + contentType: this.props.content['@type'], + }); + } + + const blockErrors = FormValidation.validateFieldsPerFieldset({ + schema: blockSchema, + formData: blocks[block], + formatMessage: this.props.intl.formatMessage, + }); + if (keys(blockErrors).length > 0) { + blocksErrors = { + ...blocksErrors, + [block]: { ...blockErrors }, + }; + } + }); + } + + if (keys(errors).length > 0 || keys(blocksErrors).length > 0) { + const activeIndex = FormValidation.showFirstTabWithErrors({ + errors, + schema: this.props.schema, + }); + + this.setState({ + errors: { + ...errors, + ...(!isEmpty(blocksErrors) && { blocks: blocksErrors }), + }, + activeIndex, + }); + + if (keys(errors).length > 0) { + // Changes the focus to the metadata tab in the sidebar if error + Object.keys(errors).forEach((err) => + toast.error( + , + ), + ); + this.props.setSidebarTab(0); + } else if (keys(blocksErrors).length > 0) { + const errorField = Object.entries( + Object.entries(blocksErrors)[0][1], + )[0][0]; + const errorMessage = Object.entries( + Object.entries(blocksErrors)[0][1], + )[0][1]; + const errorFieldTitle = errorMessage.title || errorField; + + toast.error( + , + ); + this.props.setSidebarTab(1); + this.props.setUIState({ + selected: Object.keys(blocksErrors)[0], + multiSelected: [], + hovered: null, + }); + } + } else { + // Get only the values that have been modified (Edit forms), send all in case that + // it's an add form + if (this.props.isEditForm) { + this.props.onSubmit(this.getOnlyFormModifiedValues()); + } else { + this.props.onSubmit(formData); + } + if (this.props.resetAfterSubmit) { + this.setState({ + formData: this.props.formData, + }); + if (this.props.global) { + this.props.setFormData(this.props.formData); + } + } + this.props.onCancelDraft(); + } + } + + /** + * getOnlyFormModifiedValues handler + * It returns only the values of the fields that are have really changed since the + * form was loaded. Useful for edit forms and PATCH operations, when we only want to + * send the changed data. + * @method getOnlyFormModifiedValues + * @param {Object} event Event object. + * @returns {undefined} + */ + getOnlyFormModifiedValues = () => { + const formData = this.state.formData; + + const fieldsModified = Object.keys( + difference(formData, this.state.initialFormData), + ); + return { + ...pickBy(formData, (value, key) => fieldsModified.includes(key)), + ...(formData['@static_behaviors'] && { + '@static_behaviors': formData['@static_behaviors'], + }), + }; + }; + + /** + * Removed blocks and blocks_layout fields from the form. + * @method removeBlocksLayoutFields + * @param {object} schema The schema definition of the form. + * @returns A modified copy of the given schema. + */ + removeBlocksLayoutFields = (schema) => { + const newSchema = { ...schema }; + const layoutFieldsetIndex = findIndex( + newSchema.fieldsets, + (fieldset) => fieldset.id === 'layout', + ); + if (layoutFieldsetIndex > -1) { + const layoutFields = newSchema.fieldsets[layoutFieldsetIndex].fields; + newSchema.fieldsets[layoutFieldsetIndex].fields = layoutFields.filter( + (field) => field !== 'blocks' && field !== 'blocks_layout', + ); + if (newSchema.fieldsets[layoutFieldsetIndex].fields.length === 0) { + newSchema.fieldsets = [ + ...newSchema.fieldsets.slice(0, layoutFieldsetIndex), + ...newSchema.fieldsets.slice(layoutFieldsetIndex + 1), + ]; + } + } + return newSchema; + }; + + /** + * Toggle metadata fieldset handler + * @method onToggleMetadataFieldset + * @param {Object} event Event object. + * @param {Object} blockProps Block properties. + * @returns {undefined} + */ + onToggleMetadataFieldset(event, blockProps) { + const { index } = blockProps; + this.props.setMetadataFieldsets(xor(this.props.metadataFieldsets, [index])); + } + + /** + * Render method. + * @method render + * @returns {string} Markup for the component. + */ + render() { + const { settings } = config; + const { + schema: originalSchema, + onCancel, + onSubmit, + navRoot, + type, + metadataFieldsets, + component, + buttonComponent, + } = this.props; + const formData = this.state.formData; + const schema = this.removeBlocksLayoutFields(originalSchema); + const Container = + config.getComponent({ name: 'Container' }).component || SemanticContainer; + const FormComponent = component || UiForm; + const ButtonComponent = buttonComponent || Button; + + return this.props.visual ? ( + // Removing this from SSR is important, since react-beautiful-dnd supports SSR, + // but draftJS don't like it much and the hydration gets messed up + this.state.isClient && ( + <> + + + + <> + { + const newFormData = { + ...formData, + ...newBlockData, + }; + this.setState({ + formData: newFormData, + }); + if (this.props.global) { + this.props.setFormData(newFormData); + } + }} + onSetSelectedBlocks={(blockIds) => + this.props.setUIState({ multiSelected: blockIds }) + } + onSelectBlock={this.onSelectBlock} + /> + { + if (this.props.global) { + this.props.setFormData(state.formData); + } + return this.setState(state); + }} + /> + { + const newFormData = { + ...formData, + ...newData, + }; + this.setState({ + formData: newFormData, + }); + if (this.props.global) { + this.props.setFormData(newFormData); + } + }} + onChangeField={this.onChangeField} + onSelectBlock={this.onSelectBlock} + properties={formData} + navRoot={navRoot} + type={type} + pathname={this.props.pathname} + selectedBlock={this.props.uiState.selected} + multiSelected={this.props.uiState.multiSelected} + manage={this.props.isAdminForm} + allowedBlocks={this.props.allowedBlocks} + showRestricted={this.props.showRestricted} + editable={this.props.editable} + isMainForm={this.props.editable} + // Properties to pass to the BlocksForm to match the View ones + history={this.props.history} + location={this.props.location} + token={this.props.token} + errors={this.state.errors} + blocksErrors={this.state.errors.blocks} + /> + {this.state.isClient && + this.state.sidebarMetadataIsAvailable && + this.props.editable && + createPortal( + 0} + > + {schema && + map(schema.fieldsets, (fieldset) => ( + +
+ + {fieldset.title} + {metadataFieldsets.includes(fieldset.id) ? ( + + ) : ( + + )} + + + + { + /* START CUSTOMIZATION: Filter fields based on review_timeless */ + map( + this.getFieldsToRender( + fieldset.fields, + formData, + ), + (field, index) => ( + /* END CUSTOMIZATION */ + + ), + ) + } + + +
+
+ ))} +
, + document.getElementById('sidebar-metadata'), + )} + + + +
+ + ) + ) : ( + + 0} + className={settings.verticalFormTabs ? 'vertical-form' : ''} + > +
+ + {schema && schema.fieldsets.length > 1 && ( + <> + {settings.verticalFormTabs && this.props.title && ( + + {this.props.title} + + )} + ({ + menuItem: item.title, + render: () => [ + !settings.verticalFormTabs && this.props.title && ( + + {this.props.title} + + ), + item.description && ( + + {item.description} + + ), + ...map( + this.getFieldsToRender(item.fields, formData), + (field, index) => ( + {} + } + onBlur={this.onBlurField} + onClick={this.onClickInput} + key={field} + error={this.state.errors[field]} + /> + ), + ), + ], + }))} + /> + + )} + {schema && schema.fieldsets.length === 1 && ( + + {this.props.title && ( + +

{this.props.title}

+
+ )} + {this.props.description && ( + {this.props.description} + )} + {keys(this.state.errors).length > 0 && ( + + )} + {this.props.error && ( + + )} + { + /* START CUSTOMIZATION Filter fields based on review_timeless */ + map( + this.getFieldsToRender( + schema.fieldsets[0].fields, + formData, + ), + (field) => ( + /* END CUSTOMIZATION */ + + ), + ) + } +
+ )} + {!this.props.hideActions && ( + + {onSubmit && + (this.props.textButtons ? ( + + {this.props.submitLabel + ? this.props.submitLabel + : this.props.intl.formatMessage(messages.save)} + + ) : ( + + + + ))} + {onCancel && + (this.props.textButtons ? ( + + {this.props.cancelLabel + ? this.props.cancelLabel + : this.props.intl.formatMessage(messages.cancel)} + + ) : ( + + + + ))} + + )} +
+
+
+
+ ); + } +} + +const FormIntl = injectIntl(Form, { forwardRef: true }); + +export default compose( + connect( + (state, props) => ({ + content: state.content.data, + globalData: state.form?.global, + uiState: state.form?.ui, + metadataFieldsets: state.sidebar?.metadataFieldsets, + metadataFieldFocus: state.sidebar?.metadataFieldFocus, + }), + { + setMetadataFieldsets, + setSidebarTab, + setFormData, + setUIState, + resetMetadataFocus, + }, + null, + { forwardRef: true }, + ), + withSaveAsDraft({ forwardRef: true }), +)(FormIntl); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 36eae6d6..1ee6f27e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -2177,34 +2177,6 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.0)(jiti@2.6.1)(jsdom@22.1.0)(less@3.11.1)(lightningcss@1.30.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1) - packages/volto-solr/frontend/packages/volto-solr: - dependencies: - buffer: - specifier: ^5.5.0 - version: 5.7.1 - lodash.debounce: - specifier: ^4.0.8 - version: 4.0.8 - react: - specifier: 18.2.0 - version: 18.2.0 - react-dom: - specifier: 18.2.0 - version: 18.2.0(react@18.2.0) - react-portal: - specifier: 4.2.1 - version: 4.2.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) - devDependencies: - '@plone/scripts': - specifier: ^3.6.1 - version: 3.10.3 - react-autosuggest: - specifier: 10.1.0 - version: 10.1.0(react@18.2.0) - release-it: - specifier: ^17.1.1 - version: 17.1.1(typescript@5.9.3) - packages: '@adobe/css-tools@4.4.4': From 36bfd5fc1df8aab5579f9aebad88ed6cc2f19e3a Mon Sep 17 00:00:00 2001 From: David Glick Date: Tue, 7 Apr 2026 16:32:26 -0700 Subject: [PATCH 64/95] Work around bug in plone.restapi that calls defaultFactory with a context even if it isn't context-aware --- backend/src/kitconcept/intranet/utils/calc_due_date.py | 3 ++- .../src/slots/DocumentByLine/DocumentByLine.tsx | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/backend/src/kitconcept/intranet/utils/calc_due_date.py b/backend/src/kitconcept/intranet/utils/calc_due_date.py index 5c784d9c..28e62038 100644 --- a/backend/src/kitconcept/intranet/utils/calc_due_date.py +++ b/backend/src/kitconcept/intranet/utils/calc_due_date.py @@ -1,6 +1,7 @@ from datetime import date from dateutil.relativedelta import relativedelta from plone import api +from plone.dexterity.interfaces import IDexterityContent def calc_due_date(base_date: date | None = None, interval: str | None = None) -> date: @@ -8,7 +9,7 @@ def calc_due_date(base_date: date | None = None, interval: str | None = None) -> interval = api.portal.get_registry_record( "kitconcept.intranet.content_review_default_interval" ) - if not base_date: + if not base_date or IDexterityContent.providedBy(base_date): base_date = date.today() mapping = { "d": "days", diff --git a/frontend/packages/kitconcept-intranet/src/slots/DocumentByLine/DocumentByLine.tsx b/frontend/packages/kitconcept-intranet/src/slots/DocumentByLine/DocumentByLine.tsx index f1bcd8eb..65efb344 100644 --- a/frontend/packages/kitconcept-intranet/src/slots/DocumentByLine/DocumentByLine.tsx +++ b/frontend/packages/kitconcept-intranet/src/slots/DocumentByLine/DocumentByLine.tsx @@ -73,6 +73,10 @@ const DocumentByLine = ({ content, ...props }: DocumentByLineProps) => { }); }, [creators, content]); + if (!content) { + return null; + } + return ( <>
From 08df68508ef3fc9db9462832b2c99a416d909521 Mon Sep 17 00:00:00 2001 From: David Glick Date: Tue, 7 Apr 2026 16:54:33 -0700 Subject: [PATCH 65/95] fix acceptance test --- .../src/customizations/volto/components/manage/Form/Form.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Form/Form.jsx b/frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Form/Form.jsx index bf9196a2..4594ead7 100644 --- a/frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Form/Form.jsx +++ b/frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Form/Form.jsx @@ -438,7 +438,7 @@ class Form extends Component { * @returns {Array} Filtered fields array */ getFieldsToRender(fields, formData) { - if (formData.review_timeless) { + if (formData?.review_timeless) { return fields.filter( (field) => field === 'review_timeless' || !field.startsWith('review_'), ); From 2d9a4baabbd665e962112da8a02981de5dc22639 Mon Sep 17 00:00:00 2001 From: David Glick Date: Wed, 8 Apr 2026 10:14:25 -0700 Subject: [PATCH 66/95] Remove unneeded explicit commits, disable CSRF protection --- backend/src/kitconcept/intranet/services/review.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index 64f27f89..c2dc8ddb 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -2,13 +2,14 @@ from kitconcept.intranet.behaviors.content_review import IContentReview from kitconcept.intranet.utils.calc_due_date import calc_due_date from plone import api +from plone.protect.interfaces import IDisableCSRFProtection from plone.restapi.deserializer import json_body from plone.restapi.services import Service from zExceptions import BadRequest +from zope.interface import alsoProvides from zope.interface import implementer from zope.publisher.interfaces import IPublishTraverse -import transaction @implementer(IPublishTraverse) @@ -25,6 +26,9 @@ def publishTraverse(self, request, name): return self def reply(self): + # Disable CSRF protection + alsoProvides(self.request, IDisableCSRFProtection) + match self.params: case ["approve"]: # update review_status @@ -37,7 +41,6 @@ def reply(self): self.context.review_due_date = calc_due_date(interval=interval) # update review_completed_date self.context.review_completed_date = date.today() - transaction.commit() case ["delegate"]: field = IContentReview["review_assignee"].bind(self.context) vocabulary = field.vocabulary @@ -48,7 +51,6 @@ def reply(self): if assignee not in vocabulary: raise BadRequest(f"Assignee not found in vocabulary: {vocabulary}") self.context.review_assignee = assignee - transaction.commit() case ["postpone"]: self.context.review_status = "Up-to-date" data = json_body(self.request) @@ -57,7 +59,6 @@ def reply(self): due_date = data.get("due_date", None) if due_date: self.context.review_due_date = date.fromisoformat(due_date) - transaction.commit() case _: raise BadRequest( "Unknown action: expected /@review/approve, " From 1d68403b60dfe042b97f97abd135440d641865f0 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:22:59 +0200 Subject: [PATCH 67/95] rename doc in service test --- .../tests/services/review/test_review_post.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/tests/services/review/test_review_post.py b/backend/tests/services/review/test_review_post.py index f1af8bfa..f85ddc54 100644 --- a/backend/tests/services/review/test_review_post.py +++ b/backend/tests/services/review/test_review_post.py @@ -14,7 +14,7 @@ def portal(portal_class): @pytest.fixture(scope="class") def contents(portal): with api.env.adopt_roles(["Manager"]): - doc = api.content.create(portal, type="Document", id="foobar") + doc = api.content.create(portal, type="Document", id="doc1") api.content.transition(obj=doc, transition="publish") api.user.create(email="jdoe@example.org", username="jdoe") transaction.commit() @@ -34,28 +34,28 @@ def test_response_not_reviewable(self): assert resp.status_code == 404 def test_response_no_action(self): - resp = self.api_session.post("/foobar/@review") + resp = self.api_session.post("/doc1/@review") assert resp.status_code == 400 def test_response_anonymous(self): - resp = self.anon_api_session.post("/foobar/@review") + resp = self.anon_api_session.post("/doc1/@review") assert resp.status_code == 401 def test_response_unknown_action(self): - resp = self.api_session.post("/foobar/@review/foobar") + resp = self.api_session.post("/doc1/@review/doc1") assert resp.status_code == 400 def test_action_approve(self): - doc = api.content.get("/foobar") + doc = api.content.get("/doc1") doc.review_status = "Due" doc.review_due_date = date(2002, 12, 30) transaction.commit() - resp = self.api_session.post("/foobar/@review/approve") + resp = self.api_session.post("/doc1/@review/approve") assert resp.status_code == 200 interval = doc.review_interval or self.default_review_interval - resp = self.api_session.get("/foobar").json() + resp = self.api_session.get("/doc1").json() assert resp["review_status"]["token"] == "Up-to-date" # noqa: S105 assert resp["review_due_date"] == calc_due_date(interval=interval).isoformat() assert resp["review_completed_date"] == date.today().isoformat() @@ -63,32 +63,32 @@ def test_action_approve(self): def test_action_delegate(self): comment = "Lorem Ipsum dolor sit amet" resp = self.api_session.post( - "/foobar/@review/delegate", + "/doc1/@review/delegate", json={"assignee": "jdoe", "comment": comment}, ) assert resp.status_code == 200 - resp = self.api_session.get("/foobar").json() + resp = self.api_session.get("/doc1").json() assert resp["review_assignee"]["token"] == "jdoe" # noqa: S105 assert resp["review_comment"] == comment def test_action_delegate_unknown_assignee(self): resp = self.api_session.post( - "/foobar/@review/delegate", + "/doc1/@review/delegate", json={"assignee": "f.bar"}, ) assert resp.status_code == 400 def test_action_postpone(self): - doc = api.content.get("/foobar") + doc = api.content.get("/doc1") due_date = calc_due_date(base_date=doc.review_due_date) comment = "Lorem Ipsum dolor sit amet" resp = self.api_session.post( - "/foobar/@review/postpone", + "/doc1/@review/postpone", json={"due_date": due_date.isoformat(), "comment": comment}, ) assert resp.status_code == 200 - resp = self.api_session.get("/foobar").json() + resp = self.api_session.get("/doc1").json() assert resp["review_due_date"] == due_date.isoformat() assert resp["review_comment"] == comment From b93b2c6a9f720f6ae0fe1c53ed8517fd513ab726 Mon Sep 17 00:00:00 2001 From: jnptk <110389276+jnptk@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:35:52 +0200 Subject: [PATCH 68/95] add docs for [at]review endpoint --- .../kitconcept/intranet/services/review.py | 1 - docs/docs/developer/reference/api/index.md | 1 + docs/docs/developer/reference/api/review.md | 104 ++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 docs/docs/developer/reference/api/review.md diff --git a/backend/src/kitconcept/intranet/services/review.py b/backend/src/kitconcept/intranet/services/review.py index c2dc8ddb..bfcda4a5 100644 --- a/backend/src/kitconcept/intranet/services/review.py +++ b/backend/src/kitconcept/intranet/services/review.py @@ -11,7 +11,6 @@ from zope.publisher.interfaces import IPublishTraverse - @implementer(IPublishTraverse) class ReviewPost(Service): """@review endpoint.""" diff --git a/docs/docs/developer/reference/api/index.md b/docs/docs/developer/reference/api/index.md index 460179a2..69281841 100644 --- a/docs/docs/developer/reference/api/index.md +++ b/docs/docs/developer/reference/api/index.md @@ -17,4 +17,5 @@ votes feedback byline clm +review ``` diff --git a/docs/docs/developer/reference/api/review.md b/docs/docs/developer/reference/api/review.md new file mode 100644 index 00000000..a084e7ca --- /dev/null +++ b/docs/docs/developer/reference/api/review.md @@ -0,0 +1,104 @@ +--- +myst: + html_meta: + description: "REST API reference for the @review endpoint." + keywords: "API, content, review, reminder, REST, @review" +doc_type: reference +audience: developer +status: draft +last_updated: 2026-04-09 +--- + +# @review Endpoint + +The `@review` endpoint handles approve/delegate/postpone operations on content items with the IContentReview behavior enabled. + +## Actions + +### `POST /@review/approve` + +Marks the content as reviewed and up-to-date. Automatically calculates the next review due date based on the content's review_interval (falling back to the site-wide default interval if not set), and records today as the completion date. + +**Request body:** None required. + +**Effect on content fields:** + +| Field | Value set | +|-------------------------|-----------------------------------------------------| +| `review_status` | "Up-to-date" | +| `review_due_date` | Calculated from `review_interval` (or site default) | +| `review_completed_date` | Today's date | + +**Example:** + +```http +POST /my-page/@review/approve +``` + +*** + +### `POST /@review/delegate` + +Assigns the content review to another user. Optionally attaches a comment. + +**Request body (JSON):** + +| Field | Type | Required | Description | +|------------|--------|----------|--------------------------------------------------------------------------------------------| +| `assignee` | string | Yes | User to assign the review to. Must be a valid value from the `review_assignee` vocabulary. | +| `comment` | string | No | Optional note about the delegation. | + +**Errors:** + +- `400 Bad Request` — if `assignee` is not found in the vocabulary. + +**Example:** + +```http +POST /my-page/@review/delegate +Content-Type: application/json + +{ + "assignee": "jane.doe", + "comment": "Please review the legal section." +} +``` + +*** + +### `POST /@review/postpone` + +Postpones the review to a specified future date. Optionally attaches a comment. Sets the status to "Up-to-date" without recording a completion date. + +**Request body (JSON):** + +| Field | Type | Required | Description | +|------------|------------------------|----------|-------------| +| `due_date` | string (ISO 8601 date) | No | New review due date, e.g. "2026-09-01". If omitted, the existing due date is unchanged. | +| `comment` | string | No | Optional note about why the review was postponed. | + +**Example:** + +```http +POST /my-page/@review/postpone +Content-Type: application/json + +{ + "due_date": "2026-09-01", + "comment": "Waiting for updated policy documents." +} +``` + +*** + +## Error Responses + +| Status | Condition | +|-------------------|------------------------------------------------------------| +| `400 Bad Request` | Unknown action (not approve, delegate, or postpone) | +| `400 Bad Request` | `delegate` called with an `assignee` not in the vocabulary | + +## See Also + +- [How to enable likes](/how-to-guides/engagement/enable-likes) +- [IContentReview behavior](/developer/reference/behaviors/votes) From c6d63a9b812cf8115b3a8f6284e37221a145e38f Mon Sep 17 00:00:00 2001 From: Tishasoumya-02 Date: Tue, 21 Apr 2026 19:07:24 +0530 Subject: [PATCH 69/95] i18n --- .../locales/de/LC_MESSAGES/volto.po | 116 +++ .../locales/en/LC_MESSAGES/volto.po | 116 +++ .../locales/es/LC_MESSAGES/volto.po | 116 +++ .../locales/pt_BR/LC_MESSAGES/volto.po | 116 +++ .../kitconcept-intranet/locales/volto.pot | 118 ++- .../news/reviewSidebar.feature | 1 + .../kitconcept-intranet/src/actions/index.js | 2 + .../src/actions/review/review.js | 31 + .../src/components/Sidebar/DelegateReview.jsx | 155 ++++ .../src/components/Sidebar/PostponeReview.jsx | 163 ++++ .../src/components/Sidebar/ReviewSidebar.jsx | 173 +++++ .../src/components/Toolbar/DocumentReview.jsx | 117 +++ .../src/constants/ActionTypes.js | 3 + .../volto/components/manage/Toolbar/More.jsx | 504 ++++++++++++ .../components/manage/Toolbar/Toolbar.jsx | 716 ++++++++++++++++++ .../src/reducers/review/review.js | 91 +++ .../kitconcept-intranet/src/theme/_main.scss | 1 + .../src/theme/reviewSidebar.scss | 46 ++ frontend/pnpm-lock.yaml | 37 + 19 files changed, 2621 insertions(+), 1 deletion(-) create mode 100644 frontend/packages/kitconcept-intranet/news/reviewSidebar.feature create mode 100644 frontend/packages/kitconcept-intranet/src/actions/review/review.js create mode 100644 frontend/packages/kitconcept-intranet/src/components/Sidebar/DelegateReview.jsx create mode 100644 frontend/packages/kitconcept-intranet/src/components/Sidebar/PostponeReview.jsx create mode 100644 frontend/packages/kitconcept-intranet/src/components/Sidebar/ReviewSidebar.jsx create mode 100644 frontend/packages/kitconcept-intranet/src/components/Toolbar/DocumentReview.jsx create mode 100644 frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Toolbar/More.jsx create mode 100644 frontend/packages/kitconcept-intranet/src/customizations/volto/components/manage/Toolbar/Toolbar.jsx create mode 100644 frontend/packages/kitconcept-intranet/src/reducers/review/review.js create mode 100644 frontend/packages/kitconcept-intranet/src/theme/reviewSidebar.scss diff --git a/frontend/packages/kitconcept-intranet/locales/de/LC_MESSAGES/volto.po b/frontend/packages/kitconcept-intranet/locales/de/LC_MESSAGES/volto.po index 40e25293..2490606b 100644 --- a/frontend/packages/kitconcept-intranet/locales/de/LC_MESSAGES/volto.po +++ b/frontend/packages/kitconcept-intranet/locales/de/LC_MESSAGES/volto.po @@ -16,6 +16,11 @@ msgstr "" msgid "Address" msgstr "Adresse" +#. Default: "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +#: components/Sidebar/DelegateReview +msgid "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +msgstr "" + #. Default: "Bio" #: components/theme/PersonView msgid "Bio" @@ -31,17 +36,54 @@ msgstr "Gebäude" msgid "By" msgstr "Von" +#. Default: "Cancel" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Cancel" +msgstr "" + +#. Default: "Comment" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Comment" +msgstr "" + #. Default: "Contact" #: components/Blocks/EventMetadata/View #: components/theme/PersonView msgid "Contact" msgstr "Kontakt" +#. Default: "Content marked as reviewed" +#: components/Toolbar/DocumentReview +msgid "Content marked as reviewed" +msgstr "" + #. Default: "Created On" #: components/ContentInteractions/ContentInteractions msgid "Created On" msgstr "Erstellt am" +#. Default: "Delegate" +#: components/Sidebar/DelegateReview +msgid "Delegate" +msgstr "" + +#. Default: "Delegate Review" +#: components/Sidebar/DelegateReview +msgid "Delegate Review" +msgstr "" + +#. Default: "Delegate Review Update" +#: components/Toolbar/DocumentReview +msgid "Delegate Review Update" +msgstr "" + +#. Default: "Document Review" +#: components/Toolbar/DocumentReview +msgid "Document Review" +msgstr "" + #. Default: "E-mail" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -64,6 +106,11 @@ msgstr "Ende" msgid "Error" msgstr "Fehler" +#. Default: "Expand sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Expand sidebar" +msgstr "" + #. Default: "Feedback" #: components/FeedBackForm/FeedBackForm msgid "Feedback" @@ -109,6 +156,11 @@ msgstr "Ungültige URL" msgid "Last Modified On" msgstr "Zuletzt geändert am" +#. Default: "Last updated" +#: components/Toolbar/DocumentReview +msgid "Last updated" +msgstr "" + #. Default: "List with date" #: index msgid "List with date" @@ -119,11 +171,26 @@ msgstr "Liste mit Datum" msgid "Location" msgstr "Standort" +#. Default: "Mark Changes Required" +#: components/Toolbar/DocumentReview +msgid "Mark Changes Required" +msgstr "" + +#. Default: "Mark as Reviewed" +#: components/Toolbar/DocumentReview +msgid "Mark as Reviewed" +msgstr "" + #. Default: "Name" #: components/FeedBackForm/FeedBackForm msgid "Name" msgstr "Name" +#. Default: "Next review" +#: components/Toolbar/DocumentReview +msgid "Next review" +msgstr "" + #. Default: "Only internal e-mail addresses are permitted." #: components/FeedBackForm/FeedBackForm msgid "Only internal e-mail addresses are permitted." @@ -151,11 +218,42 @@ msgstr "Bitte geben Sie Ihre geschäftliche E-Mail-Adresse ein." msgid "Please fill in the Feedback field" msgstr "Bitte füllen Sie das Feld „Feedback“ aus." +#. Default: "Postpone" +#: components/Sidebar/PostponeReview +msgid "Postpone" +msgstr "" + +#. Default: "Postpone Review" +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview +msgid "Postpone Review" +msgstr "" + +#. Default: "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +#: components/Sidebar/PostponeReview +msgid "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +msgstr "" + #. Default: "Responsibilities" #: index msgid "Responsibilities" msgstr "Zuständigkeiten" +#. Default: "Review" +#: components/Sidebar/ReviewSidebar +msgid "Review" +msgstr "" + +#. Default: "Review has been successfully delegated" +#: components/Sidebar/DelegateReview +msgid "Review has been successfully delegated" +msgstr "" + +#. Default: "Review has been successfully postponed" +#: components/Sidebar/PostponeReview +msgid "Review has been successfully postponed" +msgstr "" + #. Default: "Room" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -177,6 +275,11 @@ msgstr "Ich möchte diesen Intranet-Inhalt mit Ihnen teilen:" msgid "Share this page via email" msgstr "Seite per E-Mail teilen" +#. Default: "Shrink sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Shrink sidebar" +msgstr "" + #. Default: "Site" #: components/theme/PersonView msgid "Site" @@ -209,9 +312,17 @@ msgstr "Betreff Seite/URL" #. Default: "Success" #: components/FeedBackForm/FeedBackForm +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview msgid "Success" msgstr "Erfolg" +#. Default: "The check interval is set to %{interval}. To change it, you must edit the content." +#: components/Sidebar/PostponeReview +msgid "The check interval is set to %{interval}. To change it, you must edit the content." +msgstr "" + #. Default: "The displayed content is tailored to your organizational unit and location." #: slots/ListingDisclaimer/ListingDisclaimer msgid "The displayed content is tailored to your organizational unit and location." @@ -222,6 +333,11 @@ msgstr "Die angezeigten Inhalte wurden anhand Deiner Organisationseinheit und De msgid "To submit a Like you must be logged in" msgstr "Um ein „Gefällt mir“ abzugeben, müssen Sie angemeldet sein." +#. Default: "Use preset review interval" +#: components/Sidebar/PostponeReview +msgid "Use preset review interval" +msgstr "" + #. Default: "Website" #: components/Blocks/EventMetadata/View msgid "Website" diff --git a/frontend/packages/kitconcept-intranet/locales/en/LC_MESSAGES/volto.po b/frontend/packages/kitconcept-intranet/locales/en/LC_MESSAGES/volto.po index 8b80d20d..d0084160 100644 --- a/frontend/packages/kitconcept-intranet/locales/en/LC_MESSAGES/volto.po +++ b/frontend/packages/kitconcept-intranet/locales/en/LC_MESSAGES/volto.po @@ -16,6 +16,11 @@ msgstr "" msgid "Address" msgstr "Adresse" +#. Default: "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +#: components/Sidebar/DelegateReview +msgid "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +msgstr "" + #. Default: "Bio" #: components/theme/PersonView msgid "Bio" @@ -31,17 +36,54 @@ msgstr "" msgid "By" msgstr "" +#. Default: "Cancel" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Cancel" +msgstr "" + +#. Default: "Comment" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Comment" +msgstr "" + #. Default: "Contact" #: components/Blocks/EventMetadata/View #: components/theme/PersonView msgid "Contact" msgstr "Kontakt" +#. Default: "Content marked as reviewed" +#: components/Toolbar/DocumentReview +msgid "Content marked as reviewed" +msgstr "" + #. Default: "Created On" #: components/ContentInteractions/ContentInteractions msgid "Created On" msgstr "" +#. Default: "Delegate" +#: components/Sidebar/DelegateReview +msgid "Delegate" +msgstr "" + +#. Default: "Delegate Review" +#: components/Sidebar/DelegateReview +msgid "Delegate Review" +msgstr "" + +#. Default: "Delegate Review Update" +#: components/Toolbar/DocumentReview +msgid "Delegate Review Update" +msgstr "" + +#. Default: "Document Review" +#: components/Toolbar/DocumentReview +msgid "Document Review" +msgstr "" + #. Default: "E-mail" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -64,6 +106,11 @@ msgstr "" msgid "Error" msgstr "" +#. Default: "Expand sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Expand sidebar" +msgstr "" + #. Default: "Feedback" #: components/FeedBackForm/FeedBackForm msgid "Feedback" @@ -109,6 +156,11 @@ msgstr "" msgid "Last Modified On" msgstr "" +#. Default: "Last updated" +#: components/Toolbar/DocumentReview +msgid "Last updated" +msgstr "" + #. Default: "List with date" #: index msgid "List with date" @@ -119,11 +171,26 @@ msgstr "" msgid "Location" msgstr "" +#. Default: "Mark Changes Required" +#: components/Toolbar/DocumentReview +msgid "Mark Changes Required" +msgstr "" + +#. Default: "Mark as Reviewed" +#: components/Toolbar/DocumentReview +msgid "Mark as Reviewed" +msgstr "" + #. Default: "Name" #: components/FeedBackForm/FeedBackForm msgid "Name" msgstr "" +#. Default: "Next review" +#: components/Toolbar/DocumentReview +msgid "Next review" +msgstr "" + #. Default: "Only internal e-mail addresses are permitted." #: components/FeedBackForm/FeedBackForm msgid "Only internal e-mail addresses are permitted." @@ -151,11 +218,42 @@ msgstr "" msgid "Please fill in the Feedback field" msgstr "" +#. Default: "Postpone" +#: components/Sidebar/PostponeReview +msgid "Postpone" +msgstr "" + +#. Default: "Postpone Review" +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview +msgid "Postpone Review" +msgstr "" + +#. Default: "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +#: components/Sidebar/PostponeReview +msgid "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +msgstr "" + #. Default: "Responsibilities" #: index msgid "Responsibilities" msgstr "" +#. Default: "Review" +#: components/Sidebar/ReviewSidebar +msgid "Review" +msgstr "" + +#. Default: "Review has been successfully delegated" +#: components/Sidebar/DelegateReview +msgid "Review has been successfully delegated" +msgstr "" + +#. Default: "Review has been successfully postponed" +#: components/Sidebar/PostponeReview +msgid "Review has been successfully postponed" +msgstr "" + #. Default: "Room" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -177,6 +275,11 @@ msgstr "" msgid "Share this page via email" msgstr "" +#. Default: "Shrink sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Shrink sidebar" +msgstr "" + #. Default: "Site" #: components/theme/PersonView msgid "Site" @@ -209,9 +312,17 @@ msgstr "" #. Default: "Success" #: components/FeedBackForm/FeedBackForm +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview msgid "Success" msgstr "" +#. Default: "The check interval is set to %{interval}. To change it, you must edit the content." +#: components/Sidebar/PostponeReview +msgid "The check interval is set to %{interval}. To change it, you must edit the content." +msgstr "" + #. Default: "The displayed content is tailored to your organizational unit and location." #: slots/ListingDisclaimer/ListingDisclaimer msgid "The displayed content is tailored to your organizational unit and location." @@ -222,6 +333,11 @@ msgstr "" msgid "To submit a Like you must be logged in" msgstr "" +#. Default: "Use preset review interval" +#: components/Sidebar/PostponeReview +msgid "Use preset review interval" +msgstr "" + #. Default: "Website" #: components/Blocks/EventMetadata/View msgid "Website" diff --git a/frontend/packages/kitconcept-intranet/locales/es/LC_MESSAGES/volto.po b/frontend/packages/kitconcept-intranet/locales/es/LC_MESSAGES/volto.po index ea81aa11..d9a72717 100644 --- a/frontend/packages/kitconcept-intranet/locales/es/LC_MESSAGES/volto.po +++ b/frontend/packages/kitconcept-intranet/locales/es/LC_MESSAGES/volto.po @@ -23,6 +23,11 @@ msgstr "" msgid "Address" msgstr "" +#. Default: "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +#: components/Sidebar/DelegateReview +msgid "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +msgstr "" + #. Default: "Bio" #: components/theme/PersonView msgid "Bio" @@ -38,17 +43,54 @@ msgstr "" msgid "By" msgstr "" +#. Default: "Cancel" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Cancel" +msgstr "" + +#. Default: "Comment" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Comment" +msgstr "" + #. Default: "Contact" #: components/Blocks/EventMetadata/View #: components/theme/PersonView msgid "Contact" msgstr "" +#. Default: "Content marked as reviewed" +#: components/Toolbar/DocumentReview +msgid "Content marked as reviewed" +msgstr "" + #. Default: "Created On" #: components/ContentInteractions/ContentInteractions msgid "Created On" msgstr "" +#. Default: "Delegate" +#: components/Sidebar/DelegateReview +msgid "Delegate" +msgstr "" + +#. Default: "Delegate Review" +#: components/Sidebar/DelegateReview +msgid "Delegate Review" +msgstr "" + +#. Default: "Delegate Review Update" +#: components/Toolbar/DocumentReview +msgid "Delegate Review Update" +msgstr "" + +#. Default: "Document Review" +#: components/Toolbar/DocumentReview +msgid "Document Review" +msgstr "" + #. Default: "E-mail" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -71,6 +113,11 @@ msgstr "" msgid "Error" msgstr "" +#. Default: "Expand sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Expand sidebar" +msgstr "" + #. Default: "Feedback" #: components/FeedBackForm/FeedBackForm msgid "Feedback" @@ -116,6 +163,11 @@ msgstr "" msgid "Last Modified On" msgstr "" +#. Default: "Last updated" +#: components/Toolbar/DocumentReview +msgid "Last updated" +msgstr "" + #. Default: "List with date" #: index msgid "List with date" @@ -126,11 +178,26 @@ msgstr "" msgid "Location" msgstr "" +#. Default: "Mark Changes Required" +#: components/Toolbar/DocumentReview +msgid "Mark Changes Required" +msgstr "" + +#. Default: "Mark as Reviewed" +#: components/Toolbar/DocumentReview +msgid "Mark as Reviewed" +msgstr "" + #. Default: "Name" #: components/FeedBackForm/FeedBackForm msgid "Name" msgstr "" +#. Default: "Next review" +#: components/Toolbar/DocumentReview +msgid "Next review" +msgstr "" + #. Default: "Only internal e-mail addresses are permitted." #: components/FeedBackForm/FeedBackForm msgid "Only internal e-mail addresses are permitted." @@ -158,11 +225,42 @@ msgstr "" msgid "Please fill in the Feedback field" msgstr "" +#. Default: "Postpone" +#: components/Sidebar/PostponeReview +msgid "Postpone" +msgstr "" + +#. Default: "Postpone Review" +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview +msgid "Postpone Review" +msgstr "" + +#. Default: "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +#: components/Sidebar/PostponeReview +msgid "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +msgstr "" + #. Default: "Responsibilities" #: index msgid "Responsibilities" msgstr "" +#. Default: "Review" +#: components/Sidebar/ReviewSidebar +msgid "Review" +msgstr "" + +#. Default: "Review has been successfully delegated" +#: components/Sidebar/DelegateReview +msgid "Review has been successfully delegated" +msgstr "" + +#. Default: "Review has been successfully postponed" +#: components/Sidebar/PostponeReview +msgid "Review has been successfully postponed" +msgstr "" + #. Default: "Room" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -184,6 +282,11 @@ msgstr "" msgid "Share this page via email" msgstr "" +#. Default: "Shrink sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Shrink sidebar" +msgstr "" + #. Default: "Site" #: components/theme/PersonView msgid "Site" @@ -216,9 +319,17 @@ msgstr "" #. Default: "Success" #: components/FeedBackForm/FeedBackForm +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview msgid "Success" msgstr "" +#. Default: "The check interval is set to %{interval}. To change it, you must edit the content." +#: components/Sidebar/PostponeReview +msgid "The check interval is set to %{interval}. To change it, you must edit the content." +msgstr "" + #. Default: "The displayed content is tailored to your organizational unit and location." #: slots/ListingDisclaimer/ListingDisclaimer msgid "The displayed content is tailored to your organizational unit and location." @@ -229,6 +340,11 @@ msgstr "" msgid "To submit a Like you must be logged in" msgstr "" +#. Default: "Use preset review interval" +#: components/Sidebar/PostponeReview +msgid "Use preset review interval" +msgstr "" + #. Default: "Website" #: components/Blocks/EventMetadata/View msgid "Website" diff --git a/frontend/packages/kitconcept-intranet/locales/pt_BR/LC_MESSAGES/volto.po b/frontend/packages/kitconcept-intranet/locales/pt_BR/LC_MESSAGES/volto.po index 6a2469ba..c20dd9ea 100644 --- a/frontend/packages/kitconcept-intranet/locales/pt_BR/LC_MESSAGES/volto.po +++ b/frontend/packages/kitconcept-intranet/locales/pt_BR/LC_MESSAGES/volto.po @@ -21,6 +21,11 @@ msgstr "" msgid "Address" msgstr "Endereço" +#. Default: "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +#: components/Sidebar/DelegateReview +msgid "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +msgstr "" + #. Default: "Bio" #: components/theme/PersonView msgid "Bio" @@ -36,17 +41,54 @@ msgstr "Edifício" msgid "By" msgstr "Por" +#. Default: "Cancel" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Cancel" +msgstr "" + +#. Default: "Comment" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Comment" +msgstr "" + #. Default: "Contact" #: components/Blocks/EventMetadata/View #: components/theme/PersonView msgid "Contact" msgstr "Contato" +#. Default: "Content marked as reviewed" +#: components/Toolbar/DocumentReview +msgid "Content marked as reviewed" +msgstr "" + #. Default: "Created On" #: components/ContentInteractions/ContentInteractions msgid "Created On" msgstr "Criado em" +#. Default: "Delegate" +#: components/Sidebar/DelegateReview +msgid "Delegate" +msgstr "" + +#. Default: "Delegate Review" +#: components/Sidebar/DelegateReview +msgid "Delegate Review" +msgstr "" + +#. Default: "Delegate Review Update" +#: components/Toolbar/DocumentReview +msgid "Delegate Review Update" +msgstr "" + +#. Default: "Document Review" +#: components/Toolbar/DocumentReview +msgid "Document Review" +msgstr "" + #. Default: "E-mail" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -69,6 +111,11 @@ msgstr "Término" msgid "Error" msgstr "Erro" +#. Default: "Expand sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Expand sidebar" +msgstr "" + #. Default: "Feedback" #: components/FeedBackForm/FeedBackForm msgid "Feedback" @@ -114,6 +161,11 @@ msgstr "URL inválida" msgid "Last Modified On" msgstr "Última modificação em" +#. Default: "Last updated" +#: components/Toolbar/DocumentReview +msgid "Last updated" +msgstr "" + #. Default: "List with date" #: index msgid "List with date" @@ -124,11 +176,26 @@ msgstr "Lista com data" msgid "Location" msgstr "Local" +#. Default: "Mark Changes Required" +#: components/Toolbar/DocumentReview +msgid "Mark Changes Required" +msgstr "" + +#. Default: "Mark as Reviewed" +#: components/Toolbar/DocumentReview +msgid "Mark as Reviewed" +msgstr "" + #. Default: "Name" #: components/FeedBackForm/FeedBackForm msgid "Name" msgstr "Nome" +#. Default: "Next review" +#: components/Toolbar/DocumentReview +msgid "Next review" +msgstr "" + #. Default: "Only internal e-mail addresses are permitted." #: components/FeedBackForm/FeedBackForm msgid "Only internal e-mail addresses are permitted." @@ -156,11 +223,42 @@ msgstr "Por favor, insira seu endereço de e-mail organizacional." msgid "Please fill in the Feedback field" msgstr "Por favor, preencha o campo de Feedback" +#. Default: "Postpone" +#: components/Sidebar/PostponeReview +msgid "Postpone" +msgstr "" + +#. Default: "Postpone Review" +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview +msgid "Postpone Review" +msgstr "" + +#. Default: "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +#: components/Sidebar/PostponeReview +msgid "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +msgstr "" + #. Default: "Responsibilities" #: index msgid "Responsibilities" msgstr "Responsabilidades" +#. Default: "Review" +#: components/Sidebar/ReviewSidebar +msgid "Review" +msgstr "" + +#. Default: "Review has been successfully delegated" +#: components/Sidebar/DelegateReview +msgid "Review has been successfully delegated" +msgstr "" + +#. Default: "Review has been successfully postponed" +#: components/Sidebar/PostponeReview +msgid "Review has been successfully postponed" +msgstr "" + #. Default: "Room" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -182,6 +280,11 @@ msgstr "Gostaria de compartilhar este conteúdo da intranet com você:" msgid "Share this page via email" msgstr "Compartilhe esta página por e-mail" +#. Default: "Shrink sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Shrink sidebar" +msgstr "" + #. Default: "Site" #: components/theme/PersonView msgid "Site" @@ -214,9 +317,17 @@ msgstr "Página/URL do assunto" #. Default: "Success" #: components/FeedBackForm/FeedBackForm +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview msgid "Success" msgstr "Sucesso" +#. Default: "The check interval is set to %{interval}. To change it, you must edit the content." +#: components/Sidebar/PostponeReview +msgid "The check interval is set to %{interval}. To change it, you must edit the content." +msgstr "" + #. Default: "The displayed content is tailored to your organizational unit and location." #: slots/ListingDisclaimer/ListingDisclaimer msgid "The displayed content is tailored to your organizational unit and location." @@ -227,6 +338,11 @@ msgstr "O conteúdo exibido é personalizado para sua área organizacional e loc msgid "To submit a Like you must be logged in" msgstr "Para curtir, você precisa estar logado" +#. Default: "Use preset review interval" +#: components/Sidebar/PostponeReview +msgid "Use preset review interval" +msgstr "" + #. Default: "Website" #: components/Blocks/EventMetadata/View msgid "Website" diff --git a/frontend/packages/kitconcept-intranet/locales/volto.pot b/frontend/packages/kitconcept-intranet/locales/volto.pot index 91676eda..8d90b448 100644 --- a/frontend/packages/kitconcept-intranet/locales/volto.pot +++ b/frontend/packages/kitconcept-intranet/locales/volto.pot @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: Plone\n" -"POT-Creation-Date: 2026-01-23T14:38:56.135Z\n" +"POT-Creation-Date: 2026-04-21T13:36:59.391Z\n" "Last-Translator: Plone i18n \n" "Language-Team: Plone i18n \n" "Content-Type: text/plain; charset=utf-8\n" @@ -18,6 +18,11 @@ msgstr "" msgid "Address" msgstr "" +#. Default: "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +#: components/Sidebar/DelegateReview +msgid "Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task" +msgstr "" + #. Default: "Bio" #: components/theme/PersonView msgid "Bio" @@ -33,17 +38,54 @@ msgstr "" msgid "By" msgstr "" +#. Default: "Cancel" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Cancel" +msgstr "" + +#. Default: "Comment" +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +msgid "Comment" +msgstr "" + #. Default: "Contact" #: components/Blocks/EventMetadata/View #: components/theme/PersonView msgid "Contact" msgstr "" +#. Default: "Content marked as reviewed" +#: components/Toolbar/DocumentReview +msgid "Content marked as reviewed" +msgstr "" + #. Default: "Created On" #: components/ContentInteractions/ContentInteractions msgid "Created On" msgstr "" +#. Default: "Delegate" +#: components/Sidebar/DelegateReview +msgid "Delegate" +msgstr "" + +#. Default: "Delegate Review" +#: components/Sidebar/DelegateReview +msgid "Delegate Review" +msgstr "" + +#. Default: "Delegate Review Update" +#: components/Toolbar/DocumentReview +msgid "Delegate Review Update" +msgstr "" + +#. Default: "Document Review" +#: components/Toolbar/DocumentReview +msgid "Document Review" +msgstr "" + #. Default: "E-mail" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -66,6 +108,11 @@ msgstr "" msgid "Error" msgstr "" +#. Default: "Expand sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Expand sidebar" +msgstr "" + #. Default: "Feedback" #: components/FeedBackForm/FeedBackForm msgid "Feedback" @@ -111,6 +158,11 @@ msgstr "" msgid "Last Modified On" msgstr "" +#. Default: "Last updated" +#: components/Toolbar/DocumentReview +msgid "Last updated" +msgstr "" + #. Default: "List with date" #: index msgid "List with date" @@ -121,11 +173,26 @@ msgstr "" msgid "Location" msgstr "" +#. Default: "Mark Changes Required" +#: components/Toolbar/DocumentReview +msgid "Mark Changes Required" +msgstr "" + +#. Default: "Mark as Reviewed" +#: components/Toolbar/DocumentReview +msgid "Mark as Reviewed" +msgstr "" + #. Default: "Name" #: components/FeedBackForm/FeedBackForm msgid "Name" msgstr "" +#. Default: "Next review" +#: components/Toolbar/DocumentReview +msgid "Next review" +msgstr "" + #. Default: "Only internal e-mail addresses are permitted." #: components/FeedBackForm/FeedBackForm msgid "Only internal e-mail addresses are permitted." @@ -153,11 +220,42 @@ msgstr "" msgid "Please fill in the Feedback field" msgstr "" +#. Default: "Postpone" +#: components/Sidebar/PostponeReview +msgid "Postpone" +msgstr "" + +#. Default: "Postpone Review" +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview +msgid "Postpone Review" +msgstr "" + +#. Default: "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +#: components/Sidebar/PostponeReview +msgid "Postpone the next review date for this content. Select a new date or a time period after which you would like to be reminded again." +msgstr "" + #. Default: "Responsibilities" #: index msgid "Responsibilities" msgstr "" +#. Default: "Review" +#: components/Sidebar/ReviewSidebar +msgid "Review" +msgstr "" + +#. Default: "Review has been successfully delegated" +#: components/Sidebar/DelegateReview +msgid "Review has been successfully delegated" +msgstr "" + +#. Default: "Review has been successfully postponed" +#: components/Sidebar/PostponeReview +msgid "Review has been successfully postponed" +msgstr "" + #. Default: "Room" #: components/Summary/PersonSummary #: components/theme/PersonView @@ -179,6 +277,11 @@ msgstr "" msgid "Share this page via email" msgstr "" +#. Default: "Shrink sidebar" +#: components/Sidebar/ReviewSidebar +msgid "Shrink sidebar" +msgstr "" + #. Default: "Site" #: components/theme/PersonView msgid "Site" @@ -211,9 +314,17 @@ msgstr "" #. Default: "Success" #: components/FeedBackForm/FeedBackForm +#: components/Sidebar/DelegateReview +#: components/Sidebar/PostponeReview +#: components/Toolbar/DocumentReview msgid "Success" msgstr "" +#. Default: "The check interval is set to %{interval}. To change it, you must edit the content." +#: components/Sidebar/PostponeReview +msgid "The check interval is set to %{interval}. To change it, you must edit the content." +msgstr "" + #. Default: "The displayed content is tailored to your organizational unit and location." #: slots/ListingDisclaimer/ListingDisclaimer msgid "The displayed content is tailored to your organizational unit and location." @@ -224,6 +335,11 @@ msgstr "" msgid "To submit a Like you must be logged in" msgstr "" +#. Default: "Use preset review interval" +#: components/Sidebar/PostponeReview +msgid "Use preset review interval" +msgstr "" + #. Default: "Website" #: components/Blocks/EventMetadata/View msgid "Website" diff --git a/frontend/packages/kitconcept-intranet/news/reviewSidebar.feature b/frontend/packages/kitconcept-intranet/news/reviewSidebar.feature new file mode 100644 index 00000000..ea68e022 --- /dev/null +++ b/frontend/packages/kitconcept-intranet/news/reviewSidebar.feature @@ -0,0 +1 @@ +Add review sidebar @Tishasoumya-02 \ No newline at end of file diff --git a/frontend/packages/kitconcept-intranet/src/actions/index.js b/frontend/packages/kitconcept-intranet/src/actions/index.js index a0e0a396..e8e69e23 100644 --- a/frontend/packages/kitconcept-intranet/src/actions/index.js +++ b/frontend/packages/kitconcept-intranet/src/actions/index.js @@ -13,3 +13,5 @@ export { submitFeedbackContactForm } from './emailSend'; export { toggleLike } from './likes/likes'; +export { postponeReview, delegateReview, approveReview } from './review/review'; + diff --git a/frontend/packages/kitconcept-intranet/src/actions/review/review.js b/frontend/packages/kitconcept-intranet/src/actions/review/review.js new file mode 100644 index 00000000..3546f404 --- /dev/null +++ b/frontend/packages/kitconcept-intranet/src/actions/review/review.js @@ -0,0 +1,31 @@ +import { POSTPONE_REVIEW, DELEGATE_REVIEW , APPROVE_REVIEW} from '../../constants/ActionTypes'; + +export function postponeReview(url, data) { + return { + type: POSTPONE_REVIEW, + request: { + op: 'post', + path: `${url}/@review/postpone`, + data, + }, + }; +} +export function delegateReview(url, data) { + return { + type: DELEGATE_REVIEW, + request: { + op: 'post', + path: `${url}/@review/delegate`, + data, + }, + }; +} +export function approveReview(url) { + return { + type: 'APPROVE_REVIEW', + request: { + op: 'post', + path: `${url}/@review/approve`, + }, + }; +} diff --git a/frontend/packages/kitconcept-intranet/src/components/Sidebar/DelegateReview.jsx b/frontend/packages/kitconcept-intranet/src/components/Sidebar/DelegateReview.jsx new file mode 100644 index 00000000..cad003c5 --- /dev/null +++ b/frontend/packages/kitconcept-intranet/src/components/Sidebar/DelegateReview.jsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { TextArea, TextField, Label } from 'react-aria-components'; +import { useIntl, defineMessages } from 'react-intl'; +import { + Accordion, + Button, + Container as SemanticContainer, + Form as UiForm, + Message, + Segment, + Tab, +} from 'semantic-ui-react'; +import { toast } from 'react-toastify'; + +import { flattenToAppURL } from '@plone/volto/helpers/Url/Url'; +import SelectAutoComplete from '@plone/volto/components/manage/Widgets/SelectAutoComplete'; +import { delegateReview } from '../../actions'; +import Toast from '@plone/volto/components/manage/Toast/Toast'; + +const messages = defineMessages({ + comment: { + id: 'Comment', + defaultMessage: 'Comment', + }, + delegateReview: { + id: 'Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task', + defaultMessage: + 'Assign the view or update of this content to another person. Select the responsible person and provide a short description of the task', + }, + delegateTitle: { + id: 'Delegate Review', + defaultMessage: 'Delegate Review', + }, + cancel: { + id: 'Cancel', + defaultMessage: 'Cancel', + }, + delegateButton: { + id: 'Delegate', + defaultMessage: 'Delegate', + }, + success: { + id: 'Success', + defaultMessage: 'Success', + }, + messageDelegate: { + id: 'Review has been successfully delegated', + defaultMessage: 'Review has been successfully delegated', + }, +}); +const englishPlaceholder = + 'If you wish, you can leave a comment for the person incharge here'; +const germanPlaceholder = + 'Wenn Sie möchten, können Sie hier einen Kommentar für die verantwortliche Person hinterlassen'; +const DelegateReview = (props) => { + const { onClose } = props; + const dispatch = useDispatch(); + const content = useSelector((state) => state.content.data); + const [assignee, setAssignee] = useState(''); + const [comment, setComment] = useState(''); + + const intl = useIntl(); + const { locale } = useIntl(); + const handleSubmit = () => { + const data = {}; + if (comment) data.comment = comment; + if (assignee) data.assignee = assignee; + if (Object.keys(data).length === 0) return; + data?.assignee && + dispatch(delegateReview(flattenToAppURL(content['@id']), data)).then( + () => { + onClose(); + toast.success( + , + ); + }, + ); + }; + return ( + { + handleSubmit(); + }} + > + +
+ + {intl.formatMessage(messages.delegateTitle)} + + + +

{intl.formatMessage(messages.delegateReview)}

+ + setAssignee(value)} + wrapped={true} + /> + + +