Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/eventyay/api/serializers/organizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ class Meta:
'can_view_vouchers',
'can_change_vouchers',
'can_checkin_orders',
'can_change_submissions',
'is_reviewer',
'force_hide_speaker_names',
'can_video_create_stages',
'can_video_create_channels',
'can_video_direct_message',
'can_video_manage_announcements',
'can_video_view_users',
'can_video_manage_users',
'can_video_manage_rooms',
'can_video_manage_kiosks',
'can_video_manage_configuration',
)

def validate(self, data):
Expand Down
18 changes: 16 additions & 2 deletions app/eventyay/api/views/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
# from pretix.presale.views.organizer import filter_qs_by_attr # commented out
from eventyay.api.task import configure_video_settings_for_talks
from eventyay.api.utils import get_protocol
from eventyay.eventyay_common.video.permissions import VIDEO_TRAIT_ROLE_MAP

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -325,8 +326,12 @@ def post(request, *args, **kwargs) -> JsonResponse:

title = titles.get(locale) or titles.get("en") or title_default

attendee_trait_grants = request.data.get("traits", {}).get("attendee", "")
if not isinstance(attendee_trait_grants, str):
traits_payload = request.data.get("traits") or {}
if not isinstance(traits_payload, dict):
raise ValidationError("Traits must be provided as an object.")

attendee_trait_grants = traits_payload.get("attendee", "")
if attendee_trait_grants and not isinstance(attendee_trait_grants, str):
raise ValidationError("Attendee traits must be a string")

trait_grants = {
Expand All @@ -337,6 +342,15 @@ def post(request, *args, **kwargs) -> JsonResponse:
"scheduleuser": ["schedule-update"],
}

for trait_name, role_name in VIDEO_TRAIT_ROLE_MAP.items():
trait_value = traits_payload.get(trait_name, "")
if trait_value:
if not isinstance(trait_value, str):
raise ValidationError(
f"Trait '{trait_name}' must be a string value."
)
trait_grants[role_name] = [trait_value]

# if event already exists, update it, otherwise create a new event
event_id = request.data.get("id")
domain_path = "{}{}/{}".format(
Expand Down
2 changes: 1 addition & 1 deletion app/eventyay/api/views/product.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ class ProductAddOnViewSet(viewsets.ModelViewSet):
ordering_fields = ('id', 'position')
ordering = ('id',)
permission = None
write_permission = 'can_change_products'
write_permission = 'can_change_items'

@cached_property
def product(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Generated by Django 5.2.5 on 2025-11-27 13:17

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('base', '0002_alter_event_header_image_alter_event_locale_and_more'),
]

operations = [
migrations.AddField(
model_name='team',
name='can_video_create_channels',
field=models.BooleanField(default=False, help_text='Allows creating chat/video channels inside Eventyay Video.', verbose_name='Video: Can create channels'),
),
migrations.AddField(
model_name='team',
name='can_video_create_stages',
field=models.BooleanField(default=False, help_text='Allows creating livestream stages inside Eventyay Video.', verbose_name='Video: Can create stages'),
),
migrations.AddField(
model_name='team',
name='can_video_direct_message',
field=models.BooleanField(default=False, help_text='Grants permission to open new direct message conversations.', verbose_name='Video: Can send direct messages'),
),
migrations.AddField(
model_name='team',
name='can_video_manage_announcements',
field=models.BooleanField(default=False, help_text='Allows posting announcements in the Eventyay Video interface.', verbose_name='Video: Can create announcements'),
),
migrations.AddField(
model_name='team',
name='can_video_manage_configuration',
field=models.BooleanField(default=False, help_text='Allows editing the global Eventyay Video configuration.', verbose_name='Video: Can edit event configuration'),
),
migrations.AddField(
model_name='team',
name='can_video_manage_kiosks',
field=models.BooleanField(default=False, help_text='Allows managing kiosk displays inside Eventyay Video.', verbose_name='Video: Can create and edit kiosks'),
),
migrations.AddField(
model_name='team',
name='can_video_manage_rooms',
field=models.BooleanField(default=False, help_text='Allows editing and deleting rooms inside Eventyay Video.', verbose_name='Video: Can create and edit rooms'),
),
migrations.AddField(
model_name='team',
name='can_video_manage_users',
field=models.BooleanField(default=False, help_text='Allows moderating users (ban, silence, reactivate) in Eventyay Video.', verbose_name='Video: Can message, ban, and silence users'),
),
migrations.AddField(
model_name='team',
name='can_video_view_users',
field=models.BooleanField(default=False, help_text='Allows access to the user directory in Eventyay Video.', verbose_name='Video: Can view users'),
),
]
103 changes: 79 additions & 24 deletions app/eventyay/base/models/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from collections import OrderedDict, defaultdict
from datetime import datetime, time, timedelta
from operator import attrgetter
from typing import List
from urllib.parse import urljoin, urlparse
from zoneinfo import ZoneInfo

Expand Down Expand Up @@ -50,7 +49,14 @@
from eventyay.common.text.phrases import phrases
from eventyay.common.urls import EventUrls
from eventyay.consts import TIMEZONE_CHOICES
from eventyay.core.permissions import MAX_PERMISSIONS_IF_SILENCED, SYSTEM_ROLES, Permission
from eventyay.core.permissions import (
MAX_PERMISSIONS_IF_SILENCED,
ORGANIZER_ROLES,
SYSTEM_ROLES,
Permission,
normalize_permission_value,
traits_match_required,
)
from eventyay.core.utils.json import CustomJSONEncoder
from eventyay.helpers.database import GroupConcat
from eventyay.helpers.daterange import daterange
Expand All @@ -62,7 +68,7 @@
has_any_permission,
is_event_visible,
)

