Create route for Opinions

create OpinionRepository
create OpinionVoter
create OpinionChoiceRef
create extention String.toUUID() and List<String>.toUUID()
create OpinionAggregation
create interface RequestBuilderWithCreator for create entity by request
rename opinion_list to opinion_choice
create sql function find_citizen_opinions
fix sql function find_citizen_opinions_by_target_id
fix sql funciton find_opinion_choices
This commit is contained in:
2020-02-12 14:46:36 +01:00
parent ec6e39b130
commit 4a2d18ff87
24 changed files with 411 additions and 45 deletions

View File

@@ -49,10 +49,12 @@
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/vote/find_citizen_votes_by_target_ids.sql" />
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/vote/find_votes_by_citizen.sql" />
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/opinion/count_opinion.sql" />
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/opinion/find_citizen_opinions.sql" />
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/opinion/find_citizen_opinions_by_target_id.sql" />
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/opinion/find_citizen_opinions_by_target_ids.sql" />
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/opinion/find_opinion_choice_by_id.sql" />
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/opinion/find_opinion_choices.sql" />
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/opinion/opinion.sql" />
<option value="$PROJECT_DIR$/src/main/resources/sql/functions/opinion/find_opinions.sql" />
<option value="$PROJECT_DIR$/src/test/sql/user.sql" />
<option value="$PROJECT_DIR$/src/test/sql/citizen.sql" />
<option value="$PROJECT_DIR$/src/test/sql/article.sql" />

View File

@@ -143,6 +143,7 @@ fun Application.module(env: Env = PROD) {
CommentVoter(),
VoteVoter(),
FollowVoter(),
OpinionVoter(),
OpinionChoiceVoter()
)
}

View File

@@ -15,6 +15,7 @@ import fr.dcproject.repository.CommentGeneric as CommentGenericRepository
import fr.dcproject.repository.Constitution as ConstitutionRepository
import fr.dcproject.repository.FollowArticle as FollowArticleRepository
import fr.dcproject.repository.FollowConstitution as FollowConstitutionRepository
import fr.dcproject.repository.OpinionArticle as OpinionArticleRepository
import fr.dcproject.repository.OpinionChoice as OpinionChoiceRepository
import fr.dcproject.repository.User as UserRepository
import fr.dcproject.repository.VoteArticle as VoteArticleRepository
@@ -59,6 +60,7 @@ val Module = module {
single { VoteConstitutionRepository(get()) }
single { VoteCommentRepository(get()) }
single { OpinionChoiceRepository(get()) }
single { OpinionArticleRepository(get()) }
single { Migrations(connection = get(), directory = config.sqlFiles) }

View File

@@ -6,9 +6,14 @@ import java.util.*
open class Opinion<T : TargetI>(
id: UUID = UUID.randomUUID(),
override val createdBy: CitizenBasic,
override var target: T,
var name: String
override val target: T,
val choice: OpinionChoice
) : ExtraI<T>,
UuidEntity(id),
EntityCreatedAt by EntityCreatedAtImp(),
EntityCreatedBy<CitizenBasicI> by EntityCreatedByImp(createdBy)
EntityCreatedBy<CitizenBasicI> by EntityCreatedByImp(createdBy) {
fun getName(): String = choice.name
}
typealias OpinionArticle = Opinion<Article>

View File

@@ -11,6 +11,10 @@ class OpinionChoice(
id: UUID,
val name: String,
val target: List<String>
) : UuidEntity(id),
) : OpinionChoiceRef(id),
EntityCreatedAt by EntityCreatedAtImp(),
EntityDeletedAt by EntityDeletedAtImp()
open class OpinionChoiceRef(
id: UUID
) : UuidEntity(id)

View File

@@ -1,5 +1,11 @@
package fr.dcproject.entity
import fr.postgresjson.entity.EntityI
class OpinionAggregation(
override val entries: Set<Map.Entry<String, Int>> = emptySet()
) : AbstractMap<String, Int>(), EntityI
interface Opinionable {
val opinions: MutableMap<String, Int>
}

View File

