Hide in-progress deletions of Project & Issue from the UI

I've done a full grep on Issue.objects, Project.objects and get_object_or_404
equivelents, and applying some common sense. The goal: avoid having
confusing/half-broken pages in the UI.

On index-usage: I've decided not to update the indexes. The assumption is:
`is_deleted` items will be a tiny minority of items in general, making the
cost/benefit analysis not turn out favorably (just scanning them out as a final
step is more efficient).  Also: sqlite is able to use the correct index without
adding a special one, proof:

```
EXPLAIN QUERY PLAN SELECT [..] WHERE ("issues_issue"."project_id" = 1 AND "issues_issue"."is_muted" = (0) AND "issues_issue"."is_resolved" = (0)) ORDER BY "issues_issue"."last_seen" DESC LIMIT 250;
QUERY PLAN
`--SEARCH issues_issue USING INDEX issue_list_open (project_id=? AND is_resolved=? AND is_muted=?)

EXPLAIN QUERY PLAN SELECT [..] WHERE ("issues_issue"."project_id" = 1 AND "issues_issue"."is_muted" = (0) AND "issues_issue"."is_resolved" = (0) AND "issues_issue"."is_deleted" = 0) ORDER BY "issues_issue"."last_seen" DESC LIMIT 250;
QUERY PLAN
`--SEARCH issues_issue USING INDEX issue_list_open (project_id=? AND is_resolved=? AND is_muted=?)
```

See #139 for the 0/1 notation in the above.

