diff --git a/src/api/serializers.py b/src/api/serializers.py index 2ac02e23e81d31d04572c295787c421cc36da605..087a3fc22b3d6710493193198cc3394d31874d75 100644 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -157,7 +157,7 @@ class AssemblySerializer(HubModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - cluster_qs = Assembly.objects.accessible_by_user(conference=self.conference, user=self.request_user).exclude(hierarchy=Assembly.Hierarchy.REGULAR) + cluster_qs = Assembly.objects.associated_with_user(conference=self.conference, user=self.request_user).exclude(hierarchy=Assembly.Hierarchy.REGULAR) self.fields['parent'].queryset = cluster_qs diff --git a/src/api/views/mixins.py b/src/api/views/mixins.py index 9c36dd5e222b261983aa23cfd98d2ebc9e67a14f..8cc93edfcafef1aae7d8a3dc5578cfd40ae7f8a9 100644 --- a/src/api/views/mixins.py +++ b/src/api/views/mixins.py @@ -56,7 +56,7 @@ class ConferenceSlugAssemblyMixin(ConferenceSlugMixin): if self._assembly is None: assembly_slug = self.request.resolver_match.kwargs['assembly'] try: - self._assembly = Assembly.objects.accessible_by_user(self.request.user, self.conference).get(slug=assembly_slug) + self._assembly = Assembly.objects.associated_with_user(self.conference, user=self.request.user).get(slug=assembly_slug) except Assembly.DoesNotExist: if issuing_token := self.kwargs.get('issuing_token', None): try: diff --git a/src/api/views/schedule.py b/src/api/views/schedule.py index 3fd9bba054813a374fb2c50154be195a3fde42ba..d9c2c433d19ec4fcf9886839942d900244fd8676 100644 --- a/src/api/views/schedule.py +++ b/src/api/views/schedule.py @@ -127,7 +127,7 @@ class EventSchedule(ConferenceSlugMixin, APIView): permission_classes = [IsApiUserOrReadOnly] def get(self, request, pk, format=None, **kwargs): - event = Event.objects.accessible_by_user(conference=self.conference, user=self.request.user).get(pk=pk) + event = Event.objects.associated_with_user(conference=self.conference, user=self.request.user, show_public=True).get(pk=pk) return Response(ScheduleEncoder().encode_event(event, self.conference.timezone)) def post(self, request, pk, format=None, **kwargs): diff --git a/src/backoffice/tests/assemblies.py b/src/backoffice/tests/assemblies.py index 17c524368bd7a678b91336b114f822b4ebed0bb7..78f644e461584a049c3ef6dcead6ad91a6cb56df 100644 --- a/src/backoffice/tests/assemblies.py +++ b/src/backoffice/tests/assemblies.py @@ -18,7 +18,7 @@ class AssemblyListViewTest(BackOfficeTestCase): al.save() def test_slugname_related_assemblies(self): - self.client.force_login(self.user) + self.client.force_login(self.staff) resp = self.client.get(reverse('backoffice:assemblieslist', kwargs={'variant': 'slugname'})) self.assertEqual(resp.context['data'][0][3], 'a2, a3') self.assertIn(b'a2, a3', resp.content) diff --git a/src/backoffice/tests/base.py b/src/backoffice/tests/base.py index 80bc46fd7b983e8fd98b51e60ce2e34f8e0c5d0f..3e308d4e87677388d1b363ef87e2fc5e827178cb 100644 --- a/src/backoffice/tests/base.py +++ b/src/backoffice/tests/base.py @@ -1,6 +1,7 @@ import uuid from datetime import UTC, datetime +from django.contrib.auth.models import Group from django.test import TestCase, override_settings from core.models import Conference, ConferenceMember, PlatformUser @@ -10,6 +11,10 @@ TEST_CONF_ID = uuid.uuid4() @override_settings(SELECTED_CONFERENCE_ID=TEST_CONF_ID) class BackOfficeTestCase(TestCase): + fixtures = [ + 'bootstrap_auth_groups', + ] + def setUp(self): self.conf = Conference( id=TEST_CONF_ID, @@ -24,6 +29,9 @@ class BackOfficeTestCase(TestCase): self.user.save() self.conference_member = ConferenceMember(conference=self.conf, user=self.user) self.conference_member.save() + self.staff = PlatformUser.objects.create(username='test_staff', email='staff@where.test') + self.staff_cm = ConferenceMember.objects.create(conference=self.conf, user=self.staff, is_staff=True) + self.staff_cm.permission_groups.add(Group.objects.get(name='Assembly-Team')) def tearDown(self) -> None: self.client.logout() diff --git a/src/backoffice/views/assemblies.py b/src/backoffice/views/assemblies.py index 5d0e94b96cd7318fdcef0ac29f327012228ae2cd..bae1782e3d2ec3300503a5cc088ed2bc034c9499 100644 --- a/src/backoffice/views/assemblies.py +++ b/src/backoffice/views/assemblies.py @@ -448,7 +448,7 @@ class AssemblyEditChildrenView(AssemblyMixin, View): def get(self, *args, **kwargs): candidates_qs = ( - Assembly.objects.accessible_by_user(conference=self.conference, user=self.request.user) + Assembly.objects.associated_with_user(conference=self.conference, user=self.request.user) .filter(hierarchy=Assembly.Hierarchy.REGULAR, parent=None) .exclude(state_assembly__in=[Assembly.State.NONE, Assembly.State.REJECTED, Assembly.State.HIDDEN, Assembly.State.PLANNED]) ) @@ -481,7 +481,7 @@ class AssemblyEditLinksView(AssemblyMixin, View): if add_id is not None: try: - linkee = Assembly.objects.accessible_by_user(user=request.user, conference=self.conference).get(pk=add_id) + linkee = Assembly.objects.associated_with_user(user=request.user, conference=self.conference).get(pk=add_id) except Assembly.DoesNotExist: messages.warning('404 +Assembly %s', add_id) linkee = None @@ -502,7 +502,7 @@ class AssemblyEditLinksView(AssemblyMixin, View): if remove_id is not None: try: - linkee = Assembly.objects.accessible_by_user(user=request.user, conference=self.conference).get(pk=remove_id) + linkee = Assembly.objects.associated_with_user(user=request.user, conference=self.conference).get(pk=remove_id) except Assembly.DoesNotExist: messages.warning('404 -Assembly %s', remove_id) linkee = None @@ -524,7 +524,7 @@ class AssemblyEditLinksView(AssemblyMixin, View): def get(self, *args, **kwargs): candidates_qs = ( - Assembly.objects.accessible_by_user(conference=self.conference, user=self.request.user) + Assembly.objects.associated_with_user(conference=self.conference, user=self.request.user) .filter(hierarchy=Assembly.Hierarchy.REGULAR) .exclude(state_assembly__in=[Assembly.State.NONE, Assembly.State.PLANNED, Assembly.State.HIDDEN, Assembly.State.REJECTED]) ) diff --git a/src/backoffice/views/assemblyteam.py b/src/backoffice/views/assemblyteam.py index 4b29353b1cc69dd24ce697552438484638f2754f..2be313cf94632415f30a5adc0076864515b1af20 100644 --- a/src/backoffice/views/assemblyteam.py +++ b/src/backoffice/views/assemblyteam.py @@ -122,8 +122,7 @@ class AssembliesListMixin(AssemblyTeamMixin): # get the mode mode = (self.request.POST if self.request.method == 'POST' else self.request.GET).get('mode', self.default_assemblies_mode) - # not using .accessible_by_user() here as we're the Assembly Team anyway - qs = Assembly.objects.filter(conference=self.conference) + qs = Assembly.objects.associated_with_user(conference=self.conference, user=self.request.user, staff_can_see=True) if mode == 'not_selected': pass elif self.active_page == 'assemblies': diff --git a/src/core/fixtures/.gitignore b/src/core/fixtures/.gitignore index 267d38517c98e1dfa6c06b0252b1248c2c63d232..445270a60b5e95ecf5ea791a678e6f725227ae09 100644 --- a/src/core/fixtures/.gitignore +++ b/src/core/fixtures/.gitignore @@ -1,2 +1,3 @@ *.json !anhalter.json +!bootstrap_*.json diff --git a/src/core/fixtures/bootstrap_auth_groups.json b/src/core/fixtures/bootstrap_auth_groups.json new file mode 100644 index 0000000000000000000000000000000000000000..364f79683d75958393f5fe0d62058a51167b8396 --- /dev/null +++ b/src/core/fixtures/bootstrap_auth_groups.json @@ -0,0 +1,125 @@ +[ + { + "model": "auth.group", + "fields": { + "name": "Assembly-Team", + "permissions": [ + [ + "assembly_team", + "core", + "conferencemember" + ] + ] + } + }, + { + "model": "auth.group", + "fields": { + "name": "Karten-Verwaltung", + "permissions": [ + [ + "map_edit", + "core", + "conferencemember" + ] + ] + } + }, + { + "model": "auth.group", + "fields": { + "name": "Schedule Supervisor", + "permissions": [ + [ + "scheduleadmin", + "core", + "conferencemember" + ], + [ + "add_schedulesource", + "core", + "schedulesource" + ], + [ + "change_schedulesource", + "core", + "schedulesource" + ], + [ + "delete_schedulesource", + "core", + "schedulesource" + ], + [ + "view_schedulesource", + "core", + "schedulesource" + ], + [ + "view_schedulesourceimport", + "core", + "schedulesourceimport" + ], + [ + "add_schedulesourcemapping", + "core", + "schedulesourcemapping" + ], + [ + "change_schedulesourcemapping", + "core", + "schedulesourcemapping" + ], + [ + "delete_schedulesourcemapping", + "core", + "schedulesourcemapping" + ], + [ + "view_schedulesourcemapping", + "core", + "schedulesourcemapping" + ] + ] + } + }, + { + "model": "auth.group", + "fields": { + "name": "PL", + "permissions": [ + [ + "static_pages", + "core", + "conferencemember" + ] + ] + } + }, + { + "model": "auth.group", + "fields": { + "name": "Wiki Team", + "permissions": [ + [ + "static_pages", + "core", + "conferencemember" + ] + ] + } + }, + { + "model": "auth.group", + "fields": { + "name": "Moderation", + "permissions": [ + [ + "moderation", + "core", + "conferencemember" + ] + ] + } + } +] diff --git a/src/core/models/assemblies.py b/src/core/models/assemblies.py index 7e8139efa490eb36edf4637b6892b26b421b71d9..3ac1a0f64091c0614b7e320282e3a963494824af 100644 --- a/src/core/models/assemblies.py +++ b/src/core/models/assemblies.py @@ -19,6 +19,7 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext from django.utils.translation import gettext_lazy as _ +from core.models.base_managers import ConferenceManagerMixin from core.models.tags import TagItem from core.validators import FileSizeValidator, ImageDimensionValidator @@ -31,85 +32,28 @@ from .tags import TaggedItemMixin from .users import PlatformUser -class AssemblyManager(models.Manager): - def accessible_by_user(self, user: PlatformUser, conference: Conference): - assert user is not None +class AssemblyManager(ConferenceManagerMixin['Assembly']): + staff_permissions = ['assembly_team'] + assembly_filter = 'self' - qs = self.get_queryset() - if conference is not None: - qs = qs.filter(conference=conference) - - # crude hack because Django makes it that hard to customize AnonymousUser - if user is None or not user.is_authenticated: - user = PlatformUser.get_anonymous_user() - - # see if we can return all assemblies (i.e. the user is marked as "staff") - if user.is_authenticated: - try: - member = ConferenceMember.objects.get(conference=conference, user=user) - if member.is_staff: - return qs - except ConferenceMember.DoesNotExist: - pass - - # for everybody else, only show "public" assemblies - return qs.filter(state_assembly__in=self.model.PUBLIC_STATES) - - def associated_to_user(self, user: PlatformUser, conference: Conference): - assert user is not None + def apply_public_filter(self, queryset: 'QuerySet[Assembly]', member: ConferenceMember | None = None) -> 'QuerySet[Assembly]': + return queryset.filter(Q(state_assembly__in=self.model.PUBLIC_STATES) | Q(state_channel__in=self.model.PUBLIC_STATES)) + def associated_to_user(self, user: PlatformUser, conference: Conference) -> 'QuerySet[Assembly]': # guests cannot manage anything if user is None or not user.is_authenticated: return Assembly.objects.none() - - # prepare query - qs = self.get_queryset() - if conference is not None: - qs = qs.filter(conference=conference) - - # lookup via ConferenceMember - qs = qs.filter(members__member=user) - - # but don't show "hidden" ones to the user - qs = qs.exclude(state_assembly__in=[Assembly.State.NONE], state_channel__in=[Assembly.State.NONE]) - - # finally return the resulting QuerySet - return qs - - def conference_accessible(self, conference: Conference): return ( - self.get_queryset() - .filter(conference=conference) - .filter(Q(state_assembly__in=self.model.PUBLIC_STATES) | Q(state_channel__in=self.model.PUBLIC_STATES)) + self.associated_with_user(conference, user=user) + .filter(members__member=user) + .exclude( + Q( + state_assembly__in=[Assembly.State.NONE, Assembly.State.HIDDEN], + state_channel__in=[Assembly.State.NONE, Assembly.State.HIDDEN], + ) + ) ) - def manageable_by_user(self, user: PlatformUser, conference: Conference, staff_can_manage=True): - assert user is not None - - # guests cannot manage anything - if user is None or not user.is_authenticated: - return Assembly.objects.none() - - # prepare query - qs = self.get_queryset() - if conference is not None: - qs = qs.filter(conference=conference) - - # see if we can return all assemblies (i.e. the user is marked as "staff") - if staff_can_manage: - try: - member = ConferenceMember.objects.get(conference=conference, user=user) - if member.is_staff and member.has_perm('core.assembly_team'): - return qs - except ConferenceMember.DoesNotExist: - pass - - # lookup via AssemblyMember - qs = qs.filter(members__member=user, members__can_manage_assembly=True) - - # finally return the resulting QuerySet - return qs - def get_banner_file_name(instance: 'Assembly', filename: str): return str(Path(str(instance.id)).joinpath('banner', filename)) @@ -561,33 +505,12 @@ class Assembly(TaggedItemMixin, models.Model): self.logentries.create(user=user, kind=kind, comment=message, changes=changes) -class AssemblyLinkManager(models.Manager): - def accessible_by_user(self, user: PlatformUser, conference: Conference): - assert user is not None +class AssemblyLinkManager(ConferenceManagerMixin['AssemblyLink']): + staff_permissions = ['assembly_team'] + conference_filter = 'a__conference' - qs = self.get_queryset() - if conference is not None: - qs = qs.filter(conference=conference) - - # crude hack because Django makes it that hard to customize AnonymousUser - if user is None or not user.is_authenticated: - user = PlatformUser.get_anonymous_user() - - # for global staff apply no more limits - if user.is_staff or user.is_superuser: - return qs - - # see if we can return all links (i.e. the user is marked as "staff") - if user.is_authenticated: - try: - member = ConferenceMember.objects.get(conference=conference, user=user) - if member.is_staff: - return qs - except ConferenceMember.DoesNotExist: - pass - - # for everybody else, only show "public" links - return qs.filter(Type__in=self.model.PUBLIC_TYPES) + def apply_public_filter(self, queryset: 'QuerySet[AssemblyLink]', member: ConferenceMember) -> 'QuerySet[AssemblyLink]': + return queryset.filter(Type__in=self.model.PUBLIC_TYPES) class AssemblyLink(models.Model): @@ -626,65 +549,13 @@ class AssemblyLink(models.Model): return s -class AssemblyMemberManager(models.Manager): - def conference_accessible(self, conference: 'Conference', member: ConferenceMember | None = None) -> QuerySet['AssemblyMember']: - return self.get_queryset().filter(assembly__conference=conference).filter(show_public=True) - - def accessible_by_user(self, user: PlatformUser, conference: Conference): - assert user is not None - - qs = self.get_queryset() - if conference is not None: - qs = qs.filter(conference=conference) - - # crude hack because Django makes it that hard to customize AnonymousUser - if user is None or not user.is_authenticated: - user = PlatformUser.get_anonymous_user() - - # for global staff apply no more limits - if user.is_staff or user.is_superuser: - return qs - - # see if we can return all roles (i.e. the user is marked as "staff") - if user.is_authenticated: - try: - member = ConferenceMember.objects.get(conference=conference, user=user) - if member.is_staff: - return qs - except ConferenceMember.DoesNotExist: - pass - - # for everybody else, only show "public" roles - return qs.filter(show_public=True) - - def manageable_by_user_for_assembly(self, user: PlatformUser, assembly: Assembly): - assert user is not None and isinstance(user, PlatformUser) - assert assembly is not None and isinstance(assembly, Assembly) - - if not user.is_authenticated: - return AssemblyMember.objects.none() - - qs = AssemblyMember.objects.filter(assembly=assembly) - - # for global staff apply no more limits - if user.is_staff or user.is_superuser: - return qs - - # see if the user is marked as "staff" on the conference - try: - member = ConferenceMember.objects.get(conference=assembly.conference, user=user) - if member.is_staff: - return qs - except ConferenceMember.DoesNotExist: - pass - - # check that the user has management role in the assembly - user_is_member = AssemblyMember.objects.filter(assembly=assembly, member=user, can_manage_assembly=True).exists() - - if not user_is_member: - return AssemblyMember.objects.none() +class AssemblyMemberManager(ConferenceManagerMixin['AssemblyMember']): + staff_permissions = ['assembly_team'] + conference_filter = 'assembly__conference' + assembly_filter = 'assembly' - return qs + def apply_public_filter(self, queryset: 'QuerySet[AssemblyMember]', member: ConferenceMember | None = None) -> 'QuerySet[AssemblyMember]': + return queryset.filter(show_public=True) class AssemblyMember(models.Model): diff --git a/src/core/models/badges.py b/src/core/models/badges.py index 345845c0f7211c73f32be24b07973ceede59e269..07e949411f6f6c18ecc71e7ee565237d638b058e 100644 --- a/src/core/models/badges.py +++ b/src/core/models/badges.py @@ -77,7 +77,7 @@ class BadgeManager(ConferenceManagerMixin['Badge']): if not user.is_authenticated: return qs.filter(state=Badge.State.PUBLIC) - manageable = Assembly.objects.manageable_by_user(user, conference, staff_can_manage) + manageable = Assembly.objects.manageable_by_user(conference, user=user, staff_can_manage=staff_can_manage) return qs.filter(Q(state=Badge.State.PUBLIC) | Q(users__user=user) | Q(issuing_assembly__in=manageable)) diff --git a/src/core/models/base_managers.py b/src/core/models/base_managers.py index 960e93f718b762ed2e1946ae106465e96718be30..651ba1c3bf0315b2a57647d3c30a82cb5bf2a8fc 100644 --- a/src/core/models/base_managers.py +++ b/src/core/models/base_managers.py @@ -5,7 +5,6 @@ from django.core.exceptions import ImproperlyConfigured from django.db import models from django.db.models import Model, QuerySet -from core.models.assemblies import Assembly, AssemblyMember from core.models.conference import ConferenceMember if TYPE_CHECKING: @@ -75,6 +74,9 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]): else: prefix = f'{self.assembly_filter}__' queryset = queryset.select_related(self.assembly_filter) + + from core.models.assemblies import Assembly, AssemblyMember # pylint: disable=C0415 # Circular import avoidance + allowed_members = AssemblyMember.objects.filter(member=member.user) allowed_members = allowed_members.filter(can_manage_assembly=True) if only_manageable else allowed_members queryset = queryset.filter(**{f'{prefix}members__in': allowed_members}).exclude(**{f'{prefix}state_assembly__in': [Assembly.State.HIDDEN]}) @@ -167,6 +169,7 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]): member: ConferenceMember | None = None, only_manageable: bool | None = None, staff_can_manage: bool = True, + show_public: bool = False, ) -> QuerySet[_ModelType]: if not user and not member: raise ValueError('Either user or member must be given') @@ -177,19 +180,18 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]): member = self.get_member(conference, user, member) qs = self.get_conference_queryset(conference) if not isinstance(member, ConferenceMember): - return qs.none() + return self.conference_accessible(conference=conference, member=member) if show_public else qs.none() if member.is_staff and staff_can_manage: - if self.staff_permissions is None or (self.staff_permissions and member.has_staff_permission(self.staff_permissions)): + if self.staff_permissions is None or (self.staff_permissions and member.has_staff_permission(*self.staff_permissions)): return qs - return ( - qs.filter( - id__in=self.apply_assembly_rights_filter(qs, member, only_manageable=only_manageable) - .values('id') - .union(self.apply_self_organized_rights_filter(qs, member, only_manageable=only_manageable).values('id')) - ) - .select_related(*self.select_related_fields or []) - .prefetch_related(*self.prefetch_related_fields or []) + id_list = ( + self.apply_assembly_rights_filter(qs, member, only_manageable=only_manageable) + .values('id') + .union(self.apply_self_organized_rights_filter(qs, member, only_manageable=only_manageable).values('id')) ) + if show_public: + id_list = id_list.union(self.apply_public_filter(qs, member).values('id')) + return qs.filter(id__in=id_list).select_related(*self.select_related_fields or []).prefetch_related(*self.prefetch_related_fields or []) def associated_with_user( self, @@ -198,6 +200,7 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]): user: 'PlatformUser | None' = None, member: ConferenceMember | None = None, staff_can_see: bool = True, + show_public: bool = False, ) -> QuerySet[_ModelType]: """Returns a queryset of objects that are accessible by the given user. @@ -221,6 +224,7 @@ class ConferenceManagerMixin(models.Manager, Generic[_ModelType]): member=member, only_manageable=False, staff_can_manage=staff_can_see, + show_public=show_public, ) def manageable_by_user( diff --git a/src/core/models/events.py b/src/core/models/events.py index 2ae99c5c7132ea747d1cbc0c66d991261aad8e9d..bc1439f10660859c8d9f5911f2003e29d0e558ae 100644 --- a/src/core/models/events.py +++ b/src/core/models/events.py @@ -1,7 +1,7 @@ import logging import random from datetime import timedelta -from re import compile +from re import compile as re_compile from typing import Any from uuid import UUID, uuid4 @@ -11,7 +11,7 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import NON_FIELD_ERRORS, ValidationError from django.db import models -from django.db.models import Q +from django.db.models import Q, QuerySet from django.forms import DurationField from django.utils import timezone from django.utils.dateparse import parse_datetime @@ -20,19 +20,18 @@ from django.utils.text import slugify from django.utils.timezone import is_aware, make_aware from django.utils.translation import gettext_lazy as _ -from core.models.tags import TagItem +from core.fields import ConferenceReference +from core.markdown import compile_translated_markdown_fields, store_relationships +from core.models.assemblies import Assembly +from core.models.base_managers import ConferenceManagerMixin +from core.models.conference import Conference, ConferenceMember, ConferenceTrack +from core.models.rooms import Room +from core.models.shared import BackendMixin +from core.models.tags import TaggedItemMixin, TagItem +from core.models.users import PlatformUser +from core.utils import str2bool, str2timedelta -from ..fields import ConferenceReference -from ..markdown import compile_translated_markdown_fields, store_relationships -from ..utils import str2bool, str2timedelta -from .assemblies import Assembly -from .conference import Conference, ConferenceMember, ConferenceTrack -from .rooms import Room -from .shared import BackendMixin -from .tags import TaggedItemMixin -from .users import PlatformUser - -SIMPLE_TIME_RE = compile(r'(.*\s)?(\d+:\d+)$') +SIMPLE_TIME_RE = re_compile(r'(.*\s)?(\d+:\d+)$') logger = logging.getLogger(__name__) @@ -60,76 +59,21 @@ class EventDurationField(models.DurationField): return super().formfield(**defaults) -class EventManager(models.Manager): - def accessible_by_user(self, user: PlatformUser, conference: Conference): - assert user is not None - assert conference is not None - - qs = self.get_queryset().filter(conference=conference) - - # crude hack because Django makes it that hard to customize AnonymousUser - if user is None or not user.is_authenticated: - user = PlatformUser.get_anonymous_user() - - # for global staff apply no more limits - if user.is_staff or user.is_superuser: - return qs - - # see if we can return all events (i.e. the user is marked as "staff") - if user.is_authenticated: - try: - member = ConferenceMember.objects.get(conference=conference, user=user) - if member.is_staff: - return qs - except ConferenceMember.DoesNotExist: - pass - - # for everybody else, only show public events - return qs.filter(is_public=True) - - def conference_accessible(self, conference: Conference): - return ( - self.get_queryset() - .filter(conference=conference, is_public=True) - .filter( - Q(assembly__state_assembly__in=Assembly.PUBLIC_STATES) - | Q(assembly__state_channel__in=Assembly.PUBLIC_STATES) - | Q(kind=Event.Kind.SELF_ORGANIZED) - ) - ) - - def manageable_by_user(self, user: PlatformUser, conference: Conference, staff_can_manage: bool = True): - assert user is not None - assert conference is not None - - qs = self.get_queryset().filter(conference=conference) - - # crude hack because Django makes it that hard to customize AnonymousUser - if user is None or not user.is_authenticated: - return qs.none() +class EventManager(ConferenceManagerMixin['Event']): + staff_permissions = ['assembly_team', 'scheduleadmin'] + conference_filter = 'assembly__conference' + assembly_filter = 'assembly' - # for global staff apply no more limits - if staff_can_manage and (user.is_staff or user.is_superuser): - return qs - - # see if we can return all events (i.e. the user is marked as "staff") - if staff_can_manage: - try: - member = ConferenceMember.objects.get(conference=conference, user=user) - if member.is_staff: - return qs - except ConferenceMember.DoesNotExist: - pass + def apply_public_filter(self, queryset: 'QuerySet[Event]', member: ConferenceMember | None = None) -> 'QuerySet[Event]': + return queryset.filter( + (Q(assembly__state_assembly__in=Assembly.PUBLIC_STATES) | Q(assembly__state_channel__in=Assembly.PUBLIC_STATES) | Q(kind=Event.Kind.SELF_ORGANIZED)) + & Q(is_public=True) + ) - # for everybody else, only show public events - manageable_assemblies_qs = Assembly.objects.manageable_by_user(conference=conference, user=user).filter( - members__member=user, members__can_manage_assembly=True + def apply_manage_filter(self, queryset: 'QuerySet[Event]', member: ConferenceMember) -> 'QuerySet[Event]': + return queryset.filter( + Q(assembly__members__member=member, assembly__members__can_manage_assembly=True) | Q(owner=member.user, kind=Event.Kind.SELF_ORGANIZED) ) - # TODO: remove work around when implementing ConferenceManagerMixin - # Filter out events belonging to the SoS Assembly - if not staff_can_manage: - manageable_assemblies_qs.exclude(id=conference.self_organized_sessions_assembly_id) - return qs.filter(Q(kind=Event.Kind.SELF_ORGANIZED, owner=user) | Q(assembly_id__in=manageable_assemblies_qs.values_list('id', flat=True))) class Event(TaggedItemMixin, BackendMixin, models.Model): diff --git a/src/core/models/rooms.py b/src/core/models/rooms.py index 4a336b213300456a8528dceb136d8c9ede3f4963..c1c137845ba9350d4fb3557fe1e1fc106f15e3ad 100644 --- a/src/core/models/rooms.py +++ b/src/core/models/rooms.py @@ -10,21 +10,20 @@ from django.contrib.postgres.fields import DateTimeRangeField from django.core.exceptions import ValidationError from django.core.validators import URLValidator from django.db import models -from django.db.models import Q +from django.db.models import Q, QuerySet from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ +from core.fields import ConferenceReference +from core.markdown import compile_translated_markdown_fields, store_relationships +from core.models.assemblies import Assembly +from core.models.base_managers import ConferenceManagerMixin +from core.models.conference import Conference, ConferenceMember +from core.models.shared import BackendMixin from core.models.tags import TagItem +from core.utils import str2bool from core.validators import FileSizeValidator, ImageDimensionValidator -from ..fields import ConferenceReference -from ..markdown import compile_translated_markdown_fields, store_relationships -from ..models.conference import Conference, ConferenceMember -from ..models.users import PlatformUser -from ..utils import str2bool -from .assemblies import Assembly -from .shared import BackendMixin - class RoomType(models.TextChoices): LECTURE_HALL = 'lecturehall', _('Room__type-lecturehall') @@ -39,39 +38,14 @@ class RoomType(models.TextChoices): OTHER = 'other', _('Room__type-other') -class RoomManager(models.Manager): - def accessible_by_user(self, user: PlatformUser, conference: Conference): - assert user is not None - assert conference is not None - - qs = self.get_queryset().filter(conference=conference) - - # crude hack because Django makes it that hard to customize AnonymousUser - if user is None or not user.is_authenticated: - user = PlatformUser.get_anonymous_user() +class RoomManager(ConferenceManagerMixin['Room']): + staff_permissions = ['assembly_team'] + conference_filter = 'assembly__conference' + assembly_filter = 'assembly' - # for global staff apply no more limits - if user.is_staff or user.is_superuser: - return qs - - # see if we can return all events (i.e. the user is marked as "staff") - if user.is_authenticated: - try: - member = ConferenceMember.objects.get(conference=conference, user=user) - if member.is_staff: - return qs - except ConferenceMember.DoesNotExist: - pass - - # for everybody else, only show public events - return qs.filter(blocked=False) - - def conference_accessible(self, conference: Conference): - return ( - self.get_queryset() - .filter(conference=conference, blocked=False) - .filter(Q(assembly__state_assembly__in=Assembly.PUBLIC_STATES) | Q(assembly__state_channel__in=Assembly.PUBLIC_STATES)) - .exclude(room_type=RoomType.PROJECT) + def apply_public_filter(self, queryset: 'QuerySet[Room]', member: ConferenceMember | None = None) -> 'QuerySet[Room]': + return queryset.filter(blocked=False).filter( + Q(assembly__state_assembly__in=Assembly.PUBLIC_STATES) | Q(assembly__state_channel__in=Assembly.PUBLIC_STATES) ) def assignable_in_timeframe(self, conference: Conference, start: datetime, end: datetime, assembly: Optional[Assembly] = None, is_sos: bool = False): @@ -471,6 +445,15 @@ class RoomShare(models.Model): raise ValidationError(errors) +class RoomLinkManager(ConferenceManagerMixin['RoomLink']): + staff_permissions = ['assembly_team'] + conference_filter = 'room__assembly__conference' + assembly_filter = 'room__assembly' + + def apply_manage_filter(self, queryset: 'QuerySet[RoomLink]', member: ConferenceMember) -> 'QuerySet[RoomLink]': + return queryset.filter(Q(assembly__members__member=member, assembly__members__can_manage_assembly=True)) + + class RoomLink(models.Model): class LinkType(models.TextChoices): WEBSITE = 'website', _('RoomLink__type-website') @@ -482,6 +465,8 @@ class RoomLink(models.Model): VIDEO = 'video', _('RoomLink__type-video') AUDIO = 'audio', _('RoomLink__type-audio') + objects = RoomLinkManager() + URL_LINKTYPES = [LinkType.WEBSITE, LinkType.CHAT, LinkType.BBB, LinkType.JITSI, LinkType.PAD, LinkType.VIDEO, LinkType.AUDIO] """All LinkTypes which require the link to be a valid URL. The notable exception is media_ccc_de where only a global id is required.""" diff --git a/src/core/tests/assemblies.py b/src/core/tests/assemblies.py index c8d4b5b6b33f45720ea66daa76dd0f0b98a4b905..be70aabad8f3ac39e86666e1e272694b3a65aaa6 100644 --- a/src/core/tests/assemblies.py +++ b/src/core/tests/assemblies.py @@ -1,12 +1,13 @@ +from django.contrib.auth.models import Group, Permission from django.core import mail from django.test import TestCase, override_settings -from ..models.assemblies import Assembly +from ..models.assemblies import Assembly, AssemblyMember from ..models.conference import Conference, ConferenceMember from ..models.users import PlatformUser, UserCommunicationChannel -class AssembliesTests(TestCase): +class AssembliesTestsMixin(TestCase): def setUp(self): self.conference = Conference(slug='foo', name='Foo Conference', mail_footer='This is serious business w/ legal stuff.') self.conference.save() @@ -27,6 +28,7 @@ class AssembliesTests(TestCase): ) self.user_mgmt2 = PlatformUser(username='manager2') self.user_mgmt2.save() + self.user_mgmt2.user_permissions.add(Permission.objects.get(codename='assembly_team')) self.user_mgmt2.communication_channels.create( channel=UserCommunicationChannel.Channel.MAIL, address='notifications2@unittest.local', @@ -49,6 +51,7 @@ class AssembliesTests(TestCase): is_verified=True, use_for_notifications=False, ) + self.user_anon = PlatformUser.get_anonymous_user() ConferenceMember(conference=self.conference, user=self.user_mgmt).save() ConferenceMember(conference=self.conference, user=self.user_mgmt2).save() ConferenceMember(conference=self.conference, user=self.user_participant).save() @@ -57,11 +60,13 @@ class AssembliesTests(TestCase): self.assembly1 = Assembly(conference=self.conference, slug='fnord', name='Fnord Assembly') self.assembly1.save() self.assembly1.members.create(member=self.user_mgmt, can_manage_assembly=True) - self.assembly1.members.create(member=self.user_participant, can_manage_assembly=False) + self.assembly1.members.create(member=self.user_participant, can_manage_assembly=False, show_public=True) self.assembly2 = Assembly(conference=self.conference, slug='foo', name='Foo Bar') self.assembly2.members.create(member=self.user_mgmt2, can_manage_assembly=True) self.assembly2.save() + +class AssembliesTests(AssembliesTestsMixin): @override_settings(SUPPORT_HTML_MAILS=True) def test_mail_plainhtml(self): self.assembly2.send_mail_to_managers( @@ -105,3 +110,63 @@ class AssembliesTests(TestCase): self.assertIn('https://events.ccc.de/', the_mail.body) self.assertIn(self.assembly1.slug, the_mail.alternatives[0][0]) self.assertIn('https://events.ccc.de/', the_mail.alternatives[0][0]) + + +class AssembliesObjectManagerTests(AssembliesTestsMixin): + fixtures = [ + 'bootstrap_auth_groups', + ] + + def setUp(self): + super().setUp() + + self.conference2 = Conference(slug='bar', name='Bar Conference') + self.conference2.save() + self.assembly3 = Assembly(conference=self.conference2, slug='fnord', name='Fnord Assembly') + self.assembly3.save() + self.user_a3_mgmt = PlatformUser(username='manager3') + self.user_a3_mgmt.save() + ConferenceMember(conference=self.conference2, user=self.user_a3_mgmt).save() + self.assembly3.members.create(member=self.user_a3_mgmt, can_manage_assembly=True, show_public=True) + self.user_a3_part = PlatformUser(username='participant3') + self.user_a3_part.save() + ConferenceMember(conference=self.conference2, user=self.user_a3_part).save() + self.assembly3.members.create(member=self.user_a3_part, can_manage_assembly=False, show_public=True) + + self.assembly_group = Group.objects.get(name='Assembly-Team') + + self.user_conf_staff = PlatformUser(username='conf_staff') + self.user_conf_staff.save() + self.cm_conf_staff = ConferenceMember( + conference=self.conference, + user=self.user_conf_staff, + is_staff=True, + ) + self.cm_conf_staff.save() + self.cm_conf_staff.permission_groups.add(self.assembly_group) + + self.public_members = list(AssemblyMember.objects.filter(show_public=True)) + + def test_associated_with_user(self): + self.assertListEqual( + list(AssemblyMember.objects.associated_with_user(user=self.user_conf_staff, conference=self.conference)), + list(AssemblyMember.objects.filter(assembly__conference=self.conference)), + ) + self.assertListEqual( + list(AssemblyMember.objects.associated_with_user(user=self.user_conf_staff, conference=self.conference, staff_can_see=False)), + [], + ) + self.assertListEqual( + list(AssemblyMember.objects.associated_with_user(user=self.user_visitor, conference=self.conference)), + [], + ) + + def test_conference_accessible(self): + self.assertListEqual( + list(AssemblyMember.objects.conference_accessible(conference=self.conference)), + list(AssemblyMember.objects.filter(assembly__conference=self.conference, show_public=True)), + ) + self.assertListEqual( + list(AssemblyMember.objects.conference_accessible(conference=self.conference)), + list(AssemblyMember.objects.filter(assembly__conference=self.conference, show_public=True)), + ) diff --git a/src/plainui/views/events.py b/src/plainui/views/events.py index 0b1535e385925ebe14240a34e57923300e1e82a0..962b253a153d235c85a991bc585d796081dc9d20 100644 --- a/src/plainui/views/events.py +++ b/src/plainui/views/events.py @@ -188,7 +188,7 @@ class SosList(ConferenceRequiredMixin, FilteredListView): context['events_upcoming'] = event_filter(self.request.user, self.conf, kinds=[Event.Kind.SELF_ORGANIZED], calendar_mode=False, upcoming=True) context['events'] = event_filter(self.request.user, self.conf, kinds=[Event.Kind.SELF_ORGANIZED], calendar_mode=False) context['is_favorite_events'] = session_get_favorite_events(self.request.session, self.request.user) - context['manageable_events'] = Event.objects.manageable_by_user(self.request.user, self.conf).only('id') + context['manageable_events'] = Event.objects.manageable_by_user(self.conf, user=self.request.user).only('id') context['report_info'] = {'enabled': True}