From de8bd65a3ae4d0fa4abba3700564ede8a7e8267c Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Fri, 7 Jun 2024 10:37:28 +0200 Subject: [PATCH] WIP teams & project-management (6) not extensively tested, but it starts to feel quite complete 'for now' --- bugsink/views.py | 20 +- projects/admin.py | 3 + projects/forms.py | 83 +++ ...bership_accepted_projectmembership_role.py | 21 + ...ter_projectmembership_send_email_alerts.py | 16 + .../migrations/0013_project_visibility.py | 18 + ...r_project_slug_alter_project_visibility.py | 21 + .../migrations/0015_alter_project_name.py | 16 + projects/models.py | 56 +- projects/tasks.py | 47 ++ .../mails/project_membership_invite.html | 519 ++++++++++++++++++ .../mails/project_membership_invite.txt | 5 + .../project_membership_invite_new_user.html | 519 ++++++++++++++++++ .../project_membership_invite_new_user.txt | 5 + projects/templates/projects/project_edit.html | 60 ++ projects/templates/projects/project_list.html | 77 ++- .../projects/project_member_settings.html | 83 +++ .../templates/projects/project_members.html | 50 +- .../projects/project_members_accept.html | 31 ++ .../projects/project_members_invite.html | 68 +++ projects/templates/projects/project_new.html | 78 +++ projects/urls.py | 14 +- projects/views.py | 357 +++++++++++- teams/forms.py | 2 +- ...eam_name_alter_team_visibility_and_more.py | 26 + .../migrations/0007_alter_team_visibility.py | 18 + teams/models.py | 2 +- teams/templates/teams/team_list.html | 9 +- teams/views.py | 2 +- templates/bugsink/home_project_list.html | 17 - theme/templates/base.html | 4 + 31 files changed, 2177 insertions(+), 70 deletions(-) create mode 100644 projects/forms.py create mode 100644 projects/migrations/0011_projectmembership_accepted_projectmembership_role.py create mode 100644 projects/migrations/0012_alter_projectmembership_send_email_alerts.py create mode 100644 projects/migrations/0013_project_visibility.py create mode 100644 projects/migrations/0014_alter_project_slug_alter_project_visibility.py create mode 100644 projects/migrations/0015_alter_project_name.py create mode 100644 projects/tasks.py create mode 100644 projects/templates/mails/project_membership_invite.html create mode 100644 projects/templates/mails/project_membership_invite.txt create mode 100644 projects/templates/mails/project_membership_invite_new_user.html create mode 100644 projects/templates/mails/project_membership_invite_new_user.txt create mode 100644 projects/templates/projects/project_edit.html create mode 100644 projects/templates/projects/project_member_settings.html create mode 100644 projects/templates/projects/project_members_accept.html create mode 100644 projects/templates/projects/project_members_invite.html create mode 100644 projects/templates/projects/project_new.html create mode 100644 teams/migrations/0006_alter_team_name_alter_team_visibility_and_more.py create mode 100644 teams/migrations/0007_alter_team_visibility.py delete mode 100644 templates/bugsink/home_project_list.html 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 @@ + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/projects/templates/mails/project_membership_invite_new_user.txt b/projects/templates/mails/project_membership_invite_new_user.txt new file mode 100644 index 0000000..2376812 --- /dev/null +++ b/projects/templates/mails/project_membership_invite_new_user.txt @@ -0,0 +1,5 @@ +You have been invited to join {{ site_title }} as part of the project "{{ project_name }}". + +View, accept or reject the invitation by clicking the link below: + +{{ url }} diff --git a/projects/templates/projects/project_edit.html b/projects/templates/projects/project_edit.html new file mode 100644 index 0000000..b5c4364 --- /dev/null +++ b/projects/templates/projects/project_edit.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Edit {{ project.name }} · {{ site_title }}{% endblock %} + +{% block content %} + + +
+ +
+
+ {% csrf_token %} + +
+

{{ project.name }}

+
+ + {% if form.name %} +
+
{{ form.name.label }}
+
+ {{ form.name }} + +
+ {% if form.name.errors %} + {% for error in form.name.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.name.help_text %} +
{{ form.name.help_text|safe }}
+ {% endif %} +
+ {% endif %} + + {% if form.visibility %} +
+
{{ form.visibility.label }}
+
+ {{ form.visibility }} + +
+ {% if form.visibility.errors %} + {% for error in form.visibility.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.visibility.help_text %} +
{{ form.visibility.help_text|safe }}
+ {% endif %} +
+ {% endif %} + + + Cancel +
+ +
+
+ +{% endblock %} diff --git a/projects/templates/projects/project_list.html b/projects/templates/projects/project_list.html index 353e427..795545f 100644 --- a/projects/templates/projects/project_list.html +++ b/projects/templates/projects/project_list.html @@ -7,18 +7,37 @@ -
-

