mirror of
https://github.com/bugsink/bugsink.git
synced 2025-12-21 04:50:07 -06:00
WIP teams & project-management
This commit is contained in:
@@ -25,6 +25,7 @@ DEFAULTS = {
|
||||
"USER_REGISTRATION": CB_ANYBODY, # who can register new users. default: anybody, i.e. users can register themselves
|
||||
"USER_REGISTRATION_VERIFY_EMAIL": True,
|
||||
"USER_REGISTRATION_VERIFY_EMAIL_EXPIRY": 3 * 24 * 60 * 60, # 7 days
|
||||
"TEAM_CREATION": CB_MEMBERS, # who can create new teams. default: members, which means "any member of the site"
|
||||
|
||||
# System inner workings:
|
||||
"DIGEST_IMMEDIATELY": True,
|
||||
|
||||
@@ -53,6 +53,7 @@ INSTALLED_APPS = [
|
||||
'theme',
|
||||
'snappea',
|
||||
'compat',
|
||||
'teams',
|
||||
'projects',
|
||||
'releases',
|
||||
'ingest',
|
||||
|
||||
@@ -32,6 +32,8 @@ urlpatterns = [
|
||||
|
||||
path('api/', include('ingest.urls')),
|
||||
|
||||
path('projects/', include('projects.urls')),
|
||||
path('teams/', include('teams.urls')),
|
||||
path('events/', include('events.urls')),
|
||||
path('issues/', include('issues.urls')),
|
||||
|
||||
|
||||
18
projects/migrations/0009_project_team.py
Normal file
18
projects/migrations/0009_project_team.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('teams', '0002_create_single_team'),
|
||||
('projects', '0008_set_project_slugs'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='team',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='teams.team'),
|
||||
),
|
||||
]
|
||||
21
projects/migrations/0010_set_single_team.py
Normal file
21
projects/migrations/0010_set_single_team.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def set_single_team(apps, schema_editor):
|
||||
Team = apps.get_model('teams', 'Team')
|
||||
Project = apps.get_model('projects', 'Project')
|
||||
|
||||
team = Team.objects.all().first() # as created in 0002_create_single_team
|
||||
Project.objects.update(team=team)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0009_project_team'),
|
||||
('teams', '0002_create_single_team'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(set_single_team),
|
||||
]
|
||||
@@ -13,6 +13,8 @@ class Project(models.Model):
|
||||
# id is implied which makes it an Integer; we would prefer a uuid but the sentry clients have int baked into the DSN
|
||||
# parser (we could also introduce a special field for that purpose but that's ugly too)
|
||||
|
||||
team = models.ForeignKey("teams.Team", blank=False, null=True, on_delete=models.SET_NULL)
|
||||
|
||||
name = models.CharField(max_length=255, blank=False, null=False)
|
||||
slug = models.SlugField(max_length=50, blank=False, null=False)
|
||||
|
||||
@@ -75,7 +77,7 @@ class ProjectMembership(models.Model):
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
|
||||
# TODO inheriting True/False for None from either Organization (which we also don't have yet) or directly from
|
||||
# TODO inheriting True/False for None from either Team (which we also don't have yet) or directly from
|
||||
# User(Profile) is something we'll do later. At that point we'll probably implement it as denormalized here, so
|
||||
# we'll just have to shift the currently existing field into send_email_alerts_denormalized and create a 3-way
|
||||
# field.
|
||||
@@ -85,7 +87,7 @@ class ProjectMembership(models.Model):
|
||||
# role = models.CharField(max_length=255, blank=False, null=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} membership of {self.project}"
|
||||
return f"{self.user} project membership of {self.project}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ("project", "user")
|
||||
|
||||
85
projects/templates/projects/project_list.html
Normal file
85
projects/templates/projects/project_list.html
Normal file
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Projects · {{ site_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="m-4">
|
||||
<h1 class="text-4xl mt-4 font-bold">Projects</h1>
|
||||
|
||||
|
||||
<div class="flex bg-slate-50 mt-4 items-end">
|
||||
<div class="flex">
|
||||
<a href=""><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if state_filter == "mine" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">My projects</div></a>
|
||||
<a href=""><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if state_filter == "all" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">All projects</div></a>
|
||||
</div>
|
||||
<div class="ml-auto p-2">
|
||||
<input type="text" name="search" placeholder="search projects..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{% for project in project_list %}
|
||||
<tr class="bg-white border-slate-200 border-b-2">
|
||||
<td class="w-full p-4">
|
||||
<div>
|
||||
<a href="/issues/{{ project.id }}" class="text-xl text-cyan-500 font-bold">{{ project.name }}</a>
|
||||
</div>
|
||||
<div>
|
||||
Team {{ project.team.name }}
|
||||
| 7 members {# TODO not actaully implemented #}
|
||||
| {{ project.issue_set.count }} open issues</div> {# TODO not actually 'open' issues #}
|
||||
</td>
|
||||
|
||||
<td class="pr-2">
|
||||
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer" onclick="followContainedLink(this);" >
|
||||
<a href="{% url 'project_members' project_pk=project.id %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="pr-2">
|
||||
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer"onclick="followContainedLink(this);" >
|
||||
<a href="TODO">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div>
|
||||
<button name="action" value="leave" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-md">Leave</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/project_list.js' %}"></script>
|
||||
{% endblock %}
|
||||
58
projects/templates/projects/project_members.html
Normal file
58
projects/templates/projects/project_members.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Members · {{ project.name }} · {{ site_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
<div class="m-4 max-w-4xl flex-auto">
|
||||
|
||||
<div class="flex">
|
||||
<h1 class="text-4xl mt-4 font-bold">Members</h1>
|
||||
|
||||
<div class="ml-auto mt-4">
|
||||
<button class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md" name="action" value="resolved_next">Invite Member</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<table class="w-full mt-8">
|
||||
<tbody>
|
||||
<thead>
|
||||
<tr class="bg-slate-200">
|
||||
<th class="w-full p-4 text-left text-xl" colspan="2">{{ project.name }}</th>
|
||||
</tr>
|
||||
|
||||
{% for member in members %}
|
||||
<tr class="bg-white border-slate-200 border-b-2">
|
||||
<td class="w-full p-4">
|
||||
<div>
|
||||
<a href="TODO" class="text-xl text-cyan-500 font-bold">{{ member.user.email }}</a> {# "best name" perhaps later? #}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="p-4">
|
||||
<div>
|
||||
<button name="action" value="leave" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-md">Leave</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
8
projects/urls.py
Normal file
8
projects/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import project_list, project_members
|
||||
|
||||
urlpatterns = [
|
||||
path('', project_list, name="project_list"),
|
||||
path('<int:project_pk>/members/', project_members, name="project_members"),
|
||||
]
|
||||
@@ -1,3 +1,20 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
from .models import Project
|
||||
|
||||
|
||||
def project_list(request):
|
||||
project_list = Project.objects.all()
|
||||
return render(request, 'projects/project_list.html', {
|
||||
'state_filter': 'mine',
|
||||
'project_list': project_list,
|
||||
})
|
||||
|
||||
|
||||
def project_members(request, project_pk):
|
||||
# TODO: check if user is a member of the project and has permission to view this page
|
||||
project = Project.objects.get(id=project_pk)
|
||||
return render(request, 'projects/project_members.html', {
|
||||
'project': project,
|
||||
'members': project.projectmembership_set.all().select_related('user'),
|
||||
})
|
||||
|
||||
@@ -45,6 +45,7 @@ include = [
|
||||
"sentry_sdk_extensions*",
|
||||
"snappea*",
|
||||
"static*",
|
||||
"teams*",
|
||||
"templates*",
|
||||
"theme*",
|
||||
"users*",
|
||||
|
||||
6
static/js/project_list.js
Normal file
6
static/js/project_list.js
Normal file
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
function followContainedLink(circleDiv) {
|
||||
const link = circleDiv.querySelector("a");
|
||||
window.location.href = link.href;
|
||||
}
|
||||
6
static/js/team_list.js
Normal file
6
static/js/team_list.js
Normal file
@@ -0,0 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
function followContainedLink(circleDiv) {
|
||||
const link = circleDiv.querySelector("a");
|
||||
window.location.href = link.href;
|
||||
}
|
||||
0
teams/__init__.py
Normal file
0
teams/__init__.py
Normal file
61
teams/admin.py
Normal file
61
teams/admin.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from django.contrib import admin
|
||||
from admin_auto_filters.filters import AutocompleteFilter
|
||||
|
||||
from .models import Team, TeamMembership
|
||||
|
||||
|
||||
class TeamFilter(AutocompleteFilter):
|
||||
title = 'Team'
|
||||
field_name = 'team'
|
||||
|
||||
|
||||
class UserFilter(AutocompleteFilter):
|
||||
title = 'User'
|
||||
field_name = 'user'
|
||||
|
||||
|
||||
class TeamMembershipInline(admin.TabularInline):
|
||||
model = TeamMembership
|
||||
autocomplete_fields = [
|
||||
'user',
|
||||
]
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
search_fields = [
|
||||
'name',
|
||||
]
|
||||
|
||||
list_display = [
|
||||
'name',
|
||||
]
|
||||
|
||||
readonly_fields = [
|
||||
]
|
||||
|
||||
inlines = [
|
||||
TeamMembershipInline,
|
||||
]
|
||||
|
||||
|
||||
# the preferred way to deal with TeamMembership is actually through the inline above; however, because this may prove
|
||||
# to not scale well with (very? more than 50?) memberships per team, we've left the separate admin interface here for
|
||||
# future reference.
|
||||
@admin.register(TeamMembership)
|
||||
class TeamMembershipAdmin(admin.ModelAdmin):
|
||||
list_filter = [
|
||||
TeamFilter,
|
||||
UserFilter,
|
||||
]
|
||||
|
||||
list_display = [
|
||||
'team',
|
||||
'user',
|
||||
]
|
||||
|
||||
autocomplete_fields = [
|
||||
'team',
|
||||
'user',
|
||||
]
|
||||
6
teams/apps.py
Normal file
6
teams/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TeamsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'teams'
|
||||
26
teams/forms.py
Normal file
26
teams/forms.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from .models import TeamRole
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TeamMemberInviteForm(forms.Form):
|
||||
email = forms.EmailField(label='Email', required=True)
|
||||
role = forms.ChoiceField(
|
||||
label='Role', choices=TeamRole.choices, required=True, initial=TeamRole.MEMBER, widget=forms.RadioSelect)
|
||||
|
||||
def __init__(self, user_must_exist, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.user_must_exist = user_must_exist
|
||||
if user_must_exist:
|
||||
self.fields['email'].help_text = "The user must already exist in the system"
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
|
||||
if self.user_must_exist and not User.objects.filter(email=email).exists():
|
||||
raise forms.ValidationError('No user with this email address in the system.')
|
||||
|
||||
return email
|
||||
36
teams/migrations/0001_initial.py
Normal file
36
teams/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Team',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('slug', models.SlugField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.IntegerField(choices=[(0, 'Member'), (1, 'Admin')], default=0)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='teams.team')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('team', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
25
teams/migrations/0002_create_single_team.py
Normal file
25
teams/migrations/0002_create_single_team.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_single_team(apps, schema_editor):
|
||||
# if needed (for existing projects); this should not be preserved when we squash/restart migrations
|
||||
|
||||
Project = apps.get_model('projects', 'Project')
|
||||
Team = apps.get_model('teams', 'Team')
|
||||
|
||||
if Project.objects.count() == 0:
|
||||
return
|
||||
|
||||
Team.objects.create(name='Single Team', slug='single-team')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('teams', '0001_initial'),
|
||||
('projects', '0008_set_project_slugs'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_single_team),
|
||||
]
|
||||
16
teams/migrations/0003_team_visibility.py
Normal file
16
teams/migrations/0003_team_visibility.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('teams', '0002_create_single_team'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='team',
|
||||
name='visibility',
|
||||
field=models.IntegerField(choices=[(0, 'Public'), (1, 'Visible'), (2, 'Hidden')], default=0),
|
||||
),
|
||||
]
|
||||
16
teams/migrations/0004_teammembership_accepted.py
Normal file
16
teams/migrations/0004_teammembership_accepted.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('teams', '0003_team_visibility'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='teammembership',
|
||||
name='accepted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
0
teams/migrations/__init__.py
Normal file
0
teams/migrations/__init__.py
Normal file
43
teams/models.py
Normal file
43
teams/models.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
|
||||
from django.db import models
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class TeamRole(models.IntegerChoices):
|
||||
MEMBER = 0
|
||||
ADMIN = 1
|
||||
|
||||
|
||||
class TeamVisibility(models.IntegerChoices):
|
||||
PUBLIC = 0 # anyone can join(?); or even just click-through(?)
|
||||
VISIBLE = 1 # the team is visible, you can request to join(?), but this needs to be approved
|
||||
HIDDEN = 2 # the team is not visible to non-members; you need to be invited
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
name = models.CharField(max_length=255, blank=False, null=False)
|
||||
slug = models.SlugField(max_length=50, blank=False, null=False)
|
||||
|
||||
visibility = models.IntegerField(choices=TeamVisibility.choices, default=TeamVisibility.PUBLIC)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class TeamMembership(models.Model):
|
||||
team = models.ForeignKey(Team, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
|
||||
|
||||
# send_email_alerts = models.BooleanField(default=True) TODO (see also Project)
|
||||
role = models.IntegerField(choices=TeamRole.choices, default=TeamRole.MEMBER)
|
||||
accepted = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} team membership of {self.team}"
|
||||
|
||||
class Meta:
|
||||
unique_together = ("team", "user")
|
||||
47
teams/tasks.py
Normal file
47
teams/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 Team
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_team_invite_email_new_user(email, team_pk, token):
|
||||
team = Team.objects.get(pk=team_pk)
|
||||
|
||||
send_rendered_email(
|
||||
subject='You have been invited to join "%s"' % team.name,
|
||||
base_template_name="mails/team_membership_invite_new_user",
|
||||
recipient_list=[email],
|
||||
context={
|
||||
"site_title": get_settings().SITE_TITLE,
|
||||
"base_url": get_settings().BASE_URL + "/",
|
||||
"team": team,
|
||||
"url": reverse("team_members_accept_new_user", kwargs={
|
||||
"token": token,
|
||||
"team_pk": team_pk,
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_team_invite_email(email, team_pk):
|
||||
team = Team.objects.get(pk=team_pk)
|
||||
|
||||
send_rendered_email(
|
||||
subject='You have been invited to join "%s"' % team.name,
|
||||
base_template_name="mails/team_membership_invite",
|
||||
recipient_list=[email],
|
||||
context={
|
||||
"site_title": get_settings().SITE_TITLE,
|
||||
"base_url": get_settings().BASE_URL + "/",
|
||||
"team": team,
|
||||
"url": reverse("team_members_accept", kwargs={
|
||||
"team_pk": team_pk,
|
||||
}),
|
||||
},
|
||||
)
|
||||
519
teams/templates/mails/team_membership_invite.html
Normal file
519
teams/templates/mails/team_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 "{{ team.name }}".</span>
|
||||
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; background-color: #F2F4F6; margin: 0; padding: 0;" bgcolor="#F2F4F6">
|
||||
<tr>
|
||||
<td align="center" style="word-break: break-word; font-family: "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 "{{ team.name}}".</h1>
|
||||
|
||||
<p style="font-size: 16px; line-height: 1.625; color: #51545E; margin: 1.1875em 0 1.1875em;">
|
||||
You have been invited to join the team "{{ team.name }}" on {{ site_title }}.
|
||||
</p>
|
||||
|
||||
{# Action #}
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;">
|
||||
<tr>
|
||||
<td align="center" style="word-break: break-word; font-family: "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
teams/templates/mails/team_membership_invite.txt
Normal file
5
teams/templates/mails/team_membership_invite.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
You have been invited to join the team "{{ team.name }}" on {{ site_title }}.
|
||||
|
||||
View, accept or reject the invitation by clicking the link below:
|
||||
|
||||
{{ url }}
|
||||
519
teams/templates/mails/team_membership_invite_new_user.html
Normal file
519
teams/templates/mails/team_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 "{{ team.name }}".</span>
|
||||
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; background-color: #F2F4F6; margin: 0; padding: 0;" bgcolor="#F2F4F6">
|
||||
<tr>
|
||||
<td align="center" style="word-break: break-word; font-family: "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 "{{ team.name}}".</h1>
|
||||
|
||||
<p style="font-size: 16px; line-height: 1.625; color: #51545E; margin: 1.1875em 0 1.1875em;">
|
||||
You have been invited to join {{ site_title }} as part of the team "{{ team.name }}".
|
||||
</p>
|
||||
|
||||
{# Action #}
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; -premailer-cellspacing: 0; text-align: center; margin: 30px auto; padding: 0;">
|
||||
<tr>
|
||||
<td align="center" style="word-break: break-word; font-family: "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 team "{{ team.name }}".
|
||||
|
||||
View, accept or reject the invitation by clicking the link below:
|
||||
|
||||
{{ url }}
|
||||
87
teams/templates/teams/team_list.html
Normal file
87
teams/templates/teams/team_list.html
Normal file
@@ -0,0 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Teams · {{ site_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="m-4">
|
||||
<h1 class="text-4xl mt-4 font-bold">Teams</h1>
|
||||
|
||||
|
||||
<div class="flex bg-slate-50 mt-4 items-end">
|
||||
<div class="flex">
|
||||
<a href=""><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if state_filter == "mine" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">My Teams</div></a>
|
||||
<a href=""><div class="p-4 font-bold text-xl hover:bg-slate-200 {% if state_filter == "all" %}text-cyan-500 border-cyan-500 border-b-4 {% else %}text-slate-500 hover:border-b-4 hover:border-slate-400{% endif %}">All Teams</div></a>
|
||||
</div>
|
||||
<div class="ml-auto p-2">
|
||||
<input type="text" name="search" placeholder="search teams..." class="focus:border-cyan-500 focus:ring-cyan-200 rounded-md"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{% for team in team_list %}
|
||||
<tr class="bg-white border-slate-200 border-b-2">
|
||||
<td class="w-full p-4">
|
||||
<div class="text-xl font-bold text-slate-800">
|
||||
{{ team.name }}
|
||||
</div>
|
||||
<div>
|
||||
{{ team.project_set.count }} projects | {{ team.teammembership_set.count }} members
|
||||
</td>
|
||||
|
||||
<td class="pr-2">
|
||||
<span class="bg-cyan-100 rounded-2xl px-4 py-2 ml-2 text-sm">Admin</span>
|
||||
</td>
|
||||
|
||||
<td class="pr-2">
|
||||
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer" onclick="followContainedLink(this);" >
|
||||
<a href="{% url 'team_members' team_pk=team.id %}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="pr-2">
|
||||
<div class="rounded-full hover:bg-slate-100 p-2 cursor-pointer"onclick="followContainedLink(this);" >
|
||||
<a href="TODO">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-8">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<div>
|
||||
<button name="action" value="leave" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-md">Leave</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/team_list.js' %}"></script>
|
||||
{% endblock %}
|
||||
76
teams/templates/teams/team_members.html
Normal file
76
teams/templates/teams/team_members.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Members · {{ team.name }} · {{ site_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
<div class="m-4 max-w-4xl flex-auto">
|
||||
|
||||
{% if messages %}
|
||||
<ul class="mb-4">
|
||||
{% for message in messages %}
|
||||
{# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
|
||||
<li class="bg-cyan-50 border-2 border-cyan-800 p-4 rounded-lg">{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex">
|
||||
<h1 class="text-4xl mt-4 font-bold">Team Members</h1>
|
||||
|
||||
<div class="ml-auto mt-6">
|
||||
<a class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md" href="{% url "team_members_invite" team_pk=team.pk %}">Invite Member</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<table class="w-full mt-8">
|
||||
<tbody>
|
||||
<thead>
|
||||
<tr class="bg-slate-200">
|
||||
<th class="w-full p-4 text-left text-xl" colspan="2">{{ team.name }}</th>
|
||||
</tr>
|
||||
|
||||
{% for member in members %}
|
||||
<tr class="bg-white border-slate-200 border-b-2">
|
||||
<td class="w-full p-4">
|
||||
<div>
|
||||
<a href="TODO" class="text-xl text-cyan-500 font-bold">{{ member.user.email }}</a> {# "best name" perhaps later? #}
|
||||
{% if not member.accepted %}
|
||||
<span class="bg-slate-100 rounded-2xl px-4 py-2 ml-2 text-sm">Invited</span>
|
||||
{% elif member.role == 0 %} {# NOTE: we intentionally hide admin-ness for non-accepted users; TODO better use of constants #}
|
||||
<span class="bg-cyan-100 rounded-2xl px-4 py-2 ml-2 text-sm">Admin</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="p-4">
|
||||
<div class="flex justify-end">
|
||||
{% if request.user == member.user %} {# TODO: do not allow leaving when there is only a single admin #}
|
||||
<button name="action" value="delete-%s" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-md">Leave</button>
|
||||
{% else %} {# NOTE: in our setup request_user_is_admin is implied because only admins may view the membership page #}
|
||||
<button name="action" value="delete-%s" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-1 border-2 hover:bg-slate-200 active:ring rounded-md">Remove</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
31
teams/templates/teams/team_members_accept.html
Normal file
31
teams/templates/teams/team_members_accept.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Invitation · {{ team.name }} · {{ site_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
<div class="m-4 max-w-4xl flex-auto">
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
<div>
|
||||
<h1 class="text-4xl mt-4 font-bold">Invitation to "{{ team.name }}"</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mb-4">
|
||||
You have been invited to join the team "{{ team.name }}" in the role of "{{ membership.get_role_display }}". Please confirm by clicking the button below.
|
||||
</div>
|
||||
|
||||
<button name="action" value="accept" class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md">Accept</button>
|
||||
<button name="action" value="decline" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Decline</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
69
teams/templates/teams/team_members_invite.html
Normal file
69
teams/templates/teams/team_members_invite.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Invite Members · {{ team.name }} · {{ site_title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
|
||||
<div class="m-4 max-w-4xl flex-auto">
|
||||
<form action="" method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if messages %}
|
||||
<ul>
|
||||
{% for message in messages %}
|
||||
{# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
|
||||
<li class="bg-cyan-50 border-2 border-cyan-800 p-4 rounded-lg">{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<h1 class="text-4xl mt-4 font-bold">Invite members ({{ team.name }})</h1>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="mt-4 mb-4">
|
||||
Invite a member to join the team "{{ team.name }}". They will receive an email with a link to join.
|
||||
</div>
|
||||
|
||||
<div class="text-lg mb-6 md:mb-8">
|
||||
<input name="email" type="text" class="{% if form.email.errors %}bg-red-100{% else %}bg-slate-100{% endif %} pl-4 py-2 md:py-4 focus:outline-none w-full" {% if form.email.value %}value="{{ form.email.value }}"{% endif %} placeholder="{{ form.email.label }}" />
|
||||
{% if form.email.errors %}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% elif form.email.help_text %}
|
||||
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.email.help_text|safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-lg ml-1 mb-8"> {# ml-1 is strictly speaking not aligned, but visually it looks better "to me"; perhaps because of all of the round elements? #}
|
||||
<div class="text-slate-800 font-bold">{{ form.role.label }}</div>
|
||||
<div class="flex items-center">
|
||||
{{ form.role }}
|
||||
|
||||
</div>
|
||||
{% if form.role.errors %}
|
||||
{% for error in form.role.errors %}
|
||||
<div class="text-red-500 pt-1 px-2 text-sm">{{ error }}</div>
|
||||
{% endfor %}
|
||||
{% elif form.role.help_text %}
|
||||
<div class="text-gray-500 pt-1 px-2 text-sm">{{ form.role.help_text|safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button name="action" value="invite" class="font-bold text-slate-800 border-slate-500 pl-4 pr-4 pb-2 pt-2 border-2 bg-cyan-200 hover:bg-cyan-400 active:ring rounded-md">Invite Member</button>
|
||||
<button name="action" value="invite_and_add_another" class="font-bold text-slate-500 border-slate-300 pl-4 pr-4 pb-2 pt-2 ml-2 border-2 hover:bg-slate-200 active:ring rounded-md">Invite and add another</button>
|
||||
<a href="{% url "team_members" team_pk=team.pk %}" class="font-bold text-slate-500 ml-4">Cancel</a>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
3
teams/tests.py
Normal file
3
teams/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
12
teams/urls.py
Normal file
12
teams/urls.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import team_list, team_members, team_members_invite, team_members_accept_new_user, team_members_accept
|
||||
|
||||
urlpatterns = [
|
||||
path('', team_list, name="team_list"),
|
||||
path('<str:team_pk>/members/', team_members, name="team_members"),
|
||||
path('<str:team_pk>/members/invite/', team_members_invite, name="team_members_invite"),
|
||||
path('<str:team_pk>/members/accept/', team_members_accept, name="team_members_accept"),
|
||||
path(
|
||||
'<str:team_pk>/members/accept/<str:token>/', team_members_accept_new_user, name="team_members_accept_new_user"),
|
||||
]
|
||||
152
teams/views.py
Normal file
152
teams/views.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from django.shortcuts import render, redirect
|
||||
from django.contrib.auth import get_user_model, login
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse
|
||||
from django.contrib import messages
|
||||
|
||||
from users.models import EmailVerification
|
||||
from bugsink.app_settings import get_settings
|
||||
from bugsink.decorators import login_exempt
|
||||
|
||||
from .models import Team, TeamMembership, TeamRole
|
||||
from .forms import TeamMemberInviteForm
|
||||
from .tasks import send_team_invite_email, send_team_invite_email_new_user
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def team_list(request):
|
||||
team_list = Team.objects.all()
|
||||
return render(request, 'teams/team_list.html', {
|
||||
'state_filter': 'mine',
|
||||
'team_list': team_list,
|
||||
})
|
||||
|
||||
|
||||
def team_members(request, team_pk):
|
||||
# TODO: check if user is a member of the team and has permission to view this page
|
||||
team = Team.objects.get(id=team_pk)
|
||||
return render(request, 'teams/team_members.html', {
|
||||
'team': team,
|
||||
'members': team.teammembership_set.all().select_related('user'),
|
||||
})
|
||||
|
||||
|
||||
def team_members_invite(request, team_pk):
|
||||
# TODO: check if user is a member of the team and has permission to view this page
|
||||
|
||||
team = Team.objects.get(id=team_pk)
|
||||
|
||||
user_must_exist = True # TODO implement based on USER_REGISTRATION setting and how it compares to the current user
|
||||
user_must_exist = False
|
||||
|
||||
if request.method == 'POST':
|
||||
form = TeamMemberInviteForm(user_must_exist, request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
# because we do validation in the form (which takes user_must_exist as a param), we know we can create the
|
||||
# user if needed if this point is reached.
|
||||
email = form.cleaned_data['email']
|
||||
|
||||
user, user_created = User.objects.get_or_create(
|
||||
email=email, defaults={'username': email, 'is_active': False})
|
||||
|
||||
if user.is_active:
|
||||
send_team_invite_email.delay(email, team_pk)
|
||||
else:
|
||||
# this happens for new (in this view) users, but also for users who have been invited before but have
|
||||
# not yet accepted the invite. In the latter case, we just send a fresh email
|
||||
verification = EmailVerification.objects.create(user=user, email=user.username)
|
||||
send_team_invite_email_new_user.delay(email, team_pk, verification.token)
|
||||
|
||||
_, membership_created = TeamMembership.objects.get_or_create(team=team, user=user, defaults={
|
||||
'role': form.cleaned_data['role'],
|
||||
'accepted': False,
|
||||
})
|
||||
|
||||
if membership_created:
|
||||
messages.success(request, f"Invitation sent to {email}")
|
||||
else:
|
||||
messages.success(
|
||||
request, f"Invitation resent to {email} (it was previously sent and we just sent it again)")
|
||||
|
||||
if request.POST.get('action') == "invite_and_add_another":
|
||||
return redirect('team_members_invite', team_pk=team_pk)
|
||||
|
||||
# I think this is enough feedback, as the user will just show up there
|
||||
return redirect('team_members', team_pk=team_pk)
|
||||
|
||||
else:
|
||||
form = TeamMemberInviteForm(user_must_exist)
|
||||
|
||||
return render(request, 'teams/team_members_invite.html', {
|
||||
'team': team,
|
||||
'form': form,
|
||||
})
|
||||
|
||||
|
||||
@login_exempt # no login is required, the token is what identifies the user
|
||||
def team_members_accept_new_user(request, team_pk, token):
|
||||
# There is a lot of overlap with the email-verification flow here; security-wise we make the same assumptions as we
|
||||
# do over there, namely: access to email implies control over the account. This is also the reason we reuse that
|
||||
# app's `EmailVerification` model.
|
||||
|
||||
# clean up expired tokens; doing this on every request is just fine, it saves us from having to run a cron
|
||||
# job-like thing
|
||||
EmailVerification.objects.filter(
|
||||
created_at__lt=timezone.now() - timedelta(get_settings().USER_REGISTRATION_VERIFY_EMAIL_EXPIRY)).delete()
|
||||
|
||||
try:
|
||||
verification = EmailVerification.objects.get(token=token)
|
||||
except EmailVerification.DoesNotExist:
|
||||
# good enough (though a special page might be prettier)
|
||||
raise Http404("Invalid or expired token")
|
||||
|
||||
user = verification.user
|
||||
if not user.has_usable_password() or not user.is_active:
|
||||
# NOTE: we make the had assumption here that users without a password can self-upgrade to become users with a
|
||||
# password. In the future (e.g. LDAP) this may not be what we want, and we'll have to implement a separate field
|
||||
# to store whether we're dealing with "created by email invite, password must still be set" or "created by
|
||||
# external system, password is managed externally". For now, we're good.
|
||||
# In the above we take the (perhaps redundant) approach of checking for either of 2 login-blocking conditions.
|
||||
|
||||
return HttpResponseRedirect(reverse("set_password", kwargs={"token": token}) + "?next=" + reverse(
|
||||
team_members_accept, kwargs={"team_pk": team_pk})
|
||||
)
|
||||
# TODO: thoughts about showing the user what's going on.
|
||||
|
||||
# the above "set_password" branch is the "main flow"/"whole point" of this view: auto-login using a token and
|
||||
# subsequent password-set because no user exists yet. However, it is possible that a user ends up here while already
|
||||
# having registered, e.g. when multiple invites have been sent in a row. In that case, the password-setting may be
|
||||
# skipped and we can just skip straight to the actual team-accept
|
||||
|
||||
# TODO: check how this interacts with login_[not]_required.... my thinking is: we should just do a login() here
|
||||
# and should be OK; but this needs to be tested.
|
||||
login(request, user)
|
||||
return team_members_accept(request, team_pk)
|
||||
|
||||
|
||||
def team_members_accept(request, team_pk):
|
||||
team = Team.objects.get(id=team_pk)
|
||||
membership = TeamMembership.objects.get(team=team, user=request.user)
|
||||
|
||||
if membership.accepted:
|
||||
return redirect() # TODO same question as below
|
||||
|
||||
if request.method == 'POST':
|
||||
# no need for a form, it's just a pair of buttons
|
||||
if request.POST["action"] == "decline":
|
||||
membership.delete()
|
||||
return redirect("home")
|
||||
|
||||
if request.POST["action"] == "accept":
|
||||
membership.accepted = True
|
||||
membership.save()
|
||||
return redirect() # TODO what's a good thing to show for any given team? we don't have that yet I think.
|
||||
|
||||
raise Http404("Invalid action")
|
||||
|
||||
return render(request, "teams/team_members_accept.html", {"team": team, "membership": membership})
|
||||
2
theme/static/css/dist/styles.css
vendored
2
theme/static/css/dist/styles.css
vendored
File diff suppressed because one or more lines are too long
@@ -204,3 +204,9 @@ pre { line-height: 125%; }
|
||||
.syntax-coloring .vi { color: #19177C } /* Name.Variable.Instance */
|
||||
.syntax-coloring .vm { color: #19177C } /* Name.Variable.Magic */
|
||||
.syntax-coloring .il { color: #666666 } /* Literal.Number.Integer.Long */
|
||||
|
||||
input[type='radio'] {
|
||||
/* I wanted to style the whole of the radio button in a non-navy color (something that fits more with what we
|
||||
do generally but I didn't manage to get it done in the self-allotted time. I'm still seeing a navy outer ring */
|
||||
color: rgb(6 182 212); /* cyan-500 */
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ from django.urls import reverse
|
||||
from snappea.decorators import shared_task
|
||||
|
||||
from bugsink.app_settings import get_settings
|
||||
|
||||
from bugsink.utils import send_rendered_email
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="next" value="{{ next }}" />
|
||||
|
||||
<button class="bg-slate-800 font-medium p-2 md:p-4 text-white uppercase w-full">Sign up</button>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -108,6 +108,8 @@ def request_reset_password(request):
|
||||
|
||||
|
||||
def reset_password(request, token=None):
|
||||
# alternative name: set_password (because this one also works for initial setting of a password)
|
||||
|
||||
# clean up expired tokens; doing this on every request is just fine, it saves us from having to run a cron
|
||||
# job-like thing
|
||||
EmailVerification.objects.filter(
|
||||
@@ -120,6 +122,7 @@ def reset_password(request, token=None):
|
||||
raise Http404("Invalid or expired token")
|
||||
|
||||
user = verification.user
|
||||
next = request.POST.get("next", request.GET.get("next", redirect('home')))
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SetPasswordForm(user, request.POST)
|
||||
@@ -131,12 +134,13 @@ def reset_password(request, token=None):
|
||||
verification.delete()
|
||||
|
||||
login(request, verification.user)
|
||||
return redirect('home')
|
||||
|
||||
return redirect(next)
|
||||
|
||||
else:
|
||||
form = SetPasswordForm(user)
|
||||
|
||||
return render(request, "users/reset_password.html", {"form": form})
|
||||
return render(request, "users/reset_password.html", {"form": form, "next": next})
|
||||
|
||||
|
||||
DEBUG_CONTEXTS = {
|
||||
|
||||
Reference in New Issue
Block a user