Refactoring of updateOpinions (route/repo/query)

Can nw be set multiple opinion on sigle query
fix OpinionVoter on CREATE
This commit is contained in:
2020-03-22 00:53:08 +01:00
parent 479793503c
commit 589b6f5245
10 changed files with 151 additions and 56 deletions

View File

@@ -1,8 +1,9 @@
package fr.dcproject.repository package fr.dcproject.repository
import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.core.type.TypeReference
import fr.dcproject.entity.Article import fr.dcproject.entity.ArticleRef
import fr.dcproject.entity.OpinionAggregation import fr.dcproject.entity.CitizenRef
import fr.dcproject.entity.OpinionChoiceRef
import fr.dcproject.entity.TargetRef import fr.dcproject.entity.TargetRef
import fr.postgresjson.connexion.Paginated import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester import fr.postgresjson.connexion.Requester
@@ -44,6 +45,16 @@ open class OpinionChoice(override val requester: Requester) : RepositoryI {
"id" to id "id" to id
) )
/**
* find one opinion choices by id
*/
fun findOpinionChoicesByIds(ids: List<UUID>): List<OpinionChoiceEntity> =
requester
.getFunction("find_opinion_choices_by_ids")
.select(
"ids" to ids
)
fun upsertOpinionChoice(opinionChoice: OpinionChoiceEntity): OpinionChoiceEntity = requester fun upsertOpinionChoice(opinionChoice: OpinionChoiceEntity): OpinionChoiceEntity = requester
.getFunction("upsert_opinion_choice") .getFunction("upsert_opinion_choice")
.selectOne( .selectOne(
@@ -51,20 +62,13 @@ open class OpinionChoice(override val requester: Requester) : RepositoryI {
)!! )!!
} }
open class Opinion<T : TargetRef>(requester: Requester) : OpinionChoice(requester) { abstract class Opinion<T : TargetRef>(requester: Requester) : OpinionChoice(requester) {
/** /**
* Create an Opinion on target (article,...) * Create an Opinion on target (article,...)
*/ */
fun opinion(opinion: OpinionEntity<T>): OpinionAggregation { abstract fun updateOpinions(choices: List<OpinionChoiceRef>, citizen: CitizenRef, target: TargetRef): List<OpinionEntity<T>>
return requester fun updateOpinions(choice: OpinionChoiceRef, citizen: CitizenRef, target: TargetRef): List<OpinionEntity<T>> =
.getFunction("opinion") updateOpinions(listOf(choice), citizen, target)
.selectOne(
"reference" to opinion.target.reference,
"target_id" to opinion.target.id,
"opinion" to opinion.id,
"created_by_id" to opinion.createdBy
)!!
}
/** /**
* Find opinions of one citizen filtered by target ids * Find opinions of one citizen filtered by target ids
@@ -124,13 +128,18 @@ open class Opinion<T : TargetRef>(requester: Requester) : OpinionChoice(requeste
} }
} }
class OpinionArticle(requester: Requester) : Opinion<Article>(requester) { class OpinionArticle(requester: Requester) : Opinion<ArticleRef>(requester) {
/** /**
* Create an Opinion on Article * Create an Opinions on Article
*/ */
fun opinion(opinion: OpinionArticleEntity): OpinionArticleEntity { override fun updateOpinions(choices: List<OpinionChoiceRef>, citizen: CitizenRef, target: TargetRef): List<OpinionArticleEntity> {
return requester return requester
.getFunction("upsert_opinion") .getFunction("update_citizen_opinions_by_target_id")
.selectOne(opinion) ?: error("query 'upsert_opinion' return null") .select(
"choices_ids" to choices.map { it.id },
"citizen_id" to citizen.id,
"target_id" to target.id,
"target_reference" to target.reference
)
} }
} }

View File

