Skip to content

Commit eedcd11

Browse files
Organizations: Organization chooser page (#10325)
* Initial work for an all-purporse URL scheme for organization-specific pages * Split out handling of querystring * Adds organization chooser step (>1 organization), use `-` as "unspecified" slug * Do not handle additional kwargs, it's meaningless. Raise 404 and log a warning. * Handle 'next_name' kwarg in tests, handle NoReverseMatch by raising 404 * Setup initial test cases * Test case to ensure that the chooser list is empty when there's no organization membership * Add further test assertions * Update readthedocs/organizations/templates/organizations/organization_choose.html Co-authored-by: Anthony <[email protected]> * Revert leak from local dev env * Organization queryset is already handled in list view * Click=>Select + mark string for translation * Remove <br>, use blocktrans * Shorten heading * Remove generic URL feature. Note: The removal is kept in a single commit. If we want to re-introduce the feature, this commit can be reverted. * Overwrite get instead of dispatch --------- Co-authored-by: Anthony <[email protected]>
1 parent 2bf1aa8 commit eedcd11

File tree

7 files changed

+196
-11
lines changed

7 files changed

+196
-11
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{% extends "base.html" %}
2+
3+
{% load i18n %}
4+
{% load gravatar %}
5+
{% load organizations %}
6+
7+
{% block title %}{% trans "Choose an organization" %}{% endblock %}
8+
9+
{% block organization-bar-organization %}active{% endblock %}
10+
11+
{% block content %}
12+
<div class="col-major organization-major">
13+
<div class="module organizations organizations-list">
14+
<div class="module-wrapper">
15+
16+
<h2>{% trans "Choose an organization" %}</h2>
17+
18+
<p>
19+
{% blocktrans trimmed %}
20+
You are a member of several organizations.
21+
Select the organization that you want to use:
22+
{% endblocktrans %}
23+
</p>
24+
25+
{% if organization_list %}
26+
<!-- BEGIN organization list -->
27+
<div class="module-list">
28+
<div class="module-list-wrapper">
29+
30+
<ul>
31+
{% for org in organization_list %}
32+
<li>
33+
<a href="{% url next_name slug=org.slug %}{% if next_querystring %}?{{ next_querystring }}{% endif %}">
34+
{% gravatar org.email 48 %}
35+
{{ org.name }}
36+
</a>
37+
</li>
38+
{% endfor %}
39+
</ul>
40+
41+
</div>
42+
</div>
43+
<!-- END organization list -->
44+
{% else %}
45+
<p>{% trans "You aren't currently a member of any organizations." %}</p>
46+
{% endif %}
47+
</div>
48+
</div>
49+
</div>
50+
51+
{% endblock %}

readthedocs/organizations/tests/test_privacy_urls.py

+8-7
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,12 @@ def setUp(self):
1919
self.invite = get(TeamInvite, organization=self.organization, team=self.team)
2020
self.default_kwargs.update(
2121
{
22-
'slug': self.organization.slug,
23-
'team': self.team.slug,
24-
'hash': self.invite.hash,
25-
'owner': self.org_owner.pk,
26-
'member': self.team_member.pk,
22+
"slug": self.organization.slug,
23+
"team": self.team.slug,
24+
"hash": self.invite.hash,
25+
"owner": self.org_owner.pk,
26+
"member": self.team_member.pk,
27+
"next_name": "organization_detail",
2728
}
2829
)
2930

@@ -57,8 +58,8 @@ class AuthUserOrganizationsTest(OrganizationMixin, TestCase):
5758

5859
response_data = {
5960
# Places where we 302 on success.
60-
'/organizations/invite/{hash}/redeem/': {'status_code': 302},
61-
61+
"/organizations/choose/{next_name}/": {"status_code": 302},
62+
"/organizations/invite/{hash}/redeem/": {"status_code": 302},
6263
# 405's where we should be POST'ing
6364
'/organizations/{slug}/owners/{owner}/delete/': {'status_code': 405},
6465
'/organizations/{slug}/teams/{team}/members/{member}/revoke/': {'status_code': 405},

readthedocs/organizations/tests/test_views.py

+97
Original file line numberDiff line numberDiff line change
@@ -376,3 +376,100 @@ def test_organization_signup(self):
376376
resp,
377377
reverse('organization_detail', kwargs={'slug': org.slug}),
378378
)
379+
380+
381+
@override_settings(
382+
RTD_ALLOW_ORGANIZATIONS=True,
383+
RTD_DEFAULT_FEATURES={
384+
TYPE_AUDIT_LOGS: 90,
385+
},
386+
)
387+
class OrganizationUnspecifiedChooser(TestCase):
388+
def setUp(self):
389+
self.owner = get(User, username="owner")
390+
self.member = get(User, username="member")
391+
self.project = get(Project, slug="project")
392+
self.organization = get(
393+
Organization,
394+
owners=[self.owner],
395+
projects=[self.project],
396+
)
397+
self.team = get(
398+
Team,
399+
organization=self.organization,
400+
members=[self.member],
401+
)
402+
self.another_organization = get(
403+
Organization,
404+
owners=[self.owner],
405+
projects=[self.project],
406+
)
407+
self.client.force_login(self.owner)
408+
409+
def test_choose_organization_edit(self):
410+
"""
411+
Verify that the choose page works.
412+
"""
413+
self.assertEqual(Organization.objects.count(), 2)
414+
resp = self.client.get(
415+
reverse("organization_choose", kwargs={"next_name": "organization_edit"})
416+
)
417+
self.assertEqual(resp.status_code, 200)
418+
self.assertContains(
419+
resp, reverse("organization_edit", kwargs={"slug": self.organization.slug})
420+
)
421+
self.assertContains(
422+
resp,
423+
reverse(
424+
"organization_edit", kwargs={"slug": self.another_organization.slug}
425+
),
426+
)
427+
428+
429+
@override_settings(
430+
RTD_ALLOW_ORGANIZATIONS=True,
431+
RTD_DEFAULT_FEATURES={
432+
TYPE_AUDIT_LOGS: 90,
433+
},
434+
)
435+
class OrganizationUnspecifiedSingleOrganizationRedirect(TestCase):
436+
def setUp(self):
437+
self.owner = get(User, username="owner")
438+
self.member = get(User, username="member")
439+
self.project = get(Project, slug="project")
440+
self.organization = get(
441+
Organization,
442+
owners=[self.owner],
443+
projects=[self.project],
444+
)
445+
self.client.force_login(self.owner)
446+
447+
def test_unspecified_slug_redirects_to_organization_edit(self):
448+
self.assertEqual(Organization.objects.count(), 1)
449+
resp = self.client.get(
450+
reverse("organization_choose", kwargs={"next_name": "organization_edit"})
451+
)
452+
self.assertRedirects(
453+
resp, reverse("organization_edit", kwargs={"slug": self.organization.slug})
454+
)
455+
456+
457+
@override_settings(
458+
RTD_ALLOW_ORGANIZATIONS=True,
459+
RTD_DEFAULT_FEATURES={
460+
TYPE_AUDIT_LOGS: 90,
461+
},
462+
)
463+
class OrganizationUnspecifiedNoOrganizationRedirect(TestCase):
464+
def setUp(self):
465+
self.owner = get(User, username="owner")
466+
self.member = get(User, username="member")
467+
self.project = get(Project, slug="project")
468+
self.client.force_login(self.owner)
469+
470+
def test_choose_organization_edit(self):
471+
self.assertEqual(Organization.objects.count(), 0)
472+
resp = self.client.get(
473+
reverse("organization_choose", kwargs={"next_name": "organization_edit"})
474+
)
475+
self.assertContains(resp, "You aren't currently a member of any organizations")

readthedocs/organizations/urls/private.py

+5
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
views.ListOrganization.as_view(),
1010
name='organization_list',
1111
),
12+
re_path(
13+
r"^choose/(?P<next_name>[\w.-]+)/$",
14+
views.ChooseOrganization.as_view(),
15+
name="organization_choose",
16+
),
1217
re_path(
1318
r'^create/$',
1419
views.CreateOrganizationSignup.as_view(),

readthedocs/organizations/views/base.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,13 @@ class OrganizationView(CheckOrganizationsEnabled):
133133
"""Mixin for an organization view that doesn't have nested components."""
134134

135135
model = Organization
136-
lookup_field = 'slug'
137-
lookup_url_field = 'slug'
138136
form_class = OrganizationForm
139137
admin_only = True
140138

139+
# Only relevant when mixed into
140+
lookup_field = 'slug'
141+
lookup_url_field = 'slug'
142+
141143
def get_queryset(self):
142144
if self.admin_only:
143145
return Organization.objects.for_admin_user(user=self.request.user)

readthedocs/organizations/views/private.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from django.conf import settings
55
from django.contrib import messages
66
from django.http import HttpResponseBadRequest
7-
from django.urls import reverse_lazy
7+
from django.shortcuts import redirect
8+
from django.urls import reverse, reverse_lazy
89
from django.utils import timezone
10+
from django.utils.http import urlencode
911
from django.utils.translation import gettext_lazy as _
1012
from vanilla import CreateView, DeleteView, FormView, ListView, UpdateView
1113

@@ -69,6 +71,33 @@ def get_queryset(self):
6971
return Organization.objects.for_user(user=self.request.user)
7072

7173

74+
class ChooseOrganization(ListOrganization):
75+
template_name = "organizations/organization_choose.html"
76+
77+
def get(self, request, *args, **kwargs):
78+
79+
self.next_name = self.kwargs["next_name"]
80+
self.next_querystring = self.request.GET.get("next_querystring")
81+
82+
# Check if user has exactly 1 organization and automatically redirect in this case
83+
organizations = self.get_queryset()
84+
if organizations.count() == 1:
85+
redirect_url = reverse(
86+
self.next_name, kwargs={"slug": organizations[0].slug}
87+
)
88+
if self.next_querystring:
89+
redirect_url += "?" + urlencode(self.next_querystring)
90+
return redirect(redirect_url)
91+
92+
return super().get(request, *args, **kwargs)
93+
94+
def get_context_data(self, **kwargs):
95+
c = super().get_context_data(**kwargs)
96+
c["next_name"] = self.next_name
97+
c["next_querystring"] = self.next_querystring
98+
return c
99+
100+
72101
class EditOrganization(
73102
PrivateViewMixin,
74103
UpdateChangeReasonPostView,

0 commit comments

Comments
 (0)