Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
651447e
feat: added GET endpoints for project roles and project members
mohamedelabbas1996 Nov 19, 2025
a3829f8
Merge branch 'main' into feat/role-management-api
mohamedelabbas1996 Nov 20, 2025
261ba85
feat: implement update/delete project member endpoints and add permis…
mohamedelabbas1996 Nov 21, 2025
ab228f4
Merge branch 'feat/role-management-api' of https://github.com/Rolnick…
mohamedelabbas1996 Nov 21, 2025
5792b51
Merge branch 'main' into feat/role-management-api
mohamedelabbas1996 Nov 21, 2025
88fe192
feat: setup new page for team
annavik Nov 19, 2025
c763408
feat: setup team table
annavik Nov 24, 2025
b52b0b0
feat: setup manage access form
annavik Nov 24, 2025
655c905
feat: setup dialogs for removing members
annavik Nov 24, 2025
439249a
feat: prepare dialog for adding members
annavik Nov 24, 2025
65d0262
feat: add UserProjectMembership model as through model for Project.me…
mohamedelabbas1996 Dec 1, 2025
2047c5e
fix: replace ProjectMemberSerializer with UserProjectMembershipSerial…
mohamedelabbas1996 Dec 1, 2025
71c32b1
feat: add project membership permissions
mohamedelabbas1996 Dec 1, 2025
5246df3
feat: grant ProjectManager role membership management permissions
mohamedelabbas1996 Dec 1, 2025
2aa00c2
fix: remove members field from admin
mohamedelabbas1996 Dec 1, 2025
d815d41
fix: update router registration to use UserProjectMembershipViewSet
mohamedelabbas1996 Dec 1, 2025
73d6de1
Merge branch 'feat/role-management-api' of https://github.com/Rolnick…
mohamedelabbas1996 Dec 1, 2025
b516898
fix: add project to serializer validated_data so create permission ch…
mohamedelabbas1996 Dec 1, 2025
6dcab40
chore: move role management endpoints to nested routes under /projects
mohamedelabbas1996 Dec 2, 2025
ca50845
refactor: removed ProjectMemberPermissions
mohamedelabbas1996 Dec 2, 2025
c5e0ac7
fix: accept kwargs in ProjectRolesViewSet.list() for nested routes t…
mohamedelabbas1996 Dec 2, 2025
23a1eb8
feat: added view_userprojectmembership permission to Basic members
mohamedelabbas1996 Dec 5, 2025
5488580
feat: treat membership list action as retrieve in permission checks
mohamedelabbas1996 Dec 5, 2025
7cc9098
feat: add is_member to ProjectSerializer to allow frontend to conditi…
mohamedelabbas1996 Dec 5, 2025
ec69eb9
feat: added role descriptions
mohamedelabbas1996 Dec 5, 2025
881e614
refactor: remove old flat route registrations for role management
mohamedelabbas1996 Dec 5, 2025
40b7679
chore: deleted migration 80 after squashing migrations
mohamedelabbas1996 Dec 5, 2025
5055a8b
test: add API tests for project membership management
mohamedelabbas1996 Dec 5, 2025
128cb6d
refactor: use UserListSerializer instead of UserNestedSerializer with…
mohamedelabbas1996 Dec 5, 2025
300bbce
chore: tweak UI logic after API updates
annavik Dec 5, 2025
35b849e
feat: consider user permissions for team actions
annavik Dec 5, 2025
3fd9fd2
feat: prepare for sorting
annavik Dec 5, 2025
db08530
feat: present role details in dialog and tooltips
annavik Dec 5, 2025
732db1f
fix: cleanup
annavik Dec 5, 2025
399a6a1
feat: add proper form handling to add member form
annavik Dec 8, 2025
edc533a
feat: add link to Django admin
annavik Dec 8, 2025
e73a8a9
Merge branch 'main' into feat/role-management-api
annavik Dec 8, 2025
1149cb8
feat: add tooltip to "Remove member" button
annavik Dec 8, 2025
347856d
feat: avoid refetching of roles
annavik Dec 8, 2025
1672e2b
fix: make sure DOM is valid
annavik Dec 8, 2025
339b2cf
fix: improve icon button accessibility and tweak logic after CodeRabb…
annavik Dec 8, 2025
e6ce494
feat: add ordering_fields to UserProjectMembershipViewSet
mohamedelabbas1996 Dec 12, 2025
9523c9a
Merge branch 'feat/role-management-api' of https://github.com/Rolnick…
mohamedelabbas1996 Dec 12, 2025
b58dcb9
feat: add docstring to ProjectRoleSerializer
mohamedelabbas1996 Dec 12, 2025
3452c50
feat: add sortable table columns "Added at" and "Updated at"
annavik Dec 16, 2025
dd18d37
feat: add help text
annavik Dec 16, 2025
46d2938
copy: add missing dot
annavik Dec 16, 2025
bee820a
feat: add email sort field
annavik Dec 16, 2025
12e53f5
fix: validate for duplicate memberships during updates to prevent Int…
mohamedelabbas1996 Dec 16, 2025
b65ad4e
refactor: change ProjectRolesViewSet into RolesAPIView
mohamedelabbas1996 Dec 17, 2025
ee1cbfa
refactor: move roles endpoint to /users/roles
mohamedelabbas1996 Dec 17, 2025
43da559
test: update roles endpoint URL in tests.
mohamedelabbas1996 Dec 17, 2025
408794a
refactor: remove MANAGE_MEMBERS permission
mohamedelabbas1996 Dec 18, 2025
b361120
fix: prevent signal interference when assigning roles via API
mohamedelabbas1996 Dec 18, 2025
1936e37
refactor: use UserProjectMembership model directly in membership signals
mohamedelabbas1996 Dec 18, 2025
edb195f
merge: resolve conflicts with main branch
mihow Jan 20, 2026
75845e0
fix: address PR review feedback (quick fixes)
mihow Jan 20, 2026
98b7fb6
fix: add defensive guard in perform_update for role_cls validation
mihow Jan 20, 2026
2a67380
refactor: create MemberUserSerializer for email privacy
mihow Jan 20, 2026
5c81286
test: add validation error tests for membership API
mihow Jan 20, 2026
685b12f
feat: add UserProjectMembership through model for role management
mihow Jan 20, 2026
e29f108
fix: correct typo in BasicMember role description
mihow Jan 20, 2026
c7be19f
fix: prevent signal interference in membership update/delete
mihow Jan 20, 2026
4fb8d1e
fix: downgrade drf-nested-routers to 0.94.1 to resolve dependency con…
mihow Jan 20, 2026
c719c32
fix: version incompatibility
mihow Jan 20, 2026
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
29 changes: 29 additions & 0 deletions ami/base/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,32 @@ def has_permission(self, request, view):

