Skip to content

Commit 650a305

Browse files
mihowannavik
andauthored
Improve Project-User Management and Accessibility (#275)
* Initial support for project owners * Added projects<->users many-to-many relationship * Auto-assign user as project owner when the user creates a project * Exposed current user's projects via users/me/ endpoint * Allowed add users to a project through the admin page * Added tests for project owner auto-assignment & user's project * fix: made the select user field optional & hide the project' users field in project list view * fix: show projects that a user is added to * Added public query parameter to filter current user's projects for /projects endpoint * feat: update project overview to handle user projects * style: move create button to header * Renamed users field to members and made it optional * Use user_id param for project filtering * Update the frontend to filter projects by user_id * Add project creator to members if they are not already a member * Added project model manager * Removed user projects from /me endpoint * Updated /projects user_id filter to just check if the filtering is for the current logged in user * Moved the logic for adding project owner to members to Project.save * feat: include selected view as query param * fix : add project owner to members when the project is created from admin page * Squashed migrations * Deleted old migration files * feat: function for ensuring the owner is a member --------- Co-authored-by: mohamedelabbas1996 <hack1996man@gmail.com> (primary author) Co-authored-by: Anna Viklund <annamariaviklund@gmail.com>
1 parent 2d11938 commit 650a305

File tree

10 files changed

+187
-16
lines changed

10 files changed

+187
-16
lines changed

ami/main/admin.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,25 @@ class BlogPostAdmin(admin.ModelAdmin[BlogPost]):
5656
class ProjectAdmin(admin.ModelAdmin[Project]):
5757
"""Admin panel example for ``Project`` model."""
5858

59-
list_display = ("name", "priority", "active", "created_at", "updated_at")
59+
def save_related(self, request, form, formsets, change):
60+
super().save_related(request, form, formsets, change)
61+
form.instance.ensure_owner_membership()
62+
63+
list_display = ("name", "owner", "priority", "active", "created_at", "updated_at")
64+
list_filter = ("active", "owner")
65+
search_fields = ("name", "owner__username", "members__username")
66+
filter_horizontal = ("members",)
67+
68+
fieldsets = (
69+
(None, {"fields": ("name", "description", "priority", "active")}),
70+
(
71+
"Ownership & Access",
72+
{
73+
"fields": ("owner", "members"),
74+
"classes": ("wide",),
75+
},
76+
),
77+
)
6078

6179
@admin.action(description="Remove duplicate classifications from all detections")
6280
def _remove_duplicate_classifications(self, request: HttpRequest, queryset: QuerySet[Project]) -> None:

ami/main/api/serializers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,12 +269,14 @@ class Meta:
269269

270270
class ProjectSerializer(DefaultSerializer):
271271
deployments = DeploymentNestedSerializerWithLocationAndCounts(many=True, read_only=True)
272+
owner = UserNestedSerializer(read_only=True)
272273

273274
class Meta:
274275
model = Project
275276
fields = ProjectListSerializer.Meta.fields + [
276277
"deployments",
277278
"summary_data", # @TODO move to a 2nd request, it's too slow
279+
"owner",
278280
]
279281

280282

ami/main/api/views.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@
1010
from django.forms import BooleanField, CharField, IntegerField
1111
from django.utils import timezone
1212
from django_filters.rest_framework import DjangoFilterBackend
13-
from drf_spectacular.utils import extend_schema
13+
from drf_spectacular.types import OpenApiTypes
14+
from drf_spectacular.utils import OpenApiParameter, extend_schema
1415
from rest_framework import exceptions as api_exceptions
1516
from rest_framework import filters, serializers, status, viewsets
1617
from rest_framework.decorators import action
17-
from rest_framework.exceptions import NotFound
18+
from rest_framework.exceptions import NotFound, PermissionDenied
1819
from rest_framework.filters import SearchFilter
1920
from rest_framework.generics import GenericAPIView
2021
from rest_framework.request import Request
@@ -44,6 +45,7 @@
4445
SourceImageCollection,
4546
SourceImageUpload,
4647
Taxon,
48+
User,
4749
update_detection_counts,
4850
)
4951
from .serializers import (
@@ -121,6 +123,18 @@ class ProjectViewSet(DefaultViewSet):
121123
serializer_class = ProjectSerializer
122124
pagination_class = ProjectPagination
123125

126+
def get_queryset(self):
127+
qs: QuerySet = super().get_queryset()
128+
# Filter projects by `user_id`
129+
user_id = self.request.query_params.get("user_id")
130+
if user_id:
131+
user = User.objects.filter(pk=user_id).first()
132+
if not user == self.request.user:
133+
raise PermissionDenied("You can only view your projects")
134+
if user:
135+
qs = qs.filter_by_user(user)
136+
return qs
137+
124138
def get_serializer_class(self):
125139
"""
126140
Return different serializers for list and detail views.
@@ -130,6 +144,30 @@ def get_serializer_class(self):
130144
else:
131145
return ProjectSerializer
132146

147+
def perform_create(self, serializer):
148+
# Check if user is authenticated
149+
if not self.request.user or not self.request.user.is_authenticated:
150+
raise PermissionDenied("You must be authenticated to create a project.")
151+
152+
# Add current user as project owner
153+
serializer.save(owner=self.request.user)
154+
155+
@extend_schema(
156+
parameters=[
157+
OpenApiParameter(
158+
name="user_id",
159+
description=(
160+
"Filters projects to show only those associated with the specified user ID. "
161+
"If omitted, no user-specific filter is applied."
162+
),
163+
required=False,
164+
type=OpenApiTypes.INT,
165+
),
166+
]
167+
)
168+
def list(self, request, *args, **kwargs):
169+
return super().list(request, *args, **kwargs)
170+
133171

134172
class DeploymentViewSet(DefaultViewSet):
135173
"""
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Generated by Django 4.2.10 on 2025-01-23 10:37
2+
3+
from django.conf import settings
4+
from django.db import migrations, models
5+
import django.db.models.deletion
6+
7+
8+
class Migration(migrations.Migration):
9+
replaces = [
10+
("main", "0039_project_users"),
11+
("main", "0007_project_owner"),
12+
("main", "0040_merge_0007_project_owner_0039_project_users"),
13+
("main", "0041_alter_project_users"),
14+
("main", "0042_alter_project_users"),
15+
("main", "0043_rename_users_project_members"),
16+
]
17+
18+
dependencies = [
19+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
20+
("main", "0038_alter_detection_path_alter_sourceimage_event_and_more"),
21+
]
22+
23+
operations = [
24+
migrations.AddField(
25+
model_name="project",
26+
name="owner",
27+
field=models.ForeignKey(
28+
null=True,
29+
on_delete=django.db.models.deletion.SET_NULL,
30+
related_name="projects",
31+
to=settings.AUTH_USER_MODEL,
32+
),
33+
),
34+
migrations.AddField(
35+
model_name="project",
36+
name="members",
37+
field=models.ManyToManyField(blank=True, related_name="user_projects", to=settings.AUTH_USER_MODEL),
38+
),
39+
]

ami/main/models.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,28 @@ def create_default_research_site(project: "Project") -> "Site":
9595
return site
9696

9797

98+
class ProjectQuerySet(models.QuerySet):
99+
def filter_by_user(self, user):
100+
"""
101+
Filters projects to include only those where the given user is a member.
102+
"""
103+
return self.filter(members=user)
104+
105+
106+
class ProjectManager(models.Manager):
107+
def get_queryset(self) -> ProjectQuerySet:
108+
return ProjectQuerySet(self.model, using=self._db)
109+
110+
98111
@final
99112
class Project(BaseModel):
100113
""" """
101114

102115
name = models.CharField(max_length=_POST_TITLE_MAX_LENGTH)
103116
description = models.TextField()
104117
image = models.ImageField(upload_to="projects", blank=True, null=True)
105-
118+
owner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="projects")
119+
members = models.ManyToManyField(User, related_name="user_projects", blank=True)
106120
# Backreferences for type hinting
107121
deployments: models.QuerySet["Deployment"]
108122
events: models.QuerySet["Event"]
@@ -116,6 +130,12 @@ class Project(BaseModel):
116130
devices: models.QuerySet["Device"]
117131
sites: models.QuerySet["Site"]
118132
jobs: models.QuerySet["Job"]
133+
objects = ProjectManager()
134+
135+
def ensure_owner_membership(self):
136+
"""Add owner to members if they are not already a member"""
137+
if self.owner and not self.members.filter(id=self.owner.pk).exists():
138+
self.members.add(self.owner)
119139

