Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(interview): Project visibility [1] #1121

Merged
merged 10 commits into from
Dec 12, 2024
3 changes: 3 additions & 0 deletions rdmo/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
'MULTISITE',
'GROUPS',
'EXPORT_FORMATS',
'PROJECT_VISIBILITY',
'PROJECT_ISSUES',
'PROJECT_VIEWS',
'PROJECT_EXPORTS',
Expand Down Expand Up @@ -296,6 +297,8 @@

PROJECT_TABLE_PAGE_SIZE = 20

PROJECT_VISIBILITY = True

PROJECT_ISSUES = True

PROJECT_ISSUE_PROVIDERS = []
Expand Down
5 changes: 5 additions & 0 deletions rdmo/core/templates/core/bootstrap_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

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

{% if submit %}
<input type="submit" value="{{ submit }}" class="btn btn-primary" />
{% endif %}
{% if delete %}
<input type="submit" name="delete" value="{{ delete }}" class="btn btn-danger" />
{% endif %}
<input type="submit" name="cancel" value="{% trans 'Cancel' %}" class="btn" />
</form>
7 changes: 7 additions & 0 deletions rdmo/core/templates/core/bootstrap_form_field.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{% load i18n %}
{% load widget_tweaks %}
{% load core_tags %}

Expand Down Expand Up @@ -61,6 +62,12 @@

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

{% if type == 'selectmultiple' %}
<p class="help-block">
{% trans 'Hold down "Control", or "Command" on a Mac, to select more than one.' %}
</p>
{% endif %}

{% endif %}
{% endwith %}

Expand Down
3 changes: 3 additions & 0 deletions rdmo/core/templatetags/core_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ def bootstrap_form(context, **kwargs):
if 'submit' in kwargs:
form_context['submit'] = kwargs['submit']

if 'delete' in kwargs:
form_context['delete'] = kwargs['delete']

return render_to_string('core/bootstrap_form.html', form_context, request=context.request)


Expand Down
21 changes: 21 additions & 0 deletions rdmo/projects/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.db.models import Prefetch
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _

from .models import (
Continuation,
Expand All @@ -15,6 +16,7 @@
Project,
Snapshot,
Value,
Visibility,
)
from .validators import ProjectParentValidator

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


@admin.register(Visibility)
class VisibilityAdmin(admin.ModelAdmin):
search_fields = ('project__title', 'sites', 'groups')
list_display = ('project', 'sites_list_display', 'groups_list_display')
filter_horizontal = ('sites', 'groups')

@admin.display(description=_('Sites'))
def sites_list_display(self, obj):
return _('all Sites') if obj.sites.count() == 0 else ', '.join([
site.domain for site in obj.sites.all()
])

@admin.display(description=_('Groups'))
def groups_list_display(self, obj):
return _('all Groups') if obj.groups.count() == 0 else ', '.join([
group.name for group in obj.groups.all()
])


@admin.register(Integration)
class IntegrationAdmin(admin.ModelAdmin):
search_fields = ('project__title', 'provider_key')
Expand Down
17 changes: 17 additions & 0 deletions rdmo/projects/filters.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from django.contrib.auth.models import User
from django.db.models import F, OuterRef, Q, Subquery
from django.db.models.functions import Concat
from django.utils.dateparse import parse_datetime
Expand All @@ -18,6 +19,22 @@ class Meta:
fields = ('title', 'catalog')


class ProjectUserFilterBackend(BaseFilterBackend):

def filter_queryset(self, request, queryset, view):
if view.detail:
return queryset

user_id = request.GET.get('user')
user_username = request.GET.get('username')
if user_id or user_username:
user = User.objects.filter(Q(id=user_id) | Q(username=user_username)).first()
if user:
queryset = queryset.filter_visibility(user)

return queryset


class ProjectSearchFilterBackend(SearchFilter):

def filter_queryset(self, request, queryset, view):
Expand Down
44 changes: 43 additions & 1 deletion rdmo/projects/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from rdmo.core.utils import markdown2html

from .constants import ROLE_CHOICES
from .models import Integration, IntegrationOption, Invite, Membership, Project, Snapshot
from .models import Integration, IntegrationOption, Invite, Membership, Project, Snapshot, Visibility
from .validators import ProjectParentValidator


Expand Down Expand Up @@ -98,6 +98,48 @@ class Meta:
fields = ('title', 'description')


class ProjectUpdateVisibilityForm(forms.ModelForm):

use_required_attribute = False

def __init__(self, *args, **kwargs):
self.project = kwargs.pop('instance')
try:
instance = self.project.visibility
except Visibility.DoesNotExist:
instance = None

super().__init__(*args, instance=instance, **kwargs)

# remove the sites or group sets if they are not needed, doing this in Meta would break tests
if not settings.MULTISITE:
self.fields.pop('sites')
if not settings.GROUPS:
self.fields.pop('groups')

class Meta:
model = Visibility
fields = ('sites', 'groups')

def save(self, *args, **kwargs):
if 'cancel' in self.data:
pass
elif 'delete' in self.data:
self.instance.delete()
else:
visibility, created = Visibility.objects.update_or_create(project=self.project)

