diff --git a/rdmo/core/settings.py b/rdmo/core/settings.py index b19fc15358..c6610be4de 100644 --- a/rdmo/core/settings.py +++ b/rdmo/core/settings.py @@ -200,6 +200,7 @@ 'MULTISITE', 'GROUPS', 'EXPORT_FORMATS', + 'PROJECT_VISIBILITY', 'PROJECT_ISSUES', 'PROJECT_VIEWS', 'PROJECT_EXPORTS', @@ -296,6 +297,8 @@ PROJECT_TABLE_PAGE_SIZE = 20 +PROJECT_VISIBILITY = True + PROJECT_ISSUES = True PROJECT_ISSUE_PROVIDERS = [] diff --git a/rdmo/core/templates/core/bootstrap_form.html b/rdmo/core/templates/core/bootstrap_form.html index 6444d2ebf7..4cbced2125 100644 --- a/rdmo/core/templates/core/bootstrap_form.html +++ b/rdmo/core/templates/core/bootstrap_form.html @@ -6,6 +6,11 @@ {% include 'core/bootstrap_form_fields.html' %} + {% if submit %} + {% endif %} + {% if delete %} + + {% endif %} diff --git a/rdmo/core/templates/core/bootstrap_form_field.html b/rdmo/core/templates/core/bootstrap_form_field.html index f34e8b9454..2104e387d4 100644 --- a/rdmo/core/templates/core/bootstrap_form_field.html +++ b/rdmo/core/templates/core/bootstrap_form_field.html @@ -1,3 +1,4 @@ +{% load i18n %} {% load widget_tweaks %} {% load core_tags %} @@ -61,6 +62,12 @@ {% render_field field class="form-control" %} + {% if type == 'selectmultiple' %} +

+ {% trans 'Hold down "Control", or "Command" on a Mac, to select more than one.' %} +

