Skip to content

Commit dd34ecd

Browse files
authored
Merge pull request #116 from arenaxr/namespace
feat: add namespace permissions model
2 parents c5ecedf + c67e4de commit dd34ecd

14 files changed

+833
-179
lines changed

arena_account/settings.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@
4444
"aframe",
4545
"apriltag",
4646
"ar",
47-
"orchestrator", # proxy
4847
"arts",
4948
"audio",
5049
"auth", # proxy
5150
"build",
5251
"chat",
5352
"conf",
53+
"dashboard", # proxy
5454
"dist",
5555
"face-tracking",
5656
"files",
@@ -65,7 +65,9 @@
6565
"mqtt2", # proxy
6666
"network",
6767
"node_modules",
68+
"orchestrator", # proxy
6869
"persist", # proxy
70+
"programs", # proxy
6971
"public", # public namespace (tentative)
7072
"pythonrt", # proxy
7173
"runtime-mngr",

users/admin.py

+13-3
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
from django.contrib import admin
22

3-
from .models import Device, Scene
3+
from .models import Device, Namespace, Scene
4+
5+
6+
class NamespaceAdmin(admin.ModelAdmin):
7+
list_display = ["name", "is_default"]
8+
list_filter = ["editors", "viewers"]
9+
search_fields = ["name"]
10+
autocomplete_fields = ["owners", "editors", "viewers"]
411

512

613
class SceneAdmin(admin.ModelAdmin):
7-
list_display = ["name", "public_read", "public_write", "anonymous_users", "video_conference", "users"]
8-
autocomplete_fields = ["editors"]
14+
list_display = ["name", "is_default", "public_read", "public_write", "anonymous_users", "video_conference", "users"]
15+
list_filter = ["public_read", "public_write", "anonymous_users", "video_conference", "users", "editors", "viewers"]
16+
search_fields = ["name"]
17+
autocomplete_fields = ["owners", "editors", "viewers"]
918

1019

20+
admin.site.register(Namespace, NamespaceAdmin)
1121
admin.site.register(Scene, SceneAdmin)
1222
admin.site.register(Device)

users/forms.py

+64-5
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from django.conf import settings
55
from django.contrib.auth.models import User
66

7-
from .models import Device, Scene
7+
from .models import Device, Namespace, Scene
8+
from .mqtt import TOPIC_SUPPORTED_API_VERSIONS, all_scenes_read_token
9+
from .persistence import get_persist_scenes_ns
810

911

1012
class SocialSignupForm(_SocialSignupForm):
@@ -20,16 +22,31 @@ def clean(self):
2022
username = cleaned_data.get("username")
2123
# reject usernames in form on signup: settings.USERNAME_RESERVED
2224
if username in settings.USERNAME_RESERVED:
23-
msg = f"Sorry, {username} is a reserved word for usernames."
25+
msg = f"Sorry, '{username}' is a reserved word."
2426
self.add_error("username", msg)
25-
27+
# reject usernames in form on signup: Namespace exists in permissions
28+
elif Namespace.objects.filter(name=username).exists():
29+
msg = f"Sorry, '{username}' is a permissions namespace."
30+
self.add_error("username", msg)
31+
# reject usernames in form on signup: Namespace used in persist db
32+
else:
33+
version = TOPIC_SUPPORTED_API_VERSIONS[0] # TODO (mwfarb): resolve missing request.version
34+
token = all_scenes_read_token(version)
35+
if len(get_persist_scenes_ns(token, username)) > 0:
36+
msg = f"Sorry, '{username}' is a persistence namespace."
37+
self.add_error("username", msg)
2638

2739
class UpdateStaffForm(forms.Form):
2840
staff_username = forms.CharField(label="staff_username", required=True)
2941
is_staff = forms.BooleanField(
3042
label="is_staff", required=False, initial=False)
3143

3244

45+
class UpdateNamespaceForm(forms.Form):
46+
add = forms.CharField(label="add", required=False)
47+
edit = forms.CharField(label="edit", required=False)
48+
49+
3350
class UpdateSceneForm(forms.Form):
3451
add = forms.CharField(label="add", required=False)
3552
edit = forms.CharField(label="edit", required=False)
@@ -40,14 +57,35 @@ class UpdateDeviceForm(forms.Form):
4057
edit = forms.CharField(label="edit", required=False)
4158

