Skip to content

Commit 5d3bc73

Browse files
authored
Merge pull request #1121 from rdmorganiser/interview-visibility
feat(interview): Project visibility [1]
2 parents 612c989 + 12798ce commit 5d3bc73

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1678
-451
lines changed

Diff for: rdmo/core/settings.py

+3
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@
200200
'MULTISITE',
201201
'GROUPS',
202202
'EXPORT_FORMATS',
203+
'PROJECT_VISIBILITY',
203204
'PROJECT_ISSUES',
204205
'PROJECT_VIEWS',
205206
'PROJECT_EXPORTS',
@@ -296,6 +297,8 @@
296297

297298
PROJECT_TABLE_PAGE_SIZE = 20
298299

300+
PROJECT_VISIBILITY = True
301+
299302
PROJECT_ISSUES = True
300303

301304
PROJECT_ISSUE_PROVIDERS = []

Diff for: rdmo/core/templates/core/bootstrap_form.html

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66

77
{% include 'core/bootstrap_form_fields.html' %}
88

9+
{% if submit %}
910
<input type="submit" value="{{ submit }}" class="btn btn-primary" />
11+
{% endif %}
12+
{% if delete %}
13+
<input type="submit" name="delete" value="{{ delete }}" class="btn btn-danger" />
14+
{% endif %}
1015
<input type="submit" name="cancel" value="{% trans 'Cancel' %}" class="btn" />
1116
</form>

Diff for: rdmo/core/templates/core/bootstrap_form_field.html

+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
{% load i18n %}
12
{% load widget_tweaks %}
23
{% load core_tags %}
34

@@ -61,6 +62,12 @@
6162

6263
{% render_field field class="form-control" %}
6364

65+
{% if type == 'selectmultiple' %}
66+
<p class="help-block">
67+
{% trans 'Hold down "Control", or "Command" on a Mac, to select more than one.' %}
68+
</p>
69+
{% endif %}
70+
6471
{% endif %}
6572
{% endwith %}
6673

Diff for: rdmo/core/templatetags/core_tags.py

+3
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ def bootstrap_form(context, **kwargs):
114114
if 'submit' in kwargs:
115115
form_context['submit'] = kwargs['submit']
116116

117+
if 'delete' in kwargs:
118+
form_context['delete'] = kwargs['delete']
119+
117120
return render_to_string('core/bootstrap_form.html', form_context, request=context.request)
118121

119122

Diff for: rdmo/projects/admin.py

+21
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.db.models import Prefetch
44
from django.urls import reverse
55
from django.utils.safestring import mark_safe
6+
from django.utils.translation import gettext_lazy as _
67

78
from .models import (
89
Continuation,
@@ -15,6 +16,7 @@
1516
Project,
1617
Snapshot,
1718
Value,
19+
Visibility,
1820
)
1921
from .validators import ProjectParentValidator
2022

@@ -71,6 +73,25 @@ class ContinuationAdmin(admin.ModelAdmin):
7173
list_display = ('project', 'user', 'page')
7274

7375

76+
@admin.register(Visibility)
77+
class VisibilityAdmin(admin.ModelAdmin):
78+
search_fields = ('project__title', 'sites', 'groups')
79+
list_display = ('project', 'sites_list_display', 'groups_list_display')
80+
filter_horizontal = ('sites', 'groups')
81+
82+
@admin.display(description=_('Sites'))
83+
def sites_list_display(self, obj):
84+
return _('all Sites') if obj.sites.count() == 0 else ', '.join([
85+
site.domain for site in obj.sites.all()
86+
])
87+
88+
@admin.display(description=_('Groups'))
89+
def groups_list_display(self, obj):
90+
return _('all Groups') if obj.groups.count() == 0 else ', '.join([
91+
group.name for group in obj.groups.all()
92+
])
93+
94+
7495
@admin.register(Integration)
7596
class IntegrationAdmin(admin.ModelAdmin):
7697
search_fields = ('project__title', 'provider_key')

Diff for: rdmo/projects/filters.py

+17
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.contrib.auth.models import User
12
from django.db.models import F, OuterRef, Q, Subquery
23
from django.db.models.functions import Concat
34
from django.utils.dateparse import parse_datetime
@@ -18,6 +19,22 @@ class Meta:
1819
fields = ('title', 'catalog')
1920

