diff --git a/hypha/apply/activity/migrations/0089_alter_activity_user_alter_event_by_alter_event_type.py b/hypha/apply/activity/migrations/0089_alter_activity_user_alter_event_by_alter_event_type.py new file mode 100644 index 0000000000..7d23587996 --- /dev/null +++ b/hypha/apply/activity/migrations/0089_alter_activity_user_alter_event_by_alter_event_type.py @@ -0,0 +1,104 @@ +# Generated by Django 5.2.11 on 2026-02-27 16:14 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("activity", "0088_activity_deleted"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="activity", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL + ), + ), + migrations.AlterField( + model_name="event", + name="by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + 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"), + ("PARTNERS_UPDATED", "updated partners"), + ("PARTNERS_UPDATED_PARTNER", "partners updated partner"), + ("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"), + ("ANONYMIZE_SUBMISSION", "anonymized 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_SKELETON_SUBMISSION", "batch anonymized 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"), + ], + max_length=50, + verbose_name="verb", + ), + ), + ] diff --git a/hypha/apply/activity/models.py b/hypha/apply/activity/models.py index c3d8cae455..13d1c81132 100644 --- a/hypha/apply/activity/models.py +++ b/hypha/apply/activity/models.py @@ -197,7 +197,7 @@ def get_absolute_url(self): class Activity(models.Model): timestamp = models.DateTimeField() type = models.CharField(choices=ACTIVITY_TYPES.items(), max_length=30) - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) source_content_type = models.ForeignKey( ContentType, @@ -373,7 +373,7 @@ class Event(models.Model): when = models.DateTimeField(auto_now_add=True) type = models.CharField(_("verb"), choices=MESSAGES.choices, max_length=50) by = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True ) content_type = models.ForeignKey( ContentType, blank=True, null=True, on_delete=models.CASCADE diff --git a/hypha/apply/activity/options.py b/hypha/apply/activity/options.py index 8798979e86..4833012534 100644 --- a/hypha/apply/activity/options.py +++ b/hypha/apply/activity/options.py @@ -34,6 +34,7 @@ class MESSAGES(TextChoices): OPENED_SEALED = "OPENED_SEALED", _("opened sealed submission") REVIEW_OPINION = "REVIEW_OPINION", _("reviewed opinion") DELETE_SUBMISSION = "DELETE_SUBMISSION", _("deleted submission") + ANONYMIZE_SUBMISSION = "ANONYMIZE_SUBMISSION", _("anonymized submission") DELETE_REVIEW = "DELETE_REVIEW", _("deleted review") DELETE_REVIEW_OPINION = "DELETE_REVIEW_OPINION", _("deleted review opinion") CREATED_PROJECT = "CREATED_PROJECT", _("created project") @@ -66,6 +67,10 @@ class MESSAGES(TextChoices): REPORT_NOTIFY = "REPORT_NOTIFY", _("notified report") REVIEW_REMINDER = "REVIEW_REMINDER", _("reminder to review") BATCH_DELETE_SUBMISSION = "BATCH_DELETE_SUBMISSION", _("batch deleted submissions") + BATCH_SKELETON_SUBMISSION = ( + "BATCH_SKELETON_SUBMISSION", + _("batch anonymized submissions"), + ) BATCH_ARCHIVE_SUBMISSION = ( "BATCH_ARCHIVE_SUBMISSION", _("batch archive submissions"), diff --git a/hypha/apply/funds/forms.py b/hypha/apply/funds/forms.py index cab7bb1c0a..89492d0b77 100644 --- a/hypha/apply/funds/forms.py +++ b/hypha/apply/funds/forms.py @@ -5,6 +5,7 @@ import nh3 from django import forms +from django.conf import settings from django.db.models import Q from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -495,6 +496,29 @@ class Meta: fields = ["invited_user_email", "submission"] +class DeleteSubmissionForm(forms.Form): + # Alpine.js code added as an attribute to update confirmation text (ie. when a user has to type `delete` to finalize deletion) + anon_or_delete = forms.ChoiceField( + choices=[ + ("ANONYMIZE", "Anonymize submission"), + ("DELETE", "Delete submission"), + ], + widget=forms.RadioSelect( + attrs={ + "class": "text-sm radio-sm", + "@click": "mode = $el.value.toLowerCase()", + } + ), + initial="ANONYMIZE", + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not settings.SUBMISSION_SKELETONING_ENABLED: + del self.fields["anon_or_delete"] + + class EditCoApplicantForm(forms.ModelForm): role = forms.ChoiceField( choices=CoApplicantRole.choices, label="Role", required=False diff --git a/hypha/apply/funds/management/commands/drafts_cleanup.py b/hypha/apply/funds/management/commands/drafts_cleanup.py deleted file mode 100644 index c9eb48566b..0000000000 --- a/hypha/apply/funds/management/commands/drafts_cleanup.py +++ /dev/null @@ -1,89 +0,0 @@ -import argparse -from datetime import timedelta - -from django.core.management.base import BaseCommand -from django.db import transaction -from django.utils import timezone - -from hypha.apply.funds.models.submissions import ApplicationSubmission -from hypha.apply.funds.workflows import DRAFT_STATE - - -def check_not_negative(value) -> int: - """Used to validate `older_than_days` argument - - Args: - value: Argument to be validated - - Returns: - int: Valid non-negative value - - Raises: - argparse.ArgumentTypeError: if not non-negative integer - """ - try: - ivalue = int(value) - except ValueError: - ivalue = -1 - - if ivalue < 0: - raise argparse.ArgumentTypeError( - f'"{value}" is an invalid non-negative integer value' - ) - return ivalue - - -class Command(BaseCommand): - help = ( - "Delete all drafts that haven't been modified in the specified time (in days)" - ) - - def add_arguments(self, parser): - parser.add_argument( - "older_than_days", - action="store", - type=check_not_negative, - help="Time in days to delete drafts older than", - ) - parser.add_argument( - "--noinput", - "--no-input", - action="store_false", - dest="interactive", - help="Do not prompt the user for confirmation", - required=False, - ) - - @transaction.atomic - def handle(self, *args, **options): - interactive = options["interactive"] - older_than = options["older_than_days"] - - older_than_date = timezone.now() - timedelta(days=older_than) - - old_drafts = ApplicationSubmission.objects.filter( - status=DRAFT_STATE, draft_revision__timestamp__date__lte=older_than_date - ) - - draft_count = old_drafts.count() - - if not (draft_count := old_drafts.count()): - self.stdout.write( - f"No drafts older than {older_than} day{'s' if older_than > 1 else ''} exist." - ) - return - - if interactive: - confirm = input( - f"This action will permanently delete {draft_count} draft{'s' if draft_count != 1 else ''}.\nAre you sure you want to do this?\n\nType 'yes' to continue, or 'no' to cancel: " - ) - else: - confirm = "yes" - - if confirm == "yes": - old_drafts.delete() - self.stdout.write( - f"{draft_count} draft{'s' if draft_count != 1 else ''} deleted." - ) - else: - self.stdout.write("Deletion cancelled.") diff --git a/hypha/apply/funds/management/commands/submission_cleanup.py b/hypha/apply/funds/management/commands/submission_cleanup.py new file mode 100644 index 0000000000..5c2d01ce52 --- /dev/null +++ b/hypha/apply/funds/management/commands/submission_cleanup.py @@ -0,0 +1,134 @@ +import argparse +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from hypha.apply.funds.models.submissions import ApplicationSubmission +from hypha.apply.funds.workflows import DRAFT_STATE + + +def check_not_negative_or_zero(value) -> int: + """Used to validate a provided days argument + + Args: + value: Argument to be validated + + Returns: + int: value that is > 0 + + Raises: + argparse.ArgumentTypeError: if not non-negative integer or 0 + """ + try: + ivalue = int(value) + except ValueError: + ivalue = -1 + + if ivalue <= 0: + raise argparse.ArgumentTypeError( + f'"{value}" is an invalid non-negative integer value' + ) + return ivalue + + +class Command(BaseCommand): + help = "Clean up submissions by deleting or anonymizing them. NOTE: Drafts will only be deleted if specified." + + def add_arguments(self, parser): + parser.add_argument( + "--drafts", + action="store", + type=check_not_negative_or_zero, + help="Delete drafts that were created before the provided day threshold", + required=False, + ) + + parser.add_argument( + "--submissions", + action="store", + type=check_not_negative_or_zero, + help="Anonymize submissions that haven't seen activity after the provided day threshold", + required=False, + ) + + parser.add_argument( + "--delete", + action="store_false", + dest="interactive", + help="Delete submissions instead of anonymizing them. NOTE: Drafts will always be deleted, not anonymized", + ) + + parser.add_argument( + "--noinput", + "--no-input", + action="store_false", + dest="interactive", + help="Do not prompt the user for confirmation", + ) + + @transaction.atomic + def handle(self, *args, **options): + interactive = options["interactive"] + + if not options["drafts"] and not options["submissions"]: + self.stdout.write( + "Please use either --drafts [days] OR --submissions [days]" + ) + + if drafts_time_threshold := options["drafts"]: + older_than_date = timezone.now() - timedelta(days=drafts_time_threshold) + + old_drafts = ApplicationSubmission.objects.filter( + status=DRAFT_STATE, draft_revision__timestamp__date__lte=older_than_date + ) + + if draft_count := old_drafts.count(): + if interactive: + confirm = input( + f"This action will permanently delete {draft_count} draft{'s' if draft_count != 1 else ''}.\nAre you sure you want to do this?\n\nType 'yes' to continue, or 'no' to cancel: " + ) + else: + confirm = "yes" + + if confirm == "yes": + old_drafts.delete() + self.stdout.write( + f"{draft_count} draft{'s' if draft_count != 1 else ''} deleted." + ) + else: + self.stdout.write("Draft deletion cancelled.") + else: + self.stdout.write( + f"No drafts older than {drafts_time_threshold} day{'s' if drafts_time_threshold > 1 else ''} exist. Skipping!" + ) + + if sub_time_threshold := options["submissions"]: + older_than_date = timezone.now() - timedelta(days=sub_time_threshold) + + old_subs = ( + ApplicationSubmission.objects.with_latest_update() + .filter(last_update__date__lte=older_than_date) + .exclude(status=DRAFT_STATE) + ) + + if sub_count := old_subs.count(): + if interactive: + confirm = input( + f"This action will permanently anonymize {sub_count} submission{'s' if sub_count != 1 else ''}.\nAre you sure you want to do this?\n\nType 'yes' to continue, or 'no' to cancel: " + ) + else: + confirm = "yes" + + if confirm == "yes": + old_subs.delete() + self.stdout.write( + f"{sub_count} submission{'s' if sub_count != 1 else ''} anonymized." + ) + else: + self.stdout.write("Submission deletion cancelled.") + else: + self.stdout.write( + f"No submissions older than {sub_time_threshold} day{'s' if sub_time_threshold > 1 else ''} exist. Skipping!" + ) diff --git a/hypha/apply/funds/migrations/0132_alter_applicationsubmission_user_and_more.py b/hypha/apply/funds/migrations/0132_alter_applicationsubmission_user_and_more.py new file mode 100644 index 0000000000..a15c0707e0 --- /dev/null +++ b/hypha/apply/funds/migrations/0132_alter_applicationsubmission_user_and_more.py @@ -0,0 +1,177 @@ +# Generated by Django 5.2.11 on 2026-02-27 16:55 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("funds", "0131_delete_orphaned_attachments"), + ("wagtailcore", "0094_alter_page_locale"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name="applicationsubmission", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.CreateModel( + name="ApplicationSubmissionSkeleton", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("value", models.FloatField(null=True)), + ( + "status", + models.CharField( + choices=[ + ("accepted", "Accepted"), + ("almost", "Accepted but additional info required"), + ("com_accepted", "Accepted"), + ("com_almost", "Accepted but additional info required"), + ("com_community_review", "Community Review"), + ("com_determination", "Ready for Determination"), + ("com_external_review", "External Review"), + ("com_internal_review", "Internal Review"), + ("com_more_info", "More information required"), + ("com_open_call", "Open Call (public)"), + ( + "com_post_external_review_discussion", + "Ready For Discussion", + ), + ( + "com_post_external_review_more_info", + "More information required", + ), + ("com_post_review_discussion", "Ready For Discussion"), + ("com_post_review_more_info", "More information required"), + ("com_rejected", "Dismissed"), + ( + "concept_determination", + "Ready for Preliminary Determination", + ), + ("concept_internal_review", "Internal Review"), + ("concept_more_info", "More information required"), + ("concept_rejected", "Dismissed"), + ("concept_review_discussion", "Ready For Discussion"), + ("concept_review_more_info", "More information required"), + ("determination", "Ready for Determination"), + ("draft", "Draft"), + ("draft_proposal", "Invited for Proposal"), + ("ext_accepted", "Accepted"), + ("ext_almost", "Accepted but additional info required"), + ("ext_determination", "Ready for Determination"), + ("ext_external_review", "External Review"), + ("ext_internal_review", "Internal Review"), + ("ext_more_info", "More information required"), + ( + "ext_post_external_review_discussion", + "Ready For Discussion", + ), + ( + "ext_post_external_review_more_info", + "More information required", + ), + ("ext_post_review_discussion", "Ready For Discussion"), + ("ext_post_review_more_info", "More information required"), + ("ext_rejected", "Dismissed"), + ("external_review", "External Review"), + ("in_discussion", "Need screening"), + ("internal_review", "Internal Review"), + ("invited_to_proposal", "Concept Accepted"), + ("more_info", "More information required"), + ("post_external_review_discussion", "Ready For Discussion"), + ( + "post_external_review_more_info", + "More information required", + ), + ("post_proposal_review_discussion", "Ready For Discussion"), + ( + "post_proposal_review_more_info", + "More information required", + ), + ("post_review_discussion", "Ready For Discussion"), + ("post_review_more_info", "More information required"), + ("proposal_accepted", "Accepted"), + ( + "proposal_almost", + "Accepted but additional info required", + ), + ("proposal_determination", "Ready for Final Determination"), + ("proposal_discussion", "Proposal Received"), + ("proposal_internal_review", "Internal Review"), + ("proposal_more_info", "More information required"), + ("proposal_rejected", "Dismissed"), + ("rejected", "Dismissed"), + ("same_accepted", "Accepted"), + ("same_almost", "Accepted but additional info required"), + ("same_determination", "Ready for Determination"), + ("same_internal_review", "Review"), + ("same_more_info", "More information required"), + ("same_post_review_discussion", "Ready For Discussion"), + ("same_post_review_more_info", "More information required"), + ("same_rejected", "Dismissed"), + ], + default="in_discussion", + max_length=100, + ), + ), + ( + "category", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(), null=True, size=None + ), + ), + ("submit_time", models.DateTimeField(verbose_name="submit time")), + ( + "page", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="wagtailcore.page", + ), + ), + ( + "round", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="skeleton_submissions", + to="wagtailcore.page", + ), + ), + ( + "screening_status", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="skeleton_submissions", + to="funds.screeningstatus", + ), + ), + ( + "user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/hypha/apply/funds/models/__init__.py b/hypha/apply/funds/models/__init__.py index 8a4bba9e17..1985175984 100644 --- a/hypha/apply/funds/models/__init__.py +++ b/hypha/apply/funds/models/__init__.py @@ -14,13 +14,14 @@ from .reminders import Reminder from .reviewer_role import ReviewerRole, ReviewerSettings from .screening import ScreeningStatus -from .submissions import ApplicationSubmission +from .submissions import ApplicationSubmission, ApplicationSubmissionSkeleton __all__ = [ "ApplicationForm", "ApplicationRevision", "ApplicationSettings", "ApplicationSubmission", + "ApplicationSubmissionSkeleton", "AssignedReviewers", "Reminder", "ReviewerRole", diff --git a/hypha/apply/funds/models/submissions.py b/hypha/apply/funds/models/submissions.py index 01112ec0f2..3e88ebb7d1 100644 --- a/hypha/apply/funds/models/submissions.py +++ b/hypha/apply/funds/models/submissions.py @@ -1,12 +1,13 @@ import operator from functools import partialmethod, reduce -from typing import Optional, Self +from typing import Any, Dict, Optional, Self from django.apps import apps from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import AbstractBaseUser, AnonymousUser, Group from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVector, SearchVectorField from django.core.exceptions import PermissionDenied @@ -486,7 +487,7 @@ class ApplicationSubmission( related_query_name="submission", ) user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True ) search_data = models.TextField() search_document = SearchVectorField(null=True) @@ -1081,3 +1082,134 @@ def log_status_update(self, descriptor, source, target, **kwargs): user=by, source=instance, ) + + +class ApplicationSubmissionSkeletonQueryset(models.QuerySet): + def current_accepted(self): + # Applications which have the current stage active (have not been progressed) + return self.filter(status__in=PHASES_MAPPING["accepted"]["statuses"]).current() + + def value(self): + return self.aggregate(Count("value"), Avg("value"), Sum("value")) + + +class ApplicationSubmissionSkeleton(models.Model): + """The class to be used for stripping PII from an application and making it minimal""" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True + ) + + value = models.FloatField(null=True) + + status = models.CharField( + max_length=100, + choices=get_all_possible_states(), + default=INITIAL_STATE, + ) + + category = ArrayField(models.CharField(), null=True) + + page = models.ForeignKey("wagtailcore.Page", on_delete=models.PROTECT) + round = models.ForeignKey( + "wagtailcore.Page", + on_delete=models.PROTECT, + related_name="skeleton_submissions", + null=True, + ) + + submit_time = models.DateTimeField( + verbose_name=_("submit time"), auto_now_add=False + ) + + screening_status = models.ForeignKey( + "funds.ScreeningStatus", + related_name="skeleton_submissions", + null=True, + blank=True, + on_delete=models.PROTECT, + ) + + @classmethod + def from_dict( + cls, dict_submission: Dict[str, Any], save_user: bool = False + ) -> Self | None: + """Creates an ApplicationSubmissionSkeleton from a given dictionary + + Attempts to pull values from keys of `form_data`, `page_id`, `round_id`, `status`, `submit_time` and optionally (save_user=True) `user_id`. + + For convenience, it if values of previous keys are none will also try prepending `applicationsubmission__`. + ie. if `dict_submission.get("page_id") = None`, `dict_submission.get("applicationsubmission__page_id")` will be tried + + Args: + dict_submission: The dictionary containing the expected keys to create a ApplicationSubmissionSkeleton from + save_user: bool to save the provided user ID in `dict_submission` to the ApplicationSubmissionSkeleton + + Returns: Populated ApplicationSubmissionSkeleton if successful, None if not + """ + # If all values of the application dictionary are none, don't create a new skeleton app + if all(x is None for x in dict_submission.values()): + return None + + user = None + if save_user: + user = dict_submission.get("user_id") or dict_submission.get( + "applicationsubmission__user_id" + ) + + value = None + if form_data := dict_submission.get("form_data") or dict_submission.get( + "applicationsubmission__form_data" + ): + value = form_data.get("value") + + skeleton = ApplicationSubmissionSkeleton.objects.create( + user_id=user, + page_id=dict_submission.get("page_id") + or dict_submission.get("applicationsubmission__page_id"), + round_id=dict_submission.get("round_id") + or dict_submission.get("applicationsubmission__round_id"), + value=value, + status=dict_submission.get("status") + or dict_submission.get("applicationsubmission__status"), + submit_time=dict_submission.get("submit_time"), + screening_status_id=dict_submission.get("screening_statuses") + or dict_submission.get("applicationsubmission__screening_statuses"), + ) + + return skeleton + + @classmethod + def from_submission( + cls, submission: ApplicationSubmission, save_user: bool = False + ) -> Self: + """Creates an ApplicationSubmissionSkeleton from a given ApplicationSubmission object + + Note that this will NOT delete the provided ApplicationSubmission, just creates a ApplicationSubmissionSkeleton. + + Args: + submission: The ApplicationSubmission to create a ApplicationSubmissionSkeleton from + save_user: bool to save the user associated on the ApplicationSubmission to the ApplicationSubmissionSkeleton + + Returns: Populated ApplicationSubmissionSkeleton + """ + + user = None + if save_user: + user = submission.user + + skeleton = ApplicationSubmissionSkeleton.objects.create( + user=user, + page=submission.page, + round=submission.round, + value=submission.form_data.get("value", None), + status=submission.status, + submit_time=submission.submit_time, + screening_status=submission.get_current_screening_status(), + ) + + # TODO: Handle categories here + + skeleton.save() + + return skeleton diff --git a/hypha/apply/funds/services.py b/hypha/apply/funds/services.py index 0e23ffbeff..cbd7f074a5 100644 --- a/hypha/apply/funds/services.py +++ b/hypha/apply/funds/services.py @@ -16,11 +16,13 @@ from django.db.models.functions import Coalesce from django.http import HttpRequest from django.utils.translation import gettext as _ +from django.utils.translation import ngettext from hypha.apply.activity.messaging import MESSAGES, messenger from hypha.apply.activity.models import Activity, Event from hypha.apply.funds.models.assigned_reviewers import AssignedReviewers from hypha.apply.funds.workflows import INITIAL_STATE +from hypha.apply.funds.workflows.constants import DRAFT_STATE from hypha.apply.review.options import DISAGREE, MAYBE @@ -59,8 +61,8 @@ def bulk_delete_submissions( """Permanently deletes submissions and generate action log. Args: - submissions: queryset of submissions to archive - user: user who is archiving the submissions + submissions: queryset of submissions to delete + user: user who is deleting the submissions request: django request object Returns: @@ -87,6 +89,72 @@ def bulk_delete_submissions( return submissions +def bulk_covert_to_skeleton_submissions( + submissions: QuerySet, user, request: HttpRequest +) -> QuerySet: + """Converts submissions to skeleton submissions, deletes draft submissions and generates action log. + + Args: + submissions: queryset of submissions to convert to skeleton applications + user: user who is anonymizing the submissions + request: django request object + + Returns: + QuerySet of submissions that have been archived + """ + ApplicationSubmission = apps.get_model("funds", "ApplicationSubmission") + ApplicationSubmissionSkeleton = apps.get_model( + "funds", "ApplicationSubmissionSkeleton" + ) + + # delete NEW_SUBMISSION events for all submissions + submission_dict_list = submissions.values( + "id", + "form_data", + "page_id", + "round_id", + "status", + "submit_time", + "user_id", + "screening_statuses", + ) + + submission_ids = [x["id"] for x in submission_dict_list] + + Event.objects.filter( + type=MESSAGES.NEW_SUBMISSION, object_id__in=submission_ids + ).delete() + + skeletons = [] + for submission_dict in submission_dict_list: + if submission_dict["status"] != DRAFT_STATE: + skeletons.append( + ApplicationSubmissionSkeleton.from_dict(submission_dict, save_user=True) + ) + + # delete submissions + submissions = ApplicationSubmission.objects.filter(id__in=submission_ids) + submissions.delete() + + messages.success( + request, + ngettext( + "{sub_number} submission anonymized", + "{sub_number} submissions anonymized", + len(submission_ids), + ).format(sub_number=len(submission_ids)), + ) + + messenger( + MESSAGES.BATCH_SKELETON_SUBMISSION, + request=request, + user=user, + sources=submissions, + ) + + return skeletons + + def bulk_update_lead( submissions: QuerySet, user, request: HttpRequest, lead ) -> QuerySet: diff --git a/hypha/apply/funds/templates/funds/applicationsubmission_confirm_delete.html b/hypha/apply/funds/templates/funds/applicationsubmission_confirm_delete.html index cc40d9484f..22ca126583 100644 --- a/hypha/apply/funds/templates/funds/applicationsubmission_confirm_delete.html +++ b/hypha/apply/funds/templates/funds/applicationsubmission_confirm_delete.html @@ -1,11 +1,22 @@ {% load i18n %} -

