mirror of
https://github.com/bugsink/bugsink.git
synced 2025-12-18 02:54:55 -06:00
524 lines
23 KiB
Python
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,
|
|
})
|