Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import re

from hypha.apply.activity.adapters.activity_feed import ActivityAdapter
from hypha.apply.activity.models import TEAM, Activity
from hypha.apply.activity.models import TEAM
from hypha.apply.activity.options import MESSAGES


Expand All @@ -27,6 +27,7 @@ def change_updatelead_visibility(apps, schema_editor):
ActivityAdapter.messages[MESSAGES.UNARCHIVE_SUBMISSION],
]

Activity = apps.get_model("activity", "Activity")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never use the model directly in a migration. If you later on add fields etc. the migrations will fail. Instead use get_model() like above.

staff_identity_set = Activity.objects.none()

for message in lead_messages:
Expand Down
17 changes: 17 additions & 0 deletions hypha/apply/activity/migrations/0088_activity_deleted.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.2.11 on 2026-02-18 14:46

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("activity", "0087_alter_event_type"),
]

operations = [
migrations.AddField(
model_name="activity",
name="deleted",
field=models.DateTimeField(default=None, null=True),
),
]
1 change: 1 addition & 0 deletions hypha/apply/activity/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ class Activity(models.Model):

# Fields for handling versioning of the comment activity models
edited = models.DateTimeField(default=None, null=True)
deleted = models.DateTimeField(default=None, null=True)
current = models.BooleanField(default=True)
previous = models.ForeignKey("self", on_delete=models.CASCADE, null=True)

Expand Down
27 changes: 27 additions & 0 deletions hypha/apply/activity/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,33 @@ def edit_comment(activity: Activity, message: str) -> Activity:
return activity


def delete_comment(activity: Activity) -> Activity:
"""
Soft delete a comment by creating a clone of the original comment with a delete message.

Args:
activity (Activity): The original comment activity to be soft deleted.

Returns:
Activity: The soft deleted comment activitye.
"""

# Create a clone of the comment to soft delete
previous = Activity.objects.get(pk=activity.pk)
previous.pk = None
previous.current = False
previous.save()

activity.previous = previous
activity.deleted = timezone.now()
activity.edited = None
activity.message = ""
activity.current = True
activity.save()

return activity


