mirror of
https://github.com/bugsink/bugsink.git
synced 2025-12-30 18:00:17 -06:00
WIP teams & project-management (6)
not extensively tested, but it starts to feel quite complete 'for now'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
83
projects/forms.py
Normal 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
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
18
projects/migrations/0013_project_visibility.py
Normal file
18
projects/migrations/0013_project_visibility.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
16
projects/migrations/0015_alter_project_name.py
Normal file
16
projects/migrations/0015_alter_project_name.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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
47
projects/tasks.py
Normal 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,
|
||||
}),
|
||||
},
|
||||
)
|
||||
519
projects/templates/mails/project_membership_invite.html
Normal file
519
projects/templates/mails/project_membership_invite.html
Normal 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&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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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>
|
||||
5
projects/templates/mails/project_membership_invite.txt
Normal file
5
projects/templates/mails/project_membership_invite.txt
Normal 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 }}
|
||||
519
projects/templates/mails/project_membership_invite_new_user.html
Normal file
519
projects/templates/mails/project_membership_invite_new_user.html
Normal 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&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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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: "Nunito Sans", 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>
|
||||
@@ -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 }}
|
||||
60
projects/templates/projects/project_edit.html
Normal file
60
projects/templates/projects/project_edit.html
Normal 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 %}
|
||||
@@ -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 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 %}
|
||||
|
||||
83
projects/templates/projects/project_member_settings.html
Normal file
83
projects/templates/projects/project_member_settings.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
31
projects/templates/projects/project_members_accept.html
Normal file
31
projects/templates/projects/project_members_accept.html
Normal 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 %}
|
||||
68
projects/templates/projects/project_members_invite.html
Normal file
68
projects/templates/projects/project_members_invite.html
Normal 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 %}
|
||||
78
projects/templates/projects/project_new.html
Normal file
78
projects/templates/projects/project_new.html
Normal 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 %}
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
18
teams/migrations/0007_alter_team_visibility.py
Normal file
18
teams/migrations/0007_alter_team_visibility.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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 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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 }} 🞃 </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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user