WIP teams & project-management (6)

not extensively tested, but it starts to feel quite complete 'for now'
This commit is contained in:
Klaas van Schelven
2024-06-07 10:37:28 +02:00
parent 07a3de6c8d
commit de8bd65a3a
31 changed files with 2177 additions and 70 deletions

View File

@@ -4,24 +4,22 @@ from django.conf import settings
from django.views.decorators.http import require_GET
from django.views.decorators.cache import cache_control
from django.http import FileResponse, HttpRequest, HttpResponse
from django.shortcuts import render
from bugsink.decorators import login_exempt
def home(request):
project_count = request.user.project_set.all().count()
if project_count == 0:
return redirect("team_list")
elif project_count == 1:
if request.user.project_set.filter(projectmembership__accepted=True).distinct().count() == 1:
# if the user has exactly one project, we redirect them to that project
project = request.user.project_set.get()
return redirect("issue_list_open", project_id=project.id)
return redirect("issue_list_open", project_pk=project.id)
return render(request, "bugsink/home_project_list.html", {
# user_projecs is in the context_processor, we don't need to pass it here
})
elif request.user.project_set.all().distinct().count() > 0:
# note: no filter on projectmembership__accepted=True here; if there is _any_ project, we show the project list
return redirect("project_list")
# final fallback: show the team list
return redirect("team_list")
@login_exempt

View File

@@ -43,6 +43,9 @@ class ProjectAdmin(admin.ModelAdmin):
inlines = [
ProjectMembershipInline,
]
prepopulated_fields = {
'slug': ['name'],
}
# the preferred way to deal with ProjectMembership is actually through the inline above; however, because this may prove

83
projects/forms.py Normal file
View File

