This commit is contained in:
phiresky 2024-09-02 18:32:59 +02:00
parent 003613b736
commit c130bee420
11 changed files with 359 additions and 75 deletions

View file

@ -1,5 +1,5 @@
use lemmy_db_schema::{ use lemmy_db_schema::{
newtypes::{CommentId, CommunityId, DbUrl, LanguageId, PostId, PostReportId}, newtypes::{CommentId, CommunityId, CommunityPostTagId, DbUrl, LanguageId, PostId, PostReportId},
ListingType, ListingType,
PostFeatureType, PostFeatureType,
SortType, SortType,

View file

@ -0,0 +1,59 @@
use crate::{
newtypes::CommunityPostTagId,
schema::{community_post_tag, post_community_post_tag},
source::community_post_tag::{
CommunityPostTag,
CommunityPostTagInsertForm,
PostCommunityPostTagInsertForm,
},
traits::Crud,
utils::{get_conn, DbPool},
};
use anyhow::Context;
use diesel::{insert_into, result::Error, QueryDsl};
use diesel_async::RunQueryDsl;
use lemmy_utils::error::LemmyResult;
#[async_trait]
impl Crud for CommunityPostTag {
type InsertForm = CommunityPostTagInsertForm;
type UpdateForm = CommunityPostTagInsertForm;
type IdType = CommunityPostTagId;
async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> Result<Self, Error> {
let conn = &mut get_conn(pool).await?;
insert_into(community_post_tag::table)
.values(form)
.get_result::<Self>(conn)
.await
}
async fn update(
pool: &mut DbPool<'_>,
pid: CommunityPostTagId,
form: &Self::UpdateForm,
) -> Result<Self, Error> {
let conn = &mut get_conn(pool).await?;
diesel::update(community_post_tag::table.find(pid))
.set(form)
.get_result::<Self>(conn)
.await
}
}
impl PostCommunityPostTagInsertForm {
pub async fn insert_tag_associations(
pool: &mut DbPool<'_>,
tags: &[PostCommunityPostTagInsertForm],
) -> LemmyResult<()> {
let conn = &mut get_conn(pool).await?;
insert_into(post_community_post_tag::table)
.values(tags)
.execute(conn)
.await
.context("Failed to insert post community tag associations")?;
Ok(())
}
}

View file

@ -6,6 +6,7 @@ pub mod comment_reply;
pub mod comment_report; pub mod comment_report;
pub mod community; pub mod community;
pub mod community_block; pub mod community_block;
pub mod community_post_tag;
pub mod custom_emoji; pub mod custom_emoji;
pub mod email_verification; pub mod email_verification;
pub mod federation_allowlist; pub mod federation_allowlist;

View file

@ -284,7 +284,6 @@ impl InstanceId {
} }
} }
#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "full", derive(DieselNewType, TS))] #[cfg_attr(feature = "full", derive(DieselNewType, TS))]
#[cfg_attr(feature = "full", ts(export))] #[cfg_attr(feature = "full", ts(export))]

View file