4259

43-
class SceneForm(forms.ModelForm):
60+
class NamespaceForm(forms.ModelForm):
61+
owners = forms.ModelMultipleChoiceField(
62+
queryset=User.objects.all().order_by('username'),
63+
widget=autocomplete.ModelSelect2Multiple(
64+
url='users:user-autocomplete',
65+
forward=(forward.Self(), ),
66+
attrs={'data-minimum-input-length': 2},
67+
), required=False)
4468
editors = forms.ModelMultipleChoiceField(
4569
queryset=User.objects.all().order_by('username'),
4670
widget=autocomplete.ModelSelect2Multiple(
4771
url='users:user-autocomplete',
4872
forward=(forward.Self(), ),
4973
attrs={'data-minimum-input-length': 2},
5074
), required=False)
75+
viewers = forms.ModelMultipleChoiceField(
76+
queryset=User.objects.all().order_by('username'),
77+
widget=autocomplete.ModelSelect2Multiple(
78+
url='users:user-autocomplete',
79+
forward=(forward.Self(), ),
80+
attrs={'data-minimum-input-length': 2},
81+
), required=False)
82+
83+
class Meta:
84+
model = Namespace
85+
fields = ("owners", "editors", "viewers")
86+
87+
88+
class SceneForm(forms.ModelForm):
5189
public_read = forms.BooleanField(
5290
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), required=False)
5391
public_write = forms.BooleanField(
@@ -58,11 +96,32 @@ class SceneForm(forms.ModelForm):
5896
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), required=False)
5997
users = forms.BooleanField(
6098
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}), required=False)
99+
owners = forms.ModelMultipleChoiceField(
100+
queryset=User.objects.all().order_by('username'),
101+
widget=autocomplete.ModelSelect2Multiple(
102+
url='users:user-autocomplete',
103+
forward=(forward.Self(), ),
104+
attrs={'data-minimum-input-length': 2},
105+
), required=False)
106+
editors = forms.ModelMultipleChoiceField(
107+
queryset=User.objects.all().order_by('username'),
108+
widget=autocomplete.ModelSelect2Multiple(
109+
url='users:user-autocomplete',
110+
forward=(forward.Self(), ),
111+
attrs={'data-minimum-input-length': 2},
112+
), required=False)
113+
viewers = forms.ModelMultipleChoiceField(
114+
queryset=User.objects.all().order_by('username'),
115+
widget=autocomplete.ModelSelect2Multiple(
116+
url='users:user-autocomplete',
117+
forward=(forward.Self(), ),
118+
attrs={'data-minimum-input-length': 2},
119+
), required=False)
61120

62121
class Meta:
63122
model = Scene
64123
fields = ("public_read", "public_write",
65-
"anonymous_users", "video_conference", "users", "editors")
124+
"anonymous_users", "video_conference", "users", "owners", "editors", "viewers")
66125

67126

68127
class DeviceForm(forms.ModelForm):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Generated by Django 3.2.25 on 2025-01-09 19:16
2+
3+
from django.conf import settings
4+
import django.core.validators
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
12+
('users', '0007_scene_users'),
13+
]
14+
15+
operations = [
16+
migrations.AddField(
17+
model_name='scene',
18+
name='owners',
19+
field=models.ManyToManyField(blank=True, related_name='scene_owners', to=settings.AUTH_USER_MODEL),
20+
),
21+
migrations.AddField(
22+
model_name='scene',
23+
name='viewers',
24+
field=models.ManyToManyField(blank=True, related_name='scene_viewers', to=settings.AUTH_USER_MODEL),
25+
),
26+
migrations.AlterField(
27+
model_name='device',
28+
name='name',
29+
field=models.CharField(max_length=200, unique=True, validators=[django.core.validators.RegexValidator('^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$', 'Only alphanumeric, underscore, hyphen, in namespace/idname format allowed.')]),
30+
),
31+
migrations.AlterField(
32+
model_name='scene',
33+
name='editors',
34+
field=models.ManyToManyField(blank=True, related_name='scene_editors', to=settings.AUTH_USER_MODEL),
35+
),
36+
migrations.AlterField(
37+
model_name='scene',
38+
name='name',
39+
field=models.CharField(max_length=200, unique=True, validators=[django.core.validators.RegexValidator('^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$', 'Only alphanumeric, underscore, hyphen, in namespace/idname format allowed.')]),
40+
),
41+
migrations.CreateModel(
42+
name='Namespace',
43+
fields=[
44+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
45+
('name', models.CharField(max_length=100, unique=True, validators=[django.core.validators.RegexValidator('^[a-zA-Z0-9_-]*$', 'Only alphanumeric, underscore, hyphen allowed.')])),
46+
('editors', models.ManyToManyField(blank=True, related_name='namespace_editors', to=settings.AUTH_USER_MODEL)),
47+
('owners', models.ManyToManyField(blank=True, related_name='namespace_owners', to=settings.AUTH_USER_MODEL)),
48+
('viewers', models.ManyToManyField(blank=True, related_name='namespace_viewers', to=settings.AUTH_USER_MODEL)),
49+
],
50+
),
51+
]

