mirror of
https://github.com/jlengrand/bugsink.git
synced 2026-03-10 08:01:17 +00:00
WIP teams & project-management (4)
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
60
teams/templates/teams/team_edit.html
Normal file
60
teams/templates/teams/team_edit.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
60
teams/templates/teams/team_new.html
Normal file
60
teams/templates/teams/team_new.html
Normal 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 %}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
2
theme/static/css/dist/styles.css
vendored
2
theme/static/css/dist/styles.css
vendored
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user