- {% blocktranslate trimmed %} - Are you sure you want to delete this application? All of your data for - this application will be permanently removed from our servers forever. - This action cannot be undone. - {% endblocktranslate %} -

+ {% if SUBMISSION_SKELETONING_ENABLED %} +

+ {% blocktranslate trimmed %} + If deleted, all of the data for this application will be permanently removed from our servers. Alternatively, the application can be anonymized resulting in identifying user information being removed while statistical information will be preserved. Neither action can be undone. + {% endblocktranslate %} +

+
+ {% for field in form.visible_fields %} + {{field}} + {% endfor %} +
+ {% else %} +

+ {% blocktranslate trimmed %} + If deleted, all of the data for this application will be permanently removed from our servers. This action cannot be undone. + {% endblocktranslate %} +

+ {% endif %}
diff --git a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html index 1149290e39..4073ed3a15 100644 --- a/hypha/apply/funds/templates/funds/applicationsubmission_detail.html +++ b/hypha/apply/funds/templates/funds/applicationsubmission_detail.html @@ -120,7 +120,7 @@
{% blocktrans with stage=object.previous.stage %}Your {{ stage }} applicatio href="{% url 'funds:submissions:delete' object.id %}" hx-get="{% url 'funds:submissions:delete' object.id %}" hx-target="#htmx-modal" - aria-label="{% trans "Delete Submission" %}" + aria-label="{% trans 'Delete Submission' %}" > {% heroicon_micro "trash" class="size-4" aria_hidden=true %} {% trans "Delete" %} diff --git a/hypha/apply/funds/templates/funds/includes/admin_primary_actions.html b/hypha/apply/funds/templates/funds/includes/admin_primary_actions.html index bc377fbf4e..c80a18e730 100644 --- a/hypha/apply/funds/templates/funds/includes/admin_primary_actions.html +++ b/hypha/apply/funds/templates/funds/includes/admin_primary_actions.html @@ -1,5 +1,6 @@ {% load i18n %} {% load heroicons primaryactions_tags translate_tags %} +{% load can from permission_tags %}

