WIP teams & project-management (4)

This commit is contained in:
Klaas van Schelven
2024-06-05 22:36:05 +02:00
parent 09a26755e7
commit 0e4f13838e
10 changed files with 244 additions and 31 deletions

View File

@@ -2,7 +2,7 @@ from django import forms
from django.contrib.auth import get_user_model
from django.template.defaultfilters import yesno
from .models import TeamRole, TeamMembership
from .models import TeamRole, TeamMembership, Team
User = get_user_model()
@@ -55,3 +55,9 @@ class TeamMembershipForm(forms.ModelForm):
class Meta:
model = TeamMembership
fields = ["role"]
class TeamForm(forms.ModelForm):
class Meta:
model = Team
fields = ["name", "visibility"]

View File

@@ -19,7 +19,7 @@ class TeamVisibility(models.IntegerChoices):
class Team(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255, 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)
visibility = models.IntegerField(choices=TeamVisibility.choices, default=TeamVisibility.PUBLIC)

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Edit {{ team.name }} · {{ site_title }}{% endblock %}
{% block content %}
<div class="flex items-center justify-center">
<div class="m-4 max-w-4xl flex-auto">
<form action="" method="post">
{% csrf_token %}
<div>
<h1 class="text-4xl mt-4 font-bold">{{ team.name }}</h1>
</div>
{% if form.name %}
<div class="text-lg mb-8">
<div class="text-slate-800 font-bold">{{ form.name.label }}</div>
<div class="flex items-center">
{{ form.name }}
</div>
{% if form.name.errors %}
{% for error in form.name.errors %}
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
{% endfor %}
{% elif form.name.help_text %}
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.name.help_text|safe }}</div>
{% endif %}
</div>
{% endif %}
{% if form.visibility %}
<div class="text-lg mb-8">
<div class="text-slate-800 font-bold">{{ form.visibility.label }}</div>
<div class="flex items-center">
{{ form.visibility }}
</div>
{% if form.visibility.errors %}
{% for error in form.visibility.errors %}
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
{% endfor %}
{% elif form.visibility.help_text %}
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.visibility.help_text|safe }}</div>
{% endif %}
</div>
{% endif %}
<button name="action" value="invite" class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md">Save</button>
<a href="{% url "team_list" %}" class="text-cyan-500 font-bold ml-2">Cancel</a>
</form>
</div>
</div>
{% endblock %}

View File