120140
def deployments_count(self) -> int:
121141
return self.deployments.count()
@@ -150,6 +170,8 @@ def create_related_defaults(self):
150170
def save(self, *args, **kwargs):
151171
new_project = bool(self._state.adding)
152172
super().save(*args, **kwargs)
173+
# Add owner to members
174+
self.ensure_owner_membership()
153175
if new_project:
154176
logger.info(f"Created new project {self}")
155177
self.create_related_defaults()

ami/main/tests.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,3 +882,25 @@ def test_project_devices(self):
882882
exepcted_device_ids = {device.id for device in Device.objects.filter(project=project)}
883883
response_device_ids = {device.get("id") for device in response_data["results"]}
884884
self.assertEqual(response_device_ids, exepcted_device_ids)
885+
886+
887+
class TestProjectOwnerAutoAssignment(APITestCase):
888+
def setUp(self) -> None:
889+
self.user_1 = User.objects.create_user(
890+
email="testuser@insectai.org",
891+
is_staff=True,
892+
)
893+
self.user_2 = User.objects.create_user(
894+
email="testuser2@insectai.org",
895+
is_staff=True,
896+
)
897+
self.factory = APIRequestFactory()
898+
self.client.force_authenticate(user=self.user_1)
899+
return super().setUp()
900+
901+
def test_can_auto_assign_project_owner(self):
902+
project_endpoint = "/api/v2/projects/"
903+
request = {"name": "Test Project1234", "description": "Test Description"}
904+
self.client.post(project_endpoint, request)
905+
project = Project.objects.filter(name=request["name"]).first()
906+
self.assertEqual(self.user_1.id, project.owner.id)

