Skip to content
Draft
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
1 change: 1 addition & 0 deletions hypha/apply/activity/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
MESSAGES.REVIEW_REMINDER: "reminder",
MESSAGES.BATCH_UPDATE_INVOICE_STATUS: "invoices",
MESSAGES.REMOVE_TASK: "task",
MESSAGES.UPDATED_AUTHOR: "old_author",
}


Expand Down
1 change: 1 addition & 0 deletions hypha/apply/activity/adapters/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class EmailAdapter(AdapterBase):
MESSAGES.REPORT_NOTIFY: "messages/email/report_notify.html",
MESSAGES.REVIEW_REMINDER: "messages/email/ready_to_review.html",
MESSAGES.PROJECT_TRANSITION: "handle_project_transition",
MESSAGES.UPDATED_AUTHOR: "messages/email/author_updated.html",
}

def get_subject(self, message_type, source):
Expand Down
3 changes: 3 additions & 0 deletions hypha/apply/activity/adapters/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ class SlackAdapter(AdapterBase):
MESSAGES.UNARCHIVE_SUBMISSION: _(
"{user} has unarchived the submission: {source.title_text_display}"
),
MESSAGES.UPDATED_AUTHOR: _(
"{user} has updated author from {old_author} to {source.user} for submission <{link}|{source}>"
),
}

def __init__(self):
Expand Down
82 changes: 82 additions & 0 deletions hypha/apply/activity/migrations/0092_alter_event_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Generated by Django 5.2.13 on 2026-05-12 18:28

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("activity", "0091_alter_activity_options_and_more"),
]

operations = [
migrations.AlterField(
model_name="event",
name="type",
field=models.CharField(
choices=[
("UPDATE_LEAD", "updated lead"),
("BATCH_UPDATE_LEAD", "batch updated lead"),
("EDIT_SUBMISSION", "edited submission"),
("APPLICANT_EDIT", "edited applicant"),
("NEW_SUBMISSION", "submitted new submission"),
("DRAFT_SUBMISSION", "submitted new draft submission"),
("SCREENING", "screened"),
("TRANSITION", "transitioned"),
("BATCH_TRANSITION", "batch transitioned"),
("DETERMINATION_OUTCOME", "sent determination outcome"),
("BATCH_DETERMINATION_OUTCOME", "sent batch determination outcome"),
("INVITED_TO_PROPOSAL", "invited to proposal"),
("REVIEWERS_UPDATED", "updated reviewers"),
("BATCH_REVIEWERS_UPDATED", "batch updated reviewers"),
("READY_FOR_REVIEW", "marked ready for review"),
("BATCH_READY_FOR_REVIEW", "marked batch ready for review"),
("NEW_REVIEW", "added new review"),
("COMMENT", "added comment"),
("PROPOSAL_SUBMITTED", "submitted proposal"),
("OPENED_SEALED", "opened sealed submission"),
("REVIEW_OPINION", "reviewed opinion"),
("DELETE_SUBMISSION", "deleted submission"),
("DELETE_REVIEW", "deleted review"),
("DELETE_REVIEW_OPINION", "deleted review opinion"),
("CREATED_PROJECT", "created project"),
("UPDATE_PROJECT_LEAD", "updated project lead"),
("UPDATE_PROJECT_TITLE", "updated project title"),
("EDIT_REVIEW", "edited review"),
("SEND_FOR_APPROVAL", "sent for approval"),
("APPROVE_PROJECT", "approved project"),
("ASSIGN_PAF_APPROVER", "assign project form approver"),
("APPROVE_PAF", "approved project form"),
("PROJECT_TRANSITION", "transitioned project"),
("REQUEST_PROJECT_CHANGE", "requested project change"),
("SUBMIT_CONTRACT_DOCUMENTS", "submitted contract documents"),
("UPLOAD_DOCUMENT", "uploaded document to project"),
("UPLOAD_CONTRACT", "uploaded contract to project"),
("APPROVE_CONTRACT", "approved contract"),
("CREATE_INVOICE", "created invoice for project"),
("UPDATE_INVOICE_STATUS", "updated invoice status"),
("APPROVE_INVOICE", "approve invoice"),
("DELETE_INVOICE", "deleted invoice"),
("SENT_TO_COMPLIANCE", "sent project to compliance"),
("UPDATE_INVOICE", "updated invoice"),
("SUBMIT_REPORT", "submitted report"),
("SKIPPED_REPORT", "skipped report"),
("REPORT_FREQUENCY_CHANGED", "changed report frequency"),
("DISABLED_REPORTING", "disabled reporting"),
("REPORT_NOTIFY", "notified report"),
("REVIEW_REMINDER", "reminder to review"),
("BATCH_DELETE_SUBMISSION", "batch deleted submissions"),
("BATCH_ARCHIVE_SUBMISSION", "batch archive submissions"),
("BATCH_INVOICE_STATUS_UPDATE", "batch update invoice status"),
("STAFF_ACCOUNT_CREATED", "created new account"),
("STAFF_ACCOUNT_EDITED", "edited account"),
("ARCHIVE_SUBMISSION", "archived submission"),
("UNARCHIVE_SUBMISSION", "unarchived submission"),
("REMOVE_TASK", "remove task"),
("INVITE_COAPPLICANT", "invite co-applicant"),
("UPDATED_AUTHOR", "updated author"),
],
max_length=50,
verbose_name="verb",
),
),
]
1 change: 1 addition & 0 deletions hypha/apply/activity/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,4 @@ class MESSAGES(TextChoices):
UNARCHIVE_SUBMISSION = "UNARCHIVE_SUBMISSION", _("unarchived submission")
REMOVE_TASK = "REMOVE_TASK", _("remove task")
INVITE_COAPPLICANT = "INVITE_COAPPLICANT", _("invite co-applicant")
UPDATED_AUTHOR = "UPDATED_AUTHOR", _("updated author")
16 changes: 16 additions & 0 deletions hypha/apply/activity/templates/messages/email/author_updated.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{% extends "messages/email/applicant_base.html" %}

{% load i18n %}

{% block content %}{# fmt:off #}
{% blocktrans with title=source.title %}You have assigned as an Applicant to submission "{{ title }}".{% endblocktrans %}
{% endblock %}

{% block more_info %}
{% trans "Link to your submission" %}: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}
{% trans "If you have any questions, please submit them here" %}: {{ request.scheme }}://{{ request.get_host }}{{ source.get_absolute_url }}#communications

{% trans "See our guide for more information" %}: {{ ORG_GUIDE_URL }}

{% blocktrans %}If you have any issues accessing the submission or other general inquiries, please email us at {{ ORG_EMAIL }}{% endblocktrans %}
{% endblock %}{# fmt:on #}
29 changes: 29 additions & 0 deletions hypha/apply/funds/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,35 @@ def make_role_reviewer_fields():
return role_fields


class UpdateAuthorForm(ApplicationSubmissionModelForm):
author = forms.ModelChoiceField(
queryset=User.objects.applicants(),
label=_("Applicants"),
required=False,
)

class Meta:
model = ApplicationSubmission
fields: list = []

def __init__(self, *args, **kwargs):
kwargs.pop("user")
super().__init__(*args, **kwargs)
current_author = self.instance.user

author_field = self.fields["author"]

# Removed current author from queryset
author_field.queryset = author_field.queryset.exclude(id=current_author.id)
author_field.initial = current_author
author_field.widget.attrs.update({"data-js-choices": ""})

def save(self, *args, **kwargs):
self.instance.user = self.cleaned_data["author"]
self.instance.save()
return self.instance


class GroupedModelChoiceIterator(forms.models.ModelChoiceIterator):
def __init__(self, field, groupby):
self.groupby = groupby
Expand Down
11 changes: 11 additions & 0 deletions hypha/apply/funds/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,10 +288,21 @@ def user_can_view_post_comment_form(user, submission):
return True


def can_change_submission_author(user, submission):
if not user.is_authenticated:
return False, "Login Required"

if user.is_apply_staff:
return True, "Staff can update author"

return False, "Forbidden Error"


permissions_map = {
"submission_view": can_view_submission,
"submission_edit": can_edit_submission,
"submission_action": can_take_submission_actions,
"change_author": can_change_submission_author,
"can_view_submission_screening": can_view_submission_screening,
"archive_alter": can_alter_archived_submissions,
"co_applicant_invite": can_invite_co_applicants,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ <h5>
<relative-time datetime={{ object.submit_time|date:"c" }} class="font-semibold">
{{ object.submit_time|date:"SHORT_DATETIME_FORMAT" }}
</relative-time>
{% trans "by" %} <strong class="max-w-md font-semibold">{% display_submission_author %}</strong>
{% trans "by" %} <strong class="max-w-md font-semibold">{% get_author_display "submitted" %}</strong>
</div>

<div class="self-center line-clamp-2">
Expand All @@ -103,7 +103,7 @@ <h5>
</relative-time>
{% trans "by" %}
<strong class="max-w-md font-semibold">
{% display_submission_author True %}
{% get_author_display "updated" %}
</strong>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ <h2 class="mb-2 card-title">{% trans "Actions to take" %}</h2>
{% trans "Create Reminder" %}
</button>

<button class="w-full btn btn-outline" hx-get="{% url 'funds:submissions:change_author' pk=object.pk %}" hx-target="#htmx-modal" >{% trans "Change Author" %}</button>

<a
class="w-full btn btn-outline"
href="{% url "apply:submissions:download" pk=object.pk %}"
Expand Down
11 changes: 11 additions & 0 deletions hypha/apply/funds/templates/funds/modals/update_author_form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% load i18n %}
<c-modal.header>{% trans "Update Author" %}</c-modal.header>

<div class="p-4">
<dl class="mb-4">
<dt class="font-semibold">{% trans "Current Author" %}</dt>
<dd class="truncate">{{ object.user }} &lt;{{object.user.email}}&gt;</dd>
</dl>

{% include 'includes/dialog_form_base.html' with form=form value=value %}
</div>
42 changes: 31 additions & 11 deletions hypha/apply/funds/templatetags/workflow_tags.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Literal

from django import template
from django.conf import settings
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -51,30 +53,48 @@ def show_applicant_identity(submission: ApplicationSubmission, user: User) -> bo


@register.simple_tag(takes_context=True)
def display_submission_author(context: dict, revision_author: bool = False) -> str:
def get_author_display(context: dict, type: Literal["submitted", "updated"]) -> str:
"""Creates a formatted author string based on the submission and viewer role.

Args:
context: dict of template context
revision_author: if True, gets revision author. False (default) gets submission author
context:
dict of template context
type:
A string literal of either "submitted" (retrieves author of first revision),
or "updated" (retrieves author of live revision)

Returns:
A string with the formatted author depending on the user role (ie. a
comment from staff viewed by an applicant will return the org name).
A string with the formatted author depending on the user role (ie. an edit from
staff viewed by an applicant will return the org name).
"""
submission: ApplicationSubmission = context["object"]
request = context["request"]

author = submission.user if not revision_author else submission.live_revision.author
if (
not revision_author or author == submission.user
) and not show_applicant_identity(submission, request.user):
# Should the user's name be displayed
hide_pii = request.user != submission.user and not show_applicant_identity(
submission, request.user
)

author = None
# Edgecase handling of not having an existing revision happens for both submitted & updated
if type == "submitted":
first_revision = submission.revisions.order_by("timestamp").first()
if first_revision and (first_author := first_revision.author):
author = first_author
elif type == "updated":
live_revision = submission.live_revision
if live_revision and (live_author := live_revision.author):
author = live_author

if author is None or hide_pii:
return _("Applicant")
elif (

# If staff was to edit an application (likely an edge case)
if (
settings.HIDE_STAFF_IDENTITY
and author.is_org_faculty
and not request.user.is_org_faculty
):
return settings.ORG_LONG_NAME # Likely an edge case but covering bases
return settings.ORG_LONG_NAME

return str(author)
6 changes: 6 additions & 0 deletions hypha/apply/funds/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
CreateProjectView,
ProgressSubmissionView,
SubmissionEditView,
UpdateAuthorView,
UpdateLeadView,
UpdateMetaTermsView,
UpdateReviewersView,
Expand Down Expand Up @@ -249,6 +250,11 @@
ReminderCreateView.as_view(),
name="create_reminder",
),
path(
"author/change/",
UpdateAuthorView.as_view(),
name="change_author",
),
path(
"translate/",
TranslateSubmissionView.as_view(),
Expand Down
59 changes: 59 additions & 0 deletions hypha/apply/funds/views/submission_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from .. import services
from ..forms import (
ProgressSubmissionForm,
UpdateAuthorForm,
UpdateMetaTermsForm,
UpdateReviewersForm,
UpdateSubmissionLeadForm,
Expand Down Expand Up @@ -641,6 +642,64 @@ def post(self, *args, **kwargs):


@method_decorator(staff_required, name="dispatch")
class UpdateAuthorView(View):
model = ApplicationSubmission
form_class = UpdateAuthorForm
context_name = "author_form"
template = "funds/modals/update_author_form.html"

def dispatch(self, request, *args, **kwargs):
self.submission = get_object_or_404(ApplicationSubmission, id=kwargs.get("pk"))
permission, reason = has_permission(
"change_author",
request.user,
object=self.submission,
raise_exception=False,
)
if not permission:
messages.warning(self.request, reason)
return HttpResponseRedirect(self.submission.get_absolute_url())
return super(UpdateAuthorView, self).dispatch(request, *args, **kwargs)

def get(self, *args, **kwargs):
author_form = self.form_class(user=self.request.user, instance=self.submission)
return render(
self.request,
self.template,
context={
"form": author_form,
"value": _("Update"),
"object": self.submission,
},
)

def post(self, *args, **kwargs):
form = self.form_class(
self.request.POST, user=self.request.user, instance=self.submission
)
old_author = self.submission.user
if form.is_valid():
form.save()

messenger(
MESSAGES.UPDATED_AUTHOR,
request=self.request,
user=self.request.user,
source=self.submission,
related=old_author,
)

messages.success(self.request, "Author updated successfully")
return HttpResponseClientRefresh()

return render(
self.request,
self.template,
context={"form": form, "value": _("Update"), "object": self.submission},
status=400,
)


class UpdateMetaTermsView(View):
template = "funds/modals/update_meta_terms_form.html"

Expand Down
Loading