@@ -0,0 +1,83 @@
from django import forms
from django.contrib.auth import get_user_model
from django.template.defaultfilters import yesno
from teams.models import TeamMembership
from .models import Project, ProjectMembership, ProjectRole
User = get_user_model()
class ProjectMemberInviteForm(forms.Form):
email = forms.EmailField(label='Email', required=True)
role = forms.ChoiceField(
label='Role', choices=ProjectRole.choices, required=True, initial=ProjectRole.MEMBER, widget=forms.RadioSelect)
def __init__(self, user_must_exist, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user_must_exist = user_must_exist
if user_must_exist:
self.fields['email'].help_text = "The user must already exist in the system"
def clean_email(self):
email = self.cleaned_data['email']
if self.user_must_exist and not User.objects.filter(email=email).exists():
raise forms.ValidationError('No user with this email address in the system.')
return email
class MyProjectMembershipForm(forms.ModelForm):
"""Edit _your_ ProjectMembership, i.e. email-settings, and role only for admins"""
class Meta:
model = ProjectMembership
fields = ["send_email_alerts", "role"]
def __init__(self, *args, **kwargs):
edit_role = kwargs.pop("edit_role")
super().__init__(*args, **kwargs)
assert self.instance is not None, "This form is only implemented for editing"
if not edit_role:
del self.fields['role']
# True as default ... same TODO as in teams/forms.py
try:
tm = TeamMembership.objects.get(team=self.instance.project.team, user=self.instance.user)
team_send_email_alerts = tm.send_email_alerts if tm.send_email_alerts is not None else True
except TeamMembership.DoesNotExist:
team_send_email_alerts = True
empty_label = "Team-default (currently: %s)" % yesno(team_send_email_alerts)
self.fields['send_email_alerts'].empty_label = empty_label
self.fields['send_email_alerts'].widget.choices[0] = ("unknown", empty_label)
class ProjectForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
team_qs = kwargs.pop("team_qs", None)
super().__init__(*args, **kwargs)
if self.instance is not None and self.instance.pk is not None:
# for editing, we disallow changing the team. consideration: it's somewhat hard to see what the consequences
# for authorization are (from the user's perspective).
del self.fields["team"]
# if we ever push slug to the form, editing it should probably be disallowed as well (but mainly because it
# has consequences on the issue's short identifier)
# del self.fields["slug"]
else:
self.fields["team"].queryset = team_qs
class Meta:
model = Project
fields = ["team", "name", "visibility"]
# "slug", <= for now, we just do this in the model; if we want to do it in the form, I would want to have some
# JS in place like we have in the admin. django/contrib/admin/static/admin/js/prepopulate.js is an example of
# how Django does this (but it requires JQuery)
# "alert_on_new_issue", "alert_on_regression", "alert_on_unmute" later

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0010_set_single_team'),
]
operations = [
migrations.AddField(
model_name='projectmembership',
name='accepted',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='projectmembership',
name='role',
field=models.IntegerField(choices=[(0, 'Member'), (1, 'Admin')], default=0),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0011_projectmembership_accepted_projectmembership_role'),
]
operations = [
migrations.AlterField(
model_name='projectmembership',
name='send_email_alerts',
field=models.BooleanField(default=None, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.13 on 2024-06-06 12:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0012_alter_projectmembership_send_email_alerts'),
]
operations = [
migrations.AddField(
model_name='project',
name='visibility',
field=models.IntegerField(choices=[(1, 'Joinable'), (10, 'Visible'), (99, 'Hidden')], default=99),
),
]

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0013_project_visibility'),
]
operations = [
migrations.AlterField(
model_name='project',
name='slug',
field=models.SlugField(unique=True),
),
migrations.AlterField(
model_name='project',
name='visibility',
field=models.IntegerField(choices=[(1, 'Joinable'), (10, 'Visible'), (99, 'Team Members')], default=99),
),
]

View File

@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0014_alter_project_slug_alter_project_visibility'),
]
operations = [
migrations.AlterField(
model_name='project',
name='name',
field=models.CharField(max_length=255, unique=True),
),
]

View File

@@ -8,6 +8,20 @@ from bugsink.app_settings import get_settings
from compat.dsn import build_dsn
from teams.models import TeamMembership, TeamRole
class ProjectRole(models.IntegerChoices):
MEMBER = 0
ADMIN = 1
class ProjectVisibility(models.IntegerChoices):
# PUBLIC = 0 # anyone can see the project and its members; not sure if I want this or always require click-in
JOINABLE = 1 # anyone can join
VISIBLE = 10 # the project is visible, you can request to join(?), but this needs to be approved
TEAM_MEMBERS = 99 # the project is only visible to team-members (and for some(?) things they need to click "join")
class Project(models.Model):
# id is implied which makes it an Integer; we would prefer a uuid but the sentry clients have int baked into the DSN
@@ -15,8 +29,8 @@ class Project(models.Model):
team = models.ForeignKey("teams.Team", blank=False, null=True, on_delete=models.SET_NULL)
name = models.CharField(max_length=255, blank=False, null=False)
slug = models.SlugField(max_length=50, blank=False, null=False)
name = models.CharField(max_length=255, blank=False, null=False, unique=True)
slug = models.SlugField(max_length=50, blank=False, null=False, unique=True)
# sentry_key mirrors the "public" part of the sentry DSN. As of late 2023 Sentry's docs say the this about DSNs:
#
@@ -60,18 +74,39 @@ class Project(models.Model):
alert_on_regression = models.BooleanField(default=True)
alert_on_unmute = models.BooleanField(default=True)
# visibility
visibility = models.IntegerField(choices=ProjectVisibility.choices, default=ProjectVisibility.TEAM_MEMBERS)
def get_latest_release(self):
# TODO perfomance considerations... this can be denormalized/cached at the project level
from releases.models import ordered_releases
return list(ordered_releases(project=self))[-1]
def save(self, *args, **kwargs):
if self.slug is None:
# this is not guaranteeing uniqueness but it's enough to have something that makes our tests work.
# in realy usage slugs are provided properly on-creation.
self.slug = slugify(self.name)
if self.slug in [None, ""]:
# we don't want to have empty slugs, so we'll generate a unique one
base_slug = slugify(self.name)
similar_slugs = Project.objects.filter(slug__startswith=base_slug).values_list("slug", flat=True)
self.slug = base_slug
i = 0
while self.slug in similar_slugs:
self.slug = f"{base_slug}-{i}"
i += 1
super().save(*args, **kwargs)
def is_joinable(self, user=None):
if user is not None:
# take the user's team membership into account
try:
tm = TeamMembership.objects.get(team=self.team, user=user)
if tm.role == TeamRole.ADMIN:
return True
except TeamMembership.DoesNotExist:
pass
return self.visibility <= ProjectVisibility.JOINABLE
class ProjectMembership(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE)
@@ -81,13 +116,16 @@ class ProjectMembership(models.Model):
# User(Profile) is something we'll do later. At that point we'll probably implement it as denormalized here, so
# we'll just have to shift the currently existing field into send_email_alerts_denormalized and create a 3-way
# field.
send_email_alerts = models.BooleanField(default=True)
send_email_alerts = models.BooleanField(default=None, null=True)
# TODO this will come
# role = models.CharField(max_length=255, blank=False, null=False)
role = models.IntegerField(choices=ProjectRole.choices, default=ProjectRole.MEMBER)
accepted = models.BooleanField(default=False)
def __str__(self):
return f"{self.user} project membership of {self.project}"
class Meta:
unique_together = ("project", "user")
def is_admin(self):
return self.role == ProjectRole.ADMIN

47
projects/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 Project
@shared_task
def send_project_invite_email_new_user(email, project_pk, token):
project = Project.objects.get(pk=project_pk)
send_rendered_email(
subject='You have been invited to join "%s"' % project.name,
base_template_name="mails/project_membership_invite_new_user",
recipient_list=[email],
context={
"site_title": get_settings().SITE_TITLE,
"base_url": get_settings().BASE_URL + "/",
"project_name": project.name,
"url": get_settings().BASE_URL + reverse("project_members_accept_new_user", kwargs={
"token": token,
"project_pk": project_pk,
}),
},
)
@shared_task
def send_project_invite_email(email, project_pk):
project = Project.objects.get(pk=project_pk)
send_rendered_email(
subject='You have been invited to join "%s"' % project.name,
base_template_name="mails/project_membership_invite",
recipient_list=[email],
context={
"site_title": get_settings().SITE_TITLE,
"base_url": get_settings().BASE_URL + "/",
"project_name": project.name,
"url": get_settings().BASE_URL + reverse("project_members_accept", kwargs={
"project_pk": project_pk,
}),
},
)

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 "{{ project_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 "{{ project_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 project "{{ project_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 project "{{ project_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 "{{ project_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 "{{ project_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 project "{{ project_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 project "{{ project_name }}".
View, accept or reject the invitation by clicking the link below:
{{ url }}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Edit {{ project.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">{{ project.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 "project_list" %}" class="text-cyan-500 font-bold ml-2">Cancel</a>
</form>
</div>
</div>
{% endblock %}

View File

@@ -7,18 +7,37 @@
<div class="m-4">
<h1 class="text-4xl mt-4 font-bold">Projects</h1>
<div class="m-4 flex flex-row items-end">
<div><!-- top, LHS (h1) -->
<h1 class="text-4xl mt-4 font-bold">Projects</h1>
</div>
{# align to bottom #}
<div class="ml-auto"><!-- top, RHS (buttons) -->
{% if can_create %}
<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 'project_new' %}">New Project</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=""><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>
<a href="{% url "project_list_mine" %}"><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 Projects</div></a>
<a href="{% url "project_list_teams" %}"><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if ownership_filter == "teams" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">Other Team Projects</div></a>
<a href="{% url "project_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 Projects</div></a>
</div>
{% comment %}
<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>
{% endcomment %}
</div>
<div>
@@ -35,9 +54,22 @@
<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 #}
{{ project.team.name }}
| {{ project.member_count }} members
| {{ project.open_issue_count }} open issues
{% if project.member %}
| <a href="{% url 'project_member_settings' project_pk=project.id user_pk=request.user.id %}" class="font-bold text-cyan-500">my settings</a>
{% endif %}
</div>
</td>
<td class="pr-2 text-center">
{% if project.member %}
{% if not project.member.accepted %}
<span class="bg-slate-100 rounded-2xl px-4 py-2 ml-2 text-sm">You're&nbsp;invited!</span>
{% elif project.member.is_admin %} {# NOTE: we intentionally hide admin-ness for non-accepted users; #}
<span class="bg-cyan-100 rounded-2xl px-4 py-2 ml-2 text-sm">Admin</span>
{% endif %}
{% endif %}
</td>
<td class="pr-2">
@@ -52,7 +84,7 @@
<td class="pr-2">
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer"onclick="followContainedLink(this);" >
<a href="TODO">
<a href="{% url 'project_edit' 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="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" />
@@ -62,9 +94,30 @@
</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>
{% if project.member %}
{% if not project.member.accepted %}
<div>
<a href="{% url 'project_members_accept' project_pk=project.id %}" class="font-bold text-cyan-500">Invitation</a>
</div>
{% else %}
<div>
<button name="action" value="leave:{{ project.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>
{% endif %}
{% else %}
{% if project.is_joinable or request.user.is_superuser %}
<div>
<button name="action" value="join:{{ project.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">Join</button>
</div>
{% endif %}
{% endif %}
</td>
</tr>
{% empty %}
<tr class="bg-white border-slate-200 border-b-2">
<td class="w-full p-4">
No projects found.
</td>
</tr>
@@ -76,6 +129,8 @@
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Member settings · {{ project.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">Membership settings</h1>
</div>
<div class="mt-4 mb-4">
{% if this_is_you %}
Your membership settings for project "{{ project.name }}".
{% else %}
Settings for project "{{ project.name }}" and user {{ user.username }}.
{% endif %}
</div>
{% if form.role %}
<div class="text-lg mb-8">
<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>
{% endif %}
{% if form.send_email_alerts %}
<div class="text-lg mb-8">
<div class="text-slate-800 font-bold">{{ form.send_email_alerts.label }}</div>
<div class="flex items-center">
{{ form.send_email_alerts }}
</div>
{% if form.send_email_alerts.errors %}
{% for error in form.send_email_alerts.errors %}
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
{% endfor %}
{% elif form.send_email_alerts.help_text %}
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.send_email_alerts.help_text|safe }}</div>
{% endif %}
</div>
{% endif %}
<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 "project_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 "project_members" project_pk=project.pk %}" class="text-cyan-500 font-bold ml-2">Cancel</a>
{% endif %}
</form>
</div>
</div>
{% endblock %}

View File

@@ -11,11 +11,20 @@
<div class="m-4 max-w-4xl flex-auto">
<div class="flex">
<h1 class="text-4xl mt-4 font-bold">Members</h1>
{% 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="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 class="flex">
<h1 class="text-4xl mt-4 font-bold">Project 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 "project_members_invite" project_pk=project.pk %}">Invite Member</a>
</div>
</div>
@@ -34,17 +43,39 @@
<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? #}
<a href="{% url "project_member_settings" project_pk=project.pk user_pk=member.user_id %}" 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">Invitation pending</span>
{% elif member.role == 1 %} {# 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>
<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 class="flex justify-end">
{% 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-2 border-2 hover:bg-slate-200 active:ring rounded-md">Remove</button>
{% endif %}
</div>
</td>
</tr>
{% empty %}
<tr class="bg-white border-slate-200 border-b-2">
<td class="w-full p-4">
<div>
{# Note: this is already somewhat exceptional, because the usually you'll at least see yourself here (unless you're a superuser and a project has become memberless) #}
No members yet. <a href="{% url "project_members_invite" project_pk=project.pk %}" class="text-cyan-500 font-bold">Invite someone</a>.
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
@@ -52,6 +83,11 @@
</form>
</div>
<div class="flex flex-direction-row">
<div class="ml-auto py-8 pr-4">
<a href="{% url "team_members" team_pk=project.team_id %}" class="text-cyan-500 font-bold">{{ project.team.name }} Members</a>
<span class="font-bold text-slate-500">|</span> <a href="{% url "project_list" %}" class="text-cyan-500 font-bold">Back to Projects</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Invitation · {{ project.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 "{{ project.name }}"</h1>
</div>
<div class="mt-4 mb-4">
You have been invited to join the project "{{ project.name }}" in the role of "{{ membership.get_role_display }}". Please confirm by clicking the button below.
</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,68 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Invite Members · {{ project.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 ({{ project.name }})</h1>
</div>
<div class="mt-4 mb-4">
Invite a member to join the project "{{ project.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 "project_members" project_pk=project.pk %}" class="font-bold text-slate-500 ml-4">Cancel</a>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% load static %}
{% block title %}New project · {{ 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 Project</h1>
</div>
{% if form.team %}
<div class="text-lg mb-8">
<div class="text-slate-800 font-bold">{{ form.team.label }}</div>
<div class="flex items-center">
{{ form.team }}
</div>
{% if form.team.errors %}
{% for error in form.team.errors %}
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
{% endfor %}
{% elif form.team.help_text %}
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.team.help_text|safe }}</div>
{% endif %}
</div>
{% endif %}
{% 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 "project_list" %}" class="text-cyan-500 font-bold ml-2">Cancel</a>
</form>
</div>
</div>
{% endblock %}

View File

@@ -1,8 +1,20 @@
from django.urls import path
from .views import project_list, project_members
from .views import (
project_list, project_members, project_members_accept, project_member_settings, project_members_invite,
project_members_accept_new_user, project_new, project_edit)
urlpatterns = [
path('', project_list, name="project_list"),
path('mine/', project_list, kwargs={"ownership_filter": "mine"}, name="project_list_mine"),
path('teams/', project_list, kwargs={"ownership_filter": "teams"}, name="project_list_teams"),
path('other/', project_list, kwargs={"ownership_filter": "other"}, name="project_list_other"),
path('new/', project_new, name="project_new"),
path('<int:project_pk>/edit/', project_edit, name="project_edit"),
path('<int:project_pk>/members/', project_members, name="project_members"),
path('<int:project_pk>/members/invite/', project_members_invite, name="project_members_invite"),
path('<int:project_pk>/members/accept/', project_members_accept, name="project_members_accept"),
path('<str:project_pk>/members/accept/<str:token>/', project_members_accept_new_user,
name="project_members_accept_new_user"),
path('<int:project_pk>/members/settings/<str:user_pk>/', project_member_settings, name="project_member_settings"),
]

View File

@@ -1,20 +1,365 @@
from datetime import timedelta
from django.shortcuts import render
from django.db import models
from django.shortcuts import redirect
from django.http import Http404, HttpResponseRedirect
from django.core.exceptions import PermissionDenied
from django.contrib.auth import get_user_model
from django.contrib import messages
from django.contrib.auth import logout
from django.urls import reverse
from django.utils import timezone
from django.contrib.auth.decorators import permission_required
from .models import Project
from users.models import EmailVerification
from teams.models import TeamMembership, Team, TeamRole
from bugsink.app_settings import get_settings, CB_ANYBODY, CB_MEMBERS, CB_ADMINS
from bugsink.decorators import login_exempt
from .models import Project, ProjectMembership, ProjectRole, ProjectVisibility
from .forms import MyProjectMembershipForm, ProjectMemberInviteForm, ProjectForm
from .tasks import send_project_invite_email, send_project_invite_email_new_user
def project_list(request):
project_list = Project.objects.all()
User = get_user_model()
def project_list(request, ownership_filter=None):
my_memberships = ProjectMembership.objects.filter(user=request.user)
my_team_memberships = TeamMembership.objects.filter(user=request.user)
my_projects = Project.objects.filter(projectmembership__in=my_memberships).order_by('name').distinct()
my_teams_projects = \
Project.objects \
.filter(team__teammembership__in=my_team_memberships) \
.exclude(projectmembership__in=my_memberships) \
.order_by('name').distinct()
if request.user.is_superuser:
# superusers can see all project, even hidden ones
other_projects = Project.objects \
.exclude(projectmembership__in=my_memberships) \
.exclude(team__teammembership__in=my_team_memberships) \
.order_by('name').distinct()
else:
other_projects = Project.objects \
.exclude(projectmembership__in=my_memberships) \
.exclude(team__teammembership__in=my_team_memberships) \
.exclude(visibility=ProjectVisibility.TEAM_MEMBERS) \
.order_by('name').distinct()
if ownership_filter is None:
if my_projects.exists():
return redirect('project_list_mine')
if my_teams_projects.exists():
return redirect('project_list_teams')
if other_projects.exists():
return redirect('project_list_other')
return redirect('project_list_mine') # if nothing to show, might as well show your own
if request.method == 'POST':
full_action_str = request.POST.get('action')
action, project_pk = full_action_str.split(":", 1)
if action == "leave":
ProjectMembership.objects.filter(project=project_pk, user=request.user.id).delete()
elif action == "join":
project = Project.objects.get(id=project_pk)
if not project.is_joinable(user=request.user) and not request.user.is_superuser:
raise PermissionDenied("This project is not joinable")
messages.success(request, 'You have joined the project "%s"' % project.name)
ProjectMembership.objects.create(
project_id=project_pk, user_id=request.user.id, role=ProjectRole.MEMBER, accepted=True)
return redirect('project_member_settings', project_pk=project_pk, user_pk=request.user.id)
if ownership_filter == "mine":
base_qs = my_projects
elif ownership_filter == "teams":
base_qs = my_teams_projects
elif ownership_filter == "other":
base_qs = other_projects
else:
raise ValueError(f"Invalid ownership_filter: {ownership_filter}")
project_list = base_qs.annotate(
open_issue_count=models.Count('issue', filter=models.Q(issue__is_resolved=False, issue__is_muted=False)),
member_count=models.Count(
'projectmembership', distinct=True, filter=models.Q(projectmembership__accepted=True)),
)
if ownership_filter == "mine":
# Perhaps there's some Django-native way of doing this, but I can't figure it out soon enough, and this also
# works:
my_memberships_dict = {m.project_id: m for m in my_memberships}
project_list_2 = []
for project in project_list:
project.member = my_memberships_dict.get(project.id)
project_list_2.append(project)
project_list = project_list_2
return render(request, 'projects/project_list.html', {
'state_filter': 'mine',
'can_create':
request.user.is_superuser or TeamMembership.objects.filter(user=request.user, role=TeamRole.ADMIN).exists(),
'ownership_filter': ownership_filter,
'project_list': project_list,
})
def project_members(request, project_pk):
# TODO: check if user is a member of the project and has permission to view this page
@permission_required("projects.add_project")
def project_new(request):
if request.user.is_superuser:
team_qs = Team.objects.all()
else:
my_admin_memberships = TeamMembership.objects.filter(user=request.user, role=TeamRole.ADMIN, accepted=True)
team_qs = Team.objects.filter(teammembership__in=my_admin_memberships).distinct()
if request.method == 'POST':
form = ProjectForm(request.POST, team_qs=team_qs)
if form.is_valid():
project = form.save()
# the user who creates the project is automatically an (accepted) admin of the project
ProjectMembership.objects.create(project=project, user=request.user, role=ProjectRole.ADMIN, accepted=True)
return redirect('project_members', project_pk=project.id)
else:
form = ProjectForm(team_qs=team_qs)
return render(request, 'projects/project_new.html', {
'form': form,
})
def _check_project_admin(project, user):
if not user.is_superuser and \
not ProjectMembership.objects.filter(
project=project, user=user, role=ProjectRole.ADMIN, accepted=True).exists() and \
not TeamMembership.objects.filter(team=project.team, user=user, role=TeamRole.ADMIN, accepted=True).exists():
raise PermissionDenied("You are not an admin of this project")
def project_edit(request, project_pk):
project = Project.objects.get(id=project_pk)
_check_project_admin(project, request.user)
if request.method == 'POST':
form = ProjectForm(request.POST, instance=project)
if form.is_valid():
form.save()
return redirect('project_members', project_pk=project.id)
else:
form = ProjectForm(instance=project)
return render(request, 'projects/project_edit.html', {
'project': project,
'form': form,
})
def project_members(request, project_pk):
project = Project.objects.get(id=project_pk)
_check_project_admin(project, request.user)
if request.method == 'POST':
full_action_str = request.POST.get('action')
action, user_id = full_action_str.split(":", 1)
if action == "remove":
ProjectMembership.objects.filter(project=project_pk, user=user_id).delete()
elif action == "reinvite":
user = User.objects.get(id=user_id)
_send_project_invite_email(user, project_pk)
messages.success(request, f"Invitation resent to {user.email}")
return render(request, 'projects/project_members.html', {
'project': project,
'members': project.projectmembership_set.all().select_related('user'),
})
def _send_project_invite_email(user, project_pk):
"""Send an email to a user inviting them to a project; (for new users this includes the email-verification link)"""
if user.is_active:
send_project_invite_email.delay(user.email, project_pk)
else:
# this happens for new (in this view) users, but also for users who have been invited before but have
# not yet accepted the invite. In the latter case, we just send a fresh email
verification = EmailVerification.objects.create(user=user, email=user.username)
send_project_invite_email_new_user.delay(user.email, project_pk, verification.token)
def project_members_invite(request, project_pk):
# NOTE: project-member invite is just that: a direct invite to a project. If you want to also/instead invite someone
# to a team, you need to just do that instead.
project = Project.objects.get(id=project_pk)
_check_project_admin(project, request.user)
if get_settings().USER_REGISTRATION in [CB_ANYBODY, CB_MEMBERS]:
user_must_exist = False
elif get_settings().USER_REGISTRATION == CB_ADMINS and request.user.has_perm("users.add_user"):
user_must_exist = False
else:
user_must_exist = True
if request.method == 'POST':
form = ProjectMemberInviteForm(user_must_exist, request.POST)
if form.is_valid():
# because we do validation in the form (which takes user_must_exist as a param), we know we can create the
# user if needed if this point is reached.
email = form.cleaned_data['email']
user, user_created = User.objects.get_or_create(
email=email, defaults={'username': email, 'is_active': False})
_send_project_invite_email(user, project_pk)
_, membership_created = ProjectMembership.objects.get_or_create(project=project, user=user, defaults={
'role': form.cleaned_data['role'],
'accepted': False,
})
if membership_created:
messages.success(request, f"Invitation sent to {email}")
else:
messages.success(
request, f"Invitation resent to {email} (it was previously sent and we just sent it again)")
if request.POST.get('action') == "invite_and_add_another":
return redirect('project_members_invite', project_pk=project_pk)
# I think this is enough feedback, as the user will just show up there
return redirect('project_members', project_pk=project_pk)
else:
form = ProjectMemberInviteForm(user_must_exist)
return render(request, 'projects/project_members_invite.html', {
'project': project,
'form': form,
})
def project_member_settings(request, project_pk, user_pk):
try:
your_membership = ProjectMembership.objects.get(project=project_pk, user=request.user)
except ProjectMembership.DoesNotExist:
raise PermissionDenied("You are not a member of this project")
if not your_membership.accepted:
return redirect("project_members_accept", project_pk=project_pk)
this_is_you = str(user_pk) == str(request.user.id)
if not this_is_you:
_check_project_admin(Project.objects.get(id=project_pk), request.user)
membership = ProjectMembership.objects.get(project=project_pk, user=user_pk)
create_form = lambda data: ProjectMembershipForm(data, instance=membership) # noqa
else:
edit_role = your_membership.role == ProjectRole.ADMIN or request.user.is_superuser
create_form = lambda data: MyProjectMembershipForm(data=data, instance=your_membership, edit_role=edit_role) # noqa
if request.method == 'POST':
form = create_form(request.POST)
if form.is_valid():
form.save()
if this_is_you:
# assumption (not always true): when editing yourself, you came from the project list not the project
# members
return redirect('project_list')
return redirect('project_members', project_pk=project_pk)
else:
form = create_form(None)
return render(request, 'projects/project_member_settings.html', {
'this_is_you': this_is_you,
'user': User.objects.get(id=user_pk),
'project': Project.objects.get(id=project_pk),
'form': form,
})
@login_exempt # no login is required, the token is what identifies the user
def project_members_accept_new_user(request, project_pk, token):
# There is a lot of overlap with the email-verification flow here; security-wise we make the same assumptions as we
# do over there, namely: access to email implies control over the account. This is also the reason we reuse that
# app's `EmailVerification` model.
# clean up expired tokens; doing this on every request is just fine, it saves us from having to run a cron
# job-like thing
EmailVerification.objects.filter(
created_at__lt=timezone.now() - timedelta(get_settings().USER_REGISTRATION_VERIFY_EMAIL_EXPIRY)).delete()
try:
verification = EmailVerification.objects.get(token=token)
except EmailVerification.DoesNotExist:
# good enough (though a special page might be prettier)
raise Http404("Invalid or expired token")
user = verification.user
if not user.has_usable_password() or not user.is_active:
# NOTE: we make the had assumption here that users without a password can self-upgrade to become users with a
# password. In the future (e.g. LDAP) this may not be what we want, and we'll have to implement a separate field
# to store whether we're dealing with "created by email invite, password must still be set" or "created by
# external system, password is managed externally". For now, we're good.
# In the above we take the (perhaps redundant) approach of checking for either of 2 login-blocking conditions.
return HttpResponseRedirect(reverse("reset_password", kwargs={"token": token}) + "?next=" + reverse(
project_members_accept, kwargs={"project_pk": project_pk})
)
# the above "set_password" branch is the "main flow"/"whole point" of this view: auto-login using a token and
# subsequent password-set because no (active) user exists yet. However, it is possible that a user ends up here
# while already having completed registration, e.g. when multiple invites have been sent in a row. In that case, the
# password-setting may be skipped and we can just skip straight to the actual project-accept.
# to remove some of the confusion mentioned in "project_members_accept", we at least log you out if the verification
# you've clicked on is for a different user than the one you're logged in as.
if request.user.is_authenticated and request.user != user:
logout(request)
# In this case, we clean up the no-longer-required verification object (we make somewhat of an exception to the
# "don't change stuff on GET" rule, because it's immaterial here).
verification.delete()
# And we just redirect to the regular "accept" page. No auto-login, because we're not in a POST request here. (at a
# small cost in UX in the case you reach this page in a logged-out state).
return redirect("project_members_accept", project_pk=project_pk)
def project_members_accept(request, project_pk):
# NOTE: in principle it is confusingly possible to reach this page while logged in as user A, while having been
# invited as user B. Security-wise this is fine, but UX-wise it could be confusing. However, I'm in the assumption
# here that normal people (i.e. not me) don't have multiple accounts, so I'm not going to bother with this.
project = Project.objects.get(id=project_pk)
membership = ProjectMembership.objects.get(project=project, user=request.user)
if membership.accepted:
# i.e. the user has already accepted the invite, we just silently redirect as if they had just done so
return redirect("project_member_settings", project_pk=project_pk, user_pk=request.user.id)
if request.method == 'POST':
# no need for a form, it's just a pair of buttons
if request.POST["action"] == "decline":
membership.delete()
return redirect("home")
if request.POST["action"] == "accept":
membership.accepted = True
membership.save()
return redirect("project_member_settings", project_pk=project_pk, user_pk=request.user.id)
raise Http404("Invalid action")
return render(request, "projects/project_members_accept.html", {"project": project, "membership": membership})

View File

@@ -28,7 +28,7 @@ class TeamMemberInviteForm(forms.Form):
class MyTeamMembershipForm(forms.ModelForm):
"""Edit your TeamMembership, i.e. email-settings are OK, and role only for admins"""
"""Edit _your_ TeamMembership, i.e. email-settings, and role only for admins"""
class Meta:
model = TeamMembership

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams', '0005_teammembership_send_email_alerts'),
]
operations = [
migrations.AlterField(
model_name='team',
name='name',
field=models.CharField(max_length=255, unique=True),
),
migrations.AlterField(
model_name='team',
name='visibility',
field=models.IntegerField(choices=[(1, 'Joinable'), (10, 'Visible'), (99, 'Hidden')], default=1),
),
migrations.AlterField(
model_name='teammembership',
name='send_email_alerts',
field=models.BooleanField(blank=True, default=None, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.13 on 2024-06-06 12:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('teams', '0006_alter_team_name_alter_team_visibility_and_more'),
]
operations = [
migrations.AlterField(
model_name='team',
name='visibility',
field=models.IntegerField(choices=[(1, 'Joinable'), (10, 'Visible'), (99, 'Hidden')], default=10),
),
]

View File

@@ -23,7 +23,7 @@ class Team(models.Model):
name = models.CharField(max_length=255, blank=False, null=False, unique=True)
slug = models.SlugField(max_length=50, blank=False, null=False)
visibility = models.IntegerField(choices=TeamVisibility.choices, default=TeamVisibility.JOINABLE)
visibility = models.IntegerField(choices=TeamVisibility.choices, default=TeamVisibility.VISIBLE)
def __str__(self):
return self.name

View File

@@ -15,7 +15,6 @@
{# 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>
@@ -29,7 +28,7 @@
<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_mine" %}"><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 %}
@@ -49,14 +48,14 @@
{% 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">
<div class="text-xl font-bold text-slate-800">
{{ team.name }}
</div>
<div>
{{ 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>
| <a href="{% url 'team_member_settings' team_pk=team.id user_pk=request.user.id %}" class="font-bold text-cyan-500">my settings</a>
{% endif %}
</div>
</td>
@@ -65,7 +64,7 @@
{% if team.member %}
{% if not team.member.accepted %}
<span class="bg-slate-100 rounded-2xl px-4 py-2 ml-2 text-sm">You're&nbsp;invited!</span>
{% elif team.member.role == 1 %} {# NOTE: we intentionally hide admin-ness for non-accepted users; TODO better use of constants #}
{% elif team.member.is_admin %} {# NOTE: we intentionally hide admin-ness for non-accepted users #}
<span class="bg-cyan-100 rounded-2xl px-4 py-2 ml-2 text-sm">Admin</span>
{% endif %}
{% endif %}

View File

@@ -225,7 +225,7 @@ def team_member_settings(request, team_pk, user_pk):
this_is_you = str(user_pk) == str(request.user.id)
if not this_is_you:
if not your_membership.role == TeamRole.ADMIN:
if not request.user.is_superuser or not your_membership.role == TeamRole.ADMIN:
raise PermissionDenied("You are not an admin of this team")
membership = TeamMembership.objects.get(team=team_pk, user=user_pk)

View File

@@ -1,17 +0,0 @@
{% extends "base.html" %}
{% block title %}Projects · {{ site_title }}{% endblock %}
{% block content %}
<div class="m-4">
<h1 class="text-4xl mt-4">Projects</h1>
{% for project in user_projects %}
<a href="/issues/{{ project.id }}/"><div class="p-4 hover:bg-slate-400">{{ project.name }}</div></a>
{% endfor %}
</div>
{% endblock %}

View File

@@ -15,6 +15,9 @@
<a href="/"><img src="{% static 'images/bugsink-logo.png' %}" class="p-2 h-12 w-12"></a>
<a href="/"><div class="pt-4 pb-4 pl-2 pr-2 font-bold">{{ site_title }}</div></a>
<a href="{% url "project_list" %}"><div class="p-4 hover:bg-slate-400">Projects</div></a>
{% comment %}
<div class="dropdown">
<a href="/issues/{{ project.id }}"><div class="p-4 hover:bg-slate-400">{{ project.name }}&nbsp;&nbsp;🞃 </div></a>
@@ -26,6 +29,7 @@
{% endfor %}
</div>
</div>
{% endcomment %}
<a href="{% url "team_list" %}"><div class="p-4 hover:bg-slate-400">Teams</div></a>