{% trans "Actions to take" %}

diff --git a/hypha/apply/funds/templates/funds/includes/modal_skeleton_submission_confirm.html b/hypha/apply/funds/templates/funds/includes/modal_skeleton_submission_confirm.html new file mode 100644 index 0000000000..5652c1b5bd --- /dev/null +++ b/hypha/apply/funds/templates/funds/includes/modal_skeleton_submission_confirm.html @@ -0,0 +1,33 @@ +{% load i18n static heroicons %} + +
+ {% csrf_token %} + +
+
+ {% heroicon_outline "exclamation-triangle" class="w-6 h-6 text-error" stroke_width="1.5" aria_hidden="true" %} +
+
+ +
+

+ {% blocktrans %} + Are you sure you want to anonymize this submission? Anonymizing submissions makes them inacccesible except via statistics in the "Results" view. + {% endblocktrans %} +

+
+
+
+ +
+ + +
+
diff --git a/hypha/apply/funds/templates/funds/submissions_result.html b/hypha/apply/funds/templates/funds/submissions_result.html index 012bf1001d..2876ed081d 100644 --- a/hypha/apply/funds/templates/funds/submissions_result.html +++ b/hypha/apply/funds/templates/funds/submissions_result.html @@ -48,7 +48,6 @@

{% trans "Submissions" %} {{ submission_count }} -
diff --git a/hypha/apply/funds/templates/submissions/all.html b/hypha/apply/funds/templates/submissions/all.html index a594daf4a4..0d2e770fd5 100644 --- a/hypha/apply/funds/templates/submissions/all.html +++ b/hypha/apply/funds/templates/submissions/all.html @@ -500,6 +500,17 @@ {% heroicon_outline "trash" aria_hidden="true" size=14 class="stroke-base-content/80" %} {% trans "Delete" %} + {% if SUBMISSION_SKELETONING_ENABLED %} + + {% endif %} {% endif %} diff --git a/hypha/apply/funds/tests/test_models.py b/hypha/apply/funds/tests/test_models.py index fd3d4a9035..0163600e5f 100644 --- a/hypha/apply/funds/tests/test_models.py +++ b/hypha/apply/funds/tests/test_models.py @@ -12,7 +12,13 @@ from django.urls import reverse from hypha.apply.funds.blocks import EmailBlock, FullNameBlock -from hypha.apply.funds.models import ApplicationSubmission, AssignedReviewers, Reminder +from hypha.apply.funds.models import ( + ApplicationSubmission, + ApplicationSubmissionSkeleton, + AssignedReviewers, + Reminder, +) +from hypha.apply.funds.tests.factories.models import ScreeningStatusFactory from hypha.apply.funds.workflows.constants import DRAFT_STATE from hypha.apply.funds.workflows.registry import Request from hypha.apply.review.options import AGREE, MAYBE, NO @@ -542,6 +548,164 @@ def test_in_final_stage(self): self.assertTrue(submission.in_final_stage) +class TestApplicationSubmissionSkeleton(TestCase): + def test_create_from_submission_no_user(self): + screening_outcome = ScreeningStatusFactory() + screening_outcome.yes = True + screening_outcome.default = True + screening_outcome.save() + + submission = ApplicationSubmissionFactory() + submission.screening_statuses.add(screening_outcome) + submission.save() + + skeleton = ApplicationSubmissionSkeleton.from_submission(submission) + submission_values_dict = { + "value": submission.form_data["value"], + "page": submission.page.id, + "status": submission.status, + "round": submission.round.id, + "submit_time": submission.submit_time, + "screening_status": submission.get_current_screening_status(), + } + skeleton_values_dict = { + "value": skeleton.value, + "page": skeleton.page.id, + "status": skeleton.status, + "round": skeleton.round.id, + "submit_time": skeleton.submit_time, + "screening_status": skeleton.screening_status, + } + self.assertDictEqual(submission_values_dict, skeleton_values_dict) + self.assertIsNone(skeleton.user) + + def test_create_from_submission_with_user(self): + screening_outcome = ScreeningStatusFactory() + screening_outcome.yes = True + screening_outcome.default = True + screening_outcome.save() + + submission = ApplicationSubmissionFactory() + submission.screening_statuses.add(screening_outcome) + submission.save() + + skeleton = ApplicationSubmissionSkeleton.from_submission( + submission, save_user=True + ) + submission_values_dict = { + "value": submission.form_data["value"], + "page": submission.page.id, + "status": submission.status, + "round": submission.round.id, + "submit_time": submission.submit_time, + "screening_status": submission.get_current_screening_status(), + "user": submission.user, + } + skeleton_values_dict = { + "value": skeleton.value, + "page": skeleton.page.id, + "status": skeleton.status, + "round": skeleton.round.id, + "submit_time": skeleton.submit_time, + "screening_status": skeleton.screening_status, + "user": skeleton.user, + } + self.assertDictEqual(submission_values_dict, skeleton_values_dict) + + def test_create_from_dict_no_user(self): + screening_outcome = ScreeningStatusFactory() + screening_outcome.yes = True + screening_outcome.default = True + screening_outcome.save() + + submission = ApplicationSubmissionFactory() + submission.screening_statuses.add(screening_outcome) + submission.save() + + submission_dict = dict( + ApplicationSubmission.objects.filter(id=submission.id) + .values( + "form_data", + "page_id", + "round_id", + "status", + "submit_time", + "screening_statuses", + "user_id", + ) + .first() + ) + + skeleton = ApplicationSubmissionSkeleton.from_dict( + submission_dict, save_user=False + ) + submission_values_dict = { + "value": submission.form_data["value"], + "page": submission.page.id, + "status": submission.status, + "round": submission.round.id, + "submit_time": submission.submit_time, + "screening_status": submission.get_current_screening_status(), + } + skeleton_values_dict = { + "value": skeleton.value, + "page": skeleton.page.id, + "status": skeleton.status, + "round": skeleton.round.id, + "submit_time": skeleton.submit_time, + "screening_status": skeleton.screening_status, + } + self.assertDictEqual(submission_values_dict, skeleton_values_dict) + self.assertIsNone(skeleton.user) + + def test_create_from_dict_with_user(self): + screening_outcome = ScreeningStatusFactory() + screening_outcome.yes = True + screening_outcome.default = True + screening_outcome.save() + + submission = ApplicationSubmissionFactory() + submission.screening_statuses.add(screening_outcome) + submission.save() + + submission_dict = dict( + ApplicationSubmission.objects.filter(id=submission.id) + .values( + "form_data", + "page_id", + "round_id", + "status", + "submit_time", + "screening_statuses", + "user_id", + ) + .first() + ) + + skeleton = ApplicationSubmissionSkeleton.from_dict( + submission_dict, save_user=True + ) + submission_values_dict = { + "value": submission.form_data["value"], + "page": submission.page.id, + "status": submission.status, + "round": submission.round.id, + "submit_time": submission.submit_time, + "screening_status": submission.get_current_screening_status(), + "user": submission.user, + } + skeleton_values_dict = { + "value": skeleton.value, + "page": skeleton.page.id, + "status": skeleton.status, + "round": skeleton.round.id, + "submit_time": skeleton.submit_time, + "screening_status": skeleton.screening_status, + "user": skeleton.user, + } + self.assertDictEqual(submission_values_dict, skeleton_values_dict) + + class TestSubmissionRenderMethods(TestCase): def test_named_blocks_not_included_in_answers(self): submission = ApplicationSubmissionFactory() diff --git a/hypha/apply/funds/tests/test_services.py b/hypha/apply/funds/tests/test_services.py new file mode 100644 index 0000000000..3096ff7efb --- /dev/null +++ b/hypha/apply/funds/tests/test_services.py @@ -0,0 +1,43 @@ +from django.contrib.messages.storage.fallback import FallbackStorage +from django.contrib.sessions.middleware import SessionMiddleware +from django.test import RequestFactory, TestCase + +from hypha.apply.funds.models.submissions import ApplicationSubmission +from hypha.apply.funds.services import bulk_covert_to_skeleton_submissions +from hypha.apply.funds.tests.factories import ApplicationSubmissionFactory +from hypha.apply.users.tests.factories import StaffFactory + + +class BulkActions(TestCase): + def dummy_request(self, path): + request = RequestFactory().get(path) + middleware = SessionMiddleware(lambda x: x) + middleware.process_request(request) + request.session.save() + request.user = StaffFactory() + request._messages = FallbackStorage(request) + return request + + def test_bulk_skeleton_submissions(self): + self.maxDiff = None + request = self.dummy_request("/apply/submissions/all/bulk_skeleton/") + user = StaffFactory() + submissions = ApplicationSubmissionFactory.create_batch(4) + submission_values = [ + { + "id": submission.id, + "value": submission.form_data["value"], + "page": submission.page.id, + "status": submission.status, + "round": submission.round.id, + "submit_time": submission.submit_time, + "screening_status": submission.get_current_screening_status(), + } + for submission in submissions + ] + submission_qs = ApplicationSubmission.objects.filter( + id__in=[submission["id"] for submission in submission_values] + ) + + skeletons = bulk_covert_to_skeleton_submissions(submission_qs, user, request) + self.assertEqual(len(submission_values), len(skeletons)) diff --git a/hypha/apply/funds/tests/views/test_submission_delete.py b/hypha/apply/funds/tests/views/test_submission_delete.py index 6b8119d8e0..039475fc35 100644 --- a/hypha/apply/funds/tests/views/test_submission_delete.py +++ b/hypha/apply/funds/tests/views/test_submission_delete.py @@ -1,5 +1,7 @@ +from django.test import override_settings from django.urls import reverse +from hypha.apply.funds.models.submissions import ApplicationSubmissionSkeleton from hypha.apply.funds.tests.factories.models import ApplicationSubmissionFactory from hypha.apply.funds.workflows import DRAFT_STATE from hypha.apply.users.tests.factories import AdminFactory, ApplicantFactory @@ -26,8 +28,9 @@ def test_submission_delete_by_admin(db, client): assert res.status_code == 200 assert " + {% csrf_token %} + {% if SUBMISSION_SKELETONING_ENABLED and submission_count %} +
+
+ + +
+
+ + +
+
+ {% endif %} + + \ No newline at end of file diff --git a/hypha/apply/users/templates/wagtailusers/bulk_actions/confirm_bulk_delete.html b/hypha/apply/users/templates/wagtailusers/bulk_actions/confirm_bulk_delete.html new file mode 100644 index 0000000000..71ecdc5f3f --- /dev/null +++ b/hypha/apply/users/templates/wagtailusers/bulk_actions/confirm_bulk_delete.html @@ -0,0 +1,41 @@ +{% extends 'wagtailusers/bulk_actions/confirm_bulk_delete.html' %} +{% load i18n static wagtailusers_tags wagtailadmin_tags users_tags %} + +{% block items_with_access %} + {% if items %} +