users/models.py

+88-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.contrib.auth.models import User
22
from django.core.exceptions import ValidationError
3+
from django.core.validators import RegexValidator
34
from django.db import models
45
from django.urls import reverse
56

@@ -10,32 +11,86 @@
1011
SCENE_VIDEO_CONF_DEF = True
1112
SCENE_USERS_DEF = True
1213

14+
RE_NS = r"^[a-zA-Z0-9_-]*$"
15+
RE_NS_SLASH_ID = r"^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$"
16+
17+
ns_regex = RegexValidator(RE_NS, "Only alphanumeric, underscore, hyphen allowed.")
18+
ns_slash_id_regex = RegexValidator(
19+
RE_NS_SLASH_ID, "Only alphanumeric, underscore, hyphen, in namespace/idname format allowed."
20+
)
21+
22+
23+
class NamespaceDefault:
24+
def __init__(self, name=""):
25+
self.name = name
26+
self.owners = []
27+
self.editors = []
28+
self.viewers = []
29+
self.is_default = True
30+
31+
32+
class Namespace(models.Model):
33+
"""Model representing a namespace's permissions."""
34+
35+
name = models.CharField(max_length=100, blank=False, unique=True, validators=[ns_regex])
36+
owners = models.ManyToManyField(User, blank=True, related_name="namespace_owners")
37+
editors = models.ManyToManyField(User, blank=True, related_name="namespace_editors")
38+
viewers = models.ManyToManyField(User, blank=True, related_name="namespace_viewers")
39+
40+
def save(self, *args, **kwargs):
41+
self.full_clean() # performs regular validation then clean()
42+
super(Namespace, self).save(*args, **kwargs)
43+
44+
def clean(self):
45+
if self.name == "":
46+
raise ValidationError("Empty namespace name!")
47+
self.name = self.name.strip()
48+
49+
def __str__(self):
50+
"""String for representing the namespace object by name."""
51+
return self.name
52+
53+
@property
54+
def is_default(self):
55+
return self.editors.count() == 0 and self.viewers.count() == 0
56+
57+
58+
class SceneDefault:
59+
def __init__(self, name=""):
60+
self.name = name
61+
self.owners = []
62+
self.editors = []
63+
self.viewers = []
64+
self.public_read = SCENE_PUBLIC_READ_DEF
65+
self.public_write = SCENE_PUBLIC_WRITE_DEF
66+
self.anonymous_users = SCENE_ANON_USERS_DEF
67+
self.video_conference = SCENE_VIDEO_CONF_DEF
68+
self.users = SCENE_USERS_DEF
69+
self.is_default = True
70+
1371

1472
class Scene(models.Model):
15-
"""Model representing a scene's permissions."""
73+
"""Model representing a namespace/scene's permissions."""
1674