@@ -0,0 +1,27 @@
package fr.dcproject.entity.request
import fr.dcproject.entity.Citizen
import fr.dcproject.entity.OpinionArticle
import fr.dcproject.entity.OpinionChoiceRef
import fr.dcproject.entity.TargetRef
import fr.dcproject.repository.Article
import fr.dcproject.repository.OpinionChoice
import fr.dcproject.utils.toUUID
import org.koin.core.KoinComponent
import org.koin.core.get
class ArticleOpinionRequest(
target: String,
opinionChoice: String
) : RequestBuilderWithCreator<Citizen, OpinionArticle>, KoinComponent {
val target = TargetRef(target.toUUID())
val opinionChoice = OpinionChoiceRef(opinionChoice.toUUID())
override fun create(citizen: Citizen): OpinionArticle {
return OpinionArticle(
choice = get<OpinionChoice>().findOpinionChoiceById(opinionChoice.id)!!,
target = get<Article>().findById(target.id)!!,
createdBy = citizen
)
}
}

View File

@@ -1,3 +1,14 @@
package fr.dcproject.entity.request
import fr.dcproject.entity.CitizenRef
import fr.postgresjson.entity.EntityI
interface Request
interface RequestBuilder<E: EntityI> : Request {
fun create(): E
}
interface RequestBuilderWithCreator<C: CitizenRef, E: EntityI> : Request {
fun create(citizen: C): E
}

View File

