From eec6eadb3285a7a672f968e98872f4cc4f0de340 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Date: Mon, 26 Aug 2024 11:38:39 +0530 Subject: [PATCH] Rebuilt comment and activity --- .vscode/settings.json | 1 + hypha/apply/activity/services.py | 34 +++- .../templates/activity/edit_comment.html | 41 ++++ .../activity/include/comment_list.html | 30 ++- .../activity/include/listing_base.html | 180 +++++++++--------- .../activity/partial_comment_message.html | 32 ++++ .../activity/templatetags/activity_tags.py | 13 +- hypha/apply/activity/urls.py | 3 +- hypha/apply/activity/views.py | 36 +++- hypha/apply/api/v1/filters.py | 16 -- hypha/apply/api/v1/serializers.py | 64 ------- hypha/apply/api/v1/urls.py | 6 - hypha/apply/api/v1/views.py | 103 +--------- .../funds/applicationsubmission_detail.html | 22 ++- hypha/apply/funds/urls.py | 6 + hypha/apply/funds/views_partials.py | 22 +++ .../application_projects/project_detail.html | 3 +- hypha/apply/users/identicon.py | 163 ++++++++++++++++ hypha/apply/users/models.py | 31 +-- .../apply/users/templates/users/account.html | 1 + hypha/apply/users/templatetags/users_tags.py | 9 + hypha/static_src/javascript/edit-comment.js | 172 ----------------- hypha/static_src/sass/components/_editor.scss | 2 +- hypha/static_src/sass/components/_feed.scss | 3 +- hypha/static_src/sass/components/_form.scss | 5 + .../static_src/sass/components/_wrapper.scss | 8 +- requirements.txt | 1 + 27 files changed, 515 insertions(+), 492 deletions(-) create mode 100644 hypha/apply/activity/templates/activity/edit_comment.html create mode 100644 hypha/apply/activity/templates/activity/partial_comment_message.html create mode 100644 hypha/apply/users/identicon.py delete mode 100644 hypha/static_src/javascript/edit-comment.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 5d2307f6d7..336fd8bb9d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "pytestmark", "ratelimit", "SIGNUP", + "svgwrite", "WAGTAILADMIN", "wagtailcore" ] diff --git a/hypha/apply/activity/services.py b/hypha/apply/activity/services.py index 706ff718a1..16e04c1daa 100644 --- a/hypha/apply/activity/services.py +++ b/hypha/apply/activity/services.py @@ -1,6 +1,37 @@ +from django.utils import timezone + from .models import Activity +def edit_comment(activity: Activity, message: str) -> Activity: + """ + Edit a comment by creating a clone of the original comment with the updated message. + + Args: + activity (Activity): The original comment activity to be edited. + message (str): The new message to replace the original comment's message. + + Returns: + Activity: The edited comment activity with the updated message. + """ + if message == activity.message: + return activity + + # Create a clone of the comment to edit + previous = Activity.objects.get(pk=activity.pk) + previous.pk = None + previous.current = False + previous.save() + + activity.previous = previous + activity.edited = timezone.now() + activity.message = message + activity.current = True + activity.save() + + return activity + + def get_related_actions_for_user(obj, user): """Return Activity objects related to an object, esp. useful with ApplicationSubmission and Project. @@ -38,7 +69,8 @@ def get_related_comments_for_user(obj, user): related_query = type(obj).activities.rel.related_query_name return ( - Activity.comments.filter(**{related_query: obj}) + Activity.objects.filter(**{related_query: obj}) + .exclude(current=False) .select_related("user") .prefetch_related( "related_object", diff --git a/hypha/apply/activity/templates/activity/edit_comment.html b/hypha/apply/activity/templates/activity/edit_comment.html new file mode 100644 index 0000000000..55ef88ebfe --- /dev/null +++ b/hypha/apply/activity/templates/activity/edit_comment.html @@ -0,0 +1,41 @@ +{% load i18n %} + +
+ {% csrf_token %} +
+
+ + + +
+
+ +
+ + +
+ + + +
+ diff --git a/hypha/apply/activity/templates/activity/include/comment_list.html b/hypha/apply/activity/templates/activity/include/comment_list.html index 6d9d668ec6..5bf3ab581d 100644 --- a/hypha/apply/activity/templates/activity/include/comment_list.html +++ b/hypha/apply/activity/templates/activity/include/comment_list.html @@ -1,9 +1,27 @@ {% load i18n %} -{% for comment in comments %} - {% include "activity/include/listing_base.html" with activity=comment %} -{% endfor %} +
+
+
+ {% for comment in comments %} + {% include "activity/include/listing_base.html" with activity=comment %} + {% endfor %} -{% if not comments %} - {% trans "No comments available" %} -{% endif %} + {% if page.has_next %} + Show more... + {% endif %} +
+ + {% if not comments %} + {% trans "No comments available" %} + {% endif %} +
+
diff --git a/hypha/apply/activity/templates/activity/include/listing_base.html b/hypha/apply/activity/templates/activity/include/listing_base.html index 709a523cf8..b8e51195a2 100644 --- a/hypha/apply/activity/templates/activity/include/listing_base.html +++ b/hypha/apply/activity/templates/activity/include/listing_base.html @@ -1,101 +1,103 @@ -{% load i18n activity_tags nh3_tags markdown_tags submission_tags apply_tags heroicons %} +{% load i18n activity_tags nh3_tags markdown_tags submission_tags apply_tags heroicons users_tags %} -
- -
-
-