def get_related_activities_for_user(obj, user):
"""Return comments/communications related to an object, esp. useful with
ApplicationSubmission and Project.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
{% load heroicons activity_tags nh3_tags markdown_tags submission_tags apply_tags users_tags %}
{% load i18n heroicons activity_tags nh3_tags markdown_tags submission_tags apply_tags users_tags %}

<div class="max-w-none prose">
{{ activity|display_for:request.user|submission_links|markdown|nh3 }}
</div>

{% if activity.edited %}
<span class="text-sm text-fg-muted" data-tippy-content="{{ activity.edited|date:"SHORT_DATETIME_FORMAT" }}">(edited)</span>
<span class="text-sm text-fg-muted" data-tippy-content="{{ activity.edited|date:"SHORT_DATETIME_FORMAT" }}">({% trans "edited" %})</span>
{% endif %}

{% if activity.deleted %}
<span class="text-sm text-fg-muted" data-tippy-content="{{ activity.deleted|date:"SHORT_DATETIME_FORMAT" }}">({% trans "deleted" %})</span>
{% endif %}

{% with activity.attachments.all as attachments %}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{% load i18n activity_tags nh3_tags markdown_tags submission_tags apply_tags heroicons users_tags %}

<article class="relative h-timeline-item">
<article class="relative h-timeline-item" id="communications-wrapper--{{ activity.id }}">
<div
class="flex py-3 -ms-8 before:block before:absolute before:top-0 before:bottom-0 before:left-0 before:w-0.5 before:bg-base-300 md:-ms-20"
>
Expand Down Expand Up @@ -46,7 +46,6 @@
<div class="flex gap-1 items-center">
{% if not request.user.is_applicant %}
{% if request.user.is_apply_staff and activity.assigned_to %}

<span class="flex gap-1 items-center py-0.5 px-1.5 text-xs rounded-xl border border-gray-300 text-fg-muted">
{% heroicon_outline "user-plus" size=14 class="inline" aria_hidden=true %}
{% if activity.assigned_to.id == request.user.id %}
Expand All @@ -64,20 +63,32 @@
{{ visibility_text }}
</span>
{% endwith %}

{% endif %}

{% if editable and activity.user == request.user %}
{% if editable and activity.user == request.user and not activity.deleted %}
<a
hx-get="{% url 'activity:edit-comment' activity.id %}"
hx-target="#text-comment-{{activity.id}}"
title="Edit Comment"
title="{% trans "Edit comment" %}"
class="btn btn-sm btn-square btn-ghost"
>
{% heroicon_micro "pencil-square" aria_hidden=true %}
<span class="sr-only">{% trans "Edit" %}</span>
</a>
{% endif %}

{% if editable and activity.user == request.user and not activity.deleted and request.user.is_apply_staff %}
<a
hx-delete="{% url 'activity:delete-comment' activity.id %}"
hx-target="#communications-wrapper--{{activity.id}}"
hx-confirm="{% trans "Are you sure you want to delete this comment? This action cannot be undone." %}"
title="{% trans "Delete comment" %}"
class="btn btn-error btn-sm btn-square btn-ghost"
>
{% heroicon_micro "trash" class="opacity-80 size-4" aria_hidden=true %}
<span class="sr-only">{% trans "Delete" %}</span>
</a>
{% endif %}
</div>
</header>

Expand Down
9 changes: 8 additions & 1 deletion hypha/apply/activity/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
from django.urls import include, path

from .views import AttachmentView, NotificationsView, edit_comment, partial_comments
from .views import (
AttachmentView,
NotificationsView,
delete_comment,
edit_comment,
partial_comments,
)

app_name = "activity"

Expand All @@ -10,6 +16,7 @@
path("notifications/", NotificationsView.as_view(), name="notifications"),
path("comments/<int:pk>/", partial_comments, name="partial-comments"),
path("<pk>/edit-comment/", edit_comment, name="edit-comment"),
path("<pk>/delete-comment/", delete_comment, name="delete-comment"),
path(
"activities/attachment/<uuid:file_pk>/download/",
AttachmentView.as_view(),
Expand Down
35 changes: 33 additions & 2 deletions hypha/apply/activity/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator
from django.shortcuts import get_object_or_404, render
Expand All @@ -8,7 +8,7 @@
from rolepermissions.checkers import has_object_permission

from hypha.apply.funds.models.submissions import ApplicationSubmission
from hypha.apply.users.decorators import staff_required
from hypha.apply.users.decorators import is_apply_staff, staff_required
from hypha.apply.utils.storage import PrivateMediaView

from . import services
Expand Down Expand Up @@ -59,6 +59,9 @@ def edit_comment(request, pk):
if activity.type != COMMENT or activity.user != request.user:
raise PermissionError("You can only edit your own comments")

if activity.deleted:
raise PermissionError("You can not edit a deleted comment")

if request.GET.get("action") == "cancel":
return render(
request,
Expand All @@ -78,6 +81,34 @@ def edit_comment(request, pk):
return render(request, "activity/ui/edit_comment_form.html", {"activity": activity})


@login_required
@user_passes_test(is_apply_staff)
def delete_comment(request, pk):
"""Soft delete a comment."""
activity = get_object_or_404(Activity, id=pk)

if activity.type != COMMENT or activity.user != request.user:
raise PermissionError("You can only delete your own comments")

if activity.deleted:
raise PermissionError("You can not delete a deleted comment")

if request.method == "DELETE":
activity = services.delete_comment(activity)

return render(
request,
"activity/ui/activity-comment-item.html",
{"activity": activity, "success": True},
)
Comment on lines +99 to +103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are your thoughts on this:

Suggested change
return render(
request,
"activity/partial_comment_message.html",
{"activity": activity, "success": True},
)
response = render(
request,
"activity/partial_comment_message.html",
{"activity": activity, "success": True},
)
response.headers["HX-Trigger"] = json.dumps({"commentsUpdated": None})
return response

Combined with a new listener in comments.html

 hx-trigger="load, intersect once, commentsUpdated"

Just so the comment auto-updates and tosses the delete/edit buttons on delete

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! I have now set target to comment item instead of comment text to get this result. This has the same result but we do not need to reload all activities.


return render(
request,
"activity/ui/activity-comment-item.html",
{"activity": activity},
)


class ActivityContextMixin:
"""Mixin to add related 'comments' of the current view's 'self.object'"""

Expand Down
Loading