@@ -0,0 +1,112 @@
package fr.dcproject.repository
import com.fasterxml.jackson.core.type.TypeReference
import fr.dcproject.entity.Article
import fr.dcproject.entity.OpinionAggregation
import fr.dcproject.entity.TargetRef
import fr.postgresjson.connexion.Paginated
import fr.postgresjson.connexion.Requester
import fr.postgresjson.repository.RepositoryI
import net.pearx.kasechange.toSnakeCase
import java.util.*
import fr.dcproject.entity.Citizen as CitizenEntity
import fr.dcproject.entity.Opinion as OpinionEntity
import fr.dcproject.entity.OpinionChoice as OpinionChoiceEntity
open class OpinionChoice(override val requester: Requester) : RepositoryI {
/**
* find all opinion choices
* can be filtered by target compatibility
*/
fun findOpinionsChoices(targets: List<String> = emptyList()): List<OpinionChoiceEntity> =
requester
.getFunction("find_opinion_choices")
.select(
"targets" to targets
)
/**
* find one opinion choices by id
*/
fun findOpinionChoiceById(id: UUID): OpinionChoiceEntity? =
requester
.getFunction("find_opinion_choice_by_id")
.selectOne(
"id" to id
)
}
open class Opinion<T : TargetRef>(requester: Requester) : OpinionChoice(requester) {
/**
* Create an Opinion on target (article,...)
*/
fun opinion(opinion: OpinionEntity<T>): OpinionAggregation {
return requester
.getFunction("opinion")
.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
*/
fun findCitizenOpinionsByTargets(
citizen: CitizenEntity,
targets: List<UUID>
): List<OpinionEntity<T>> {
val typeReference = object : TypeReference<List<OpinionEntity<T>>>() {}
return requester.run {
getFunction("find_citizen_opinions_by_target_ids")
.select(
typeReference, mapOf(
"citizen_id" to citizen.id,
"ids" to targets
)
)
}
}
/**
* find opinion of citizen filtered by one target id
*/
fun findCitizenOpinionsByTarget(
citizen: CitizenEntity,
target: UUID
): List<OpinionEntity<T>> {
val typeReference = object : TypeReference<List<OpinionEntity<T>>>() {}
return requester
.getFunction("find_citizen_opinions_by_target_id")
.select(
typeReference, mapOf(
"citizen_id" to citizen.id,
"id" to target
)
)
}
/**
* find paginated opinion of one citizen
* can be sorted
*/
fun findCitizenOpinions(
citizen: CitizenEntity,
page: Int = 1,
limit: Int = 50,
sort: String? = null,
direction: RepositoryI.Direction? = null
): Paginated<OpinionEntity<TargetRef>> {
return requester
.getFunction("find_citizen_opinions")
.select(page, limit,
"sort" to sort?.toSnakeCase(),
"direction" to direction,
"citizen" to citizen.id
)
}
}
class OpinionArticle(requester: Requester) : Opinion<Article>(requester)

View File

@@ -0,0 +1,72 @@
package fr.dcproject.routes
import fr.dcproject.citizen
import fr.dcproject.entity.request.ArticleOpinionRequest
import fr.dcproject.security.voter.OpinionVoter.Action.VIEW
import fr.dcproject.security.voter.assertCan
import fr.dcproject.utils.toUUID
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
import io.ktor.locations.Location
import io.ktor.locations.get
import io.ktor.locations.put
import io.ktor.request.receive
import io.ktor.response.respond
import io.ktor.routing.Route
import io.ktor.util.KtorExperimentalAPI
import org.koin.core.KoinComponent
import org.koin.core.get
import fr.dcproject.entity.Article as ArticleEntity
import fr.dcproject.entity.Citizen as CitizenEntity
import fr.dcproject.repository.OpinionArticle as OpinionArticleRepository
@KtorExperimentalLocationsAPI
object OpinionArticlePaths {
/**
* Get paginated opinion of citizen for one article
*/
@Location("/citizens/{citizen}/opinions/articles")
class CitizenOpinionArticleRequest(
val citizen: CitizenEntity,
page: Int = 1,
limit: Int = 50
) : PaginatedRequestI by PaginatedRequest(page, limit)
/**
* Put an opinion on one article
*/
@Location("/articles/{article}/opinons")
class ArticleOpinion(val article: ArticleEntity)
/**
* Get all Opinion of citizen on targets by target ids
*/
@Location("/citizen/{citizen}/opinions")
class CitizenOpinions(val citizen: CitizenEntity, id: List<String>): KoinComponent {
val opinionsEntities = get<OpinionArticleRepository>()
.findCitizenOpinionsByTargets(citizen, id.toUUID())
}
}
@KtorExperimentalAPI
@KtorExperimentalLocationsAPI
fun Route.opinionArticle(repo: OpinionArticleRepository) {
get<OpinionArticlePaths.CitizenOpinionArticleRequest> {
val opinions = repo.findCitizenOpinions(citizen, it.page, it.limit)
call.respond(opinions)
}
get<OpinionArticlePaths.CitizenOpinions> {
assertCan(VIEW, it.opinionsEntities)
call.respond(it.opinionsEntities)
}
put<OpinionArticlePaths.ArticleOpinion> {
val optionArticle = call.receive<ArticleOpinionRequest>().create(citizen)
assertCan(VIEW, optionArticle)
call.respond(HttpStatusCode.Created, optionArticle)
}
}

View File

@@ -9,6 +9,7 @@ import fr.dcproject.routes.VoteArticlePaths.CommentVoteRequest
import fr.dcproject.security.voter.VoteVoter.Action.CREATE
import fr.dcproject.security.voter.VoteVoter.Action.VIEW
import fr.dcproject.security.voter.assertCan
import fr.dcproject.utils.toUUID
import io.ktor.application.call
import io.ktor.http.HttpStatusCode
import io.ktor.locations.KtorExperimentalLocationsAPI
@@ -45,10 +46,7 @@ object VoteArticlePaths {
@Location("/citizens/{citizen}/votes")
class CitizenVotesByIdsRequest(val citizen: Citizen, id: List<String>) {
val id: List<UUID> = id
.map { it.trim() }
.filter { it.isNotBlank() }
.map { UUID.fromString(it) }
val id: List<UUID> = id.toUUID()
}
}

View File

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

View File

@@ -0,0 +1,10 @@
package fr.dcproject.utils
import java.util.*
fun String.toUUID(): UUID = UUID.fromString(this.trim())
fun List<String>.toUUID(): List<UUID> = this
.map { it.trim() }
.filter { it.isNotBlank() }
.map { UUID.fromString(it) }

View File

@@ -2,22 +2,22 @@ do
$$
declare
article_count int = (select count(*) from article);
_citizensIds uuid[] = (select array_agg(id) from citizen);
begin
delete from opinion_on_article;
delete from opinion_list;
delete from opinion_choice;
insert into opinion_list (id, name, target)
select uuid_in(md5('opinion_list'||row_number() over ())::cstring), 'Opinion'||row_number() over (), '{article}'
insert into opinion_choice (id, name, target)
select uuid_in(md5('opinion_choice'||row_number() over ())::cstring), 'Opinion'||row_number() over (), '{article}'
from generate_series(0,20);
for i in 0..9 loop
insert into opinion_on_article (id, created_by_id, target_id, opinion)
insert into opinion_on_article (id, created_by_id, target_id, choice_id, created_at)
select
uuid_in(md5('opinion_on_article'||rn+(article_count*i))::cstring),
z.id,
a.id,
uuid_in(md5('opinion_list'||((rn+i) % 5 +1))::cstring)
uuid_in(md5('opinion_choice'||((rn+i) % 5 +1))::cstring),
now() + ((rn+i) || ' minute')::interval
from (select *, row_number() over ()+i+5 % 5 rn from citizen) z
join (select *, row_number() over () rn from article) a using (rn);
end loop;

View File

@@ -9,10 +9,10 @@ begin
into agg
from (
select
count(o.opinion) as total,
count(o) as total,
ol.name as label
from opinion o
join opinion_list ol on o.opinion = ol.id
join opinion_choice ol on o.choice_id = ol.id
where o.target_id = _target_id
group by ol.name
order by ol.name

View File

@@ -0,0 +1,46 @@
create or replace function find_citizen_opinions(
_citizen_id uuid,
direction text default 'desc',
sort text default 'created_at',
"limit" int default 50,
"offset" int default 0,
out resource json,
out total int
) language plpgsql as
$$
begin
select json_agg(t), (
select count(o.id)
from opinion o
where o.created_by_id = _citizen_id
)
into resource, total
from (
select
o.*,
to_json(ol) as choice
from opinion as o
join opinion_choice ol on o.choice_id = ol.id
where created_by_id = _citizen_id
order by
case direction when 'asc' then
case sort
when 'created_at' then o.created_at::text
else null
end
end,
case direction when 'desc' then
case sort
when 'created_at' then o.created_at::text
end
end
desc,
o.created_at desc
limit "limit" offset "offset"
) t;
end
$$;
-- select * from find_citizen_opinions('6434f4f9-f570-f22a-c134-8668350651ff', null, null, 2, 2);

View File

@@ -11,9 +11,9 @@ begin
from (
select
o.*,
ol.name
to_json(ol) as choice
from opinion as o
join opinion_list ol on o.opinion = ol.id
join opinion_choice ol on o.choice_id = ol.id
where target_id = _id
and created_by_id = _citizen_id

View File

@@ -9,9 +9,9 @@ begin
from (
select
o.*,
ol.name
to_json(ol) as choice
from opinion as o
join opinion_list ol on o.opinion = ol.id
join opinion_choice ol on o.choice_id = ol.id
where target_id = any(_ids)
and created_by_id = _citizen_id

View File

@@ -3,7 +3,7 @@ create or replace function find_opinion_choice_by_id(_id uuid, out resource json
$$
begin
select to_json(ol) into resource
from opinion_list ol
from opinion_choice ol
where (ol.deleted_at <= now()
or ol.deleted_at is null)
and (ol.id = _id);

View File

@@ -6,10 +6,9 @@ begin
into resource
from (
select ol.*
from opinion_list ol
where (ol.deleted_at <= now()
or ol.deleted_at is null)
and (ol.target is null or array_length(targets) = 0 or ol.target = any(targets))
from opinion_choice ol
where (ol.deleted_at is null or ol.deleted_at > now())
and (ol.target is null or targets is null or array_length(targets, 1) = 0 or ol.target && targets)
order by ol.name
) t;

View File

@@ -3,9 +3,9 @@ create or replace function opinion(reference regclass, _target_id uuid, _created
$$
begin
if reference = 'article'::regclass then
insert into opinion_on_article (created_by_id, target_id, opinion)
insert into opinion_on_article (created_by_id, target_id, choice_id)
values (_created_by_id, _target_id, _opinion)
on conflict (created_by_id, target_id, opinion) do nothing;
on conflict (created_by_id, target_id, choice_id) do nothing;
else
raise exception '% no implemented for opinion', reference::text;
end if;

View File

@@ -4,7 +4,7 @@ drop table if exists resource_view;
-- Extra resources
drop table if exists opinion_on_article;
drop table if exists opinion;
drop table if exists opinion_list;
drop table if exists opinion_choice;
drop table if exists follow_article;
drop table if exists follow_constitution;

View File

@@ -503,7 +503,7 @@ create table resource_view
ip cidr null
);
create table opinion_list
create table opinion_choice
(
id uuid default uuid_generate_v4() not null primary key,
name text not null unique,
@@ -514,20 +514,20 @@ create table opinion_list
create table opinion
(
opinion uuid not null references opinion_list (id),
choice_id uuid not null references opinion_choice (id),
foreign key (created_by_id) references citizen (id),
primary key (id),
unique (created_by_id, target_id, opinion)
unique (created_by_id, target_id, choice_id)
) inherits (extra);
create table opinion_on_article
(
target_reference regclass default 'article'::regclass not null,
foreign key (opinion) references opinion_list (id),
foreign key (choice_id) references opinion_choice (id),
foreign key (target_id) references article (id),
foreign key (created_by_id) references citizen (id),
primary key (id),
unique (created_by_id, target_id, opinion)
unique (created_by_id, target_id, choice_id)
) inherits (opinion);

View File

@@ -58,13 +58,13 @@ begin
select upsert_article(created_article) into created_article;
insert into opinion_list(id, name, target)
insert into opinion_choice(id, name, target)
values (opinion1, 'Opinion1', '{article}');
insert into opinion_list(id, name, target)
insert into opinion_choice(id, name, target)
values (opinion2, 'Opinion2', '{article}');
insert into opinion_list(name, target)
insert into opinion_choice(name, target)
values ('Opinion3', '{article}');
perform opinion(
@@ -73,33 +73,51 @@ begin
_created_by_id => _citizen_id,
_opinion => opinion1
);
assert (select count(*) = 1 from opinion_on_article), 'opinion must be inserted';
assert (select opinion = opinion1 from opinion_on_article limit 1), 'opinion must be inserted';
perform opinion(
reference => 'article'::regclass,
_target_id => (created_article->>'id')::uuid,
_created_by_id => _citizen_id,
_opinion => opinion2
);
assert (select count(*) = 2 from opinion_on_article), 'opinions must be inserted';
assert (select choice_id = opinion1 from opinion_on_article limit 1), 'opinion must be inserted';
assert(select (a#>>'{opinions, Opinion1}')::int = 1
from find_article_by_id((created_article->>'id')::uuid) a), 'the article must be have a opinion';
assert(
select (o#>>'{0, name}') = 'Opinion1'
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';
assert(
select (o#>>'{0, name}') = 'Opinion1'
select (o#>>'{0, choice, name}') = 'Opinion1'
from find_citizen_opinions_by_target_ids(_citizen_id, array[(created_article->>'id')::uuid]) o),
'The first opinion must have a name';
assert(
select find_opinion_choices()#>>'{0, name}' = 'Opinion1'
), 'find_opinion_choices mst be return all opinions';
), 'find_opinion_choices must be return all opinions';
assert(
select (find_opinion_choice_by_id(opinion1)->>'name') = 'Opinion1'
), 'find_opinion_choice_by_id must return the opinion_choice';
assert(
select json_array_length(resource) = 1 from find_citizen_opinions(_citizen_id, null, null, 1, 1)
), 'find_citizen_opinions must return only 1 result if limit is set to 1';
assert(
select total = 2 from find_citizen_opinions(_citizen_id, null, null, 2, 1)
), 'find_citizen_opinions must return the total and it should be 2';
assert(
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';
-- delete vote and context
delete from opinion;
delete from opinion_list;
delete from opinion_choice;
delete from article;
delete from citizen;
delete from "user";