WIP teams & project-management

This commit is contained in:
Klaas van Schelven
2024-06-03 22:30:10 +02:00
parent 222a6906dd
commit 9d9cac3e9d
40 changed files with 1998 additions and 7 deletions

View File

@@ -25,6 +25,7 @@ DEFAULTS = {
"USER_REGISTRATION": CB_ANYBODY, # who can register new users. default: anybody, i.e. users can register themselves
"USER_REGISTRATION_VERIFY_EMAIL": True,
"USER_REGISTRATION_VERIFY_EMAIL_EXPIRY": 3 * 24 * 60 * 60, # 7 days
"TEAM_CREATION": CB_MEMBERS, # who can create new teams. default: members, which means "any member of the site"
# System inner workings:
"DIGEST_IMMEDIATELY": True,

View File

@@ -53,6 +53,7 @@ INSTALLED_APPS = [
'theme',
'snappea',
'compat',
'teams',
'projects',
'releases',
'ingest',

View File

@@ -32,6 +32,8 @@ urlpatterns = [
path('api/', include('ingest.urls')),
path('projects/', include('projects.urls')),
path('teams/', include('teams.urls')),
path('events/', include('events.urls')),
path('issues/', include('issues.urls')),

View File

@@ -0,0 +1,18 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('teams', '0002_create_single_team'),
('projects', '0008_set_project_slugs'),
]
operations = [
migrations.AddField(
model_name='project',
name='team',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='teams.team'),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations
def set_single_team(apps, schema_editor):
Team = apps.get_model('teams', 'Team')
Project = apps.get_model('projects', 'Project')
team = Team.objects.all().first() # as created in 0002_create_single_team
Project.objects.update(team=team)
class Migration(migrations.Migration):
dependencies = [
('projects', '0009_project_team'),
('teams', '0002_create_single_team'),
]
operations = [
migrations.RunPython(set_single_team),
]

View File

@@ -13,6 +13,8 @@ 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
# parser (we could also introduce a special field for that purpose but that's ugly too)
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)
@@ -75,7 +77,7 @@ class ProjectMembership(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
# TODO inheriting True/False for None from either Organization (which we also don't have yet) or directly from
# TODO inheriting True/False for None from either Team (which we also don't have yet) or directly from
# 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.
@@ -85,7 +87,7 @@ class ProjectMembership(models.Model):
# role = models.CharField(max_length=255, blank=False, null=False)
def __str__(self):
return f"{self.user} membership of {self.project}"
return f"{self.user} project membership of {self.project}"
class Meta:
unique_together = ("project", "user")

View File

@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Projects · {{ site_title }}{% endblock %}
{% block content %}
<div class="m-4">
<h1 class="text-4xl mt-4 font-bold">Projects</h1>
<div class="flex bg-slate-50 mt-4 items-end">
<div class="flex">
<a href=""><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if state_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 projects</div></a>
<a href=""><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if state_filter == "all" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">All projects</div></a>
</div>
<div class="ml-auto p-2">
<input type="text" name="search" placeholder="search projects..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md"/>
</div>
</div>
<div>
<form action="" method="post">
{% csrf_token %}
<table class="w-full">
<tbody>
{% for project in project_list %}
<tr class="bg-white border-slate-200 border-b-2">
<td class="w-full p-4">
<div>
<a href="/issues/{{ project.id }}" class="text-xl text-cyan-500 font-bold">{{ project.name }}</a>
</div>
<div>
Team {{ project.team.name }}
| 7 members {# TODO not actaully implemented #}
| {{ project.issue_set.count }} open issues</div> {# TODO not actually 'open' issues #}
</td>
<td class="pr-2">
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer" onclick="followContainedLink(this);" >
<a href="{% url 'project_members' project_pk=project.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="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
</a>
</div>
</td>
<td class="pr-2">
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer"onclick="followContainedLink(this);" >
<a href="TODO">
<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" />
</svg>
</a>
</div>
</td>
<td>
<div>
<button name="action" value="leave" 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>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/project_list.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Members · {{ project.name }} · {{ site_title }}{% endblock %}
{% block content %}
<div class="flex items-center justify-center">
<div class="m-4 max-w-4xl flex-auto">
<div class="flex">
<h1 class="text-4xl mt-4 font-bold">Members</h1>
<div class="ml-auto mt-4">
<button class="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" name="action" value="resolved_next">Invite Member</button>
</div>
</div>
<div>
<form action="" method="post">
{% csrf_token %}
<table class="w-full mt-8">
<tbody>
<thead>
<tr class="bg-slate-200">
<th class="w-full p-4 text-left text-xl" colspan="2">{{ project.name }}</th>
</tr>
{% for member in members %}
<tr class="bg-white border-slate-200 border-b-2">
<td class="w-full p-4">
<div>
<a href="TODO" class="text-xl text-cyan-500 font-bold">{{ member.user.email }}</a> {# "best name" perhaps later? #}
</div>
</td>
<td class="p-4">
<div>
<button name="action" value="leave" 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>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</div>
</div>
{% endblock %}

8
projects/urls.py Normal file
View File

@@ -0,0 +1,8 @@
from django.urls import path
from .views import project_list, project_members
urlpatterns = [
path('', project_list, name="project_list"),
path('<int:project_pk>/members/', project_members, name="project_members"),
]

View File

@@ -1,3 +1,20 @@
from django.shortcuts import render
# Create your views here.
from .models import Project
def project_list(request):
project_list = Project.objects.all()
return render(request, 'projects/project_list.html', {
'state_filter': 'mine',
'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
project = Project.objects.get(id=project_pk)
return render(request, 'projects/project_members.html', {
'project': project,
'members': project.projectmembership_set.all().select_related('user'),
})

View File

@@ -45,6 +45,7 @@ include = [
"sentry_sdk_extensions*",
"snappea*",
"static*",
"teams*",
"templates*",
"theme*",
"users*",

View File

@@ -0,0 +1,6 @@
"use strict";
function followContainedLink(circleDiv) {
const link = circleDiv.querySelector("a");
window.location.href = link.href;
}

6
static/js/team_list.js Normal file
View File

@@ -0,0 +1,6 @@
"use strict";
function followContainedLink(circleDiv) {
const link = circleDiv.querySelector("a");
window.location.href = link.href;
}

0
teams/__init__.py Normal file
View File

61
teams/admin.py Normal file
View File

@@ -0,0 +1,61 @@
from django.contrib import admin
from admin_auto_filters.filters import AutocompleteFilter
from .models import Team, TeamMembership
class TeamFilter(AutocompleteFilter):
title = 'Team'
field_name = 'team'
class UserFilter(AutocompleteFilter):
title = 'User'
field_name = 'user'
class TeamMembershipInline(admin.TabularInline):
model = TeamMembership
autocomplete_fields = [
'user',
]
extra = 0
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
search_fields = [
'name',
]
list_display = [
'name',
]
readonly_fields = [
]
inlines = [
TeamMembershipInline,
]
# the preferred way to deal with TeamMembership is actually through the inline above; however, because this may prove
# to not scale well with (very? more than 50?) memberships per team, we've left the separate admin interface here for
# future reference.
@admin.register(TeamMembership)
class TeamMembershipAdmin(admin.ModelAdmin):
list_filter = [
TeamFilter,
UserFilter,
]
list_display = [
'team',
'user',
]
autocomplete_fields = [
'team',
'user',
]

6
teams/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TeamsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'teams'

26
teams/forms.py Normal file
View File

@@ -0,0 +1,26 @@
from django import forms
from django.contrib.auth import get_user_model
from .models import TeamRole
User = get_user_model()
class TeamMemberInviteForm(forms.Form):
email = forms.EmailField(label='Email', required=True)
role = forms.ChoiceField(
label='Role', choices=TeamRole.choices, required=True, initial=TeamRole.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

View File

@@ -0,0 +1,36 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Team',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('slug', models.SlugField()),
],
),
migrations.CreateModel(
name='TeamMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.IntegerField(choices=[(0, 'Member'), (1, 'Admin')], default=0)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='teams.team')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('team', 'user')},
},
),
]

View File

@@ -0,0 +1,25 @@
from django.db import migrations
def create_single_team(apps, schema_editor):
# if needed (for existing projects); this should not be preserved when we squash/restart migrations
Project = apps.get_model('projects', 'Project')
Team = apps.get_model('teams', 'Team')
if Project.objects.count() == 0:
return
Team.objects.create(name='Single Team', slug='single-team')
class Migration(migrations.Migration):
dependencies = [
('teams', '0001_initial'),
('projects', '0008_set_project_slugs'),
]
operations = [
migrations.RunPython(create_single_team),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams', '0002_create_single_team'),
]
operations = [
migrations.AddField(
model_name='team',
name='visibility',
field=models.IntegerField(choices=[(0, 'Public'), (1, 'Visible'), (2, 'Hidden')], default=0),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams', '0003_team_visibility'),
]
operations = [
migrations.AddField(
model_name='teammembership',
name='accepted',
field=models.BooleanField(default=False),
),
]

View File

43
teams/models.py Normal file
View File

@@ -0,0 +1,43 @@
import uuid
from django.db import models
from django.conf import settings
class TeamRole(models.IntegerChoices):
MEMBER = 0
ADMIN = 1
class TeamVisibility(models.IntegerChoices):
PUBLIC = 0 # anyone can join(?); or even just click-through(?)
VISIBLE = 1 # the team is visible, you can request to join(?), but this needs to be approved
HIDDEN = 2 # the team is not visible to non-members; you need to be invited
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)
slug = models.SlugField(max_length=50, blank=False, null=False)
visibility = models.IntegerField(choices=TeamVisibility.choices, default=TeamVisibility.PUBLIC)
def __str__(self):
return self.name
class TeamMembership(models.Model):
team = models.ForeignKey(Team, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
# send_email_alerts = models.BooleanField(default=True) TODO (see also Project)
role = models.IntegerField(choices=TeamRole.choices, default=TeamRole.MEMBER)
accepted = models.BooleanField(default=False)
def __str__(self):
return f"{self.user} team membership of {self.team}"
class Meta:
unique_together = ("team", "user")

47
teams/tasks.py Normal file
View File

@@ -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 Team
@shared_task
def send_team_invite_email_new_user(email, team_pk, token):
team = Team.objects.get(pk=team_pk)
send_rendered_email(
subject='You have been invited to join "%s"' % team.name,
base_template_name="mails/team_membership_invite_new_user",
recipient_list=[email],
context={
"site_title": get_settings().SITE_TITLE,
"base_url": get_settings().BASE_URL + "/",
"team": team,
"url": reverse("team_members_accept_new_user", kwargs={
"token": token,
"team_pk": team_pk,
}),
},
)
@shared_task
def send_team_invite_email(email, team_pk):
team = Team.objects.get(pk=team_pk)
send_rendered_email(
subject='You have been invited to join "%s"' % team.name,
base_template_name="mails/team_membership_invite",
recipient_list=[email],
context={
"site_title": get_settings().SITE_TITLE,
"base_url": get_settings().BASE_URL + "/",
"team": team,
"url": reverse("team_members_accept", kwargs={
"team_pk": team_pk,
}),
},
)

View File

@@ -0,0 +1,519 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="color-scheme: light dark; supported-color-schemes: light dark;">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title></title>
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;display=swap");
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
.u-margin-bottom-none {
margin-bottom: 0;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 45px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3,
span,
.purchase_item {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
<style type="text/css" rel="stylesheet" media="all">
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
</style>
</head>
<body style="width: 100% !important; height: 100%; -webkit-text-size-adjust: none; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; background-color: #F2F4F6; color: #51545E; margin: 0;" bgcolor="#F2F4F6">
<span class="preheader" style="display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden;">{# 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 "{{ team.name }}".</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; background-color: #F2F4F6; margin: 0; padding: 0;" bgcolor="#F2F4F6">
<tr>
<td align="center" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr>
<td class="email-masthead" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;" align="center">
<a href="{{ base_url }}" class="f-fallback email-masthead_name" style="color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;">
{{ site_title }}
</a>
</td>
</tr>
{# Email Body #}
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation" style="width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; background-color: #FFFFFF; margin: 0 auto; padding: 0;" bgcolor="#FFFFFF">
{# Body content #}
<tr>
<td class="content-cell" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; padding: 45px;">
<div class="f-fallback">
<h1 style="margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left;" align="left">Invitation to "{{ team.name}}".</h1>
<p style="font-size: 16px; line-height: 1.625; color: #51545E; margin: 1.1875em 0 1.1875em;">
You have been invited to join the team "{{ team.name }}" on {{ site_title }}.
</p>
{# Action #}
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;">
<tr>
<td align="center" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
{# Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design #}
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<a href="{{ url }}" class="f-fallback button button--green" target="_blank" style="color: #51545E; background-color: #A5F3FC; display: inline-block; text-decoration: none; border-radius: 3px; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); -webkit-text-size-adjust: none; box-sizing: border-box; border-color: #A5F3FC; border-style: solid; border-width: 10px 18px;"><b>View invitation</b></a>
</td>
</tr>
</table>
</td>
</tr>
</table>
{# Sub copy #}
<table class="body-sub" role="presentation" style="margin-top: 25px; padding-top: 25px; border-top-width: 1px; border-top-color: #EAEAEC; border-top-style: solid;">
<tr>
<td style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<p class="f-fallback sub" style="font-size: 13px; line-height: 1.625; color: #51545E; margin: .4em 0 0;">Copyable link:</p>
<p class="f-fallback sub" style="font-size: 13px; line-height: 1.625; color: #51545E; margin: 0 0 1.1875em;">{{ url }}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,5 @@
You have been invited to join the team "{{ team.name }}" on {{ site_title }}.
View, accept or reject the invitation by clicking the link below:
{{ url }}

View File

@@ -0,0 +1,519 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns="http://www.w3.org/1999/xhtml" style="color-scheme: light dark; supported-color-schemes: light dark;">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title></title>
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&amp;display=swap");
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
.u-margin-bottom-none {
margin-bottom: 0;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 45px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3,
span,
.purchase_item {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
<style type="text/css" rel="stylesheet" media="all">
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
</style>
</head>
<body style="width: 100% !important; height: 100%; -webkit-text-size-adjust: none; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; background-color: #F2F4F6; color: #51545E; margin: 0;" bgcolor="#F2F4F6">
<span class="preheader" style="display: none !important; visibility: hidden; mso-hide: all; font-size: 1px; line-height: 1px; max-height: 0; max-width: 0; opacity: 0; overflow: hidden;">{# 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 "{{ team.name }}".</span>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; background-color: #F2F4F6; margin: 0; padding: 0;" bgcolor="#F2F4F6">
<tr>
<td align="center" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr>
<td class="email-masthead" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; text-align: center; padding: 25px 0;" align="center">
<a href="{{ base_url }}" class="f-fallback email-masthead_name" style="color: #A8AAAF; font-size: 16px; font-weight: bold; text-decoration: none; text-shadow: 0 1px 0 white;">
{{ site_title }}
</a>
</td>
</tr>
{# Email Body #}
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; margin: 0; padding: 0;">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation" style="width: 570px; -premailer-width: 570px; -premailer-cellpadding: 0; -premailer-cellspacing: 0; background-color: #FFFFFF; margin: 0 auto; padding: 0;" bgcolor="#FFFFFF">
{# Body content #}
<tr>
<td class="content-cell" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px; padding: 45px;">
<div class="f-fallback">
<h1 style="margin-top: 0; color: #333333; font-size: 22px; font-weight: bold; text-align: left;" align="left">Invitation to "{{ team.name}}".</h1>
<p style="font-size: 16px; line-height: 1.625; color: #51545E; margin: 1.1875em 0 1.1875em;">
You have been invited to join {{ site_title }} as part of the team "{{ team.name }}".
</p>
{# Action #}
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;">
<tr>
<td align="center" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
{# Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design #}
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center" style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<a href="{{ url }}" class="f-fallback button button--green" target="_blank" style="color: #51545E; background-color: #A5F3FC; display: inline-block; text-decoration: none; border-radius: 3px; box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16); -webkit-text-size-adjust: none; box-sizing: border-box; border-color: #A5F3FC; border-style: solid; border-width: 10px 18px;"><b>View invitation</b></a>
</td>
</tr>
</table>
</td>
</tr>
</table>
{# Sub copy #}
<table class="body-sub" role="presentation" style="margin-top: 25px; padding-top: 25px; border-top-width: 1px; border-top-color: #EAEAEC; border-top-style: solid;">
<tr>
<td style="word-break: break-word; font-family: &quot;Nunito Sans&quot;, Helvetica, Arial, sans-serif; font-size: 16px;">
<p class="f-fallback sub" style="font-size: 13px; line-height: 1.625; color: #51545E; margin: .4em 0 0;">Copyable link:</p>
<p class="f-fallback sub" style="font-size: 13px; line-height: 1.625; color: #51545E; margin: 0 0 1.1875em;">{{ url }}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,5 @@
You have been invited to join {{ site_title }} as part of the team "{{ team.name }}".
View, accept or reject the invitation by clicking the link below:
{{ url }}

View File

@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Teams · {{ site_title }}{% endblock %}
{% block content %}
<div class="m-4">
<h1 class="text-4xl mt-4 font-bold">Teams</h1>
<div class="flex bg-slate-50 mt-4 items-end">
<div class="flex">
<a href=""><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if state_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=""><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if state_filter == "all" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">All Teams</div></a>
</div>
<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>
</div>
<div>
<form action="" method="post">
{% csrf_token %}
<table class="w-full">
<tbody>
{% 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-slate-800">
{{ team.name }}
</div>
<div>
{{ team.project_set.count }} projects | {{ team.teammembership_set.count }} members
</td>
<td class="pr-2">
<span class="bg-cyan-100 rounded-2xl px-4 py-2 ml-2 text-sm">Admin</span>
</td>
<td class="pr-2">
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer" onclick="followContainedLink(this);" >
<a href="{% url 'team_members' 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="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
</a>
</div>
</td>
<td class="pr-2">
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer"onclick="followContainedLink(this);" >
<a href="TODO">
<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" />
</svg>
</a>
</div>
</td>
<td>
<div>
<button name="action" value="leave" 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>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/team_list.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Members · {{ team.name }} · {{ site_title }}{% endblock %}
{% block content %}
<div class="flex items-center justify-center">
<div class="m-4 max-w-4xl flex-auto">
{% if messages %}
<ul class="mb-4">
{% for message in messages %}
{# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
<li class="bg-cyan-50 border-2 border-cyan-800 p-4 rounded-lg">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div class="flex">
<h1 class="text-4xl mt-4 font-bold">Team Members</h1>
<div class="ml-auto mt-6">
<a class="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_members_invite" team_pk=team.pk %}">Invite Member</a>
</div>
</div>
<div>
<form action="" method="post">
{% csrf_token %}
<table class="w-full mt-8">
<tbody>
<thead>
<tr class="bg-slate-200">
<th class="w-full p-4 text-left text-xl" colspan="2">{{ team.name }}</th>
</tr>
{% for member in members %}
<tr class="bg-white border-slate-200 border-b-2">
<td class="w-full p-4">
<div>
<a href="TODO" class="text-xl text-cyan-500 font-bold">{{ member.user.email }}</a> {# "best name" perhaps later? #}
{% if not member.accepted %}
<span class="bg-slate-100 rounded-2xl px-4 py-2 ml-2 text-sm">Invited</span>
{% elif member.role == 0 %} {# NOTE: we intentionally hide admin-ness for non-accepted users; TODO better use of constants #}
<span class="bg-cyan-100 rounded-2xl px-4 py-2 ml-2 text-sm">Admin</span>
{% endif %}
</div>
</td>
<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="delete-%s" 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>
{% else %} {# NOTE: in our setup request_user_is_admin is implied because only admins may view the membership page #}
<button name="action" value="delete-%s" 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>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Invitation · {{ 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">Invitation to "{{ team.name }}"</h1>
</div>
<div class="mt-4 mb-4">
You have been invited to join the team "{{ team.name }}" in the role of "{{ membership.get_role_display }}". Please confirm by clicking the button below.
</div>
<button name="action" value="accept" 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">Accept</button>
<button name="action" value="decline" 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">Decline</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Invite Members · {{ 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 %}
{% if messages %}
<ul>
{% for message in messages %}
{# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
<li class="bg-cyan-50 border-2 border-cyan-800 p-4 rounded-lg">{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
<div>
<h1 class="text-4xl mt-4 font-bold">Invite members ({{ team.name }})</h1>
</div>
<div class="mt-4 mb-4">
Invite a member to join the team "{{ team.name }}". They will receive an email with a link to join.
</div>
<div class="text-lg mb-6 md:mb-8">
<input name="email" type="text" class="{% if form.email.errors %}bg-red-100{% else %}bg-slate-100{% endif %} pl-4 py-2 md:py-4 focus:outline-none w-full" {% if form.email.value %}value="{{ form.email.value }}"{% endif %} placeholder="{{ form.email.label }}" />
{% if form.email.errors %}
{% for error in form.email.errors %}
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
{% endfor %}
{% elif form.email.help_text %}
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.email.help_text|safe }}</div>
{% endif %}
</div>
<div class="text-lg ml-1 mb-8"> {# ml-1 is strictly speaking not aligned, but visually it looks better "to me"; perhaps because of all of the round elements? #}
<div class="text-slate-800 font-bold">{{ form.role.label }}</div>
<div class="flex items-center">
{{ form.role }}
</div>
{% if form.role.errors %}
{% for error in form.role.errors %}
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
{% endfor %}
{% elif form.role.help_text %}
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.role.help_text|safe }}</div>
{% endif %}
</div>
<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">Invite Member</button>
<button name="action" value="invite_and_add_another" 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">Invite and add another</button>
<a href="{% url "team_members" team_pk=team.pk %}" class="font-bold text-slate-500 ml-4">Cancel</a>
</form>
</div>
</div>
{% endblock %}

3
teams/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

12
teams/urls.py Normal file
View File

@@ -0,0 +1,12 @@
from django.urls import path
from .views import team_list, team_members, team_members_invite, team_members_accept_new_user, team_members_accept
urlpatterns = [
path('', team_list, name="team_list"),
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"),
path(
'<str:team_pk>/members/accept/<str:token>/', team_members_accept_new_user, name="team_members_accept_new_user"),
]

152
teams/views.py Normal file
View File

@@ -0,0 +1,152 @@
from datetime import timedelta
from django.shortcuts import render, redirect
from django.contrib.auth import get_user_model, login
from django.http import Http404, HttpResponseRedirect
from django.utils import timezone
from django.urls import reverse
from django.contrib import messages
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
from .tasks import send_team_invite_email, send_team_invite_email_new_user
User = get_user_model()
def team_list(request):
team_list = Team.objects.all()
return render(request, 'teams/team_list.html', {
'state_filter': 'mine',
'team_list': team_list,
})
def team_members(request, team_pk):
# TODO: check if user is a member of the team and has permission to view this page
team = Team.objects.get(id=team_pk)
return render(request, 'teams/team_members.html', {
'team': team,
'members': team.teammembership_set.all().select_related('user'),
})
def team_members_invite(request, team_pk):
# TODO: check if user is a member of the team and has permission to view this page
team = Team.objects.get(id=team_pk)
user_must_exist = True # TODO implement based on USER_REGISTRATION setting and how it compares to the current user
user_must_exist = False
if request.method == 'POST':
form = TeamMemberInviteForm(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})
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)
_, membership_created = TeamMembership.objects.get_or_create(team=team, 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('team_members_invite', team_pk=team_pk)
# I think this is enough feedback, as the user will just show up there
return redirect('team_members', team_pk=team_pk)
else:
form = TeamMemberInviteForm(user_must_exist)
return render(request, 'teams/team_members_invite.html', {
'team': team,
'form': form,
})
@login_exempt # no login is required, the token is what identifies the user
def team_members_accept_new_user(request, team_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("set_password", kwargs={"token": token}) + "?next=" + reverse(
team_members_accept, kwargs={"team_pk": team_pk})
)
# TODO: thoughts about showing the user what's going on.
# 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 user exists yet. However, it is possible that a user ends up here while already
# having registered, 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 team-accept
# TODO: check how this interacts with login_[not]_required.... my thinking is: we should just do a login() here
# and should be OK; but this needs to be tested.
login(request, user)
return team_members_accept(request, team_pk)
def team_members_accept(request, team_pk):
team = Team.objects.get(id=team_pk)
membership = TeamMembership.objects.get(team=team, user=request.user)
if membership.accepted:
return redirect() # TODO same question as below
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() # TODO what's a good thing to show for any given team? we don't have that yet I think.
raise Http404("Invalid action")
return render(request, "teams/team_members_accept.html", {"team": team, "membership": membership})

File diff suppressed because one or more lines are too long

View File

@@ -204,3 +204,9 @@ pre { line-height: 125%; }
.syntax-coloring .vi { color: #19177C } /* Name.Variable.Instance */
.syntax-coloring .vm { color: #19177C } /* Name.Variable.Magic */
.syntax-coloring .il { color: #666666 } /* Literal.Number.Integer.Long */
input[type='radio'] {
/* I wanted to style the whole of the radio button in a non-navy color (something that fits more with what we
do generally but I didn't manage to get it done in the self-allotted time. I'm still seeing a navy outer ring */
color: rgb(6 182 212); /* cyan-500 */
}

View File

@@ -3,7 +3,6 @@ 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

View File

@@ -38,6 +38,8 @@
{% endif %}
</div>
<input type="hidden" name="next" value="{{ next }}" />
<button class="bg-slate-800 font-medium p-2 md:p-4 text-white uppercase w-full">Sign up</button>
</form>

View File

@@ -108,6 +108,8 @@ def request_reset_password(request):
def reset_password(request, token=None):
# alternative name: set_password (because this one also works for initial setting of a password)
# 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(
@@ -120,6 +122,7 @@ def reset_password(request, token=None):
raise Http404("Invalid or expired token")
user = verification.user
next = request.POST.get("next", request.GET.get("next", redirect('home')))
if request.method == 'POST':
form = SetPasswordForm(user, request.POST)
@@ -131,12 +134,13 @@ def reset_password(request, token=None):
verification.delete()
login(request, verification.user)
return redirect('home')
return redirect(next)
else:
form = SetPasswordForm(user)
return render(request, "users/reset_password.html", {"form": form})
return render(request, "users/reset_password.html", {"form": form, "next": next})
DEBUG_CONTEXTS = {