@ -1,26 +1,48 @@
use crate::{
newtypes::{CommunityId, CommunityPostTagId, DbUrl, PostId},
schema::{community_post_tag, post_community_post_tag},
};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
use ts_rs::TS; use ts_rs::TS;
use crate::newtypes::{CommunityId, CommunityPostTagId};
/// A tag that can be assigned to a post within a community. /// A tag that can be assigned to a post within a community.
/// The tag object is created by the community moderators. /// The tag object is created by the community moderators.
/// The assignment happens by the post creator and can be updated by the community moderators. /// The assignment happens by the post creator and can be updated by the community moderators.
#[skip_serializing_none] #[skip_serializing_none]
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "full", derive(TS, Queryable))] #[cfg_attr(feature = "full", derive(TS, Queryable, Selectable, Identifiable))]
#[cfg_attr(feature = "full", diesel(table_name = community_post_tag))]
#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))]
#[cfg_attr(feature = "full", ts(export))] #[cfg_attr(feature = "full", ts(export))]
pub struct CommunityPostTag { pub struct CommunityPostTag {
pub id: CommunityPostTagId, pub id: CommunityPostTagId,
pub ap_id: String, pub ap_id: DbUrl,
pub community_id: CommunityId, pub community_id: CommunityId,
pub name: String, pub name: String,
pub published: DateTime<Utc>, pub published: DateTime<Utc>,
pub updated: Option<DateTime<Utc>>, pub updated: Option<DateTime<Utc>>,
pub deleted: Option<DateTime<Utc>> pub deleted: Option<DateTime<Utc>>,
} }
#[derive(Debug, Clone)]
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = community_post_tag))]
pub struct CommunityPostTagInsertForm {
pub ap_id: DbUrl,
pub community_id: CommunityId,
pub name: String,
// default now
pub published: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
pub deleted: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", diesel(table_name = post_community_post_tag))]
pub struct PostCommunityPostTagInsertForm {
pub post_id: PostId,
pub community_post_tag_id: CommunityPostTagId,
}

View file

@ -10,6 +10,7 @@ pub mod comment_reply;
pub mod comment_report; pub mod comment_report;
pub mod community; pub mod community;
pub mod community_block; pub mod community_block;
pub mod community_post_tag;
pub mod custom_emoji; pub mod custom_emoji;
pub mod custom_emoji_keyword; pub mod custom_emoji_keyword;
pub mod email_verification; pub mod email_verification;
@ -39,7 +40,6 @@ pub mod registration_application;
pub mod secret; pub mod secret;
pub mod site; pub mod site;
pub mod tagline; pub mod tagline;
pub mod community_post_tag;
/// Default value for columns like [community::Community.inbox_url] which are marked as serde(skip). /// Default value for columns like [community::Community.inbox_url] which are marked as serde(skip).
/// ///

View file

@ -28,6 +28,7 @@ use lemmy_db_schema::{
community_follower, community_follower,
community_moderator, community_moderator,
community_person_ban, community_person_ban,
community_post_tag,
image_details, image_details,
instance_block, instance_block,
local_user, local_user,
@ -37,17 +38,15 @@ use lemmy_db_schema::{
person_post_aggregates, person_post_aggregates,
post, post,
post_aggregates, post_aggregates,
post_community_post_tag,
post_hide, post_hide,
post_like, post_like,
post_read, post_read,
post_saved, post_saved,
post_community_post_tag,
community_post_tag
}, },
source::{local_user::LocalUser, site::Site}, source::{local_user::LocalUser, site::Site},
utils::{ utils::{
functions::coalesce, functions::coalesce,
functions::json_agg,
fuzzy_search, fuzzy_search,
get_conn, get_conn,
limit_and_offset, limit_and_offset,
@ -217,14 +216,31 @@ fn queries<'a>() -> Queries<
} else { } else {
Box::new(None::<i64>.into_sql::<sql_types::Nullable<sql_types::BigInt>>()) Box::new(None::<i64>.into_sql::<sql_types::Nullable<sql_types::BigInt>>())
}; };
let community_post_tags: Box<dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable<sql_types::Json>>> =
Box::new( // We fetch post tags by letting postgresql aggregate them internally in a subquery into JSON.
post_community_post_tag::table // This is a simple way to join m rows into n rows without duplicating the data and getting
.inner_join(community_post_tag::table) // complex diesel types. In pure SQL you would usually do this either using a LEFT JOIN + then
.select(diesel::dsl::sql::<diesel::sql_types::Json>("json_agg(community_post_tag.*)")) // aggregating the results in the application code. But this results in a lot of duplicate
.filter(post_community_post_tag::post_id.eq(post_aggregates::post_id)) // data transferred (since each post will be returned once per tag that it has) and more
.single_value(), // complicated application code. The diesel docs suggest doing three separate sequential queries
); // in this case (see https://diesel.rs/guides/relations.html#many-to-many-or-mn ): First fetch
// the posts, then fetch all relevant post-tag-association tuples from the db, and then fetch
// all the relevant tag objects.
//
// If we want to filter by post tag we will have to add
// separate logic below since this subquery can't affect filtering, but it is simple (`WHERE
// exists (select 1 from post_community_post_tags where community_post_tag_id in (1,2,3,4)`).
let community_post_tags: Box<
dyn BoxableExpression<_, Pg, SqlType = sql_types::Nullable<sql_types::Json>>,
> = Box::new(
post_community_post_tag::table
.inner_join(community_post_tag::table)
.select(diesel::dsl::sql::<diesel::sql_types::Json>(
"json_agg(community_post_tag.*)",
))
.filter(post_community_post_tag::post_id.eq(post_aggregates::post_id))
.single_value(),
);
query query
.inner_join(person::table) .inner_join(person::table)
@ -258,7 +274,7 @@ fn queries<'a>() -> Queries<
post_aggregates::comments.nullable() - read_comments, post_aggregates::comments.nullable() - read_comments,
post_aggregates::comments, post_aggregates::comments,
), ),
community_post_tags community_post_tags,
)) ))
}; };
@ -741,11 +757,12 @@ impl<'a> PostQuery<'a> {
} }
} }
#[allow(clippy::indexing_slicing)]
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{ use crate::{
post_view::{PaginationCursorData, PostQuery, PostView}, post_view::{PaginationCursorData, PostQuery, PostView},
structs::LocalUserView, structs::{LocalUserView, PostCommunityPostTags},
}; };
use chrono::Utc; use chrono::Utc;
use lemmy_db_schema::{ use lemmy_db_schema::{
@ -765,6 +782,11 @@ mod tests {
CommunityUpdateForm, CommunityUpdateForm,
}, },
community_block::{CommunityBlock, CommunityBlockForm}, community_block::{CommunityBlock, CommunityBlockForm},
community_post_tag::{
CommunityPostTag,
CommunityPostTagInsertForm,
PostCommunityPostTagInsertForm,
},
instance::Instance, instance::Instance,
instance_block::{InstanceBlock, InstanceBlockForm}, instance_block::{InstanceBlock, InstanceBlockForm},
language::Language, language::Language,
@ -790,6 +812,7 @@ mod tests {
const POST_BY_BLOCKED_PERSON: &str = "post by blocked person"; const POST_BY_BLOCKED_PERSON: &str = "post by blocked person";
const POST_BY_BOT: &str = "post by bot"; const POST_BY_BOT: &str = "post by bot";
const POST: &str = "post"; const POST: &str = "post";
const POST_WITH_TAGS: &str = "post with tags";
fn names(post_views: &[PostView]) -> Vec<&str> { fn names(post_views: &[PostView]) -> Vec<&str> {
post_views.iter().map(|i| i.post.name.as_str()).collect() post_views.iter().map(|i| i.post.name.as_str()).collect()
@ -803,6 +826,9 @@ mod tests {
inserted_community: Community, inserted_community: Community,
inserted_post: Post, inserted_post: Post,
inserted_bot_post: Post, inserted_bot_post: Post,
inserted_post_with_tags: Post,
tag_1: CommunityPostTag,
tag_2: CommunityPostTag,
site: Site, site: Site,
} }
@ -874,6 +900,36 @@ mod tests {
PersonBlock::block(pool, &person_block).await?; PersonBlock::block(pool, &person_block).await?;
// Two community post tags
let tag_1 = CommunityPostTag::create(
pool,
&CommunityPostTagInsertForm {
ap_id: Url::parse(&format!("{}/tags/test_tag1", inserted_community.actor_id))
.expect("valid")
.into(),
community_id: inserted_community.id,
name: "Test Tag 1".into(),
published: None,
updated: None,
deleted: None,
},
)
.await?;
let tag_2 = CommunityPostTag::create(
pool,
&CommunityPostTagInsertForm {
ap_id: Url::parse(&format!("{}/tags/test_tag2", inserted_community.actor_id))
.expect("valid")
.into(),
community_id: inserted_community.id,
name: "Test Tag 2".into(),
published: None,
updated: None,
deleted: None,
},
)
.await?;
// A sample post // A sample post
let new_post = PostInsertForm::builder() let new_post = PostInsertForm::builder()
.name(POST.to_string()) .name(POST.to_string())
@ -891,6 +947,28 @@ mod tests {
.build(); .build();
let inserted_bot_post = Post::create(pool, &new_bot_post).await?; let inserted_bot_post = Post::create(pool, &new_bot_post).await?;
// A sample post with tags
let new_post = PostInsertForm::builder()
.name(POST_WITH_TAGS.to_string())
.creator_id(inserted_person.id)
.community_id(inserted_community.id)
.language_id(Some(LanguageId(47)))
.build();
let inserted_post_with_tags = Post::create(pool, &new_post).await?;
let inserted_tags = vec![
PostCommunityPostTagInsertForm {
post_id: inserted_post_with_tags.id,
community_post_tag_id: tag_1.id,
},
PostCommunityPostTagInsertForm {
post_id: inserted_post_with_tags.id,
community_post_tag_id: tag_2.id,
},
];
PostCommunityPostTagInsertForm::insert_tag_associations(pool, &inserted_tags).await?;
let local_user_view = LocalUserView { let local_user_view = LocalUserView {
local_user: inserted_local_user, local_user: inserted_local_user,
local_user_vote_display_mode: LocalUserVoteDisplayMode::default(), local_user_vote_display_mode: LocalUserVoteDisplayMode::default(),
@ -930,6 +1008,9 @@ mod tests {
inserted_community, inserted_community,
inserted_post, inserted_post,
inserted_bot_post, inserted_bot_post,
inserted_post_with_tags,
tag_1,
tag_2,
site, site,
}) })
} }
@ -948,12 +1029,14 @@ mod tests {
LocalUser::update(pool, data.local_user_view.local_user.id, &local_user_form).await?; LocalUser::update(pool, data.local_user_view.local_user.id, &local_user_form).await?;
data.local_user_view.local_user.show_bot_accounts = false; data.local_user_view.local_user.show_bot_accounts = false;
let read_post_listing = PostQuery { let mut read_post_listing = PostQuery {
community_id: Some(data.inserted_community.id), community_id: Some(data.inserted_community.id),
..data.default_post_query() ..data.default_post_query()
} }
.list(&data.site, pool) .list(&data.site, pool)
.await?; .await?;
// remove tags post
read_post_listing.remove(0);
let post_listing_single_with_person = PostView::read( let post_listing_single_with_person = PostView::read(
pool, pool,
@ -990,7 +1073,10 @@ mod tests {
.list(&data.site, pool) .list(&data.site, pool)
.await?; .await?;
// should include bot post which has "undetermined" language // should include bot post which has "undetermined" language
assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_with_bots)); assert_eq!(
vec![POST_WITH_TAGS, POST_BY_BOT, POST],
names(&post_listings_with_bots)
);
cleanup(data, pool).await cleanup(data, pool).await
} }
@ -1019,13 +1105,13 @@ mod tests {
// Should be 2 posts, with the bot post, and the blocked // Should be 2 posts, with the bot post, and the blocked
assert_eq!( assert_eq!(
vec![POST_BY_BOT, POST, POST_BY_BLOCKED_PERSON], vec![POST_WITH_TAGS, POST_BY_BOT, POST, POST_BY_BLOCKED_PERSON],
names(&read_post_listing_multiple_no_person) names(&read_post_listing_multiple_no_person)
); );
assert_eq!( assert_eq!(
Some(&expected_post_listing_no_person), Some(&expected_post_listing_no_person),
read_post_listing_multiple_no_person.get(1) read_post_listing_multiple_no_person.get(2)
); );
assert_eq!( assert_eq!(
expected_post_listing_no_person, expected_post_listing_no_person,
@ -1106,12 +1192,13 @@ mod tests {
LocalUser::update(pool, data.local_user_view.local_user.id, &local_user_form).await?; LocalUser::update(pool, data.local_user_view.local_user.id, &local_user_form).await?;
data.local_user_view.local_user.show_bot_accounts = false; data.local_user_view.local_user.show_bot_accounts = false;
let read_post_listing = PostQuery { let mut read_post_listing = PostQuery {
community_id: Some(data.inserted_community.id), community_id: Some(data.inserted_community.id),
..data.default_post_query() ..data.default_post_query()
} }
.list(&data.site, pool) .list(&data.site, pool)
.await?; .await?;
read_post_listing.remove(0);
assert_eq!(vec![expected_post_with_upvote], read_post_listing); assert_eq!(vec![expected_post_with_upvote], read_post_listing);
let like_removed = let like_removed =
@ -1196,6 +1283,7 @@ mod tests {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let expected_post_listing = vec![ let expected_post_listing = vec![
("tegan".to_owned(), true, true),
("mybot".to_owned(), false, false), ("mybot".to_owned(), false, false),
("tegan".to_owned(), true, true), ("tegan".to_owned(), true, true),
]; ];
@ -1234,17 +1322,23 @@ mod tests {
let post_listings_all = data.default_post_query().list(&data.site, pool).await?; let post_listings_all = data.default_post_query().list(&data.site, pool).await?;
// no language filters specified, all posts should be returned // no language filters specified, all posts should be returned
assert_eq!(vec![EL_POSTO, POST_BY_BOT, POST], names(&post_listings_all)); assert_eq!(
vec![EL_POSTO, POST_WITH_TAGS, POST_BY_BOT, POST],
names(&post_listings_all)
);
LocalUserLanguage::update(pool, vec![french_id], data.local_user_view.local_user.id).await?; LocalUserLanguage::update(pool, vec![french_id], data.local_user_view.local_user.id).await?;
let post_listing_french = data.default_post_query().list(&data.site, pool).await?; let post_listing_french = data.default_post_query().list(&data.site, pool).await?;
// only one post in french and one undetermined should be returned // only one post in french and one undetermined should be returned
assert_eq!(vec![POST_BY_BOT, POST], names(&post_listing_french)); assert_eq!(
vec![POST_WITH_TAGS, POST_BY_BOT, POST],
names(&post_listing_french)
);
assert_eq!( assert_eq!(
Some(french_id), Some(french_id),
post_listing_french.get(1).map(|p| p.post.language_id) post_listing_french.get(2).map(|p| p.post.language_id)
); );
LocalUserLanguage::update( LocalUserLanguage::update(
@ -1261,6 +1355,7 @@ mod tests {
.map(|p| (p.post.name, p.post.language_id)) .map(|p| (p.post.name, p.post.language_id))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let expected_post_listings_french_und = vec![ let expected_post_listings_french_und = vec![
(POST_WITH_TAGS.to_owned(), french_id),
(POST_BY_BOT.to_owned(), UNDETERMINED_ID), (POST_BY_BOT.to_owned(), UNDETERMINED_ID),
(POST.to_owned(), french_id), (POST.to_owned(), french_id),
]; ];
@ -1291,7 +1386,7 @@ mod tests {
// Make sure you don't see the removed post in the results // Make sure you don't see the removed post in the results
let post_listings_no_admin = data.default_post_query().list(&data.site, pool).await?; let post_listings_no_admin = data.default_post_query().list(&data.site, pool).await?;
assert_eq!(vec![POST], names(&post_listings_no_admin)); assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listings_no_admin));
// Removed bot post is shown to admins on its profile page // Removed bot post is shown to admins on its profile page
data.local_user_view.local_user.admin = true; data.local_user_view.local_user.admin = true;
@ -1376,7 +1471,12 @@ mod tests {
// no instance block, should return all posts // no instance block, should return all posts
let post_listings_all = data.default_post_query().list(&data.site, pool).await?; let post_listings_all = data.default_post_query().list(&data.site, pool).await?;
assert_eq!( assert_eq!(
vec![POST_FROM_BLOCKED_INSTANCE, POST_BY_BOT, POST], vec![
POST_FROM_BLOCKED_INSTANCE,
POST_WITH_TAGS,
POST_BY_BOT,
POST
],
names(&post_listings_all) names(&post_listings_all)
); );
@ -1389,7 +1489,10 @@ mod tests {
// now posts from communities on that instance should be hidden // now posts from communities on that instance should be hidden
let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?; let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?;
assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_blocked)); assert_eq!(
vec![POST_WITH_TAGS, POST_BY_BOT, POST],
names(&post_listings_blocked)
);
assert!(post_listings_blocked assert!(post_listings_blocked
.iter() .iter()
.all(|p| p.post.id != post_from_blocked_instance.id)); .all(|p| p.post.id != post_from_blocked_instance.id));
@ -1398,7 +1501,12 @@ mod tests {
InstanceBlock::unblock(pool, &block_form).await?; InstanceBlock::unblock(pool, &block_form).await?;
let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?; let post_listings_blocked = data.default_post_query().list(&data.site, pool).await?;
assert_eq!( assert_eq!(
vec![POST_FROM_BLOCKED_INSTANCE, POST_BY_BOT, POST], vec![
POST_FROM_BLOCKED_INSTANCE,
POST_WITH_TAGS,
POST_BY_BOT,
POST
],
names(&post_listings_blocked) names(&post_listings_blocked)
); );
@ -1539,7 +1647,7 @@ mod tests {
// Make sure you don't see the read post in the results // Make sure you don't see the read post in the results
let post_listings_hide_read = data.default_post_query().list(&data.site, pool).await?; let post_listings_hide_read = data.default_post_query().list(&data.site, pool).await?;
assert_eq!(vec![POST], names(&post_listings_hide_read)); assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listings_hide_read));
// Test with the show_read override as true // Test with the show_read override as true
let post_listings_show_read_true = PostQuery { let post_listings_show_read_true = PostQuery {
@ -1549,7 +1657,7 @@ mod tests {
.list(&data.site, pool) .list(&data.site, pool)
.await?; .await?;
assert_eq!( assert_eq!(
vec![POST_BY_BOT, POST], vec![POST_WITH_TAGS, POST_BY_BOT, POST],
names(&post_listings_show_read_true) names(&post_listings_show_read_true)
); );
@ -1560,7 +1668,10 @@ mod tests {
} }
.list(&data.site, pool) .list(&data.site, pool)
.await?; .await?;
assert_eq!(vec![POST], names(&post_listings_show_read_false)); assert_eq!(
vec![POST_WITH_TAGS, POST],
names(&post_listings_show_read_false)
);
cleanup(data, pool).await cleanup(data, pool).await
} }
@ -1581,7 +1692,10 @@ mod tests {
// Make sure you don't see the hidden post in the results // Make sure you don't see the hidden post in the results
let post_listings_hide_hidden = data.default_post_query().list(&data.site, pool).await?; let post_listings_hide_hidden = data.default_post_query().list(&data.site, pool).await?;
assert_eq!(vec![POST], names(&post_listings_hide_hidden)); assert_eq!(
vec![POST_WITH_TAGS, POST],
names(&post_listings_hide_hidden)
);
// Make sure it does come back with the show_hidden option // Make sure it does come back with the show_hidden option
let post_listings_show_hidden = PostQuery { let post_listings_show_hidden = PostQuery {
@ -1592,15 +1706,13 @@ mod tests {
} }
.list(&data.site, pool) .list(&data.site, pool)
.await?; .await?;
assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_show_hidden)); assert_eq!(
vec![POST_WITH_TAGS, POST_BY_BOT, POST],
names(&post_listings_show_hidden)
);
// Make sure that hidden field is true. // Make sure that hidden field is true.
assert!( assert!(&post_listings_show_hidden[1].hidden);
&post_listings_show_hidden
.first()
.ok_or(LemmyErrorType::CouldntFindPost)?
.hidden
);
cleanup(data, pool).await cleanup(data, pool).await
} }
@ -1622,7 +1734,7 @@ mod tests {
// Make sure you don't see the nsfw post in the regular results // Make sure you don't see the nsfw post in the regular results
let post_listings_hide_nsfw = data.default_post_query().list(&data.site, pool).await?; let post_listings_hide_nsfw = data.default_post_query().list(&data.site, pool).await?;
assert_eq!(vec![POST], names(&post_listings_hide_nsfw)); assert_eq!(vec![POST_WITH_TAGS, POST], names(&post_listings_hide_nsfw));
// Make sure it does come back with the show_nsfw option // Make sure it does come back with the show_nsfw option
let post_listings_show_nsfw = PostQuery { let post_listings_show_nsfw = PostQuery {
@ -1633,16 +1745,13 @@ mod tests {
} }
.list(&data.site, pool) .list(&data.site, pool)
.await?; .await?;
assert_eq!(vec![POST_BY_BOT, POST], names(&post_listings_show_nsfw)); assert_eq!(
vec![POST_WITH_TAGS, POST_BY_BOT, POST],
names(&post_listings_show_nsfw)
);
// Make sure that nsfw field is true. // Make sure that nsfw field is true.
assert!( assert!(&post_listings_show_nsfw[1].post.nsfw);
&post_listings_show_nsfw
.first()
.ok_or(LemmyErrorType::CouldntFindPost)?
.post
.nsfw
);
cleanup(data, pool).await cleanup(data, pool).await
} }
@ -1776,7 +1885,7 @@ mod tests {
hidden: false, hidden: false,
saved: false, saved: false,
creator_blocked: false, creator_blocked: false,
community_post_tags: None, community_post_tags: PostCommunityPostTags::default(),
}) })
} }
@ -1810,7 +1919,7 @@ mod tests {
} }
.list(&data.site, pool) .list(&data.site, pool)
.await?; .await?;
assert_eq!(2, authenticated_query.len()); assert_eq!(3, authenticated_query.len());
let unauthenticated_post = PostView::read(pool, data.inserted_post.id, None, false).await?; let unauthenticated_post = PostView::read(pool, data.inserted_post.id, None, false).await?;
assert!(unauthenticated_post.is_none()); assert!(unauthenticated_post.is_none());
@ -1892,4 +2001,32 @@ mod tests {
cleanup(data, pool).await cleanup(data, pool).await
} }
#[tokio::test]
#[serial]
async fn post_tags_present() -> LemmyResult<()> {
let pool = &build_db_pool_for_tests().await;
let pool = &mut pool.into();
let data = init_data(pool).await?;
let post_view = PostView::read(
pool,
data.inserted_post_with_tags.id,
Some(&data.local_user_view.local_user),
false,
)
.await?
.ok_or(LemmyErrorType::CouldntFindPost)?;
assert_eq!(2, post_view.community_post_tags.tags.len());
assert_eq!(data.tag_1.name, post_view.community_post_tags.tags[0].name);
assert_eq!(data.tag_2.name, post_view.community_post_tags.tags[1].name);
let all_posts = data.default_post_query().list(&data.site, pool).await?;
assert_eq!(2, all_posts[0].community_post_tags.tags.len()); // post with tags
assert_eq!(0, all_posts[1].community_post_tags.tags.len()); // bot post
assert_eq!(0, all_posts[2].community_post_tags.tags.len()); // normal post
Ok(())
}
} }

