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 %}
-
+ {% 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 %}
+
+ {% blocktranslate trimmed %}
+ If deleted, all of the data for this application will be permanently removed from our servers. This action cannot be undone.
+ {% endblocktranslate %}
+
{% trans "Are you sure you want to delete these users?" %}
+{% trans "Are you sure you want to delete this user?" %}
+ {% with submission_count=user|get_user_submission_count %} + {% if submission_count %} +