@@ -7,18 +7,36 @@
<div class="m-4">
<h1 class="text-4xl mt-4 font-bold">Teams</h1>
<div class="m-4 flex flex-row items-end">
<div><!-- top, LHS (h1) -->
<h1 class="text-4xl mt-4 font-bold">Teams</h1>
</div>
{# align to bottom #}
<div class="ml-auto"><!-- top, RHS (buttons) -->
{# the below is not correct, but what is? #}
{% if perms.teams.add_team %}
<div>
<a class="block font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md" href="{% url 'team_new' %}">New Team</a>
</div>
{% endif %}
</div> {# top, RHS (buttons) #}
</div>
<div class="m-4"><!-- main content -->
<div class="flex bg-slate-50 mt-4 items-end">
<div class="flex">
<a href="{% url "team_list" %}"><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if ownership_filter == "mine" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">My Teams</div></a>
<a href="{% url "team_list_other" %}"><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if ownership_filter == "other" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">Other Teams</div></a>
</div>
{% comment %}
<div class="ml-auto p-2">
<input type="text" name="search" placeholder="search teams..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md"/>
</div>
{% endcomment %}
</div>
<div>
@@ -31,13 +49,15 @@
{% for team in team_list %}
<tr class="bg-white border-slate-200 border-b-2">
<td class="w-full p-4">
<div class="text-xl font-bold text-cyan-500">
<a href={% url "team_member_settings" team_pk=team.id user_pk=request.user.id %}>{{ team.name }}</a>
<div class="text-xl font-bold">
{{ team.name }}
</div>
<div>
{{ team.project_count }} projects | {{ team.member_count }} members
{{ team.project_count }} projects
| {{ team.member_count }} members
{% if team.member %}
| <a href="{% url 'team_member_settings' team_pk=team.id user_pk=request.user.id %}" class="font-bold text-cyan-500">personal settings</a>
{% endif %}
</div>
</td>
@@ -66,7 +86,7 @@
<td class="pr-2">
{% if team.member.is_admin %}
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer"onclick="followContainedLink(this);" >
<a href="TODO">
<a href="{% url "team_edit" team_pk=team.id %}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
@@ -82,7 +102,7 @@
<div>
<a href="{% url 'team_members_accept' team_pk=team.id %}" class="font-bold text-cyan-500">Invitation</a>
</div>
{% elif team.member.role == 1 %} {# NOTE: we intentionally hide admin-ness for non-accepted users; TODO better use of constants #}
{% else %}
<div>
<button name="action" value="leave:{{ team.id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-md">Leave</button>
</div>
@@ -107,6 +127,8 @@
</div>
</div>
</div>
{% endblock %}

View File

@@ -67,11 +67,17 @@
</div>
{% endif %}
<button name="action" value="invite" class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md">Save</button>
<button class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md">Save</button>
{% if this_is_you %}
<a href="{% url "team_list" %}" class="text-cyan-500 font-bold ml-2">Cancel</a> {# not quite perfect, because "you" can also click on yourself in the member list #}
{% else %}
<a href="{% url "team_members" team_pk=team.pk %}" class="text-cyan-500 font-bold ml-2">Cancel</a>
{% endif %}
</form>
</div>
</div>
{% endblock %}

View File

@@ -54,10 +54,13 @@
<td class="p-4">
<div class="flex justify-end">
{% if request.user == member.user %} {# TODO: do not allow leaving when there is only a single admin #}
<button name="action" value="remove:{{ member.user_id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-md">Leave</button>
{% if not member.accepted %}
<button name="action" value="reinvite:{{ member.user_id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Reinvite</button>
{% endif %}
{% if request.user == member.user %}
<button name="action" value="remove:{{ member.user_id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Leave</button>
{% else %} {# NOTE: in our setup request_user_is_admin is implied because only admins may view the membership page #}
<button name="action" value="remove:{{ member.user_id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-md">Remove</button>
<button name="action" value="remove:{{ member.user_id }}" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Remove</button>
{% endif %}
</div>
</td>
@@ -80,6 +83,10 @@
</form>
</div>
<div class="flex flex-direction-row">
<div class="ml-auto py-8 pr-4">
<a href="{% url "team_list" %}" class="text-cyan-500 font-bold">Back to Teams</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% load static %}
{% block title %}New team · {{ site_title }}{% endblock %}
{% block content %}
<div class="flex items-center justify-center">
<div class="m-4 max-w-4xl flex-auto">
<form action="" method="post">
{% csrf_token %}
<div>
<h1 class="text-4xl mt-4 font-bold">New Team</h1>
</div>
{% if form.name %}
<div class="text-lg mb-8">
<div class="text-slate-800 font-bold">{{ form.name.label }}</div>
<div class="flex items-center">
{{ form.name }}
</div>
{% if form.name.errors %}
{% for error in form.name.errors %}
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
{% endfor %}
{% elif form.name.help_text %}
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.name.help_text|safe }}</div>
{% endif %}
</div>
{% endif %}
{% if form.visibility %}
<div class="text-lg mb-8">
<div class="text-slate-800 font-bold">{{ form.visibility.label }}</div>
<div class="flex items-center">
{{ form.visibility }}
</div>
{% if form.visibility.errors %}
{% for error in form.visibility.errors %}
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
{% endfor %}
{% elif form.visibility.help_text %}
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.visibility.help_text|safe }}</div>
{% endif %}
</div>
{% endif %}
<button name="action" value="invite" class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md">Save</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -2,11 +2,13 @@ from django.urls import path
from .views import (
team_list, team_members, team_members_invite, team_members_accept_new_user, team_members_accept,
team_member_settings)
team_member_settings, team_new, team_edit)
urlpatterns = [
path('', team_list, name="team_list"),
path('other/', team_list, kwargs={"ownership_filter": "other"}, name="team_list_other"),
path('new/', team_new, name="team_new"),
path('<str:team_pk>/edit/', team_edit, name="team_edit"),
path('<str:team_pk>/members/', team_members, name="team_members"),
path('<str:team_pk>/members/invite/', team_members_invite, name="team_members_invite"),
path('<str:team_pk>/members/accept/', team_members_accept, name="team_members_accept"),

View File

@@ -8,13 +8,14 @@ from django.core.exceptions import PermissionDenied
from django.utils import timezone
from django.urls import reverse
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from users.models import EmailVerification
from bugsink.app_settings import get_settings
from bugsink.decorators import login_exempt
from .models import Team, TeamMembership, TeamRole
from .forms import TeamMemberInviteForm, TeamMembershipForm, MyTeamMembershipForm
from .forms import TeamMemberInviteForm, TeamMembershipForm, MyTeamMembershipForm, TeamForm
from .tasks import send_team_invite_email, send_team_invite_email_new_user
User = get_user_model()
@@ -59,15 +60,58 @@ def team_list(request, ownership_filter="mine"):
})
@permission_required("teams.add_team")
def team_new(request):
if request.method == 'POST':
form = TeamForm(request.POST)
if form.is_valid():
team = form.save()
# the user who creates the team is automatically an (accepted) admin of the team
TeamMembership.objects.create(team=team, user=request.user, role=TeamRole.ADMIN, accepted=True)
return redirect('team_members', team_pk=team.id)
else:
form = TeamForm()
return render(request, 'teams/team_new.html', {
'form': form,
})
@permission_required("teams.edit_team")
def team_edit(request, team_pk):
team = Team.objects.get(id=team_pk)
if request.method == 'POST':
form = TeamForm(request.POST, instance=team)
if form.is_valid():
form.save()
return redirect('team_members', team_pk=team.id)
else:
form = TeamForm(instance=team)
return render(request, 'teams/team_edit.html', {
'team': team,
'form': form,
})
def team_members(request, team_pk):
# TODO: check if user is a member of the team and has permission to view this page
if request.method == 'POST':
full_action_str = request.POST.get('action')
action, user_id = full_action_str.split(":", 1)
assert action == "remove", "Invalid action"
TeamMembership.objects.filter(team=team_pk, user=user_id).delete()
# messages.success("User removed from team") I think this will be obvious enough
if action == "remove":
TeamMembership.objects.filter(team=team_pk, user=user_id).delete()
elif action == "reinvite":
user = User.objects.get(id=user_id)
_send_team_invite_email(user, team_pk)
messages.success(request, f"Invitation resent to {user.email}")
team = Team.objects.get(id=team_pk)
return render(request, 'teams/team_members.html', {
@@ -76,6 +120,17 @@ def team_members(request, team_pk):
})
def _send_team_invite_email(user, team_pk):
"""Send an email to a user inviting them to a team; (for new users this includes the email-verification link)"""
if user.is_active:
send_team_invite_email.delay(user.email, team_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_team_invite_email_new_user.delay(user.email, team_pk, verification.token)
def team_members_invite(request, team_pk):
# TODO: check if user is a member of the team and has permission to view this page
@@ -95,13 +150,7 @@ def team_members_invite(request, team_pk):
user, user_created = User.objects.get_or_create(
email=email, defaults={'username': email, 'is_active': False})
if user.is_active:
send_team_invite_email.delay(email, team_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_team_invite_email_new_user.delay(email, team_pk, verification.token)
_send_team_invite_email(user, team_pk)
_, membership_created = TeamMembership.objects.get_or_create(team=team, user=user, defaults={
'role': form.cleaned_data['role'],
@@ -212,7 +261,8 @@ def team_members_accept(request, team_pk):
membership = TeamMembership.objects.get(team=team, user=request.user)
if membership.accepted:
return redirect() # TODO same question as below
# i.e. the user has already accepted the invite, we just silently redirect as if they had just done so
return redirect("team_member_settings", team_pk=team_pk, user_pk=request.user.id)
if request.method == 'POST':
# no need for a form, it's just a pair of buttons
@@ -223,7 +273,7 @@ def team_members_accept(request, team_pk):
if request.POST["action"] == "accept":
membership.accepted = True
membership.save()
return redirect() # TODO what's a good thing to show for any given team? we don't have that yet I think.
return redirect("team_member_settings", team_pk=team_pk, user_pk=request.user.id)
raise Http404("Invalid action")

File diff suppressed because one or more lines are too long