+ {% endif %} + {% endif %} {% endwith %} diff --git a/rdmo/core/templatetags/core_tags.py b/rdmo/core/templatetags/core_tags.py index 52a094b9ee..548ded6d81 100644 --- a/rdmo/core/templatetags/core_tags.py +++ b/rdmo/core/templatetags/core_tags.py @@ -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) diff --git a/rdmo/projects/admin.py b/rdmo/projects/admin.py index bf8956f28d..07c72480ed 100644 --- a/rdmo/projects/admin.py +++ b/rdmo/projects/admin.py @@ -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, @@ -15,6 +16,7 @@ Project, Snapshot, Value, + Visibility, ) from .validators import ProjectParentValidator @@ -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') diff --git a/rdmo/projects/filters.py b/rdmo/projects/filters.py index 211ac992ca..1962d71d67 100644 --- a/rdmo/projects/filters.py +++ b/rdmo/projects/filters.py @@ -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 @@ -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): diff --git a/rdmo/projects/forms.py b/rdmo/projects/forms.py index 11adc0f1b9..3d2d1a220a 100644 --- a/rdmo/projects/forms.py +++ b/rdmo/projects/forms.py @@ -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 @@ -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 diff --git a/rdmo/projects/managers.py b/rdmo/projects/managers.py index 72e7c21a0b..a6d49e4ff6 100644 --- a/rdmo/projects/managers.py +++ b/rdmo/projects/managers.py @@ -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): @@ -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): diff --git a/rdmo/projects/migrations/0062_visibility.py b/rdmo/projects/migrations/0062_visibility.py new file mode 100644 index 0000000000..e418f334f2 --- /dev/null +++ b/rdmo/projects/migrations/0062_visibility.py @@ -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',), + }, + ), + ] diff --git a/rdmo/projects/models/__init__.py b/rdmo/projects/models/__init__.py index 35dcab847f..16af648546 100644 --- a/rdmo/projects/models/__init__.py +++ b/rdmo/projects/models/__init__.py @@ -6,3 +6,4 @@ from .project import Project from .snapshot import Snapshot from .value import Value +from .visibility import Visibility diff --git a/rdmo/projects/models/visibility.py b/rdmo/projects/models/visibility.py new file mode 100644 index 0000000000..61e4e3d2f4 --- /dev/null +++ b/rdmo/projects/models/visibility.py @@ -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.') diff --git a/rdmo/projects/permissions.py b/rdmo/projects/permissions.py index 9fa7be5ffb..da693c8d0d 100644 --- a/rdmo/projects/permissions.py +++ b/rdmo/projects/permissions.py @@ -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', ) diff --git a/rdmo/projects/rules.py b/rdmo/projects/rules.py index c0b7901bab..70e5f44326 100644 --- a/rdmo/projects/rules.py +++ b/rdmo/projects/rules.py @@ -1,5 +1,6 @@ from django.conf import settings from django.contrib.sites.shortcuts import get_current_site +from django.core.exceptions import ObjectDoesNotExist import rules from rules.predicates import is_superuser @@ -46,6 +47,17 @@ def is_project_guest(user, project): return user in project.guests or (project.parent and is_project_guest(user, project.parent)) +@rules.predicate +def is_visible(user, project): + if user.is_authenticated: + try: + return project.visibility.is_visible(user) + except ObjectDoesNotExist: + return False + else: + return False + + @rules.predicate def is_site_manager(user, project): if user.is_authenticated: @@ -67,7 +79,7 @@ def is_site_manager_for_current_site(user, request): rules.add_rule('projects.can_view_all_projects', is_site_manager_for_current_site | is_superuser) rules.add_perm('projects.add_project', can_add_project) -rules.add_perm('projects.view_project_object', is_project_member | is_site_manager) +rules.add_perm('projects.view_project_object', is_project_member | is_visible | is_site_manager) rules.add_perm('projects.change_project_object', is_project_manager | is_project_owner | is_site_manager) rules.add_perm('projects.change_project_progress_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) # noqa: E501 rules.add_perm('projects.delete_project_object', is_project_owner | is_site_manager) @@ -75,7 +87,12 @@ def is_site_manager_for_current_site(user, request): rules.add_perm('projects.export_project_object', is_project_owner | is_project_manager | is_site_manager) rules.add_perm('projects.import_project_object', is_project_owner | is_project_manager | is_site_manager) -rules.add_perm('projects.view_membership_object', is_project_member | is_site_manager) +rules.add_perm('projects.view_visibility_object', is_site_manager) +rules.add_perm('projects.add_visibility_object', is_site_manager) +rules.add_perm('projects.change_visibility_object', is_site_manager) +rules.add_perm('projects.delete_visibility_object', is_site_manager) + +rules.add_perm('projects.view_membership_object', is_project_member | is_visible | is_site_manager) rules.add_perm('projects.add_membership_object', is_project_owner | is_site_manager) rules.add_perm('projects.change_membership_object', is_project_owner | is_site_manager) rules.add_perm('projects.delete_membership_object', is_project_owner | is_site_manager) @@ -85,28 +102,28 @@ def is_site_manager_for_current_site(user, request): rules.add_perm('projects.change_invite_object', is_project_owner | is_site_manager) rules.add_perm('projects.delete_invite_object', is_project_owner | is_site_manager) -rules.add_perm('projects.view_integration_object', is_project_member | is_site_manager) +rules.add_perm('projects.view_integration_object', is_project_member | is_visible | is_site_manager) rules.add_perm('projects.add_integration_object', is_project_owner | is_project_manager | is_site_manager) rules.add_perm('projects.change_integration_object', is_project_owner | is_project_manager | is_site_manager) rules.add_perm('projects.delete_integration_object', is_project_owner | is_project_manager | is_site_manager) -rules.add_perm('projects.view_issue_object', is_project_member | is_site_manager) +rules.add_perm('projects.view_issue_object', is_project_member | is_visible | is_site_manager) rules.add_perm('projects.add_issue_object', is_project_manager | is_project_owner | is_site_manager) rules.add_perm('projects.change_issue_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) # noqa: E501 rules.add_perm('projects.delete_issue_object', is_project_manager | is_project_owner | is_site_manager) -rules.add_perm('projects.view_snapshot_object', is_project_member | is_site_manager) +rules.add_perm('projects.view_snapshot_object', is_project_member | is_visible | is_site_manager) rules.add_perm('projects.add_snapshot_object', is_project_manager | is_project_owner | is_site_manager) rules.add_perm('projects.change_snapshot_object', is_project_manager | is_project_owner | is_site_manager) rules.add_perm('projects.rollback_snapshot_object', is_project_manager | is_project_owner | is_site_manager) rules.add_perm('projects.export_snapshot_object', is_project_owner | is_project_manager | is_site_manager) -rules.add_perm('projects.view_value_object', is_project_member | is_site_manager) +rules.add_perm('projects.view_value_object', is_project_member | is_visible | is_site_manager) rules.add_perm('projects.add_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) rules.add_perm('projects.change_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) # noqa: E501 rules.add_perm('projects.delete_value_object', is_project_author | is_project_manager | is_project_owner | is_site_manager) # noqa: E501 -rules.add_perm('projects.view_page_object', is_project_member | is_site_manager) +rules.add_perm('projects.view_page_object', is_project_member | is_visible | is_site_manager) # TODO: use one of the permissions above rules.add_perm('projects.is_project_owner', is_project_owner) diff --git a/rdmo/projects/serializers/v1/__init__.py b/rdmo/projects/serializers/v1/__init__.py index fe72127295..8e61748193 100644 --- a/rdmo/projects/serializers/v1/__init__.py +++ b/rdmo/projects/serializers/v1/__init__.py @@ -8,7 +8,18 @@ from rdmo.questions.models import Catalog from rdmo.services.validators import ProviderValidator -from ...models import Integration, IntegrationOption, Invite, Issue, IssueResource, Membership, Project, Snapshot, Value +from ...models import ( + Integration, + IntegrationOption, + Invite, + Issue, + IssueResource, + Membership, + Project, + Snapshot, + Value, + Visibility, +) from ...validators import ProjectParentValidator, ValueConflictValidator, ValueQuotaValidator, ValueTypeValidator @@ -91,6 +102,17 @@ class Meta: read_only_fields = ProjectSerializer.Meta.read_only_fields +class ProjectVisibilitySerializer(serializers.ModelSerializer): + + class Meta: + model = Visibility + fields = ( + 'project', + 'sites', + 'groups' + ) + + class ProjectMembershipSerializer(serializers.ModelSerializer): class Meta: diff --git a/rdmo/projects/templates/projects/project_detail_header.html b/rdmo/projects/templates/projects/project_detail_header.html index 6302201657..c6ca098c90 100644 --- a/rdmo/projects/templates/projects/project_detail_header.html +++ b/rdmo/projects/templates/projects/project_detail_header.html @@ -4,6 +4,7 @@ {% load accounts_tags %} {% has_perm 'projects.change_project_object' request.user project as can_change_project %} +{% has_perm 'projects.change_visibility_object' request.user project as can_change_visibility %}