from eventyay.eventyay_common.video.permissions import VIDEO_PERMISSION_BY_FIELD, VIDEO_TRAIT_ROLE_MAP
from ..settings import settings_hierarkey
from .auth import User
from .mixins import OrderedModel, PretalxModel
Expand All @@ -85,7 +91,6 @@ def default_roles():
attendee = [
Permission.EVENT_VIEW,
Permission.EVENT_EXHIBITION_CONTACT,
Permission.EVENT_CHAT_DIRECT,
]
viewer = attendee + [Permission.ROOM_VIEW, Permission.ROOM_CHAT_READ]
participant = viewer + [
Expand Down Expand Up @@ -1352,38 +1357,73 @@ def decode_token(self, token, allow_raise=False):
if exc and allow_raise:
raise exc

def _get_trait_grants_with_defaults(self):
base_trait_grants = self.trait_grants if self.trait_grants is not None else default_grants()
slug = getattr(self, "slug", None) or getattr(self, "id", None)
if not slug:
return base_trait_grants
augmented = dict(base_trait_grants)
for role, trait_name in VIDEO_TRAIT_ROLE_MAP.items():
augmented.setdefault(role, [f"eventyay-video-event-{slug}-{trait_name.replace('_', '-')}"])
return augmented

def _remove_direct_messaging_if_unauthorized(self, result, user_traits):
"""Remove EVENT_CHAT_DIRECT permission if user doesn't have the direct messaging trait.

Args:
result: Permission result dictionary to modify
user_traits: List of user traits
"""
direct_messaging_def = VIDEO_PERMISSION_BY_FIELD.get('can_video_direct_message')
if not direct_messaging_def:
return

direct_messaging_trait = direct_messaging_def.trait_value(self.slug)
has_direct_messaging_trait = direct_messaging_trait in user_traits

if not has_direct_messaging_trait:
direct_message_value = Permission.EVENT_CHAT_DIRECT.value
result[self] = {
p for p in result[self]
if normalize_permission_value(p) != direct_message_value
}

def has_permission_implicit(
self,
*,
traits,
permissions: List[Permission],
permissions: list[Permission],
room=None,
allow_empty_traits=True,
):
# Ensure trait_grants and roles are not None - use defaults if missing
event_trait_grants = self.trait_grants if self.trait_grants is not None else default_grants()
event_trait_grants = self._get_trait_grants_with_defaults()
event_roles = self.roles if self.roles is not None else default_roles()

for role, required_traits in event_trait_grants.items():
if (
isinstance(required_traits, list)
and all(any(x in traits for x in (r if isinstance(r, list) else [r])) for r in required_traits)
traits_match_required(traits, required_traits)
and (required_traits or allow_empty_traits)
):
role_permissions = event_roles.get(role, SYSTEM_ROLES.get(role, []))
if any(p in role_permissions or p.value in role_permissions for p in permissions):
if any(
normalize_permission_value(p) in role_permissions
for p in permissions
):
return True

if room:
room_trait_grants = room.trait_grants if room.trait_grants is not None else {}
for role, required_traits in room_trait_grants.items():
if (
isinstance(required_traits, list)
and all(any(x in traits for x in (r if isinstance(r, list) else [r])) for r in required_traits)
traits_match_required(traits, required_traits)
and (required_traits or allow_empty_traits)
):
role_permissions = event_roles.get(role, SYSTEM_ROLES.get(role, []))
if any(p in role_permissions or p.value in role_permissions for p in permissions):
if any(
normalize_permission_value(p) in role_permissions
for p in permissions
):
return True

# Return False if no permission was granted
Expand All @@ -1405,7 +1445,7 @@ def has_permission(self, *, user, permission: Permission, room=None):
return False

if self.has_permission_implicit(
traits=user.traits,
traits=user.traits or [],
permissions=permission,
room=room,
allow_empty_traits=user.type == User.UserType.PERSON,
Expand All @@ -1415,7 +1455,8 @@ def has_permission(self, *, user, permission: Permission, room=None):
roles = user.get_role_grants(room)
event_roles = self.roles if self.roles is not None else default_roles()
for r in roles:
if any(p.value in event_roles.get(r, SYSTEM_ROLES.get(r, [])) for p in permission):
role_perms = event_roles.get(r, SYSTEM_ROLES.get(r, []))
if any(normalize_permission_value(p) in role_perms for p in permission):
return True

async def has_permission_async(self, *, user, permission: Permission, room=None):
Expand All @@ -1434,7 +1475,7 @@ async def has_permission_async(self, *, user, permission: Permission, room=None)
return False

if self.has_permission_implicit(
traits=user.traits,
traits=user.traits or [],
permissions=permission,
room=room,
allow_empty_traits=user.type == User.UserType.PERSON,
Expand All @@ -1444,7 +1485,8 @@ async def has_permission_async(self, *, user, permission: Permission, room=None)
roles = await user.get_role_grants_async(room)
event_roles = self.roles if self.roles is not None else default_roles()
for r in roles:
if any(p.value in event_roles.get(r, SYSTEM_ROLES.get(r, [])) for p in permission):
role_perms = event_roles.get(r, SYSTEM_ROLES.get(r, []))
if any(normalize_permission_value(p) in role_perms for p in permission):
return True

def get_all_permissions(self, user):
Expand All @@ -1455,25 +1497,38 @@ def get_all_permissions(self, user):
allow_empty_traits = user.type == User.UserType.PERSON

# Ensure trait_grants and roles are not None
event_trait_grants = self.trait_grants if self.trait_grants is not None else default_grants()
event_trait_grants = self._get_trait_grants_with_defaults()
event_roles = self.roles if self.roles is not None else default_roles()

user_traits = user.traits or []

for role, required_traits in event_trait_grants.items():
if (
isinstance(required_traits, list)
and all(any(x in user.traits for x in (r if isinstance(r, list) else [r])) for r in required_traits)
traits_match_required(user_traits, required_traits)
and (required_traits or allow_empty_traits)
):
result[self].update(event_roles.get(role, SYSTEM_ROLES.get(role, [])))

# Removed user.world_grants loop (attribute not present on unified User model)
role_perms = event_roles.get(role, SYSTEM_ROLES.get(role, []))
result[self].update(role_perms)

# Admin mode in the ticket/talk system is represented by the ``admin`` trait on the video side.
# When admin mode is ON, the user has the ``admin`` trait and should retain full access.
admin_mode_active = "admin" in user_traits

if admin_mode_active:
# Grant all video manager permissions when admin mode is active
for role_name in ORGANIZER_ROLES:
role_perms = event_roles.get(role_name, SYSTEM_ROLES.get(role_name, []))
result[self].update(role_perms)
else:
# Remove EVENT_CHAT_DIRECT from ALL users unless they have the direct messaging trait.
# Only users with can_video_direct_message team permission get the video_direct_messaging trait.
self._remove_direct_messaging_if_unauthorized(result, user_traits)

for room in self.rooms.all():
room_trait_grants = room.trait_grants if room.trait_grants is not None else {}
for role, required_traits in room_trait_grants.items():
if (
isinstance(required_traits, list)
and all(any(x in user.traits for x in (r if isinstance(r, list) else [r])) for r in required_traits)
traits_match_required(user_traits, required_traits)
and (required_traits or allow_empty_traits)
):
result[room].update(event_roles.get(role, SYSTEM_ROLES.get(role, [])))
Expand Down
46 changes: 46 additions & 0 deletions app/eventyay/base/models/organizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,52 @@ class Meta:
default=False,
)

can_video_create_stages = models.BooleanField(
default=False,
verbose_name=_('Video: Can create stages'),
help_text=_('Allows creating livestream stages inside Eventyay Video.'),
)
can_video_create_channels = models.BooleanField(
default=False,
verbose_name=_('Video: Can create channels'),
help_text=_('Allows creating chat/video channels inside Eventyay Video.'),
)
can_video_direct_message = models.BooleanField(
default=False,
verbose_name=_('Video: Can send direct messages'),
help_text=_('Grants permission to open new direct message conversations.'),
)
can_video_manage_announcements = models.BooleanField(
default=False,
verbose_name=_('Video: Can create announcements'),
help_text=_('Allows posting announcements in the Eventyay Video interface.'),
)
can_video_view_users = models.BooleanField(
default=False,
verbose_name=_('Video: Can view users'),
help_text=_('Allows access to the user directory in Eventyay Video.'),
)
can_video_manage_users = models.BooleanField(
default=False,
verbose_name=_('Video: Can message, ban, and silence users'),
help_text=_('Allows moderating users (ban, silence, reactivate) in Eventyay Video.'),
)
can_video_manage_rooms = models.BooleanField(
default=False,
verbose_name=_('Video: Can create and edit rooms'),
help_text=_('Allows editing and deleting rooms inside Eventyay Video.'),
)
can_video_manage_kiosks = models.BooleanField(
default=False,
verbose_name=_('Video: Can create and edit kiosks'),
help_text=_('Allows managing kiosk displays inside Eventyay Video.'),
)
can_video_manage_configuration = models.BooleanField(
default=False,
verbose_name=_('Video: Can edit event configuration'),
help_text=_('Allows editing the global Eventyay Video configuration.'),
)

@cached_property
def permission_set_display(self) -> set:
"""The same as :meth:`permission_set`, but with human-readable names."""
Expand Down
Loading