View file

@ -1,9 +1,33 @@
#[cfg(feature = "full")] #[cfg(feature = "full")]
use diesel::Queryable; use diesel::Queryable;
use diesel::{
deserialize::{FromSql, FromSqlRow},
expression::AsExpression,
pg::{Pg, PgValue},
serialize::ToSql,
sql_types::{self, Nullable},
};
use lemmy_db_schema::{ use lemmy_db_schema::{
aggregates::structs::{CommentAggregates, PersonAggregates, PostAggregates, SiteAggregates}, aggregates::structs::{CommentAggregates, PersonAggregates, PostAggregates, SiteAggregates},
source::{ source::{
comment::Comment, comment_report::CommentReport, community::Community, community_post_tag::CommunityPostTag, custom_emoji::CustomEmoji, custom_emoji_keyword::CustomEmojiKeyword, images::{ImageDetails, LocalImage}, local_site::LocalSite, local_site_rate_limit::LocalSiteRateLimit, local_user::LocalUser, local_user_vote_display_mode::LocalUserVoteDisplayMode, person::Person, post::Post, post_report::PostReport, private_message::PrivateMessage, private_message_report::PrivateMessageReport, registration_application::RegistrationApplication, site::Site comment::Comment,
comment_report::CommentReport,
community::Community,
community_post_tag::CommunityPostTag,
custom_emoji::CustomEmoji,
custom_emoji_keyword::CustomEmojiKeyword,
images::{ImageDetails, LocalImage},
local_site::LocalSite,
local_site_rate_limit::LocalSiteRateLimit,
local_user::LocalUser,
local_user_vote_display_mode::LocalUserVoteDisplayMode,
person::Person,
post::Post,
post_report::PostReport,
private_message::PrivateMessage,
private_message_report::PrivateMessageReport,
registration_application::RegistrationApplication,
site::Site,
}, },
SubscribedType, SubscribedType,
}; };
@ -128,7 +152,7 @@ pub struct PostView {
pub creator_blocked: bool, pub creator_blocked: bool,
pub my_vote: Option<i16>, pub my_vote: Option<i16>,
pub unread_comments: i64, pub unread_comments: i64,
pub community_post_tags: Option<serde_json::Value> pub community_post_tags: PostCommunityPostTags,
} }
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
@ -213,3 +237,42 @@ pub struct LocalImageView {
pub local_image: LocalImage, pub local_image: LocalImage,
pub person: Person, pub person: Person,
} }
#[derive(
Clone,
serde::Serialize,
serde::Deserialize,
Debug,
PartialEq,
TS,
FromSqlRow,
AsExpression,
Default,
)]
#[serde(transparent)]
#[diesel(sql_type = Nullable<sql_types::Json>)]
pub struct PostCommunityPostTags {
pub tags: Vec<CommunityPostTag>,
}
impl FromSql<Nullable<sql_types::Json>, Pg> for PostCommunityPostTags {
fn from_sql(bytes: PgValue) -> diesel::deserialize::Result<Self> {
let value = <serde_json::Value as FromSql<sql_types::Json, Pg>>::from_sql(bytes)?;
Ok(serde_json::from_value::<PostCommunityPostTags>(value)?)
}
fn from_nullable_sql(
bytes: Option<<Pg as diesel::backend::Backend>::RawValue<'_>>,
) -> diesel::deserialize::Result<Self> {
match bytes {
Some(bytes) => Self::from_sql(bytes),
None => Ok(Self { tags: vec![] }),
}
}
}
impl ToSql<Nullable<sql_types::Json>, Pg> for PostCommunityPostTags {
fn to_sql(&self, out: &mut diesel::serialize::Output<Pg>) -> diesel::serialize::Result {
let value = serde_json::to_value(self)?;
<serde_json::Value as ToSql<sql_types::Json, Pg>>::to_sql(&value, &mut out.reborrow())
}
}

