-
Notifications
You must be signed in to change notification settings - Fork 11
Role Management API and UI #801
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
651447e
a3829f8
261ba85
ab228f4
5792b51
88fe192
c763408
b52b0b0
655c905
439249a
65d0262
2047c5e
71c32b1
5246df3
2aa00c2
d815d41
73d6de1
b516898
6dcab40
ca50845
c5e0ac7
23a1eb8
5488580
7cc9098
ec69eb9
881e614
40b7679
5055a8b
128cb6d
300bbce
35b849e
3fd9fd2
db08530
732db1f
399a6a1
edc533a
e73a8a9
1149cb8
347856d
1672e2b
339b2cf
e6ce494
9523c9a
b58dcb9
3452c50
dd18d37
46d2938
bee820a
12e53f5
b65ad4e
ee1cbfa
43da559
408794a
b361120
1936e37
edb195f
75845e0
98b7fb6
2a67380
5c81286
685b12f
e29f108
c7be19f
4fb8d1e
c719c32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
|
@@ -108,7 +107,7 @@ def save_related(self, request, form, formsets, change): | |
| ( | ||
| "Ownership & Access", | ||
| { | ||
| "fields": ("owner", "members"), | ||
| "fields": ("owner",), | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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",), | ||
| }, | ||
| ), | ||
|
|
||
| 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"), | ||
| ], | ||
| }, | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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", | ||
|
|
@@ -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" | ||
|
|
@@ -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"] | ||
|
|
@@ -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"), | ||
|
|
@@ -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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
| """ | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.