diff --git a/admin/src/main/resources/i18n/fr.json b/admin/src/main/resources/i18n/fr.json
index e9ec5d7ab6..19f6aa3398 100644
--- a/admin/src/main/resources/i18n/fr.json
+++ b/admin/src/main/resources/i18n/fr.json
@@ -25,6 +25,7 @@
"actualites.infos.list": "Actualités : lister les actualités",
"actualites.threads.list": "Actualités : lister les fils d'actualités",
"actualites.view": "Actualités : accéder au service actualités",
+ "actualites.genai.falc": "Utiliser la simplification de texte (FALC)",
"add.child": "Ajouter un enfant",
"add.class": "Ajouter une classe",
"add.group": "Ajouter un groupe",
@@ -679,6 +680,17 @@
"management.structure.informations.attach.parent.error.title": "Erreur d'ajout",
"management.structure.informations.attach.parent.success.content": "Cette structure a bien été attachée à une structure parente",
"management.structure.informations.attach.parent.success.title": "Structure parente attachée",
+ "management.structure.informations.communications.title": "Droits de communications",
+ "management.structure.informations.communications.description": "Ré-initialisation des droits de communications de l'établissement",
+ "management.structure.informations.communications.reset.action": "Ré-initialiser",
+ "management.structure.informations.communications.warning.title": "Réinitialisation",
+ "management.structure.informations.communications.warning.content": "La réinitialisation des droits de communication est une action irréversible. Elle supprimera les modifications apportées aux règles de communication entre les différents groupe (sauf groupes manuels) de votre établissement pour remettre la configuration par défaut.",
+ "management.structure.informations.communications.warning.continue": "Continuer",
+ "management.structure.informations.communications.confirm.content": "Êtes-vous sur de vouloir réinitialiser les règles de communications",
+ "management.structure.informations.communications.notify.success.content" : "Les règles de communications ont bien été réinitialisées",
+ "management.structure.informations.communications.notify.success.title": "Réinitialisation",
+ "management.structure.informations.communications.notify.error.content" : "Les règles de communications n'ont pas pu être réinitialisées",
+ "management.structure.informations.communications.notify.error.title": "Erreur de réinitialisation",
"management.structure.informations.detach.parent.error.content": "Cette structure n'a pas pu être détachée de son parent",
"management.structure.informations.detach.parent.error.title": "Erreur lors de la suppression",
"management.structure.informations.detach.parent.success.content": "Cette structure a bien été détachée de son parent",
diff --git a/admin/src/main/ts/package.json b/admin/src/main/ts/package.json
index db83a9459d..12f6edc227 100644
--- a/admin/src/main/ts/package.json
+++ b/admin/src/main/ts/package.json
@@ -34,9 +34,9 @@
"font-awesome": "4.7.0",
"jquery": "^3.4.1",
"ngx-infinite-scroll": "14.0.1",
- "ngx-ode-core": "dev",
- "ngx-ode-sijil": "dev",
- "ngx-ode-ui": "dev",
+ "ngx-ode-core": "develop-b2school",
+ "ngx-ode-sijil": "develop-b2school",
+ "ngx-ode-ui": "develop-b2school",
"ngx-trumbowyg": "^6.0.7",
"noty": "2.4.1",
"reflect-metadata": "0.1.10",
@@ -72,4 +72,4 @@
"typescript": "~4.6.4",
"webpack": "^5.70.0"
}
-}
\ No newline at end of file
+}
diff --git a/admin/src/main/ts/src/app/communication/communication-rules.service.ts b/admin/src/main/ts/src/app/communication/communication-rules.service.ts
index 42b605830b..0bf9994b1e 100644
--- a/admin/src/main/ts/src/app/communication/communication-rules.service.ts
+++ b/admin/src/main/ts/src/app/communication/communication-rules.service.ts
@@ -185,6 +185,10 @@ export class CommunicationRulesService {
.filter(group => !!group)
.filter(group => group.id === groupId);
}
+
+ public resetCommunication(structureId: string): Observable {
+ return this.http.post(`/communication/rules/${structureId}/reset`, null);
+ }
}
export interface BidirectionalCommunicationRules {
diff --git a/admin/src/main/ts/src/app/management/management.module.ts b/admin/src/main/ts/src/app/management/management.module.ts
index 62fd09fdc1..f7a3934ae9 100644
--- a/admin/src/main/ts/src/app/management/management.module.ts
+++ b/admin/src/main/ts/src/app/management/management.module.ts
@@ -46,6 +46,7 @@ import { MatDialogModule } from '@angular/material/dialog';
import { StructureUserPositionsComponent } from './structure-user-positions/structure-user-positions.component';
import { SharedModule } from '../_shared/shared.module';
import { ConfigResolver } from '../core/resolvers/config.resolver';
+import {CommunicationRulesService} from "../communication/communication-rules.service";
@NgModule({
imports: [
@@ -117,7 +118,8 @@ import { ConfigResolver } from '../core/resolvers/config.resolver';
SubjectsService,
CalendarService,
ImportEDTReportsService,
- SubjectsGuardService
+ SubjectsGuardService,
+ CommunicationRulesService
]
})
export class ManagementModule {}
diff --git a/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.html b/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.html
index 729da4d8af..db68cdc788 100644
--- a/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.html
+++ b/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.html
@@ -320,3 +320,38 @@
+
+
+
+
+ management.structure.informations.communications.title
+
+
management.structure.informations.communications.description
+
+
+
+ management.structure.informations.communications.warning.content
+
+
+
+
+ management.structure.informations.communications.confirm.content
+
+
+
+
diff --git a/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.scss b/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.scss
index 23e20b6b15..e418690e64 100644
--- a/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.scss
+++ b/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.scss
@@ -3,6 +3,11 @@
justify-content: flex-end;
}
+.action-communication {
+ display: flex;
+ margin-top: 1em;
+}
+
input[type="checkbox"],
button.cancel,
ode-message-sticker,
diff --git a/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.ts b/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.ts
index 1e3a0e8ada..e8a9c18324 100644
--- a/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.ts
+++ b/admin/src/main/ts/src/app/management/structure-informations/structure-informations.component.ts
@@ -11,6 +11,7 @@ import { BundlesService } from 'ngx-ode-sijil';
import { Context } from 'src/app/core/store/mappings/context';
import { Config } from 'src/app/core/resolvers/Config';
import { HttpClient } from '@angular/common/http';
+import {CommunicationRulesService} from "../../communication/communication-rules.service";
class UserMetric {
active: number = 0;
@@ -68,6 +69,8 @@ export class StructureInformationsComponent extends OdeComponent implements OnIn
public showMfaWarningLightbox = false;
public isADMC: boolean = false;
public showSettingsLightbox = false;
+ public showResetCommunicationWarningLightBox = false;
+ public showResetCommunicationConfirmLightBox = false;
private config: Config;
@@ -78,7 +81,8 @@ export class StructureInformationsComponent extends OdeComponent implements OnIn
private infoService: StructureInformationsService,
private notify: NotifyService,
private bundles: BundlesService,
- private http: HttpClient) {
+ private http: HttpClient,
+ private communicationService: CommunicationRulesService) {
super(injector);
}
@@ -267,6 +271,8 @@ export class StructureInformationsComponent extends OdeComponent implements OnIn
closeLightbox(): void
{
this.showSettingsLightbox = false;
+ this.showResetCommunicationConfirmLightBox = false;
+ this.showResetCommunicationWarningLightBox = false;
this.changeDetector.markForCheck();
}
@@ -277,4 +283,18 @@ export class StructureInformationsComponent extends OdeComponent implements OnIn
return this.config['allow-adml-structure-name-change'];
}
+ openConfirmResetConfirmation(): void {
+ this.closeLightbox();
+ this.showResetCommunicationConfirmLightBox = true;
+ }
+
+ resetCommunicationRules(): void {
+ this.closeLightbox();
+ this.communicationService.resetCommunication(this.structure._id).subscribe(
+ {
+ next: (data) => this.notify.success("management.structure.informations.communications.notify.success.content", "management.structure.informations.communications.notify.success.title"),
+ error: (error) => this.notify.notify("management.structure.informations.communications.notify.error.content", "management.structure.informations.communications.notify.error.title", error, "error")
+ });
+ }
+
}
diff --git a/app-registry/src/main/java/org/entcore/registry/services/impl/DefaultWidgetService.java b/app-registry/src/main/java/org/entcore/registry/services/impl/DefaultWidgetService.java
index 64b6a85ad9..fa44a6c643 100644
--- a/app-registry/src/main/java/org/entcore/registry/services/impl/DefaultWidgetService.java
+++ b/app-registry/src/main/java/org/entcore/registry/services/impl/DefaultWidgetService.java
@@ -22,10 +22,9 @@
import static fr.wseduc.webutils.Utils.defaultValidationParamsNull;
import static org.entcore.common.neo4j.Neo4jResult.validEmptyHandler;
+import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
-import java.util.function.Function;
-import java.util.stream.Collector;
import org.entcore.common.neo4j.Neo4j;
import org.entcore.common.neo4j.Neo4jResult;
@@ -235,20 +234,30 @@ public void massAuthorize(String widgetId, String structureId, List prof
if(structureId == null || structureId .trim().isEmpty() ||
widgetId == null || widgetId.trim().isEmpty() || profiles == null || profiles.isEmpty()){
handler.handle(new Either.Left("invalid.parameters"));
+ return;
}
- String query =
+ final boolean includeAdml = profiles.contains("AdminLocal");
+ final List filteredProfiles = includeAdml ? new ArrayList<>(profiles) : profiles;
+ if (includeAdml) filteredProfiles.remove("AdminLocal");
+
+ JsonObject params = new JsonObject()
+ .put("widgetId", widgetId)
+ .put("structureId", structureId);
+
+ String queryAdml =
+ "MATCH (w:Widget {id: {widgetId}}), " +
+ "(parentStructure:Structure {id: {structureId}})<-[:HAS_ATTACHMENT*0..]-(s:Structure)<-[:DEPENDS]-(fg:FunctionGroup) " +
+ "WHERE fg.filter = 'AdminLocal' AND NOT(fg-[:AUTHORIZED]->w) " +
+ "CREATE UNIQUE fg-[:AUTHORIZED]->w";
+
+ String queryProfile =
"MATCH (w:Widget {id: {widgetId}}), " +
"(parentStructure:Structure {id: {structureId}})<-[:HAS_ATTACHMENT*0..]-(s:Structure)<-[:DEPENDS]-(g:ProfileGroup)-[:HAS_PROFILE]->(p:Profile) " +
"WHERE p.name IN {profiles} AND NOT(g-[:AUTHORIZED]->w) " +
"CREATE UNIQUE g-[:AUTHORIZED]->w";
- JsonObject params = new JsonObject()
- .put("widgetId", widgetId)
- .put("structureId", structureId)
- .put("profiles", new JsonArray(profiles));
-
- neo.execute(query, params, validEmptyHandler(handler));
+ executeMassDispatch(includeAdml, filteredProfiles, params, queryAdml, queryProfile, handler);
}
@Override
@@ -256,20 +265,30 @@ public void massUnauthorize(String widgetId, String structureId, List pr
if(structureId == null || structureId .trim().isEmpty() ||
widgetId == null || widgetId.trim().isEmpty() || profiles == null || profiles.isEmpty()){
handler.handle(new Either.Left("invalid.parameters"));
+ return;
}
- String query =
+ final boolean includeAdml = profiles.contains("AdminLocal");
+ final List filteredProfiles = includeAdml ? new ArrayList<>(profiles) : profiles;
+ if (includeAdml) filteredProfiles.remove("AdminLocal");
+
+ JsonObject params = new JsonObject()
+ .put("widgetId", widgetId)
+ .put("structureId", structureId);
+
+ String queryAdml =
+ "MATCH (parentStructure:Structure {id: {structureId}})<-[:HAS_ATTACHMENT*0..]-(s:Structure)<-[:DEPENDS]-(fg:FunctionGroup), " +
+ "fg-[rel:AUTHORIZED]->(w:Widget {id: {widgetId}}) " +
+ "WHERE fg.filter = 'AdminLocal' " +
+ "DELETE rel";
+
+ String queryProfile =
"MATCH (parentStructure:Structure {id: {structureId}})<-[:HAS_ATTACHMENT*0..]-(s:Structure)<-[:DEPENDS]-(g:ProfileGroup)-[:HAS_PROFILE]->(p:Profile), " +
"g-[rel:AUTHORIZED]->(w:Widget {id: {widgetId}}) " +
"WHERE p.name IN {profiles} " +
"DELETE rel";
- JsonObject params = new JsonObject()
- .put("widgetId", widgetId)
- .put("structureId", structureId)
- .put("profiles", new JsonArray(profiles));
-
- neo.execute(query, params, validEmptyHandler(handler));
+ executeMassDispatch(includeAdml, filteredProfiles, params, queryAdml, queryProfile, handler);
}
@Override
@@ -277,20 +296,30 @@ public void massSetMandatory(String widgetId, String structureId, List p
if(structureId == null || structureId .trim().isEmpty() ||
widgetId == null || widgetId.trim().isEmpty() || profiles == null || profiles.isEmpty()){
handler.handle(new Either.Left("invalid.parameters"));
+ return;
}
- String query =
+ final boolean includeAdml = profiles.contains("AdminLocal");
+ final List filteredProfiles = includeAdml ? new ArrayList<>(profiles) : profiles;
+ if (includeAdml) filteredProfiles.remove("AdminLocal");
+
+ JsonObject params = new JsonObject()
+ .put("widgetId", widgetId)
+ .put("structureId", structureId);
+
+ String queryAdml =
+ "MATCH (parentStructure:Structure {id: {structureId}})<-[:HAS_ATTACHMENT*0..]-(s:Structure)<-[:DEPENDS]-(fg:FunctionGroup), " +
+ "fg-[rel:AUTHORIZED]->(w:Widget {id: {widgetId}}) " +
+ "WHERE fg.filter = 'AdminLocal' " +
+ "SET rel.mandatory = true";
+
+ String queryProfile =
"MATCH (parentStructure:Structure {id: {structureId}})<-[:HAS_ATTACHMENT*0..]-(s:Structure)<-[:DEPENDS]-(g:ProfileGroup)-[:HAS_PROFILE]->(p:Profile), " +
"g-[rel:AUTHORIZED]->(w:Widget {id: {widgetId}}) " +
"WHERE p.name IN {profiles} " +
"SET rel.mandatory = true";
- JsonObject params = new JsonObject()
- .put("widgetId", widgetId)
- .put("structureId", structureId)
- .put("profiles", new JsonArray(profiles));
-
- neo.execute(query, params, validEmptyHandler(handler));
+ executeMassDispatch(includeAdml, filteredProfiles, params, queryAdml, queryProfile, handler);
}
@Override
@@ -298,21 +327,50 @@ public void massRemoveMandatory(String widgetId, String structureId, List("invalid.parameters"));
+ return;
}
- String query =
+ final boolean includeAdml = profiles.contains("AdminLocal");
+ final List filteredProfiles = includeAdml ? new ArrayList<>(profiles) : profiles;
+ if (includeAdml) filteredProfiles.remove("AdminLocal");
+
+ JsonObject params = new JsonObject()
+ .put("widgetId", widgetId)
+ .put("structureId", structureId);
+
+ String queryAdml =
+ "MATCH (parentStructure:Structure {id: {structureId}})<-[:HAS_ATTACHMENT*0..]-(s:Structure)<-[:DEPENDS]-(fg:FunctionGroup), " +
+ "fg-[rel:AUTHORIZED]->(w:Widget {id: {widgetId}}) " +
+ "WHERE fg.filter = 'AdminLocal' " +
+ "AND COALESCE(w.locked ,false) = false " +
+ "REMOVE rel.mandatory";
+
+ String queryProfile =
"MATCH (parentStructure:Structure {id: {structureId}})<-[:HAS_ATTACHMENT*0..]-(s:Structure)<-[:DEPENDS]-(g:ProfileGroup)-[:HAS_PROFILE]->(p:Profile), " +
"g-[rel:AUTHORIZED]->(w:Widget {id: {widgetId}}) " +
"WHERE p.name IN {profiles} " +
"AND COALESCE(w.locked ,false) = false " +
"REMOVE rel.mandatory";
- JsonObject params = new JsonObject()
- .put("widgetId", widgetId)
- .put("structureId", structureId)
- .put("profiles", new JsonArray(profiles));
+ executeMassDispatch(includeAdml, filteredProfiles, params, queryAdml, queryProfile, handler);
+ }
- neo.execute(query, params, validEmptyHandler(handler));
+ /** Dispatch mass operation: ADML only, profiles only, or both sequentially. */
+ private void executeMassDispatch(boolean includeAdml, List filteredProfiles,
+ JsonObject params, String queryAdml, String queryProfile,
+ Handler> handler) {
+ if (includeAdml && !filteredProfiles.isEmpty()) {
+ params.put("profiles", new JsonArray(filteredProfiles));
+ neo.execute(queryAdml, params, validEmptyHandler(r -> {
+ if (r.isLeft()) handler.handle(r);
+ else neo.execute(queryProfile, params, validEmptyHandler(handler));
+ }));
+ } else if (includeAdml) {
+ neo.execute(queryAdml, params, validEmptyHandler(handler));
+ } else {
+ params.put("profiles", new JsonArray(filteredProfiles));
+ neo.execute(queryProfile, params, validEmptyHandler(handler));
+ }
}
@Override
diff --git a/audience/src/main/java/org/entcore/audience/controllers/AudienceController.java b/audience/src/main/java/org/entcore/audience/controllers/AudienceController.java
index 4e35b51d62..b3bdd9051a 100644
--- a/audience/src/main/java/org/entcore/audience/controllers/AudienceController.java
+++ b/audience/src/main/java/org/entcore/audience/controllers/AudienceController.java
@@ -112,7 +112,7 @@ public void deleteReaction(final HttpServerRequest request) {
final String resourceId = request.getParam("resourceId");
verify(module, resourceType, Collections.singleton(resourceId), request)
.onSuccess(user -> reactionService.deleteReaction(module, resourceType, resourceId, user)
- .onSuccess(e -> Renders.ok(request))
+ .onSuccess(e -> Renders.render(request, e))
.onFailure(th -> {
Renders.log.error("Error while deleting reaction for user and resource " + module + "@" + resourceType + "@" + resourceId, th);
Renders.renderError(request);
diff --git a/audience/src/main/java/org/entcore/audience/reaction/service/ReactionService.java b/audience/src/main/java/org/entcore/audience/reaction/service/ReactionService.java
index 7f7337e34e..ecdd1a56f6 100644
--- a/audience/src/main/java/org/entcore/audience/reaction/service/ReactionService.java
+++ b/audience/src/main/java/org/entcore/audience/reaction/service/ReactionService.java
@@ -12,9 +12,9 @@ public interface ReactionService {
Future getReactionDetails(String module, String resourceType, String resourceId, int page, int size);
- Future upsertReaction(String module, String resourceType, String resourceId, UserInfos userInfos, String reactionType);
+ Future upsertReaction(String module, String resourceType, String resourceId, UserInfos userInfos, String reactionType);
- Future deleteReaction(String module, String resourceType, String resourceId, UserInfos user);
+ Future deleteReaction(String module, String resourceType, String resourceId, UserInfos user);
Future deleteAllReactionsOfUsers(Set userIds);
diff --git a/audience/src/main/java/org/entcore/audience/reaction/service/impl/ReactionServiceImpl.java b/audience/src/main/java/org/entcore/audience/reaction/service/impl/ReactionServiceImpl.java
index 46e401d0d9..b26a97b70f 100644
--- a/audience/src/main/java/org/entcore/audience/reaction/service/impl/ReactionServiceImpl.java
+++ b/audience/src/main/java/org/entcore/audience/reaction/service/impl/ReactionServiceImpl.java
@@ -89,13 +89,23 @@ private Future> getUserDisplayNames(Set userIds) {
}
@Override
- public Future upsertReaction(String module, String resourceType, String resourceId, UserInfos userInfos, String reactionType) {
- return reactionDao.upsertReaction(module, resourceType, resourceId, userInfos.getUserId(), userInfos.getType(), reactionType);
+ public Future upsertReaction(String module, String resourceType, String resourceId, UserInfos userInfos, String reactionType) {
+ Promise reactionsSummaryPromise = Promise.promise();
+ reactionDao.upsertReaction(module, resourceType, resourceId, userInfos.getUserId(), userInfos.getType(), reactionType)
+ .compose(e -> getReactionsSummary(module, resourceType, Collections.singleton(resourceId), userInfos))
+ .onSuccess(reactionsSummaryPromise::complete)
+ .onFailure(reactionsSummaryPromise::fail);
+ return reactionsSummaryPromise.future();
}
@Override
- public Future deleteReaction(String module, String resourceType, String resourceId, UserInfos userInfos) {
- return reactionDao.deleteReaction(module, resourceType, resourceId, userInfos.getUserId());
+ public Future deleteReaction(String module, String resourceType, String resourceId, UserInfos userInfos) {
+ Promise reactionsSummaryPromise = Promise.promise();
+ reactionDao.deleteReaction(module, resourceType, resourceId, userInfos.getUserId())
+ .compose(e -> getReactionsSummary(module, resourceType, Collections.singleton(resourceId), userInfos))
+ .onSuccess(reactionsSummaryPromise::complete)
+ .onFailure(reactionsSummaryPromise::fail);
+ return reactionsSummaryPromise.future();
}
@Override
diff --git a/audience/src/test/java/org/entcore/audience/reaction/service/impl/ReactionServiceImplTest.java b/audience/src/test/java/org/entcore/audience/reaction/service/impl/ReactionServiceImplTest.java
index c1274d2004..c5b8c9f2ac 100644
--- a/audience/src/test/java/org/entcore/audience/reaction/service/impl/ReactionServiceImplTest.java
+++ b/audience/src/test/java/org/entcore/audience/reaction/service/impl/ReactionServiceImplTest.java
@@ -22,10 +22,7 @@
import org.junit.runner.RunWith;
import org.testcontainers.containers.PostgreSQLContainer;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
+import java.util.*;
import java.util.stream.Collectors;
@RunWith(VertxUnitRunner.class)
@@ -337,37 +334,42 @@ public void testUpsertReaction(final TestContext context) {
userInfos3.setLastName("last-name-3");
userInfos3.setType("PERSEDUCNAT");
+ final String resourceId = "r-id-upsert-0";
+
final int page = 1;
final int size = 100;
// User 1 saves their first reaction
- reactionService.upsertReaction("mod-upsert", "rt-upsert", "r-id-upsert-0", userInfos, "reaction-type-1")
- .compose(e -> reactionService.getReactionDetails("mod-upsert", "rt-upsert", "r-id-upsert-0", page, size))
- .onSuccess(reactionDetails -> {
- final int count = reactionDetails.getReactionCounters().getCountByType().get("reaction-type-1");
- context.assertEquals(1, count, "Should have a count of one because we just registered a reaction of this type for this resource");
+ reactionService.upsertReaction("mod-upsert", "rt-upsert", resourceId, userInfos, "reaction-type-1")
+ .onSuccess(reactionsSummary -> {
+ final ReactionsSummaryForResource reactionsSummaryForResource = reactionsSummary.getReactionsByResource().get(resourceId);
+ context.assertEquals(1, reactionsSummaryForResource.getTotalReactionsCounter(), "Should have a count of one because we just registered a reaction for this resource");
+ context.assertEquals("reaction-type-1", reactionsSummaryForResource.getUserReaction(), "Should be reaction-type-1");
})
// User 1 saves another reaction for the same resource
- .compose(e -> reactionService.upsertReaction("mod-upsert", "rt-upsert", "r-id-upsert-0", userInfos, "reaction-type-2"))
- .compose(e -> reactionService.getReactionDetails("mod-upsert", "rt-upsert", "r-id-upsert-0", page, size))
- .onSuccess(e -> {
- final Map counts = e.getReactionCounters().getCountByType();
- context.assertEquals(1, counts.size(), "Should have only one entry for this resource because the only two reactions come from the same user so the latest should replace the other one");
- final int count = counts.get("reaction-type-2");
- context.assertEquals(1, count, "Should have a count of one because we just registered a reaction of this type for this resource");
+ .compose(e -> reactionService.upsertReaction("mod-upsert", "rt-upsert", resourceId, userInfos, "reaction-type-2"))
+ .onSuccess(reactionsSummary -> {
+ final ReactionsSummaryForResource reactionsSummaryForResource = reactionsSummary.getReactionsByResource().get(resourceId);
+ context.assertEquals(1, reactionsSummaryForResource.getTotalReactionsCounter(), "Should still be one for this resource because the user just updated his first reaction");
+ context.assertEquals("reaction-type-2", reactionsSummaryForResource.getUserReaction(), "Should be reaction-type-2 after update");
})
// Another user registers a reaction for the same resource
- .compose(e -> reactionService.upsertReaction("mod-upsert", "rt-upsert", "r-id-upsert-0", userInfos2, "reaction-type-3"))
- .compose(e -> reactionService.getReactionDetails("mod-upsert", "rt-upsert", "r-id-upsert-0", page, size))
- .onSuccess(e -> {
- final Map counts = e.getReactionCounters().getCountByType();
- context.assertEquals(2, counts.size(), "Should have 2 entries, one per user who saved a reaction");
- context.assertEquals(1, counts.get("reaction-type-2"), "User 1 previously registered that reaction so it should appear");
- context.assertEquals(1, counts.get("reaction-type-3"), "User 1 previously registered that reaction so it should appear");
+ .compose(e -> reactionService.upsertReaction("mod-upsert", "rt-upsert", resourceId, userInfos2, "reaction-type-3"))
+ .onSuccess(reactionsSummaryResponse -> {
+ final ReactionsSummaryForResource reactionsSummaryForResource = reactionsSummaryResponse.getReactionsByResource().get(resourceId);
+ context.assertEquals(2, reactionsSummaryForResource.getTotalReactionsCounter(), "Should now be 2 reactions on this resource, from user 1 and user 2");
+ context.assertEquals("reaction-type-3", reactionsSummaryForResource.getUserReaction(), "Should be reaction-type-3 of user 2");
+ context.assertTrue(reactionsSummaryForResource.getReactionTypes().containsAll(Sets.newHashSet("reaction-type-2", "reaction-type-3")), "Should list reaction type 2 and 3 on this resource");
})
// Yet another user registers a reaction for this resource but of a type which has already been registered
- .compose(e -> reactionService.upsertReaction("mod-upsert", "rt-upsert", "r-id-upsert-0", userInfos3, "reaction-type-3"))
- .compose(e -> reactionService.getReactionDetails("mod-upsert", "rt-upsert", "r-id-upsert-0", page, size))
+ .compose(e -> reactionService.upsertReaction("mod-upsert", "rt-upsert", resourceId, userInfos3, "reaction-type-3"))
+ .onSuccess(reactionsSummaryResponse -> {
+ final ReactionsSummaryForResource reactionsSummaryForResource = reactionsSummaryResponse.getReactionsByResource().get(resourceId);
+ context.assertEquals(3, reactionsSummaryForResource.getTotalReactionsCounter(), "Should now be 3 after a third user reacted");
+ context.assertEquals("reaction-type-3", reactionsSummaryForResource.getUserReaction(), "Should be reaction-type-3 of user 3");
+ })
+ // One last check of the reaction details
+ .compose(e -> reactionService.getReactionDetails("mod-upsert", "rt-upsert", resourceId, page, size))
.onSuccess(e -> {
final Map counts = e.getReactionCounters().getCountByType();
context.assertEquals(2, counts.size(), "Should have 2 entries, one per type of reaction");
@@ -397,9 +399,11 @@ public void testDeleteReaction(final TestContext context) {
final int page = 1;
final int size = 100;
- // User saves their first reaction
+ // User saves his first reaction
reactionService.upsertReaction("mod-delete", "rt-delete", "r-id-delete-0", userInfos, "reaction-type-1")
+ // another user saves the same reaction on the same resource
.compose(e -> reactionService.upsertReaction("mod-delete", "rt-delete", "r-id-delete-0", userInfos2, "reaction-type-1"))
+ // verifying the reaction details on the resource
.compose(e -> reactionService.getReactionDetails("mod-delete", "rt-delete", "r-id-delete-0", page, size))
.onSuccess(reactionDetails -> {
context.assertEquals(2, reactionDetails.getReactionCounters().getAllReactionsCounter(), "Should be a total of two reactions for this resource");
@@ -408,7 +412,16 @@ public void testDeleteReaction(final TestContext context) {
context.assertEquals(2, reactionDetails.getUserReactions().size(), "Users' reaction list should contain two reactions");
context.assertTrue(reactionDetails.getUserReactions().stream().map(UserReaction::getUserId).collect(Collectors.toSet()).containsAll(Sets.newHashSet("user-id", "user-id-2")), "Users' reaction list should contain user-id and user-id-2");
})
+ // deleting reaction of first user
.compose(e -> reactionService.deleteReaction("mod-delete", "rt-delete", "r-id-delete-0", userInfos))
+ // verifying reaction summary returned by deletion
+ .onSuccess(e -> {
+ final ReactionsSummaryForResource reactionsSummaryForResource = e.getReactionsByResource().get("r-id-delete-0");
+ context.assertEquals(1, reactionsSummaryForResource.getTotalReactionsCounter(), "Should be a total of one reaction for the resource after deletion of first user's reaction");
+ context.assertNull(reactionsSummaryForResource.getUserReaction(), "Reaction of first user on the resource should be null.");
+ context.assertTrue(Collections.singleton("reaction-type-1").containsAll(reactionsSummaryForResource.getReactionTypes()), "List of reaction types should still contain reaction type of second user's reaction");
+ })
+ // verifying reaction details
.compose(e -> reactionService.getReactionDetails("mod-delete", "rt-delete", "r-id-delete-0", page, size))
.onSuccess(reactionDetails -> {
context.assertEquals(1, reactionDetails.getReactionCounters().getAllReactionsCounter(), "Should be a total of one reaction for this resource, after deleting one reaction");
@@ -417,7 +430,16 @@ public void testDeleteReaction(final TestContext context) {
context.assertEquals(1, reactionDetails.getUserReactions().size(), "List of users' reactions should contain 1 reaction after deletion");
context.assertTrue(reactionDetails.getUserReactions().stream().noneMatch(userReaction -> userReaction.getUserId().equals("user-id")), "Reaction of user-id should have been removed from users' reaction list.");
})
+ // deleting reaction of second user
.compose(e -> reactionService.deleteReaction("mod-delete", "rt-delete", "r-id-delete-0", userInfos2))
+ // verifying reaction summary returned by deletion
+ .onSuccess(e -> {
+ final ReactionsSummaryForResource reactionsSummaryForResource = e.getReactionsByResource().get("r-id-delete-0");
+ context.assertEquals(0, reactionsSummaryForResource.getTotalReactionsCounter(), "Should be a total of 0 reaction for the resource after deletion of second user's reaction");
+ context.assertNull(reactionsSummaryForResource.getUserReaction(), "Reaction of second user on the resource should be null.");
+ context.assertTrue(reactionsSummaryForResource.getReactionTypes().isEmpty(), "List of reaction types should be empty");
+ })
+ // verifying reaction details
.compose(e -> reactionService.getReactionDetails("mod-delete", "rt-delete", "r-id-delete-0", page, size))
.onSuccess(reactionDetails -> {
context.assertTrue(reactionDetails.getReactionCounters().getCountByType().isEmpty(), "Reaction counters by type should be empty, after deleting last reaction");
diff --git a/broker-parent/broker/src/main/java/org/entcore/broker/client/AbstractNATSBrokerClient.java b/broker-parent/broker/src/main/java/org/entcore/broker/client/AbstractNATSBrokerClient.java
index c1bd02959c..3c64e98820 100644
--- a/broker-parent/broker/src/main/java/org/entcore/broker/client/AbstractNATSBrokerClient.java
+++ b/broker-parent/broker/src/main/java/org/entcore/broker/client/AbstractNATSBrokerClient.java
@@ -615,7 +615,7 @@ public Future request(String subject, K message, long timeout) {
private Future request(NatsClient client, String subject, String payload, long timeout) throws Exception {
Promise future = Promise.promise();
final byte[] payloadBytes = payload != null? payload.getBytes(charset) : new byte[0];
- client.request(subject, payloadBytes, Duration.ofMillis(timeout))
+ client.requestWithTimeout(subject, null, payloadBytes, Duration.ofMillis(timeout))
.onSuccess(e -> {
log.debug("Message sent to subject: " + subject);
try {
diff --git a/build.sh b/build.sh
index 816fb15ac0..7ef2c2cfd3 100755
--- a/build.sh
+++ b/build.sh
@@ -232,10 +232,11 @@ localDep () {
}
watch () {
+ docker compose run --rm maven sh -c "mvn $MVN_OPTS help:evaluate -Dexpression=project.groupId -q -DforceStdout -pl $MODULE && echo -n '~$MODULE~' && mvn $MVN_OPTS help:evaluate -Dexpression=project.version -pl $MODULE -q -DforceStdout" > .version.properties
docker compose run --rm \
- -u "$USER_UID:$GROUP_GID" $CI_OPTION \
+ $USER_OPTION \
-v $PWD/../$SPRINGBOARD:/home/node/$SPRINGBOARD \
- node sh -c "npx gulp watch-$MODULE $NODE_OPTION --springboard=../$SPRINGBOARD 2>/dev/null"
+ node sh -c "npx gulp watch-$MODULE $NODE_OPTION --springboard=../$SPRINGBOARD "
rm -f .version.properties
}
diff --git a/common/src/main/java/org/entcore/common/neo4j/StatementsBuilder.java b/common/src/main/java/org/entcore/common/neo4j/StatementsBuilder.java
index 69c7d97850..f92b856108 100644
--- a/common/src/main/java/org/entcore/common/neo4j/StatementsBuilder.java
+++ b/common/src/main/java/org/entcore/common/neo4j/StatementsBuilder.java
@@ -43,6 +43,13 @@ public StatementsBuilder add(String query, JsonObject params) {
}
return this;
}
+ public StatementsBuilder add(StatementsBuilder st) {
+ st.build()
+ .stream()
+ .map(JsonObject.class::cast)
+ .forEach(job -> this.add(job.getString("statement"), job.getJsonObject("parameters")));
+ return this;
+ }
public StatementsBuilder add(String query, Map params) {
return add(query, new JsonObject(params));
diff --git a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java
index 3738d44f7a..57138d0eaf 100644
--- a/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java
+++ b/communication/src/main/java/org/entcore/communication/controllers/CommunicationController.java
@@ -40,6 +40,7 @@
import org.entcore.common.communication.CommunicationUtils;
import org.entcore.common.http.filter.AdminFilter;
import org.entcore.common.http.filter.ResourceFilter;
+import org.entcore.common.http.filter.SuperAdminFilter;
import org.entcore.common.user.UserUtils;
import org.entcore.common.validation.StringValidation;
import org.entcore.communication.filters.CommunicationDiscoverVisibleFilter;
@@ -408,6 +409,15 @@ public void handle(JsonObject body) {
});
}
+ @Post("/rules/:structureId/reset")
+ @SecuredAction(value = "", type = ActionType.RESOURCE)
+ @ResourceFilter(SuperAdminFilter.class)
+ @MfaProtected()
+ public void resetRules(final HttpServerRequest request) {
+ String structureId = request.params().get("structureId");
+ communicationService.resetRules(structureId, defaultResponseHandler(request));
+ }
+
/**
* Send the default communication rules contained inside the mod.json file.
* @param request Incoming request.
diff --git a/communication/src/main/java/org/entcore/communication/services/CommunicationService.java b/communication/src/main/java/org/entcore/communication/services/CommunicationService.java
index 2eee789fff..8ecc35d3fb 100644
--- a/communication/src/main/java/org/entcore/communication/services/CommunicationService.java
+++ b/communication/src/main/java/org/entcore/communication/services/CommunicationService.java
@@ -51,7 +51,15 @@ public interface CommunicationService {
*/
void visibleUsersForShare(String userId, String search, JsonArray userIds, Handler> responseHandler);
- //enum VisibleType { USERS, GROUPS, BOTH }
+ /**
+ * Reset all communication rules on a structure and apply the default one configured
+ * in console or configuration
+ * @param structureId The target structure to reset
+ * @param eitherHandler handler for the response to the client
+ */
+ void resetRules(String structureId, Handler> eitherHandler);
+
+ //enum VisibleType { USERS, GROUPS, BOTH }
enum Direction {
INCOMING (0x01),
OUTGOING (0x10),
diff --git a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java
index a134936cd2..70cd636e32 100644
--- a/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java
+++ b/communication/src/main/java/org/entcore/communication/services/impl/DefaultCommunicationService.java
@@ -33,7 +33,9 @@
import org.entcore.common.conversation.LegacySearchVisibleRequest;
import org.entcore.common.neo4j.Neo4j;
import org.entcore.common.neo4j.StatementsBuilder;
+import org.entcore.common.neo4j.TransactionHelper;
import org.entcore.common.notification.TimelineHelper;
+import org.entcore.common.schema.Source;
import org.entcore.common.user.DefaultFunctions;
import org.entcore.common.user.UserInfos;
import org.entcore.common.user.UserUtils;
@@ -60,12 +62,61 @@ public class DefaultCommunicationService implements CommunicationService {
final JsonArray discoverVisibleExpectedProfile = new JsonArray();
private final String visiblesSearchType;
private final EventBus eventBus;
+ private final JsonObject defaultRules;
public DefaultCommunicationService(final Vertx vertx, final TimelineHelper notifyTimeline, final JsonObject config) {
this.notifyTimeline = notifyTimeline;
this.discoverVisibleExpectedProfile.addAll(config.getJsonArray("discoverVisibleExpectedProfile", new JsonArray()));
this.visiblesSearchType = config.getString("visibles-search-type", "light");
this.eventBus = vertx.eventBus();
+ this.defaultRules = config.getJsonObject("initDefaultCommunicationRules");
+ }
+
+ @Override
+ public void resetRules(String structureId, Handler> eitherHandler) {
+ log.warn("Reset communication rules for structure {}",structureId);
+
+ List statements = Lists.newLinkedList();
+ StatementsBuilder builder = new StatementsBuilder();
+ JsonObject params = new JsonObject();
+
+ params.put("structureId", structureId);
+ //remove communiqueWith to apply default configuration
+ String query = " MATCH(s:Structure {id: {structureId}})<-[:BELONGS]-(c:Class)<-[:DEPENDS]-(pg:Group) " +
+ " WHERE NOT(pg:ManualGroup) and has(pg.communiqueWith) " +
+ " REMOVE pg.communiqueWith ";
+ builder.add(query, params);
+
+ query = " MATCH(s:Structure {id: {structureId}})<-[:DEPENDS]-(pg:Group) " +
+ " WHERE NOT(pg:ManualGroup) AND has(pg.communiqueWith) " +
+ " REMOVE pg.communiqueWith ";
+ builder.add(query, params);
+ //remove link between group
+ query = "MATCH(s:Structure {id: {structureId}})<-[:BELONGS]-(:Class)<-[:DEPENDS]-(g:Group)-[c:COMMUNIQUE]->(g2:Group) "
+ + " WHERE NOT(g:ManualGroup) " +
+ " DELETE c";
+ builder.add(query, params);
+ query = "MATCH(s:Structure {id: {structureId}})<-[:DEPENDS]-(g:Group)-[c:COMMUNIQUE]->(g2:Group) "
+ + " WHERE NOT(g:ManualGroup) " +
+ " DELETE c";
+ builder.add(query, params);
+ //remove incoming communication from an external group
+ query = "MATCH(s:Structure {id: {structureId}, users:'INCOMING'})<-[:DEPENDS]-(g:Group)<-[c:COMMUNIQUE]-(g2:Group) "
+ + " WHERE NOT(g:ManualGroup) " +
+ " DELETE c";
+
+ builder.add(query, params);
+
+ statements.add(builder);
+
+ JsonArray structureIds = new JsonArray(Lists.newArrayList(structureId));
+ //apply default communiqueWith
+ statements.addAll(getStatementsForDefaultRules(structureIds, defaultRules));
+ //apply communique relation
+ statements.add(getApplyDefaultRulesStatements(structureIds));
+
+ StatementsBuilder allStatements = statements.stream().reduce(new StatementsBuilder(), StatementsBuilder::add);
+ neo4j.executeTransaction(allStatements.build(), null, true, validUniqueResultHandler(eitherHandler) );
}
@Override
@@ -346,9 +397,7 @@ public void removeLinkBetweenRelativeAndStudent(String groupId, Direction direct
neo4j.execute(query, params, validUniqueResultHandler(handler));
}
- @Override
- public void initDefaultRules(JsonArray structureIds, JsonObject defaultRules, final Integer transactionId,
- final Boolean commit, final Handler> handler) {
+ private List getStatementsForDefaultRules(JsonArray structureIds, JsonObject defaultRules) {
final StatementsBuilder s1 = new StatementsBuilder();
final StatementsBuilder s2 = new StatementsBuilder();
final StatementsBuilder s3 = new StatementsBuilder();
@@ -365,19 +414,27 @@ public void initDefaultRules(JsonArray structureIds, JsonObject defaultRules, fi
"SET ag.users = 'BOTH' "
);
for (String attr : defaultRules.fieldNames()) {
- initDefaultRules(structureIds, attr, defaultRules.getJsonObject(attr), s1, s2);
+ getStatementsForDefaultRules(structureIds, attr, defaultRules.getJsonObject(attr), s1, s2);
}
- neo4j.executeTransaction(s1.build(), transactionId, false, new Handler>() {
+ return Lists.newArrayList(s1, s2, s3);
+ }
+
+ @Override
+ public void initDefaultRules(JsonArray structureIds, JsonObject defaultRules, final Integer transactionId,
+ final Boolean commit, final Handler> handler) {
+ List statementsBuilderList = getStatementsForDefaultRules(structureIds, defaultRules);
+
+ neo4j.executeTransaction(statementsBuilderList.get(0).build(), transactionId, false, new Handler>() {
@Override
public void handle(Message event) {
if ("ok".equals(event.body().getString("status"))) {
Integer transactionId = event.body().getInteger("transactionId");
- neo4j.executeTransaction(s2.build(), transactionId, false, new Handler>() {
+ neo4j.executeTransaction(statementsBuilderList.get(1).build(), transactionId, false, new Handler>() {
@Override
public void handle(Message event) {
if ("ok".equals(event.body().getString("status"))) {
Integer transactionId = event.body().getInteger("transactionId");
- neo4j.executeTransaction(s3.build(), transactionId, commit.booleanValue(),
+ neo4j.executeTransaction(statementsBuilderList.get(2).build(), transactionId, commit.booleanValue(),
new Handler>() {
@Override
public void handle(Message message) {
@@ -412,8 +469,8 @@ public void initDefaultRules(JsonArray structureIds, JsonObject defaultRules,
initDefaultRules(structureIds, defaultRules, null, true, handler);
}
- private void initDefaultRules(JsonArray structureIds, String attr, JsonObject defaultRules,
- final StatementsBuilder existingGroups, final StatementsBuilder newGroups) {
+ private void getStatementsForDefaultRules(JsonArray structureIds, String attr, JsonObject defaultRules,
+ final StatementsBuilder existingGroups, final StatementsBuilder newGroups) {
final String[] a = attr.split("\\-");
final String c = "Class".equals(a[0]) ? "*2" : "";
String relativeStudent = defaultRules.getString("Relative-Student"); // TODO check type in enum
@@ -516,9 +573,7 @@ private void initDefaultRules(JsonArray structureIds, String attr, JsonObject de
}
}
- @Override
- public void applyDefaultRules(JsonArray structureIds, final Integer transactionId, final Boolean commit,
- Handler> handler) {
+ private StatementsBuilder getApplyDefaultRulesStatements(JsonArray structureIds) {
StatementsBuilder s = new StatementsBuilder();
JsonObject params = new JsonObject().put("structures", structureIds);
String query =
@@ -565,6 +620,13 @@ public void applyDefaultRules(JsonArray structureIds, final Integer transactionI
"WITH DISTINCT v " +
"SET v:Visible ";
s.add(setVisible2, params);
+ return s;
+ }
+
+ @Override
+ public void applyDefaultRules(JsonArray structureIds, final Integer transactionId, final Boolean commit,
+ Handler> handler) {
+ StatementsBuilder s = getApplyDefaultRulesStatements(structureIds);
neo4j.executeTransaction(s.build(), transactionId, commit.booleanValue(), event -> {
if ("ok".equals(event.body().getString("status"))) {
handler.handle(new Either.Right<>(event.body()));
diff --git a/conversation/backend/src/main/java/org/entcore/conversation/controllers/ConversationController.java b/conversation/backend/src/main/java/org/entcore/conversation/controllers/ConversationController.java
index e20ce62481..29ccd26fed 100644
--- a/conversation/backend/src/main/java/org/entcore/conversation/controllers/ConversationController.java
+++ b/conversation/backend/src/main/java/org/entcore/conversation/controllers/ConversationController.java
@@ -463,18 +463,16 @@ public void handle(Either event) {
public void handle(JsonObject userDetails) {
message.mergeIn(userDetails);
- conversationService.send(parentMessageId, id, message, user, new Handler>() {
- public void handle(Either event) {
- if(event.isRight()){
- for(Object recipient : message.getJsonArray("allUsers", new fr.wseduc.webutils.collections.JsonArray())){
- if(recipient.toString().equals(user.getUserId()))
- continue;
- updateUserQuota(recipient.toString(), size.get());
- }
- }
- result.handle(event);
- }
- });
+ conversationService.send(parentMessageId, id, message, user, event1 -> {
+ if(event1.isRight()){
+ for(Object recipient : message.getJsonArray("allUsers", new JsonArray())){
+ if(recipient.toString().equals(user.getUserId()))
+ continue;
+ updateUserQuota(recipient.toString(), size.get());
+ }
+ }
+ result.handle(event1);
+ });
}
});
}
diff --git a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java
index 46af5024b5..3ba2069ce0 100644
--- a/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java
+++ b/conversation/backend/src/main/java/org/entcore/conversation/service/impl/SqlConversationService.java
@@ -34,6 +34,7 @@
import io.vertx.core.eventbus.DeliveryOptions;
import io.vertx.core.eventbus.EventBus;
import io.vertx.core.http.HttpServerRequest;
+import io.vertx.core.json.Json;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.entcore.common.conversation.LegacySearchVisibleRequest;
@@ -270,100 +271,126 @@ private void sendMessage(final String parentMessageId, final String draftId, fin
if (validationParamsError(user, result, draftId))
return;
- getSenderAttachments(user.getUserId(), draftId, new Handler>() {
- public void handle(Either event) {
- if(event.isLeft()){
- result.handle(new Either.Left(event.left().getValue()));
- return;
- }
-
- JsonArray attachmentIds = event.right().getValue().getJsonArray("attachmentids");
- long totalQuota = event.right().getValue().getLong("totalquota");
- String unread = "false";
- final JsonArray ids = message.getJsonArray("allUsers", new fr.wseduc.webutils.collections.JsonArray());
- if(ids.contains(user.getUserId()))
- unread = "true";
- SqlStatementsBuilder builder = new SqlStatementsBuilder();
-
- String updateMessage =
- "UPDATE " + messageTable + " SET state = ? WHERE id = ? "+
- "RETURNING id, subject, body, thread_id";
- String updateUnread = "UPDATE " + userMessageTable + " " +
- "SET unread = " + unread +
- " WHERE user_id = ? AND message_id = ? ";
- builder.prepared(updateMessage, new fr.wseduc.webutils.collections.JsonArray().add(State.SENT.name()).add(draftId));
- builder.prepared(updateUnread, new fr.wseduc.webutils.collections.JsonArray().add(user.getUserId()).add(draftId));
-
- final String insertThread =
- "INSERT INTO conversation.threads as t (" +
- "SELECT thread_id as id, date, subject, \"from\", \"to\", cc, cci, \"displayNames\" " +
- "FROM conversation.messages m " +
- "WHERE m.id = ?) " +
- "ON CONFLICT (id) DO UPDATE SET date = EXCLUDED.date, subject = EXCLUDED.subject, \"from\" = EXCLUDED.\"from\", " +
- "\"to\" = EXCLUDED.\"to\", cc = EXCLUDED.cc, cci = EXCLUDED.cci, \"displayNames\" = EXCLUDED.\"displayNames\" " +
- "WHERE t.id = EXCLUDED.id ";
- builder.prepared(insertThread, new fr.wseduc.webutils.collections.JsonArray().add(draftId));
-
- final String insertUserThread =
- "INSERT INTO conversation.userthreads as ut (user_id,thread_id,nb_unread) VALUES (?,?,?) " +
- "ON CONFLICT (user_id,thread_id) DO UPDATE SET nb_unread = ut.nb_unread + 1 " +
- "WHERE ut.user_id = EXCLUDED.user_id AND ut.thread_id = EXCLUDED.thread_id";
- if (threadId != null) {
- builder.prepared(insertUserThread, new fr.wseduc.webutils.collections.JsonArray().add(user.getUserId()).add(threadId).add(0));
- }
-
- String insertUserMessage = "INSERT INTO " + userMessageTable + "(user_id, message_id, total_quota) VALUES ";
- String insertUserAttachment = "INSERT INTO " + userMessageAttachmentTable + "(user_id, message_id, attachment_id) VALUES ";
-
- StringBuilder insertUserMessageBuilder = new StringBuilder(insertUserMessage);
- StringBuilder insertUserAttachmentBuilder = new StringBuilder(insertUserAttachment);
-
- int userMessageValueCount = 0;
- int userMessageAttachementCount = 0;
-
- // Messages
- //
- // Optimisation de l'envois des messages: faire des requêtes unitaires est couteux pour postgresql notament
- // au niveau de la gestion des locks et de son index. Les groupes en insert .. values est bcp plus efficace
- for(Object toObj : ids){
- if(toObj.equals(user.getUserId())) continue;
-
- userMessageValueCount++;
- insertUserMessageBuilder.append(String.format("('%s', '%s', %s ),", toObj, draftId, totalQuota));
- if (userMessageValueCount >= conversationBatchSize) {
- builder.prepared(insertUserMessageBuilder.deleteCharAt(insertUserMessageBuilder.length()-1).toString(), new JsonArray());
- insertUserMessageBuilder = new StringBuilder(insertUserMessage);
- userMessageValueCount = 0;
- }
- if (threadId != null) {
- builder.prepared(insertUserThread, new fr.wseduc.webutils.collections.JsonArray().add(toObj.toString()).add(threadId).add(1));
- }
- }
- if (userMessageValueCount > 0) {
- builder.prepared(insertUserMessageBuilder.deleteCharAt(insertUserMessageBuilder.length()-1).toString(), new JsonArray());
- }
-
- // Pièces jointes
- for(Object toObj : ids){
- if(toObj.equals(user.getUserId())) continue;
-
- for(Object attachmentId : attachmentIds){
- userMessageAttachementCount++;
- insertUserAttachmentBuilder.append(String.format("('%s', '%s', '%s' ),", toObj, draftId, attachmentId));
- if (userMessageAttachementCount >= conversationBatchSize) {
- builder.prepared(insertUserAttachmentBuilder.deleteCharAt(insertUserAttachmentBuilder.length()-1).toString(), new JsonArray());
- insertUserAttachmentBuilder = new StringBuilder(insertUserAttachment);
- userMessageAttachementCount = 0;
- }
- }
- }
- if (userMessageAttachementCount > 0) {
- builder.prepared(insertUserAttachmentBuilder.deleteCharAt(insertUserAttachmentBuilder.length()-1).toString(), new JsonArray());
- }
-
- sql.transaction(builder.build(),new DeliveryOptions().setSendTimeout(sendTimeout), SqlResult.validUniqueResultHandler(0, result));
- }
- });
+ getSenderAttachments(user.getUserId(), draftId, event -> {
+ if(event.isLeft()){
+ result.handle(new Either.Left(event.left().getValue()));
+ return;
+ }
+
+ JsonArray attachmentIds = event.right().getValue().getJsonArray("attachmentids");
+ long totalQuota = event.right().getValue().getLong("totalquota");
+ String unread = "false";
+ final JsonArray ids = message.getJsonArray("allUsers", new JsonArray());
+ if(ids.contains(user.getUserId()))
+ unread = "true";
+ SqlStatementsBuilder builder = new SqlStatementsBuilder();
+
+ //select + update with exception if state is not different from previous STATE to avoid double send and ROLLBACK
+ String updateMessage =
+ "SELECT update_message_with_state_transition(?, ?), id, subject, body, thread_id FROM " + messageTable + " WHERE id = ? ";
+
+ String updateUnread = "UPDATE " + userMessageTable + " " +
+ "SET unread = " + unread +
+ " WHERE user_id = ? AND message_id = ? ";
+ builder.prepared(updateMessage, new JsonArray().add(draftId).add(State.SENT.name()).add(draftId));
+ builder.prepared(updateUnread, new JsonArray().add(user.getUserId()).add(draftId));
+
+ final String insertThread =
+ "INSERT INTO conversation.threads as t (" +
+ "SELECT thread_id as id, date, subject, \"from\", \"to\", cc, cci, \"displayNames\" " +
+ "FROM conversation.messages m " +
+ "WHERE m.id = ?) " +
+ "ON CONFLICT (id) DO UPDATE SET date = EXCLUDED.date, subject = EXCLUDED.subject, \"from\" = EXCLUDED.\"from\", " +
+ "\"to\" = EXCLUDED.\"to\", cc = EXCLUDED.cc, cci = EXCLUDED.cci, \"displayNames\" = EXCLUDED.\"displayNames\" " +
+ "WHERE t.id = EXCLUDED.id ";
+ builder.prepared(insertThread, new fr.wseduc.webutils.collections.JsonArray().add(draftId));
+
+ final String insertUserThread =
+ "INSERT INTO conversation.userthreads as ut (user_id,thread_id,nb_unread) VALUES ";
+ final String insertUserThreadConflict =
+ " ON CONFLICT (user_id,thread_id) DO UPDATE SET nb_unread = ut.nb_unread + 1 WHERE ut.user_id = EXCLUDED.user_id AND ut.thread_id = EXCLUDED.thread_id";
+ if (threadId != null) {
+ builder.prepared(insertUserThread + " (?, ?, ?) " + insertUserThreadConflict, new JsonArray().add(user.getUserId()).add(threadId).add(0));
+ }
+
+ String insertUserMessage = "INSERT INTO " + userMessageTable + "(user_id, message_id, total_quota) VALUES ";
+ String insertUserAttachment = "INSERT INTO " + userMessageAttachmentTable + "(user_id, message_id, attachment_id) VALUES ";
+
+ StringBuilder insertUserMessageBuilder = new StringBuilder(insertUserMessage);
+ StringBuilder insertUserAttachmentBuilder = new StringBuilder(insertUserAttachment);
+ StringBuilder insertThreadBuilder = new StringBuilder(insertUserThread);
+
+ int userMessageValueCount = 0;
+ int userMessageAttachementCount = 0;
+ int userThreadValueCount = 0;
+
+ JsonArray userMessageValues = new JsonArray();
+ JsonArray userMessageAttachmentsValues = new JsonArray();
+ JsonArray userThreadValues = new JsonArray();
+
+ // Messages
+ //
+ // Optimisation de l'envois des messages: faire des requêtes unitaires est couteux pour postgresql notament
+ // au niveau de la gestion des locks et de son index. Les groupes en insert .. values est bcp plus efficace
+ for(Object toObj : ids){
+ if(toObj.equals(user.getUserId())) continue;
+
+ userMessageValueCount++;
+ insertUserMessageBuilder.append("(? ,? , ? ),");
+ userMessageValues.add( toObj) .add(draftId).add(totalQuota);
+
+ if (threadId != null) {
+ userThreadValueCount++;
+ insertThreadBuilder.append("(? , ?, 1),");
+ userThreadValues.add(toObj).add(threadId);
+ }
+ if (userMessageValueCount >= conversationBatchSize) {
+ builder.prepared(insertUserMessageBuilder.deleteCharAt(insertUserMessageBuilder.length()-1).toString(), userMessageValues);
+ insertUserMessageBuilder = new StringBuilder(insertUserMessage);
+ userMessageValueCount = 0;
+ userMessageValues = new JsonArray();
+
+ if(userThreadValueCount > 0) {
+ builder.prepared(insertThreadBuilder.deleteCharAt(insertThreadBuilder.length() - 1).append(insertUserThreadConflict).toString(),
+ userThreadValues);
+ insertThreadBuilder = new StringBuilder(insertUserThread);
+ userThreadValueCount = 0;
+ userThreadValues = new JsonArray();
+ }
+ }
+ }
+ if (userMessageValueCount > 0) {
+ builder.prepared(insertUserMessageBuilder.deleteCharAt(insertUserMessageBuilder.length()-1).toString(), userMessageValues);
+ }
+ if(userThreadValueCount > 0) {
+ builder.prepared(insertThreadBuilder.deleteCharAt(insertThreadBuilder.length() - 1).append(insertUserThreadConflict).toString(), userThreadValues);
+ }
+
+ // Pièces jointes
+ for(Object toObj : ids){
+ if(toObj.equals(user.getUserId())) continue;
+
+ for(Object attachmentId : attachmentIds){
+ userMessageAttachementCount++;
+ insertUserAttachmentBuilder.append("(?, ?, ?),");
+ userMessageAttachmentsValues.add(toObj).add(draftId).add(attachmentId);
+
+ if (userMessageAttachementCount >= conversationBatchSize) {
+ builder.prepared(insertUserAttachmentBuilder.deleteCharAt(insertUserAttachmentBuilder.length()-1).toString(),
+ userMessageAttachmentsValues);
+ insertUserAttachmentBuilder = new StringBuilder(insertUserAttachment);
+ userMessageAttachementCount = 0;
+ userMessageAttachmentsValues = new JsonArray();
+ }
+ }
+ }
+ if (userMessageAttachementCount > 0) {
+ builder.prepared(insertUserAttachmentBuilder.deleteCharAt(insertUserAttachmentBuilder.length()-1).toString(),
+ userMessageAttachmentsValues);
+ }
+
+ sql.transaction(builder.build(),new DeliveryOptions().setSendTimeout(sendTimeout), SqlResult.validUniqueResultHandler(0, result));
+ });
}
@Override
@@ -427,7 +454,7 @@ protected void list(String folder, String restrain, Boolean unread, UserInfos us
String messageConditionUnread = addMessageConditionUnread(folder, values, unread, user);
String messagesFields = "m.id, m.subject, m.from, m.state, m.\"fromName\", m.to, m.\"toName\", m.cc, m.\"ccName\", m.cci, m.\"cciName\", m.\"displayNames\", m.date, m.\"noReply\" ";
- values.add(State.SENT.name()).add(State.RECALL.name()).add(user.getUserId());
+ values.add(user.getUserId()).add(user.getUserId()).add(State.SENT.name()).add(State.RECALL.name()).add(user.getUserId());
String additionalWhere = addCompleteFolderCondition(values, restrain, unread, folder, user, states);
if(searchText != null){
@@ -435,8 +462,20 @@ protected void list(String folder, String restrain, Boolean unread, UserInfos us
values.add(StringUtils.join(checkAndComposeWordFromSearchText(searchText), " & "));
}
String query = "SELECT "+messagesFields+", um.unread as unread, " +
- "CASE when COUNT(distinct r) = 0 THEN false ELSE true END AS response, COUNT(*) OVER() as count, " +
- "CASE when COUNT(distinct uma) = 0 THEN false ELSE true END AS \"hasAttachment\" " +
+ " EXISTS (" +
+ " SELECT 1 " +
+ " FROM conversation.messages r " +
+ " WHERE r.parent_id = m.id " +
+ " AND r.from = ? " +
+ " AND r.state IN ('SENT','RECALL') " +
+ " ) AS response," +
+ " COUNT(*) OVER() as count, " +
+ " EXISTS (" +
+ " SELECT 1 " +
+ " FROM conversation.usermessagesattachments uma " +
+ " WHERE uma.user_id = ? " +
+ " AND uma.message_id = m.id" +
+ " ) AS \"hasAttachment\" " +
"FROM " + userMessageTable + " um LEFT JOIN " +
userMessageAttachmentTable + " uma ON um.user_id = uma.user_id AND um.message_id = uma.message_id JOIN " +
messageTable + " m ON (um.message_id = m.id" + messageConditionUnread + ") LEFT JOIN " +
diff --git a/conversation/backend/src/main/resources/sql/022-conversation-add-function-to-secure-send-message.sql b/conversation/backend/src/main/resources/sql/022-conversation-add-function-to-secure-send-message.sql
new file mode 100644
index 0000000000..27f8d6f8fe
--- /dev/null
+++ b/conversation/backend/src/main/resources/sql/022-conversation-add-function-to-secure-send-message.sql
@@ -0,0 +1,15 @@
+CREATE OR REPLACE FUNCTION update_message_with_state_transition(
+ p_id text,
+ p_new_state text
+) RETURNS void AS $$
+BEGIN
+UPDATE conversation.messages
+SET state = p_new_state
+WHERE id = p_id
+ AND state <> p_new_state;
+
+IF NOT FOUND THEN
+ RAISE EXCEPTION 'Concurrency error: state mismatch for message %', p_id;
+END IF;
+END;
+$$ LANGUAGE plpgsql;
\ No newline at end of file
diff --git a/conversation/frontend/package.json.template b/conversation/frontend/package.json.template
index 9100582ef6..87ef116c85 100644
--- a/conversation/frontend/package.json.template
+++ b/conversation/frontend/package.json.template
@@ -37,49 +37,49 @@
"@edifice.io/client": "%packageVersion%",
"@edifice.io/utilities": "%packageVersion%",
"@edifice.io/tiptap-extensions": "%packageVersion%",
- "@react-spring/web": "^9.7.5",
- "@tanstack/react-query": "^5.59.13",
- "clsx": "^2.1.1",
+ "@react-spring/web": "9.7.5",
+ "@tanstack/react-query": "5.90.19",
+ "clsx": "2.1.1",
"i18next": "23.8.1",
"i18next-http-backend": "2.4.2",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
"react-error-boundary": "4.0.13",
- "react-hook-form": "^7.53.0",
+ "react-hook-form": "7.71.1",
"react-i18next": "14.1.0",
- "react-router-dom": "^6.27.0",
- "zustand": "^4.5.5"
+ "react-router-dom": "6.30.3",
+ "zustand": "4.5.7"
},
"devDependencies": {
- "@axe-core/react": "^4.10.0",
- "@eslint/js": "^9.12.0",
- "@tanstack/react-query-devtools": "^5.59.13",
- "@testing-library/jest-dom": "^6.5.0",
- "@testing-library/react": "^16.0.1",
- "@testing-library/user-event": "^14.5.2",
- "@types/node": "^18.19.55",
- "@types/react": "^18.3.11",
- "@types/react-dom": "^18.3.1",
- "@vitejs/plugin-react": "^4.3.2",
- "@vitest/coverage-v8": "^2.1.3",
- "@vitest/ui": "^2.1.3",
- "eslint": "^9.12.0",
+ "@axe-core/react": "4.11.0",
+ "@eslint/js": "9.39.2",
+ "@tanstack/react-query-devtools": "5.91.2",
+ "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/react": "16.3.2",
+ "@testing-library/user-event": "14.6.1",
+ "@types/node": "18.19.130",
+ "@types/react": "18.3.27",
+ "@types/react-dom": "18.3.7",
+ "@vitejs/plugin-react": "4.7.0",
+ "@vitest/coverage-v8": "2.1.9",
+ "@vitest/ui": "2.1.9",
+ "eslint": "9.39.2",
"eslint-plugin-react-hooks": "5.1.0-rc.0",
- "eslint-plugin-react-refresh": "^0.4.12",
- "globals": "^15.11.0",
- "husky": "^9.1.6",
- "inquirer": "^8.2.6",
- "jsdom": "^25.0.1",
+ "eslint-plugin-react-refresh": "0.4.26",
+ "globals": "15.15.0",
+ "husky": "9.1.7",
+ "inquirer": "8.2.7",
+ "jsdom": "25.0.1",
"lint-staged": "15.2.9",
- "msw": "^2.4.11",
+ "msw": "2.12.7",
"nx": "19.6.0",
- "prettier": "^3.3.3",
- "playwright": "^1.51.1",
- "typescript": "^5.6.3",
- "typescript-eslint": "^8.8.1",
+ "prettier": "3.8.0",
+ "playwright": "1.57.0",
+ "typescript": "5.9.3",
+ "typescript-eslint": "8.53.1",
"vite": "5.4.1",
- "vite-tsconfig-paths": "^5.0.1",
- "vitest": "^2.1.3"
+ "vite-tsconfig-paths": "5.1.4",
+ "vitest": "2.1.9"
},
"packageManager": "pnpm@8.6.6",
"engines": {
diff --git a/conversation/frontend/src/features/message-edit/components/RecipientListSelectedItem.tsx b/conversation/frontend/src/features/message-edit/components/RecipientListSelectedItem.tsx
index 27f29f6dae..a343ddbfbd 100644
--- a/conversation/frontend/src/features/message-edit/components/RecipientListSelectedItem.tsx
+++ b/conversation/frontend/src/features/message-edit/components/RecipientListSelectedItem.tsx
@@ -25,12 +25,8 @@ export function RecipientListSelectedItem({
const classNameProfile =
type === 'user'
- ? clsx({
- 'text-orange-500': (recipient as User).profile === 'Student',
- 'text-blue-500': (recipient as User).profile === 'Relative',
- 'text-purple-500': (recipient as User).profile === 'Teacher',
- 'text-green-500': (recipient as User).profile === 'Personnel',
- 'text-red-500': ![
+ ? clsx(`user-profile-${(recipient as User).profile?.toLowerCase()}`, {
+ 'user-profile-guest': ![
'Student',
'Relative',
'Teacher',
diff --git a/directory/src/main/java/org/entcore/directory/controllers/UserController.java b/directory/src/main/java/org/entcore/directory/controllers/UserController.java
index c87bd98302..bf0de66fc8 100644
--- a/directory/src/main/java/org/entcore/directory/controllers/UserController.java
+++ b/directory/src/main/java/org/entcore/directory/controllers/UserController.java
@@ -94,6 +94,8 @@ public class UserController extends BaseController {
private UserBookService userBookService;
private TimelineHelper notification;
private static final int MOTTO_MAX_LENGTH = 75;
+ private static final int HEALTH_MAX_LENGTH = 1000;
+ private static final int HOBBY_VALUES_MAX_LENGTH = 80;
private final EventHelper eventHelper;
private JsonObject userBookData;
private JsonArray userBookMoods;
@@ -274,6 +276,24 @@ public void handle(final JsonObject body) {
badRequest(request);
return;
}
+ String health = body.getString("health");
+ if (health != null && health.length() > HEALTH_MAX_LENGTH) {
+ badRequest(request);
+ return;
+ }
+ JsonArray hobbies = body.getJsonArray("hobbies");
+ if (hobbies != null) {
+ for (int i = 0; i < hobbies.size(); i++) {
+ JsonObject hobby = hobbies.getJsonObject(i);
+ if (hobby != null) {
+ String values = hobby.getString("values", "");
+ if (values.length() > HOBBY_VALUES_MAX_LENGTH) {
+ badRequest(request);
+ return;
+ }
+ }
+ }
+ }
userBookService.update(userId, body, new Handler>() {
@Override
public void handle(Either event) {
diff --git a/directory/src/main/java/org/entcore/directory/services/impl/DefaultSchoolService.java b/directory/src/main/java/org/entcore/directory/services/impl/DefaultSchoolService.java
index d5aef9092d..67edb9d2a3 100644
--- a/directory/src/main/java/org/entcore/directory/services/impl/DefaultSchoolService.java
+++ b/directory/src/main/java/org/entcore/directory/services/impl/DefaultSchoolService.java
@@ -41,6 +41,7 @@
import java.util.Arrays;
import java.util.Collections;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collector;
@@ -517,7 +518,43 @@ public void searchCriteria(List structures, boolean getClassesForMonoEta
query.append("['ManualGroup','FunctionalGroup','CommunityGroup'] as groupTypes");
JsonObject params = new JsonObject().put("structures", new JsonArray(structures)).put("1d", firstLevel).put("defLoe", defaultStructureLevelsOfEducation);
- neo.execute(query.toString(), params, validUniqueResultHandler(handler));
+ neo.execute(query.toString(), params, validUniqueResultHandler(result -> {
+ if (result.isRight()) {
+ JsonObject data = result.right().getValue();
+ JsonArray functions = data.getJsonArray("functions");
+
+ // Deduplicate functions by function name only, ignoring establishment code
+ // Format: "ESTABLISHMENT_CODE$FUNCTION_CODE$FUNCTION_NAME$JOB_CODE$DISCIPLINE"
+ if (functions != null && functions.size() > 0) {
+ java.util.Map functionMap = new java.util.TreeMap<>();
+ List malformedFunctions = new java.util.ArrayList<>();
+
+ for (int i = 0; i < functions.size(); i++) {
+ String functionStr = functions.getString(i);
+ if (functionStr != null && !functionStr.isEmpty()) {
+ // Extract function name (3rd part after 2nd $)
+ String[] parts = functionStr.split("\\$", 4);
+ if (parts.length >= 3) {
+ String functionName = parts[2];
+ // TreeMap keeps alphabetical order and prevents duplicates
+ if (!functionMap.containsKey(functionName)) {
+ functionMap.put(functionName, functionStr);
+ }
+ } else {
+ // Keep malformed entries as-is
+ malformedFunctions.add(functionStr);
+ }
+ }
+ }
+
+ // Build final list: sorted functions + malformed entries
+ List sortedFunctions = new java.util.ArrayList<>(functionMap.values());
+ sortedFunctions.addAll(malformedFunctions);
+ data.put("functions", new JsonArray(sortedFunctions));
+ }
+ }
+ handler.handle(result);
+ }));
}
@Override
diff --git a/feeder/src/main/java/org/entcore/feeder/csv/CsvValidator.java b/feeder/src/main/java/org/entcore/feeder/csv/CsvValidator.java
index 13e53d6deb..39bd4cad8b 100644
--- a/feeder/src/main/java/org/entcore/feeder/csv/CsvValidator.java
+++ b/feeder/src/main/java/org/entcore/feeder/csv/CsvValidator.java
@@ -354,9 +354,13 @@ private void checkClassesMapping(String path, String profile, String charset, Ha
if (invalidColumns.size() > 0 ) {
parseErrors("invalid.column", invalidColumns, profile, handler);
return;
- } else if (!columns.contains("classes") && !columns.contains("childClasses")) {
- handler.handle(result);
- return;
+ } else if (!columns.contains("classes") && !columns.contains("childClasses")) {
+ // Relative profile: childClasses is required when linking students by name/first name
+ if ("Relative".equals(profile) && !columns.contains("childExternalId") && (columns.contains("childLastName") || columns.contains("childFirstName"))) {
+ addErrorByFile(profile, "missing.column.childClasses");
+ }
+ handler.handle(result);
+ return;
} else {
int j = 0;
for (String column : columns) {
diff --git a/feeder/src/main/resources/i18n/fr.json b/feeder/src/main/resources/i18n/fr.json
index fca4972ff1..8c933e4195 100644
--- a/feeder/src/main/resources/i18n/fr.json
+++ b/feeder/src/main/resources/i18n/fr.json
@@ -44,6 +44,7 @@
"lastName": "nom",
"mapping.unknown.error": "Erreur inconnue lors de la recherche des identifiants.",
"missing.attribute": "Ligne {0} : Attribut {1} manquant.",
+ "missing.column.childClasses": "La colonne \"Classes de l'élève\" est obligatoire lors d'un import parent avec jointure élève par nom et prénom.",
"missing.columns": "Ligne {0} : colonne manquante.",
"missing.student": "Élève manquant : {0}",
"missing.student.soft": "Ligne {0} : le parent {2} {3} référence un élève non retrouvé.",
diff --git a/package.json b/package.json
index ed554936d1..4fc923ebe8 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"angular-sanitize": "1.8.3",
"axios": "0.15.3",
"core-js": "^2.4.1",
- "entcore": "dev",
+ "entcore": "develop-b2school",
"entcore-generic-icons": "https://github.com/edificeio/generic-icons.git",
"entcore-toolkit": "^1.0.1",
"humane-js": "^3.2.2",
@@ -76,8 +76,8 @@
"karma-jasmine": "~1.1.0",
"karma-jasmine-html-reporter": "^0.2.2",
"merge2": "^1.0.3",
- "ode-ngjs-front": "dev",
- "ode-ts-client": "dev",
+ "ode-ngjs-front": "develop-b2school",
+ "ode-ts-client": "develop-b2school",
"rxjs": "5.4.2",
"sass-loader": "^13.0.2",
"source-map-loader": "^0.1.5",
diff --git a/pom.xml b/pom.xml
index 3a552e1714..b8c39b548a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -57,20 +57,20 @@
- 6.14-SNAPSHOT
+ 6.14-develop-b2school-SNAPSHOT
4.13.2
1.19.3
- 2.1-SNAPSHOT
+ 2.1-develop-b2school-SNAPSHOT
4.1-SNAPSHOT
- 3.3-SNAPSHOT
- 3.1-SNAPSHOT
+ 3.3-develop-b2school-SNAPSHOT
+ 3.1-develop-b2school-SNAPSHOT
2.9.4
2.1
1.11.4
20220608.1
2.15.2
0.3-SNAPSHOT
- 3.0-SNAPSHOT
+ 3.0-develop-b2school-SNAPSHOT
3.0.2
0.2.0
3.9
diff --git a/portal/frontend/package.json.template b/portal/frontend/package.json.template
index f490a187c2..39eb2687bd 100644
--- a/portal/frontend/package.json.template
+++ b/portal/frontend/package.json.template
@@ -36,46 +36,47 @@
"@react-spring/web": "^9.7.5",
"@tanstack/react-query": "5.62.7",
"@uidotdev/usehooks": "2.4.1",
- "clsx": "^2.1.1",
+ "clsx": "2.1.1",
"i18next": "23.8.1",
"i18next-http-backend": "2.4.2",
- "framer-motion": "^12.18.1",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
+ "framer-motion": "12.31.0",
+ "ode-explorer": "%packageVersion%",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
"react-error-boundary": "4.0.13",
- "react-hook-form": "^7.53.0",
+ "react-hook-form": "7.71.1",
"react-i18next": "14.1.0",
- "react-router-dom": "^6.27.0",
- "zustand": "^4.5.5"
+ "react-router-dom": "6.30.3",
+ "zustand": "4.5.7"
},
"devDependencies": {
- "@axe-core/react": "^4.10.0",
- "@eslint/js": "^9.12.0",
- "@tanstack/react-query-devtools": "^5.59.13",
- "@testing-library/jest-dom": "^6.5.0",
- "@testing-library/react": "^16.0.1",
- "@testing-library/user-event": "^14.5.2",
- "@types/node": "^18.19.55",
- "@types/react": "^18.3.11",
- "@types/react-dom": "^18.3.1",
- "@vitejs/plugin-react": "^4.3.2",
- "@vitest/coverage-v8": "^2.1.3",
- "@vitest/ui": "^2.1.3",
- "eslint": "^9.16.0",
- "eslint-plugin-react-hooks": "^5.1.0-rc.0",
- "eslint-plugin-react-refresh": "^0.4.12",
- "globals": "^15.11.0",
- "husky": "^9.1.6",
- "jsdom": "^25.0.1",
+ "@axe-core/react": "4.11.1",
+ "@eslint/js": "9.39.2",
+ "@tanstack/react-query-devtools": "5.91.3",
+ "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/react": "16.3.2",
+ "@testing-library/user-event": "14.6.1",
+ "@types/node": "18.19.130",
+ "@types/react": "18.3.27",
+ "@types/react-dom": "18.3.7",
+ "@vitejs/plugin-react": "4.7.0",
+ "@vitest/coverage-v8": "2.1.9",
+ "@vitest/ui": "2.1.9",
+ "eslint": "9.39.2",
+ "eslint-plugin-react-hooks": "5.2.0",
+ "eslint-plugin-react-refresh": "0.4.26",
+ "globals": "15.15.0",
+ "husky": "9.1.7",
+ "jsdom": "25.0.1",
"lint-staged": "15.2.9",
- "msw": "^2.4.11",
+ "msw": "2.12.8",
"nx": "19.6.0",
- "prettier": "^3.3.3",
- "typescript": "^5.6.3",
- "typescript-eslint": "^8.8.1",
- "vite": "^5.4.6",
- "vite-tsconfig-paths": "^5.0.1",
- "vitest": "^2.1.3"
+ "prettier": "3.8.1",
+ "typescript": "5.9.3",
+ "typescript-eslint": "8.54.0",
+ "vite": "5.4.21",
+ "vite-tsconfig-paths": "5.1.4",
+ "vitest": "2.1.9"
},
"packageManager": "pnpm@9.12.2",
"engines": {
diff --git a/tests/src/main/scala/org/entcore/test/scenarios/AppRegistryScenario.scala b/tests/src/main/scala/org/entcore/test/scenarios/AppRegistryScenario.scala
index e2634ea123..8ca6af4c53 100644
--- a/tests/src/main/scala/org/entcore/test/scenarios/AppRegistryScenario.scala
+++ b/tests/src/main/scala/org/entcore/test/scenarios/AppRegistryScenario.scala
@@ -42,6 +42,7 @@ object AppRegistryScenario {
}
}
}).saveAs("roles")))
+ .exitHereIfFailed
.foreach("${roles}", "role") {
exec(http("Create role ${role(0)}")
.post("""/appregistry/role""")
@@ -62,8 +63,7 @@ object AppRegistryScenario {
.post("""/appregistry/role""")
.header("Content-Type", "application/json")
.body(StringBody("""{"role":"${testRole(0)}-test","actions":["${testRole(1)}"]}"""))
- .check(status.is(201), jsonPath("$.id").find.saveAs("test-id")))
- .exec(http("Update role ${testRole(0)}-test")
+ .check(status.is(201), jsonPath("$.id").find.saveAs("test-id"))) .exitHereIfFailed .exec(http("Update role ${testRole(0)}-test")
.put("""/appregistry/role/${test-id}""")
.header("Content-Type", "application/json")
.body(StringBody("""{"role":"${testRole(0)}-bla-test"}"""))
@@ -90,6 +90,7 @@ object AppRegistryScenario {
}
}.map(_.mkString("\",\""))
}).saveAs("rolesIds")))
+ .exitHereIfFailed
.exec(http("Find profil groups with roles")
.get("""/appregistry/groups/roles?structureId=${schoolId}""")
.check(status.is(200),
@@ -104,6 +105,7 @@ object AppRegistryScenario {
}
}
}).saveAs("profilGroupIds")))
+ .exitHereIfFailed
.exec(http("Link teacher profil groups with roles")
.post("""/appregistry/authorize/group?schoolId=${schoolId}""")
.header("Content-Type", "application/json")
diff --git a/tests/src/main/scala/org/entcore/test/scenarios/AuthScenario.scala b/tests/src/main/scala/org/entcore/test/scenarios/AuthScenario.scala
index df5373d6c4..eebc4c32de 100644
--- a/tests/src/main/scala/org/entcore/test/scenarios/AuthScenario.scala
+++ b/tests/src/main/scala/org/entcore/test/scenarios/AuthScenario.scala
@@ -49,6 +49,7 @@ object AuthScenario {
.check(status.is(302), header("Location").find.transformOption(_.map(location =>
location.substring(location.indexOf("code=") + 5).substring(0, 36)
)).saveAs("oauth2Code")))
+ .exitHereIfFailed
.exec(http("Logout teacher user")
.get("""/auth/logout""")
.check(status.is(302)))
@@ -63,6 +64,7 @@ object AuthScenario {
.formParam("""redirect_uri""", "http://localhost:1500/code")
.check(status.is(200), jsonPath("$.token_type").is("Bearer"),
jsonPath("$.access_token").find.saveAs("oauth2AccessToken")))
+ .exitHereIfFailed
.exec(http("Get userinfo with access token")
.get("/auth/oauth2/userinfo")
.header("Authorization", "Bearer ${oauth2AccessToken}")
diff --git a/tests/src/main/scala/org/entcore/test/scenarios/ConversationScenario.scala b/tests/src/main/scala/org/entcore/test/scenarios/ConversationScenario.scala
index bd8da2a351..1f855fc8df 100644
--- a/tests/src/main/scala/org/entcore/test/scenarios/ConversationScenario.scala
+++ b/tests/src/main/scala/org/entcore/test/scenarios/ConversationScenario.scala
@@ -21,6 +21,7 @@ object ConversationScenario {
.saveAs("conversationTeacherVisibleGroupId"),
jsonPath("$.users.id").findAll.transformOption(_.orElse(Some(Nil)))
.saveAs("conversationTeacherVisibleUserId")))
+ .exitHereIfFailed
.exec(http("Create draft message")
.post("/conversation/draft")
.header("Content-Type", "application/json")
@@ -67,9 +68,11 @@ object ConversationScenario {
.get("/conversation/list/INBOX")
.check(status.is(200), jsonPath("$[0].id").find.saveAs("conversationMessageId"),
jsonPath("$[0].unread").find.transformOption(_.map(u => String.valueOf(u))).is("true")))
+ .exitHereIfFailed
.exec(http("Count unread messages")
.get("/conversation/count/INBOX?unread=true")
.check(status.is(200), jsonPath("$.count").find.saveAs("unreadMessageNumber")))
+ .exitHereIfFailed
.exec(http("Read message")
.get("/conversation/message/${conversationMessageId}")
.check(status.is(200), jsonPath("$.body").find.exists, jsonPath("$.state").find.is("SENT")))
@@ -124,6 +127,7 @@ object ConversationScenario {
status.is(200),
jsonPath("$[0].name").find.is("folder"),
jsonPath("$[0].id").find.saveAs("folderId")))
+ .exitHereIfFailed
.exec(http("Create a subfolder")
.post("/conversation/folder")
.header("Content-Type", "application/json")
@@ -135,6 +139,7 @@ object ConversationScenario {
status.is(200),
jsonPath("$[0].name").find.is("subfolder"),
jsonPath("$[0].id").find.saveAs("subfolderId")))
+ .exitHereIfFailed
.exec(http("Rename a folder")
.put("/conversation/folder/${subfolderId}")
.header("Content-Type", "application/json")
@@ -197,10 +202,10 @@ object ConversationScenario {
.post("/conversation/draft")
.header("Content-Type", "application/json")
.body(StringBody("""{"subject":"Attachments", "body":"Testing attachments.
","to":[]}"""))
- .check(status.is(201), jsonPath("$.id").find.saveAs("attachmentDraftId")))
- .exec(http("Check teacher quota before adding the attachment")
+ .check(status.is(201), jsonPath("$.id").find.saveAs("attachmentDraftId"))) .exitHereIfFailed .exec(http("Check teacher quota before adding the attachment")
.get("""/workspace/quota/user/${teacherId}""")
.check(status.is(200), jsonPath("$.storage").find.saveAs("teacherStorageInitial")))
+ .exitHereIfFailed
.exec(http("Add attachment")
.post("""/conversation/message/${attachmentDraftId}/attachment""")
.headers(headers_202)
@@ -211,6 +216,7 @@ object ConversationScenario {
.check(
status.is(200),
jsonPath("$.attachments[0::].id").find.saveAs("attachmentId")))
+ .exitHereIfFailed
.exec(http("Check teacher quota after adding the attachment")
.get("""/workspace/quota/user/${teacherId}""")
.check(status.is(200), jsonPath("$.storage").find.greaterThan("${teacherStorageInitial}")))
@@ -245,6 +251,7 @@ object ConversationScenario {
.exec(http("Check student quota before deleting the attachment")
.get("""/workspace/quota/user/${studentId}""")
.check(status.is(200), jsonPath("$.storage").find.saveAs("studentStorageInitial")))
+ .exitHereIfFailed
.exec(http("Move message to trash")
.put("/conversation/trash")
.header("Content-Type", "application/json")
diff --git a/tests/src/main/scala/org/entcore/test/scenarios/DirectoryAdmlScenario.scala b/tests/src/main/scala/org/entcore/test/scenarios/DirectoryAdmlScenario.scala
index 76c120be58..3f91b8b02f 100644
--- a/tests/src/main/scala/org/entcore/test/scenarios/DirectoryAdmlScenario.scala
+++ b/tests/src/main/scala/org/entcore/test/scenarios/DirectoryAdmlScenario.scala
@@ -37,8 +37,7 @@ object DirectoryAdmlScenario {
.post("""/directory/group""")
.header("Content-Type", "application/json")
.body(StringBody("""{"name": "Group with rattachment", "structureId":"${schoolId}"}"""))
- .check(status.is(201), jsonPath("$.id").find.saveAs("manual-group-id")))
-
+ .check(status.is(201), jsonPath("$.id").find.saveAs("manual-group-id"))) .exitHereIfFailed
.exec(http("update group")
.put("""/directory/group/${manual-group-id}""")
.header("Content-Type", "application/json")
diff --git a/tests/src/main/scala/org/entcore/test/scenarios/DirectoryScenario.scala b/tests/src/main/scala/org/entcore/test/scenarios/DirectoryScenario.scala
index 259a091afa..c6dfd856d8 100644
--- a/tests/src/main/scala/org/entcore/test/scenarios/DirectoryScenario.scala
+++ b/tests/src/main/scala/org/entcore/test/scenarios/DirectoryScenario.scala
@@ -54,14 +54,17 @@ object DirectoryScenario {
.get("""/directory/api/ecole""")
.check(status.is(200), jsonPath("$.status").is("ok"),
jsonPath("$.result.*.id").find.saveAs("schoolId")))
+ .exitHereIfFailed
.exec(http("List classes")
.get("""/directory/api/classes?id=${schoolId}""")
.check(status.is(200), jsonPath("$.status").is("ok"),
jsonPath("$.result.*.classId").find.saveAs("classId")))
+ .exitHereIfFailed
.exec(http("List students in class")
.get("""/directory/api/personnes?id=${classId}&type=Student""")
.check(status.is(200), jsonPath("$.status").is("ok"),
jsonPath("$.result.*.userId").find.saveAs("childrenId")))
+ .exitHereIfFailed
.exec(http("Create manual teacher")
.post("""/directory/api/user""")
.formParam("""classId""", """${classId}""")
@@ -113,6 +116,7 @@ object DirectoryScenario {
}
}.toMap
})).saveAs("createdUserIds")))
+ .exitHereIfFailed
.exec{session =>
val uIds = session("createdUserIds").as[Map[String, String]]
session.set("teacherId", uIds.get("Teacher").get).set("studentId", uIds.get("Student").get)
@@ -124,11 +128,13 @@ object DirectoryScenario {
.check(status.is(200), jsonPath("$.status").is("ok"),
jsonPath("$.result.*.login").find.saveAs("teacherLogin"),
jsonPath("$.result.*.code").find.saveAs("teacherCode")))
+ .exitHereIfFailed
.exec(http("Student details")
.get("""/directory/api/details?id=${studentId}""")
.check(status.is(200), jsonPath("$.status").is("ok"),
jsonPath("$.result.*.login").find.saveAs("studentLogin"),
jsonPath("$.result.*.code").find.saveAs("studentCode")))
+ .exitHereIfFailed
// create function
.exec(http("Create function")
@@ -189,8 +195,7 @@ object DirectoryScenario {
.post("""/directory/group""")
.header("Content-Type", "application/json")
.body(StringBody("""{"name": "Group with rattachment"}"""))
- .check(status.is(201), jsonPath("$.id").find.saveAs("manuel-group-id")))
-
+ .check(status.is(201), jsonPath("$.id").find.saveAs("manuel-group-id"))) .exitHereIfFailed
.exec(http("update group")
.put("""/directory/group/${manuel-group-id}""")
.header("Content-Type", "application/json")
diff --git a/tests/src/main/scala/org/entcore/test/scenarios/GroupsAndSharesScenario.scala b/tests/src/main/scala/org/entcore/test/scenarios/GroupsAndSharesScenario.scala
index 68971122fa..7e72ecee8a 100644
--- a/tests/src/main/scala/org/entcore/test/scenarios/GroupsAndSharesScenario.scala
+++ b/tests/src/main/scala/org/entcore/test/scenarios/GroupsAndSharesScenario.scala
@@ -36,8 +36,7 @@ object GroupsAndSharesScenario {
.exec(http("Search users and groups")
.post("/communication/visible")
.body(StringBody("""{"profiles" : ["Teacher", "Personnel"]}"""))
- .check(jsonPath("$.groups[*].id").findAll.saveAs("shareGroupIds"), jsonPath("$.users[*].id").findAll.saveAs("shareUserIds")))
- .exec{ session =>
+ .check(jsonPath("$.groups[*].id").findAll.saveAs("shareGroupIds"), jsonPath("$.users[*].id").findAll.saveAs("shareUserIds"))) .exitHereIfFailed .exec{ session =>
val shareUserIds = session("shareUserIds").as[Seq[String]].mkString("\",\"")
val shareAllIds = session("shareGroupIds").as[Seq[String]].mkString("\",\"") + "\",\"" + shareUserIds
session.set("shareUserIds", shareUserIds).set("shareAllIds", shareAllIds)
@@ -45,8 +44,7 @@ object GroupsAndSharesScenario {
.exec(http("Create share bookmark")
.post("/directory/sharebookmark")
.body(StringBody("""{"name" : "Mon favoris de partage", "members": ["${shareUserIds}"]}"""))
- .check(status.is(201), jsonPath("$.id").find.saveAs("shareBookmarkId")))
- .exec(http("Update share bookmark")
+ .check(status.is(201), jsonPath("$.id").find.saveAs("shareBookmarkId"))) .exitHereIfFailed .exec(http("Update share bookmark")
.put("/directory/sharebookmark/${shareBookmarkId}")
.body(StringBody("""{"name" : "Mon favoris de partage renommé", "members": ["${shareAllIds}"]}"""))
.check(status.is(200)))
@@ -56,6 +54,7 @@ object GroupsAndSharesScenario {
.exec(http("List share bookmark")
.get("/directory/sharebookmark/all")
.check(status.is(200), jsonPath("$..id").findAll.transform(_.size).saveAs("sbCount")))
+ .exitHereIfFailed
.exec(http("Delete share bookmark")
.delete("/directory/sharebookmark/${shareBookmarkId}")
.check(status.is(200)))
diff --git a/tests/src/main/scala/org/entcore/test/scenarios/ImportScenario.scala b/tests/src/main/scala/org/entcore/test/scenarios/ImportScenario.scala
index c44509d3e4..b3f19543bd 100644
--- a/tests/src/main/scala/org/entcore/test/scenarios/ImportScenario.scala
+++ b/tests/src/main/scala/org/entcore/test/scenarios/ImportScenario.scala
@@ -15,6 +15,7 @@ object ImportScenario {
.exec(http("Directory : list Schools")
.get("""/directory/api/ecole""")
.check(status.is(200), jsonPath("$.status").is("ok"), jsonPath("$.result").find.saveAs("schools")))
+ .exitHereIfFailed
.doIf(session => session("schools").asOption[String].getOrElse("") == "{}") {
exec(http("Directory : import schools")
.post("""/directory/wizard/import""")
diff --git a/tests/src/main/scala/org/entcore/test/scenarios/TimelineScenario.scala b/tests/src/main/scala/org/entcore/test/scenarios/TimelineScenario.scala
index 34f4621075..f613d1bfd8 100644
--- a/tests/src/main/scala/org/entcore/test/scenarios/TimelineScenario.scala
+++ b/tests/src/main/scala/org/entcore/test/scenarios/TimelineScenario.scala
@@ -37,6 +37,7 @@ object TimelineScenario {
.formParam("""scope""", "org.entcore.timeline.controllers.TimelineController|publish")
.check(status.is(200), jsonPath("$.token_type").is("Bearer"),
jsonPath("$.access_token").find.saveAs("clientCredentialsToken")))
+ .exitHereIfFailed
.exec(http("MyExternalApp publish on Timeline")
.post("/timeline/publish")
.header("Authorization", "Bearer ${clientCredentialsToken}")
diff --git a/tests/src/test/js/it/scenarios/admin/resetCommunication.ts b/tests/src/test/js/it/scenarios/admin/resetCommunication.ts
new file mode 100644
index 0000000000..c0c3bc5d1a
--- /dev/null
+++ b/tests/src/test/js/it/scenarios/admin/resetCommunication.ts
@@ -0,0 +1,124 @@
+import {describe } from "https://jslib.k6.io/k6chaijs/4.3.4.0/index.js";
+
+import {
+ authenticateWeb,
+ initStructure,
+ Session,
+ Structure,
+ getProfileGroupsOfStructureByType,
+ getProfileGroupsRelatedToGroup,
+ resetRulesAndCheck,
+ removeCommunicationBetweenGroups,
+ getAdmlsOrMakThem,
+ addCommunicationBetweenGroups,
+ ProfileGroup
+} from "../../../node_modules/edifice-k6-commons/dist/index.js";
+import {fail} from "k6";
+
+const maxDuration = __ENV.MAX_DURATION || "5m";
+const schoolName = __ENV.DATA_SCHOOL_NAME || "Test it admin";
+const gracefulStop = parseInt(__ENV.GRACEFUL_STOP || "2s");
+
+export const options = {
+ setupTimeout: "1h",
+ thresholds: {
+ checks: ["rate == 1.00"],
+ },
+ scenarios: {
+ testResetCommunicationsRules: {
+ executor: "per-vu-iterations",
+ exec: "testResetCommunicationsRules",
+ vus: 1,
+ maxDuration: maxDuration,
+ gracefulStop,
+ },
+ },
+};
+
+type InitData = {
+ structure: Structure;
+}
+
+export function setup() {
+ let structure: Structure;
+ describe("[Reset-Communications-Rules] Initialize data", () => {
+ authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD);
+ structure = initStructure(`${schoolName} - School`);
+ });
+ return { structure };
+}
+
+export function testResetCommunicationsRules(data: InitData){
+
+ describe('[Admin][Structure][Communication] Test that we can apply default communication rules ', () => {
+
+ authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD);
+
+ const profileGroups: ProfileGroup[] = getProfileGroupsOfStructureByType("Teacher", data.structure);
+ const profileGroupTeacherStruct: ProfileGroup = profileGroups.find(p => p.name === (schoolName + ' - School-Teacher'));
+
+ if(profileGroupTeacherStruct === null) {
+ fail("[Admin][Structure][Communication] Unable to find the teacher group of the structure");
+ }
+ let incomingRelation: ProfileGroup[] = getProfileGroupsRelatedToGroup(profileGroupTeacherStruct.id, "incoming");
+ let outgoingRelation: ProfileGroup[] = getProfileGroupsRelatedToGroup(profileGroupTeacherStruct.id, "outgoing");
+
+ for(let i = 0; i < incomingRelation.length; i++) {
+ removeCommunicationBetweenGroups(incomingRelation[i].id, profileGroupTeacherStruct.id);
+ }
+ for(let i = 0; i < outgoingRelation.length; i++) {
+ removeCommunicationBetweenGroups(profileGroupTeacherStruct.id, outgoingRelation[i].id);
+ }
+
+ incomingRelation = getProfileGroupsRelatedToGroup(profileGroupTeacherStruct.id, "incoming");
+ outgoingRelation = getProfileGroupsRelatedToGroup(profileGroupTeacherStruct.id, "outgoing");
+
+ if (incomingRelation !== null && incomingRelation.length > 0) {
+ fail("[Admin][Structure][Communication] Incoming group communication should be empty");
+ }
+ if (outgoingRelation !== null && outgoingRelation.length > 0) {
+ fail("[Admin][Structure][Communication] Outgoing group communication should be empty");
+ }
+
+ //add custom communication rule
+ const profilGuestGroups: ProfileGroup[] = getProfileGroupsOfStructureByType("Guest", data.structure);
+
+ const targetGuestGroup = profilGuestGroups[0];
+
+ addCommunicationBetweenGroups(profileGroupTeacherStruct.id, targetGuestGroup.id);
+
+ outgoingRelation = getProfileGroupsRelatedToGroup(profileGroupTeacherStruct.id, "outgoing");
+
+ if (outgoingRelation !== null && outgoingRelation.length != 1) {
+ fail("[Admin][Structure][Communication] Outgoing group communication should be equal to 1");
+ }
+
+ //reset all rules => the structure has no communication on the teacher group
+ resetRulesAndCheck(data.structure, 200);
+
+ const incomingUpdatedRelation: ProfileGroup[] = getProfileGroupsRelatedToGroup(profileGroupTeacherStruct.id, "incoming");
+ const outgoingUpdatedRelation: ProfileGroup[] = getProfileGroupsRelatedToGroup(profileGroupTeacherStruct.id, "outgoing");
+
+ if (incomingUpdatedRelation === null || incomingUpdatedRelation.length === 0) {
+ fail("[Admin][Structure][Communication] Incoming group communication should not be empty");
+ }
+ if (outgoingUpdatedRelation === null || outgoingUpdatedRelation.length === 0) {
+ fail("[Admin][Structure][Communication] Outgoing group communication should not be empty");
+ }
+ //custom relation test
+ if (outgoingUpdatedRelation.find((p) => p.id === targetGuestGroup.id)) {
+ fail("[Admin][Structure][Communication] Outgoing group communication should not contain custom relation");
+ }
+ });
+
+ describe('[Admin][Structure][Communication] Test that adml cant reset communications rules ', () => {
+
+ authenticateWeb(__ENV.ADMC_LOGIN, __ENV.ADMC_PASSWORD);
+
+ const admlTeacher = getAdmlsOrMakThem(data.structure, 'Teacher', 1, [])[0]
+ authenticateWeb(admlTeacher.login)
+
+ //reset all rules => the structure has no communication on the teacher group
+ resetRulesAndCheck(data.structure, 401);
+ });
+}
diff --git a/tests/src/test/js/pnpm-lock.yaml b/tests/src/test/js/pnpm-lock.yaml
index bf36c21fd7..e2f6689a76 100644
--- a/tests/src/test/js/pnpm-lock.yaml
+++ b/tests/src/test/js/pnpm-lock.yaml
@@ -12,20 +12,20 @@ importers:
specifier: ^0.54.2
version: 0.54.2
edifice-k6-commons:
- specifier: latest
- version: 2.1.1
+ specifier: 2.1.6-develop-b2school-3
+ version: 2.1.6-develop-b2school-3
packages:
'@types/k6@0.54.2':
resolution: {integrity: sha512-B5LPxeQm97JnUTpoKNE1UX9jFp+JiJCAXgZOa2P7aChxVoPQXKfWMzK+739xHq3lPkKj1aV+HeOxkP56g/oWBg==}
- edifice-k6-commons@2.1.1:
- resolution: {integrity: sha512-r+eeO3hjTj4thRwDckG0SsCFkBIw0nuOVVJaZFOoDKHbidxjkgiRYA0Ygmu+yWCffBHpcHUiFMZtANaxYxlnJQ==}
- engines: {node: 18 || 20}
+ edifice-k6-commons@2.1.6-develop-b2school-3:
+ resolution: {integrity: sha512-H4qJMdtIdR025qWy45/T5IsN2SJU1N/GmLXiSqXfbt96rylJoeA7YysZLJAU6vW3kwJBHYjenn5rwCHWkwVMlA==}
+ engines: {node: '>=18'}
snapshots:
'@types/k6@0.54.2': {}
- edifice-k6-commons@2.1.1: {}
+ edifice-k6-commons@2.1.6-develop-b2school-3: {}
diff --git a/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java b/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java
index 95f2449d2e..7fb3345dc7 100644
--- a/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java
+++ b/timeline/src/main/java/org/entcore/timeline/controllers/TimelineController.java
@@ -19,9 +19,6 @@
package org.entcore.timeline.controllers;
-import io.vertx.core.*;
-import io.vertx.core.logging.Logger;
-import io.vertx.core.logging.LoggerFactory;
import fr.wseduc.bus.BusAddress;
import fr.wseduc.rs.Delete;
import fr.wseduc.rs.Get;
@@ -36,36 +33,34 @@
import fr.wseduc.webutils.collections.TTLSet;
import fr.wseduc.webutils.http.BaseController;
import fr.wseduc.webutils.request.RequestUtils;
+import io.vertx.core.Future;
+import io.vertx.core.Handler;
+import io.vertx.core.Promise;
+import io.vertx.core.Vertx;
+import io.vertx.core.eventbus.Message;
+import io.vertx.core.http.HttpServerRequest;
+import io.vertx.core.impl.logging.Logger;
+import io.vertx.core.impl.logging.LoggerFactory;
+import io.vertx.core.json.JsonArray;
+import io.vertx.core.json.JsonObject;
import org.entcore.common.cache.CacheService;
import org.entcore.common.events.EventHelper;
import org.entcore.common.events.EventStore;
import org.entcore.common.events.EventStoreFactory;
-import org.entcore.common.http.filter.AdminFilter;
-import org.entcore.common.http.filter.AdmlOfStructures;
-import org.entcore.common.http.filter.ResourceFilter;
-import org.entcore.common.http.filter.SuperAdminFilter;
-import org.entcore.common.http.filter.Trace;
+import org.entcore.common.http.filter.*;
import org.entcore.common.http.request.JsonHttpServerRequest;
import org.entcore.common.mute.MuteHelper;
+import org.entcore.common.notification.NotificationUtils;
import org.entcore.common.notification.TimelineHelper;
import org.entcore.common.notification.TimelineNotificationsLoader;
-import org.entcore.common.notification.NotificationUtils;
import org.entcore.common.user.UserInfos;
import org.entcore.common.user.UserUtils;
import org.entcore.timeline.Timeline;
import org.entcore.timeline.controllers.helper.NotificationHelper;
-import org.entcore.timeline.events.CachedTimelineEventStore;
-import org.entcore.timeline.events.DefaultTimelineEventStore;
-import org.entcore.timeline.events.MobileTimelineEventStore;
-import org.entcore.timeline.events.SplitTimelineEventStore;
-import org.entcore.timeline.events.TimelineEventStore;
+import org.entcore.timeline.events.*;
import org.entcore.timeline.events.TimelineEventStore.AdminAction;
import org.entcore.timeline.services.TimelineConfigService;
import org.entcore.timeline.services.TimelineMailerService;
-import io.vertx.core.eventbus.Message;
-import io.vertx.core.http.HttpServerRequest;
-import io.vertx.core.json.JsonArray;
-import io.vertx.core.json.JsonObject;
import org.vertx.java.core.http.RouteMatcher;
import java.io.StringReader;
@@ -84,7 +79,7 @@
public class TimelineController extends BaseController {
private static final long IMMEDIATE_NOTIF_DELAY_BY_CHUNK = 30000L;
- public static Logger log = LoggerFactory.getLogger(TimelineController.class);
+ private static final Logger log = LoggerFactory.getLogger(TimelineController.class);
private TimelineEventStore store;
private TimelineConfigService configService;
@@ -869,32 +864,39 @@ public void handle(JsonObject event) {
switch (action) {
case "add":
final String sender = json.getString("sender");
- if (sender == null || sender.startsWith("no-reply") || json.getBoolean("disableAntiFlood", false) || antiFlood.add(sender)) {
+ final boolean disableAntiflood = json.getBoolean("disableAntiflood", false);
+
+ log.info(String.format("[Timeline.add] Add new notification from sender %s with antiflood activation = %s from module %s for resources %s ",
+ sender, !disableAntiflood, json.getString("type", ""), json.getString("resource", "")));
+ final boolean mustCheckAntiflood = (sender != null && !sender.startsWith("no-reply") && !disableAntiflood);
+
+ if (mustCheckAntiflood && antiFlood.contains(sender)) {
+ log.info(String.format("[Timeline.add] Sender %s has activate antiflood ", sender));
+ }
+ if ( !mustCheckAntiflood || antiFlood.add(sender)) {
this.removeMutersFromRecipientList(json)
.onComplete(notificationResult -> {
final JsonObject notification = notificationResult.succeeded() ? notificationResult.result() : json;
- store.add(notification, new Handler() {
- public void handle(JsonObject result) {
- // IF call only when recipient size > maxRecipientLength (10k by default)
- // timer for performance (thread block) reasons
- if (result.containsKey(SplitTimelineEventStore.CHUNKED_NOTIFICATIONS)) {
- final JsonArray chunkedNotifications = result.getJsonArray(SplitTimelineEventStore.CHUNKED_NOTIFICATIONS);
- result.remove(SplitTimelineEventStore.CHUNKED_NOTIFICATIONS);
- for (int i = 0; i < chunkedNotifications.size(); i++) {
- final JsonObject cn = chunkedNotifications.getJsonObject(i);
- vertx.setTimer((i* IMMEDIATE_NOTIF_DELAY_BY_CHUNK) + 1000L, t -> {
- final JsonArray chunkRecipientsIds = cn.getJsonArray("recipientsIds");
- log.info("Launch chunked immediate notification. Recipients : " +
- (chunkRecipientsIds != null ? chunkRecipientsIds.size():0));
- notificationHelper.sendImmediateNotifications(new JsonHttpServerRequest(cn.getJsonObject("request")), cn);
- });
- }
- } else {
- notificationHelper.sendImmediateNotifications(new JsonHttpServerRequest(notification.getJsonObject("request")), notification);
- }
- handler.handle(result);
- }
- });
+ store.add(notification, result -> {
+ // IF call only when recipient size > maxRecipientLength (10k by default)
+ // timer for performance (thread block) reasons
+ if (result.containsKey(SplitTimelineEventStore.CHUNKED_NOTIFICATIONS)) {
+ final JsonArray chunkedNotifications = result.getJsonArray(SplitTimelineEventStore.CHUNKED_NOTIFICATIONS);
+ result.remove(SplitTimelineEventStore.CHUNKED_NOTIFICATIONS);
+ for (int i = 0; i < chunkedNotifications.size(); i++) {
+ final JsonObject cn = chunkedNotifications.getJsonObject(i);
+ vertx.setTimer((i* IMMEDIATE_NOTIF_DELAY_BY_CHUNK) + 1000L, t -> {
+ final JsonArray chunkRecipientsIds = cn.getJsonArray("recipientsIds");
+ log.info("Launch chunked immediate notification. Recipients : " +
+ (chunkRecipientsIds != null ? chunkRecipientsIds.size():0));
+ notificationHelper.sendImmediateNotifications(new JsonHttpServerRequest(cn.getJsonObject("request")), cn);
+ });
+ }
+ } else {
+ notificationHelper.sendImmediateNotifications(new JsonHttpServerRequest(notification.getJsonObject("request")), notification);
+ }
+ handler.handle(result);
+ });
});
if (refreshTypesCache && eventTypes != null && !eventTypes.contains(json.getString("type"))) {
eventTypes = null;