View file

@ -1,3 +1,5 @@
-- This file should undo anything in `up.sql` -- This file should undo anything in `up.sql`
drop table post_community_post_tag; DROP TABLE post_community_post_tag;
drop table community_post_tag;
DROP TABLE community_post_tag;

View file

@ -1,17 +1,18 @@
-- a tag for a post, valid in a community. created by mods of a community -- a tag for a post, valid in a community. created by mods of a community
CREATE TABLE community_post_tag ( CREATE TABLE community_post_tag (
id SERIAL PRIMARY KEY, id serial PRIMARY KEY,
ap_id TEXT NOT NULL UNIQUE, ap_id text NOT NULL UNIQUE,
community_id INT NOT NULL REFERENCES community(id), community_id int NOT NULL REFERENCES community (id) ON UPDATE CASCADE ON DELETE CASCADE,
name TEXT NOT NULL, name text NOT NULL,
published TIMESTAMPTZ NOT NULL, published timestamptz NOT NULL DEFAULT now(),
updated TIMESTAMPTZ, updated timestamptz,
deleted TIMESTAMPTZ deleted timestamptz
); );
-- an association between a post and a community post tag. created/updated by the post author or mods of a community -- an association between a post and a community post tag. created/updated by the post author or mods of a community
CREATE TABLE post_community_post_tag ( CREATE TABLE post_community_post_tag (
post_id INT NOT NULL REFERENCES post(id), post_id int NOT NULL REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE,
community_post_tag_id INT NOT NULL REFERENCES community_post_tag(id), community_post_tag_id int NOT NULL REFERENCES community_post_tag (id) ON UPDATE CASCADE ON DELETE CASCADE,
PRIMARY KEY (post_id, community_post_tag_id) PRIMARY KEY (post_id, community_post_tag_id)
); );

