From 9d9cac3e9db920bde531182cd14f517e5a3841ad Mon Sep 17 00:00:00 2001 From: Klaas van Schelven Date: Mon, 3 Jun 2024 22:30:10 +0200 Subject: [PATCH] WIP teams & project-management --- bugsink/app_settings.py | 1 + bugsink/settings/default.py | 1 + bugsink/urls.py | 2 + projects/migrations/0009_project_team.py | 18 + projects/migrations/0010_set_single_team.py | 21 + projects/models.py | 6 +- projects/templates/projects/project_list.html | 85 +++ .../templates/projects/project_members.html | 58 ++ projects/urls.py | 8 + projects/views.py | 19 +- pyproject.toml | 1 + static/js/project_list.js | 6 + static/js/team_list.js | 6 + teams/__init__.py | 0 teams/admin.py | 61 ++ teams/apps.py | 6 + teams/forms.py | 26 + teams/migrations/0001_initial.py | 36 ++ teams/migrations/0002_create_single_team.py | 25 + teams/migrations/0003_team_visibility.py | 16 + .../0004_teammembership_accepted.py | 16 + teams/migrations/__init__.py | 0 teams/models.py | 43 ++ teams/tasks.py | 47 ++ .../mails/team_membership_invite.html | 519 ++++++++++++++++++ .../mails/team_membership_invite.txt | 5 + .../team_membership_invite_new_user.html | 519 ++++++++++++++++++ .../mails/team_membership_invite_new_user.txt | 5 + teams/templates/teams/team_list.html | 87 +++ teams/templates/teams/team_members.html | 76 +++ .../templates/teams/team_members_accept.html | 31 ++ .../templates/teams/team_members_invite.html | 69 +++ teams/tests.py | 3 + teams/urls.py | 12 + teams/views.py | 152 +++++ theme/static/css/dist/styles.css | 2 +- theme/static_src/src/styles.css | 6 + users/tasks.py | 1 - users/templates/users/reset_password.html | 2 + users/views.py | 8 +- 40 files changed, 1998 insertions(+), 7 deletions(-) create mode 100644 projects/migrations/0009_project_team.py create mode 100644 projects/migrations/0010_set_single_team.py create mode 100644 projects/templates/projects/project_list.html create mode 100644 projects/templates/projects/project_members.html create mode 100644 projects/urls.py create mode 100644 static/js/project_list.js create mode 100644 static/js/team_list.js create mode 100644 teams/__init__.py create mode 100644 teams/admin.py create mode 100644 teams/apps.py create mode 100644 teams/forms.py create mode 100644 teams/migrations/0001_initial.py create mode 100644 teams/migrations/0002_create_single_team.py create mode 100644 teams/migrations/0003_team_visibility.py create mode 100644 teams/migrations/0004_teammembership_accepted.py create mode 100644 teams/migrations/__init__.py create mode 100644 teams/models.py create mode 100644 teams/tasks.py create mode 100644 teams/templates/mails/team_membership_invite.html create mode 100644 teams/templates/mails/team_membership_invite.txt create mode 100644 teams/templates/mails/team_membership_invite_new_user.html create mode 100644 teams/templates/mails/team_membership_invite_new_user.txt create mode 100644 teams/templates/teams/team_list.html create mode 100644 teams/templates/teams/team_members.html create mode 100644 teams/templates/teams/team_members_accept.html create mode 100644 teams/templates/teams/team_members_invite.html create mode 100644 teams/tests.py create mode 100644 teams/urls.py create mode 100644 teams/views.py 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

+ + +
+ +
+ +
+
+ +
+ +
+ {% csrf_token %} + + + + {% for project in project_list %} + + + + + + + + + + + {% endfor %} + +
+ +
+ Team {{ project.team.name }} + | 7 members {# TODO not actaully implemented #} + | {{ project.issue_set.count }} open issues
{# TODO not actually 'open' issues #} +
+ + + + +
+ +
+
+ +
+
+ + +
+ +{% 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

+ +
+ +
+
+ +
+
+ {% csrf_token %} + + + + + + + + + {% for member in members %} + + + + + + + {% endfor %} + +
{{ project.name }}
+
+ {{ member.user.email }} {# "best name" perhaps later? #} +
+
+
+ +
+
+ +
+
+ +
+
+ +{% 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 @@ + + + + + + + + + + + + + + + + + + + + + + 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 @@ + + + + + + + + + + + + + + + + + + + + + + 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 %} + + + +
+

Teams

+ + +
+ +
+ +
+
+ +
+ +
+ {% csrf_token %} + + + + {% for team in team_list %} + + + + + + + + + + + + + {% endfor %} + +
+
+ {{ team.name }} +
+
+ {{ team.project_set.count }} projects | {{ team.teammembership_set.count }} members +
+ Admin + + + + + +
+ +
+
+ +
+
+ + +
+ +{% 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 %} + +
+

Team Members

+ + +
+ +
+
+ {% csrf_token %} + + + + + + + + + {% for member in members %} + + + + + + + {% endfor %} + +
{{ team.name }}
+
+ {{ member.user.email }} {# "best name" perhaps later? #} + {% if not member.accepted %} + Invited + {% elif member.role == 0 %} {# NOTE: we intentionally hide admin-ness for non-accepted users; TODO better use of constants #} + Admin + {% endif %} +
+
+
+ {% if request.user == member.user %} {# TODO: do not allow leaving when there is only a single admin #} + + {% else %} {# NOTE: in our setup request_user_is_admin is implied because only admins may view the membership page #} + + {% 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 %} + + + +
+ +
+
+ {% csrf_token %} + +
+

Invitation to "{{ team.name }}"

+
+ +
+ 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. +
+ + + +
+ +
+
+ +{% 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 %} + + + +
+ +
+
+ {% csrf_token %} + + {% if messages %} +
    + {% for message in messages %} + {# if we introduce different levels we can use{% message.level == DEFAULT_MESSAGE_LEVELS.SUCCESS %} #} +
  • {{ message }}
  • + {% endfor %} +
+ {% endif %} + +
+

Invite members ({{ team.name }})

+
+ + +
+ Invite a member to join the team "{{ team.name }}". They will receive an email with a link to join. +
+ +
+ + {% if form.email.errors %} + {% for error in form.email.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.email.help_text %} +
{{ form.email.help_text|safe }}
+ {% endif %} +
+ +
{# ml-1 is strictly speaking not aligned, but visually it looks better "to me"; perhaps because of all of the round elements? #} +
{{ form.role.label }}
+
+ {{ form.role }} + +
+ {% if form.role.errors %} + {% for error in form.role.errors %} +
{{ error }}
+ {% endfor %} + {% elif form.role.help_text %} +
{{ form.role.help_text|safe }}
+ {% endif %} +
+ + + + Cancel + +
+ +
+
+ +{% 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 %} + + 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 = {