3838
3939# ── Vocabulary ────────────────────────────────────────────────────────────────
4040
41+ # vote_label / down_label name what an upvote / downvote means for the flair.
4142FLAIRS = {
42- "bug" : {"label" : "Bug" , "vote_label" : "Me too" , "icon" : "fa-bug" },
43- "question" : {"label" : "Question" , "vote_label" : "Same question" , "icon" : "fa-circle-question" },
44- "feature" : {"label" : "Feature request" , "vote_label" : "I want this" , "icon" : "fa-lightbulb" },
45- "feedback" : {"label" : "Feedback" , "vote_label" : "Agree" , "icon" : "fa-comment-dots" },
46- "announcement" : {"label" : "Announcement" , "vote_label" : "Noted" , "icon" : "fa-bullhorn" , "admin_only" : True },
43+ "bug" : {"label" : "Bug" , "vote_label" : "Me too" , "down_label" : "Can't reproduce" ,
44+ "icon" : "fa-bug" , "hint" : "Something is broken or behaves wrongly" },
45+ "question" : {"label" : "Question" , "vote_label" : "Upvote" , "down_label" : "Downvote" ,
46+ "icon" : "fa-circle-question" , "hint" : "Ask how something works" },
47+ "feature" : {"label" : "Feature request" , "vote_label" : "Upvote" , "down_label" : "Downvote" ,
48+ "icon" : "fa-lightbulb" , "hint" : "Suggest something new or better" },
49+ "feedback" : {"label" : "Feedback" , "vote_label" : "Upvote" , "down_label" : "Downvote" ,
50+ "icon" : "fa-comment-dots" , "hint" : "Share what you like or don't" },
51+ "announcement" : {"label" : "Announcement" , "vote_label" : "Upvote" , "down_label" : "Downvote" ,
52+ "icon" : "fa-bullhorn" , "hint" : "News from the team" , "admin_only" : True },
4753}
4854
4955STATUSES = {
@@ -405,11 +411,19 @@ def restore_post(post: CommunityPost) -> None:
405411# ── Votes ─────────────────────────────────────────────────────────────────────
406412
407413def _recount_votes (post : CommunityPost ) -> int :
408- post .vote_count = db .session .query (func .count (CommunityVote .id )).filter_by (post_id = post .id ).scalar () or 0
409- return post .vote_count
410-
411-
412- def toggle_vote (post : CommunityPost , user ) -> tuple [bool , int ]:
414+ rows = dict (db .session .query (CommunityVote .value , func .count (CommunityVote .id ))
415+ .filter_by (post_id = post .id ).group_by (CommunityVote .value ).all ())
416+ post .vote_count = rows .get (1 , 0 )
417+ post .downvote_count = rows .get (- 1 , 0 )
418+ post .score = post .vote_count - post .downvote_count
419+ return post .score
420+
421+
422+ def cast_vote (post : CommunityPost , user , value : int = 1 ) -> int :
423+ """Reddit-style: +1 / -1; repeating your current vote clears it, the
424+ opposite vote switches it. Returns the user's vote afterwards (1, -1, 0)."""
425+ if value not in (1 , - 1 ):
426+ raise CommunityError ("Vote must be up or down." )
413427 require_can_write (user , voting = True )
414428 if post .author_id == user .id :
415429 raise CommunityError ("You can't vote on your own post." , code = "own_post" )
@@ -418,24 +432,37 @@ def toggle_vote(post: CommunityPost, user) -> tuple[bool, int]:
418432 if post .deleted_at is not None or post .status == "duplicate" :
419433 raise CommunityError ("Voting is closed on this post." , code = "closed" )
420434 existing = CommunityVote .query .filter_by (post_id = post .id , user_id = user .id ).first ()
421- if existing :
435+ if existing is not None and existing . value == value :
422436 db .session .delete (existing )
423- voted = False
437+ mine = 0
438+ elif existing is not None :
439+ existing .value = value
440+ mine = value
424441 else :
425- db .session .add (CommunityVote (post_id = post .id , user_id = user .id ))
426- voted = True
442+ db .session .add (CommunityVote (post_id = post .id , user_id = user .id , value = value ))
443+ mine = value
427444 try :
428445 db .session .flush ()
429446 except IntegrityError : # double-click race: the other request won
430447 db .session .rollback ()
431- voted = True
432- count = _recount_votes (post )
448+ mine = my_vote ( post , user )
449+ _recount_votes (post )
433450 db .session .commit ()
434- return voted , count
451+ return mine
435452
436453
437- def has_voted (post : CommunityPost , user ) -> bool :
438- return CommunityVote .query .filter_by (post_id = post .id , user_id = user .id ).first () is not None
454+ def my_vote (post : CommunityPost , user ) -> int :
455+ vote = CommunityVote .query .filter_by (post_id = post .id , user_id = user .id ).first ()
456+ return vote .value if vote is not None else 0
457+
458+
459+ def my_votes (posts , user ) -> dict [int , int ]:
460+ """post id -> the user's vote, for a list page in one query."""
461+ ids = [p .id for p in posts ]
462+ if not ids :
463+ return {}
464+ return dict (db .session .query (CommunityVote .post_id , CommunityVote .value )
465+ .filter (CommunityVote .user_id == user .id , CommunityVote .post_id .in_ (ids )).all ())
439466
440467
441468# ── Comments ──────────────────────────────────────────────────────────────────
@@ -571,7 +598,7 @@ def set_status(post: CommunityPost, admin, status: str) -> None:
571598 label = STATUSES [status ]
572599 _notify (post .author_id , "status_change" , post , actor = admin , detail = label )
573600 if status == "fixed" :
574- voter_ids = [v .user_id for v in CommunityVote .query .filter_by (post_id = post .id ).all ()]
601+ voter_ids = [v .user_id for v in CommunityVote .query .filter_by (post_id = post .id , value = 1 ).all ()]
575602 for uid in voter_ids :
576603 if uid != post .author_id :
577604 _notify (uid , "status_change" , post , actor = admin , detail = label )
@@ -602,15 +629,16 @@ def mark_duplicate(post: CommunityPost, admin, target_public_id: str) -> Communi
602629 if target .duplicate_of_id == post .id :
603630 raise CommunityError ("That post is already a duplicate of this one." )
604631 # Move votes (and the duplicate's author, who clearly has the issue too).
605- carriers = {v .user_id for v in CommunityVote .query .filter_by (post_id = post .id ).all ()}
632+ # Upvotes ("me too") carry over; downvotes on the duplicate don't.
633+ carriers = {v .user_id for v in CommunityVote .query .filter_by (post_id = post .id , value = 1 ).all ()}
606634 if post .author_id :
607635 carriers .add (post .author_id )
608636 already = {v .user_id for v in CommunityVote .query .filter_by (post_id = target .id ).all ()}
609637 for uid in carriers - already - {target .author_id }:
610- db .session .add (CommunityVote (post_id = target .id , user_id = uid ))
638+ db .session .add (CommunityVote (post_id = target .id , user_id = uid , value = 1 ))
611639 CommunityVote .query .filter_by (post_id = post .id ).delete ()
612640 post .duplicate_of_id = target .id
613- post .vote_count = 0
641+ post .vote_count = post . downvote_count = post . score = 0
614642 db .session .flush ()
615643 _recount_votes (target )
616644 set_status (post , admin , "duplicate" ) # commits
@@ -667,7 +695,7 @@ def resolve_report(report: CommunityReport, admin, resolution: str) -> None:
667695
668696# ── Listing ───────────────────────────────────────────────────────────────────
669697
670- SORTS = {"active" : CommunityPost .last_activity_at , "new" : CommunityPost .created_at , "top" : CommunityPost .vote_count }
698+ SORTS = {"active" : CommunityPost .last_activity_at , "new" : CommunityPost .created_at , "top" : CommunityPost .score }
671699
672700
673701def _visible_query (viewer , * , include_deleted = False ):
@@ -821,13 +849,24 @@ def image_urls(img: CommunityImage) -> dict:
821849 }
822850
823851
824- def serialize_post (post : CommunityPost , viewer , * , detail = False ) -> dict :
852+ def can_vote (post : CommunityPost , viewer ) -> bool :
853+ return (post .author_id != viewer .id and post .deleted_at is None and post .status != "duplicate"
854+ and post .visibility == "public" )
855+
856+
857+ def serialize_post (post : CommunityPost , viewer , * , detail = False , vote = None ) -> dict :
858+ """``vote`` is the viewer's vote when the caller already knows it (list
859+ pages batch it via my_votes); otherwise it is looked up for detail views."""
825860 flair = FLAIRS .get (post .flair , FLAIRS ["question" ])
826861 admin = is_admin (viewer )
862+ if vote is None and detail :
863+ vote = my_vote (post , viewer )
827864 data = {
828865 "id" : post .public_id ,
829866 "flair" : post .flair , "flair_label" : flair ["label" ], "flair_icon" : flair ["icon" ],
830- "vote_label" : flair ["vote_label" ],
867+ "vote_label" : flair ["vote_label" ], "down_label" : flair ["down_label" ],
868+ "downvote_count" : post .downvote_count , "score" : post .score ,
869+ "my_vote" : vote or 0 , "can_vote" : can_vote (post , viewer ),
831870 "title" : post .title ,
832871 "status" : post .status , "status_label" : STATUSES .get (post .status , post .status ),
833872 "visibility" : post .visibility ,
@@ -848,14 +887,13 @@ def serialize_post(post: CommunityPost, viewer, *, detail=False) -> dict:
848887 "match_format" : post .match_format ,
849888 "match_format_label" : BUG_FORMATS .get (post .match_format or "" , None ),
850889 "images" : [image_urls (i ) for i in post .images if i .purged_at is None or admin ],
851- "voted" : has_voted ( post , viewer ) ,
890+ "voted" : vote == 1 ,
852891 "duplicate_of" : None ,
853892 "can" : {
854893 "edit" : can_edit_post (post , viewer ),
855894 "delete" : admin or post .author_id == viewer .id ,
856895 "comment" : can_comment (post , viewer ),
857- "vote" : (post .author_id != viewer .id and post .deleted_at is None and post .status != "duplicate"
858- and post .visibility == "public" ),
896+ "vote" : can_vote (post , viewer ),
859897 "moderate" : admin ,
860898 },
861899 })
@@ -924,7 +962,7 @@ def admin_queues(limit=50) -> dict:
924962 "private" : live .filter (CommunityPost .visibility == "private" , CommunityPost .needs_admin .is_ (True ))
925963 .order_by (CommunityPost .last_activity_at .asc ()).limit (limit ).all (),
926964 "unanswered" : live .filter (CommunityPost .visibility == "public" , CommunityPost .needs_admin .is_ (True ))
927- .order_by (CommunityPost .vote_count .desc (), CommunityPost .created_at .asc ()).limit (limit ).all (),
965+ .order_by (CommunityPost .score .desc (), CommunityPost .created_at .asc ()).limit (limit ).all (),
928966 "reports" : CommunityReport .query .filter (CommunityReport .resolved_at .is_ (None ))
929967 .order_by (CommunityReport .created_at .asc ()).limit (limit ).all (),
930968 "deleted" : CommunityPost .query .filter (CommunityPost .deleted_at .isnot (None ))
0 commit comments