Yesterday, the database was completed; all models defined, all migrations applied, all test data seeded. Today, I built the Projects API on top of that foundation. Serializers, a ViewSet with custom permissions, search, and filter, and a dedicated endpoint for a user's own projects. By the end of today, every project-related endpoint was tested and working in Postman.
The Approach: ViewSet Over Individual Views
For the project's API, there were two options: write individual function-based or class-based views for each endpoint, or use a ModelViewSet that handles all CRUD operations in one class.
I went with ModelViewSet. The reason is straightforward: all six standard operations on a project: list, create, retrieve, update, partial update, delete; follow the same pattern. ViewSet handles that pattern automatically, and the router generates all the URL patterns from a single registration line. The only code I needed to write was the parts that deviate from the default: custom permissions, custom queryset filtering, and a non-standard mine action.
This is exactly the DRF design philosophy: write the parts that are unique to your app, let the framework handle the boilerplate.
Serializers — More Than Just Field Mapping
The ProjectSerializer does more than map model fields to JSON. Three things make it interesting.
First, the tech_stack_list and roles_list fields. The database stores these as comma-separated strings for simplicity. The API should return them as proper arrays; that's what any frontend or API consumer expects. SerializerMethodField handles this: a method on the serializer calls the model's helper method and returns the result as a list. The raw string field is also included in the serializer for write operations. When creating or updating a project, the frontend sends a comma-separated string, and when reading, it gets back both the raw string and the parsed list.
Second, the nested owner data. The project detail response includes the owner's profile information: username, avatar, skills, GitHub URL, not just the owner's ID. This means a single request to /api/projects/1/ gives the frontend everything it needs to render the project detail page without a second request to fetch the owner's profile. SerializerMethodField handles this too, using the UserSerializer from the accounts app to serialize the owner.
Third, the request_status field for authenticated users. When a logged-in user views a project, the serializer checks whether that user has already sent a collaboration request for it and includes the status: pending, accepted, rejected, or null if no request exists. The frontend uses this to decide what to show in the apply button area. This field requires access to the current request object, which is passed through the serializer context from the view.
Permissions — The IsOwner Class
Django REST Framework's built-in permission classes handle authentication; IsAuthenticated confirms a user is logged in. But DevCollab needs a second layer: confirming that the logged-in user is the owner of the specific object they're trying to modify.
The IsOwner custom permission class handles this. It implements has_object_permission: a method DRF calls automatically for single-object operations like retrieve, update, and delete. It checks whether request.user matches obj.owner. If they match, the operation proceeds. If they don't, DRF returns a 403 Forbidden response.
The has_permission method handles request-level permission, before DRF even fetches the object. Safe methods like GET and HEAD always pass. Write methods require authentication.
This two-level approach, request-level and object-level, means unauthenticated users get a 401, authenticated non-owners get a 403, and owners get through. Each error code communicates exactly what the problem is.
The ViewSet — Bringing the Pieces Together
The ProjectViewSet is where everything connects. Four customized behaviors on top of what ModelViewSet provides automatically.
get_queryset: the default queryset returns all projects. DevCollab's version adds filtering. If a search query parameter is present, it filters across title, description, and tech stack using Q objects with OR logic and icontains for case-insensitive matching. If a tech_stack parameter is present, it filters projects where the tech stack contains that value. If a role parameter is present, it filters on the roles needed. Multiple filters stack; a request with both search and tech_stack gets both applied simultaneously. The base queryset only includes projects where is_open is True for the public list; the mine action gets all of the user's projects regardless of open status.
get_permissions: different actions need different permissions. List and retrieve are public; no authentication needed, anyone can browse projects. Create requires authentication; you must be logged in to post a project. Update, partial update, and delete require both authentication and ownership; you must be logged in, and you must own the project. The mine action requires authentication only. get_permissions returns the right combination of permission classes based on self.action.
perform_create: When a project is created, the owner is set to request.user automatically. The owner field is not in the serializer's writable fields — it's always set in the view, never accepted from the request body. This prevents one user from creating a project and attributing it to another user.
The mine action: a custom action decorated with @action(detail=False, methods=['get']) that returns only the projects owned by the current user. It bypasses the is_open filter, so owners see all their projects, including closed ones. The URL is /api/projects/mine/ and it requires authentication.
Search and Filter in Practice
The search and filter system deserves its own explanation because it's the feature users will interact with most on the browse page.
A request to /api/projects/?search=django returns projects where "django" appears anywhere in the title, description, or tech stack, case insensitive. A request to /api/projects/?tech_stack=react returns projects where React is listed in the tech stack. Both can be combined: /api/projects/?search=portfolio&tech_stack=nextjs returns projects about portfolios that use Next.js.
The icontains lookup is important here; it uses SQL LIKE under the hood, which means partial matches work. Searching for "django" matches "Django REST Framework" in the tech stack. This is the behavior users expect from a search box.
Q objects handle the OR logic for keyword search; the same keyword is checked against multiple fields simultaneously, and a project is included if the keyword appears in any of them. Without Q objects, a filter would require the keyword to appear in all fields at once, which is never what you want for search.
The Router
The router is one of DRF's most satisfying features. Registering ProjectViewSet with the router generates all of these URL patterns automatically:
The list and create endpoint on the collection URL, the retrieve, update, partial update, and delete endpoints on the detail URL with the project ID, and the custom mine action on its own URL. Six standard endpoints and one custom one, all from a single router.register() call.
The router is included in the main project URLs under the /api/ prefix, so all project endpoints live under /api/projects/.
Testing in Postman
Every endpoint was tested in Postman before calling it done today. The test sequence matters; some endpoints depend on data created by others.
First, register a user and copy the access token. Then create a project with that token. Then, retrieve the project list without a token to confirm it's public. Then try to update the project without a token and confirm you get a 401. Then try to update it with a different user's token and confirm you get a 403. Then update it with the owner's token and confirm it works. Then hit the mine endpoint and confirm only your projects appear. Then search for the project by title and confirm it appears in the results. Then filter by tech stack and confirm the filter works.
Testing the failure cases: 401, 403, 404, is as important as testing the success cases. The permission system is only trustworthy if you've verified it rejects what it should reject.
Where Things Stand After Day 89
The project's API is complete and tested. Every endpoint works, permissions are enforced correctly, search and filter return the right results, and the mine endpoint gives owners a view of all their projects.
Tomorrow: the collaboration requests API: sending requests, listing them for a project, accepting and rejecting, and viewing your own sent requests. That completes the entire backend.
Thanks for reading. Feel free to share your thoughts!
Top comments (0)