diff --git a/client-html/src/js/common/components/documents/DocumentHeader.tsx b/client-html/src/js/common/components/documents/DocumentHeader.tsx index b190f4d9f9f..e9c9337d137 100644 --- a/client-html/src/js/common/components/documents/DocumentHeader.tsx +++ b/client-html/src/js/common/components/documents/DocumentHeader.tsx @@ -235,7 +235,7 @@ class DocumentHeader extends React.PureComponent { > {$t('who_can_view_this_document')} - {getDocumentShareSummary(item.access, item.attachmentRelations)} + {getDocumentShareSummary(item.displayAccess, item.attachmentRelations)} diff --git a/client-html/src/js/common/components/form/documents/DocumentShare.tsx b/client-html/src/js/common/components/form/documents/DocumentShare.tsx index 952d5b483ee..7dc40b84b82 100644 --- a/client-html/src/js/common/components/form/documents/DocumentShare.tsx +++ b/client-html/src/js/common/components/form/documents/DocumentShare.tsx @@ -314,8 +314,8 @@ const DocumentShare: React.FC = ({ const contactRelated = isSingleContactAttachment(documentSource.attachmentRelations); - const SummaryLabel = useMemo(() => getDocumentShareSummary(documentSource.access, documentSource.attachmentRelations), - [documentSource.access, documentSource.attachmentRelations]); + const SummaryLabel = useMemo(() => getDocumentShareSummary(documentSource.displayAccess, documentSource.attachmentRelations), + [documentSource.displayAccess, documentSource.attachmentRelations]); const linkOrPublic = ["Link", "Public"].includes(documentSource.access); diff --git a/client-html/src/js/common/utils/documents/index.ts b/client-html/src/js/common/utils/documents/index.ts index 99be7b66b53..fef06a4fae8 100644 --- a/client-html/src/js/common/utils/documents/index.ts +++ b/client-html/src/js/common/utils/documents/index.ts @@ -41,6 +41,17 @@ export const getDocumentShareSummary = ( : ', all tutors in skillsOnCourse portal'; break; } + case 'Students only': { + if (isSingleContactAttachment(attachmentRelations)) { + label += ', ' + attachmentRelations.map(r => + r.relatedContacts.map(c => c.name)); + break; + } + if (attachmentRelations.length) { + label += ', some students in skillsOnCourse portal'; + } + break; + } case 'Tutors and enrolled students': { if (isSingleContactAttachment(attachmentRelations)) { label += ', ' + attachmentRelations.map(r => @@ -127,4 +138,4 @@ export const groupAttachmentsByEntity = (attachmentRelations: DocumentAttachment export const getLatestDocumentItem = (data: DocumentVersion[]) => { if (data && data.length === 1) return data[0]; return data.find(elem => elem.current); -}; \ No newline at end of file +}; diff --git a/client-html/src/js/containers/entities/documents/Documents.tsx b/client-html/src/js/containers/entities/documents/Documents.tsx index 974e845faef..844fbb0ba5b 100644 --- a/client-html/src/js/containers/entities/documents/Documents.tsx +++ b/client-html/src/js/containers/entities/documents/Documents.tsx @@ -68,7 +68,7 @@ interface DocumentProps { setListFullScreenEditView?: BooleanArgFunction; } -const isRemoved = (value: string) => "isRemoved is " + value; +const isRemoved = (value: boolean) => "isRemoved is " + value; const filterGroups: FilterGroup[] = [ { @@ -76,27 +76,32 @@ const filterGroups: FilterGroup[] = [ filters: [ { name: "Website", - expression: "webVisibility is PUBLIC and " + isRemoved("false"), + expression: `displayWebVisibility is PUBLIC and ${isRemoved(false)}`, active: true }, { name: "Private", - expression: "webVisibility is PRIVATE and " + isRemoved("false"), + expression: `displayWebVisibility is PRIVATE and ${isRemoved(false)}`, active: true }, { name: "Tutors and enrolled students", - expression: "webVisibility is STUDENTS and " + isRemoved("false"), + expression: `displayWebVisibility is TUTORS_AND_ENROLLED_STUDENTS and ${isRemoved(false)}`, active: true }, { - name: "Tutors", - expression: "webVisibility is TUTORS and " + isRemoved("false"), + name: "Tutors only", + expression: `displayWebVisibility is TUTORS_ONLY and ${isRemoved(false)}`, + active: true + }, + { + name: "Students only", + expression: `displayWebVisibility is STUDENTS_ONLY and ${isRemoved(false)}`, active: true }, { name: "Linkable", - expression: "(webVisibility is PUBLIC or webVisibility is LINK) and " + isRemoved("false"), + expression: `(webVisibility is PUBLIC or webVisibility is LINK) and ${isRemoved(false)}`, active: true }, { @@ -108,7 +113,7 @@ const filterGroups: FilterGroup[] = [ ), - expression: isRemoved("true"), + expression: isRemoved(true), active: false } ] @@ -283,4 +288,4 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({ clearEditingDocument: () => dispatch(clearEditingDocument()) }); -export default connect(mapStateToProps, mapDispatchToProps)(withStyles(Documents, styles)); \ No newline at end of file +export default connect(mapStateToProps, mapDispatchToProps)(withStyles(Documents, styles)); diff --git a/server-api/src/main/resources/def/entity/Document.yaml b/server-api/src/main/resources/def/entity/Document.yaml index 8c27914c15e..4264f52287e 100644 --- a/server-api/src/main/resources/def/entity/Document.yaml +++ b/server-api/src/main/resources/def/entity/Document.yaml @@ -30,6 +30,8 @@ properties: type: string access: $ref: '../enum/DocumentVisibility.yaml' + displayAccess: + $ref: '../enum/DocumentVisibility.yaml' shared: type: boolean removed: diff --git a/server-api/src/main/resources/def/enum/DocumentVisibility.yaml b/server-api/src/main/resources/def/enum/DocumentVisibility.yaml index 5cd0371173a..f0f7eacb9f0 100644 --- a/server-api/src/main/resources/def/enum/DocumentVisibility.yaml +++ b/server-api/src/main/resources/def/enum/DocumentVisibility.yaml @@ -5,4 +5,5 @@ enum: - Public - Tutors and enrolled students - Tutors only + - Students only - Link diff --git a/server/src/main/groovy/ish/oncourse/server/api/service/DocumentApiService.groovy b/server/src/main/groovy/ish/oncourse/server/api/service/DocumentApiService.groovy index dd54df823db..e0c00d830b1 100644 --- a/server/src/main/groovy/ish/oncourse/server/api/service/DocumentApiService.groovy +++ b/server/src/main/groovy/ish/oncourse/server/api/service/DocumentApiService.groovy @@ -59,6 +59,7 @@ class DocumentApiService extends TaggableApiService TUTOR_RELATED_ENTITIES = List.of( + Assessment.class.simpleName, + CourseClass.class.simpleName, + Course.class.simpleName + ) /** * @return attached record diff --git a/server/src/main/groovy/ish/oncourse/server/cayenne/Document.groovy b/server/src/main/groovy/ish/oncourse/server/cayenne/Document.groovy index a50fcf9a317..d1f25c6343a 100644 --- a/server/src/main/groovy/ish/oncourse/server/cayenne/Document.groovy +++ b/server/src/main/groovy/ish/oncourse/server/cayenne/Document.groovy @@ -16,6 +16,7 @@ import ish.common.types.AttachmentInfoVisibility import ish.common.types.NodeType import ish.oncourse.API import ish.oncourse.cayenne.QueueableEntity +import ish.oncourse.server.api.v1.model.DocumentVisibilityDTO import ish.oncourse.server.cayenne.glue._Document import ish.oncourse.server.document.DocumentService import ish.oncourse.server.license.LicenseService @@ -36,6 +37,7 @@ class Document extends _Document implements DocumentTrait, Queueable { public static final String LINK_PROPERTY = "link" public static final String ACTIVE_PROPERTY = "active" public static final String CURRENT_VERSION_PROPERTY = "currentVersion" + public static final String DISPLAY_WEB_VISIBILITY_PROPERTY = "displayWebVisibility" @Inject private DocumentService documentService @@ -214,7 +216,27 @@ class Document extends _Document implements DocumentTrait, Queueable { String collegeKey = licenseService.getCollege_key() return collegeKey != null ? "https://${collegeKey}.cloud.oncourse.cc/document/${id}" : "" } -} - + /** + * Returns the display value of webVisibility, considering document attachments. + * + * @return this document's display visibility + */ + @API + DocumentVisibilityDTO getDisplayWebVisibility() { + boolean hasCourseAttachment = attachmentRelations.stream() + .map { it -> it.entityIdentifier } + .anyMatch { it -> AttachmentRelation.TUTOR_RELATED_ENTITIES.contains(it) } + + def visibility = DocumentVisibilityDTO.values()[0].fromDbType(webVisibility) + if (!hasCourseAttachment) { + if (visibility == DocumentVisibilityDTO.TUTORS_AND_ENROLLED_STUDENTS) { + return DocumentVisibilityDTO.STUDENTS_ONLY + } else if (visibility == DocumentVisibilityDTO.TUTORS_ONLY) { + return DocumentVisibilityDTO.PRIVATE + } + } + return visibility + } +} diff --git a/server/src/main/groovy/ish/oncourse/server/preference/DefaultUserPreference.groovy b/server/src/main/groovy/ish/oncourse/server/preference/DefaultUserPreference.groovy index 7864e4abc17..cd3a2694db2 100644 --- a/server/src/main/groovy/ish/oncourse/server/preference/DefaultUserPreference.groovy +++ b/server/src/main/groovy/ish/oncourse/server/preference/DefaultUserPreference.groovy @@ -305,7 +305,7 @@ class DefaultUserPreference { new ColumnDTO(title: 'Document name', attribute: Document.NAME.name, sortable: true, width: W200, visible: true), new ColumnDTO(title: 'Date added', attribute: Document.ADDED.name, sortable: true, width: W200, visible: true, type: ColumnTypeDTO.DATE), new ColumnDTO(title: 'Size', attribute: Document.CURRENT_VERSION_PROPERTY + "." + DocumentVersion.BYTE_SIZE.name, sortable: false, width: W200, visible: true), - new ColumnDTO(title: 'Security level', attribute: Document.WEB_VISIBILITY.name, sortable: true, width: W100, visible: true), + new ColumnDTO(title: 'Security level', attribute: Document.DISPLAY_WEB_VISIBILITY_PROPERTY, sortable: false, width: W100, visible: true, prefetches: [Document.ATTACHMENT_RELATIONS.path().toString()]), new ColumnDTO(title: 'File name', attribute: Document.CURRENT_VERSION_PROPERTY + "." + DocumentVersion.FILE_NAME.name, sortable: false, width: W100, visible: true), new ColumnDTO(title: 'Type', attribute: Document.CURRENT_VERSION_PROPERTY + "." + DocumentVersion.MIME_TYPE.name, sortable: false, width: W100, visible: true), new ColumnDTO(title: 'Active', attribute: Document.ACTIVE_PROPERTY, sortable: false, width: W100, visible: false, system: true, type: ColumnTypeDTO.BOOLEAN) diff --git a/server/src/main/java/ish/oncourse/aql/model/EntityFactory.java b/server/src/main/java/ish/oncourse/aql/model/EntityFactory.java index f206ec3c426..1fe3e400512 100644 --- a/server/src/main/java/ish/oncourse/aql/model/EntityFactory.java +++ b/server/src/main/java/ish/oncourse/aql/model/EntityFactory.java @@ -56,6 +56,7 @@ public class EntityFactory { CourseClassEnrolmentMin.class, CourseClassSessionsCount.class, CourseClassIsDistantLearningCourse.class, + DocumentDisplayWebVisibility.class, EnrolmentIsClassCompleted.class, SessionTutor.class, PaymentInBanking.class, @@ -196,4 +197,3 @@ private boolean entityIsTaggable(String entityName){ } } - diff --git a/server/src/main/java/ish/oncourse/aql/model/attribute/DocumentDisplayWebVisibility.java b/server/src/main/java/ish/oncourse/aql/model/attribute/DocumentDisplayWebVisibility.java new file mode 100644 index 00000000000..75e34ea83b2 --- /dev/null +++ b/server/src/main/java/ish/oncourse/aql/model/attribute/DocumentDisplayWebVisibility.java @@ -0,0 +1,46 @@ +/* + * Copyright ish group pty ltd 2025. + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU Affero General Public License version 3 as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + */ + +package ish.oncourse.aql.model.attribute; + +import ish.oncourse.aql.model.EntityFactory; +import ish.oncourse.aql.model.SyntheticAttributeDescriptor; +import ish.oncourse.server.cayenne.Document; +import org.apache.cayenne.Persistent; +import org.apache.cayenne.exp.parser.SimpleNode; + +import java.util.Optional; + +public class DocumentDisplayWebVisibility implements SyntheticAttributeDescriptor { + + public DocumentDisplayWebVisibility(EntityFactory entityFactory) { + } + + @Override + public Class getEntityType() { + return Document.class; + } + + @Override + public String getAttributeName() { + return Document.DISPLAY_WEB_VISIBILITY_PROPERTY; + } + + @Override + public SimpleNode spawnNode() { + return new SyntheticDocumentDisplayWebVisibilityNode(); + } + + @Override + public Optional> getAttributeType() { + return Optional.of(String.class); + } +} diff --git a/server/src/main/java/ish/oncourse/aql/model/attribute/SyntheticDocumentDisplayWebVisibilityNode.java b/server/src/main/java/ish/oncourse/aql/model/attribute/SyntheticDocumentDisplayWebVisibilityNode.java new file mode 100644 index 00000000000..0de0e86cc6d --- /dev/null +++ b/server/src/main/java/ish/oncourse/aql/model/attribute/SyntheticDocumentDisplayWebVisibilityNode.java @@ -0,0 +1,193 @@ +/* + * Copyright ish group pty ltd 2025. + * + * This program is free software: you can redistribute it and/or modify it under the terms of the + * GNU Affero General Public License version 3 as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + */ + +package ish.oncourse.aql.model.attribute; + +import ish.common.types.AttachmentInfoVisibility; +import ish.oncourse.aql.impl.CompilationContext; +import ish.oncourse.aql.impl.LazyExpressionNode; +import ish.oncourse.server.api.v1.model.DocumentVisibilityDTO; +import ish.oncourse.server.cayenne.*; +import org.apache.cayenne.ObjectContext; +import org.apache.cayenne.exp.Expression; +import org.apache.cayenne.exp.ExpressionFactory; +import org.apache.cayenne.exp.parser.*; +import org.apache.cayenne.query.ObjectSelect; + +import java.util.List; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Implements a special node for handling Document web visibility filtering in AQL queries. + *

+ * This class extends {@link LazyExpressionNode} to provide custom resolution logic for the synthetic + * {@link Document#DISPLAY_WEB_VISIBILITY_PROPERTY} attribute, which abstracts the complex visibility rules + * for documents based on their {@link Document#WEB_VISIBILITY} property and attachment relationships. + *

+ * The node translates high-level visibility concepts (PUBLIC, PRIVATE, etc.) into appropriate Cayenne + * expressions that consider both DB visibility settings and contextual factors like whether + * documents are tutor-related. + * + * @see DocumentDisplayWebVisibility + * @see Document#WEB_VISIBILITY + * @see DocumentVisibilityDTO + */ +public class SyntheticDocumentDisplayWebVisibilityNode extends LazyExpressionNode { + + private static final String WEB_VISIBILITY_PATH = Document.WEB_VISIBILITY.getName(); + private static final String ID_PATH = Document.ID.getName(); + private static final String ATTACHMENT_RELATIONS_PATH = Document.ATTACHMENT_RELATIONS.getName(); + private static final String ENTITY_IDENTIFIER_PATH = AttachmentRelation.ENTITY_IDENTIFIER.getName(); + + /** + * Predicate to check if a document is attached to tutors-related entities. + */ + private static final Predicate DOCUMENT_TUTOR_RELATED = doc -> doc.getAttachmentRelations().stream() + .map(AttachmentRelation::getEntityIdentifier) + .anyMatch(AttachmentRelation.TUTOR_RELATED_ENTITIES::contains); + + /** + * Strategy enum for different visibility processing approaches. + */ + private enum VisibilityStrategy { + DIRECT_MATCH, // PUBLIC, LINK + COURSE_RELATED_ONLY, // TUTORS_ONLY, TUTORS_AND_ENROLLED_STUDENTS + COMPLEX_FILTERING // PRIVATE, STUDENTS_ONLY + } + + @Override + public SimpleNode resolveSelf(CompilationContext ctx) { + return this; + } + + @Override + public SimpleNode resolveParent(SimpleNode parent, List args, CompilationContext ctx) { + if (parent.getType() != Expression.EQUAL_TO) { + throw new IllegalArgumentException("Unsupported operation: " + parent.getClass().getSimpleName()); + } + if (args.size() < 3) { + throw new IllegalArgumentException("Not sufficient arguments to resolve " + Document.DISPLAY_WEB_VISIBILITY_PROPERTY); + } + if (!(args.get(1) instanceof ASTObjPath)) { + throw new IllegalArgumentException("Argument 2 must be a path"); + } + if (!(args.get(2) instanceof ASTScalar)) { + throw new IllegalArgumentException("Argument 3 must be a scalar"); + } + + ASTObjPath pathNode = (ASTObjPath) args.get(1); + Object value = ((ASTScalar) args.get(2)).getValue(); + DocumentVisibilityDTO visibility = DocumentVisibilityDTO.valueOf(value.toString()); + + String prefix = extractPrefix(pathNode.getPath()); + + return createExpressionForVisibility(visibility, prefix, ctx.getContext()); + } + + private String extractPrefix(String path) { + int index = path.lastIndexOf("." + Document.DISPLAY_WEB_VISIBILITY_PROPERTY); + return index <= 0 ? "" : path.substring(0, index); + } + + private VisibilityStrategy getVisibilityStrategy(DocumentVisibilityDTO visibility) { + switch (visibility) { + case PUBLIC: + case LINK: + return VisibilityStrategy.DIRECT_MATCH; + case TUTORS_ONLY: + case TUTORS_AND_ENROLLED_STUDENTS: + return VisibilityStrategy.COURSE_RELATED_ONLY; + case PRIVATE: + case STUDENTS_ONLY: + return VisibilityStrategy.COMPLEX_FILTERING; + default: + throw new IllegalArgumentException("Unsupported document visibility: " + visibility); + } + } + + private SimpleNode createExpressionForVisibility(DocumentVisibilityDTO visibility, String prefix, ObjectContext context) { + switch (getVisibilityStrategy(visibility)) { + case DIRECT_MATCH: + return createDirectMatchExpression(visibility, prefix); + case COURSE_RELATED_ONLY: + return createCourseRelatedExpression(visibility, prefix); + case COMPLEX_FILTERING: + return createComplexFilteringExpression(visibility, prefix, context); + default: + throw new IllegalArgumentException("Unsupported document visibility: " + visibility); + } + } + + private SimpleNode createDirectMatchExpression(DocumentVisibilityDTO visibility, String prefix) { + String webVisibilityPath = objPath(prefix, WEB_VISIBILITY_PATH); + return (SimpleNode) ExpressionFactory.matchExp(webVisibilityPath, visibility); + } + + private SimpleNode createCourseRelatedExpression(DocumentVisibilityDTO visibility, String prefix) { + String webVisibilityPath = objPath(prefix, WEB_VISIBILITY_PATH); + Expression visibilityMatch = ExpressionFactory.matchExp(webVisibilityPath, visibility); + + String relationPath = objPath(prefix, ATTACHMENT_RELATIONS_PATH + "." + ENTITY_IDENTIFIER_PATH); + Expression courseRelatedMatch = ExpressionFactory.inExp(relationPath, AttachmentRelation.TUTOR_RELATED_ENTITIES); + + return (SimpleNode) ExpressionFactory.and(visibilityMatch, courseRelatedMatch); + } + + private SimpleNode createComplexFilteringExpression(DocumentVisibilityDTO visibility, String prefix, ObjectContext context) { + switch (visibility) { + case PRIVATE: + return createPrivateVisibilityExpression(prefix, context); + case STUDENTS_ONLY: + return createStudentsOnlyVisibilityExpression(prefix, context); + default: + throw new IllegalArgumentException("Unsupported complex visibility: " + visibility); + } + } + + private SimpleNode createPrivateVisibilityExpression(String prefix, ObjectContext context) { + String webVisibilityPath = objPath(prefix, WEB_VISIBILITY_PATH); + Expression privateDocsMatch = ExpressionFactory.matchExp(webVisibilityPath, AttachmentInfoVisibility.PRIVATE); + + Predicate notTutorRelated = DOCUMENT_TUTOR_RELATED.negate(); + List effectivelyPrivateDocsIds = findDocumentsByPredicate(context, AttachmentInfoVisibility.TUTORS, notTutorRelated); + + String idPath = objPath(prefix, ID_PATH); + Expression effectivelyPrivateDocsMatch = ExpressionFactory.inExp(idPath, effectivelyPrivateDocsIds); + + return (SimpleNode) ExpressionFactory.or(privateDocsMatch, effectivelyPrivateDocsMatch); + } + + private SimpleNode createStudentsOnlyVisibilityExpression(String prefix, ObjectContext context) { + Predicate notTutorRelated = DOCUMENT_TUTOR_RELATED.negate(); + List studentsOnlyDocsIds = findDocumentsByPredicate(context, AttachmentInfoVisibility.STUDENTS, notTutorRelated); + + String idPath = objPath(prefix, ID_PATH); + return (SimpleNode) ExpressionFactory.inExp(idPath, studentsOnlyDocsIds); + } + + private String objPath(String prefix, String property) { + return prefix.isEmpty() ? property : prefix + "." + property; + } + + private List findDocumentsByPredicate(ObjectContext context, AttachmentInfoVisibility visibility, + Predicate predicate) { + List documents = ObjectSelect.query(Document.class) + .where(Document.WEB_VISIBILITY.eq(visibility)) + .prefetch(Document.ATTACHMENT_RELATIONS.disjointById()) + .select(context); + + return documents.stream() + .filter(predicate) + .map(Document::getId) + .collect(Collectors.toList()); + } +}