@@ -1,19 +1,16 @@
package fr.dcproject.routes package fr.dcproject.routes
import fr.dcproject.citizen import fr.dcproject.citizen
import fr.dcproject.entity.Citizen
import fr.dcproject.entity.CitizenRef import fr.dcproject.entity.CitizenRef
import fr.dcproject.entity.OpinionArticle
import fr.dcproject.entity.OpinionChoiceRef import fr.dcproject.entity.OpinionChoiceRef
import fr.dcproject.entity.request.RequestBuilder import fr.dcproject.entity.request.RequestBuilder
import fr.dcproject.entity.request.getContent import fr.dcproject.entity.request.getContent
import fr.dcproject.repository.OpinionChoice import fr.dcproject.security.voter.OpinionVoter.Action.CREATE
import fr.dcproject.security.voter.OpinionVoter.Action.VIEW import fr.dcproject.security.voter.OpinionVoter.Action.VIEW
import fr.dcproject.security.voter.assertCan import fr.dcproject.security.voter.assertCan
import fr.dcproject.utils.toUUID import fr.dcproject.utils.toUUID
import io.ktor.application.ApplicationCall import io.ktor.application.ApplicationCall
import io.ktor.application.call import io.ktor.application.call
import io.ktor.features.BadRequestException
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location import io.ktor.locations.Location
@@ -47,25 +44,14 @@ object OpinionArticlePaths {
*/ */
@Location("/articles/{article}/opinions") @Location("/articles/{article}/opinions")
@KtorExperimentalAPI @KtorExperimentalAPI
class ArticleOpinion(val article: ArticleEntity) : RequestBuilder<OpinionArticle> { class ArticleOpinion(val article: ArticleEntity) : RequestBuilder<List<OpinionChoiceRef>> {
private class Content( private class Content(ids: List<String>) : KoinComponent {
opinionChoice: String val ids = ids.map { it.toUUID() }
) : KoinComponent {
val opinionChoice = OpinionChoiceRef(opinionChoice.toUUID())
fun create(citizen: Citizen, article: ArticleEntity): OpinionArticle {
return OpinionArticle(
choice = get<OpinionChoice>().findOpinionChoiceById(opinionChoice.id) ?: throw BadRequestException("OpinionChoice not exist: id(${opinionChoice.id})"),
target = article,
createdBy = citizen
)
}
} }
override suspend fun getContent(call: ApplicationCall): OpinionArticle { override suspend fun getContent(call: ApplicationCall): List<OpinionChoiceRef> =
return call.receive<Content>().create(call.citizen, article) call.receive<Content>().ids.map { OpinionChoiceRef(it) }
}
} }
/** /**
@@ -95,9 +81,9 @@ fun Route.opinionArticle(repo: OpinionArticleRepository) {
put<OpinionArticlePaths.ArticleOpinion> { put<OpinionArticlePaths.ArticleOpinion> {
call.getContent(it) call.getContent(it)
.let { opinion -> .let { choices ->
assertCan(VIEW, opinion) assertCan(CREATE, it.article)
repo.opinion(opinion) repo.updateOpinions(choices, citizen, it.article)
}.let { }.let {
call.respond(HttpStatusCode.Created, it) call.respond(HttpStatusCode.Created, it)
} }

View File

@@ -1,5 +1,6 @@
package fr.dcproject.security.voter package fr.dcproject.security.voter
import fr.dcproject.entity.ArticleAuthI
import fr.dcproject.entity.Opinion import fr.dcproject.entity.Opinion
import io.ktor.application.ApplicationCall import io.ktor.application.ApplicationCall
@@ -12,13 +13,17 @@ class OpinionVoter : Voter {
override fun supports(action: ActionI, call: ApplicationCall, subject: Any?): Boolean { override fun supports(action: ActionI, call: ApplicationCall, subject: Any?): Boolean {
return (action is Action) return (action is Action)
.and(subject is Opinion<*>?) .and(subject is Opinion<*>? || subject is ArticleAuthI<*>)
} }
override fun vote(action: ActionI, call: ApplicationCall, subject: Any?): Vote { override fun vote(action: ActionI, call: ApplicationCall, subject: Any?): Vote {
val user = call.user val user = call.user
if (action == Action.CREATE) { if (action == Action.CREATE) {
return if (user != null) Vote.GRANTED else Vote.DENIED return if (user != null && (
(subject is ArticleAuthI<*> && !subject.isDeleted()) ||
(subject is Opinion<*> && subject.createdBy.user.id == user.id)
)) Vote.GRANTED
else Vote.DENIED
} }
if (action == Action.VIEW) { if (action == Action.VIEW) {

View File

@@ -38,14 +38,15 @@ enum class Vote {
private val votersAttributeKey = AttributeKey<List<Voter>>("voters") private val votersAttributeKey = AttributeKey<List<Voter>>("voters")
fun ApplicationCall.assertCan(action: ActionI, subject: Any? = null) { fun ApplicationCall.assertCan(action: ActionI, subject: Any? = null, agreeIfNullOrEmpty: Boolean = true) {
if (!can(action, subject)) { val isNullOrEmpty = (subject == null || (subject is Collection<*> && subject.isNullOrEmpty()))
if (!can(action, subject) && !agreeIfNullOrEmpty && isNullOrEmpty) {
throw UnauthorizedException(action) throw UnauthorizedException(action)
} }
} }
fun PipelineContext<Unit, ApplicationCall>.assertCan(action: ActionI, subject: Any? = null) = fun PipelineContext<Unit, ApplicationCall>.assertCan(action: ActionI, subject: Any? = null, agreeIfNullOrEmpty: Boolean = true) =
context.assertCan(action, subject) context.assertCan(action, subject, agreeIfNullOrEmpty)
fun PipelineContext<Unit, ApplicationCall>.can(action: ActionI, subject: Any? = null) = fun PipelineContext<Unit, ApplicationCall>.can(action: ActionI, subject: Any? = null) =
context.can(action, subject) context.can(action, subject)

View File

@@ -1376,10 +1376,12 @@ components:
ArticleOpinionRequest: ArticleOpinionRequest:
type: object type: object
properties: properties:
opinion_choice: ids:
type: string type: array
format: uuid items:
example: 6e978eb5-3c48-0def-b093-e01f43983adb type: string
format: uuid
example: 6e978eb5-3c48-0def-b093-e01f43983adb
OpinionChoices: OpinionChoices:
description: Opinion Choice description: Opinion Choice

View File

@@ -0,0 +1,11 @@
create or replace function find_opinion_choices_by_ids(_ids uuid[], out resource json)
language plpgsql as
$$
begin
select json_agg(ol) into resource
from opinion_choice ol
where (ol.deleted_at <= now()
or ol.deleted_at is null)
and ol.id = any(_ids);
end;
$$;

View File

@@ -0,0 +1,31 @@
create or replace function update_citizen_opinions_by_target_id(
_choices_ids uuid[],
_citizen_id uuid,
_target_id uuid,
_target_reference regclass,
out opinions json,
out ids_deleted uuid[]
) language plpgsql as
$$
begin
if _target_reference = 'article'::regclass then
insert into opinion_on_article (created_by_id, target_id, choice_id)
select _citizen_id, _target_id, _choice_id
from unnest(_choices_ids) _choice_id
on conflict (created_by_id, target_id, choice_id) do nothing;
with deleted as (
delete from opinion_on_article o
where o.created_by_id = _citizen_id
and o.target_id = _target_id
and (not array[o.choice_id]::uuid[] <@ _choices_ids or _choices_ids = '{}'::uuid[])
returning id
)
select array_agg(d.id) into ids_deleted from deleted d;
else
raise exception '% no implemented for opinion', _target_reference::text;
end if;
select find_citizen_opinions_by_target_id(_citizen_id, _target_id) into opinions;
end
$$;

View File

@@ -45,15 +45,22 @@ class OpinionSteps : En, KoinTest {
} }
} }
private fun createOpinion(opinionChoiceName: String, articleId: String, firstName: String, lastName: String, id: String? = null) { private fun createOpinion(
opinionChoiceName: String,
articleId: String,
firstName: String,
lastName: String,
id: String? = null
) {
val opinion = OpinionArticle( val opinion = OpinionArticle(
id = id?.toUUID() ?: UUID.randomUUID(), id = id?.toUUID() ?: UUID.randomUUID(),
choice = get<OpinionChoiceRepository>().findOpinionsChoiceByName(opinionChoiceName) choice = get<OpinionChoiceRepository>().findOpinionsChoiceByName(opinionChoiceName)
?: error("Opinion Choice not exist"), ?: error("Opinion Choice not exist"),
target = get<ArticleRepository>().findById(articleId.toUUID()) ?: error("Article not exist"), target = get<ArticleRepository>().findById(articleId.toUUID()) ?: error("Article not exist"),
createdBy = get<CitizenRepository>().findByUsername("$firstName-$lastName".toLowerCase().replace(' ', '-')) ?: error("Citizen not exist") createdBy = get<CitizenRepository>().findByUsername("$firstName-$lastName".toLowerCase().replace(' ', '-'))
?: error("Citizen not exist")
) )
get<OpinionRepository>().opinion(opinion) get<OpinionRepository>().updateOpinions(opinion.choice, opinion.createdBy, opinion.target)
} }
private fun createOpinionOnArticle(extraInfo: DataTable? = null) { private fun createOpinionOnArticle(extraInfo: DataTable? = null) {
@@ -69,6 +76,6 @@ class OpinionSteps : En, KoinTest {
} ?: error("You must provide the 'article' parameter"), } ?: error("You must provide the 'article' parameter"),
createdBy = get<CitizenRepository>().findByUsername(username) ?: error("Citizen not exist") createdBy = get<CitizenRepository>().findByUsername(username) ?: error("Citizen not exist")
) )
get<OpinionRepository>().opinion(opinion) get<OpinionRepository>().updateOpinions(opinion.choice, opinion.createdBy, opinion.target)
} }
} }

View File

@@ -65,7 +65,8 @@ internal class OpinionVoterTest {
every { user } returns tesla.user every { user } returns tesla.user
}.let { }.let {
supports(OpinionVoter.Action.VIEW, it, opinion1) `should be` true supports(OpinionVoter.Action.VIEW, it, opinion1) `should be` true
supports(OpinionVoter.Action.VIEW, it, article1) `should be` false supports(OpinionVoter.Action.VIEW, it, article1) `should be` true
supports(OpinionVoter.Action.VIEW, it, einstein) `should be` false
supports(p, it, opinion1) `should be` false supports(p, it, opinion1) `should be` false
} }
} }

View File

@@ -41,6 +41,8 @@ declare
opinion_choice1_id uuid = uuid_generate_v4(); opinion_choice1_id uuid = uuid_generate_v4();
opinion_choice2_id uuid = uuid_generate_v4(); opinion_choice2_id uuid = uuid_generate_v4();
opinion2 json; opinion2 json;
_opinions json;
_opinions_deleted_ids uuid[];
begin begin
-- insert user for context -- insert user for context
select insert_user(created_user) into created_user; select insert_user(created_user) into created_user;
@@ -120,6 +122,46 @@ begin
select (resource#>>'{0, choice, name}') = 'Opinion1' from find_citizen_opinions(_citizen_id, null, null, 1, 0) select (resource#>>'{0, choice, name}') = 'Opinion1' from find_citizen_opinions(_citizen_id, null, null, 1, 0)
), 'find_citizen_opinions must return a list of opinion with name'; ), 'find_citizen_opinions must return a list of opinion with name';
-- test update_citizen_opinions_by_target_id
select opinions into _opinions
from update_citizen_opinions_by_target_id(
array[opinion_choice1_id]::uuid[],
_citizen_id,
(created_article->>'id')::uuid,
'article'
);
assert (json_array_length(_opinions) = 1), format('Opinions updated must be count of 1. instead of: %s', json_array_length(_opinions));
assert(select (_opinions#>>'{0, choice, id}')::uuid = opinion_choice1_id), 'opinion1 is not inserted';
assert(
select (o#>>'{0, choice, name}') = 'Opinion1'
from find_citizen_opinions_by_target_id(_citizen_id, (created_article->>'id')::uuid) o),
'The opinion must have a name';
-- test update_citizen_opinions_by_target_id with multiple ids
select opinions into _opinions
from update_citizen_opinions_by_target_id(
array[opinion_choice1_id, opinion_choice2_id]::uuid[],
_citizen_id,
(created_article->>'id')::uuid,
'article'
);
assert (json_array_length(_opinions) = 2), format('(on multi update) Opinions updated must be count of 1. instead of: %s', json_array_length(_opinions));
assert(select (_opinions#>>'{0, choice, id}')::uuid = opinion_choice1_id), '(on multi update) opinion1 is not inserted';
assert(select (_opinions#>>'{1, choice, id}')::uuid = opinion_choice2_id), '(on multi update) opinion2 is not inserted';
assert(
select (o#>>'{0, choice, name}') = 'Opinion1'
from find_citizen_opinions_by_target_id(_citizen_id, (created_article->>'id')::uuid) o),
'(on multi update) The opinion must have a name';
-- test update_citizen_opinions_by_target_id if empty
select opinions, ids_deleted into _opinions, _opinions_deleted_ids
from update_citizen_opinions_by_target_id(
'{}'::uuid[],
_citizen_id,
(created_article->>'id')::uuid,
'article'
);
assert json_array_length(_opinions) = 0;
rollback; rollback;
raise notice 'opinion test pass'; raise notice 'opinion test pass';
end end