2021

22+
class ProjectUserFilterBackend(BaseFilterBackend):
23+
24+
def filter_queryset(self, request, queryset, view):
25+
if view.detail:
26+
return queryset
27+
28+
user_id = request.GET.get('user')
29+
user_username = request.GET.get('username')
30+
if user_id or user_username:
31+
user = User.objects.filter(Q(id=user_id) | Q(username=user_username)).first()
32+
if user:
33+
queryset = queryset.filter_visibility(user)
34+
35+
return queryset
36+
37+
2138
class ProjectSearchFilterBackend(SearchFilter):
2239

2340
def filter_queryset(self, request, queryset, view):

Diff for: rdmo/projects/forms.py

+43-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from rdmo.core.utils import markdown2html
1313

1414
from .constants import ROLE_CHOICES
15-
from .models import Integration, IntegrationOption, Invite, Membership, Project, Snapshot
15+
from .models import Integration, IntegrationOption, Invite, Membership, Project, Snapshot, Visibility
1616
from .validators import ProjectParentValidator
1717

1818

@@ -98,6 +98,48 @@ class Meta:
9898
fields = ('title', 'description')
9999

100100

101+
class ProjectUpdateVisibilityForm(forms.ModelForm):
102+
103+
use_required_attribute = False
104+
105+
def __init__(self, *args, **kwargs):
106+
self.project = kwargs.pop('instance')
107+
try:
108+
instance = self.project.visibility
109+
except Visibility.DoesNotExist:
110+
instance = None
111+
112+
super().__init__(*args, instance=instance, **kwargs)
113+
114+
# remove the sites or group sets if they are not needed, doing this in Meta would break tests
115+
if not settings.MULTISITE:
116+
self.fields.pop('sites')
117+
if not settings.GROUPS:
118+
self.fields.pop('groups')
119+
120+
class Meta:
121+
model = Visibility
122+
fields = ('sites', 'groups')
123+
124+
def save(self, *args, **kwargs):
125+
if 'cancel' in self.data:
126+
pass
127+
elif 'delete' in self.data:
128+
self.instance.delete()
129+
else:
130+
visibility, created = Visibility.objects.update_or_create(project=self.project)
131+
132+
sites = self.cleaned_data.get('sites')
133+
if sites is not None:
134+
visibility.sites.set(sites)
135+
136+
groups = self.cleaned_data.get('groups')
137+
if groups is not None:
138+
visibility.groups.set(groups)
139+
140+
return self.project
141+
142+
101143
class ProjectUpdateCatalogForm(forms.ModelForm):
102144

103145
use_required_attribute = False

Diff for: rdmo/projects/managers.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,20 @@ def filter_user(self, user):
2121
elif is_site_manager(user):
2222
return self.filter_current_site()
2323
else:
24-
queryset = self.filter(user=user)
24+
queryset = self.filter_visibility(user)
2525
for instance in queryset:
2626
queryset |= instance.get_descendants()
2727
return queryset.distinct()
2828
else:
2929
return self.none()
3030

31+
def filter_visibility(self, user):
32+
groups = user.groups.all()
33+
sites_filter = Q(visibility__sites=None) | Q(visibility__sites=settings.SITE_ID)
34+
groups_filter = Q(visibility__groups=None) | Q(visibility__groups__in=groups)
35+
visibility_filter = Q(visibility__isnull=False) & sites_filter & groups_filter
36+
return self.filter(Q(user=user) | visibility_filter)
37+
3138

3239
class MembershipQuerySet(models.QuerySet):
3340

@@ -157,6 +164,9 @@ def get_queryset(self):
157164
def filter_user(self, user):
158165
return self.get_queryset().filter_user(user)
159166

167+
def filter_visibility(self, user):
168+
return self.get_queryset().filter_visibility(user)
169+
160170

161171
class MembershipManager(CurrentSiteManagerMixin, models.Manager):
162172

