Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

draft-api/article-api: Add disclaimer field #562

Merged
merged 6 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ trait ContentValidator {
def validateArticle(article: Article, isImported: Boolean): Try[Article] = {
val validationErrors = validateArticleContent(article.content) ++
article.introduction.flatMap(i => validateIntroduction(i)) ++
validateArticleDisclaimer(article.disclaimer.getOrElse(Seq.empty)) ++
validateMetaDescription(article.metaDescription, isImported) ++
validateTitle(article.title) ++
validateCopyright(article.copyright) ++
Expand Down Expand Up @@ -89,6 +90,14 @@ trait ContentValidator {
}) ++ validateNonEmpty("content", contents)
}

private def validateArticleDisclaimer(disclaimers: Seq[Disclaimer]): Seq[ValidationMessage] = {
disclaimers.flatMap(disclaimer => {
val field = s"disclaimer.${disclaimer.language}"
TextValidator.validate(field, disclaimer.disclaimer, allLegalTags).toList ++
validateLanguage("content.language", disclaimer.language)
})
}

private def rootElementContainsOnlySectionBlocks(field: String, html: String): Option[ValidationMessage] = {
val legalTopLevelTag = "section"
val topLevelTags = stringToJsoupDocument(html).children().asScala.map(_.tagName())
Expand Down
15 changes: 10 additions & 5 deletions article-api/src/test/scala/no/ndla/articleapi/TestData.scala
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ trait TestData {
availability = Availability.everyone,
relatedContent = Seq.empty,
revisionDate = Some(NDLADate.now().withNano(0)),
slug = None
slug = None,
disclaimer = None
)

val sampleDomainArticle: Article = Article(
Expand All @@ -177,7 +178,8 @@ trait TestData {
availability = Availability.everyone,
relatedContent = Seq.empty,
revisionDate = None,
slug = None
slug = None,
disclaimer = None
)

val sampleDomainArticle2: Article = Article(
Expand All @@ -202,7 +204,8 @@ trait TestData {
availability = Availability.everyone,
relatedContent = Seq.empty,
revisionDate = None,
slug = None
slug = None,
disclaimer = None
)

val sampleArticleWithByNcSa: Article = sampleArticleWithPublicDomain.copy(copyright = byNcSaCopyright)
Expand Down Expand Up @@ -239,7 +242,8 @@ trait TestData {
availability = Availability.everyone,
relatedContent = Seq.empty,
revisionDate = None,
slug = None
slug = None,
disclaimer = None
)

val apiArticleWithHtmlFaultV2: api.ArticleV2DTO = api.ArticleV2DTO(
Expand Down Expand Up @@ -308,7 +312,8 @@ trait TestData {
availability = Availability.everyone,
relatedContent = Seq.empty,
revisionDate = None,
slug = None
slug = None,
disclaimer = None
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import no.ndla.common.model.domain.{
ArticleMetaImage,
Author,
Description,
Disclaimer,
Introduction,
RequiredLibrary,
Tag,
Expand All @@ -30,6 +31,8 @@ class ContentValidatorTest extends UnitSuite with TestEnvironment {
val validDocument = """<section><h1>heisann</h1><h2>heia</h2></section>"""
val validIntroduction = """<p>heisann <span lang="en">heia</span></p><p>hopp</p>"""
val invalidDocument = """<section><invalid></invalid></section>"""
val validDisclaimer =
"""<p><strong>hallo!</strong><ndlaembed data-content-id="123" data-open-in="current-context" data-resource="content-link" data-content-type="article">test</ndlaembed></p>"""

test("validateArticle does not throw an exception on a valid document") {
val article = TestData.sampleArticleWithByNcSa.copy(content = Seq(ArticleContent(validDocument, "nb")))
Expand Down Expand Up @@ -77,6 +80,44 @@ class ContentValidatorTest extends UnitSuite with TestEnvironment {
contentValidator.validateArticle(article, false).isSuccess should be(true)
}

test("validateArticle should throw an error if disclaimer contains illegal HTML tags") {
val article = TestData.sampleArticleWithByNcSa.copy(
content = Seq(ArticleContent(validDocument, "nb")),
disclaimer = Some(Seq(Disclaimer("<p><hallo>hei</hallo></p>", "nb")))
)

val Failure(error: ValidationException) = contentValidator.validateArticle(article, false)
error should be(
ValidationException(
"disclaimer.nb",
"The content contains illegal tags and/or attributes. Allowed HTML tags are: h3, msgroup, a, article, sub, sup, mtext, msrow, tbody, mtd, pre, thead, figcaption, mover, msup, semantics, ol, span, mroot, munder, h4, mscarries, dt, nav, mtr, ndlaembed, li, br, mrow, merror, mphantom, u, audio, ul, maligngroup, mfenced, annotation, div, strong, section, i, mspace, malignmark, mfrac, code, h2, td, aside, em, mstack, button, dl, th, tfoot, math, tr, b, blockquote, msline, col, annotation-xml, mstyle, caption, mpadded, mo, mlongdiv, msubsup, p, munderover, maction, menclose, h1, details, mmultiscripts, msqrt, mscarry, mstac, mi, mglyph, mlabeledtr, mtable, mprescripts, summary, mn, msub, ms, table, colgroup, dd"
)
)
}

test("validateArticle should not throw an error if disclaimer contains legal HTML tags") {
val article = TestData.sampleArticleWithByNcSa.copy(
content = Seq(ArticleContent(validDocument, "nb")),
disclaimer = Some(
Seq(
Disclaimer(
validDisclaimer,
"nb"
)
)
)
)
contentValidator.validateArticle(article, false).isSuccess should be(true)
}

test("validateArticle should not throw an error if disclaimer contains plain text") {
val article = TestData.sampleArticleWithByNcSa.copy(
content = Seq(ArticleContent(validDocument, "nb")),
disclaimer = Some(Seq(Disclaimer("disclaimer", "nb")))
)
contentValidator.validateArticle(article, false).isSuccess should be(true)
}

test("validateArticle should throw an error if metaDescription contains HTML tags") {
val article = TestData.sampleArticleWithByNcSa.copy(
content = Seq(ArticleContent(validDocument, "nb")),
Expand Down
22 changes: 22 additions & 0 deletions common/src/main/scala/no/ndla/common/model/domain/Disclaimer.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Part of NDLA common
* Copyright (C) 2024 NDLA
*
* See LICENSE
*/

package no.ndla.common.model.domain

import io.circe.{Decoder, Encoder}
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
import no.ndla.language.model.LanguageField

case class Disclaimer(disclaimer: String, language: String) extends LanguageField[String] {
override def value: String = disclaimer
override def isEmpty: Boolean = disclaimer.isEmpty
}

object Disclaimer {
implicit def encoder: Encoder[Disclaimer] = deriveEncoder[Disclaimer]
implicit def decoder: Decoder[Disclaimer] = deriveDecoder[Disclaimer]
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ case class Article(
availability: Availability,
relatedContent: Seq[RelatedContent],
revisionDate: Option[NDLADate],
slug: Option[String]
slug: Option[String],
disclaimer: Option[Seq[Disclaimer]]
) extends Content

object Article {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ case class Draft(
comments: Seq[Comment],
priority: Priority,
started: Boolean,
qualityEvaluation: Option[QualityEvaluation]
qualityEvaluation: Option[QualityEvaluation],
disclaimer: Option[Seq[Disclaimer]]
) extends Content {

def supportedLanguages: Seq[String] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ case class ArticleDTO(
@description("If the article should be prioritized. Possible values are prioritized, on-hold, unspecified") priority: String,
@description("If the article has been edited after last status or responsible change") started: Boolean,
@description("The quality evaluation of the article. Consist of a score from 1 to 5 and a comment.") qualityEvaluation : Option[QualityEvaluationDTO],
@description("The disclaimer of the article") disclaimer: Option[DisclaimerDTO]
)

object ArticleDTO {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Part of NDLA draft-api
* Copyright (C) 2024 NDLA
*
* See LICENSE
*/

package no.ndla.draftapi.model.api

import io.circe.{Decoder, Encoder}
import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder}
import sttp.tapir.Schema.annotations.description

case class DisclaimerDTO(
@description("The freetext html content of the disclaimer") disclaimer: String,
@description("ISO 639-1 code that represents the language used in the disclaimer") language: String
)

object DisclaimerDTO {
implicit def encoder: Encoder[DisclaimerDTO] = deriveEncoder
implicit def decoder: Decoder[DisclaimerDTO] = deriveDecoder
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ case class NewArticleDTO(
@description("If the article should be prioritized") prioritized: Option[Boolean],
@description("If the article should be prioritized. Possible values are prioritized, on-hold, unspecified") priority: Option[String],
@description("The quality evaluation of the article. Consist of a score from 1 to 5 and a comment.") qualityEvaluation : Option[QualityEvaluationDTO],
@description("The disclaimer of the article") disclaimer: Option[String]
)
// format: on

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ case class UpdatedArticleDTO(
@description("If the article should be prioritized") prioritized: Option[Boolean],
@description("If the article should be prioritized. Possible values are prioritized, on-hold, unspecified") priority: Option[String],
@description("The quality evaluation of the article. Consist of a score from 1 to 5 and a comment.") qualityEvaluation : Option[QualityEvaluationDTO],
@description("The disclaimer of the article") disclaimer: Option[String]
)
// format: on

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ trait ConverterService {
val domainContent = newArticle.content
.map(content => common.ArticleContent(removeUnknownEmbedTagAttribute(content), newArticle.language))
.toSeq
val domainDisclaimer = newArticle.disclaimer.map { d => Seq(common.Disclaimer(d, newArticle.language)) }

val status = externalIds match {
case Nil => common.Status(PLANNED, Set.empty)
Expand Down Expand Up @@ -124,7 +125,8 @@ trait ConverterService {
comments = newCommentToDomain(newArticle.comments.getOrElse(List.empty)),
priority = priority,
started = false,
qualityEvaluation = qualityEvaluationToDomain(newArticle.qualityEvaluation)
qualityEvaluation = qualityEvaluationToDomain(newArticle.qualityEvaluation),
disclaimer = domainDisclaimer
)
)
}
Expand Down Expand Up @@ -257,6 +259,9 @@ trait ConverterService {
private def toDomainTitle(articleTitle: api.ArticleTitleDTO): common.Title =
common.Title(articleTitle.title, articleTitle.language)

private def toDomainDisclaimer(articleDisclaimer: api.DisclaimerDTO): common.Disclaimer =
common.Disclaimer(articleDisclaimer.disclaimer, articleDisclaimer.language)

private def toDomainContent(articleContent: api.ArticleContentDTO): common.ArticleContent = {
common.ArticleContent(removeUnknownEmbedTagAttribute(articleContent.content), articleContent.language)
}
Expand Down Expand Up @@ -386,6 +391,8 @@ trait ConverterService {
val metaImage = findByLanguageOrBestEffort(article.metaImage, language).map(toApiArticleMetaImage)
val revisionMetas = article.revisionMeta.map(toApiRevisionMeta)
val responsible = article.responsible.map(toApiResponsible)
val disclaimer =
article.disclaimer.flatMap { d => findByLanguageOrBestEffort(d, language).map(toApiArticleDisclaimer) }

Success(
api.ArticleDTO(
Expand Down Expand Up @@ -421,7 +428,8 @@ trait ConverterService {
prioritized = article.priority == Priority.Prioritized,
priority = article.priority.entryName,
started = article.started,
qualityEvaluation = toApiQualityEvaluation(article.qualityEvaluation)
qualityEvaluation = toApiQualityEvaluation(article.qualityEvaluation),
disclaimer = disclaimer
)
)
} else {
Expand Down Expand Up @@ -453,6 +461,12 @@ trait ConverterService {
def toApiArticleTitle(title: common.Title): api.ArticleTitleDTO =
api.ArticleTitleDTO(Jsoup.parseBodyFragment(title.title).body().text(), title.title, title.language)

private def toApiArticleDisclaimer(disclaimer: common.Disclaimer): api.DisclaimerDTO =
api.DisclaimerDTO(
disclaimer.disclaimer,
disclaimer.language
)

private def toApiArticleContent(content: common.ArticleContent): api.ArticleContentDTO =
api.ArticleContentDTO(content.content, content.language)

Expand Down Expand Up @@ -620,7 +634,8 @@ trait ConverterService {
availability = draft.availability,
relatedContent = draft.relatedContent,
revisionDate = getNextRevision(draft.revisionMeta).map(_.revisionDate),
slug = draft.slug
slug = draft.slug,
disclaimer = draft.disclaimer
)
)
}
Expand Down Expand Up @@ -781,6 +796,17 @@ trait ConverterService {
.traverse(lang => articleWithNewContent.title.toSeq.map(t => toDomainTitle(api.ArticleTitleDTO(t, t, lang))))
.flatten
)

val updatedDisclaimer = articleWithNewContent.disclaimer match {
case None => toMergeInto.disclaimer
case Some(newDisclaimer) =>
val updated = mergeLanguageFields(
toMergeInto.disclaimer.getOrElse(Seq.empty),
maybeLang.map(lang => toDomainDisclaimer(api.DisclaimerDTO(newDisclaimer, lang))).toSeq
)
Option.when(updated.nonEmpty)(updated)
}

val updatedContents = mergeLanguageFields(
toMergeInto.content,
maybeLang
Expand Down Expand Up @@ -845,7 +871,8 @@ trait ConverterService {
comments = updatedComments,
priority = priority,
started = toMergeInto.started,
qualityEvaluation = qualityEvaluationToDomain(article.qualityEvaluation)
qualityEvaluation = qualityEvaluationToDomain(article.qualityEvaluation),
disclaimer = updatedDisclaimer
)

Success(converted)
Expand Down Expand Up @@ -933,7 +960,8 @@ trait ConverterService {
comments = comments,
priority = priority,
started = false,
qualityEvaluation = qualityEvaluationToDomain(article.qualityEvaluation)
qualityEvaluation = qualityEvaluationToDomain(article.qualityEvaluation),
disclaimer = article.disclaimer.map { d => Seq(common.Disclaimer(d, lang)) }
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ trait ContentValidator {
if (shouldValidateEntireArticle)
article.content.flatMap(c => validateArticleContent(c)) ++
article.introduction.flatMap(i => validateIntroduction(i)) ++
validateArticleDisclaimer(article.disclaimer.getOrElse(Seq.empty)) ++
article.metaDescription.flatMap(m => validateMetaDescription(m)) ++
validateTitles(article.title) ++
article.copyright.map(x => validateCopyright(x)).toSeq.flatten ++
Expand Down Expand Up @@ -164,6 +165,13 @@ trait ContentValidator {
validateLanguage("content.language", content.language)
}

private def validateArticleDisclaimer(disclaimers: Seq[Disclaimer]): Seq[ValidationMessage] = {
disclaimers.flatMap(disclaimer => {
TextValidator.validate("disclaimer", disclaimer.disclaimer, allLegalTags).toList ++
validateLanguage("disclaimer.language", disclaimer.language)
})
}

private def rootElementContainsOnlySectionBlocks(field: String, html: String): Option[ValidationMessage] = {
val legalTopLevelTag = "section"
val topLevelTags = stringToJsoupDocument(html).children().asScala.map(_.tagName())
Expand Down
Loading
Loading