17-
name = models.CharField(max_length=200, blank=False, unique=True)
75+
name = models.CharField(max_length=200, blank=False, unique=True, validators=[ns_slash_id_regex])
1876
summary = models.TextField(max_length=1000, blank=True)
19-
editors = models.ManyToManyField(User, blank=True)
77+
owners = models.ManyToManyField(User, blank=True, related_name="scene_owners")
78+
editors = models.ManyToManyField(User, blank=True, related_name="scene_editors")
79+
viewers = models.ManyToManyField(User, blank=True, related_name="scene_viewers")
2080
creation_date = models.DateTimeField(auto_now_add=True)
21-
public_read = models.BooleanField(
22-
default=SCENE_PUBLIC_READ_DEF, blank=True)
23-
public_write = models.BooleanField(
24-
default=SCENE_PUBLIC_WRITE_DEF, blank=True)
25-
anonymous_users = models.BooleanField(
26-
default=SCENE_ANON_USERS_DEF, blank=True)
27-
video_conference = models.BooleanField(
28-
default=SCENE_VIDEO_CONF_DEF, blank=True)
29-
users = models.BooleanField(
30-
default=SCENE_USERS_DEF, blank=True)
81+
public_read = models.BooleanField(default=SCENE_PUBLIC_READ_DEF, blank=True)
82+
public_write = models.BooleanField(default=SCENE_PUBLIC_WRITE_DEF, blank=True)
83+
anonymous_users = models.BooleanField(default=SCENE_ANON_USERS_DEF, blank=True)
84+
video_conference = models.BooleanField(default=SCENE_VIDEO_CONF_DEF, blank=True)
85+
users = models.BooleanField(default=SCENE_USERS_DEF, blank=True)
3186

3287
def save(self, *args, **kwargs):
3388
self.full_clean() # performs regular validation then clean()
3489
super(Scene, self).save(*args, **kwargs)
3590

3691
def clean(self):
3792
if self.name == "":
38-
raise ValidationError("Empty scene name!")
93+
raise ValidationError("Empty namespace/scene name!")
3994
self.name = self.name.strip()
4095

4196
def __str__(self):
@@ -54,11 +109,23 @@ def namespace(self):
54109
def sceneid(self):
55110
return self.name.split("/")[1]
56111

112+
@property
113+
def is_default(self):
114+
return (
115+
self.public_read is SCENE_PUBLIC_READ_DEF
116+
and self.public_write is SCENE_PUBLIC_WRITE_DEF
117+
and self.anonymous_users is SCENE_ANON_USERS_DEF
118+
and self.video_conference is SCENE_VIDEO_CONF_DEF
119+
and self.users is SCENE_USERS_DEF
120+
and self.editors.count() == 0
121+
and self.viewers.count() == 0
122+
)
123+
57124

58125
class Device(models.Model):
59-
"""Model representing a device's permissions."""
126+
"""Model representing a namespace/device's permissions."""
60127

61-
name = models.CharField(max_length=200, blank=False, unique=True)
128+
name = models.CharField(max_length=200, blank=False, unique=True, validators=[ns_slash_id_regex])
62129
summary = models.TextField(max_length=1000, blank=True)
63130
creation_date = models.DateTimeField(auto_now_add=True)
64131

@@ -68,7 +135,7 @@ def save(self, *args, **kwargs):
68135

69136
def clean(self):
70137
if self.name == "":
71-
raise ValidationError("Empty device name!")
138+
raise ValidationError("Empty namespace/device name!")
72139
self.name = self.name.strip()
73140

74141
def __str__(self):
@@ -78,3 +145,7 @@ def __str__(self):
78145
@property
79146
def namespace(self):
80147
return self.name.split("/")[0]
148+
149+
@property
150+
def deviceid(self):
151+
return self.name.split("/")[1]

users/mqtt.py

+7
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
SCENE_PUBLIC_WRITE_DEF,
1313
SCENE_USERS_DEF,
1414
SCENE_VIDEO_CONF_DEF,
15+
Namespace,
1516
Scene,
1617
)
1718
from .mqtt_match import topic_matches_sub
@@ -290,6 +291,12 @@ def set_scene_perms_api_v2(
290291
# scene owners have rights to their scene objects only
291292
topicv2_add_scene_reader(pubs, subs, realm, username, "+", ids)
292293
topicv2_add_scene_writer(pubs, subs, realm, username, "+", ids)
294+
# add namespaces that have been granted by other owners
295+
u_namespaces = Namespace.objects.filter(editors=user)
296+
for u_namespace in u_namespaces:
297+
if not sceneid or u_namespace.name == f"{namespace}":
298+
topicv2_add_scene_reader(pubs, subs, realm, u_namespace.name, "+", ids)
299+
topicv2_add_scene_writer(pubs, subs, realm, u_namespace.name, "+", ids)
293300
# add scenes that have been granted by other owners
294301
u_scenes = Scene.objects.filter(editors=user)
295302
for u_scene in u_scenes:

0 commit comments

Comments
 (0)