def has_object_permission(self, request, view, obj: BaseModel):
return obj.check_permission(request.user, view.action)


class UserMembershipPermission(ObjectPermission):
"""
Custom permission for UserProjectMembershipViewSet.

The `list` action has no object to check against, so we treat it like a
`retrieve` action: we fetch the active project, create a temporary
membership object for it, and apply the same permission check. All other
actions fall back to the default ObjectPermission logic.
"""

def has_permission(self, request, view):
# Special handling for the list action: treat it like retrieve action
from ami.main.models import UserProjectMembership

if view.action == "list":
project = view.get_active_project()
if not project:
return False

# Create an unsaved membership instance with only project set
membership = UserProjectMembership(user=None, project=project)

# Check whether the requesting user would be allowed to retrieve this
return membership.check_permission(request.user, "retrieve")

# Fallback to default ObjectPermission behavior
return super().has_permission(request, view)
5 changes: 4 additions & 1 deletion ami/base/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ def get_active_project(
param = "project_id"

project_id = None
# Support nested routers: /projects/{project_pk}/members/{pk}
if kwargs and "project_pk" in kwargs:
project_id = kwargs["project_pk"]
# Extract from URL if `/projects/` is in the url path
if kwargs and "/projects/" in request.path:
elif kwargs and "/projects/" in request.path:
project_id = kwargs.get("pk")

# If not in URL, try query parameters
Expand Down
5 changes: 2 additions & 3 deletions ami/main/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,7 @@ def save_related(self, request, form, formsets, change):

list_display = ("name", "owner", "priority", "active", "created_at", "updated_at")
list_filter = ("active", "owner")
search_fields = ("name", "owner__email", "members__email")
filter_horizontal = ("members",)
search_fields = ("name", "owner__email")

inlines = [ProjectPipelineConfigInline]
autocomplete_fields = ("default_filters_include_taxa", "default_filters_exclude_taxa")
Expand Down Expand Up @@ -108,7 +107,7 @@ def save_related(self, request, form, formsets, change):
(
"Ownership & Access",
{
"fields": ("owner", "members"),
"fields": ("owner",),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note from conversation: if we wanted to allow membership editing from the admin, use an inline model with our new through model (Membership). Or a read-only list to show the members, but force editing from the React UI

"classes": ("wide",),
},
),
Expand Down
19 changes: 19 additions & 0 deletions ami/main/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ class ProjectSerializer(DefaultSerializer):
feature_flags = serializers.SerializerMethodField()
owner = UserNestedSerializer(read_only=True)
settings = ProjectSettingsSerializer(source="*", required=False)
is_member = serializers.SerializerMethodField()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand All @@ -341,6 +342,23 @@ def get_feature_flags(self, obj):
return obj.feature_flags.dict()
return {}

def get_is_member(self, obj):
"""Check if the current user is a member of this project."""
from ami.users.roles import Role

request = self.context["request"]
user = request.user

if not user or not user.is_authenticated:
return False

# Return True for superusers
if user.is_superuser:
return True

# Check if the user has any role in the project
return Role.user_has_any_role(user, obj)

class Meta:
model = Project
fields = ProjectListSerializer.Meta.fields + [
Expand All @@ -349,6 +367,7 @@ class Meta:
"owner",
"feature_flags",
"settings",
"is_member", # is the current user a member of this project
]


Expand Down
160 changes: 160 additions & 0 deletions ami/main/migrations/0080_userprojectmembership.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
from django.db import migrations, models
from django.conf import settings
import django.db.models.deletion


def forwards(apps, schema_editor):
UserProjectMembership = apps.get_model("main", "UserProjectMembership")

# Copy data from old implicit M2M table
through_table = "main_project_members"

with schema_editor.connection.cursor() as cursor:
cursor.execute(f"SELECT project_id, user_id FROM {through_table};")
rows = cursor.fetchall()

# Create new through model entries
for project_id, user_id in rows:
UserProjectMembership.objects.get_or_create(
project_id=project_id,
user_id=user_id,
)


def backwards(apps, schema_editor):
UserProjectMembership = apps.get_model("main", "UserProjectMembership")

with schema_editor.connection.cursor() as cursor:
# Recreate old table
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS main_project_members (
id serial PRIMARY KEY,
project_id integer NOT NULL,
user_id integer NOT NULL
);
"""
)
# Copy back membership data
for m in UserProjectMembership.objects.all():
cursor.execute(
"INSERT INTO main_project_members (project_id, user_id) VALUES (%s, %s)",
[m.project_id, m.user_id],
)


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("main", "0079_alter_project_options"),
]

operations = [
# 1. Create through model
migrations.CreateModel(
name="UserProjectMembership",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_memberships",
to=settings.AUTH_USER_MODEL,
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_memberships",
to="main.project",
),
),
],
options={"unique_together": {("user", "project")}},
),
# 2. Copy old M2M data to new through model
migrations.RunPython(forwards, backwards),
# 3. Drop old M2M implicit table
migrations.RunSQL(
"DROP TABLE IF EXISTS main_project_members;",
reverse_sql="DROP TABLE IF EXISTS main_project_members;",
),
# 4. Update Django's internal model state (NO DB change)
migrations.SeparateDatabaseAndState(
database_operations=[],
state_operations=[
migrations.AlterField(
model_name="project",
name="members",
field=models.ManyToManyField(
blank=True,
related_name="user_projects",
through="main.UserProjectMembership",
to=settings.AUTH_USER_MODEL,
),
)
],
),
# 5. Update Project permissions to current state
migrations.AlterModelOptions(
name="project",
options={
"ordering": ["-priority", "created_at"],
"permissions": [
("create_identification", "Can create identifications"),
("update_identification", "Can update identifications"),
("delete_identification", "Can delete identifications"),
("create_job", "Can create a job"),
("update_job", "Can update a job"),
("run_ml_job", "Can run/retry/cancel ML jobs"),
("run_populate_captures_collection_job", "Can run/retry/cancel Populate Collection jobs"),
("run_data_storage_sync_job", "Can run/retry/cancel Data Storage Sync jobs"),
("run_data_export_job", "Can run/retry/cancel Data Export jobs"),
("run_single_image_ml_job", "Can process a single capture"),
("run_post_processing_job", "Can run/retry/cancel Post-Processing jobs"),
("delete_job", "Can delete a job"),
("create_deployment", "Can create a deployment"),
("delete_deployment", "Can delete a deployment"),
("update_deployment", "Can update a deployment"),
("sync_deployment", "Can sync images to a deployment"),
("create_sourceimagecollection", "Can create a collection"),
("update_sourceimagecollection", "Can update a collection"),
("delete_sourceimagecollection", "Can delete a collection"),
("populate_sourceimagecollection", "Can populate a collection"),
("create_sourceimage", "Can create a source image"),
("update_sourceimage", "Can update a source image"),
("delete_sourceimage", "Can delete a source image"),
("star_sourceimage", "Can star a source image"),
("create_sourceimageupload", "Can create a source image upload"),
("update_sourceimageupload", "Can update a source image upload"),
("delete_sourceimageupload", "Can delete a source image upload"),
("create_s3storagesource", "Can create storage"),
("delete_s3storagesource", "Can delete storage"),
("update_s3storagesource", "Can update storage"),
("test_s3storagesource", "Can test storage connection"),
("create_site", "Can create a site"),
("delete_site", "Can delete a site"),
("update_site", "Can update a site"),
("create_device", "Can create a device"),
("delete_device", "Can delete a device"),
("update_device", "Can update a device"),
("view_userprojectmembership", "Can view project members"),
("create_userprojectmembership", "Can add a user to the project"),
(
"update_userprojectmembership",
"Can update a user's project membership and role in the project",
),
("delete_userprojectmembership", "Can remove a user from the project"),
("create_dataexport", "Can create a data export"),
("update_dataexport", "Can update a data export"),
("delete_dataexport", "Can delete a data export"),
("view_private_data", "Can view private data"),
],
},
),
]
59 changes: 57 additions & 2 deletions ami/main/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,12 @@ class Project(ProjectSettingsMixin, BaseModel):
description = models.TextField(blank=True)
image = models.ImageField(upload_to="projects", blank=True, null=True)
owner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="projects")
members = models.ManyToManyField(User, related_name="user_projects", blank=True)
members = models.ManyToManyField(
User,
through="UserProjectMembership",
related_name="user_projects",
blank=True,
)
draft = models.BooleanField(
default=False,
help_text="Indicates whether this project is in draft mode",
Expand Down Expand Up @@ -405,6 +410,11 @@ class Permissions:
CREATE_DEVICE = "create_device"
DELETE_DEVICE = "delete_device"
UPDATE_DEVICE = "update_device"
# User project membership permissions
VIEW_USER_PROJECT_MEMBERSHIP = "view_userprojectmembership"
CREATE_USER_PROJECT_MEMBERSHIP = "create_userprojectmembership"
UPDATE_USER_PROJECT_MEMBERSHIP = "update_userprojectmembership"
DELETE_USER_PROJECT_MEMBERSHIP = "delete_userprojectmembership"

# Data Export permissions
CREATE_DATA_EXPORT = "create_dataexport"
Expand All @@ -415,7 +425,6 @@ class Permissions:
VIEW_PRIVATE_DATA = "view_private_data"
DELETE_OCCURRENCES = "delete_occurrences"
IMPORT_DATA = "import_data"
MANAGE_MEMBERS = "manage_members"

class Meta:
ordering = ["-priority", "created_at"]
Expand Down Expand Up @@ -466,6 +475,11 @@ class Meta:
("create_device", "Can create a device"),
("delete_device", "Can delete a device"),
("update_device", "Can update a device"),
# User project membership permissions
("view_userprojectmembership", "Can view project members"),
("create_userprojectmembership", "Can add a user to the project"),
("update_userprojectmembership", "Can update a user's project membership and role in the project"),
("delete_userprojectmembership", "Can remove a user from the project"),
# Data Export permissions
("create_dataexport", "Can create a data export"),
("update_dataexport", "Can update a data export"),
Expand All @@ -475,6 +489,47 @@ class Meta:
]


class UserProjectMembership(BaseModel):
"""
Through model connecting User <-> Project.
This model represents membership ONLY.
Role assignment is handled separately via permission groups.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for explaining this in the docstring.

"""

user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="project_memberships",
)

project = models.ForeignKey(
"main.Project",
on_delete=models.CASCADE,
related_name="project_memberships",
)

def check_permission(self, user: AbstractUser | AnonymousUser, action: str) -> bool:
project = self.project
# Allow viewing membership details if the user has view permission on the project
if action == "retrieve":
return user.has_perm(Project.Permissions.VIEW_USER_PROJECT_MEMBERSHIP, project)
# Allow users to delete their own membership
if action == "destroy" and user == self.user:
return True
return super().check_permission(user, action)

def get_user_object_permissions(self, user) -> list[str]:
# Return delete permission if user is the same as the membership user
user_permissions = super().get_user_object_permissions(user)
if user == self.user:
if "delete" not in user_permissions:
user_permissions.append("delete")
return user_permissions

class Meta:
unique_together = ("user", "project")


@final
class Device(BaseModel):
"""
Expand Down
Loading