From 7ce8ea5669898f02bc944f61621c09c043c09ed0 Mon Sep 17 00:00:00 2001 From: Ndibe Raymond Olisaemeka Date: Sun, 2 Jun 2024 20:57:32 +0100 Subject: [PATCH] improve project recommendation * create views and utils methods to recommend projects * create api function to receive recommended projects in the frontend * modify project filtering * refactor recommend projects function Issue: #1013 Signed-off-by: Ndibe Raymond Olisaemeka --- .pre-commit-config.yaml | 5 +- zubhub_backend/zubhub/projects/urls.py | 105 ++- zubhub_backend/zubhub/projects/utils.py | 302 +++--- zubhub_backend/zubhub/projects/views.py | 287 +++--- zubhub_frontend/zubhub/src/api/api.js | 5 + .../src/store/actions/projectActions.js | 875 +++++++++--------- .../views/project_details/ProjectDetails.jsx | 31 +- 7 files changed, 852 insertions(+), 758 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 62f88d1e5..d93514454 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,13 +10,14 @@ repos: files: zubhub_backend/.*\.py$ - repo: https://github.com/PyCQA/isort - rev: 5.11.5 # do not update this until our CI runner uses Python >=3.8.1 + rev: 5.11.5 hooks: - id: isort + args: ["--profile", "black"] files: zubhub_backend/.*\.py$ - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 # do not update this until our CI runner uses Python >=3.8.1 + rev: 5.0.4 hooks: - id: flake8 # use same max-line-length as black diff --git a/zubhub_backend/zubhub/projects/urls.py b/zubhub_backend/zubhub/projects/urls.py index a735d72c8..1a64d8a35 100644 --- a/zubhub_backend/zubhub/projects/urls.py +++ b/zubhub_backend/zubhub/projects/urls.py @@ -1,46 +1,71 @@ from django.urls import path -from .views import * + +from .views import ( + AddCommentAPIView, + CategoryListAPIView, + DeleteCommentAPIView, + ProjectAutocompleteAPIView, + ProjectCreateAPIView, + ProjectDeleteAPIView, + ProjectDetailsAPIView, + ProjectListAPIView, + ProjectRecommendAPIView, + ProjectSearchAPIView, + ProjectTagAutocompleteAPIView, + ProjectTagSearchAPIView, + ProjectUpdateAPIView, + SavedProjectsAPIView, + StaffPickDetailsAPIView, + StaffPickListAPIView, + ToggleLikeAPIView, + ToggleSaveAPIView, + UnpublishCommentAPIView, +) app_name = "projects" urlpatterns = [ - path('', ProjectListAPIView.as_view(), name='list_projects'), - path('tags/autocomplete/', - ProjectTagAutocompleteAPIView.as_view(), - name='autocomplete_tags'), - path('tags/search/', ProjectTagSearchAPIView.as_view(), - name='search_tags'), - path('autocomplete/', - ProjectAutocompleteAPIView.as_view(), - name='autocomplete_projects'), - path('search/', ProjectSearchAPIView.as_view(), name='search_projects'), - path('create/', ProjectCreateAPIView.as_view(), name='create_project'), - path('/update/', - ProjectUpdateAPIView.as_view(), - name='update_project'), - path('/delete/', - ProjectDeleteAPIView.as_view(), - name='delete_project'), - path('saved/', SavedProjectsAPIView.as_view(), name="saved_projects"), - path('/toggle-like/', - ToggleLikeAPIView.as_view(), - name="toggle_like"), - path('/toggle-save/', - ToggleSaveAPIView.as_view(), - name="toggle_save"), - path('/add-comment/', - AddCommentAPIView.as_view(), - name="add_comment"), - path('/unpublish-comment/', - UnpublishCommentAPIView.as_view(), - name="unpublish_comment"), - path('/delete-comment/', - DeleteCommentAPIView.as_view(), - name="delete_comment"), - path('/', ProjectDetailsAPIView.as_view(), name='detail_project'), - path('categories/', CategoryListAPIView.as_view(), name='category'), - path('staff-picks/', StaffPickListAPIView.as_view(), name="staff_picks"), - path('staff-picks//', - StaffPickDetailsAPIView.as_view(), - name="staff_pick_details") + path("", ProjectListAPIView.as_view(), name="list_projects"), + path( + "tags/autocomplete/", + ProjectTagAutocompleteAPIView.as_view(), + name="autocomplete_tags", + ), + path("tags/search/", ProjectTagSearchAPIView.as_view(), name="search_tags"), + path( + "autocomplete/", + ProjectAutocompleteAPIView.as_view(), + name="autocomplete_projects", + ), + path("search/", ProjectSearchAPIView.as_view(), name="search_projects"), + path("create/", ProjectCreateAPIView.as_view(), name="create_project"), + path("/update/", ProjectUpdateAPIView.as_view(), name="update_project"), + path("/delete/", ProjectDeleteAPIView.as_view(), name="delete_project"), + path("saved/", SavedProjectsAPIView.as_view(), name="saved_projects"), + path("/toggle-like/", ToggleLikeAPIView.as_view(), name="toggle_like"), + path("/toggle-save/", ToggleSaveAPIView.as_view(), name="toggle_save"), + path("/add-comment/", AddCommentAPIView.as_view(), name="add_comment"), + path( + "/unpublish-comment/", + UnpublishCommentAPIView.as_view(), + name="unpublish_comment", + ), + path( + "/delete-comment/", + DeleteCommentAPIView.as_view(), + name="delete_comment", + ), + path("/", ProjectDetailsAPIView.as_view(), name="detail_project"), + path( + "/recommend/", + ProjectRecommendAPIView.as_view(), + name="recommend_projects", + ), + path("categories/", CategoryListAPIView.as_view(), name="category"), + path("staff-picks/", StaffPickListAPIView.as_view(), name="staff_picks"), + path( + "staff-picks//", + StaffPickDetailsAPIView.as_view(), + name="staff_pick_details", + ), ] diff --git a/zubhub_backend/zubhub/projects/utils.py b/zubhub_backend/zubhub/projects/utils.py index d8dc20d08..f34f54f5f 100644 --- a/zubhub_backend/zubhub/projects/utils.py +++ b/zubhub_backend/zubhub/projects/utils.py @@ -1,18 +1,17 @@ -from enum import IntEnum import re +from enum import IntEnum from typing import Optional, Set + from akismet import Akismet -from lxml.html.clean import Cleaner -from lxml.html import document_fromstring +from creators.tasks import send_mass_email, send_mass_text from django.apps import apps -from django.core.cache import cache +from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.postgres.search import SearchQuery, SearchRank -from django.db.models import prefetch_related_objects -from django.db.models import F -from django.conf import settings -from creators.tasks import send_mass_email, send_mass_text -from django.db.models import Q +from django.core.cache import cache +from django.db.models import F, Q, prefetch_related_objects +from lxml.html import document_fromstring +from lxml.html.clean import Cleaner Creator = get_user_model() @@ -31,7 +30,7 @@ def task_lock(key): def update_images(project, images_data): - Image = apps.get_model('projects.Image') + Image = apps.get_model("projects.Image") images = project.images.all() images_to_save = [] @@ -53,7 +52,7 @@ def update_images(project, images_data): def update_tags(project, tags_data): - Tag = apps.get_model('projects.Tag') + Tag = apps.get_model("projects.Tag") tags = project.tags.all() tags_dict = {} @@ -72,6 +71,7 @@ def update_tags(project, tags_data): def send_staff_pick_notification(staff_pick): from creators.models import Setting + subscribed = Setting.objects.filter(subscribe=True) email_contexts = [] phone_contexts = [] @@ -79,32 +79,24 @@ def send_staff_pick_notification(staff_pick): for each in subscribed: if each.creator.email: email_contexts.append( - {"title": staff_pick.title, - "user": each.creator.username, - "email": each.creator.email, - "staff_pick_id": staff_pick.id - } + { + "title": staff_pick.title, + "user": each.creator.username, + "email": each.creator.email, + "staff_pick_id": staff_pick.id, + } ) if each.creator.phone: phone_contexts.append( - { - "phone": each.creator.phone, - "staff_pick_id": staff_pick.id - } + {"phone": each.creator.phone, "staff_pick_id": staff_pick.id} ) if len(email_contexts) > 0: - send_mass_email.delay( - template_name=template_name, - ctxs=email_contexts - ) + send_mass_email.delay(template_name=template_name, ctxs=email_contexts) if len(phone_contexts) > 0: - send_mass_text.delay( - template_name=template_name, - ctxs=phone_contexts - ) + send_mass_text.delay(template_name=template_name, ctxs=phone_contexts) def send_spam_notification(comment_id, staffs_and_mods): @@ -114,64 +106,52 @@ def send_spam_notification(comment_id, staffs_and_mods): for each in staffs_and_mods: if each.email: email_contexts.append( - {"user": each.username, - "email": each.email, - "comment_id": comment_id - } + {"user": each.username, "email": each.email, "comment_id": comment_id} ) if each.phone: - phone_contexts.append( - { - "phone": each.phone, - "comment_id": comment_id - } - ) + phone_contexts.append({"phone": each.phone, "comment_id": comment_id}) if len(email_contexts) > 0: - send_mass_email.delay( - template_name=template_name, - ctxs=email_contexts - ) + send_mass_email.delay(template_name=template_name, ctxs=email_contexts) if len(phone_contexts) > 0: - send_mass_text.delay( - template_name=template_name, - ctxs=phone_contexts - ) + send_mass_text.delay(template_name=template_name, ctxs=phone_contexts) def filter_spam(ctx): - - site_url = settings.DEFAULT_BACKEND_PROTOCOL + \ - "://"+settings.DEFAULT_BACKEND_DOMAIN + site_url = ( + settings.DEFAULT_BACKEND_PROTOCOL + "://" + settings.DEFAULT_BACKEND_DOMAIN + ) if site_url.find("localhost") != -1: return - Comment = apps.get_model('projects.Comment') - PublishingRule = apps.get_model('projects.PublishingRule') + Comment = apps.get_model("projects.Comment") + PublishingRule = apps.get_model("projects.PublishingRule") comment = Comment.objects.get(id=ctx.get("comment_id")) - if ctx.get("method") == 'POST' and comment.publish.type != PublishingRule.DRAFT: - akismet_api = Akismet(key=settings.AKISMET_API_KEY, - blog_url=site_url) + if ctx.get("method") == "POST" and comment.publish.type != PublishingRule.DRAFT: + akismet_api = Akismet(key=settings.AKISMET_API_KEY, blog_url=site_url) is_spam = akismet_api.comment_check( user_ip=ctx.get("REMOTE_ADDR"), user_agent=ctx.get("HTTP_USER_AGENT"), - comment_type='comment', + comment_type="comment", comment_content=ctx.get("text"), blog_lang=ctx.get("lang"), ) if is_spam: comment.publish.type = PublishingRule.DRAFT - comment.publish.publisher_id = None ## Set publisher_id to none when zubhub system unpublished a comment. + # Set publisher_id to none when zubhub system unpublished a comment + comment.publish.publisher_id = None comment.save() staffs_and_mods = Creator.objects.filter(is_staff=True) - staffs_and_mods = staffs.union(Creator.objects.filter(tags__name="moderator")) + staffs_and_mods = staffs_and_mods.union( + Creator.objects.filter(tags__name="moderator") + ) send_spam_notification(ctx.get("comment_id"), staffs_and_mods) @@ -189,8 +169,9 @@ def project_changed(obj, instance): changed = True elif not obj.materials_used == instance.materials_used: changed = True - elif ((not obj.publish.type == instance.publish.type) or - (not set(obj.publish.visible_to.all()) == set(instance.publish.visible_to.all()))): + elif (not obj.publish.type == instance.publish.type) or ( + not set(obj.publish.visible_to.all()) == set(instance.publish.visible_to.all()) + ): changed = True else: obj_images = obj.images.all() @@ -216,21 +197,19 @@ def project_changed(obj, instance): def parse_comment_trees(user, data, creators_dict): - PublishingRule = apps.get_model('projects.PublishingRule') + PublishingRule = apps.get_model("projects.PublishingRule") def recursive_parse(data): arr = [] for comment in data: - - ## this is an expensive operation. We should make out time to figure - ## out all the places in the codebase where there are database query related problems - ## and start optimizing those queries. + # this is an expensive operation. We should make out time to figure + # out all the places in the codebase where there are database query + # related problems and start optimizing those queries. rule = PublishingRule.objects.get(id=comment["data"]["publish"]) """ If user making request is permitted to view comment """ if can_view(user, rule.comment_target): - parsed = {} parsed["id"] = comment["id"] parsed["project"] = comment["data"]["project"] @@ -254,34 +233,47 @@ def can_view(user, target): """ """ get type and visible_to from publish """ - PublishingRule = apps.get_model('projects.PublishingRule') + PublishingRule = apps.get_model("projects.PublishingRule") type = target.publish.type visible_to = target.publish.visible_to - if (type == PublishingRule.DRAFT and user.is_authenticated and - (user.comments.filter(id=target.id).exists() or - user.projects.filter(id=target.id).exists())): + if ( + type == PublishingRule.DRAFT + and user.is_authenticated + and ( + user.comments.filter(id=target.id).exists() + or user.projects.filter(id=target.id).exists() + ) + ): return True if type == PublishingRule.PUBLIC: return True if type == PublishingRule.AUTHENTICATED_VIEWERS and user.is_authenticated: return True - if (type == PublishingRule.PREVIEW and user.is_authenticated and - ( visible_to.filter(id=user.id).count() > 0 or - user.comments.filter(id=target.id).exists() or - user.projects.filter(id=target.id).exists())): - """ Check if user is in visible_to or if project/comment belongs to the authenticated user """ + if ( + type == PublishingRule.PREVIEW + and user.is_authenticated + and ( + visible_to.filter(id=user.id).count() > 0 + or user.comments.filter(id=target.id).exists() + or user.projects.filter(id=target.id).exists() + ) + ): + """ + Check if user is in visible_to or if project/comment + belongs to the authenticated user + """ return True return False def get_published_projects_for_user(user, all): - """ + """ Get all projects user can view. - Given a queryset of projects, + Given a queryset of projects, get a subset of projects in the queryset that user is permitted to view. Params: @@ -292,25 +284,31 @@ def get_published_projects_for_user(user, all): queryset of projects user is permitted to view. """ - Project = apps.get_model('projects.Project') - PublishingRule = apps.get_model('projects.PublishingRule') - + Project = apps.get_model("projects.Project") + PublishingRule = apps.get_model("projects.PublishingRule") """ fetch all projects where publishing rule is PUBLIC """ public = all.filter(publish__type=PublishingRule.PUBLIC) - """ fetch all projects where publishing rule is AUTHENTICATED_VIEWERS if user is authenticated """ + """ + fetch all projects where publishing rule is AUTHENTICATED_VIEWERS + if user is authenticated + """ authenticated = Project.objects.none() if user.is_authenticated: authenticated = all.filter(publish__type=PublishingRule.AUTHENTICATED_VIEWERS) - + """ fetch all projects where publishing rule is PREVIEW """ - visible_to = all.filter(publish__type=PublishingRule.PREVIEW, - publish__visible_to__id=user.id) + visible_to = all.filter( + publish__type=PublishingRule.PREVIEW, publish__visible_to__id=user.id + ) if user.is_authenticated: - published_by_owner = all.filter(publish__type=PublishingRule.PREVIEW, - creator=user, publish__publisher_id=user.id) + published_by_owner = all.filter( + publish__type=PublishingRule.PREVIEW, + creator=user, + publish__publisher_id=user.id, + ) visible_to |= published_by_owner all = public @@ -326,8 +324,12 @@ def detect_mentions(kwargs): profile_username = kwargs.get("profile_username", None) if isinstance(text, str): - mentions = list(map(lambda x: x.split( - "@")[1], re.findall("\B@[0-9a-zA-Z_.-]+", text))) + mentions = list( + map( + lambda x: x.split("@")[1], + re.findall("\B@[0-9a-zA-Z_.-]+", text), # noqa: W605 + ) + ) email_contexts = [] phone_contexts = [] @@ -337,41 +339,43 @@ def detect_mentions(kwargs): for mention in mentions: if mention.email and (mention.username != creator): email_contexts.append( - {"creator": creator, - "project_id": project_id, - "profile_username": profile_username, - "email": mention.email - } + { + "creator": creator, + "project_id": project_id, + "profile_username": profile_username, + "email": mention.email, + } ) if mention.phone and (mention.username != creator): phone_contexts.append( - {"creator": creator, - "project_id": project_id, - "profile_username": profile_username, - "phone": mention.phone - } + { + "creator": creator, + "project_id": project_id, + "profile_username": profile_username, + "phone": mention.phone, + } ) if len(email_contexts) > 0: - send_mass_email.delay( - template_name=template_name, - ctxs=email_contexts - ) + send_mass_email.delay(template_name=template_name, ctxs=email_contexts) if len(phone_contexts) > 0: - send_mass_text.delay( - template_name=template_name, - ctxs=phone_contexts - ) + send_mass_text.delay(template_name=template_name, ctxs=phone_contexts) + """ String html tags, event handlers, styles, etc from comment string """ + + def clean_comment_text(string): doc = document_fromstring(string) cleaner = Cleaner() return cleaner.clean_html(doc).text_content() + """ Clean project description while still allowing for basic html structure """ + + def clean_project_desc(string): cleaner = Cleaner(remove_tags=["a"]) return cleaner.clean_html(string) @@ -383,25 +387,33 @@ class ProjectSearchCriteria(IntEnum): TITLE_DESCRIPTION = 2 -default_search_criteria = {ProjectSearchCriteria.CATGEORY, ProjectSearchCriteria.TAG, ProjectSearchCriteria.TITLE_DESCRIPTION} -def perform_project_search(user, query_string, search_criteria: Optional[Set[ProjectSearchCriteria]] = None): +default_search_criteria = { + ProjectSearchCriteria.CATGEORY, + ProjectSearchCriteria.TAG, + ProjectSearchCriteria.TITLE_DESCRIPTION, +} + + +def perform_project_search( + user, query_string, search_criteria: Optional[Set[ProjectSearchCriteria]] = None +): """ Perform search for projects matching query. - performs search across categories, tags, and projects, + performs search across categories, tags, and projects, and aggregate all projects that match the result. - This function should be improved, perhaps use elasticsearch? + This function should be improved, perhaps use elasticsearch? serious performance problems might be encountered in the future. """ - Category = apps.get_model('projects.Category') - Tag = apps.get_model('projects.Tag') - Project = apps.get_model('projects.Project') - PublishingRule = apps.get_model('projects.PublishingRule') + Category = apps.get_model("projects.Category") + Tag = apps.get_model("projects.Tag") + Project = apps.get_model("projects.Project") + # PublishingRule = apps.get_model("projects.PublishingRule") query = SearchQuery(query_string, search_type="phrase") - rank = SearchRank(F('search_vector'), query) + rank = SearchRank(F("search_vector"), query) result_projects = None # fetch all projects whose category(s) matches the search query @@ -419,28 +431,28 @@ def perform_project_search(user, query_string, search_criteria: Optional[Set[Pro result_projects = Project.objects.none() if ProjectSearchCriteria.CATGEORY in search_criteria and result_categories_tree: - prefetch_related_objects(result_categories_tree, 'projects') + prefetch_related_objects(result_categories_tree, "projects") for category in result_categories_tree: - result_projects |= category.projects.all().annotate(rank=rank).order_by('-rank') + result_projects |= ( + category.projects.all().annotate(rank=rank).order_by("-rank") + ) ################################################################# # fetch all projects whose tag(s) matches the search query - result_tags = Tag.objects.filter( - search_vector=query).prefetch_related("projects") + result_tags = Tag.objects.filter(search_vector=query).prefetch_related("projects") if ProjectSearchCriteria.TAG in search_criteria: for tag in result_tags: - result_projects |= tag.projects.all().annotate(rank=rank).order_by('-rank') + result_projects |= tag.projects.all().annotate(rank=rank).order_by("-rank") ############################################################ - # ################################################################# # # fetch all projects whose publishing rule matches the search query # choices = PublishingRule.PUBLISHING_CHOICES # types_dict = dict((y, x) for x, y in choices) # type = types_dict.get(query_string, 4) - # result_rules = PublishingRule.objects.filter(type=type).select_related("project_target") + # result_rules = PublishingRule.objects.filter(type=type).select_related("project_target") # noqa: E501 # for rule in result_rules: # project = rule.project_target @@ -452,7 +464,11 @@ def perform_project_search(user, query_string, search_criteria: Optional[Set[Pro # fetch all projects that matches the search term if ProjectSearchCriteria.TITLE_DESCRIPTION in search_criteria: - result_projects |= Project.objects.annotate(rank=rank).filter(search_vector=query ).order_by('-rank') + result_projects |= ( + Project.objects.annotate(rank=rank) + .filter(search_vector=query) + .order_by("-rank") + ) ############################################################## result = [] @@ -463,3 +479,45 @@ def perform_project_search(user, query_string, search_criteria: Optional[Set[Pro result.append(project) return result + + +def recommend_projects(project): + """ + Params: + project - Project object + Returns list of three projects to recommend to a user based on project's categories + """ + + Project = apps.get_model("projects.Project") + PublishingRule = apps.get_model("projects.PublishingRule") + + title_words = project.title.split() + categories = project.category.all() + tags = project.tags.all() + + all_projects = Project.objects.filter(publish__type=PublishingRule.PUBLIC).exclude( + pk=project.pk + ) + + similar_title = Q() + for word in title_words: + similar_title |= Q(title__icontains=word) + same_category = Q(category__in=categories) + same_tags = Q(tags__in=tags) + + projects = all_projects.filter( + similar_title & (same_category | same_tags) + ).distinct() + + if projects.count() < 3: + projects |= all_projects.filter(same_category & same_tags).distinct() + + if projects.count() < 3: + projects |= all_projects.filter( + similar_title | same_category | same_tags + ).distinct() + + if projects.count() < 3: + projects |= all_projects.order_by("-likes_count").distinct() + + return list(projects[:3]) diff --git a/zubhub_backend/zubhub/projects/views.py b/zubhub_backend/zubhub/projects/views.py index 69ed4ce98..a20ebc89b 100644 --- a/zubhub_backend/zubhub/projects/views.py +++ b/zubhub_backend/zubhub/projects/views.py @@ -1,33 +1,62 @@ -from django.shortcuts import get_object_or_404 -from django.http import Http404 -from django.utils.translation import ugettext_lazy as _ -from notifications.models import Notification -from rest_framework.response import Response +from activitylog.models import Activitylog +from creators.models import Creator +from creators.utils import ( + activity_log, + activity_notification, + send_notification, + set_badge_comment_category, + set_badge_like_category, + set_badge_project_category, + set_badge_view_category, +) from django.contrib.auth.models import AnonymousUser -from django.contrib.postgres.search import TrigramSimilarity, SearchQuery, SearchRank +from django.contrib.postgres.search import SearchQuery, SearchRank, TrigramSimilarity from django.core.exceptions import PermissionDenied -from django.db.models import F from django.db import transaction +from django.db.models import F +from django.http import Http404 +from django.shortcuts import get_object_or_404 +from django.utils.translation import ugettext_lazy as _ +from notifications.models import Notification +from projects.permissions import ( + CustomUserRateThrottle, + GetUserRateThrottle, + IsOwner, + IsStaffOrModerator, + PostUserRateThrottle, + SustainedRateThrottle, +) from rest_framework import status -from rest_framework.generics import (UpdateAPIView, CreateAPIView, ListAPIView, - RetrieveAPIView, DestroyAPIView) -from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly, AllowAny -from projects.permissions import (IsOwner, IsStaffOrModerator, - SustainedRateThrottle, PostUserRateThrottle, - GetUserRateThrottle, CustomUserRateThrottle) -from activitylog.models import Activitylog -from .models import Project, Comment, StaffPick, Category, Tag, PublishingRule -from creators.models import Creator -from .utils import (ProjectSearchCriteria, project_changed, detect_mentions, - perform_project_search, can_view, - get_published_projects_for_user) -from creators.utils import (activity_notification, send_notification, activity_log, set_badge_like_category, - set_badge_project_category, set_badge_view_category, - set_badge_comment_category) -from .serializers import (ProjectSerializer, ProjectListSerializer, - CommentSerializer, CategorySerializer, TagSerializer, - StaffPickSerializer) -from .pagination import ProjectNumberPagination +from rest_framework.generics import ( + CreateAPIView, + DestroyAPIView, + ListAPIView, + RetrieveAPIView, + UpdateAPIView, +) +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +from .models import Category, Comment, Project, PublishingRule, StaffPick, Tag +from .pagination import ProjectNumberPagination +from .serializers import ( + CategorySerializer, + CommentSerializer, + ProjectListSerializer, + ProjectSerializer, + StaffPickSerializer, + TagSerializer, +) +from .utils import ( + ProjectSearchCriteria, + can_view, + detect_mentions, + get_published_projects_for_user, + perform_project_search, + project_changed, + recommend_projects, +) + class ProjectCreateAPIView(CreateAPIView): """ @@ -50,6 +79,7 @@ class ProjectCreateAPIView(CreateAPIView): "publish": {"type": 4, "visible_to": []}\n }\n """ + queryset = Project.objects.all() serializer_class = ProjectSerializer permission_classes = [IsAuthenticated] @@ -60,20 +90,22 @@ def perform_create(self, serializer): self.request.user.save() - creator = Creator.objects.get(id = obj.creator_id) - project_count= creator.projects_count + creator = Creator.objects.get(id=obj.creator_id) + project_count = creator.projects_count set_badge_project_category(creator, project_count) if self.request.user.followers is not None: send_notification( list(self.request.user.followers.all()), self.request.user, - [{"project": obj.title} for _ in list(self.request.user.followers.all())], + [ + {"project": obj.title} + for _ in list(self.request.user.followers.all()) + ], Notification.Type.FOLLOWING_PROJECT, - f'/creators/{self.request.user.username}' + f"/creators/{self.request.user.username}", ) - - + class ProjectUpdateAPIView(UpdateAPIView): """ @@ -98,6 +130,7 @@ class ProjectUpdateAPIView(UpdateAPIView): "publish": {"type": 4, "visible_to": []}\n }\n """ + queryset = Project.objects.all() serializer_class = ProjectSerializer permission_classes = [IsAuthenticated, IsOwner] @@ -113,10 +146,7 @@ def perform_update(self, serializer): self.request.user.save() if project_changed(old, new): - info = { - "project_id": str(new.pk), - "editor": self.request.user.username - } + info = {"project_id": str(new.pk), "editor": self.request.user.username} activity_notification(["edited_project"], **info) # because project_changed still needs to make reference to the @@ -133,6 +163,7 @@ class ProjectDeleteAPIView(DestroyAPIView): Requires project id. Returns {details: "ok"} """ + queryset = Project.objects.all() serializer_class = ProjectSerializer permission_classes = [IsAuthenticated, IsOwner] @@ -140,10 +171,10 @@ class ProjectDeleteAPIView(DestroyAPIView): def delete(self, request, *args, **kwargs): project = self.get_object() - creator = Creator.objects.get(id = project.creator_id) + creator = Creator.objects.get(id=project.creator_id) if project: result = self.destroy(request, *args, **kwargs) - project_count_after_deletion = creator.projects_count -1 + project_count_after_deletion = creator.projects_count - 1 request.user.save() set_badge_project_category(creator, project_count_after_deletion) return result @@ -162,7 +193,7 @@ class ProjectListAPIView(ListAPIView): pagination_class = ProjectNumberPagination def get_queryset(self): - all = Project.objects.prefetch_related('publish__visible_to').all() + all = Project.objects.prefetch_related("publish__visible_to").all() return get_published_projects_for_user(self.request.user, all) @@ -179,13 +210,16 @@ class ProjectTagSearchAPIView(ListAPIView): throttle_classes = [GetUserRateThrottle, SustainedRateThrottle] def get_queryset(self): - query_string = self.request.GET.get('q') + query_string = self.request.GET.get("q") query = SearchQuery(query_string) - rank = SearchRank(F('search_vector'), query) - tags = Tag.objects.annotate(rank=rank).filter( - search_vector=query).order_by('-rank') + rank = SearchRank(F("search_vector"), query) + tags = ( + Tag.objects.annotate(rank=rank) + .filter(search_vector=query) + .order_by("-rank") + ) return tags - + class ProjectTagAutocompleteAPIView(ListAPIView): """ @@ -200,10 +234,12 @@ class ProjectTagAutocompleteAPIView(ListAPIView): throttle_classes = [GetUserRateThrottle, SustainedRateThrottle] def get_queryset(self): - query_string = self.request.GET.get('q') - tags = Tag.objects.annotate( - similarity=TrigramSimilarity('name', query_string)).filter( - similarity__gt=0.25).order_by('-similarity')[:20] + query_string = self.request.GET.get("q") + tags = ( + Tag.objects.annotate(similarity=TrigramSimilarity("name", query_string)) + .filter(similarity__gt=0.25) + .order_by("-similarity")[:20] + ) return tags @@ -220,10 +256,14 @@ class ProjectAutocompleteAPIView(ListAPIView): throttle_classes = [GetUserRateThrottle, SustainedRateThrottle] def get_queryset(self): - query_string = self.request.GET.get('q') - projects = Project.objects.annotate( - similarity=TrigramSimilarity('title', query_string)).filter( - similarity__gt=0.01).order_by('-similarity')[:20] + query_string = self.request.GET.get("q") + projects = ( + Project.objects.annotate( + similarity=TrigramSimilarity("title", query_string) + ) + .filter(similarity__gt=0.01) + .order_by("-similarity")[:20] + ) result = [] for project in projects: if can_view(self.request.user, project): @@ -247,14 +287,13 @@ class ProjectSearchAPIView(ListAPIView): def get_queryset(self): try: search_criteria = { - ProjectSearchCriteria(int(self.request.GET.get('criteria', - ''))) + ProjectSearchCriteria(int(self.request.GET.get("criteria", ""))) } except (KeyError, ValueError): search_criteria = None - return perform_project_search(self.request.user, - self.request.GET.get("q"), - search_criteria) + return perform_project_search( + self.request.user, self.request.GET.get("q"), search_criteria + ) class ProjectDetailsAPIView(RetrieveAPIView): @@ -281,16 +320,36 @@ def get_object(self): obj.views_count += 1 obj.save() else: - if not self.request.user in obj.views.all(): + if self.request.user not in obj.views.all(): obj.views.add(self.request.user) obj.views_count += 1 obj.save() - creator = Creator.objects.get(id = obj.creator_id) + creator = Creator.objects.get(id=obj.creator_id) set_badge_view_category(creator) return obj else: - raise PermissionDenied( - _('you are not permitted to view this project')) + raise PermissionDenied(_("you are not permitted to view this project")) + + +class ProjectRecommendAPIView(ListAPIView): + """ + Fetch 3 projects to recommend. + + Requires project id. + Returns list of three projects. + """ + + serializer_class = ProjectSerializer + permission_classes = [AllowAny] + throttle_classes = [CustomUserRateThrottle, SustainedRateThrottle] + + def get_queryset(self): + pk = self.kwargs.get("pk") + project = get_object_or_404(Project, pk=pk) + try: + return recommend_projects(project) + except Exception: + return Project.objects.none() class SavedProjectsAPIView(ListAPIView): @@ -308,7 +367,8 @@ class SavedProjectsAPIView(ListAPIView): def get_queryset(self): all = self.request.user.saved_for_future.prefetch_related( - 'publish__visible_to').all() + "publish__visible_to" + ).all() return get_published_projects_for_user(self.request.user, all) @@ -331,9 +391,7 @@ def get_object(self): obj = get_object_or_404(self.get_queryset(), pk=pk) """ check if user is permitted to view this project """ if can_view(self.request.user, obj): - with transaction.atomic(): - if self.request.user in obj.likes.all(): obj.likes.remove(self.request.user) obj.save() @@ -344,26 +402,26 @@ def get_object(self): send_notification( [obj.creator], self.request.user, - [{'project': obj.title}], + [{"project": obj.title}], Notification.Type.CLAP, - f'/projects/{obj.pk}' + f"/projects/{obj.pk}", ) activity_log( [obj.creator], self.request.user, - [{'project': obj.title}], + [{"project": obj.title}], Activitylog.Type.CLAP, - f'/projects/{obj.pk}' + f"/projects/{obj.pk}", ) - creator = Creator.objects.get(id = obj.creator_id) + creator = Creator.objects.get(id=obj.creator_id) set_badge_like_category(creator) return obj else: - raise PermissionDenied( - _('you are not permitted to view this project')) + raise PermissionDenied(_("you are not permitted to view this project")) + class ToggleSaveAPIView(RetrieveAPIView): """ @@ -384,9 +442,7 @@ def get_object(self): obj = get_object_or_404(self.get_queryset(), pk=pk) """ check if user is permitted to view this project """ if can_view(self.request.user, obj): - with transaction.atomic(): - if self.request.user in obj.saved_by.all(): obj.saved_by.remove(self.request.user) obj.save() @@ -397,24 +453,23 @@ def get_object(self): send_notification( [obj.creator], self.request.user, - [{'project': obj.title}], + [{"project": obj.title}], Notification.Type.BOOKMARK, - f'/projects/{obj.pk}' + f"/projects/{obj.pk}", ) activity_log( [obj.creator], self.request.user, - [{'project': obj.title}], + [{"project": obj.title}], Activitylog.Type.BOOKMARK, - f'/projects/{obj.pk}' + f"/projects/{obj.pk}", ) - return obj else: - raise PermissionDenied( - _('you are not permitted to view this project')) + raise PermissionDenied(_("you are not permitted to view this project")) + class AddCommentAPIView(CreateAPIView): """ @@ -442,76 +497,77 @@ def get_object(self): if can_view(self.request.user, obj): return obj else: - raise PermissionDenied( - _('you are not permitted to view this project')) + raise PermissionDenied(_("you are not permitted to view this project")) def create(self, request, *args, **kwargs): serializer = self.get_serializer(data=self.request.data) if not serializer.is_valid(): - return Response(serializer.errors, - status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) parent_id = self.request.data.get("parent_id", None) text = serializer.validated_data.get("text", None) with transaction.atomic(): - publishing_rule = PublishingRule.objects.create( - type=PublishingRule.PUBLIC, - publisher_id=str(self.request.user.id)) + type=PublishingRule.PUBLIC, publisher_id=str(self.request.user.id) + ) if parent_id: - try: parent_comment = Comment.objects.get(id=parent_id) except Comment.DoesNotExist: raise Http404(_("parent comment does not exist")) - parent_comment.add_child(project=self.get_object(), - creator=self.request.user, - text=text, - publish=publishing_rule) + parent_comment.add_child( + project=self.get_object(), + creator=self.request.user, + text=text, + publish=publishing_rule, + ) else: - Comment.add_root(project=self.get_object(), - creator=self.request.user, - text=text, - publish=publishing_rule) + Comment.add_root( + project=self.get_object(), + creator=self.request.user, + text=text, + publish=publishing_rule, + ) result = self.get_object() - creator_str= self.request.user.username - creator_id= Creator.objects.get(username= creator_str).id - creator= Creator.objects.get(id = creator_id) + creator_str = self.request.user.username + creator_id = Creator.objects.get(username=creator_str).id + creator = Creator.objects.get(id=creator_id) set_badge_comment_category(creator) if result: - detect_mentions({ - "text": text, - "project_id": result.pk, - "creator": request.user.username - }) + detect_mentions( + { + "text": text, + "project_id": result.pk, + "creator": request.user.username, + } + ) send_notification( [result.creator], self.request.user, - [{'project': result.title}], + [{"project": result.title}], Notification.Type.COMMENT, - f'/projects/{result.pk}' + f"/projects/{result.pk}", ) activity_log( [result.creator], self.request.user, - [{'project': result.title}], + [{"project": result.title}], Activitylog.Type.COMMENT, - f'/projects/{result.pk}' + f"/projects/{result.pk}", ) - return Response(ProjectSerializer(result, context={ - 'request': request - }).data, - status=status.HTTP_201_CREATED) - + return Response( + ProjectSerializer(result, context={"request": request}).data, + status=status.HTTP_201_CREATED, + ) class CategoryListAPIView(ListAPIView): @@ -588,6 +644,7 @@ class UnpublishCommentAPIView(UpdateAPIView): Requires comment id. Returns unpublished comment. """ + queryset = Comment.objects.all() serializer_class = CommentSerializer permission_classes = [IsAuthenticated, IsStaffOrModerator] @@ -598,8 +655,8 @@ def perform_update(self, serializer): old_publishing_rule = self.get_object().publish publishing_rule = PublishingRule.objects.create( - type=PublishingRule.DRAFT, - publisher_id=str(self.request.user.id)) + type=PublishingRule.DRAFT, publisher_id=str(self.request.user.id) + ) comment = serializer.save(publish=publishing_rule) old_publishing_rule.delete() diff --git a/zubhub_frontend/zubhub/src/api/api.js b/zubhub_frontend/zubhub/src/api/api.js index 31daf09d9..52e2abbe1 100644 --- a/zubhub_frontend/zubhub/src/api/api.js +++ b/zubhub_frontend/zubhub/src/api/api.js @@ -809,6 +809,11 @@ class API { return this.request({ token, url }).then(res => res.json()); }; + getMoreProjects = id => { + const url = `projects/${id}/recommend/`; + return this.request({ url }).then(res => res.json()); + }; + /** * @method getActivity * @author Yaya Mamoudou diff --git a/zubhub_frontend/zubhub/src/store/actions/projectActions.js b/zubhub_frontend/zubhub/src/store/actions/projectActions.js index 6454dbfb1..43bbd09bd 100644 --- a/zubhub_frontend/zubhub/src/store/actions/projectActions.js +++ b/zubhub_frontend/zubhub/src/store/actions/projectActions.js @@ -1,5 +1,6 @@ -import ZubhubAPI from '../../api'; import { toast } from 'react-toastify'; +import ZubhubAPI from '../../api'; + const API = new ZubhubAPI(); /** @@ -8,13 +9,11 @@ const API = new ZubhubAPI(); * * @todo - describe function's signature */ -export const setProjects = projects => { - return dispatch => { - dispatch({ - type: 'SET_PROJECTS', - payload: { all_projects: projects }, - }); - }; +export const setProjects = projects => dispatch => { + dispatch({ + type: 'SET_PROJECTS', + payload: { all_projects: projects }, + }); }; /** @@ -23,18 +22,14 @@ export const setProjects = projects => { * * @todo - describe function's signature */ -export const createProject = props => { - - return () => { - return API.createProject(props).then(res => { - if (!res.id) { - throw new Error(JSON.stringify(res)); - } else { - return res; - } - }); - }; -}; +export const createProject = props => () => + API.createProject(props).then(res => { + if (!res.id) { + throw new Error(JSON.stringify(res)); + } else { + return res; + } + }); /** * @function shouldUploadToLocal @@ -42,21 +37,18 @@ export const createProject = props => { * * @todo - describe function's signature */ -export const shouldUploadToLocal = args => { - return () => { - return API.shouldUploadToLocal(args) - .then(res => { - if (res.local === undefined) { - throw new Error(); - } else { - return res; - } - }) - .catch(() => { - toast.warning(args.t('createProject.errors.unexpected')); - }); - }; -}; +export const shouldUploadToLocal = args => () => + API.shouldUploadToLocal(args) + .then(res => { + if (res.local === undefined) { + throw new Error(); + } else { + return res; + } + }) + .catch(() => { + toast.warning(args.t('createProject.errors.unexpected')); + }); /** * @function updateProject @@ -64,18 +56,15 @@ export const shouldUploadToLocal = args => { * * @todo - describe function's signature */ -export const updateProject = props => { - return () => { - return API.updateProject(props).then(res => { - if (!res.id) { - throw new Error(JSON.stringify(res)); - } else { - return res - // toast.success(props.t('createProject.updateToastSuccess')); - } - }); - }; -}; +export const updateProject = props => () => + API.updateProject(props).then(res => { + if (!res.id) { + throw new Error(JSON.stringify(res)); + } else { + return res; + // toast.success(props.t('createProject.updateToastSuccess')); + } + }); /** * @function deleteProject @@ -83,18 +72,15 @@ export const updateProject = props => { * * @todo - describe function's signature */ -export const deleteProject = args => { - return () => { - return API.deleteProject({ token: args.token, id: args.id }).then(res => { - if (res.detail !== 'ok') { - throw new Error(res.detail); - } else { - toast.success(args.t('projectDetails.deleteProjectToastSuccess')); - return args.navigate('/profile'); - } - }); - }; -}; +export const deleteProject = args => () => + API.deleteProject({ token: args.token, id: args.id }).then(res => { + if (res.detail !== 'ok') { + throw new Error(res.detail); + } else { + toast.success(args.t('projectDetails.deleteProjectToastSuccess')); + return args.navigate('/profile'); + } + }); /** * @function unpublishComment @@ -102,29 +88,26 @@ export const deleteProject = args => { * * @todo - describe function's signature */ -export const unpublishComment = args => { - return () => { - return API.unpublishComment({ token: args.token, id: args.id }) - .then(res => { - if (res.text) { - toast.success(args.t('comments.unpublishCommentToastSuccess')); - return res; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('comments.errors.unexpected')); - } else { - toast.warning(error.message); - } - }); - }; -}; +export const unpublishComment = args => () => + API.unpublishComment({ token: args.token, id: args.id }) + .then(res => { + if (res.text) { + toast.success(args.t('comments.unpublishCommentToastSuccess')); + return res; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('comments.errors.unexpected')); + } else { + toast.warning(error.message); + } + }); /** * @function deleteComment @@ -132,17 +115,14 @@ export const unpublishComment = args => { * * @todo - describe function's signature */ -export const deleteComment = args => { - return () => { - return API.deleteComment({ token: args.token, id: args.id }).then(res => { - if (res.detail !== 'ok') { - throw new Error(res.detail); - } else { - toast.success(args.t('comments.deleteCommentToastSuccess')); - } - }); - }; -}; +export const deleteComment = args => () => + API.deleteComment({ token: args.token, id: args.id }).then(res => { + if (res.detail !== 'ok') { + throw new Error(res.detail); + } else { + toast.success(args.t('comments.deleteCommentToastSuccess')); + } + }); /** * @function getProject @@ -150,29 +130,26 @@ export const deleteComment = args => { * * @todo - describe function's signature */ -export const getProject = args => { - return () => { - return API.getProject(args) - .then(res => { - if (res.title) { - return { project: res, loading: false }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projectDetails.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { loading: false }; - }); - }; -}; +export const getProject = args => () => + API.getProject(args) + .then(res => { + if (res.title) { + return { project: res, loading: false }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projectDetails.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); /** * @function getProjects @@ -180,33 +157,54 @@ export const getProject = args => { * * @todo - describe function's signature */ -export const getProjects = args => { - return dispatch => { - return API.getProjects(args) - .then(res => { - if (Array.isArray(res.results)) { - dispatch({ - type: 'SET_PROJECTS', - payload: { all_projects: res }, - }); - return { loading: false }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projects.errors.unexpected')); - } else { - toast.warning(error.message); - } +export const getProjects = args => dispatch => + API.getProjects(args) + .then(res => { + if (Array.isArray(res.results)) { + dispatch({ + type: 'SET_PROJECTS', + payload: { all_projects: res }, + }); return { loading: false }; - }); - }; -}; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); + +export const getMoreProjects = args => () => + API.getMoreProjects(args) + .then(res => { + if (Array.isArray(res)) { + return { + results: res, + loading: false, + }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projectDetails.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); /** * @function getCategories @@ -214,29 +212,26 @@ export const getProjects = args => { * * @todo - describe function's signature */ -export const getCategories = args => { - return () => { - return API.getCategories() - .then(res => { - if (Array.isArray(res) && res.length > 0 && res[0].name) { - return { categories: res, loading: false }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projects.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { loading: false }; - }); - }; -}; +export const getCategories = args => () => + API.getCategories() + .then(res => { + if (Array.isArray(res) && res.length > 0 && res[0].name) { + return { categories: res, loading: false }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); /** * @function searchProjects @@ -244,54 +239,48 @@ export const getCategories = args => { * * @todo - describe function's signature */ -export const searchProjects = args => { - return () => { - return API.searchProjects(args) - .then(res => { - if (Array.isArray(res.results)) { - return { ...res, loading: false, tab: args.tab }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projects.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { loading: false, tab: args.tab }; - }); - }; -}; +export const searchProjects = args => () => + API.searchProjects(args) + .then(res => { + if (Array.isArray(res.results)) { + return { ...res, loading: false, tab: args.tab }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false, tab: args.tab }; + }); -export const searchTags = args => { - return () => { - return API.searchTags(args) - .then(res => { - if (Array.isArray(res.results)) { - return { ...res, loading: false, tab: args.tab }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - console.error(error); - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projects.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { loading: false, tab: args.tab }; - }); - }; -}; +export const searchTags = args => () => + API.searchTags(args) + .then(res => { + if (Array.isArray(res.results)) { + return { ...res, loading: false, tab: args.tab }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + console.error(error); + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false, tab: args.tab }; + }); /** * @function suggestTags @@ -299,31 +288,26 @@ export const searchTags = args => { * * @todo - describe function's signature */ -export const suggestTags = args => { - return () => { - return API.suggestTags(args.value) - .then(res => { - if (Array.isArray(res)) { - return res.length > 0 - ? { tag_suggestion: res } - : { tag_suggestion_open: false }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projects.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { tag_suggestion_open: false }; - }); - }; -}; +export const suggestTags = args => () => + API.suggestTags(args.value) + .then(res => { + if (Array.isArray(res)) { + return res.length > 0 ? { tag_suggestion: res } : { tag_suggestion_open: false }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { tag_suggestion_open: false }; + }); /** * @function getUserProjects @@ -331,34 +315,31 @@ export const suggestTags = args => { * * @todo - describe function's signature */ -export const getUserProjects = args => { - return () => { - return API.getUserProjects(args) - .then(res => { - if (Array.isArray(res.results)) { - return { - results: res.results, - prev_page: res.previous, - next_page: res.next, - loading: false, - }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projects.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { loading: false }; - }); - }; -}; +export const getUserProjects = args => () => + API.getUserProjects(args) + .then(res => { + if (Array.isArray(res.results)) { + return { + results: res.results, + prev_page: res.previous, + next_page: res.next, + loading: false, + }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); /** * @function getUserProjects @@ -366,8 +347,8 @@ export const getUserProjects = args => { * * @todo - describe function's signature */ -export const getUserDrafts = args => { - return API.getUserDrafts(args) +export const getUserDrafts = args => + API.getUserDrafts(args) .then(res => { if (Array.isArray(res.results)) { return { @@ -391,7 +372,6 @@ export const getUserDrafts = args => { } return { loading: false }; }); -}; /** * @function getSaved @@ -399,32 +379,29 @@ export const getUserDrafts = args => { * * @todo - describe function's signature */ -export const getSaved = args => { - return () => { - return API.getSaved(args) - .then(res => { - if (Array.isArray(res.results)) { - return { - ...res, - loading: false, - }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('savedProjects.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { loading: false }; - }); - }; -}; +export const getSaved = args => () => + API.getSaved(args) + .then(res => { + if (Array.isArray(res.results)) { + return { + ...res, + loading: false, + }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('savedProjects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); /** * @function toggleLike @@ -432,30 +409,27 @@ export const getSaved = args => { * * @todo - describe function's signature */ -export const toggleLike = args => { - return () => { - return API.toggleLike(args) - .then(res => { - if (res.title) { - return { project: res }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projectDetails.errors.unexpected')); - } else { - toast.warning(error.message); - } +export const toggleLike = args => () => + API.toggleLike(args) + .then(res => { + if (res.title) { + return { project: res }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projectDetails.errors.unexpected')); + } else { + toast.warning(error.message); + } - return { loading: false }; - }); - }; -}; + return { loading: false }; + }); /** * @function toggleSave @@ -463,29 +437,26 @@ export const toggleLike = args => { * * @todo - describe function's signature */ -export const toggleSave = args => { - return () => { - return API.toggleSave(args) - .then(res => { - if (res.title) { - return { project: res }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projects.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { loading: false }; - }); - }; -}; +export const toggleSave = args => () => + API.toggleSave(args) + .then(res => { + if (res.title) { + return { project: res }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); /** * @function addComment @@ -493,29 +464,26 @@ export const toggleSave = args => { * * @todo - describe function's signature */ -export const addComment = args => { - return () => { - return API.addComment(args) - .then(res => { - if (res.title) { - return { project: res, loading: false }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('comments.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { loading: false }; - }); - }; -}; +export const addComment = args => () => + API.addComment(args) + .then(res => { + if (res.title) { + return { project: res, loading: false }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('comments.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); /** * @function setStaffPicks @@ -523,13 +491,11 @@ export const addComment = args => { * * @todo - describe function's signature */ -export const setStaffPicks = staff_picks => { - return dispatch => { - dispatch({ - type: 'SET_PROJECTS', - payload: { staff_picks }, - }); - }; +export const setStaffPicks = staff_picks => dispatch => { + dispatch({ + type: 'SET_PROJECTS', + payload: { staff_picks }, + }); }; /** @@ -538,13 +504,11 @@ export const setStaffPicks = staff_picks => { * * @todo - describe function's signature */ -export const setHero = hero => { - return dispatch => { - dispatch({ - type: 'SET_PROJECTS', - payload: { hero }, - }); - }; +export const setHero = hero => dispatch => { + dispatch({ + type: 'SET_PROJECTS', + payload: { hero }, + }); }; /** @@ -553,13 +517,11 @@ export const setHero = hero => { * * @todo - describe function's signature */ -export const setZubhub = zubhub => { - return dispatch => { - dispatch({ - type: 'SET_PROJECTS', - payload: { zubhub }, - }); - }; +export const setZubhub = zubhub => dispatch => { + dispatch({ + type: 'SET_PROJECTS', + payload: { zubhub }, + }); }; /** @@ -568,33 +530,30 @@ export const setZubhub = zubhub => { * * @todo - describe function's signature */ -export const getHero = args => { - return dispatch => { - return API.getHero() - .then(res => { - if (res.id || res.title !== undefined) { - const { header_logo_url, footer_logo_url, site_mode } = res; - delete res.header_logo_url; - delete res.footer_logo_url; - delete res.site_mode; +export const getHero = args => dispatch => + API.getHero() + .then(res => { + if (res.id || res.title !== undefined) { + const { header_logo_url, footer_logo_url, site_mode } = res; + delete res.header_logo_url; + delete res.footer_logo_url; + delete res.site_mode; - dispatch(setHero(res)); - dispatch(setZubhub({ header_logo_url, footer_logo_url, site_mode })); - return { loading: false }; - } else { - throw new Error(); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projects.errors.unexpected')); - } else { - toast.warning(error.message); - } + dispatch(setHero(res)); + dispatch(setZubhub({ header_logo_url, footer_logo_url, site_mode })); return { loading: false }; - }); - }; -}; + } else { + throw new Error(); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); /** * @function getStaffPicks @@ -602,33 +561,30 @@ export const getHero = args => { * * @todo - describe function's signature */ -export const getStaffPicks = args => { - return dispatch => { - return API.getStaffPicks(args) - .then(res => { - if (Array.isArray(res)) { - dispatch(setStaffPicks(res)); - return { loading: false }; - } else if (res.detail === 'not found') { - dispatch(setStaffPicks([])); - } else { - dispatch(setStaffPicks([])); - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('projects.errors.unexpected')); - } else { - toast.warning(error.message); - } +export const getStaffPicks = args => dispatch => + API.getStaffPicks(args) + .then(res => { + if (Array.isArray(res)) { + dispatch(setStaffPicks(res)); return { loading: false }; - }); - }; -}; + } else if (res.detail === 'not found') { + dispatch(setStaffPicks([])); + } else { + dispatch(setStaffPicks([])); + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('projects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); /** * @function getStaffPick @@ -636,29 +592,26 @@ export const getStaffPicks = args => { * * @todo - describe function's signature */ -export const getStaffPick = args => { - return () => { - return API.getStaffPick(args) - .then(res => { - if (res.id) { - return { - staff_pick: res, - loading: false, - }; - } else { - res = Object.keys(res) - .map(key => res[key]) - .join('\n'); - throw new Error(res); - } - }) - .catch(error => { - if (error.message.startsWith('Unexpected')) { - toast.warning(args.t('savedProjects.errors.unexpected')); - } else { - toast.warning(error.message); - } - return { loading: false }; - }); - }; -}; +export const getStaffPick = args => () => + API.getStaffPick(args) + .then(res => { + if (res.id) { + return { + staff_pick: res, + loading: false, + }; + } else { + res = Object.keys(res) + .map(key => res[key]) + .join('\n'); + throw new Error(res); + } + }) + .catch(error => { + if (error.message.startsWith('Unexpected')) { + toast.warning(args.t('savedProjects.errors.unexpected')); + } else { + toast.warning(error.message); + } + return { loading: false }; + }); diff --git a/zubhub_frontend/zubhub/src/views/project_details/ProjectDetails.jsx b/zubhub_frontend/zubhub/src/views/project_details/ProjectDetails.jsx index 29169f42f..8431ce95b 100644 --- a/zubhub_frontend/zubhub/src/views/project_details/ProjectDetails.jsx +++ b/zubhub_frontend/zubhub/src/views/project_details/ProjectDetails.jsx @@ -28,7 +28,7 @@ import BookmarkIcon from '@mui/icons-material/Bookmark'; import BookmarkBorderIcon from '@mui/icons-material/BookmarkBorder'; import CloseIcon from '@mui/icons-material/Close'; import VisibilityIcon from '@mui/icons-material/Visibility'; -import CloseOutlined from '@mui/icons-material/CloseOutlined'; +import { CloseOutlined } from '@mui/icons-material'; import ClapIcon, { ClapBorderIcon } from '../../assets/js/icons/ClapIcon'; import CustomButton from '../../components/button/Button'; @@ -80,7 +80,7 @@ const buildMaterialsUsedComponent = (classes, state) => { * * @todo - describe function's signature */ -const buildTagsComponent = (_, tags) => +const buildTagsComponent = tags => tags.map((tag, index) => ( // { + setOpen(!open); + props.navigate(window.location.pathname, { replace: true }); + }; + const handleSetState = obj => { if (obj) { Promise.resolve(obj).then(obj => { @@ -124,11 +129,6 @@ function ProjectDetails(props) { } }; - const toggleDialog = () => { - setOpen(!open); - props.navigate(window.location.pathname, { replace: true }); - }; - React.useEffect(() => { Promise.resolve( props.getProject({ @@ -139,13 +139,8 @@ function ProjectDetails(props) { ).then(async obj => { if (obj.project) { const { project } = obj; - const userProjects = await props.getUserProjects({ - limit: 4, - username: project.creator.username, - project_to_omit: project.id, - }); - const moreProjects = userProjects.results; - setMoreProjects(moreProjects); + const moreProjects = await props.getMoreProjects(project.id); + setMoreProjects(moreProjects.results); parseComments(project.comments); } handleSetState(obj); @@ -329,7 +324,7 @@ function ProjectDetails(props) { {project.images.map((image, index) => ( -
+
-
- {buildTagsComponent(classes, project.tags, props.navigate)} -
+
{buildTagsComponent(project.tags)}
) : null} @@ -547,6 +540,7 @@ function ProjectDetails(props) { ProjectDetails.propTypes = { auth: PropTypes.object.isRequired, getProject: PropTypes.func.isRequired, + getMoreProjects: PropTypes.func.isRequired, getUserProjects: PropTypes.func.isRequired, suggestCreators: PropTypes.func.isRequired, deleteProject: PropTypes.func.isRequired, @@ -571,6 +565,7 @@ const mapDispatchToProps = dispatch => ({ toggleSave: args => dispatch(ProjectActions.toggleSave(args)), addComment: args => dispatch(ProjectActions.addComment(args)), getUserProjects: args => dispatch(ProjectActions.getUserProjects(args)), + getMoreProjects: args => dispatch(ProjectActions.getMoreProjects(args)), }); export default connect(mapStateToProps, mapDispatchToProps)(ProjectDetails);