sites = self.cleaned_data.get('sites')
if sites is not None:
visibility.sites.set(sites)

groups = self.cleaned_data.get('groups')
if groups is not None:
visibility.groups.set(groups)

return self.project


class ProjectUpdateCatalogForm(forms.ModelForm):

use_required_attribute = False
Expand Down
12 changes: 11 additions & 1 deletion rdmo/projects/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,20 @@ def filter_user(self, user):
elif is_site_manager(user):
return self.filter_current_site()
else:
queryset = self.filter(user=user)
queryset = self.filter_visibility(user)
for instance in queryset:
queryset |= instance.get_descendants()
return queryset.distinct()
else:
return self.none()

def filter_visibility(self, user):
groups = user.groups.all()
sites_filter = Q(visibility__sites=None) | Q(visibility__sites=settings.SITE_ID)
groups_filter = Q(visibility__groups=None) | Q(visibility__groups__in=groups)
visibility_filter = Q(visibility__isnull=False) & sites_filter & groups_filter
return self.filter(Q(user=user) | visibility_filter)


class MembershipQuerySet(models.QuerySet):

Expand Down Expand Up @@ -157,6 +164,9 @@ def get_queryset(self):
def filter_user(self, user):
return self.get_queryset().filter_user(user)

def filter_visibility(self, user):
return self.get_queryset().filter_visibility(user)


class MembershipManager(CurrentSiteManagerMixin, models.Manager):

Expand Down
32 changes: 32 additions & 0 deletions rdmo/projects/migrations/0062_visibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 4.2.16 on 2024-12-06 10:11

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('sites', '0002_alter_domain_unique'),
('projects', '0061_alter_value_value_type'),
]

operations = [
migrations.CreateModel(
name='Visibility',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(editable=False, verbose_name='created')),
('updated', models.DateTimeField(editable=False, verbose_name='updated')),
('groups', models.ManyToManyField(blank=True, help_text='The groups for which the project is visible.', to='auth.group', verbose_name='Group')),
('project', models.OneToOneField(help_text='The project for this visibility.', on_delete=django.db.models.deletion.CASCADE, to='projects.project', verbose_name='Project')),
('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')),
],
options={
'verbose_name': 'Visibility',
'verbose_name_plural': 'Visibilities',
'ordering': ('project',),
},
),
]
1 change: 1 addition & 0 deletions rdmo/projects/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
from .project import Project
from .snapshot import Snapshot
from .value import Value
from .visibility import Visibility
66 changes: 66 additions & 0 deletions rdmo/projects/models/visibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.sites.models import Site
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.translation import ngettext_lazy

from rdmo.core.models import Model


class Visibility(Model):

project = models.OneToOneField(
'Project', on_delete=models.CASCADE,
verbose_name=_('Project'),
help_text=_('The project for this visibility.')
)
sites = models.ManyToManyField(
Site, blank=True,
verbose_name=_('Sites'),
help_text=_('The sites for which the project is visible (in a multi site setup).')
)
groups = models.ManyToManyField(
Group, blank=True,
verbose_name=_('Group'),
help_text=_('The groups for which the project is visible.')
)

class Meta:
ordering = ('project', )
verbose_name = _('Visibility')
verbose_name_plural = _('Visibilities')

def __str__(self):
return str(self.project)

def is_visible(self, user):
return (
not self.sites.exists() or self.sites.filter(id=settings.SITE_ID).exists()
) and (
not self.groups.exists() or self.groups.filter(id__in=[group.id for group in user.groups.all()]).exists()
)

def get_help_display(self):
sites = self.sites.values_list('domain', flat=True)
groups = self.groups.values_list('name', flat=True)

if sites and groups:
return ngettext_lazy(
'This project can be accessed by all users on %s or in the group %s.',
'This project can be accessed by all users on %s or in the groups %s.',
len(groups)
) % (
', '.join(sites),
', '.join(groups)
)
elif sites:
return _('This project can be accessed by all users on %s.') % ', '.join(sites)
elif groups:
return ngettext_lazy(
'This project can be accessed by all users in the group %s.',
'This project can be accessed by all users in the groups %s.',
len(groups)
) % ', '.join(groups)
else:
return _('This project can be accessed by all users.')
22 changes: 22 additions & 0 deletions rdmo/projects/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,25 @@ def get_required_object_permissions(self, method, model_cls):
return ('projects.change_project_progress_object', )
else:
return ('projects.view_project_object', )


class HasProjectVisibilityModelPermission(HasModelPermission):

def get_required_permissions(self, method, model_cls):
if method == 'POST':
return ('projects.change_visibility', )
elif method == 'DELETE':
return ('projects.delete_visibility', )
else:
return ('projects.view_visibility', )


class HasProjectVisibilityObjectPermission(HasProjectPermission):

def get_required_object_permissions(self, method, model_cls):
if method == 'POST':
return ('projects.change_visibility_object', )
elif method == 'DELETE':
return ('projects.delete_visibility_object', )
else:
return ('projects.view_visibility_object', )
Loading
Loading