Diff for: rdmo/projects/migrations/0062_visibility.py

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Generated by Django 4.2.16 on 2024-12-06 10:11
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('auth', '0012_alter_user_first_name_max_length'),
11+
('sites', '0002_alter_domain_unique'),
12+
('projects', '0061_alter_value_value_type'),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name='Visibility',
18+
fields=[
19+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
20+
('created', models.DateTimeField(editable=False, verbose_name='created')),
21+
('updated', models.DateTimeField(editable=False, verbose_name='updated')),
22+
('groups', models.ManyToManyField(blank=True, help_text='The groups for which the project is visible.', to='auth.group', verbose_name='Group')),
23+
('project', models.OneToOneField(help_text='The project for this visibility.', on_delete=django.db.models.deletion.CASCADE, to='projects.project', verbose_name='Project')),
24+
('sites', models.ManyToManyField(blank=True, help_text='The sites for which the project is visible (in a multi site setup).', to='sites.site', verbose_name='Sites')),
25+
],
26+
options={
27+
'verbose_name': 'Visibility',
28+
'verbose_name_plural': 'Visibilities',
29+
'ordering': ('project',),
30+
},
31+
),
32+
]

Diff for: rdmo/projects/models/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
from .project import Project
77
from .snapshot import Snapshot
88
from .value import Value
9+
from .visibility import Visibility

Diff for: rdmo/projects/models/visibility.py

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from django.conf import settings
2+
from django.contrib.auth.models import Group
3+
from django.contrib.sites.models import Site
4+
from django.db import models
5+
from django.utils.translation import gettext_lazy as _
6+
from django.utils.translation import ngettext_lazy
7+
8+
from rdmo.core.models import Model
9+
10+
11+
class Visibility(Model):
12+
13+
project = models.OneToOneField(
14+
'Project', on_delete=models.CASCADE,
15+
verbose_name=_('Project'),
16+
help_text=_('The project for this visibility.')
17+
)
18+
sites = models.ManyToManyField(
19+
Site, blank=True,
20+
verbose_name=_('Sites'),
21+
help_text=_('The sites for which the project is visible (in a multi site setup).')
22+
)
23+
groups = models.ManyToManyField(
24+
Group, blank=True,
25+
verbose_name=_('Group'),
26+
help_text=_('The groups for which the project is visible.')
27+
)
28+
29+
class Meta:
30+
ordering = ('project', )
31+
verbose_name = _('Visibility')
32+
verbose_name_plural = _('Visibilities')
33+
34+
def __str__(self):
35+
return str(self.project)
36+
37+
def is_visible(self, user):
38+
return (
39+
not self.sites.exists() or self.sites.filter(id=settings.SITE_ID).exists()
40+
) and (
41+
not self.groups.exists() or self.groups.filter(id__in=[group.id for group in user.groups.all()]).exists()
42+
)
43+
44+
def get_help_display(self):
45+
sites = self.sites.values_list('domain', flat=True)
46+
groups = self.groups.values_list('name', flat=True)
47+
48+
if sites and groups:
49+
return ngettext_lazy(
50+
'This project can be accessed by all users on %s or in the group %s.',
51+
'This project can be accessed by all users on %s or in the groups %s.',
52+
len(groups)
53+
) % (
54+
', '.join(sites),
55+
', '.join(groups)
56+
)
57+
elif sites:
58+
return _('This project can be accessed by all users on %s.') % ', '.join(sites)
59+
elif groups:
60+
return ngettext_lazy(
61+
'This project can be accessed by all users in the group %s.',
62+
'This project can be accessed by all users in the groups %s.',
63+
len(groups)
64+
) % ', '.join(groups)
65+
else:
66+
return _('This project can be accessed by all users.')

Diff for: rdmo/projects/permissions.py

+22
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,25 @@ def get_required_object_permissions(self, method, model_cls):
9090
return ('projects.change_project_progress_object', )
9191
else:
9292
return ('projects.view_project_object', )
93+
94+
95+
class HasProjectVisibilityModelPermission(HasModelPermission):
96+
97+
def get_required_permissions(self, method, model_cls):
98+
if method == 'POST':
99+
return ('projects.change_visibility', )
100+
elif method == 'DELETE':
101+
return ('projects.delete_visibility', )
102+
else:
103+
return ('projects.view_visibility', )
104+
105+
106+
class HasProjectVisibilityObjectPermission(HasProjectPermission):
107+
108+
def get_required_object_permissions(self, method, model_cls):
109+
if method == 'POST':
110+
return ('projects.change_visibility_object', )
111+
elif method == 'DELETE':
112+
return ('projects.delete_visibility_object', )
113+
else:
114+
return ('projects.view_visibility_object', )

0 commit comments

Comments
 (0)