diff --git a/bugsink/app_settings.py b/bugsink/app_settings.py
index 65e45ff..3dc64f2 100644
--- a/bugsink/app_settings.py
+++ b/bugsink/app_settings.py
@@ -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,
diff --git a/bugsink/settings/default.py b/bugsink/settings/default.py
index a7b7b8f..afaa482 100644
--- a/bugsink/settings/default.py
+++ b/bugsink/settings/default.py
@@ -53,6 +53,7 @@ INSTALLED_APPS = [
'theme',
'snappea',
'compat',
+ 'teams',
'projects',
'releases',
'ingest',
diff --git a/bugsink/urls.py b/bugsink/urls.py
index e78eef6..29ac56c 100644
--- a/bugsink/urls.py
+++ b/bugsink/urls.py
@@ -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')),
diff --git a/projects/migrations/0009_project_team.py b/projects/migrations/0009_project_team.py
new file mode 100644
index 0000000..acf6d0c
--- /dev/null
+++ b/projects/migrations/0009_project_team.py
@@ -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'),
+ ),
+ ]
diff --git a/projects/migrations/0010_set_single_team.py b/projects/migrations/0010_set_single_team.py
new file mode 100644
index 0000000..7c0cf90
--- /dev/null
+++ b/projects/migrations/0010_set_single_team.py
@@ -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),
+ ]
diff --git a/projects/models.py b/projects/models.py
index 6458aa3..6a9bb3f 100644
--- a/projects/models.py
+++ b/projects/models.py
@@ -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")
diff --git a/projects/templates/projects/project_list.html b/projects/templates/projects/project_list.html
new file mode 100644
index 0000000..353e427
--- /dev/null
+++ b/projects/templates/projects/project_list.html
@@ -0,0 +1,85 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block title %}Projects · {{ site_title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
Projects
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/projects/templates/projects/project_members.html b/projects/templates/projects/project_members.html
new file mode 100644
index 0000000..dc19a95
--- /dev/null
+++ b/projects/templates/projects/project_members.html
@@ -0,0 +1,58 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block title %}Members · {{ project.name }} · {{ site_title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
Members
+
+
+ Invite Member
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/projects/urls.py b/projects/urls.py
new file mode 100644
index 0000000..e3e3ba0
--- /dev/null
+++ b/projects/urls.py
@@ -0,0 +1,8 @@
+from django.urls import path
+
+from .views import project_list, project_members
+
+urlpatterns = [
+ path('', project_list, name="project_list"),
+ path('/members/', project_members, name="project_members"),
+]
diff --git a/projects/views.py b/projects/views.py
index 91ea44a..3646d57 100644
--- a/projects/views.py
+++ b/projects/views.py
@@ -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'),
+ })
diff --git a/pyproject.toml b/pyproject.toml
index 4191a52..3e74e0b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,6 +45,7 @@ include = [
"sentry_sdk_extensions*",
"snappea*",
"static*",
+ "teams*",
"templates*",
"theme*",
"users*",
diff --git a/static/js/project_list.js b/static/js/project_list.js
new file mode 100644
index 0000000..9f25b48
--- /dev/null
+++ b/static/js/project_list.js
@@ -0,0 +1,6 @@
+"use strict";
+
+function followContainedLink(circleDiv) {
+ const link = circleDiv.querySelector("a");
+ window.location.href = link.href;
+}
diff --git a/static/js/team_list.js b/static/js/team_list.js
new file mode 100644
index 0000000..9f25b48
--- /dev/null
+++ b/static/js/team_list.js
@@ -0,0 +1,6 @@
+"use strict";
+
+function followContainedLink(circleDiv) {
+ const link = circleDiv.querySelector("a");
+ window.location.href = link.href;
+}
diff --git a/teams/__init__.py b/teams/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/teams/admin.py b/teams/admin.py
new file mode 100644
index 0000000..3f6aa0f
--- /dev/null
+++ b/teams/admin.py
@@ -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',
+ ]
diff --git a/teams/apps.py b/teams/apps.py
new file mode 100644
index 0000000..274d77f
--- /dev/null
+++ b/teams/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class TeamsConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'teams'
diff --git a/teams/forms.py b/teams/forms.py
new file mode 100644
index 0000000..3ea09eb
--- /dev/null
+++ b/teams/forms.py
@@ -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
diff --git a/teams/migrations/0001_initial.py b/teams/migrations/0001_initial.py
new file mode 100644
index 0000000..c648ead
--- /dev/null
+++ b/teams/migrations/0001_initial.py
@@ -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')},
+ },
+ ),
+ ]
diff --git a/teams/migrations/0002_create_single_team.py b/teams/migrations/0002_create_single_team.py
new file mode 100644
index 0000000..a7628d3
--- /dev/null
+++ b/teams/migrations/0002_create_single_team.py
@@ -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),
+ ]
diff --git a/teams/migrations/0003_team_visibility.py b/teams/migrations/0003_team_visibility.py
new file mode 100644
index 0000000..9d6365d
--- /dev/null
+++ b/teams/migrations/0003_team_visibility.py
@@ -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),
+ ),
+ ]
diff --git a/teams/migrations/0004_teammembership_accepted.py b/teams/migrations/0004_teammembership_accepted.py
new file mode 100644
index 0000000..f31a6dc
--- /dev/null
+++ b/teams/migrations/0004_teammembership_accepted.py
@@ -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),
+ ),
+ ]
diff --git a/teams/migrations/__init__.py b/teams/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/teams/models.py b/teams/models.py
new file mode 100644
index 0000000..460e588
--- /dev/null
+++ b/teams/models.py
@@ -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")
diff --git a/teams/tasks.py b/teams/tasks.py
new file mode 100644
index 0000000..0c79825
--- /dev/null
+++ b/teams/tasks.py
@@ -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,
+ }),
+ },
+ )
diff --git a/teams/templates/mails/team_membership_invite.html b/teams/templates/mails/team_membership_invite.html
new file mode 100644
index 0000000..508c279
--- /dev/null
+++ b/teams/templates/mails/team_membership_invite.html
@@ -0,0 +1,519 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ site_title }}
+
+
+
+ {# Email Body #}
+
+
+
+ {# Body content #}
+
+
+
+
Invitation to "{{ team.name}}".
+
+
+ You have been invited to join the team "{{ team.name }}" on {{ site_title }}.
+
+
+ {# Action #}
+
+
+
+ {# Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design #}
+
+
+
+
+
+ {# Sub copy #}
+
+
+
+ Copyable link:
+ {{ url }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/teams/templates/mails/team_membership_invite.txt b/teams/templates/mails/team_membership_invite.txt
new file mode 100644
index 0000000..6095020
--- /dev/null
+++ b/teams/templates/mails/team_membership_invite.txt
@@ -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 }}
diff --git a/teams/templates/mails/team_membership_invite_new_user.html b/teams/templates/mails/team_membership_invite_new_user.html
new file mode 100644
index 0000000..81c4e49
--- /dev/null
+++ b/teams/templates/mails/team_membership_invite_new_user.html
@@ -0,0 +1,519 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ site_title }}
+
+
+
+ {# Email Body #}
+
+
+
+ {# Body content #}
+
+
+
+
Invitation to "{{ team.name}}".
+
+
+ You have been invited to join {{ site_title }} as part of the team "{{ team.name }}".
+
+
+ {# Action #}
+
+
+
+ {# Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design #}
+
+
+
+
+
+ {# Sub copy #}
+
+
+
+ Copyable link:
+ {{ url }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/teams/templates/mails/team_membership_invite_new_user.txt b/teams/templates/mails/team_membership_invite_new_user.txt
new file mode 100644
index 0000000..70baf8a
--- /dev/null
+++ b/teams/templates/mails/team_membership_invite_new_user.txt
@@ -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 }}
diff --git a/teams/templates/teams/team_list.html b/teams/templates/teams/team_list.html
new file mode 100644
index 0000000..f1eed8c
--- /dev/null
+++ b/teams/templates/teams/team_list.html
@@ -0,0 +1,87 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block title %}Teams · {{ site_title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/teams/templates/teams/team_members.html b/teams/templates/teams/team_members.html
new file mode 100644
index 0000000..85b5b95
--- /dev/null
+++ b/teams/templates/teams/team_members.html
@@ -0,0 +1,76 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block title %}Members · {{ team.name }} · {{ site_title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ {% if messages %}
+
+ {% for message in messages %}
+ {# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #}
+ {{ message }}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/teams/templates/teams/team_members_accept.html b/teams/templates/teams/team_members_accept.html
new file mode 100644
index 0000000..5ad5335
--- /dev/null
+++ b/teams/templates/teams/team_members_accept.html
@@ -0,0 +1,31 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block title %}Invitation · {{ team.name }} · {{ site_title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+{% endblock %}
diff --git a/teams/templates/teams/team_members_invite.html b/teams/templates/teams/team_members_invite.html
new file mode 100644
index 0000000..fb3ff85
--- /dev/null
+++ b/teams/templates/teams/team_members_invite.html
@@ -0,0 +1,69 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block title %}Invite Members · {{ team.name }} · {{ site_title }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+{% endblock %}
diff --git a/teams/tests.py b/teams/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/teams/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/teams/urls.py b/teams/urls.py
new file mode 100644
index 0000000..b989786
--- /dev/null
+++ b/teams/urls.py
@@ -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('/members/', team_members, name="team_members"),
+ path('/members/invite/', team_members_invite, name="team_members_invite"),
+ path('/members/accept/', team_members_accept, name="team_members_accept"),
+ path(
+ '/members/accept//', team_members_accept_new_user, name="team_members_accept_new_user"),
+]
diff --git a/teams/views.py b/teams/views.py
new file mode 100644
index 0000000..bcab4a5
--- /dev/null
+++ b/teams/views.py
@@ -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})
diff --git a/theme/static/css/dist/styles.css b/theme/static/css/dist/styles.css
index 3b07a96..5e02eb0 100644
--- a/theme/static/css/dist/styles.css
+++ b/theme/static/css/dist/styles.css
@@ -1 +1 @@
-/*! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:IBM Plex Sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid #0000;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid #0000;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:#0000;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=checkbox]:indeterminate,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:#0000;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:#0000;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-1\/2{left:50%}.float-right{float:right}.m-1{margin:.25rem}.m-4{margin:1rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.h-12{height:3rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-screen{height:100vh}.w-1\/4{width:25%}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-128{width:32rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.flex-\[2_1_96rem\]{flex:2 1 96rem}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-none{list-style-type:none}.flex-col{flex-direction:column}.place-content-end{place-content:end}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.self-stretch{align-self:stretch}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.rounded-e-md{border-start-end-radius:.375rem;border-end-end-radius:.375rem}.rounded-s-md{border-start-start-radius:.375rem;border-end-start-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b-2{border-bottom-width:2px}.border-b-4{border-bottom-width:4px}.border-l-2{border-left-width:2px}.border-r-2{border-right-width:2px}.border-t-2{border-top-width:2px}.border-dotted{border-style:dotted}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity))}.border-cyan-800{--tw-border-opacity:1;border-color:rgb(21 94 117/var(--tw-border-opacity))}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity))}.bg-cyan-200{--tw-bg-opacity:1;background-color:rgb(165 243 252/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-slate-300{--tw-gradient-from:#cbd5e1 var(--tw-gradient-from-position);--tw-gradient-to:#cbd5e100 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.p-12{padding:3rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-1{padding-left:.25rem}.pl-12{padding-left:3rem}.pl-2{padding-left:.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-normal{line-height:1.5}.tracking-normal{letter-spacing:0}.text-cyan-500{--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.line-through{text-decoration-line:line-through}.decoration-dotted{text-decoration-style:dotted}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}.dropdown{position:relative;display:inline-block}.dropdown-content-right{display:none;position:absolute;z-index:1;margin-left:auto;right:0}.dropdown-content-left{display:none;position:absolute;z-index:1;left:0}.dropdown:hover .dropdown-content-left,.dropdown:hover .dropdown-content-right{display:flex}.triangle-left{position:relative}.triangle-left:before{content:"";border-color:#0000 #cbd5e1 #0000 #0000;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-8px;top:20px}.triangle-left:after{content:"";border-color:#0000 #fff #0000 #0000;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-6px;top:20px}pre{line-height:125%}.syntax-coloring .c{color:#3d7b7b;font-style:italic}.syntax-coloring .err{border:1px solid red}.syntax-coloring .k{color:green;font-weight:700}.syntax-coloring .o{color:#666}.syntax-coloring .ch,.syntax-coloring .cm{color:#3d7b7b;font-style:italic}.syntax-coloring .cp{color:#9c6500}.syntax-coloring .c1,.syntax-coloring .cpf,.syntax-coloring .cs{color:#3d7b7b;font-style:italic}.syntax-coloring .gd{color:#a00000}.syntax-coloring .ge{font-style:italic}.syntax-coloring .ges{font-weight:700;font-style:italic}.syntax-coloring .gr{color:#e40000}.syntax-coloring .gh{color:navy;font-weight:700}.syntax-coloring .gi{color:#008400}.syntax-coloring .go{color:#717171}.syntax-coloring .gp{color:navy;font-weight:700}.syntax-coloring .gs{font-weight:700}.syntax-coloring .gu{color:purple;font-weight:700}.syntax-coloring .gt{color:#04d}.syntax-coloring .kc,.syntax-coloring .kd,.syntax-coloring .kn{color:green;font-weight:700}.syntax-coloring .kp{color:green}.syntax-coloring .kr{color:green;font-weight:700}.syntax-coloring .kt{color:#b00040}.syntax-coloring .m{color:#666}.syntax-coloring .s{color:#ba2121}.syntax-coloring .na{color:#687822}.syntax-coloring .nb{color:green}.syntax-coloring .nc{color:#00f;font-weight:700}.syntax-coloring .no{color:#800}.syntax-coloring .nd{color:#a2f}.syntax-coloring .ni{color:#717171;font-weight:700}.syntax-coloring .ne{color:#cb3f38;font-weight:700}.syntax-coloring .nf{color:#00f}.syntax-coloring .nl{color:#767600}.syntax-coloring .nn{color:#00f;font-weight:700}.syntax-coloring .nt{color:green;font-weight:700}.syntax-coloring .nv{color:#19177c}.syntax-coloring .ow{color:#a2f;font-weight:700}.syntax-coloring .w{color:#bbb}.syntax-coloring .mb,.syntax-coloring .mf,.syntax-coloring .mh,.syntax-coloring .mi,.syntax-coloring .mo{color:#666}.syntax-coloring .dl,.syntax-coloring .sa,.syntax-coloring .sb,.syntax-coloring .sc{color:#ba2121}.syntax-coloring .sd{color:#ba2121;font-style:italic}.syntax-coloring .s2{color:#ba2121}.syntax-coloring .se{color:#aa5d1f;font-weight:700}.syntax-coloring .sh{color:#ba2121}.syntax-coloring .si{color:#a45a77;font-weight:700}.syntax-coloring .sx{color:green}.syntax-coloring .sr{color:#a45a77}.syntax-coloring .s1{color:#ba2121}.syntax-coloring .ss{color:#19177c}.syntax-coloring .bp{color:green}.syntax-coloring .fm{color:#00f}.syntax-coloring .vc,.syntax-coloring .vg,.syntax-coloring .vi,.syntax-coloring .vm{color:#19177c}.syntax-coloring .il{color:#666}.hover\:border-b-4:hover{border-bottom-width:4px}.hover\:border-slate-400:hover{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity))}.hover\:bg-cyan-400:hover{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.hover\:bg-slate-400:hover{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity))}.focus\:border-cyan-500:focus{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity))}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-cyan-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(165 243 252/var(--tw-ring-opacity))}.active\:ring:active{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}@media (min-width:768px){.md\:mb-8{margin-bottom:2rem}.md\:h-16{height:4rem}.md\:w-16{width:4rem}.md\:p-4{padding:1rem}.md\:p-8{padding:2rem}.md\:py-4{padding-top:1rem;padding-bottom:1rem}.md\:pb-16{padding-bottom:4rem}.md\:pl-24{padding-left:6rem}.md\:pr-24{padding-right:6rem}.md\:pt-24{padding-top:6rem}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:w-5\/12{width:41.666667%}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:pb-0{padding-bottom:0}}
\ No newline at end of file
+/*! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:IBM Plex Sans,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}[multiple],[type=date],[type=datetime-local],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week],input:where(:not([type])),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}[multiple]:focus,[type=date]:focus,[type=datetime-local]:focus,[type=email]:focus,[type=month]:focus,[type=number]:focus,[type=password]:focus,[type=search]:focus,[type=tel]:focus,[type=text]:focus,[type=time]:focus,[type=url]:focus,[type=week]:focus,input:where(:not([type])):focus,select:focus,textarea:focus{outline:2px solid #0000;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.5em 1.5em;padding-right:2.5rem;-webkit-print-color-adjust:exact;print-color-adjust:exact}[multiple],[size]:where(select:not([size="1"])){background-image:none;background-position:0 0;background-repeat:unset;background-size:initial;padding-right:.75rem;-webkit-print-color-adjust:unset;print-color-adjust:unset}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:0;-webkit-print-color-adjust:exact;print-color-adjust:exact;display:inline-block;vertical-align:middle;background-origin:border-box;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-shrink:0;height:1rem;width:1rem;color:#2563eb;background-color:#fff;border-color:#6b7280;border-width:1px;--tw-shadow:0 0 #0000}[type=checkbox]{border-radius:0}[type=radio]{border-radius:100%}[type=checkbox]:focus,[type=radio]:focus{outline:2px solid #0000;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:2px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}[type=checkbox]:checked,[type=radio]:checked{border-color:#0000;background-color:currentColor;background-size:100% 100%;background-position:50%;background-repeat:no-repeat}[type=checkbox]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=checkbox]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=radio]:checked{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='3'/%3E%3C/svg%3E")}@media (forced-colors:active) {[type=radio]:checked{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:checked:focus,[type=checkbox]:checked:hover,[type=checkbox]:indeterminate,[type=radio]:checked:focus,[type=radio]:checked:hover{border-color:#0000;background-color:currentColor}[type=checkbox]:indeterminate{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3E%3C/svg%3E");background-size:100% 100%;background-position:50%;background-repeat:no-repeat}@media (forced-colors:active) {[type=checkbox]:indeterminate{-webkit-appearance:auto;-moz-appearance:auto;appearance:auto}}[type=checkbox]:indeterminate:focus,[type=checkbox]:indeterminate:hover{border-color:#0000;background-color:currentColor}[type=file]{background:unset;border-color:inherit;border-width:0;border-radius:0;padding:0;font-size:unset;line-height:inherit}[type=file]:focus{outline:1px solid ButtonText;outline:1px auto -webkit-focus-ring-color}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.visible{visibility:visible}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.left-1\/2{left:50%}.float-right{float:right}.m-1{margin:.25rem}.m-4{margin:1rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-4{margin-left:1rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mr-4{margin-right:1rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.contents{display:contents}.hidden{display:none}.size-8{width:2rem;height:2rem}.h-12{height:3rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-screen{height:100vh}.w-1\/4{width:25%}.w-10\/12{width:83.333333%}.w-12{width:3rem}.w-128{width:32rem}.w-3\/4{width:75%}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-8{width:2rem}.w-full{width:100%}.max-w-4xl{max-width:56rem}.flex-\[2_1_96rem\]{flex:2 1 96rem}.flex-auto{flex:1 1 auto}.flex-none{flex:none}.border-collapse{border-collapse:collapse}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.rotate-180{--tw-rotate:180deg}.rotate-180,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-none{list-style-type:none}.flex-col{flex-direction:column}.place-content-end{place-content:end}.content-start{align-content:flex-start}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.self-stretch{align-self:stretch}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-scroll{overflow-y:scroll}.text-ellipsis{text-overflow:ellipsis}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-e-md{border-start-end-radius:.375rem;border-end-end-radius:.375rem}.rounded-s-md{border-start-start-radius:.375rem;border-end-start-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b-2{border-bottom-width:2px}.border-b-4{border-bottom-width:4px}.border-l-2{border-left-width:2px}.border-r-2{border-right-width:2px}.border-t-2{border-top-width:2px}.border-dotted{border-style:dotted}.border-cyan-500{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity))}.border-cyan-800{--tw-border-opacity:1;border-color:rgb(21 94 117/var(--tw-border-opacity))}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity))}.bg-cyan-100{--tw-bg-opacity:1;background-color:rgb(207 250 254/var(--tw-bg-opacity))}.bg-cyan-200{--tw-bg-opacity:1;background-color:rgb(165 243 252/var(--tw-bg-opacity))}.bg-cyan-50{--tw-bg-opacity:1;background-color:rgb(236 254 255/var(--tw-bg-opacity))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity))}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-slate-300{--tw-gradient-from:#cbd5e1 var(--tw-gradient-from-position);--tw-gradient-to:#cbd5e100 var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.p-12{padding:3rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pl-1{padding-left:.25rem}.pl-12{padding-left:3rem}.pl-2{padding-left:.5rem}.pl-4{padding-left:1rem}.pl-6{padding-left:1.5rem}.pr-2{padding-right:.5rem}.pr-4{padding-right:1rem}.pr-6{padding-right:1.5rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-serif{font-family:ui-serif,Georgia,Cambria,Times New Roman,Times,serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.italic{font-style:italic}.leading-normal{line-height:1.5}.tracking-normal{letter-spacing:0}.text-cyan-500{--tw-text-opacity:1;color:rgb(6 182 212/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity))}.underline{text-decoration-line:underline}.line-through{text-decoration-line:line-through}.decoration-dotted{text-decoration-style:dotted}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Sans;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-sans-v19-cyrillic_cyrillic-ext_greek_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-regular.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:400;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-italic.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:normal;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700.woff2) format("woff2")}@font-face{font-display:swap;font-family:IBM Plex Mono;font-style:italic;font-weight:700;src:url(../../fonts/ibm-plex-mono-v19-cyrillic_cyrillic-ext_latin_latin-ext_vietnamese-700italic.woff2) format("woff2")}.dropdown{position:relative;display:inline-block}.dropdown-content-right{display:none;position:absolute;z-index:1;margin-left:auto;right:0}.dropdown-content-left{display:none;position:absolute;z-index:1;left:0}.dropdown:hover .dropdown-content-left,.dropdown:hover .dropdown-content-right{display:flex}.triangle-left{position:relative}.triangle-left:before{content:"";border-color:#0000 #cbd5e1 #0000 #0000;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-8px;top:20px}.triangle-left:after{content:"";border-color:#0000 #fff #0000 #0000;border-style:solid;border-width:9px 8px 9px 0;position:absolute;left:-6px;top:20px}pre{line-height:125%}.syntax-coloring .c{color:#3d7b7b;font-style:italic}.syntax-coloring .err{border:1px solid red}.syntax-coloring .k{color:green;font-weight:700}.syntax-coloring .o{color:#666}.syntax-coloring .ch,.syntax-coloring .cm{color:#3d7b7b;font-style:italic}.syntax-coloring .cp{color:#9c6500}.syntax-coloring .c1,.syntax-coloring .cpf,.syntax-coloring .cs{color:#3d7b7b;font-style:italic}.syntax-coloring .gd{color:#a00000}.syntax-coloring .ge{font-style:italic}.syntax-coloring .ges{font-weight:700;font-style:italic}.syntax-coloring .gr{color:#e40000}.syntax-coloring .gh{color:navy;font-weight:700}.syntax-coloring .gi{color:#008400}.syntax-coloring .go{color:#717171}.syntax-coloring .gp{color:navy;font-weight:700}.syntax-coloring .gs{font-weight:700}.syntax-coloring .gu{color:purple;font-weight:700}.syntax-coloring .gt{color:#04d}.syntax-coloring .kc,.syntax-coloring .kd,.syntax-coloring .kn{color:green;font-weight:700}.syntax-coloring .kp{color:green}.syntax-coloring .kr{color:green;font-weight:700}.syntax-coloring .kt{color:#b00040}.syntax-coloring .m{color:#666}.syntax-coloring .s{color:#ba2121}.syntax-coloring .na{color:#687822}.syntax-coloring .nb{color:green}.syntax-coloring .nc{color:#00f;font-weight:700}.syntax-coloring .no{color:#800}.syntax-coloring .nd{color:#a2f}.syntax-coloring .ni{color:#717171;font-weight:700}.syntax-coloring .ne{color:#cb3f38;font-weight:700}.syntax-coloring .nf{color:#00f}.syntax-coloring .nl{color:#767600}.syntax-coloring .nn{color:#00f;font-weight:700}.syntax-coloring .nt{color:green;font-weight:700}.syntax-coloring .nv{color:#19177c}.syntax-coloring .ow{color:#a2f;font-weight:700}.syntax-coloring .w{color:#bbb}.syntax-coloring .mb,.syntax-coloring .mf,.syntax-coloring .mh,.syntax-coloring .mi,.syntax-coloring .mo{color:#666}.syntax-coloring .dl,.syntax-coloring .sa,.syntax-coloring .sb,.syntax-coloring .sc{color:#ba2121}.syntax-coloring .sd{color:#ba2121;font-style:italic}.syntax-coloring .s2{color:#ba2121}.syntax-coloring .se{color:#aa5d1f;font-weight:700}.syntax-coloring .sh{color:#ba2121}.syntax-coloring .si{color:#a45a77;font-weight:700}.syntax-coloring .sx{color:green}.syntax-coloring .sr{color:#a45a77}.syntax-coloring .s1{color:#ba2121}.syntax-coloring .ss{color:#19177c}.syntax-coloring .bp{color:green}.syntax-coloring .fm{color:#00f}.syntax-coloring .vc,.syntax-coloring .vg,.syntax-coloring .vi,.syntax-coloring .vm{color:#19177c}.syntax-coloring .il{color:#666}input[type=radio]{color:#06b6d4}.hover\:border-b-4:hover{border-bottom-width:4px}.hover\:border-slate-400:hover{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity))}.hover\:bg-cyan-400:hover{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity))}.hover\:bg-slate-400:hover{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity))}.focus\:border-cyan-500:focus{--tw-border-opacity:1;border-color:rgb(6 182 212/var(--tw-border-opacity))}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}.focus\:ring-cyan-200:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(165 243 252/var(--tw-ring-opacity))}.active\:ring:active{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}@media (min-width:768px){.md\:mb-8{margin-bottom:2rem}.md\:h-16{height:4rem}.md\:w-16{width:4rem}.md\:p-4{padding:1rem}.md\:p-8{padding:2rem}.md\:py-4{padding-top:1rem;padding-bottom:1rem}.md\:pb-16{padding-bottom:4rem}.md\:pl-24{padding-left:6rem}.md\:pr-24{padding-right:6rem}.md\:pt-24{padding-top:6rem}}@media (min-width:1024px){.lg\:flex{display:flex}.lg\:w-5\/12{width:41.666667%}.lg\:flex-row-reverse{flex-direction:row-reverse}.lg\:pb-0{padding-bottom:0}}
\ No newline at end of file
diff --git a/theme/static_src/src/styles.css b/theme/static_src/src/styles.css
index 50de464..5533e24 100644
--- a/theme/static_src/src/styles.css
+++ b/theme/static_src/src/styles.css
@@ -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 */
+}
diff --git a/users/tasks.py b/users/tasks.py
index 32be878..90c7d23 100644
--- a/users/tasks.py
+++ b/users/tasks.py
@@ -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
diff --git a/users/templates/users/reset_password.html b/users/templates/users/reset_password.html
index 67bc486..4d91d5e 100644
--- a/users/templates/users/reset_password.html
+++ b/users/templates/users/reset_password.html
@@ -38,6 +38,8 @@
{% endif %}
+
+
Sign up
diff --git a/users/views.py b/users/views.py
index 1c6fa52..e22ae1f 100644
--- a/users/views.py
+++ b/users/views.py
@@ -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 = {