Intitial teams support #147

Merged
Oleg-Komarov merged 34 commits from teams-support into main 2024-05-23 19:43:54 +02:00
20 changed files with 506 additions and 74 deletions

View File

@ -416,11 +416,17 @@
@extend .dropdown-divider @extend .dropdown-divider
+margin(0, top) +margin(0, top)
+margin(1, bottom)
.dropdown-item a
&a &.dropdown-item
+padding(3, x) +padding(3, x)
a
&.dropdown-item-disabled
opacity: .5
pointer-events: none
.extension-icon .extension-icon
display: inline-block display: inline-block
vertical-align: bottom vertical-align: bottom

View File

@ -14,7 +14,7 @@
{% if not field.is_hidden %} {% if not field.is_hidden %}
<label for="{{ field.id_for_label }}" class="form-check-label"> <label for="{{ field.id_for_label }}" class="form-check-label">
{{ label|safe }} {{ label|safe }}
{% if field.field.required %}<span class="form-required-indicator">*</span>{% endif %} {% if field.field.required or required %}<span class="form-required-indicator">*</span>{% endif %}
</label> </label>
{% endif %} {% endif %}
@ -24,7 +24,7 @@
{% if not field.is_hidden %} {% if not field.is_hidden %}
<label for="{{ field.id_for_label }}"> <label for="{{ field.id_for_label }}">
{{ label|safe }} {{ label|safe }}
{% if field.field.required %}<span class="form-required-indicator">*</span>{% endif %} {% if field.field.required or required %}<span class="form-required-indicator">*</span>{% endif %}
</label> </label>
{% endif %} {% endif %}

View File

@ -163,6 +163,18 @@ class ExtensionUpdateForm(forms.ModelForm):
self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews self.add_preview_formset.error_messages['too_few_forms'] = self.msg_need_previews
user_teams = self.request.user.teams.all()
if self.request.user in self.instance.authors.all() and len(user_teams) > 0:
team_slug = None
if self.instance.team:
team_slug = self.instance.team.slug
choices = [(None, 'None'), *[(team.slug, team.name) for team in user_teams]]
self.fields['team'] = forms.ChoiceField(
choices=choices,
required=False,
initial=team_slug,
)
def is_valid(self, *args, **kwargs) -> bool: def is_valid(self, *args, **kwargs) -> bool:
"""Validate all nested forms and form(set)s first.""" """Validate all nested forms and form(set)s first."""
if 'submit_draft' in self.data: if 'submit_draft' in self.data:
@ -198,6 +210,27 @@ class ExtensionUpdateForm(forms.ModelForm):
return all(is_valid_flags) return all(is_valid_flags)
def clean_team(self):
# don't modify instance if the field value wasn't sent
# empty value reset the team
if 'team' in self.data:
# TODO permissions check
# shouldn't happen normally: the form doesn't render the select
if self.request.user not in self.instance.authors.all():
self.add_error('team', _('Not allowed to set the team'))
return
team_slug = self.cleaned_data['team']
if team_slug:
team = self.request.user.teams.filter(slug=team_slug).first()
if not team:
self.add_error('team', _('User does not belong to the team'))
return
else:
self.instance.team = team
else:
self.instance.team = None
def clean(self): def clean(self):
"""Perform additional validation and status changes.""" """Perform additional validation and status changes."""
super().clean() super().clean()

View File

@ -128,12 +128,19 @@ class ExtensionManager(models.Manager):
def unlisted(self): def unlisted(self):
return self.exclude(status=self.model.STATUSES.APPROVED) return self.exclude(status=self.model.STATUSES.APPROVED)
def authored_by(self, user_id: int): def _authored_by_filter(self, user):
return self.filter(maintainer__user_id=user_id) filter = Q(maintainer__user_id=user.pk)
user_teams = user.teams.all()
if user_teams:
filter = filter | Q(team__in=[t.pk for t in user_teams])
return filter
def listed_or_authored_by(self, user_id: int): def authored_by(self, user):
return self.filter(self._authored_by_filter(user)).distinct()
def listed_or_authored_by(self, user):
return self.filter( return self.filter(
Q(status=self.model.STATUSES.APPROVED) | Q(maintainer__user_id=user_id) Q(status=self.model.STATUSES.APPROVED) | self._authored_by_filter(user)
).distinct() ).distinct()
@ -385,10 +392,14 @@ class Extension(CreatedModifiedMixin, RatingMixin, TrackChangesMixin, models.Mod
) )
def has_maintainer(self, user) -> bool: def has_maintainer(self, user) -> bool:
"""Return True if given user is listed as a maintainer.""" """Return True if given user is listed as a maintainer or is a member of the team."""
if user is None or user.is_anonymous: if user is None or user.is_anonymous:
return False return False
return user in self.authors.all() if user in self.authors.all():
return True
if self.team and user in self.team.users.all():
return True
return False
def can_rate(self, user) -> bool: def can_rate(self, user) -> bool:
"""Return True if given user can rate this extension. """Return True if given user can rate this extension.