View file

@ -183,14 +183,14 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) {
.route("/follow", web::post().to(follow_community)) .route("/follow", web::post().to(follow_community))
.route("/block", web::post().to(block_community)) .route("/block", web::post().to(block_community))
.route("/delete", web::post().to(delete_community)) .route("/delete", web::post().to(delete_community))
.route("/post_tags", web::get().to(get_community_post_tags)) // .route("/post_tags", web::get().to(get_community_post_tags))
// Mod Actions // Mod Actions
// .route("/post_tags", web::post().to(create_update_community_post_tag))
// .route("/post_tags/delete", web::post().to(delete_community_post_tag)),
.route("/remove", web::post().to(remove_community)) .route("/remove", web::post().to(remove_community))
.route("/transfer", web::post().to(transfer_community)) .route("/transfer", web::post().to(transfer_community))
.route("/ban_user", web::post().to(ban_from_community)) .route("/ban_user", web::post().to(ban_from_community))
.route("/mod", web::post().to(add_mod_to_community)) .route("/mod", web::post().to(add_mod_to_community)),
.route("/post_tags", web::post().to(create_update_community_post_tag))
.route("/post_tags/delete", web::post().to(delete_community_post_tag)),
) )
.service( .service(
web::scope("/federated_instances") web::scope("/federated_instances")