{% trans "Are you sure you want to delete these users?" %}

+ + {% with submission_count=items|get_user_submission_count %} + {% if submission_count %} +
+ {% icon name="warning" classname="" title="warning icon" %} + + {% if SUBMISSION_SKELETONING_ENABLED %} + {% blocktrans %}This will result in {{submission_count}} associated submissions being deleted or anonymized!{% endblocktrans %} + {% else %} + {% blocktrans %}This will result in {{submission_count}} associated submissions being deleted!{% endblocktrans %} + {% endif %} + +
+ {% endif %} + {% endwith %} + {% endif %} +{% endblock items_with_access %} + +{% block form_section %} + {% if items %} + {% include "includes/delete_user_submissions_form.html" with post_url=submit_url submission_count=items|get_user_submission_count %} + {% else %} + {% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %} + {% endif %} +{% endblock form_section %} + +{% block extra_css %} + +{% endblock %} \ No newline at end of file diff --git a/hypha/apply/users/templates/wagtailusers/users/confirm_delete.html b/hypha/apply/users/templates/wagtailusers/users/confirm_delete.html new file mode 100644 index 0000000000..e46758bec3 --- /dev/null +++ b/hypha/apply/users/templates/wagtailusers/users/confirm_delete.html @@ -0,0 +1,27 @@ +{% extends "wagtailadmin/generic/confirm_delete.html" %} +{% load i18n static users_tags wagtailadmin_tags %} + +{% block main_content %} +