(Project-indexes: not an issue, the scale is "below relevance for indexes")
This commit is contained in:
Klaas van Schelven
2025-07-07 09:29:22 +02:00
parent 308034aadd
commit 7b340fd8ff
4 changed files with 24 additions and 18 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ def issue_membership_required(function):
if "issue_pk" not in kwargs:
raise TypeError("issue_pk must be passed as a keyword argument")
issue_pk = kwargs.pop("issue_pk")
issue = get_object_or_404(Issue, pk=issue_pk)
issue = get_object_or_404(Issue, pk=issue_pk, is_deleted=False)
kwargs["issue"] = issue
if request.user.is_superuser:
return function(request, *args, **kwargs)
+7 -4
View File
@@ -131,7 +131,7 @@ class BaseIngestAPIView(View):
@classmethod
def get_project(cls, project_pk, sentry_key):
try:
return Project.objects.get(pk=project_pk, sentry_key=sentry_key)
return Project.objects.get(pk=project_pk, sentry_key=sentry_key, is_deleted=False)
except Project.DoesNotExist:
# We don't distinguish between "project not found" and "key incorrect"; there's no real value in that from
# the user perspective (they deal in dsns). Additional advantage: no need to do constant-time-comp on
@@ -251,9 +251,12 @@ class BaseIngestAPIView(View):
ingested_at = parse_timestamp(event_metadata["ingested_at"])
digested_at = datetime.now(timezone.utc) if digested_at is None else digested_at # explicit passing: test only
project = Project.objects.get(pk=event_metadata["project_id"])
if project.is_deleted:
return # don't process events for deleted projects
try:
project = Project.objects.get(pk=event_metadata["project_id"], is_deleted=False)
except Project.DoesNotExist:
# we may get here if the project was deleted after the event was ingested, but before it was digested
# (covers both "deletion in progress (is_deleted=True)" and "fully deleted").
return
if not cls.count_project_periods_and_act_on_it(project, digested_at):
return # if over-quota: just return (any cleanup is done calling-side)
+1 -1
View File
@@ -308,7 +308,7 @@ def _issue_list_pt_2(request, project, state_filter, unapplied_issue_ids):
}
issue_list = d_state_filter[state_filter](
Issue.objects.filter(project=project)
Issue.objects.filter(project=project, is_deleted=False)
).order_by("-last_seen")
if request.GET.get("q"):
+15 -12
View File
@@ -35,21 +35,24 @@ def project_list(request, ownership_filter=None):
my_memberships = ProjectMembership.objects.filter(user=request.user)
my_team_memberships = TeamMembership.objects.filter(user=request.user)
my_projects = Project.objects.filter(projectmembership__in=my_memberships).order_by('name').distinct()
my_projects = Project.objects.filter(
projectmembership__in=my_memberships, is_deleted=False).order_by('name').distinct()
my_teams_projects = \
Project.objects \
.filter(team__teammembership__in=my_team_memberships) \
.filter(team__teammembership__in=my_team_memberships, is_deleted=False) \
.exclude(projectmembership__in=my_memberships) \
.order_by('name').distinct()
if request.user.is_superuser:
# superusers can see all project, even hidden ones
other_projects = Project.objects \
.filter(is_deleted=False) \
.exclude(projectmembership__in=my_memberships) \
.exclude(team__teammembership__in=my_team_memberships) \
.order_by('name').distinct()
else:
other_projects = Project.objects \
.filter(is_deleted=False) \
.exclude(projectmembership__in=my_memberships) \
.exclude(team__teammembership__in=my_team_memberships) \
.exclude(visibility=ProjectVisibility.TEAM_MEMBERS) \
@@ -158,7 +161,7 @@ def _check_project_admin(project, user):
@atomic_for_request_method
def project_edit(request, project_pk):
project = Project.objects.get(id=project_pk)
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
@@ -195,7 +198,7 @@ def project_edit(request, project_pk):
@atomic_for_request_method
def project_members(request, project_pk):
project = Project.objects.get(id=project_pk)
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
if request.method == 'POST':
@@ -230,7 +233,7 @@ def project_members_invite(request, project_pk):
# NOTE: project-member invite is just that: a direct invite to a project. If you want to also/instead invite someone
# to a team, you need to just do that instead.
project = Project.objects.get(id=project_pk)
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
@@ -292,7 +295,7 @@ def project_member_settings(request, project_pk, user_pk):
this_is_you = str(user_pk) == str(request.user.id)
if not this_is_you:
_check_project_admin(Project.objects.get(id=project_pk), request.user)
_check_project_admin(Project.objects.get(id=project_pk, is_deleted=False), request.user)
membership = ProjectMembership.objects.get(project=project_pk, user=user_pk)
create_form = lambda data: ProjectMembershipForm(data, instance=membership) # noqa
@@ -317,7 +320,7 @@ def project_member_settings(request, project_pk, user_pk):
return render(request, 'projects/project_member_settings.html', {
'this_is_you': this_is_you,
'user': User.objects.get(id=user_pk),
'project': Project.objects.get(id=project_pk),
'project': Project.objects.get(id=project_pk, is_deleted=False),
'form': form,
})
@@ -377,7 +380,7 @@ def project_members_accept(request, project_pk):
# invited as user B. Security-wise this is fine, but UX-wise it could be confusing. However, I'm in the assumption
# here that normal people (i.e. not me) don't have multiple accounts, so I'm not going to bother with this.
project = Project.objects.get(id=project_pk)
project = Project.objects.get(id=project_pk, is_deleted=False)
membership = ProjectMembership.objects.get(project=project, user=request.user)
if membership.accepted:
@@ -402,7 +405,7 @@ def project_members_accept(request, project_pk):
@atomic_for_request_method
def project_sdk_setup(request, project_pk, platform=""):
project = Project.objects.get(id=project_pk)
project = Project.objects.get(id=project_pk, is_deleted=False)
if not request.user.is_superuser and not ProjectMembership.objects.filter(project=project, user=request.user,
accepted=True).exists():
@@ -423,7 +426,7 @@ def project_sdk_setup(request, project_pk, platform=""):
@atomic_for_request_method
def project_alerts_setup(request, project_pk):
project = Project.objects.get(id=project_pk)
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
if request.method == 'POST':
@@ -446,7 +449,7 @@ def project_alerts_setup(request, project_pk):
@atomic_for_request_method
def project_messaging_service_add(request, project_pk):
project = Project.objects.get(id=project_pk)
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
if request.method == 'POST':
@@ -474,7 +477,7 @@ def project_messaging_service_add(request, project_pk):
@atomic_for_request_method
def project_messaging_service_edit(request, project_pk, service_pk):
project = Project.objects.get(id=project_pk)
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
instance = project.service_configs.get(id=service_pk)