- {{ activity|display_activity_author:request.user }} - {{ activity.timestamp|date:"SHORT_DATETIME_FORMAT" }} - {% if activity.edited %} - • - {% trans "edited" %} - {% endif %} -

- - {% if editable and activity.user == request.user %} -

- - {% heroicon_mini "pencil-square" size=18 class="inline me-1" aria_hidden=true %} - {% trans "Edit" %} - -

- {% endif %} - -

- {% heroicon_micro "eye" class="fill-fg-muted inline me-1 w-4 h-4 mr-1" aria_hidden=true %} - {{ activity.visibility|visibility_display:request.user }} -

-
+
+
+ {% with activity|display_author:request.user as author_name %} -
- {% if submission_title %} - {% trans "updated" %} {{ activity.source.title }} - {% endif %} - - {% if editable %} -
- {{ activity|display_for:request.user|submission_links|markdown|nh3 }} -
- -
- {% else %} -
- {{ activity|display_for:request.user|submission_links|markdown|nh3 }} +
+
+ {% if activity.type == 'comment' %} + + {% else %} +
+ {% heroicon_micro "eye" class="fill-gray-400 inline" aria_hidden=true %} +
+ {% endif %}
- {% endif %} +
- {% if not submission_title and activity|user_can_see_related:request.user %} - {% with url=activity.related_object.get_absolute_url %} - {% if url %} - - {% trans "View " %}{{ activity.related_object|model_verbose_name }} - {% heroicon_micro "chevron-double-right" class="inline w-4 h-4 ms-1" aria_hidden=true %} - - {% endif %} - {% endwith %} - {% endif %} + {% if activity.type == 'comment' %} +
+ + +
+ {% if submission_title %} + {% trans "updated" %} {{ activity.source.title }} + {% endif %} + +
+ {% include 'activity/partial_comment_message.html' with activity=activity %} +
+ + {% if not submission_title and activity|user_can_see_related:request.user %} +
+ {% with url=activity.related_object.get_absolute_url %} + {% if url %} + + {% trans "View " %}{{ activity.related_object|model_verbose_name }} + + {% endif %} + {% endwith %} +
+ {% endif %} +
+
+ {% else %} +
+ {{ activity|display_author:request.user }} + + {{ activity|display_for:request.user|striptags|lowerfirst }} + {{ activity.timestamp|date:"SHORT_DATETIME_FORMAT" }} +
{% endif %} {% endwith %} diff --git a/hypha/apply/activity/templates/activity/partial_comment_message.html b/hypha/apply/activity/templates/activity/partial_comment_message.html new file mode 100644 index 0000000000..070e5ef7f3 --- /dev/null +++ b/hypha/apply/activity/templates/activity/partial_comment_message.html @@ -0,0 +1,32 @@ +{% load heroicons activity_tags nh3_tags markdown_tags submission_tags apply_tags users_tags %} + +
+ {{ activity|display_for:request.user|submission_links|markdown|nh3 }} +
+ +{% if activity.edited %} + (edited) +{% endif %} + +{% with activity.attachments.all as attachments %} + {% if attachments %} + + {% endif %} +{% endwith %} diff --git a/hypha/apply/activity/templatetags/activity_tags.py b/hypha/apply/activity/templatetags/activity_tags.py index 64713f9305..ed5ca48d83 100644 --- a/hypha/apply/activity/templatetags/activity_tags.py +++ b/hypha/apply/activity/templatetags/activity_tags.py @@ -3,6 +3,7 @@ from django import template from django.conf import settings from django.utils.translation import gettext_lazy as _ +from django.template.defaultfilters import stringfilter from hypha.apply.determinations.models import Determination from hypha.apply.funds.models.submissions import ApplicationSubmission @@ -36,8 +37,10 @@ def display_activity_author(activity, user) -> str: and activity.user.is_org_faculty ): return settings.ORG_LONG_NAME + if isinstance(activity.related_object, Review) and activity.source.user == user: return _("Reviewer") + if ( settings.HIDE_IDENTITY_FROM_REVIEWERS and isinstance(activity.source, ApplicationSubmission) @@ -45,7 +48,8 @@ def display_activity_author(activity, user) -> str: and user in activity.source.reviewers.all() ): return _("Applicant") - return activity.user.get_display_name_with_group() + + return activity.user.get_display_name() @register.filter @@ -165,3 +169,10 @@ def source_type(value) -> str: if value and "submission" in value: return "Submission" return str(value).capitalize() + + +@register.filter(is_safe=True) +@stringfilter +def lowerfirst(value): + """Lowercase the first character of the value.""" + return value and value[0].lower() + value[1:] diff --git a/hypha/apply/activity/urls.py b/hypha/apply/activity/urls.py index 4ec8389522..02634bdb03 100644 --- a/hypha/apply/activity/urls.py +++ b/hypha/apply/activity/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path -from .views import AttachmentView, NotificationsView +from .views import AttachmentView, NotificationsView, edit_comment app_name = "activity" @@ -8,6 +8,7 @@ urlpatterns = [ path("anymail/", include("anymail.urls")), path("notifications/", NotificationsView.as_view(), name="notifications"), + path("/edit-comment/", edit_comment, name="edit-comment"), path( "activities/attachment//download/", AttachmentView.as_view(), diff --git a/hypha/apply/activity/views.py b/hypha/apply/activity/views.py index 6f14dad786..0b2966f198 100644 --- a/hypha/apply/activity/views.py +++ b/hypha/apply/activity/views.py @@ -1,5 +1,6 @@ from django.conf import settings -from django.shortcuts import get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.shortcuts import get_object_or_404, render from django.utils import timezone from django.utils.decorators import method_decorator from django.views.generic import CreateView, ListView @@ -10,11 +11,38 @@ from hypha.apply.utils.storage import PrivateMediaView from hypha.apply.utils.views import DelegatedViewMixin +from . import services from .filters import NotificationFilter from .forms import CommentForm from .messaging import MESSAGES, messenger from .models import COMMENT, Activity, ActivityAttachment -from .services import get_related_comments_for_user + + +@login_required +def edit_comment(request, pk): + """Edit a comment.""" + activity = get_object_or_404(Activity, id=pk) + + if activity.type != COMMENT or activity.user != request.user: + raise PermissionError("You can only edit your own comments") + + if request.GET.get("action") == "cancel": + return render( + request, + "activity/partial_comment_message.html", + {"activity": activity}, + ) + + if request.method == "POST": + activity = services.edit_comment(activity, request.POST.get("message")) + + return render( + request, + "activity/partial_comment_message.html", + {"activity": activity, "success": True}, + ) + + return render(request, "activity/edit_comment.html", {"activity": activity}) class ActivityContextMixin: @@ -24,7 +52,9 @@ def get_context_data(self, **kwargs): extra = { # Do not prefetch on the related_object__author as the models # are not homogeneous and this will fail - "comments": get_related_comments_for_user(self.object, self.request.user) + "comments": services.get_related_comments_for_user( + self.object, self.request.user + ) } return super().get_context_data(**extra, **kwargs) diff --git a/hypha/apply/api/v1/filters.py b/hypha/apply/api/v1/filters.py index 396cce8208..8487df78c4 100644 --- a/hypha/apply/api/v1/filters.py +++ b/hypha/apply/api/v1/filters.py @@ -3,7 +3,6 @@ from django_filters import rest_framework as filters from wagtail.models import Page -from hypha.apply.activity.models import Activity from hypha.apply.categories.blocks import CategoryQuestionBlock from hypha.apply.categories.models import Option from hypha.apply.funds.models import ApplicationSubmission, FundType, LabType @@ -132,18 +131,3 @@ def filter(self, qs, value): return qs return qs.newer(value) - - -class CommentFilter(filters.FilterSet): - since = filters.DateTimeFilter(field_name="timestamp", lookup_expr="gte") - before = filters.DateTimeFilter(field_name="timestamp", lookup_expr="lte") - newer = NewerThanFilter(queryset=Activity.comments.all()) - - class Meta: - model = Activity - fields = ["visibility", "since", "before", "newer"] - - -class AllCommentFilter(CommentFilter): - class Meta(CommentFilter.Meta): - fields = CommentFilter.Meta.fields + ["source_object_id"] diff --git a/hypha/apply/api/v1/serializers.py b/hypha/apply/api/v1/serializers.py index b366e8486a..ad0e7d4adf 100644 --- a/hypha/apply/api/v1/serializers.py +++ b/hypha/apply/api/v1/serializers.py @@ -1,8 +1,6 @@ from django.contrib.auth import get_user_model -from django_nh3.templatetags.nh3_tags import nh3_value from rest_framework import serializers -from hypha.apply.activity.models import Activity from hypha.apply.categories.models import MetaTerm from hypha.apply.determinations.models import Determination from hypha.apply.determinations.templatetags.determination_tags import ( @@ -17,7 +15,6 @@ from hypha.apply.review.models import Review, ReviewOpinion from hypha.apply.review.options import RECOMMENDATION_CHOICES from hypha.apply.users.groups import PARTNER_GROUP_NAME, STAFF_GROUP_NAME -from hypha.core.utils import markdown_to_html User = get_user_model() @@ -394,67 +391,6 @@ def get_landing_url(self, obj): return None -class CommentSerializer(serializers.ModelSerializer): - user = serializers.StringRelatedField() - message = serializers.SerializerMethodField() - edit_url = serializers.HyperlinkedIdentityField(view_name="api:v1:comments-edit") - editable = serializers.SerializerMethodField() - timestamp = TimestampField(read_only=True) - edited = TimestampField(read_only=True) - - class Meta: - model = Activity - fields = ( - "id", - "timestamp", - "user", - "message", - "visibility", - "edited", - "edit_url", - "editable", - ) - - def get_message(self, obj): - return nh3_value(markdown_to_html(obj.message)) - - def get_editable(self, obj): - return self.context["request"].user == obj.user - - -class CommentCreateSerializer(serializers.ModelSerializer): - user = serializers.StringRelatedField() - edit_url = serializers.HyperlinkedIdentityField(view_name="api:v1:comments-edit") - editable = serializers.SerializerMethodField() - timestamp = TimestampField(read_only=True) - edited = TimestampField(read_only=True) - - class Meta: - model = Activity - fields = ( - "id", - "timestamp", - "user", - "message", - "visibility", - "edited", - "edit_url", - "editable", - ) - - def get_editable(self, obj): - return self.context["request"].user == obj.user - - -class CommentEditSerializer(CommentCreateSerializer): - class Meta(CommentCreateSerializer.Meta): - read_only_fields = ( - "timestamp", - "visibility", - "edited", - ) - - class UserSerializer(serializers.Serializer): id = serializers.CharField(read_only=True) email = serializers.CharField(read_only=True) diff --git a/hypha/apply/api/v1/urls.py b/hypha/apply/api/v1/urls.py index 9de4d44339..c415ccec25 100644 --- a/hypha/apply/api/v1/urls.py +++ b/hypha/apply/api/v1/urls.py @@ -7,11 +7,9 @@ from hypha.apply.api.v1.review.views import SubmissionReviewViewSet from .views import ( - CommentViewSet, CurrentUser, RoundViewSet, SubmissionActionViewSet, - SubmissionCommentViewSet, SubmissionFilters, SubmissionViewSet, ) @@ -21,7 +19,6 @@ router = routers.SimpleRouter() router.register(r"submissions", SubmissionViewSet, basename="submissions") -router.register(r"comments", CommentViewSet, basename="comments") router.register(r"rounds", RoundViewSet, basename="rounds") submission_router = routers.NestedSimpleRouter( @@ -30,9 +27,6 @@ submission_router.register( r"actions", SubmissionActionViewSet, basename="submission-actions" ) -submission_router.register( - r"comments", SubmissionCommentViewSet, basename="submission-comments" -) submission_router.register(r"reviews", SubmissionReviewViewSet, basename="reviews") submission_router.register( r"determinations", SubmissionDeterminationViewSet, basename="determinations" diff --git a/hypha/apply/api/v1/views.py b/hypha/apply/api/v1/views.py index a0742446a1..b6ae04268d 100644 --- a/hypha/apply/api/v1/views.py +++ b/hypha/apply/api/v1/views.py @@ -1,7 +1,5 @@ from django.core.exceptions import PermissionDenied as DjangoPermissionDenied -from django.db import transaction from django.db.models import Prefetch -from django.utils import timezone from django_filters import rest_framework as filters from rest_framework import mixins, permissions, viewsets from rest_framework.decorators import action @@ -10,22 +8,17 @@ from rest_framework.views import APIView from rest_framework_api_key.permissions import HasAPIKey -from hypha.apply.activity.messaging import MESSAGES, messenger -from hypha.apply.activity.models import COMMENT, Activity from hypha.apply.determinations.views import DeterminationCreateOrUpdateView from hypha.apply.funds.models import ApplicationSubmission, RoundsAndLabs from hypha.apply.funds.reviewers.services import get_all_reviewers from hypha.apply.funds.workflow import STATUSES from hypha.apply.review.models import Review -from .filters import CommentFilter, SubmissionsFilter +from .filters import SubmissionsFilter from .mixin import SubmissionNestedMixin from .pagination import StandardResultsSetPagination -from .permissions import IsApplyStaffUser, IsAuthor +from .permissions import IsApplyStaffUser from .serializers import ( - CommentCreateSerializer, - CommentEditSerializer, - CommentSerializer, OpenRoundLabSerializer, RoundLabDetailSerializer, RoundLabSerializer, @@ -302,98 +295,6 @@ def open(self, request): return Response(serializer.data) -class SubmissionCommentViewSet( - SubmissionNestedMixin, - mixins.ListModelMixin, - mixins.CreateModelMixin, - viewsets.GenericViewSet, -): - """ - List all the comments on a submission. - """ - - queryset = Activity.comments.all().select_related("user") - serializer_class = CommentCreateSerializer - permission_classes = ( - permissions.IsAuthenticated, - IsApplyStaffUser, - ) - filter_backends = (filters.DjangoFilterBackend,) - filter_class = CommentFilter - pagination_class = StandardResultsSetPagination - - def get_queryset(self): - return ( - super() - .get_queryset() - .filter(submission=self.get_submission_object()) - .visible_to(self.request.user) - ) - - def perform_create(self, serializer): - """ - Add a comment on a submission. - """ - obj = serializer.save( - timestamp=timezone.now(), - type=COMMENT, - user=self.request.user, - source=self.get_submission_object(), - ) - messenger( - MESSAGES.COMMENT, - request=self.request, - user=self.request.user, - source=obj.source, - related=obj, - ) - - -class CommentViewSet(viewsets.GenericViewSet): - """ - Edit a comment. - """ - - queryset = Activity.comments.all().select_related("user") - serializer_class = CommentEditSerializer - permission_classes = (permissions.IsAuthenticated, IsAuthor) - - def get_serializer_class(self): - if self.action == "list": - return CommentSerializer - return CommentEditSerializer - - def get_queryset(self): - return super().get_queryset().visible_to(self.request.user) - - @action(detail=True, methods=["post"]) - def edit(self, request, *args, **kwargs): - return self.edit_comment(request, *args, **kwargs) - - @transaction.atomic - def edit_comment(self, request, *args, **kwargs): - comment_to_edit = self.get_object() - comment_to_update = self.get_object() - - comment_to_edit.previous = comment_to_update - comment_to_edit.pk = None - comment_to_edit.edited = timezone.now() - - serializer = self.get_serializer(comment_to_edit, data=request.data) - serializer.is_valid(raise_exception=True) - - if serializer.validated_data["message"] != comment_to_update.message: - self.perform_create(serializer) - comment_to_update.current = False - comment_to_update.save() - return Response(serializer.data) - - return Response(self.get_serializer(comment_to_update).data) - - def perform_create(self, serializer): - serializer.save() - - class CurrentUser(APIView): permission_classes = [permissions.IsAuthenticated] diff --git a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html index d456e94c36..ca0ae89244 100644 --- a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html @@ -53,8 +53,13 @@

{{ object.title }} {% trans "Submission details" %} - - {% trans "Communications" %} ({{ comments.count }}) + + {% trans "Conversations" %} ({{ comments.count }}) {% trans "Past Submis {% if not object.is_archive %}

{% trans "Add communication" %}

{% include "activity/include/comment_form.html" %} - {% include "activity/include/comment_list.html" with editable=True %} - {% else %} - {% include "activity/include/comment_list.html" with editable=False %} {% endif %} + +
+

{% trans "Loading…" %}

+

@@ -242,5 +253,4 @@

{% trans "Add communication" %}

{{ comment_form.media.js }} - {% endblock %} diff --git a/hypha/apply/funds/urls.py b/hypha/apply/funds/urls.py index 758bdcb125..f47642db83 100644 --- a/hypha/apply/funds/urls.py +++ b/hypha/apply/funds/urls.py @@ -50,6 +50,7 @@ partial_reviews_card, partial_reviews_decisions, partial_submission_activities, + partial_submission_comments, partial_submission_lead, sub_menu_bulk_update_lead, sub_menu_bulk_update_reviewers, @@ -189,6 +190,11 @@ partial_submission_activities, name="partial-activities", ), + path( + "partial/comments/", + partial_submission_comments, + name="partial-comments", + ), path("lead/update/", UpdateLeadView.as_view(), name="lead_update"), path("archive/", htmx_archive_unarchive_submission, name="archive"), path( diff --git a/hypha/apply/funds/views_partials.py b/hypha/apply/funds/views_partials.py index 9ecb40fc6c..0033481fb2 100644 --- a/hypha/apply/funds/views_partials.py +++ b/hypha/apply/funds/views_partials.py @@ -4,6 +4,8 @@ from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.db.models import Count, Q +from django.core.paginator import Paginator +from django.db.models import Count from django.http import HttpRequest, HttpResponse from django.shortcuts import get_object_or_404, render from django.urls import reverse_lazy @@ -16,6 +18,7 @@ from hypha.apply.activity.services import ( get_related_actions_for_user, + get_related_comments_for_user, ) from hypha.apply.categories.models import MetaTerm, Option from hypha.apply.funds.forms import BatchUpdateReviewersForm @@ -230,6 +233,25 @@ def partial_submission_activities(request, pk): return render(request, "activity/include/action_list.html", ctx) +@login_required +@require_http_methods(["GET"]) +def partial_submission_comments(request, pk): + submission = get_object_or_404(ApplicationSubmission, pk=pk) + has_permission( + "submission_view", request.user, object=submission, raise_exception=True + ) + qs = get_related_comments_for_user(submission, request.user) + + page = Paginator(qs, per_page=10, orphans=5).page(request.GET.get("page", 1)) + + ctx = { + "page": page, + "comments": page.object_list, + "editable": not submission.is_archive, + } + return render(request, "activity/include/comment_list.html", ctx) + + @login_required @require_http_methods(["GET"]) def partial_reviews_card(request: HttpRequest, pk: str) -> HttpResponse: diff --git a/hypha/apply/projects/templates/application_projects/project_detail.html b/hypha/apply/projects/templates/application_projects/project_detail.html index 50de4de9a7..c20c4845df 100644 --- a/hypha/apply/projects/templates/application_projects/project_detail.html +++ b/hypha/apply/projects/templates/application_projects/project_detail.html @@ -244,7 +244,7 @@
{% trans "PAF Approvals" %}

{% trans "Add communication" %}

{% include "activity/include/comment_form.html" %} - {% include "activity/include/comment_list.html" with editable=False %} + {% include "activity/include/comment_list.html" with editable=True %}
@@ -264,7 +264,6 @@

{% trans "Add communication" %}

{% block extra_js %} - diff --git a/hypha/apply/users/identicon.py b/hypha/apply/users/identicon.py new file mode 100644 index 0000000000..5c256da98a --- /dev/null +++ b/hypha/apply/users/identicon.py @@ -0,0 +1,163 @@ +import hashlib +from typing import Union + +import svgwrite + + +def binarize(string: str): + # Create a SHA256 hash of the input string + hash = hashlib.sha256(bytes(string, "utf-8")).hexdigest() + + # Convert hex string to decimal value + decimal_value = int(hash, 16) + + # Convert decimal value to binary string, excluding the '0b' prefix + binary_string = bin(decimal_value)[2:] + + # increase string length to 256 if not long enoughs + while len(binary_string) < 256: + binary_string = "0" + binary_string + + return binary_string # Returns a 256 long string of binary + + +def get_identicon( + data: str, + color: Union[str, None] = None, + background: Union[str, None] = None, + size: Union[int, int] = 50, +): + binarized = binarize(data) + + color_data = binarized[:58] # Trim to 58 bits + field_data = binarized[58:] # Trim to 198 bits + + if color is None: + segment_length = 6 # Length of each segment + + color_map = { + 0: "#5bc0eb", # blue + 1: "#65a30d", # yellow + 2: "#9bc53d", # green + 3: "#e55934", # red + 4: "#fa7921", # orange + } + + # get the first 6 bits of the color data + color_data_segment = color_data[:segment_length] + + # Convert the segment from binary to decimal + decimal_value = int(color_data_segment, 2) + + # Use hashlib to generate a hash value based on the segment + hash_value = hashlib.md5(str(decimal_value).encode()).hexdigest() + + # Convert the hash value to an integer + # thanks again ChatGPT! This conversion introduces a lot of randomness as the hash integer is quite large + # the line below (hash_integer % len(color_map)) divides the hash integer by the length of the color map (in this case 5) + # and returns the remainder, which is then used as the index for the color map + hash_integer = int(hash_value, 16) + + # Map the integer value to an index within the range of available colors + color_index = hash_integer % len(color_map) + + # Get the color based on the index + color = color_map[color_index] + + else: + color = "#" + color + + fields = [] + + for _ in range(66): + fields.append(field_data[:3]) # get first 3 bits + field_data = field_data[3:] # then remove them + + field_fill = [] + + for field in fields: + # convert bits to list (010 -> [0, 1, 0]) + bit_list = list(field) + + # sum all bits + bit_sum = int(bit_list[0]) + int(bit_list[1]) + int(bit_list[2]) + + if bit_sum <= 1: + field_fill.append(False) + elif bit_sum >= 2: + field_fill.append(True) + + # x, y, x-limit (see comments above) (max: 11,11,6) + usable_grid_size = [5, 5, 3] + + # credits to ChatGPT lol, didn't know this existed + dwg = svgwrite.Drawing("identicon.svg", size=(size, size)) + + if background is not None: + if background == "light": + background = "#ffffff" + elif background == "dark": + background = "#212121" + else: + background = "#" + background + + try: + dwg.add(dwg.rect((0, 0), (size, size), fill=background)) # fill background + except TypeError as err: + raise ValueError( + "Invalid background color – only pass on HEX colors without the '#' prefix or 'light'/'dark'" + ) from err + + # Size of each identicon cell (e.g. 250 / 5 = 50) + cell_size = size / usable_grid_size[0] + + # iterate through y + for i in range(usable_grid_size[1]): + row_list = [] + + # iterate through x + for j in range(usable_grid_size[2]): + # i (row) * x (size, e.g. 11) + j (column index) -> list index + if field_fill[i * usable_grid_size[2] + j] is True: + # Calculate cell position + x = j * cell_size + y = i * cell_size + + # Draw cell rectangle with the assigned color + try: + dwg.add(dwg.rect((x, y), (cell_size, cell_size), fill=color)) + except TypeError: + raise ValueError( + "Invalid fill color – only pass on HEX colors without the '#' prefix", + ) from None + else: + pass # pass instead of continue because continue would skip the row_list appending + + # make a separate list for reversing + row_list.append(field_fill[i * usable_grid_size[2] + j]) + + # reverse the list & remove the first element (the middle one / x-limit) + row_list_reversed = list(reversed(row_list))[1:] + + # make a separate index for the reversed list since k is not an index like j + row_list_index = 0 + + for k in row_list_reversed: + if k is True: + # Calculate cell position + x = (row_list_index + usable_grid_size[2]) * cell_size + y = i * cell_size + + # Draw cell rectangle with the assigned color + try: + dwg.add(dwg.rect((x, y), (cell_size, cell_size), fill=color)) + except TypeError as err: + raise ValueError( + "Invalid fill color – only pass on HEX colors without the '#' prefix" + ) from err + else: + pass # pass instead of continue because continue would skip the index increment + row_list_index += 1 + + # Get the SVG as a string + return dwg.tostring() diff --git a/hypha/apply/users/models.py b/hypha/apply/users/models.py index 17d08f29a1..d04115a6d6 100644 --- a/hypha/apply/users/models.py +++ b/hypha/apply/users/models.py @@ -224,7 +224,7 @@ class User(AbstractUser): wagtail_reference_index_ignore = True def __str__(self): - return self.get_full_name() if self.get_full_name() else self.get_short_name() + return self.get_display_name() def get_full_name(self): return self.full_name.strip() @@ -236,19 +236,22 @@ def get_short_name(self) -> str: """ return self.email.split("@")[0] - def get_display_name_with_group(self) -> str: - """Gets the user's display name, along with their role in parenthesis - - If the user has a full name set that will be used, otherwise pulls the email. - """ - display_name = str(self) - is_apply_staff = f" ({STAFF_GROUP_NAME})" if self.is_apply_staff else "" - is_reviewer = f" ({REVIEWER_GROUP_NAME})" if self.is_reviewer else "" - is_partner = f" ({PARTNER_GROUP_NAME})" if self.is_partner else "" - is_applicant = f" ({APPLICANT_GROUP_NAME})" if self.is_applicant else "" - is_finance = f" ({FINANCE_GROUP_NAME})" if self.is_finance else "" - is_contracting = f" ({CONTRACTING_GROUP_NAME})" if self.is_contracting else "" - return f"{display_name}{is_apply_staff}{is_reviewer}{is_applicant}{is_partner}{is_finance}{is_contracting}" + def get_display_name(self): + return self.full_name.strip() if self.full_name else self.get_short_name() + + def get_role_names(self): + roles = [] + if self.is_apply_staff: + roles.append(STAFF_GROUP_NAME) + if self.is_reviewer: + roles.append(REVIEWER_GROUP_NAME) + if self.is_applicant: + roles.append(APPLICANT_GROUP_NAME) + if self.is_finance: + roles.append(FINANCE_GROUP_NAME) + if self.is_contracting: + roles.append(CONTRACTING_GROUP_NAME) + return roles @cached_property def roles(self): diff --git a/hypha/apply/users/templates/users/account.html b/hypha/apply/users/templates/users/account.html index 4b5ceb77d0..c1e1118da9 100644 --- a/hypha/apply/users/templates/users/account.html +++ b/hypha/apply/users/templates/users/account.html @@ -38,6 +38,7 @@

{% trans "Submit a new applicat

{% trans "Profile" %}

+
{% csrf_token %} {% for field in form %} diff --git a/hypha/apply/users/templatetags/users_tags.py b/hypha/apply/users/templatetags/users_tags.py index 8c8622cb03..240c4da5cc 100644 --- a/hypha/apply/users/templatetags/users_tags.py +++ b/hypha/apply/users/templatetags/users_tags.py @@ -1,6 +1,9 @@ from django import template +from django.utils.safestring import SafeString from django_otp import devices_for_user +from hypha.apply.users.identicon import get_identicon + from ..utils import can_use_oauth_check register = template.Library() @@ -42,3 +45,9 @@ def tokens_text(token_set): for token in token_set: tokens_string += str(token.token) + " \n" return tokens_string + + +@register.simple_tag() +def user_image(identifier: str, size=20): + """Checking if 2FA devices exist for the user""" + return SafeString(get_identicon(identifier, size=size)) diff --git a/hypha/static_src/javascript/edit-comment.js b/hypha/static_src/javascript/edit-comment.js deleted file mode 100644 index d092bf5fe0..0000000000 --- a/hypha/static_src/javascript/edit-comment.js +++ /dev/null @@ -1,172 +0,0 @@ -(function ($) { - "use strict"; - - const comment = ".js-comment"; - const pageDown = ".js-pagedown"; - const editBlock = ".js-edit-block"; - const editButton = ".js-edit-comment"; - const feedContent = ".js-feed-content"; - const commentError = ".js-comment-error"; - const cancelEditButton = ".js-cancel-edit"; - const submitEditButton = ".js-submit-edit"; - - // handle edit - $(editButton).click(function (e) { - e.preventDefault(); - - closeAllEditors(); - - const editBlockWrapper = $(this).closest(feedContent).find(editBlock); - const commentWrapper = $(this).closest(feedContent).find(comment); - const commentContents = $(commentWrapper).attr("data-comment"); - - // hide the edit link and original comment - $(this).parent().hide(); - $(commentWrapper).hide(); - - const markup = ` -
-
- -
-
- `; - - const buttons = ` -
- - -
- `; - - // add the comment to the editor - const markupEditor = $(markup).append(buttons); - $(editBlockWrapper).append(markupEditor); - - // run the editor - initEditor(); - }); - - // handle cancel - $(document).on("click", cancelEditButton, function () { - showComment(this); - showEditButton(this); - hidePageDownEditor(this); - if ($(commentError).length) { - hideError(); - } - }); - - // handle submit - $(document).on("click", submitEditButton, function () { - const commentContainer = $(this).closest(editBlock).siblings(comment); - const editedComment = $(this) - .closest(pageDown) - .find(".wmd-preview") - .html(); - // const editedVisibility = $('input[name="radio-visibility"]:checked').val(); - const commentMD = $(this).closest(editBlock).find("textarea").val(); - const editUrl = $(commentContainer).attr("data-edit-url"); - - fetch(editUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-CSRFToken": window.Cookies.get("csrftoken"), - }, - body: JSON.stringify({ - message: editedComment, - // visibility: editedVisibility - }), - }) - .then((response) => { - if (!response.ok) { - const error = Object.assign({}, response, { - status: response.status, - statusText: response.statusText, - }); - return Promise.reject(error); - } - return response.json(); - }) - .then((data) => { - updateComment( - commentContainer, - data.id, - data.message, - data.visibility, - data.edit_url, - commentMD - ); - showComment(this); - showEditButton(this); - hidePageDownEditor(this); - }) - .catch((error) => { - if (error.status === 404) { - handleError( - this, - "Update unsuccessful. This comment has been edited elsewhere. To get the latest updates please refresh the page, but note any unsaved changes will be lost by doing so." - ); - } else { - handleError( - this, - "An error has occured. Please try again later." - ); - } - }); - }); - - const handleError = (el, message) => { - $(el) - .closest(editBlock) - .append( - `

${message}

` - ); - $(el).attr("disabled", true); - }; - - const initEditor = () => { - const converterOne = window.Markdown.getSanitizingConverter(); - const commentEditor = new window.Markdown.Editor( - converterOne, - "-edit-comment" - ); - commentEditor.run(); - }; - - const showEditButton = (el) => { - $(editButton).parent().show(); - }; - - const hidePageDownEditor = (el) => { - $(el).closest(pageDown).remove(); - }; - - const showComment = (el) => { - $(el).closest(editBlock).siblings(comment).show(); - }; - - const updateComment = ( - el, - id, - comment, - visibility, - editUrl, - commentMarkdown - ) => { - $(el).html(comment); - $(el).attr("data-id", id); - $(el).attr("data-edit-url", editUrl); - $(el).attr("data-comment", commentMarkdown); - $(el).attr("data-visibility", visibility); - }; - - const closeAllEditors = () => { - $(comment).show(); - $(pageDown).remove(); - $(editButton).parent().show(); - }; - - const hideError = () => $(commentError).remove(); -})(jQuery); diff --git a/hypha/static_src/sass/components/_editor.scss b/hypha/static_src/sass/components/_editor.scss index 04e674fe5e..7bfd0df6db 100644 --- a/hypha/static_src/sass/components/_editor.scss +++ b/hypha/static_src/sass/components/_editor.scss @@ -1,5 +1,5 @@ .wmd-preview { - background-color: $color--sky-blue; + background-color: #f3f4f6; padding: 0 10px; width: 100%; overflow: hidden; /* prevent collapsing margins */ diff --git a/hypha/static_src/sass/components/_feed.scss b/hypha/static_src/sass/components/_feed.scss index ed8dd60a70..02871fc753 100644 --- a/hypha/static_src/sass/components/_feed.scss +++ b/hypha/static_src/sass/components/_feed.scss @@ -96,8 +96,7 @@ } &--edit-button { - border-inline-start: 2px solid $color--mid-grey; - padding-inline-start: 15px; + padding-inline-start: 0.5em; } &--last-edited { diff --git a/hypha/static_src/sass/components/_form.scss b/hypha/static_src/sass/components/_form.scss index 89ca82eb64..36b66d5d18 100644 --- a/hypha/static_src/sass/components/_form.scss +++ b/hypha/static_src/sass/components/_form.scss @@ -446,6 +446,11 @@ } } + .wmd-input { + field-sizing: content; + min-height: 15rem; + } + &__comments { .fields--visible { display: grid; diff --git a/hypha/static_src/sass/components/_wrapper.scss b/hypha/static_src/sass/components/_wrapper.scss index e2b2542818..09c28730db 100644 --- a/hypha/static_src/sass/components/_wrapper.scss +++ b/hypha/static_src/sass/components/_wrapper.scss @@ -265,15 +265,9 @@ } &--comments { - padding-block-end: 15px; - margin-block-end: 15px; + margin-block-end: 1rem; border-block-end: 1px solid $color--mid-grey; - @include media-query(md) { - padding-block-end: 35px; - margin-block-end: 35px; - } - .helptext { font-size: 15px; } diff --git a/requirements.txt b/requirements.txt index 011039c573..72e120ad11 100644 --- a/requirements.txt +++ b/requirements.txt @@ -52,6 +52,7 @@ pwned-passwords-django==2.1 qrcode==7.4.2 reportlab==4.0.9 social_auth_app_django==5.4.1 +svgwrite==1.4.3 tablib==3.5.0 tomd==0.1.3 wagtail==5.2.6