Skip to content

Commit 546ff7f

Browse files
gregagiForge
andauthored
Rebuild project like flow (#108)
* Rebuild project like flow * Address like review feedback * Annotate homepage project likes * Move like annotations to query helper * Show like toggle errors --------- Co-authored-by: Forge <forge@gregagi.com>
1 parent 7349d17 commit 546ff7f

11 files changed

Lines changed: 307 additions & 215 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project tries to adhere to [Semantic Versioning](https://semver.org/spe
2121
- Simplified the landing page by removing the hero showcase, proof strip, and quick-link card band.
2222
- Expanded the home page project and guide sections to show six items and gave guides and jobs full-width sections.
2323
- Stopped capturing noisy project-like API requests in PostHog request analytics and session recording network logs while keeping explicit like/unlike events.
24+
- Rebuilt project likes to render counts from annotated project queries and use a single toggle request instead of per-card like API reads.
2425

2526
### Fixed
2627
- Made the bottom-right desktop ad use a solid surface instead of an opacity fade.

api/tests.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,60 @@ def test_update_like_only_toggles_value_not_project_or_author(self):
105105
self.assertEqual(like.project, self.project)
106106
self.assertFalse(like.like)
107107

108+
def test_project_like_toggle_requires_authentication(self):
109+
response = self.client.post(
110+
reverse("api_project_like_toggle", kwargs={"project_id": self.project.id}),
111+
data=json.dumps({"like": True}),
112+
content_type="application/json",
113+
)
114+
115+
self.assertIn(response.status_code, {401, 403})
116+
self.assertFalse(Like.objects.exists())
117+
118+
def test_project_like_toggle_creates_like_and_returns_count(self):
119+
self.client.force_login(self.user)
120+
121+
response = self.client.post(
122+
reverse("api_project_like_toggle", kwargs={"project_id": self.project.id}),
123+
data=json.dumps({"like": True}),
124+
content_type="application/json",
125+
)
126+
127+
self.assertEqual(response.status_code, 200)
128+
self.assertEqual(response.json()["like"], True)
129+
self.assertEqual(response.json()["like_count"], 1)
130+
self.assertTrue(Like.objects.get(author=self.user, project=self.project).like)
131+
132+
def test_project_like_toggle_updates_existing_like_and_returns_count(self):
133+
Like.objects.create(author=self.user, project=self.project, like=True)
134+
Like.objects.create(author=self.other_user, project=self.project, like=True)
135+
self.client.force_login(self.user)
136+
137+
response = self.client.post(
138+
reverse("api_project_like_toggle", kwargs={"project_id": self.project.id}),
139+
data=json.dumps({"like": False}),
140+
content_type="application/json",
141+
)
142+
143+
self.assertEqual(response.status_code, 200)
144+
self.assertEqual(response.json()["like"], False)
145+
self.assertEqual(response.json()["like_count"], 1)
146+
self.assertFalse(Like.objects.get(author=self.user, project=self.project).like)
147+
148+
def test_project_like_toggle_toggles_when_like_value_is_omitted(self):
149+
Like.objects.create(author=self.user, project=self.project, like=True)
150+
self.client.force_login(self.user)
151+
152+
response = self.client.post(
153+
reverse("api_project_like_toggle", kwargs={"project_id": self.project.id}),
154+
data=json.dumps({}),
155+
content_type="application/json",
156+
)
157+
158+
self.assertEqual(response.status_code, 200)
159+
self.assertEqual(response.json()["like"], False)
160+
self.assertEqual(response.json()["like_count"], 0)
161+
108162

109163
class SearchProjectsApiTests(TestCase):
110164
def test_search_projects_filters_to_public_active_projects_and_limits_results(self):

api/urls.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from django.urls import path
22

33
from . import views
4-
from .views import CreateLikeProjectAPIView, CreatePostAPIView, UpdateLikeProjectAPIView
4+
from .views import CreateLikeProjectAPIView, CreatePostAPIView, ProjectLikeToggleAPIView, UpdateLikeProjectAPIView
55

66
urlpatterns = [
77
path("like/", CreateLikeProjectAPIView.as_view()),
88
path("like/<int:pk>/", UpdateLikeProjectAPIView.as_view()),
9+
path("projects/<int:project_id>/like/", ProjectLikeToggleAPIView.as_view(), name="api_project_like_toggle"),
910
path("search/projects/", views.search_projects, name="api_search_projects"),
1011
path("posts/", CreatePostAPIView.as_view(), name="api_create_post"),
1112
]

api/views.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from rest_framework.decorators import api_view, permission_classes
55
from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated
66
from rest_framework.response import Response
7+
from rest_framework.views import APIView
78

89
from blog.models import Post
910
from builtwithdjango.analytics import capture
@@ -98,6 +99,63 @@ def perform_destroy(self, instance):
9899
)
99100

100101

102+
class ProjectLikeToggleAPIView(APIView):
103+
permission_classes = [IsAuthenticated]
104+
105+
def get_requested_like(self, request):
106+
if "like" not in request.data:
107+
return None
108+
109+
value = request.data["like"]
110+
if isinstance(value, bool):
111+
return value
112+
if isinstance(value, str):
113+
normalized_value = value.lower()
114+
if normalized_value in {"true", "1", "yes", "on"}:
115+
return True
116+
if normalized_value in {"false", "0", "no", "off"}:
117+
return False
118+
119+
return bool(value)
120+
121+
def post(self, request, project_id):
122+
project = generics.get_object_or_404(Project, pk=project_id)
123+
requested_like = self.get_requested_like(request)
124+
like_value = requested_like
125+
if like_value is None:
126+
existing_like = Like.objects.filter(author=request.user, project=project).first()
127+
like_value = not bool(existing_like and existing_like.like)
128+
129+
like, _ = Like.objects.update_or_create(
130+
author=request.user,
131+
project=project,
132+
defaults={"like": like_value},
133+
)
134+
like_count = Like.objects.filter(project=project, like=True).count()
135+
136+
capture(
137+
request,
138+
"project liked" if like.like else "project unliked",
139+
properties={
140+
"project_id": like.project_id,
141+
"author_id": like.author_id,
142+
"like_id": like.id,
143+
"like_value": like.like,
144+
"like_count": like_count,
145+
},
146+
groups={"project": str(like.project_id)},
147+
)
148+
149+
return Response(
150+
{
151+
"project": project.id,
152+
"like": like.like,
153+
"like_count": like_count,
154+
},
155+
status=status.HTTP_200_OK,
156+
)
157+
158+
101159
@api_view(["GET"])
102160
@permission_classes([AllowAny])
103161
def search_projects(request):

0 commit comments

Comments
 (0)