{{ project.title }}

@@ -26,6 +27,16 @@

{{ project.title }}

{% include 'projects/project_detail_header_catalog.html' %} + {% if settings.PROJECT_VISIBILITY and project.visibility %} + + + {% trans 'Visibility' %} + + + {% include 'projects/project_detail_header_visibility.html' %} + + + {% endif %} {% if project.is_child_node or project.get_descendant_count %} {% drilldown_tree_for_node project as project_tree all_descendants %} diff --git a/rdmo/projects/templates/projects/project_detail_header_visibility.html b/rdmo/projects/templates/projects/project_detail_header_visibility.html new file mode 100644 index 0000000000..4fa4952801 --- /dev/null +++ b/rdmo/projects/templates/projects/project_detail_header_visibility.html @@ -0,0 +1,14 @@ +{% load i18n %} +{% load core_tags %} + +{% if can_change_visibility %} +

+ + + +

+{% endif %} + + +{{ project.visibility.get_help_display }} + diff --git a/rdmo/projects/templates/projects/project_detail_sidebar.html b/rdmo/projects/templates/projects/project_detail_sidebar.html index 3943bcccd2..5c90ed7d5c 100644 --- a/rdmo/projects/templates/projects/project_detail_sidebar.html +++ b/rdmo/projects/templates/projects/project_detail_sidebar.html @@ -77,6 +77,21 @@

{% trans 'Options' %}

{% endif %} +{% has_perm 'projects.change_visibility_object' request.user project as can_change_visibility %} +{% if settings.PROJECT_VISIBILITY and can_change_visibility %} + +{% endif %} + {% has_perm 'projects.add_membership_object' request.user project as can_add_membership %} {% if can_add_membership %}