Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commitf96aceb

Browse files
authored
FIX: Use SolvedTopics to list posts in /activity/solved instead of user actions (#376)
In#342 we moved solutions away from topic_custom_fields into proper tables, with the tables as the proper source of truth to a topic's solution.The user's /my/activity/solved route uses user_actions which is not accurate, and a user has reported a bug where their solution is not reflected there (user actions are not a good representation of what a topic's solution is). This commit introduces - a new route to get solutions, and is mindful `hide_user_profiles_from_public` and such settings- also mindful of PMs and private categories- a new template that makes use of the `<UserStream>` to load posts safely and avoid reimplementation
1 parent041b58e commitf96aceb

File tree

8 files changed

+374
-9
lines changed

8 files changed

+374
-9
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
classDiscourseSolved::SolvedTopicsController < ::ApplicationController
4+
requires_pluginDiscourseSolved::PLUGIN_NAME
5+
6+
defby_user
7+
params.require(:username)
8+
user=
9+
fetch_user_from_params(
10+
include_inactive:
11+
current_user.try(:staff?) ||(current_user &&SiteSetting.show_inactive_accounts),
12+
)
13+
raiseDiscourse::NotFoundunlessguardian.public_can_see_profiles?
14+
raiseDiscourse::NotFoundunlessguardian.can_see_profile?(user)
15+
16+
offset=[0,params[:offset].to_i].max
17+
limit=params.fetch(:limit,30).to_i
18+
19+
posts=
20+
Post
21+
.joins(
22+
"INNER JOIN discourse_solved_solved_topics ON discourse_solved_solved_topics.answer_post_id = posts.id",
23+
)
24+
.joins(:topic)
25+
.joins("LEFT JOIN categories ON categories.id = topics.category_id")
26+
.where(user_id:user.id,deleted_at:nil)
27+
.where(topics:{archetype:Archetype.default,deleted_at:nil})
28+
.where(
29+
"topics.category_id IS NULL OR NOT categories.read_restricted OR topics.category_id IN (:secure_category_ids)",
30+
secure_category_ids:guardian.secure_category_ids,
31+
)
32+
.includes(:user,topic:%i[categorytags])
33+
.order("discourse_solved_solved_topics.created_at DESC")
34+
.offset(offset)
35+
.limit(limit)
36+
37+
render_serialized(posts,DiscourseSolved::SolvedPostSerializer,root:"user_solved_posts")
38+
end
39+
end
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
classDiscourseSolved::SolvedPostSerializer <ApplicationSerializer
4+
attributes:created_at,
5+
:archived,
6+
:avatar_template,
7+
:category_id,
8+
:closed,
9+
:cooked,
10+
:excerpt,
11+
:name,
12+
:post_id,
13+
:post_number,
14+
:post_type,
15+
:raw,
16+
:slug,
17+
:topic_id,
18+
:topic_title,
19+
:truncated,
20+
:url,
21+
:user_id,
22+
:username
23+
24+
defarchived
25+
object.topic.archived
26+
end
27+
28+
defavatar_template
29+
object.user&.avatar_template
30+
end
31+
32+
defcategory_id
33+
object.topic.category_id
34+
end
35+
36+
defclosed
37+
object.topic.closed
38+
end
39+
40+
defexcerpt
41+
@excerpt ||=PrettyText.excerpt(cooked,300,keep_emoji_images:true)
42+
end
43+
44+
defname
45+
object.user&.name
46+
end
47+
48+
definclude_name?
49+
SiteSetting.enable_names?
50+
end
51+
52+
defpost_id
53+
object.id
54+
end
55+
56+
defslug
57+
Slug.for(object.topic.title)
58+
end
59+
60+
definclude_slug?
61+
object.topic.title.present?
62+
end
63+
64+
deftopic_title
65+
object.topic.title
66+
end
67+
68+
deftruncated
69+
true
70+
end
71+
72+
definclude_truncated?
73+
cooked.length >300
74+
end
75+
76+
defurl
77+
"#{Discourse.base_url}#{object.url}"
78+
end
79+
80+
defuser_id
81+
object.user_id
82+
end
83+
84+
defusername
85+
object.user&.username
86+
end
87+
end

‎assets/javascripts/discourse/routes/user-activity-solved.js‎

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,110 @@
1-
importUserActivityStreamRoutefrom"discourse/routes/user-activity-stream";
1+
import{tracked}from"@glimmer/tracking";
2+
importEmberObjectfrom"@ember/object";
3+
import{service}from"@ember/service";
4+
import{Promise}from"rsvp";
5+
import{ajax}from"discourse/lib/ajax";
6+
importDiscourseRoutefrom"discourse/routes/discourse";
27
import{i18n}from"discourse-i18n";
38

4-
exportdefaultclassUserActivitySolvedextendsUserActivityStreamRoute{
5-
userActionType=15;
6-
noContentHelpKey="solved.no_solutions";
9+
classSolvedPostsStream{
10+
@trackedcontent=[];
11+
@trackedloading=false;
12+
@trackedloaded=false;
13+
@trackeditemsLoaded=0;
14+
@trackedcanLoadMore=true;
15+
16+
constructor({ username, siteCategories}){
17+
this.username=username;
18+
this.siteCategories=siteCategories;
19+
}
20+
21+
getnoContent(){
22+
returnthis.loaded&&this.content.length===0;
23+
}
24+
25+
findItems(){
26+
if(this.loading||!this.canLoadMore){
27+
returnPromise.resolve();
28+
}
29+
30+
this.loading=true;
31+
32+
constlimit=20;
33+
returnajax(
34+
`/solution/by_user.json?username=${this.username}&offset=${this.itemsLoaded}&limit=${limit}`
35+
)
36+
.then((result)=>{
37+
constuserSolvedPosts=result.user_solved_posts||[];
38+
39+
if(userSolvedPosts.length===0){
40+
this.canLoadMore=false;
41+
return;
42+
}
43+
44+
constposts=userSolvedPosts.map((p)=>{
45+
constpost=EmberObject.create(p);
46+
post.set("titleHtml",post.topic_title);
47+
post.set("postUrl",post.url);
48+
49+
if(post.category_id&&this.siteCategories){
50+
post.set(
51+
"category",
52+
this.siteCategories.find((c)=>c.id===post.category_id)
53+
);
54+
}
55+
returnpost;
56+
});
57+
58+
this.content=[...this.content, ...posts];
59+
this.itemsLoaded=this.itemsLoaded+userSolvedPosts.length;
60+
61+
if(userSolvedPosts.length<limit){
62+
this.canLoadMore=false;
63+
}
64+
})
65+
.finally(()=>{
66+
this.loaded=true;
67+
this.loading=false;
68+
});
69+
}
70+
}
71+
72+
exportdefaultclassUserActivitySolvedextendsDiscourseRoute{
73+
@servicesite;
74+
@servicecurrentUser;
75+
76+
model(){
77+
constuser=this.modelFor("user");
78+
79+
conststream=newSolvedPostsStream({
80+
username:user.username,
81+
siteCategories:this.site.categories,
82+
});
83+
84+
returnstream.findItems().then(()=>{
85+
return{
86+
stream,
87+
emptyState:this.emptyState(),
88+
};
89+
});
90+
}
91+
92+
setupController(controller,model){
93+
controller.setProperties({
94+
model,
95+
emptyState:this.emptyState(),
96+
});
97+
}
98+
99+
renderTemplate(){
100+
this.render("user-activity-solved");
101+
}
7102

8103
emptyState(){
9104
constuser=this.modelFor("user");
10105

11106
lettitle,body;
12-
if(this.isCurrentUser(user)){
107+
if(this.currentUser&&user.id===this.currentUser.id){
13108
title=i18n("solved.no_solved_topics_title");
14109
body=i18n("solved.no_solved_topics_body");
15110
}else{
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
importRouteTemplatefrom"ember-route-template";
2+
importEmptyStatefrom"discourse/components/empty-state";
3+
importUserStreamfrom"discourse/components/user-stream";
4+
5+
exportdefaultRouteTemplate(
6+
<template>
7+
{{#if@controller.model.stream.noContent}}
8+
<EmptyState
9+
@title={{@controller.model.emptyState.title}}
10+
@body={{@controller.model.emptyState.body}}
11+
/>
12+
{{else}}
13+
<UserStream@stream={{@controller.model.stream}} />
14+
{{/if}}
15+
</template>
16+
);

‎config/routes.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
DiscourseSolved::Engine.routes.drawdo
44
post"/accept"=>"answer#accept"
55
post"/unaccept"=>"answer#unaccept"
6+
7+
get"/by_user"=>"solved_topics#by_user"
68
end
79

810
Discourse::Application.routes.draw{mount ::DiscourseSolved::Engine,at:"solution"}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# frozen_string_literal: true
2+
3+
describeDiscourseSolved::SolvedTopicsControllerdo
4+
fab!(:user)
5+
fab!(:another_user){Fabricate(:user)}
6+
fab!(:admin)
7+
fab!(:topic)
8+
fab!(:post){Fabricate(:post,topic:)}
9+
fab!(:answer_post){Fabricate(:post,topic:,user:)}
10+
fab!(:solved_topic){Fabricate(:solved_topic,topic:,answer_post:)}
11+
12+
describe"#by_user"do
13+
context"when accessing with username"do
14+
it"returns solved posts for the specified user"do
15+
sign_in(admin)
16+
17+
get"/solution/by_user.json",params:{username:user.username}
18+
19+
expect(response.status).toeq(200)
20+
result=response.parsed_body
21+
expect(result["user_solved_posts"]).tobe_present
22+
expect(result["user_solved_posts"].length).toeq(1)
23+
expect(result["user_solved_posts"][0]["post_id"]).toeq(answer_post.id)
24+
end
25+
26+
it"returns 404 for a non-existent user"do
27+
sign_in(admin)
28+
29+
get"/solution/by_user.json",params:{username:"non-existent-user"}
30+
31+
expect(response.status).toeq(404)
32+
end
33+
34+
it"correctly handles the offset parameter"do
35+
sign_in(admin)
36+
37+
get"/solution/by_user.json",params:{username:user.username,offset:1}
38+
39+
expect(response.status).toeq(200)
40+
result=response.parsed_body
41+
expect(result["user_solved_posts"]).tobe_empty
42+
end
43+
44+
it"correctly handles the limit parameter"do
45+
Fabricate(:solved_topic,answer_post:Fabricate(:post,user:))
46+
47+
sign_in(admin)
48+
49+
get"/solution/by_user.json",params:{username:user.username,limit:1}
50+
51+
expect(response.status).toeq(200)
52+
result=response.parsed_body
53+
expect(result["user_solved_posts"].length).toeq(1)
54+
end
55+
end
56+
57+
context"when accessing without username"do
58+
it"returns 400 for the current user"do
59+
sign_in(user)
60+
61+
get"/solution/by_user.json"
62+
63+
expect(response.status).toeq(400)
64+
end
65+
66+
it"returns 400 if not logged in"do
67+
get"/solution/by_user.json"
68+
69+
expect(response.status).toeq(400)
70+
end
71+
end
72+
73+
context"with visibility restrictions"do
74+
context"with private category solved topic"do
75+
fab!(:group){Fabricate(:group).tap{ |g|g.add(user)}}
76+
fab!(:private_category){Fabricate(:private_category,group:)}
77+
fab!(:private_topic){Fabricate(:topic,category:private_category)}
78+
fab!(:private_post){Fabricate(:post,topic:private_topic)}
79+
fab!(:private_answer_post){Fabricate(:post,topic:private_topic,user:user)}
80+
fab!(:private_solved_topic)do
81+
Fabricate(:solved_topic,topic:private_topic,answer_post:private_answer_post)
82+
end
83+
84+
it"respects category permissions"do
85+
sign_in(another_user)
86+
87+
get"/solution/by_user.json",params:{username:user.username}
88+
89+
expect(response.status).toeq(200)
90+
result=response.parsed_body
91+
# admin sees both solutions
92+
expect(result["user_solved_posts"].length).toeq(1)
93+
94+
sign_in(user)
95+
96+
get"/solution/by_user.json",params:{username:user.username}
97+
98+
expect(response.status).toeq(200)
99+
result=response.parsed_body
100+
expect(result["user_solved_posts"].length).toeq(2)
101+
end
102+
end
103+
104+
it"does not return PMs"do
105+
topic.update(archetype:Archetype.private_message,category:nil)
106+
107+
sign_in(user)
108+
109+
get"/solution/by_user.json",params:{username:user.username}
110+
111+
expect(response.status).toeq(200)
112+
result=response.parsed_body
113+
expect(result["user_solved_posts"]).tobe_empty
114+
end
115+
end
116+
end
117+
end

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp