Files
bugsink/projects/views.py
Klaas van Schelven 2a90d6ab1e Message service backend setup: switch config form per-service in the UI
See #281, which this commit prepares for
2025-11-26 09:10:14 +01:00

524 lines
23 KiB
Python

import json
from datetime import timedelta
from django.shortcuts import render
from django.db import models
from django.shortcuts import redirect
from django.http import Http404, HttpResponseRedirect
from django.core.exceptions import PermissionDenied
from django.contrib.auth import get_user_model
from django.contrib import messages
from django.contrib.auth import logout
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from users.models import EmailVerification
from teams.models import TeamMembership, Team, TeamRole
from bugsink.app_settings import get_settings, CB_ANYBODY, CB_MEMBERS, CB_ADMINS
from bugsink.decorators import login_exempt, atomic_for_request_method
from bugsink.utils import assert_
from alerts.models import MessagingServiceConfig, get_alert_service_backend_class, get_alert_service_kind_choices
from alerts.forms import MessagingServiceConfigNewForm, MessagingServiceConfigEditForm
from .models import Project, ProjectMembership, ProjectRole, ProjectVisibility
from .forms import ProjectMembershipForm, MyProjectMembershipForm, ProjectMemberInviteForm, ProjectForm
from .tasks import send_project_invite_email, send_project_invite_email_new_user
User = get_user_model()
@atomic_for_request_method
def project_list(request, ownership_filter=None):
my_memberships = ProjectMembership.objects.filter(user=request.user)
# using `id__in` here to ensure the counts later on is not restricted to our own memberships (at most 1)
my_projects = Project.objects.filter(
id__in=ProjectMembership.objects.filter(user=request.user).values('project_id'), is_deleted=False) \
.order_by('name').distinct()
my_teams_projects = \
Project.objects \
.filter(team_id__in=TeamMembership.objects.filter(user=request.user).values('team_id'), is_deleted=False) \
.exclude(projectmembership__in=my_memberships) \
.order_by('name').distinct()
if request.user.is_superuser:
# superusers can see all projects, even hidden ones
other_projects = Project.objects \
.filter(is_deleted=False) \
.exclude(id__in=ProjectMembership.objects.filter(user=request.user).values('project_id')) \
.exclude(team_id__in=TeamMembership.objects.filter(user=request.user).values('team_id')) \
.order_by('name').distinct()
else:
other_projects = Project.objects \
.filter(is_deleted=False) \
.exclude(id__in=ProjectMembership.objects.filter(user=request.user).values('project_id')) \
.exclude(team_id__in=TeamMembership.objects.filter(user=request.user).values('team_id')) \
.exclude(visibility=ProjectVisibility.TEAM_MEMBERS) \
.order_by('name').distinct()
if ownership_filter is None:
if my_projects.exists():
return redirect('project_list_mine')
if my_teams_projects.exists():
return redirect('project_list_teams')
if other_projects.exists():
return redirect('project_list_other')
return redirect('project_list_mine') # if nothing to show, might as well show your own
if request.method == 'POST':
full_action_str = request.POST.get('action')
action, project_pk = full_action_str.split(":", 1)
if action == "leave":
ProjectMembership.objects.filter(project=project_pk, user=request.user.id).delete()
elif action == "join":
project = Project.objects.get(id=project_pk)
if not project.is_joinable(user=request.user) and not request.user.is_superuser:
raise PermissionDenied("This project is not joinable")
messages.success(request, _('You have joined the project "%s"') % project.name)
ProjectMembership.objects.create(
project_id=project_pk, user_id=request.user.id, role=ProjectRole.MEMBER, accepted=True)
return redirect('project_member_settings', project_pk=project_pk, user_pk=request.user.id)
if ownership_filter == "mine":
base_qs = my_projects
elif ownership_filter == "teams":
base_qs = my_teams_projects
elif ownership_filter == "other":
base_qs = other_projects
else:
raise ValueError(f"Invalid ownership_filter: {ownership_filter}")
project_list = base_qs.annotate(
# open_issue_count disabled, it's too expensive
# open_issue_count=models.Count('issue', filter=models.Q(issue__is_resolved=False, issue__is_muted=False)),
member_count=models.Count(
'projectmembership', distinct=True, filter=models.Q(projectmembership__accepted=True)),
).select_related('team')
if ownership_filter == "mine":
# Perhaps there's some Django-native way of doing this, but I can't figure it out soon enough, and this also
# works:
my_memberships_dict = {m.project_id: m for m in my_memberships}
project_list_2 = []
for project in project_list:
project.member = my_memberships_dict.get(project.id)
project_list_2.append(project)
project_list = project_list_2
return render(request, 'projects/project_list.html', {
'can_create':
request.user.is_superuser or TeamMembership.objects.filter(user=request.user, role=TeamRole.ADMIN).exists(),
'ownership_filter': ownership_filter,
'project_list': project_list,
})
@atomic_for_request_method
def project_new(request):
if not (request.user.is_superuser or TeamMembership.objects.filter(user=request.user,
role=TeamRole.ADMIN).exists()):
raise PermissionDenied("You need to be a team admin to create a project")
if get_settings().SINGLE_TEAM and Team.objects.count() == 0:
# we just create the Single Team if it doesn't exist yet (whatever user triggers this doesn't matter)
Team.objects.create(name="Single Team")
if request.user.is_superuser:
team_qs = Team.objects.all()
else:
my_admin_memberships = TeamMembership.objects.filter(user=request.user, role=TeamRole.ADMIN, accepted=True)
team_qs = Team.objects.filter(teammembership__in=my_admin_memberships).distinct()
if request.method == 'POST':
form = ProjectForm(request.POST, team_qs=team_qs)
if form.is_valid():
project = form.save()
# the user who creates the project is automatically an (accepted) admin of the project
ProjectMembership.objects.create(project=project, user=request.user, role=ProjectRole.ADMIN, accepted=True)
return redirect('project_sdk_setup', project_pk=project.id)
else:
form = ProjectForm(team_qs=team_qs)
return render(request, 'projects/project_new.html', {
'form': form,
})
def _check_project_admin(project, user):
if not user.is_superuser and \
not ProjectMembership.objects.filter(
project=project, user=user, role=ProjectRole.ADMIN, accepted=True).exists() and \
not TeamMembership.objects.filter(team=project.team, user=user, role=TeamRole.ADMIN, accepted=True).exists():
raise PermissionDenied("You are not an admin of this project")
@atomic_for_request_method
def project_edit(request, project_pk):
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
if request.method == 'POST':
action = request.POST.get('action')
if action == 'delete':
# Double-check that the user is an admin or superuser
if (not request.user.is_superuser
and not ProjectMembership.objects.filter(
project=project, user=request.user, role=ProjectRole.ADMIN, accepted=True).exists()
and not TeamMembership.objects.filter(
team=project.team, user=request.user, role=TeamRole.ADMIN, accepted=True).exists()):
raise PermissionDenied("Only project or team admins can delete projects")
# Delete the project
project.delete_deferred()
messages.success(request, f'Project "{project.name}" has been deleted successfully.')
return redirect('project_list')
form = ProjectForm(request.POST, instance=project)
if form.is_valid():
form.save()
messages.success(request, 'Project settings updated successfully.')
return redirect('project_list')
else:
form = ProjectForm(instance=project)
return render(request, 'projects/project_edit.html', {
'project': project,
'form': form,
})
@atomic_for_request_method
def project_members(request, project_pk):
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
if request.method == 'POST':
full_action_str = request.POST.get('action')
action, user_id = full_action_str.split(":", 1)
if action == "remove":
ProjectMembership.objects.filter(project=project_pk, user=user_id).delete()
elif action == "reinvite":
user = User.objects.get(id=user_id)
_send_project_invite_email(user, project_pk)
messages.success(request, f"Invitation resent to {user.email}")
return render(request, 'projects/project_members.html', {
'project': project,
'members': project.projectmembership_set.all().select_related('user'),
})
def _send_project_invite_email(user, project_pk):
"""Send an email to a user inviting them to a project; (for new users this includes the email-verification link)"""
if user.is_active:
send_project_invite_email.delay(user.email, project_pk)
else:
# this happens for new (in this view) users, but also for users who have been invited before but have
# not yet accepted the invite. In the latter case, we just send a fresh email
verification = EmailVerification.objects.create(user=user, email=user.username)
send_project_invite_email_new_user.delay(user.email, project_pk, verification.token)
@atomic_for_request_method
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, is_deleted=False)
_check_project_admin(project, request.user)
if get_settings().USER_REGISTRATION in [CB_ANYBODY, CB_MEMBERS]:
user_must_exist = False
elif get_settings().USER_REGISTRATION == CB_ADMINS and request.user.is_superuser:
user_must_exist = False
else:
user_must_exist = True
if request.method == 'POST':
form = ProjectMemberInviteForm(user_must_exist, request.POST)
if form.is_valid():
# because we do validation in the form (which takes user_must_exist as a param), we know we can create the
# user if needed if this point is reached.
email = form.cleaned_data['email']
user, user_created = User.objects.get_or_create(
email=email, defaults={'username': email, 'is_active': False})
_send_project_invite_email(user, project_pk)
_, membership_created = ProjectMembership.objects.get_or_create(project=project, user=user, defaults={
'role': form.cleaned_data['role'],
'accepted': False,
})
if membership_created:
messages.success(request, f"Invitation sent to {email}")
else:
messages.success(
request, f"Invitation resent to {email} (it was previously sent and we just sent it again)")
if request.POST.get('action') == "invite_and_add_another":
return redirect('project_members_invite', project_pk=project_pk)
# I think this is enough feedback, as the user will just show up there
return redirect('project_members', project_pk=project_pk)
else:
form = ProjectMemberInviteForm(user_must_exist)
return render(request, 'projects/project_members_invite.html', {
'project': project,
'form': form,
})
@atomic_for_request_method
def project_member_settings(request, project_pk, user_pk):
try:
your_membership = ProjectMembership.objects.get(project=project_pk, user=request.user)
except ProjectMembership.DoesNotExist:
raise PermissionDenied("You are not a member of this project")
if not your_membership.accepted:
return redirect("project_members_accept", project_pk=project_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, is_deleted=False), request.user)
membership = ProjectMembership.objects.get(project=project_pk, user=user_pk)
create_form = lambda data: ProjectMembershipForm(data, instance=membership) # noqa
else:
edit_role = your_membership.role == ProjectRole.ADMIN or request.user.is_superuser
create_form = lambda data: MyProjectMembershipForm(data=data, instance=your_membership, edit_role=edit_role) # noqa
if request.method == 'POST':
form = create_form(request.POST)
if form.is_valid():
form.save()
if this_is_you:
# assumption (not always true): when editing yourself, you came from the project list not the project
# members
return redirect('project_list')
return redirect('project_members', project_pk=project_pk)
else:
form = create_form(None)
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, is_deleted=False),
'form': form,
})
@atomic_for_request_method
@login_exempt # no login is required, the token is what identifies the user
def project_members_accept_new_user(request, project_pk, token):
# There is a lot of overlap with the email-verification flow here; security-wise we make the same assumptions as we
# do over there, namely: access to email implies control over the account. This is also the reason we reuse that
# app's `EmailVerification` model.
# clean up expired tokens; doing this on every request is just fine, it saves us from having to run a cron
# job-like thing
EmailVerification.objects.filter(
created_at__lt=timezone.now() - timedelta(get_settings().USER_REGISTRATION_VERIFY_EMAIL_EXPIRY)).delete()
try:
verification = EmailVerification.objects.get(token=token)
except EmailVerification.DoesNotExist:
# good enough (though a special page might be prettier)
raise Http404("Invalid or expired token")
user = verification.user
if not user.has_usable_password() or not user.is_active:
# NOTE: we make the had assumption here that users without a password can self-upgrade to become users with a
# password. In the future (e.g. LDAP) this may not be what we want, and we'll have to implement a separate field
# to store whether we're dealing with "created by email invite, password must still be set" or "created by
# external system, password is managed externally". For now, we're good.
# In the above we take the (perhaps redundant) approach of checking for either of 2 login-blocking conditions.
return HttpResponseRedirect(reverse("reset_password", kwargs={"token": token}) + "?next=" + reverse(
project_members_accept, kwargs={"project_pk": project_pk})
)
# the above "set_password" branch is the "main flow"/"whole point" of this view: auto-login using a token and
# subsequent password-set because no (active) user exists yet. However, it is possible that a user ends up here
# while already having completed registration, e.g. when multiple invites have been sent in a row. In that case, the
# password-setting may be skipped and we can just skip straight to the actual project-accept.
# to remove some of the confusion mentioned in "project_members_accept", we at least log you out if the verification
# you've clicked on is for a different user than the one you're logged in as.
if request.user.is_authenticated and request.user != user:
logout(request)
# In this case, we clean up the no-longer-required verification object (we make somewhat of an exception to the
# "don't change stuff on GET" rule, because it's immaterial here).
verification.delete()
# And we just redirect to the regular "accept" page. No auto-login, because we're not in a POST request here. (at a
# small cost in UX in the case you reach this page in a logged-out state).
return redirect("project_members_accept", project_pk=project_pk)
@atomic_for_request_method
def project_members_accept(request, project_pk):
# NOTE: in principle it is confusingly possible to reach this page while logged in as user A, while having been
# 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, is_deleted=False)
membership = ProjectMembership.objects.get(project=project, user=request.user)
if membership.accepted:
# i.e. the user has already accepted the invite, we just silently redirect as if they had just done so
return redirect("project_member_settings", project_pk=project_pk, user_pk=request.user.id)
if request.method == 'POST':
# no need for a form, it's just a pair of buttons
if request.POST["action"] == "decline":
membership.delete()
return redirect("home")
if request.POST["action"] == "accept":
membership.accepted = True
membership.save()
return redirect("project_member_settings", project_pk=project_pk, user_pk=request.user.id)
raise Http404("Invalid action")
return render(request, "projects/project_members_accept.html", {"project": project, "membership": membership})
@atomic_for_request_method
def project_sdk_setup(request, project_pk, platform=""):
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():
raise PermissionDenied("You are not a member of this project")
# NOTE about lexers:: I have bugsink/pyments_extensions; but the platforms mentioned there don't necessarily map to
# what I will make selectable here. "We'll see" whether yet another lookup dict will be needed.
assert_(platform in ["", "python", "javascript", "php"])
template_name = "projects/project_sdk_setup%s.html" % ("_" + platform if platform else "")
return render(request, template_name, {
"project": project,
"dsn": project.dsn,
})
@atomic_for_request_method
def project_alerts_setup(request, project_pk):
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
if request.method == 'POST':
full_action_str = request.POST.get('action')
action, service_id = full_action_str.split(":", 1)
if action == "remove":
MessagingServiceConfig.objects.filter(project=project_pk, id=service_id).delete()
elif action == "test":
service = MessagingServiceConfig.objects.get(project=project_pk, id=service_id)
service_backend = service.get_backend()
service_backend.send_test_message()
messages.success(
request, "Test message sent; check the configured service to see if it arrived.")
return render(request, 'projects/project_alerts_setup.html', {
'project': project,
'service_configs': project.service_configs.all(),
})
@atomic_for_request_method
def project_messaging_service_add(request, project_pk):
project = Project.objects.get(id=project_pk, is_deleted=False)
_check_project_admin(project, request.user)
config_forms = {
kind: get_alert_service_backend_class(kind).get_form_class()()
for (kind, _) in get_alert_service_kind_choices()
}
if request.method == 'POST':
form = MessagingServiceConfigNewForm(project, request.POST)
kind = form.data.get('kind') or form.fields['kind'].initial
config_form = get_alert_service_backend_class(kind).get_form_class()(data=request.POST)
config_forms[kind] = config_form
if form.is_valid():
if config_form.is_valid():
service = form.save(commit=False)
service.config = json.dumps(config_form.get_config())
service.save()
messages.success(request, "Messaging service added successfully.")
return redirect('project_alerts_setup', project_pk=project_pk)
else:
form = MessagingServiceConfigNewForm(project)
kind = form.fields['kind'].initial
return render(request, 'projects/project_messaging_service_new.html', {
'project': project,
'form': form,
'config_forms': config_forms,
'selected_config_form_kind': kind,
})
@atomic_for_request_method
def project_messaging_service_edit(request, project_pk, service_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)
# for editing, we don't allow for changing the kind; although it's probably possible to implement it, it would raise
# questions on "how much are the various configs related (should data be transferred from one config to another).
# and even though "it's possible" simply disallowing greatly simplifies the implementation.
config_form_class = get_alert_service_backend_class(instance.kind).get_form_class()
if request.method == 'POST':
form = MessagingServiceConfigEditForm(request.POST, instance=instance)
config_form = config_form_class(data=request.POST)
if form.is_valid() and config_form.is_valid():
service = form.save(commit=False)
service.config = json.dumps(config_form.get_config())
service.save()
messages.success(request, "Messaging service updated successfully.")
return redirect('project_alerts_setup', project_pk=project_pk)
else:
form = MessagingServiceConfigEditForm(instance=instance)
config_form = config_form_class(config=json.loads(instance.config))
return render(request, 'projects/project_messaging_service_edit.html', {
'project': project,
'service_config': instance,
'form': form,
'config_form': config_form,
})