{% trans "Are you sure you want to delete this user?" %}

+ {% with submission_count=user|get_user_submission_count %} + {% if submission_count %} +
+ {% icon name="warning" classname="" title="warning icon" %} + + {% if SUBMISSION_SKELETONING_ENABLED %} + {% blocktrans %}This will result in the user's {{submission_count}} associated submissions being deleted or anonymized!{% endblocktrans %} + {% else %} + {% blocktrans %}This will result in the user's {{submission_count}} associated submissions being deleted!{% endblocktrans %} + {% endif %} + +
+ {% endif %} + {% url 'wagtailusers_users:delete' user.pk as post_url %} + {% include "includes/delete_user_submission_form.html" with post_url=post_url %} + {% endwith %} +{% endblock %} + + +{% block extra_css %} + +{% endblock %} diff --git a/hypha/apply/users/templatetags/users_tags.py b/hypha/apply/users/templatetags/users_tags.py index ecf44478bc..03109e2115 100644 --- a/hypha/apply/users/templatetags/users_tags.py +++ b/hypha/apply/users/templatetags/users_tags.py @@ -1,8 +1,13 @@ +from collections import abc +from typing import Dict, Iterable, Literal + from django import template +from django.apps import apps from django.utils.safestring import SafeString from django_otp import devices_for_user from hypha.apply.users.identicon import get_identicon +from hypha.apply.users.models import User from ..utils import can_use_oauth_check @@ -47,6 +52,39 @@ def tokens_text(token_set): return tokens_string -@register.simple_tag() +@register.simple_tag def user_image(identifier: str, size=20): return SafeString(get_identicon(identifier, size=size)) + + +@register.filter +def get_user_submission_count( + user: User | Iterable[User | Dict[Literal["item"], User]], +) -> int: + """Get the number of submissions associated to either one user or a list of users + + Also handles Wagtail's user deletion view where a list of dicts containing {"item": } gets passed + + Args: + user: A user object OR an iterable containing either User objects/dictionaries of {"item": } + + Returns: + Count of all submissions associated to the user(s) + + Raises: + TypeError: when none of the previously specified types are provided in `user` + """ + ApplicationSubmission = apps.get_model("funds", "ApplicationSubmission") + if isinstance(user, User): + return ApplicationSubmission.objects.filter(user=user).count() + elif isinstance(user, abc.Iterable): + if all(isinstance(x, User) for x in user): + return ApplicationSubmission.objects.filter(user__in=user).count() + elif (items_extract := [x.get("item") for x in user]) and all( + isinstance(x, User) for x in items_extract + ): + return ApplicationSubmission.objects.filter(user__in=items_extract).count() + + raise TypeError( + "User instance or iterable of users not provided to get_user_submission_count!" + ) diff --git a/hypha/apply/users/wagtail_hooks.py b/hypha/apply/users/wagtail_hooks.py index db12f370ab..9f81f48f1e 100644 --- a/hypha/apply/users/wagtail_hooks.py +++ b/hypha/apply/users/wagtail_hooks.py @@ -1,7 +1,10 @@ +from django.apps import apps +from django.conf import settings from wagtail import hooks from wagtail.models import Site from hypha.apply.activity.messaging import MESSAGES, messenger +from hypha.apply.users.models import User from .utils import send_activation_email, update_is_staff @@ -35,6 +38,67 @@ def notify_after_edit_user(request, user): ) +@hooks.register("before_delete_user") +def anonymize_delete_user_submissions(request, user): + if ( + settings.SUBMISSION_SKELETONING_ENABLED + and request.method == "POST" + and request.POST.get("handle_subs") == "anon" + ): + ApplicationSubmissionSkeleton = apps.get_model( + "funds", "ApplicationSubmissionSkeleton" + ) + + submissions_to_skeleton = list( + user.applicationsubmission_set.exclude_draft().values( + "form_data", + "page_id", + "round_id", + "status", + "submit_time", + "screening_statuses", + ) + ) + + for submission_dict in submissions_to_skeleton: + ApplicationSubmissionSkeleton.from_dict(submission_dict) + + +@hooks.register("before_bulk_action") +def bulk_anonymize_delete_user_submissions( + request, action_type, objects, action_class_instance +): + # Handling for bulk deletion of users when anonymization is selected + if ( + settings.SUBMISSION_SKELETONING_ENABLED + and action_type == "delete" + and request.method == "POST" + and request.POST.get("handle_subs") == "anon" + and all(isinstance(x, User) for x in objects) + ): + ApplicationSubmission = apps.get_model("funds", "ApplicationSubmission") + + ApplicationSubmissionSkeleton = apps.get_model( + "funds", "ApplicationSubmissionSkeleton" + ) + + submissions_to_skeleton = list( + ApplicationSubmission.objects.filter(user__in=objects) + .exclude_draft() + .values( + "form_data", + "page_id", + "round_id", + "status", + "submit_time", + "screening_statuses", + ) + ) + + for submission_dict in submissions_to_skeleton: + ApplicationSubmissionSkeleton.from_dict(submission_dict) + + # Handle setting of `is_staff` after updating a user hooks.register("after_create_user", update_is_staff) hooks.register("after_edit_user", update_is_staff) diff --git a/hypha/core/context_processors.py b/hypha/core/context_processors.py index 5300a707be..479d040655 100644 --- a/hypha/core/context_processors.py +++ b/hypha/core/context_processors.py @@ -23,4 +23,5 @@ def global_vars(request): "SUBMISSIONS_TABLE_EXCLUDED_FIELDS": settings.SUBMISSIONS_TABLE_EXCLUDED_FIELDS, "HIJACK_ENABLE": settings.HIJACK_ENABLE, "LANGUAGE_SWITCHER": settings.LANGUAGE_SWITCHER, + "SUBMISSION_SKELETONING_ENABLED": settings.SUBMISSION_SKELETONING_ENABLED, } diff --git a/hypha/settings/base.py b/hypha/settings/base.py index e4feb55237..95ea95c5a7 100644 --- a/hypha/settings/base.py +++ b/hypha/settings/base.py @@ -90,6 +90,8 @@ # Enable Projects in Hypha. Contracts and invoicing that comes after a submission is approved. PROJECTS_ENABLED = env.bool("PROJECTS_ENABLED", False) +SUBMISSION_SKELETONING_ENABLED = env.bool("SUBMISSION_SKELETONING_ENABLED", False) + # Auto create projects for approved applications. PROJECTS_AUTO_CREATE = env.bool("PROJECTS_AUTO_CREATE", False) diff --git a/hypha/templates/cotton/modal/confirm.html b/hypha/templates/cotton/modal/confirm.html index 682f865974..72043313f0 100644 --- a/hypha/templates/cotton/modal/confirm.html +++ b/hypha/templates/cotton/modal/confirm.html @@ -4,7 +4,7 @@ class="px-4 pt-5 pb-4 sm:p-6" action="{{ request.path }}" method="post" - x-data="{userConfirmationInput: ''}" + x-data="{userConfirmationInput: '', delConfirmText: `{% trans 'delete' %}`, anonConfirmText: `{% trans 'anonymize' %}`, mode: 'delete'}" > {% csrf_token %} @@ -21,7 +21,7 @@