ui/src/components/gallery/gallery.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ export const Gallery = ({
7979
/>
8080
))
8181
)}
82-
8382
{!isLoading && items.length === 0 && <EmptyState />}
8483
</div>
8584
)

ui/src/design-system/components/tabs/tabs.module.scss

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
}
77

88
.tabsList {
9-
margin-bottom: 16px;
109
padding: 2px 0;
1110

11+
&:not(:last-child) {
12+
margin-bottom: 16px;
13+
}
14+
1215
:not(:last-child) {
1316
margin-right: 4px;
1417
}

ui/src/pages/projects/projects.tsx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,33 @@ import { useProjects } from 'data-services/hooks/projects/useProjects'
22
import { PageFooter } from 'design-system/components/page-footer/page-footer'
33
import { PageHeader } from 'design-system/components/page-header/page-header'
44
import { PaginationBar } from 'design-system/components/pagination-bar/pagination-bar'
5+
import * as Tabs from 'design-system/components/tabs/tabs'
56
import { NewProjectDialog } from 'pages/project-details/new-project-dialog'
67
import { STRING, translate } from 'utils/language'
78
import { usePagination } from 'utils/usePagination'
89
import { UserPermission } from 'utils/user/types'
10+
import { useUser } from 'utils/user/userContext'
11+
import { useUserInfo } from 'utils/user/userInfoContext'
12+
import { useSelectedView } from 'utils/useSelectedView'
913
import { ProjectGallery } from './project-gallery'
10-
import styles from './projects.module.scss'
14+
15+
export const TABS = {
16+
MY_PROJECTS: 'my-projects',
17+
ALL_PROJECTS: 'all-projects',
18+
}
1119

1220
export const Projects = () => {
21+
const { user } = useUser()
22+
const { userInfo } = useUserInfo()
23+
const { selectedView: selectedTab, setSelectedView: setSelectedTab } =
24+
useSelectedView(user.loggedIn ? TABS.MY_PROJECTS : TABS.ALL_PROJECTS)
1325
const { pagination, setPage } = usePagination()
26+
const filters =
27+
user.loggedIn && selectedTab === TABS.MY_PROJECTS
28+
? [{ field: 'user_id', value: userInfo?.id }]
29+
: []
1430
const { projects, total, userPermissions, isLoading, isFetching, error } =
15-
useProjects({ pagination })
31+
useProjects({ pagination, filters })
1632
const canCreate = userPermissions?.includes(UserPermission.Create)
1733

1834
return (
@@ -23,15 +39,23 @@ export const Projects = () => {
2339
isLoading={isLoading}
2440
isFetching={isFetching}
2541
>
26-
{canCreate && <NewProjectDialog />}
42+
{user.loggedIn ? (
43+
<Tabs.Root onValueChange={setSelectedTab} value={selectedTab}>
44+
<Tabs.List>
45+
<Tabs.Trigger
46+
label={translate(STRING.TAB_ITEM_MY_PROJECTS)}
47+
value={TABS.MY_PROJECTS}
48+
/>
49+
<Tabs.Trigger
50+
label={translate(STRING.TAB_ITEM_ALL_PROJECTS)}
51+
value={TABS.ALL_PROJECTS}
52+
/>
53+
</Tabs.List>
54+
</Tabs.Root>
55+
) : null}
56+
{canCreate ? <NewProjectDialog /> : null}
2757
</PageHeader>
28-
<div className={styles.galleryContent}>
29-
<ProjectGallery
30-
error={error}
31-
isLoading={isLoading}
32-
projects={projects}
33-
/>
34-
</div>
58+
<ProjectGallery error={error} isLoading={isLoading} projects={projects} />
3559
<PageFooter>
3660
{projects?.length ? (
3761
<PaginationBar

ui/src/utils/language.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,13 @@ export enum STRING {
163163
NAV_ITEM_TAXA,
164164

165165
/* TAB_ITEM */
166+
TAB_ITEM_ALL_PROJECTS,
166167
TAB_ITEM_COLLECTIONS,
167168
TAB_ITEM_DEVICES,
168169
TAB_ITEM_FIELDS,
169170
TAB_ITEM_GALLERY,
170171
TAB_ITEM_IDENTIFICATION,
172+
TAB_ITEM_MY_PROJECTS,
171173
TAB_ITEM_PIPELINES,
172174
TAB_ITEM_SITES,
173175
TAB_ITEM_STORAGE,
@@ -411,11 +413,13 @@ const ENGLISH_STRINGS: { [key in STRING]: string } = {
411413
[STRING.NAV_ITEM_TAXA]: 'Taxa',
412414

413415
/* TAB_ITEM */
416+
[STRING.TAB_ITEM_ALL_PROJECTS]: 'All projects',
414417
[STRING.TAB_ITEM_COLLECTIONS]: 'Collections',
415418
[STRING.TAB_ITEM_DEVICES]: 'Device types',
416419
[STRING.TAB_ITEM_FIELDS]: 'Fields',
417420
[STRING.TAB_ITEM_GALLERY]: 'Gallery view',
418421
[STRING.TAB_ITEM_IDENTIFICATION]: 'Identification',
422+
[STRING.TAB_ITEM_MY_PROJECTS]: 'My projects',
419423
[STRING.TAB_ITEM_PIPELINES]: 'Pipelines',
420424
[STRING.TAB_ITEM_SITES]: 'Sites',
421425
[STRING.TAB_ITEM_STORAGE]: 'Storage',

0 commit comments

Comments
 (0)