diff --git a/core/constants.py b/core/constants.py index e972aaa3..76716a7e 100644 --- a/core/constants.py +++ b/core/constants.py @@ -1,4 +1,4 @@ ONE_DAY_IN_SECONDS = 60 * 60 * 24 ONE_WEEK_IN_SECONDS = ONE_DAY_IN_SECONDS * 7 -VIEWS_CACHING_TIMEOUT = 60 * 1 -LIKES_CACHING_TIMEOUT = 60 * 1 +VIEWS_CACHING_TIMEOUT = ONE_DAY_IN_SECONDS +LIKES_CACHING_TIMEOUT = 60 * 60 * 8 diff --git a/core/services.py b/core/services.py index 02e347c2..b431509d 100644 --- a/core/services.py +++ b/core/services.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.cache import cache +from django.db.models import Count from core.constants import VIEWS_CACHING_TIMEOUT from core.models import Like, View, Link @@ -64,12 +65,23 @@ def add_view(obj, user): view, is_created = View.objects.get_or_create( content_type=obj_type, object_id=obj.id, user=user ) + views_count = cache.get(f"views_count_{obj_type}_{obj.id}", None) + if views_count is not None: + cache.set( + f"views_count_{obj_type}_{obj.id}", views_count + 1, VIEWS_CACHING_TIMEOUT + ) + return view def remove_view(obj, user): obj_type = ContentType.objects.get_for_model(obj) View.objects.filter(content_type=obj_type, object_id=obj.id, user=user).delete() + views_count = cache.get(f"views_count_{obj_type}_{obj.id}", None) + if views_count is not None: + cache.set( + f"views_count_{obj_type}_{obj.id}", views_count - 1, VIEWS_CACHING_TIMEOUT + ) def is_viewer(obj, user) -> bool: @@ -85,6 +97,33 @@ def get_viewers(obj): return User.objects.filter(views__content_type=obj_type, views__object_id=obj.id) +def cache_views_for_many_objects(objs): + """ + Set cached views count for many objects in 1 SQL query + + Args: + objs: + List of objects that need their views retrieved + + Returns: QuerySet[dict[object_id, count]] + """ + obj_type = ContentType.objects.get_for_model(objs[0]) + ids = [obj.id for obj in objs] + data = ( + View.objects.filter(content_type=obj_type, object_id__in=ids) + .values("object_id") + .annotate(count=Count("object_id")) + ) + for i in data: + cache.set(f"views_count_{obj_type}_{i.object_id}", i.count, VIEWS_CACHING_TIMEOUT) + return data + + +def get_views_count_cached(obj): + obj_type = ContentType.objects.get_for_model(obj) + return cache.get(f"views_count_{obj_type}_{obj.id}", None) + + def get_views_count(obj): obj_type = ContentType.objects.get_for_model(obj) # cache this diff --git a/projects/serializers.py b/projects/serializers.py index 165bc5e6..5dd4dee6 100644 --- a/projects/serializers.py +++ b/projects/serializers.py @@ -71,7 +71,6 @@ class ProjectDetailSerializer(serializers.ModelSerializer): vacancies = ProjectVacancyListSerializer(many=True, read_only=True) short_description = serializers.SerializerMethodField() industry_id = serializers.IntegerField(required=False) - likes_count = serializers.SerializerMethodField(method_name="count_likes") views_count = serializers.SerializerMethodField(method_name="count_views") links = serializers.SerializerMethodField() partner_programs_tags = serializers.SerializerMethodField() @@ -95,9 +94,6 @@ def validate(self, data): def get_short_description(cls, project): return project.get_short_description() - def count_likes(self, project): - return get_likes_count(project) - def count_views(self, project): return get_views_count(project) @@ -128,7 +124,6 @@ class Meta: "datetime_created", "datetime_updated", "views_count", - "likes_count", "cover", "partner_programs_tags", ] @@ -141,7 +136,6 @@ class Meta: class ProjectListSerializer(serializers.ModelSerializer): - likes_count = serializers.SerializerMethodField(method_name="count_likes") views_count = serializers.SerializerMethodField(method_name="count_views") collaborator_count = serializers.SerializerMethodField( method_name="get_collaborator_count" @@ -168,9 +162,6 @@ def get_short_description(cls, project): def get_collaborator_count(cls, obj): return len(obj.collaborator_set.all()) - def count_likes(self, obj): - return get_likes_count(obj) - class Meta: model = Project fields = [ @@ -186,12 +177,11 @@ class Meta: "collaborator_count", "vacancies", "datetime_created", - "likes_count", "views_count", "partner_programs_tags", ] - read_only_fields = ["leader", "views_count", "likes_count"] + read_only_fields = ["leader", "views_count"] def is_valid(self, *, raise_exception=False): return super().is_valid(raise_exception=raise_exception) diff --git a/projects/views.py b/projects/views.py index 3e6952f2..fb42309f 100644 --- a/projects/views.py +++ b/projects/views.py @@ -1,4 +1,5 @@ from django.contrib.auth import get_user_model +from django.core.cache import cache from django.db.models import Q from django_filters import rest_framework as filters from rest_framework import generics, permissions, status @@ -8,7 +9,13 @@ from core.permissions import IsStaffOrReadOnly from core.serializers import SetLikedSerializer -from core.services import add_view, set_like +from core.services import ( + add_view, + set_like, + get_views_count, + get_views_count_cached, + cache_views_for_many_objects, +) from partner_programs.models import PartnerProgram, PartnerProgramUserProfile from projects.filters import ProjectFilter from projects.constants import VERBOSE_STEPS @@ -49,6 +56,35 @@ class ProjectList(generics.ListCreateAPIView): filter_backends = (filters.DjangoFilterBackend,) filterset_class = ProjectFilter + def list(self, request, *args, **kwargs): + # check that we are not filtering + if not request.query_params: + cached_view = cache.get("project_list_view", None) + if cached_view is not None: + return Response(cached_view) + + queryset = self.filter_queryset(self.get_queryset()) + # order by view count. View Count is a cached value for each project + + # check random project for whether it's views are in the cache + # if not, then cache all projects' views + if get_views_count_cached(queryset.first()) is None: + project_views = cache_views_for_many_objects(queryset) + project_views_dict = { + view.object_id: project_views[view.object_id] for view in queryset + } + views = {project.id: project_views_dict[project.id] for project in queryset} + else: + views = {project.id: get_views_count(project) for project in queryset} + + # TODO: add paging ASAP + queryset = sorted(queryset, key=lambda project: views[project.id], reverse=True) + serializer = self.get_serializer(queryset, many=True) + if not request.query_params: + data = serializer.data + cache.set("project_list_view", data, 60 * 15) + return Response(serializer.data) + def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -72,6 +108,7 @@ def create(self, request, *args, **kwargs): ) headers = self.get_success_headers(serializer.data) + cache.delete("project_list_view") return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) def post(self, request, *args, **kwargs): @@ -136,6 +173,7 @@ def put(self, request, pk, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) check_related_fields_update(request.data, pk) + cache.delete("project_list_view") return super(ProjectDetail, self).put(request, pk) def patch(self, request, pk, **kwargs): @@ -154,6 +192,7 @@ def patch(self, request, pk, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) check_related_fields_update(request.data, pk) + cache.delete("project_list_view") return super(ProjectDetail, self).put(request, pk)