Intitial teams support #147
@ -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
|
||||||
|
@ -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 %}
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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.
|
||||||
|
@ -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>
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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')
|
||||||
|
@ -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)
|
||||||
|
@ -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(
|
||||||
|
@ -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)
|
||||||
|
@ -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',
|
||||||
|
@ -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()
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
49
teams/templates/teams/confirm_leave.html
Normal file
49
teams/templates/teams/confirm_leave.html
Normal 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 %}
|
@ -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
52
teams/tests/test_leave.py
Normal 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)
|
@ -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',
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user