View File

@ -0,0 +1,16 @@
{% if user in extension.authors.all and user.teams.count > 0 %}
<div class="row mb-3">
<div class="col">
{# django won't allow submitting an empty field for a required field, so using a hack with an explicit required=True #}
{% include "common/components/field.html" with field=extension_form.team label="Assign Team" required=True %}
</div>
</div>
{% endif %}
<div>
{% include "common/components/field.html" with field=extension_form.description label="Description" placeholder="Describe the extension..." %}
</div>
<div>
{% include "common/components/field.html" with field=extension_form.support placeholder="https://example.com" %}
</div>

View File

@ -1,5 +1,5 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load i18n common pipeline %} {% load common filters i18n pipeline %}
{% block page_title %} {% block page_title %}
{% with extension=extension_form.instance %} {% with extension=extension_form.instance %}
@ -38,16 +38,7 @@
</section> </section>
<section class="card p-3 mb-3"> <section class="card p-3 mb-3">
{% for field in extension_form %} {% include "extensions/components/extension_form.html" with extension_form=extension_form %}
{% if field != 'tags' %}
{# TODO: fix handling of tags #}
<div class="row">
<div class="col">
{% include "common/components/field.html" with placeholder="Enter the text here..." %}
</div>
</div>
{% endif %}
{% endfor %}
</section> </section>
<section class="mt-4"> <section class="mt-4">

View File

@ -1,6 +1,5 @@
{% extends "common/base.html" %} {% extends "common/base.html" %}
{% load filters %} {% load common filters i18n pipeline %}
{% load i18n common pipeline %}
{% block page_title %}{{ extension.name }}{% endblock page_title %} {% block page_title %}{{ extension.name }}{% endblock page_title %}
{% block content %} {% block content %}
@ -29,13 +28,7 @@
{{ form.errors }} {{ form.errors }}
<section class="card p-3"> <section class="card p-3">
<div> {% include "extensions/components/extension_form.html" with extension_form=form %}
{% include "common/components/field.html" with field=form.description label="Description" placeholder="Describe the extension..." %}
</div>
<div>
{% include "common/components/field.html" with field=form.support placeholder="https://example.com" %}
</div>
</section> </section>
<section class="mt-4"> <section class="mt-4">

View File

@ -5,10 +5,13 @@ from django.test import TestCase
from common.tests.factories.extensions import create_approved_version, create_version from common.tests.factories.extensions import create_approved_version, create_version
from common.tests.factories.files import FileFactory, ImageFactory from common.tests.factories.files import FileFactory, ImageFactory
from common.tests.factories.teams import TeamFactory
from common.tests.factories.users import UserFactory
from common.tests.utils import _get_all_form_errors, CheckFilePropertiesMixin from common.tests.utils import _get_all_form_errors, CheckFilePropertiesMixin
from extensions.models import Extension from extensions.models import Extension
from files.models import File from files.models import File
from reviewers.models import ApprovalActivity from reviewers.models import ApprovalActivity
from teams.models import TeamsUsers
TEST_FILES_DIR = Path(__file__).resolve().parent / 'files' TEST_FILES_DIR = Path(__file__).resolve().parent / 'files'
POST_DATA = { POST_DATA = {
@ -499,3 +502,128 @@ class UpdateTest(CheckFilePropertiesMixin, TestCase):
response3 = self.client.get(url) response3 = self.client.get(url)
self.assertEqual(response3.status_code, 302) self.assertEqual(response3.status_code, 302)
self.assertEqual(response3['Location'], extension.get_draft_url()) self.assertEqual(response3['Location'], extension.get_draft_url())
def test_team_field_in_draft_form(self):
version = create_version(
extension__status=Extension.STATUSES.DRAFT,
)
extension = version.extension
author = extension.authors.first()
self.client.force_login(author)
team = TeamFactory(slug='test-team')
TeamsUsers(team=team, user=author).save()
url = extension.get_draft_url()
response = self.client.get(url)
# a simple check that we have an input with the team option available
self.assertContains(response, 'value="test-team"')
# post the form to save the team field
response = self.client.post(
url,
{
**POST_DATA,
'team': 'test-team',
'save_draft': '',
},
)
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
extension.refresh_from_db()
self.assertEqual(extension.team.slug, 'test-team')
# can't assign an invalid team slug
response = self.client.post(
url,
{
**POST_DATA,
'team': '-',
'save_draft': '',
},
)
self.assertEqual(response.status_code, 200, _get_all_form_errors(response))
# add another team member, they shouldn't see the field
user = UserFactory()
team2 = TeamFactory(slug='test-team2')
TeamsUsers(team=team, user=user).save()
TeamsUsers(team=team2, user=user).save()
self.client.force_login(user)
response = self.client.get(url)
self.assertNotContains(response, 'value="test-team"')
response = self.client.post(
url,
{
**POST_DATA,
'team': 'test-team2',
'save_draft': '',
},
)
# the field is ignored: no error expected and the team wasn't updated
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
extension.refresh_from_db()
self.assertEqual(extension.team.slug, 'test-team')
def test_team_field_in_update_form(self):
"""This test is a copy-paste of the one above, only status, url and form data differ."""
version = create_version(
extension__status=Extension.STATUSES.APPROVED,
)
extension = version.extension
author = extension.authors.first()
self.client.force_login(author)
team = TeamFactory(slug='test-team')
TeamsUsers(team=team, user=author).save()
url = extension.get_manage_url()
response = self.client.get(url)
# a simple check that we have an input with the team option available
self.assertContains(response, 'value="test-team"')
# post the form to save the team field
response = self.client.post(
url,
{
**POST_DATA,
'team': 'test-team',
'save': '',
},
)
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
extension.refresh_from_db()
self.assertEqual(extension.team.slug, 'test-team')
# can't assign an invalid team slug
response = self.client.post(
url,
{
**POST_DATA,
'team': '-',
'save': '',
},
)
self.assertEqual(response.status_code, 200, _get_all_form_errors(response))
# add another team member, they shouldn't see the field
user = UserFactory()
team2 = TeamFactory(slug='test-team2')
TeamsUsers(team=team, user=user).save()
TeamsUsers(team=team2, user=user).save()
self.client.force_login(user)
response = self.client.get(url)
self.assertNotContains(response, 'value="test-team"')
response = self.client.post(
url,
{
**POST_DATA,
'team': 'test-team2',
'save': '',
},
)
# the field is ignored: no error expected and the team wasn't updated
self.assertEqual(response.status_code, 302, _get_all_form_errors(response))
extension.refresh_from_db()
self.assertEqual(extension.team.slug, 'test-team')

View File

@ -4,10 +4,11 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from common.tests.factories.extensions import create_version, create_approved_version from common.tests.factories.extensions import create_version, create_approved_version
from common.tests.factories.teams import TeamFactory
from common.tests.factories.users import UserFactory from common.tests.factories.users import UserFactory
from extensions.models import Extension, Version from extensions.models import Extension, Version
from files.models import File from files.models import File
from teams.models import Team from teams.models import Team, TeamsUsers
def _create_extension(): def _create_extension():
@ -190,7 +191,7 @@ class ExtensionDetailViewTest(_BaseTestCase):
self._check_detail_page(response, extension) self._check_detail_page(response, extension)
def test_can_view_unlisted_extension_if_maintaner(self): def test_can_view_unlisted_extension_if_maintainer(self):
extension = _create_extension() extension = _create_extension()
self.client.force_login(extension.authors.first()) self.client.force_login(extension.authors.first())
@ -198,6 +199,20 @@ class ExtensionDetailViewTest(_BaseTestCase):
self._check_detail_page(response, extension) self._check_detail_page(response, extension)
def test_can_view_unlisted_extension_if_team_member(self):
extension = _create_extension()
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user).save()
extension.team = team
extension.save()
self.client.force_login(user)
response = self.client.get(extension.get_manage_url())
self._check_detail_page(response, extension)
def test_can_view_publicly_listed_extension_anonymously(self): def test_can_view_publicly_listed_extension_anonymously(self):
extension = _create_extension() extension = _create_extension()
extension.approve() extension.approve()
@ -245,7 +260,7 @@ class ExtensionManageViewTest(_BaseTestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
def test_can_view_manage_extension_page_if_maintaner(self): def test_can_view_manage_extension_page_if_maintainer(self):
extension = _create_extension() extension = _create_extension()
extension.approve() extension.approve()
@ -254,6 +269,20 @@ class ExtensionManageViewTest(_BaseTestCase):
self._check_manage_page(response, extension) self._check_manage_page(response, extension)
def test_can_view_manage_extension_page_if_team_member(self):
extension = _create_extension()
extension.approve()
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user).save()
extension.team = team
extension.save()
self.client.force_login(user)
response = self.client.get(extension.get_manage_url())
self._check_manage_page(response, extension)
class ListedExtensionsTest(_BaseTestCase): class ListedExtensionsTest(_BaseTestCase):
def setUp(self): def setUp(self):
@ -354,3 +383,17 @@ class UpdateVersionViewTest(_BaseTestCase):
self.assertEqual(response2.status_code, 302) self.assertEqual(response2.status_code, 302)
version.refresh_from_db() version.refresh_from_db()
self.assertEqual(version.blender_version_max, '4.2.0') self.assertEqual(version.blender_version_max, '4.2.0')
class MyExtensionsTest(_BaseTestCase):
def test_team_members_see_extensions_in_my_extensions(self):
extension = _create_extension()
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user).save()
extension.team = team
extension.save()
self.client.force_login(user)
response = self.client.get(reverse('extensions:manage-list'))
self.assertContains(response, extension.name)

View File

@ -99,7 +99,16 @@ class ManageListView(LoginRequiredMixin, ListView):
template_name = 'extensions/manage/list.html' template_name = 'extensions/manage/list.html'
def get_queryset(self): def get_queryset(self):
return Extension.objects.authored_by(user_id=self.request.user.pk) return Extension.objects.authored_by(self.request.user).prefetch_related(
'authors',
'preview_set',
'preview_set__file',
'ratings',
'team',
'versions',
'versions__file',
'versions__tags',
)
class UpdateExtensionView( class UpdateExtensionView(

View File

@ -23,7 +23,7 @@ class ExtensionQuerysetMixin:
if self.request.user.is_staff: if self.request.user.is_staff:
return Extension.objects.all() return Extension.objects.all()
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
return Extension.objects.listed_or_authored_by(user_id=self.request.user.pk) return Extension.objects.listed_or_authored_by(self.request.user)
return Extension.objects.listed return Extension.objects.listed
@ -32,7 +32,7 @@ class MaintainedExtensionMixin:
def dispatch(self, *args, **kwargs): def dispatch(self, *args, **kwargs):
self.extension = get_object_or_404( self.extension = get_object_or_404(
Extension.objects.authored_by(user_id=self.request.user.pk), Extension.objects.authored_by(self.request.user),
slug=self.kwargs['slug'], slug=self.kwargs['slug'],
) )
return super().dispatch(*args, **kwargs) return super().dispatch(*args, **kwargs)

View File

@ -50,6 +50,7 @@ class HomeView(ListedExtensionsView):
'preview_set', 'preview_set',
'preview_set__file', 'preview_set__file',
'ratings', 'ratings',
'team',
'versions', 'versions',
'versions__file', 'versions__file',
'versions__tags', 'versions__tags',
@ -107,6 +108,7 @@ class SearchView(ListedExtensionsView):
'preview_set', 'preview_set',
'preview_set__file', 'preview_set__file',
'ratings', 'ratings',
'team',
'versions', 'versions',
'versions__file', 'versions__file',
'versions__tags', 'versions__tags',

View File

@ -18,7 +18,7 @@ class UploadFileView(LoginRequiredMixin, CreateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
drafts = Extension.objects.authored_by(user_id=self.request.user.pk).filter( drafts = Extension.objects.authored_by(self.request.user).filter(
status=Extension.STATUSES.DRAFT status=Extension.STATUSES.DRAFT
) )
context['drafts'] = drafts context['drafts'] = drafts
@ -41,7 +41,7 @@ class UploadFileView(LoginRequiredMixin, CreateView):
if parsed_extension_fields: if parsed_extension_fields:
# Try to look up extension by the same author and file info # Try to look up extension by the same author and file info
extension = ( extension = (
Extension.objects.authored_by(user_id=self.request.user.pk) Extension.objects.authored_by(self.request.user)
.filter(type=self.file.type, **parsed_extension_fields) .filter(type=self.file.type, **parsed_extension_fields)
.first() .first()
) )

View File

@ -1,7 +1,7 @@
import logging import logging
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models, transaction
from django.urls import reverse from django.urls import reverse
from common.model_mixins import CreatedModifiedMixin from common.model_mixins import CreatedModifiedMixin
@ -49,3 +49,39 @@ class TeamsUsers(CreatedModifiedMixin, models.Model):
@property @property
def is_manager(self) -> bool: def is_manager(self) -> bool:
return self.role == TEAM_ROLE_MANAGER return self.role == TEAM_ROLE_MANAGER
@transaction.atomic
def delete(self):
# This runs when a user is leaving a team.
# If the user had authored an extension, other team members shouldn't have access to it,
# unless the extension has another maintainer who is still on that team.
for extension in self.user.extensions.filter(team=self.team).all():
# assuming small datasets, not optimizing db access
authors = extension.authors.all()
has_other_authors_from_the_team = False
for author in authors:
if author.pk == self.user.pk:
continue
if self.team in author.teams.all():
has_other_authors_from_the_team = True
break
if not has_other_authors_from_the_team:
extension.team = None
extension.save(update_fields={'team'})
return super().delete()
@property
def may_leave(self) -> bool:
nr_of_managers = TeamsUsers.objects.filter(role=TEAM_ROLE_MANAGER, team=self.team).count()
user_is_manager = (
TeamsUsers.objects.filter(
role=TEAM_ROLE_MANAGER,
team=self.team,
user=self.user,
).first()
is not None
)
if user_is_manager and nr_of_managers < 2:
return False
return True

View File

@ -0,0 +1,49 @@
{% extends "common/base.html" %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-8 mx-auto my-4">
<div class="box">
<h2>
{% blocktranslate with team_name=object.name %}Leave team {{ team_name }}?{% endblocktranslate %}
</h2>
{% if may_leave %}
<p>
{% blocktranslate %}
If you wish to join the team again in the future, you will need to ask the team manager to add you back.
{% endblocktranslate %}
{% if will_lose_access_to %}
<br>
{% blocktranslate %}
You will lose access to all team extensions that were not uploaded by you:
{% endblocktranslate %}
<ul>
{% for extension in will_lose_access_to %}
<li><a href="{{ extension.get_absolute_url }}">{{ extension }}</a></li>
{% endfor %}
</ul>
{% endif %}
</p>
<div class="btn-row-fluid">
<a href="#" class="btn js-btn-back">
<i class="i-cancel"></i>
<span>{% trans 'Cancel' %}</span>
</a>
<form method="post">
{% csrf_token %}
<button type="submit" class="btn btn-block btn-danger">
<i class="i-log-out"></i>
<span>{% trans 'Confirm Leave' %}</span>
</button>
</form>
</div>
{% else %}
<p>
{% trans 'You cannot leave this team because you are the only manager.' %}
</p>
{% endif %}
</div>
</div>
</div>
{% endblock content %}

View File

@ -4,34 +4,63 @@
<h1 class="mb-3">Teams</h1> <h1 class="mb-3">Teams</h1>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<table class="table table-hover"> {% if team_memberships %}
<thead> <table class="table table-hover">
<tr> <thead>
<th class="w-100"> <tr>
Team name <th class="w-100">
</th> Team name
<th> </th>
Role <th>
</th> Role
</tr> </th>
</thead> <th>
<tbody> Users
{% for team_member in user.team_users.all %} </th>
{% with team=team_member.team %} <th></th>
<tr> </tr>
<td> </thead>
<a class="px-0" href="{{ team.get_absolute_url }}">{{ team.name }}</a> <tbody>
</td> {% for team_member in team_memberships %}
<td> {% with team=team_member.team %}
<div class="badge"> <tr>
{{ team_member.get_role_display }} <td>
</div> <a class="px-0" href="{{ team.get_absolute_url }}">{{ team.name }}</a>
</td> </td>
</tr> <td>
{% endwith %} <div class="badge">
{% endfor %} {{ team_member.get_role_display }}
</tbody> </div>
</table> </td>
<td class="text-center">
<div class="badge">
{{ team.team_users.all.count }}
</div>
</td>
<td>
<div class="dropdown">
<button class="btn btn-link dropdown-toggle js-dropdown-toggle" data-toggle-menu-id="team-{{ team.slug }}">
<i class="i-more-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right js-dropdown-menu" id="team-{{ team.slug }}">
<li>
<a class="dropdown-item {% if not team_member.may_leave %}dropdown-item-disabled{% endif %}" href="{% url 'teams:leave-team' slug=team.slug %}">
<i class="i-log-out"></i>Leave Team
</a>
</li>
</ul>
</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
{% else %}
<p>
You are not assigned to any teams yet.
</p>
{% endif %}
</div> </div>
</div> </div>
{% endblock settings %} {% endblock settings %}

52
teams/tests/test_leave.py Normal file
View File

@ -0,0 +1,52 @@
from django.test import TestCase
from django.urls import reverse
from common.tests.factories.extensions import create_version
from common.tests.factories.teams import TeamFactory
from common.tests.factories.users import UserFactory
from constants.base import TEAM_ROLE_MANAGER, TEAM_ROLE_MEMBER
from teams.models import TeamsUsers
class TeamLeaveTest(TestCase):
def test_the_only_manager_cant_leave(self):
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user, role=TEAM_ROLE_MANAGER).save()
self.assertEqual(user.teams.count(), 1)
self.client.force_login(user)
response = self.client.get(reverse('teams:leave-team', args=[team.slug]))
self.assertContains(response, 'cannot leave')
self.client.post(reverse('teams:leave-team', args=[team.slug]))
user.refresh_from_db()
self.assertEqual(user.teams.count(), 1)
# create another manager
user2 = UserFactory()
TeamsUsers(team=team, user=user2, role=TEAM_ROLE_MANAGER).save()
# try to leave again
response = self.client.get(reverse('teams:leave-team', args=[team.slug]))
self.assertNotContains(response, 'cannot leave')
self.client.post(reverse('teams:leave-team', args=[team.slug]))
user.refresh_from_db()
self.assertEqual(user.teams.count(), 0)
def test_extensions_lose_team_assignment(self):
team = TeamFactory(slug='test-team')
user = UserFactory()
TeamsUsers(team=team, user=user, role=TEAM_ROLE_MEMBER).save()
extension = create_version().extension
extension.team = team
extension.authors.add(user)
extension.save()
self.client.force_login(user)
self.client.post(reverse('teams:leave-team', args=[team.slug]))
user.refresh_from_db()
self.assertEqual(user.teams.count(), 0)
extension.refresh_from_db()
self.assertIsNone(extension.team)

View File

@ -5,4 +5,9 @@ import teams.views
app_name = 'teams' app_name = 'teams'
urlpatterns = [ urlpatterns = [
path('settings/teams/', teams.views.TeamsView.as_view(), name='list'), path('settings/teams/', teams.views.TeamsView.as_view(), name='list'),
path(
'settings/leave-team/<slug:slug>/',
teams.views.LeaveTeamView.as_view(),
name='leave-team',
),
] ]

View File

@ -1,12 +1,43 @@
"""Team pages.""" """Team pages."""
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect
from django.views.generic import ListView from django.views.generic import ListView
from django.views.generic.detail import DetailView
import teams.models from extensions.models import Extension
from teams.models import Team, TeamsUsers
class TeamsView(LoginRequiredMixin, ListView): class TeamsView(LoginRequiredMixin, ListView):
model = teams.models.Team model = Team
def get_queryset(self): def get_context_data(self, **kwargs):
return self.request.user.teams.all() context = super().get_context_data(**kwargs)
context['team_memberships'] = (
self.request.user.team_users.select_related('team').order_by('team__name').all()
)
return context
class LeaveTeamView(LoginRequiredMixin, DetailView):
model = Team
template_name = 'teams/confirm_leave.html'
def post(self, request, *args, **kwargs):
team = self.get_object()
team_user = TeamsUsers.objects.filter(team=team, user=self.request.user).first()
if team_user and team_user.may_leave:
team_user.delete()
return redirect('teams:list')
def get_context_data(self, **kwargs):
team = self.get_object()
team_user = TeamsUsers.objects.filter(team=team, user=self.request.user).first()
context = super().get_context_data(**kwargs)
context['may_leave'] = team_user.may_leave
context['will_lose_access_to'] = list(
Extension.objects.authored_by(self.request.user).exclude(
maintainer__user_id=self.request.user.pk
)
)
return context

View File

@ -2,9 +2,7 @@
<div class="nav nav-pills flex-column" role="tablist" aria-orientation="vertical"> <div class="nav nav-pills flex-column" role="tablist" aria-orientation="vertical">
{% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %} {% include "common/components/nav_link.html" with name="users:my-profile" title="Profile" classes="i-home py-2" %}
{% if user.teams.count %} {% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
{% include "common/components/nav_link.html" with name="teams:list" title="Teams" classes="i-users py-2" %}
{% endif %}
<div class="nav-pills-divider"></div> <div class="nav-pills-divider"></div>