Projects

+
+
+

Projects

+
+ + {# align to bottom #} +
+ {% if can_create %} +
+ New Project +
+ {% endif %} +
{# top, RHS (buttons) #} +
+ + + +
+ {% comment %}
+ {% endcomment %}
@@ -35,9 +54,22 @@ {{ project.name }}
- Team {{ project.team.name }} - | 7 members {# TODO not actaully implemented #} - | {{ project.issue_set.count }} open issues
{# TODO not actually 'open' issues #} + {{ project.team.name }} + | {{ project.member_count }} members + | {{ project.open_issue_count }} open issues + {% if project.member %} + | my settings + {% endif %} +
+ + + {% if project.member %} + {% if not project.member.accepted %} + You're invited! + {% elif project.member.is_admin %} {# NOTE: we intentionally hide admin-ness for non-accepted users; #} + Admin + {% endif %} + {% endif %} @@ -52,7 +84,7 @@
- + @@ -62,9 +94,30 @@ -
- -
+ {% if project.member %} + {% if not project.member.accepted %} +
+ Invitation +
+ {% else %} +
+ +
+ {% endif %} + {% else %} + {% if project.is_joinable or request.user.is_superuser %} +
+ +
+ {% endif %} + {% endif %} + + + + {% empty %} + + + No projects found. @@ -76,6 +129,8 @@
+
+ {% endblock %} diff --git a/projects/templates/projects/project_member_settings.html b/projects/templates/projects/project_member_settings.html new file mode 100644 index 0000000..cd30b3e --- /dev/null +++ b/projects/templates/projects/project_member_settings.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Member settings · {{ project.name }} · {{ site_title }}{% endblock %} + +{% block content %} + + +
+ +
+
+ {% csrf_token %} + + {% if messages %} +
    + {% for message in messages %} + {# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + +
+

Membership settings

+
+ +
+ {% if this_is_you %} + Your membership settings for project "{{ project.name }}". + {% else %} + Settings for project "{{ project.name }}" and user {{ user.username }}. + {% endif %} +
+ + {% if form.role %} +
+
{{ form.role.label }}
+
+ {{ form.role }} + +
+ {% if form.role.errors %} + {% for error in form.role.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.role.help_text %} +
{{ form.role.help_text|safe }}
+ {% endif %} +
+ {% endif %} + + {% if form.send_email_alerts %} +
+
{{ form.send_email_alerts.label }}
+
+ {{ form.send_email_alerts }} + +
+ {% if form.send_email_alerts.errors %} + {% for error in form.send_email_alerts.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.send_email_alerts.help_text %} +
{{ form.send_email_alerts.help_text|safe }}
+ {% endif %} +
+ {% endif %} + + + {% if this_is_you %} + Cancel {# not quite perfect, because "you" can also click on yourself in the member list #} + {% else %} + Cancel + {% endif %} +
+ +
+ +
+ + +{% endblock %} diff --git a/projects/templates/projects/project_members.html b/projects/templates/projects/project_members.html index dc19a95..d2073f1 100644 --- a/projects/templates/projects/project_members.html +++ b/projects/templates/projects/project_members.html @@ -11,11 +11,20 @@
-
-

Members

+ {% if messages %} +
    + {% for message in messages %} + {# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} -
- +
+

Project Members

+ +
@@ -34,17 +43,39 @@
- {{ 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. +
+ + + {% endfor %} @@ -52,6 +83,11 @@
+
diff --git a/projects/templates/projects/project_members_accept.html b/projects/templates/projects/project_members_accept.html new file mode 100644 index 0000000..1261e3f --- /dev/null +++ b/projects/templates/projects/project_members_accept.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Invitation · {{ project.name }} · {{ site_title }}{% endblock %} + +{% block content %} + + + +
+ +
+
+ {% csrf_token %} + +
+

Invitation to "{{ project.name }}"

+
+ +
+ You have been invited to join the project "{{ project.name }}" in the role of "{{ membership.get_role_display }}". Please confirm by clicking the button below. +
+ + + +
+ +
+
+ +{% endblock %} diff --git a/projects/templates/projects/project_members_invite.html b/projects/templates/projects/project_members_invite.html new file mode 100644 index 0000000..9f0a406 --- /dev/null +++ b/projects/templates/projects/project_members_invite.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Invite Members · {{ project.name }} · {{ site_title }}{% endblock %} + +{% block content %} + + + +
+ +
+
+ {% csrf_token %} + + {% if messages %} +
    + {% for message in messages %} + {# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + +
+

Invite members ({{ project.name }})

+
+ +
+ Invite a member to join the project "{{ project.name }}". They will receive an email with a link to join. +
+ +
+ + {% if form.email.errors %} + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.email.help_text %} +
{{ form.email.help_text|safe }}
+ {% endif %} +
+ +
{# ml-1 is strictly speaking not aligned, but visually it looks better "to me"; perhaps because of all of the round elements? #} +
{{ form.role.label }}
+
+ {{ form.role }} + +
+ {% if form.role.errors %} + {% for error in form.role.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.role.help_text %} +
{{ form.role.help_text|safe }}
+ {% endif %} +
+ + + + Cancel + +
+ +
+
+ +{% endblock %} diff --git a/projects/templates/projects/project_new.html b/projects/templates/projects/project_new.html new file mode 100644 index 0000000..02b9181 --- /dev/null +++ b/projects/templates/projects/project_new.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}New project · {{ site_title }}{% endblock %} + +{% block content %} + + +
+ +
+
+ {% csrf_token %} + +
+

New Project

+
+ + {% if form.team %} +
+
{{ form.team.label }}
+
+ {{ form.team }} + +
+ {% if form.team.errors %} + {% for error in form.team.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.team.help_text %} +
{{ form.team.help_text|safe }}
+ {% endif %} +
+ {% endif %} + + {% if form.name %} +
+
{{ form.name.label }}
+
+ {{ form.name }} + +
+ {% if form.name.errors %} + {% for error in form.name.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.name.help_text %} +
{{ form.name.help_text|safe }}
+ {% endif %} +
+ {% endif %} + + {% if form.visibility %} +
+
{{ form.visibility.label }}
+
+ {{ form.visibility }} + +
+ {% if form.visibility.errors %} + {% for error in form.visibility.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.visibility.help_text %} +
{{ form.visibility.help_text|safe }}
+ {% endif %} +
+ {% endif %} + + + Cancel + +
+ +
+
+ +{% 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 %}
New Team @@ -29,7 +28,7 @@
{% comment %} @@ -49,14 +48,14 @@ {% for team in team_list %} -
+
{{ team.name }}
{{ team.project_count }} projects | {{ team.member_count }} members {% if team.member %} - | personal settings + | my settings {% endif %}
@@ -65,7 +64,7 @@ {% if team.member %} {% if not team.member.accepted %} You're invited! - {% elif team.member.role == 1 %} {# NOTE: we intentionally hide admin-ness for non-accepted users; TODO better use of constants #} + {% elif team.member.is_admin %} {# NOTE: we intentionally hide admin-ness for non-accepted users #} Admin {% endif %} {% endif %} diff --git a/teams/views.py b/teams/views.py index e80710a..9fda9f8 100644 --- a/teams/views.py +++ b/teams/views.py @@ -225,7 +225,7 @@ def team_member_settings(request, team_pk, user_pk): this_is_you = str(user_pk) == str(request.user.id) if not this_is_you: - if not your_membership.role == TeamRole.ADMIN: + if not request.user.is_superuser or not your_membership.role == TeamRole.ADMIN: raise PermissionDenied("You are not an admin of this team") membership = TeamMembership.objects.get(team=team_pk, user=user_pk) diff --git a/templates/bugsink/home_project_list.html b/templates/bugsink/home_project_list.html deleted file mode 100644 index 5aa77d7..0000000 --- a/templates/bugsink/home_project_list.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Projects · {{ site_title }}{% endblock %} - -{% block content %} - - -
-

Projects

- -{% for project in user_projects %} -
{{ project.name }}
-{% endfor %} - -
- -{% endblock %} diff --git a/theme/templates/base.html b/theme/templates/base.html index ec19be3..db463d7 100644 --- a/theme/templates/base.html +++ b/theme/templates/base.html @@ -15,6 +15,9 @@
{{ site_title }}
+
Projects
+ + {% comment %}
+ {% endcomment %}
Teams