diff --git a/bugsink/views.py b/bugsink/views.py
index bc0a999..27ae403 100644
--- a/bugsink/views.py
+++ b/bugsink/views.py
@@ -4,24 +4,22 @@ from django.conf import settings
from django.views.decorators.http import require_GET
from django.views.decorators.cache import cache_control
from django.http import FileResponse, HttpRequest, HttpResponse
-from django.shortcuts import render
from bugsink.decorators import login_exempt
def home(request):
- project_count = request.user.project_set.all().count()
-
- if project_count == 0:
- return redirect("team_list")
-
- elif project_count == 1:
+ if request.user.project_set.filter(projectmembership__accepted=True).distinct().count() == 1:
+ # if the user has exactly one project, we redirect them to that project
project = request.user.project_set.get()
- return redirect("issue_list_open", project_id=project.id)
+ return redirect("issue_list_open", project_pk=project.id)
- return render(request, "bugsink/home_project_list.html", {
- # user_projecs is in the context_processor, we don't need to pass it here
- })
+ elif request.user.project_set.all().distinct().count() > 0:
+ # note: no filter on projectmembership__accepted=True here; if there is _any_ project, we show the project list
+ return redirect("project_list")
+
+ # final fallback: show the team list
+ return redirect("team_list")
@login_exempt
diff --git a/projects/admin.py b/projects/admin.py
index 2c75b32..dccd827 100644
--- a/projects/admin.py
+++ b/projects/admin.py
@@ -43,6 +43,9 @@ class ProjectAdmin(admin.ModelAdmin):
inlines = [
ProjectMembershipInline,
]
+ prepopulated_fields = {
+ 'slug': ['name'],
+ }
# the preferred way to deal with ProjectMembership is actually through the inline above; however, because this may prove
diff --git a/projects/forms.py b/projects/forms.py
new file mode 100644
index 0000000..1b6f3c2
--- /dev/null
+++ b/projects/forms.py
@@ -0,0 +1,83 @@
+from django import forms
+from django.contrib.auth import get_user_model
+from django.template.defaultfilters import yesno
+
+from teams.models import TeamMembership
+
+from .models import Project, ProjectMembership, ProjectRole
+
+User = get_user_model()
+
+
+class ProjectMemberInviteForm(forms.Form):
+ email = forms.EmailField(label='Email', required=True)
+ role = forms.ChoiceField(
+ label='Role', choices=ProjectRole.choices, required=True, initial=ProjectRole.MEMBER, widget=forms.RadioSelect)
+
+ def __init__(self, user_must_exist, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.user_must_exist = user_must_exist
+ if user_must_exist:
+ self.fields['email'].help_text = "The user must already exist in the system"
+
+ def clean_email(self):
+ email = self.cleaned_data['email']
+
+ if self.user_must_exist and not User.objects.filter(email=email).exists():
+ raise forms.ValidationError('No user with this email address in the system.')
+
+ return email
+
+
+class MyProjectMembershipForm(forms.ModelForm):
+ """Edit _your_ ProjectMembership, i.e. email-settings, and role only for admins"""
+
+ class Meta:
+ model = ProjectMembership
+ fields = ["send_email_alerts", "role"]
+
+ def __init__(self, *args, **kwargs):
+ edit_role = kwargs.pop("edit_role")
+ super().__init__(*args, **kwargs)
+ assert self.instance is not None, "This form is only implemented for editing"
+
+ if not edit_role:
+ del self.fields['role']
+
+ # True as default ... same TODO as in teams/forms.py
+ try:
+ tm = TeamMembership.objects.get(team=self.instance.project.team, user=self.instance.user)
+ team_send_email_alerts = tm.send_email_alerts if tm.send_email_alerts is not None else True
+ except TeamMembership.DoesNotExist:
+ team_send_email_alerts = True
+
+ empty_label = "Team-default (currently: %s)" % yesno(team_send_email_alerts)
+ self.fields['send_email_alerts'].empty_label = empty_label
+ self.fields['send_email_alerts'].widget.choices[0] = ("unknown", empty_label)
+
+
+class ProjectForm(forms.ModelForm):
+
+ def __init__(self, *args, **kwargs):
+ team_qs = kwargs.pop("team_qs", None)
+ super().__init__(*args, **kwargs)
+ if self.instance is not None and self.instance.pk is not None:
+ # for editing, we disallow changing the team. consideration: it's somewhat hard to see what the consequences
+ # for authorization are (from the user's perspective).
+ del self.fields["team"]
+
+ # if we ever push slug to the form, editing it should probably be disallowed as well (but mainly because it
+ # has consequences on the issue's short identifier)
+ # del self.fields["slug"]
+ else:
+ self.fields["team"].queryset = team_qs
+
+ class Meta:
+ model = Project
+
+ fields = ["team", "name", "visibility"]
+ # "slug", <= for now, we just do this in the model; if we want to do it in the form, I would want to have some
+ # JS in place like we have in the admin. django/contrib/admin/static/admin/js/prepopulate.js is an example of
+ # how Django does this (but it requires JQuery)
+
+ # "alert_on_new_issue", "alert_on_regression", "alert_on_unmute" later
diff --git a/projects/migrations/0011_projectmembership_accepted_projectmembership_role.py b/projects/migrations/0011_projectmembership_accepted_projectmembership_role.py
new file mode 100644
index 0000000..86bffcc
--- /dev/null
+++ b/projects/migrations/0011_projectmembership_accepted_projectmembership_role.py
@@ -0,0 +1,21 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0010_set_single_team'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='projectmembership',
+ name='accepted',
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AddField(
+ model_name='projectmembership',
+ name='role',
+ field=models.IntegerField(choices=[(0, 'Member'), (1, 'Admin')], default=0),
+ ),
+ ]
diff --git a/projects/migrations/0012_alter_projectmembership_send_email_alerts.py b/projects/migrations/0012_alter_projectmembership_send_email_alerts.py
new file mode 100644
index 0000000..617b6b3
--- /dev/null
+++ b/projects/migrations/0012_alter_projectmembership_send_email_alerts.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0011_projectmembership_accepted_projectmembership_role'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='projectmembership',
+ name='send_email_alerts',
+ field=models.BooleanField(default=None, null=True),
+ ),
+ ]
diff --git a/projects/migrations/0013_project_visibility.py b/projects/migrations/0013_project_visibility.py
new file mode 100644
index 0000000..0c8ec19
--- /dev/null
+++ b/projects/migrations/0013_project_visibility.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.13 on 2024-06-06 12:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0012_alter_projectmembership_send_email_alerts'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='project',
+ name='visibility',
+ field=models.IntegerField(choices=[(1, 'Joinable'), (10, 'Visible'), (99, 'Hidden')], default=99),
+ ),
+ ]
diff --git a/projects/migrations/0014_alter_project_slug_alter_project_visibility.py b/projects/migrations/0014_alter_project_slug_alter_project_visibility.py
new file mode 100644
index 0000000..f8d2763
--- /dev/null
+++ b/projects/migrations/0014_alter_project_slug_alter_project_visibility.py
@@ -0,0 +1,21 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0013_project_visibility'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='project',
+ name='slug',
+ field=models.SlugField(unique=True),
+ ),
+ migrations.AlterField(
+ model_name='project',
+ name='visibility',
+ field=models.IntegerField(choices=[(1, 'Joinable'), (10, 'Visible'), (99, 'Team Members')], default=99),
+ ),
+ ]
diff --git a/projects/migrations/0015_alter_project_name.py b/projects/migrations/0015_alter_project_name.py
new file mode 100644
index 0000000..9ad96d4
--- /dev/null
+++ b/projects/migrations/0015_alter_project_name.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('projects', '0014_alter_project_slug_alter_project_visibility'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='project',
+ name='name',
+ field=models.CharField(max_length=255, unique=True),
+ ),
+ ]
diff --git a/projects/models.py b/projects/models.py
index 6a9bb3f..5032434 100644
--- a/projects/models.py
+++ b/projects/models.py
@@ -8,6 +8,20 @@ from bugsink.app_settings import get_settings
from compat.dsn import build_dsn
+from teams.models import TeamMembership, TeamRole
+
+
+class ProjectRole(models.IntegerChoices):
+ MEMBER = 0
+ ADMIN = 1
+
+
+class ProjectVisibility(models.IntegerChoices):
+ # PUBLIC = 0 # anyone can see the project and its members; not sure if I want this or always require click-in
+ JOINABLE = 1 # anyone can join
+ VISIBLE = 10 # the project is visible, you can request to join(?), but this needs to be approved
+ TEAM_MEMBERS = 99 # the project is only visible to team-members (and for some(?) things they need to click "join")
+
class Project(models.Model):
# id is implied which makes it an Integer; we would prefer a uuid but the sentry clients have int baked into the DSN
@@ -15,8 +29,8 @@ class Project(models.Model):
team = models.ForeignKey("teams.Team", blank=False, null=True, on_delete=models.SET_NULL)
- name = models.CharField(max_length=255, blank=False, null=False)
- slug = models.SlugField(max_length=50, blank=False, null=False)
+ name = models.CharField(max_length=255, blank=False, null=False, unique=True)
+ slug = models.SlugField(max_length=50, blank=False, null=False, unique=True)
# sentry_key mirrors the "public" part of the sentry DSN. As of late 2023 Sentry's docs say the this about DSNs:
#
@@ -60,18 +74,39 @@ class Project(models.Model):
alert_on_regression = models.BooleanField(default=True)
alert_on_unmute = models.BooleanField(default=True)
+ # visibility
+ visibility = models.IntegerField(choices=ProjectVisibility.choices, default=ProjectVisibility.TEAM_MEMBERS)
+
def get_latest_release(self):
# TODO perfomance considerations... this can be denormalized/cached at the project level
from releases.models import ordered_releases
return list(ordered_releases(project=self))[-1]
def save(self, *args, **kwargs):
- if self.slug is None:
- # this is not guaranteeing uniqueness but it's enough to have something that makes our tests work.
- # in realy usage slugs are provided properly on-creation.
- self.slug = slugify(self.name)
+ if self.slug in [None, ""]:
+ # we don't want to have empty slugs, so we'll generate a unique one
+ base_slug = slugify(self.name)
+ similar_slugs = Project.objects.filter(slug__startswith=base_slug).values_list("slug", flat=True)
+ self.slug = base_slug
+ i = 0
+ while self.slug in similar_slugs:
+ self.slug = f"{base_slug}-{i}"
+ i += 1
+
super().save(*args, **kwargs)
+ def is_joinable(self, user=None):
+ if user is not None:
+ # take the user's team membership into account
+ try:
+ tm = TeamMembership.objects.get(team=self.team, user=user)
+ if tm.role == TeamRole.ADMIN:
+ return True
+ except TeamMembership.DoesNotExist:
+ pass
+
+ return self.visibility <= ProjectVisibility.JOINABLE
+
class ProjectMembership(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE)
@@ -81,13 +116,16 @@ class ProjectMembership(models.Model):
# User(Profile) is something we'll do later. At that point we'll probably implement it as denormalized here, so
# we'll just have to shift the currently existing field into send_email_alerts_denormalized and create a 3-way
# field.
- send_email_alerts = models.BooleanField(default=True)
+ send_email_alerts = models.BooleanField(default=None, null=True)
- # TODO this will come
- # role = models.CharField(max_length=255, blank=False, null=False)
+ role = models.IntegerField(choices=ProjectRole.choices, default=ProjectRole.MEMBER)
+ accepted = models.BooleanField(default=False)
def __str__(self):
return f"{self.user} project membership of {self.project}"
class Meta:
unique_together = ("project", "user")
+
+ def is_admin(self):
+ return self.role == ProjectRole.ADMIN
diff --git a/projects/tasks.py b/projects/tasks.py
new file mode 100644
index 0000000..219320e
--- /dev/null
+++ b/projects/tasks.py
@@ -0,0 +1,47 @@
+from django.urls import reverse
+
+from snappea.decorators import shared_task
+
+from bugsink.app_settings import get_settings
+from bugsink.utils import send_rendered_email
+
+from .models import Project
+
+
+@shared_task
+def send_project_invite_email_new_user(email, project_pk, token):
+ project = Project.objects.get(pk=project_pk)
+
+ send_rendered_email(
+ subject='You have been invited to join "%s"' % project.name,
+ base_template_name="mails/project_membership_invite_new_user",
+ recipient_list=[email],
+ context={
+ "site_title": get_settings().SITE_TITLE,
+ "base_url": get_settings().BASE_URL + "/",
+ "project_name": project.name,
+ "url": get_settings().BASE_URL + reverse("project_members_accept_new_user", kwargs={
+ "token": token,
+ "project_pk": project_pk,
+ }),
+ },
+ )
+
+
+@shared_task
+def send_project_invite_email(email, project_pk):
+ project = Project.objects.get(pk=project_pk)
+
+ send_rendered_email(
+ subject='You have been invited to join "%s"' % project.name,
+ base_template_name="mails/project_membership_invite",
+ recipient_list=[email],
+ context={
+ "site_title": get_settings().SITE_TITLE,
+ "base_url": get_settings().BASE_URL + "/",
+ "project_name": project.name,
+ "url": get_settings().BASE_URL + reverse("project_members_accept", kwargs={
+ "project_pk": project_pk,
+ }),
+ },
+ )
diff --git a/projects/templates/mails/project_membership_invite.html b/projects/templates/mails/project_membership_invite.html
new file mode 100644
index 0000000..6193b5d
--- /dev/null
+++ b/projects/templates/mails/project_membership_invite.html
@@ -0,0 +1,519 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {# As I understand it, this hidden div is specifically meant to be shown in email clients' preview (preview of message content in list of emails) #}You have been invited to "{{ project_name }}".
+
+
+
diff --git a/projects/templates/mails/project_membership_invite.txt b/projects/templates/mails/project_membership_invite.txt
new file mode 100644
index 0000000..ed8cd2b
--- /dev/null
+++ b/projects/templates/mails/project_membership_invite.txt
@@ -0,0 +1,5 @@
+You have been invited to join the project "{{ project_name }}" on {{ site_title }}.
+
+View, accept or reject the invitation by clicking the link below:
+
+{{ url }}
diff --git a/projects/templates/mails/project_membership_invite_new_user.html b/projects/templates/mails/project_membership_invite_new_user.html
new file mode 100644
index 0000000..ca9c5d1
--- /dev/null
+++ b/projects/templates/mails/project_membership_invite_new_user.html
@@ -0,0 +1,519 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {# As I understand it, this hidden div is specifically meant to be shown in email clients' preview (preview of message content in list of emails) #}You have been invited to "{{ project_name }}".
+
- {{ member.user.email }} {# "best name" perhaps later? #}
+ {{ member.user.email }} {# "best name" perhaps later? #}
+ {% if not member.accepted %}
+ Invitation pending
+ {% elif member.role == 1 %} {# NOTE: we intentionally hide admin-ness for non-accepted users; TODO better use of constants #}
+ Admin
+ {% endif %}
-
-
+
+ {% if not member.accepted %}
+
+ {% endif %}
+ {% if request.user == member.user %}
+
+ {% else %} {# NOTE: in our setup request_user_is_admin is implied because only admins may view the membership page #}
+
+ {% endif %}
+ {% empty %}
+
+
+
+ {# Note: this is already somewhat exceptional, because the usually you'll at least see yourself here (unless you're a superuser and a project has become memberless) #}
+ No members yet. Invite someone.
+
+
+{% endblock %}
diff --git a/projects/urls.py b/projects/urls.py
index e3e3ba0..6941707 100644
--- a/projects/urls.py
+++ b/projects/urls.py
@@ -1,8 +1,20 @@
from django.urls import path
-from .views import project_list, project_members
+from .views import (
+ project_list, project_members, project_members_accept, project_member_settings, project_members_invite,
+ project_members_accept_new_user, project_new, project_edit)
urlpatterns = [
path('', project_list, name="project_list"),
+ path('mine/', project_list, kwargs={"ownership_filter": "mine"}, name="project_list_mine"),
+ path('teams/', project_list, kwargs={"ownership_filter": "teams"}, name="project_list_teams"),
+ path('other/', project_list, kwargs={"ownership_filter": "other"}, name="project_list_other"),
+ path('new/', project_new, name="project_new"),
+ path('/edit/', project_edit, name="project_edit"),
path('/members/', project_members, name="project_members"),
+ path('/members/invite/', project_members_invite, name="project_members_invite"),
+ path('/members/accept/', project_members_accept, name="project_members_accept"),
+ path('/members/accept//', project_members_accept_new_user,
+ name="project_members_accept_new_user"),
+ path('/members/settings//', project_member_settings, name="project_member_settings"),
]
diff --git a/projects/views.py b/projects/views.py
index 3646d57..6b3d27d 100644
--- a/projects/views.py
+++ b/projects/views.py
@@ -1,20 +1,365 @@
+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.contrib.auth.decorators import permission_required
-from .models import Project
+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
+
+from .models import Project, ProjectMembership, ProjectRole, ProjectVisibility
+from .forms import MyProjectMembershipForm, ProjectMemberInviteForm, ProjectForm
+from .tasks import send_project_invite_email, send_project_invite_email_new_user
-def project_list(request):
- project_list = Project.objects.all()
+User = get_user_model()
+
+
+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_teams_projects = \
+ Project.objects \
+ .filter(team__teammembership__in=my_team_memberships) \
+ .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 \
+ .exclude(projectmembership__in=my_memberships) \
+ .exclude(team__teammembership__in=my_team_memberships) \
+ .order_by('name').distinct()
+ else:
+ other_projects = Project.objects \
+ .exclude(projectmembership__in=my_memberships) \
+ .exclude(team__teammembership__in=my_team_memberships) \
+ .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=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)),
+ )
+
+ 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', {
- 'state_filter': 'mine',
+ '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,
})
-def project_members(request, project_pk):
- # TODO: check if user is a member of the project and has permission to view this page
+@permission_required("projects.add_project")
+def project_new(request):
+ 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_members', 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")
+
+
+def project_edit(request, project_pk):
project = Project.objects.get(id=project_pk)
+
+ _check_project_admin(project, request.user)
+
+ if request.method == 'POST':
+ form = ProjectForm(request.POST, instance=project)
+
+ if form.is_valid():
+ form.save()
+ return redirect('project_members', project_pk=project.id)
+
+ else:
+ form = ProjectForm(instance=project)
+
+ return render(request, 'projects/project_edit.html', {
+ 'project': project,
+ 'form': form,
+ })
+
+
+def project_members(request, project_pk):
+ project = Project.objects.get(id=project_pk)
+ _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)
+
+
+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)
+
+ _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.has_perm("users.add_user"):
+ 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,
+ })
+
+
+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), 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),
+ 'form': form,
+ })
+
+
+@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)
+
+
+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)
+ 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})
diff --git a/teams/forms.py b/teams/forms.py
index e0eb253..cc403f5 100644
--- a/teams/forms.py
+++ b/teams/forms.py
@@ -28,7 +28,7 @@ class TeamMemberInviteForm(forms.Form):
class MyTeamMembershipForm(forms.ModelForm):
- """Edit your TeamMembership, i.e. email-settings are OK, and role only for admins"""
+ """Edit _your_ TeamMembership, i.e. email-settings, and role only for admins"""
class Meta:
model = TeamMembership
diff --git a/teams/migrations/0006_alter_team_name_alter_team_visibility_and_more.py b/teams/migrations/0006_alter_team_name_alter_team_visibility_and_more.py
new file mode 100644
index 0000000..6c69f5d
--- /dev/null
+++ b/teams/migrations/0006_alter_team_name_alter_team_visibility_and_more.py
@@ -0,0 +1,26 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('teams', '0005_teammembership_send_email_alerts'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='team',
+ name='name',
+ field=models.CharField(max_length=255, unique=True),
+ ),
+ migrations.AlterField(
+ model_name='team',
+ name='visibility',
+ field=models.IntegerField(choices=[(1, 'Joinable'), (10, 'Visible'), (99, 'Hidden')], default=1),
+ ),
+ migrations.AlterField(
+ model_name='teammembership',
+ name='send_email_alerts',
+ field=models.BooleanField(blank=True, default=None, null=True),
+ ),
+ ]
diff --git a/teams/migrations/0007_alter_team_visibility.py b/teams/migrations/0007_alter_team_visibility.py
new file mode 100644
index 0000000..2b8afd6
--- /dev/null
+++ b/teams/migrations/0007_alter_team_visibility.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.13 on 2024-06-06 12:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('teams', '0006_alter_team_name_alter_team_visibility_and_more'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='team',
+ name='visibility',
+ field=models.IntegerField(choices=[(1, 'Joinable'), (10, 'Visible'), (99, 'Hidden')], default=10),
+ ),
+ ]
diff --git a/teams/models.py b/teams/models.py
index 53d9ef0..8d04393 100644
--- a/teams/models.py
+++ b/teams/models.py
@@ -23,7 +23,7 @@ class Team(models.Model):
name = models.CharField(max_length=255, blank=False, null=False, unique=True)
slug = models.SlugField(max_length=50, blank=False, null=False)
- visibility = models.IntegerField(choices=TeamVisibility.choices, default=TeamVisibility.JOINABLE)
+ visibility = models.IntegerField(choices=TeamVisibility.choices, default=TeamVisibility.VISIBLE)
def __str__(self):
return self.name
diff --git a/teams/templates/teams/team_list.html b/teams/templates/teams/team_list.html
index f8eef8f..c3868ff 100644
--- a/teams/templates/teams/team_list.html
+++ b/teams/templates/teams/team_list.html
@@ -15,7 +15,6 @@
{# align to bottom #}
- {# the below is not correct, but what is? #}
{% if perms.teams.add_team %}