Skip to content

feat(projects): add projects module with kanban board and backlog#386

Merged
pchopinet merged 31 commits into
mainfrom
feat/projects-module
Jul 12, 2026
Merged

feat(projects): add projects module with kanban board and backlog#386
pchopinet merged 31 commits into
mainfrom
feat/projects-module

Conversation

@pchopinet

@pchopinet pchopinet commented Jul 12, 2026

Copy link
Copy Markdown
Member

Summary

New workspace/projects/ module: collaborative task management with shared projects, a kanban board and a hand-ordered backlog.

Note on the UI: in this first iteration the UI is primarily a way to visualize the module's data, not a polished, production-ready interface. Expect rough edges in day-to-day usage; the API and data model are the stable surface of this PR, and the UI will be refined in follow-up iterations.

Data model

  • Project (type personal/kanban, optional attached auth.Group, archived flag, no owner column) with a partial unique constraint guaranteeing one personal project per user
  • ProjectMember with admin/member roles and left_at (chat pattern); service-level guard keeps at least one active admin per project
  • TaskStatus as a per-project table with a semantic category (backlog/active/done), seeded with 4 fixed statuses at creation (schema ready for custom columns later)
  • Task (priority, due date, assignees, labels, manual position) and Label

Access control and actions

  • Centralized helpers in queries.py (user_project_ids(user, role=None), get_project_role) used by every endpoint and view
  • Declarative action registry mirroring files/actions/ (min_role, available_when_archived, personal-project rules) plus a bulk POST /api/v1/projects/actions endpoint driving the UI

API (DRF ViewSets + serializers)

  • CRUD for projects (archive/unarchive), members (invite, role change, leave), labels, read-only statuses and tasks, all nested under /api/v1/projects/<uuid>
  • Single idempotent POST .../tasks/reorder endpoint covering backlog ordering, in-column sort and cross-column kanban moves
  • Error contract: 404 for invisible projects, 403 for insufficient role or archived, 400 for business-rule violations

UI (data-visualization first)

  • /projects list with lazy-created personal project and a creation modal
  • /projects/<uuid> board + backlog tabs, native HTML5 drag and drop (pinned-folders pattern), action-gated task modal, alpine-ajax partial refreshes
  • The module is flagged preview: true in the module registry, so the dashboard shows the "Preview" badge

Tests and CI

  • 118 Python tests + 4 JS tests, module coverage 97.3%; projects added to the CI matrix with a 85% floor

Test plan

  • uv run python manage.py test workspace.projects (118 tests OK)
  • node --test "workspace/projects/tests/js/**/*.test.js" (4/4)
  • uv run coverage report --include='workspace/projects/*' --fail-under=85 (97.3%)
  • ruff format/check clean; core and dashboard suites unaffected

pchopinet and others added 26 commits July 12, 2026 11:02
When add_member encounters an active member, it now delegates to change_member_role
to apply the role change with guards. This prevents unconditionally demoting the
last active admin to member, a state that change_member_role and remove_member
already prevent for direct calls.

Separated paths:
- New member or reactivating departed member: direct assignment to role/left_at
- Active member: delegated to change_member_role with last-admin guard

Regression test: test_add_member_on_active_last_admin_cannot_demote now ensures
add_member(project, sole_admin_user, role=MEMBER) raises LastAdminError.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The registry kept _by_id to index actions by id, but action ids are not unique
across target types. Both DeleteProjectAction and DeleteTaskAction register
id="delete", and since task.py loads last, get("delete") silently returned the
task action, breaking project callers. Verified no external callers of get() or
_by_id exist. Removed dead API while keeping register(), get_available_actions(),
all(), _ensure_loaded(), _reset() working exactly as today.
…r archived guard

Route create and partial_update responses through get_serializer to include
project context. Replace malformed test with two specific tests for missing
user and invalid user ID. Add comprehensive test coverage for archived project
mutation blocking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task.status used PROTECT, which raises even when the referencing
TaskStatus rows are being deleted in the same cascade (project delete
-> cascade statuses -> protect trips on tasks). RESTRICT keeps direct
status deletion blocked while letting a same-cascade delete proceed.
MemberViewSet.destroy applied the archived-project write guard to the
self-leave path, trapping members in archived projects. The guard now
only applies when an admin removes someone else; the last-admin rule
still blocks self-leave at the service level.
LabelSerializer.validate_name was check-then-act; a concurrent insert
of the same name could still hit the database unique constraint and
surface as a 500. The viewset now catches that IntegrityError inside a
savepoint and returns a 400 with the same validation message.
saveTask had no in-flight guard and the modal's Save button was never
bound to the saving flag, so a double click (or a slow network) could
fire two create requests and produce duplicate tasks.
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4eced253-d474-42dc-a5b8-7f7ee24ff5ef

📥 Commits

Reviewing files that changed from the base of the PR and between ed39567 and 029a058.

📒 Files selected for processing (8)
  • workspace/projects/serializers.py
  • workspace/projects/services/members.py
  • workspace/projects/services/tasks.py
  • workspace/projects/tests/js/board.test.js
  • workspace/projects/ui/static/projects/ui/js/board.js
  • workspace/projects/views_actions.py
  • workspace/projects/viewsets.py
  • workspace/settings.py
🚧 Files skipped from review as they are similar to previous changes (6)
  • workspace/settings.py
  • workspace/projects/services/members.py
  • workspace/projects/views_actions.py
  • workspace/projects/serializers.py
  • workspace/projects/viewsets.py
  • workspace/projects/services/tasks.py

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added Projects with personal and collaborative project spaces.
    • Added Kanban boards, backlogs, task creation, editing, filtering, drag-and-drop reordering, labels, priorities, due dates, and assignees.
    • Added project member management, role-based permissions, group access, and project archiving.
    • Added project and task actions tailored to user permissions.
    • Added API support for managing projects, tasks, members, labels, statuses, and reordering.
  • Bug Fixes

    • Improved safeguards around archived projects and preserving at least one project administrator.

Walkthrough

Changes

Adds a complete projects and tasks feature with Django models, migrations, access rules, services, REST APIs, action permissions, board UI, routing, and automated tests. CI now enforces 85% coverage for the projects module.

Projects and tasksworkspace/projects/models.py, workspace/projects/migrations/*, workspace/projects/services/*
Defines projects, memberships, statuses, labels, tasks, creation workflows, membership guards, status transitions, and task reordering.

API and permissionsworkspace/projects/viewsets.py, workspace/projects/views_actions.py, workspace/projects/serializers.py
Adds CRUD endpoints, archive controls, filtering, bulk action availability, validation, and role-based access enforcement.

Board UIworkspace/projects/ui/*, workspace/projects/ui/static/projects/ui/js/board.js
Adds project listing, Kanban board/backlog rendering, task modal interactions, drag-and-drop reordering, and task actions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant BoardUI
  participant ActionsAPI
  participant ProjectsAPI
  participant Database
  User->>BoardUI: Open project or task
  BoardUI->>ActionsAPI: Request available actions
  ActionsAPI->>Database: Resolve project, task, and role
  Database-->>ActionsAPI: Matching records and access role
  ActionsAPI-->>BoardUI: Action descriptors
  BoardUI->>ProjectsAPI: Create, update, delete, or reorder task
  ProjectsAPI->>Database: Persist task changes
  Database-->>ProjectsAPI: Updated state
  ProjectsAPI-->>BoardUI: API response
  BoardUI->>ProjectsAPI: Refresh board and backlog
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 6.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding the projects module with kanban board and backlog UI.
Description check ✅ Passed The description is clearly related to the changeset and matches the new projects module, API, UI, tests, and CI updates.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread workspace/projects/viewsets.py Fixed
Comment thread workspace/projects/viewsets.py Fixed
Comment thread workspace/projects/viewsets.py Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (4)
workspace/projects/tests/test_queries.py (1)

12-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate setUp fixture across two test classes.

UserProjectIdsTests.setUp (Lines 13-27) and GetProjectRoleTests.setUp (Lines 66-80) build the identical admin/member/outsider/project/membership fixture. Consider extracting a shared local mixin/helper for this file to avoid the duplication.

♻️ Proposed refactor
+class _QueriesFixtureMixin:
+    def setUp(self):
+        self.admin = User.objects.create_user(
+            username="admin1", email="admin1@test.com", password="pass123"
+        )
+        self.member = User.objects.create_user(
+            username="member1", email="member1@test.com", password="pass123"
+        )
+        self.outsider = User.objects.create_user(
+            username="outsider1", email="outsider1@test.com", password="pass123"
+        )
+        self.project = Project.objects.create(name="Website", created_by=self.admin)
+        ProjectMember.objects.create(
+            project=self.project, user=self.admin, role=ProjectMember.Role.ADMIN
+        )
+        ProjectMember.objects.create(project=self.project, user=self.member)
+
+
-class UserProjectIdsTests(TestCase):
-    def setUp(self):
-        self.admin = User.objects.create_user(
-            username="admin1", email="admin1@test.com", password="pass123"
-        )
-        self.member = User.objects.create_user(
-            username="member1", email="member1@test.com", password="pass123"
-        )
-        self.outsider = User.objects.create_user(
-            username="outsider1", email="outsider1@test.com", password="pass123"
-        )
-        self.project = Project.objects.create(name="Website", created_by=self.admin)
-        ProjectMember.objects.create(
-            project=self.project, user=self.admin, role=ProjectMember.Role.ADMIN
-        )
-        ProjectMember.objects.create(project=self.project, user=self.member)
+class UserProjectIdsTests(_QueriesFixtureMixin, TestCase):

Apply the analogous change to GetProjectRoleTests.

Also applies to: 65-80

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workspace/projects/tests/test_queries.py` around lines 12 - 27, Extract the
identical fixture setup from UserProjectIdsTests.setUp and
GetProjectRoleTests.setUp into a shared local mixin or helper in this test
module. Have both test classes reuse it while preserving the existing admin,
member, outsider, project, and membership attributes and setup behavior.
workspace/projects/ui/static/projects/ui/js/board.js (1)

4-9: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Test the drag path, not just moveUuid moveUuid is only exercised through window.projectBoardHelpers; the production reorder flow in onDrop uses direct DOM moves plus listOrder and never calls it. Add coverage for onDrop/reorder behavior, or drop the helper export if it is only for unit tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workspace/projects/ui/static/projects/ui/js/board.js` around lines 4 - 9, The
current coverage targets moveUuid through window.projectBoardHelpers but does
not validate the production reorder flow in onDrop, which directly updates the
DOM and listOrder. Add tests that exercise onDrop and verify the resulting
order, or remove the projectBoardHelpers export if moveUuid is only retained for
unit tests.
workspace/projects/serializers.py (1)

147-154: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

N+1 query pattern in validate_assignees.

Each assignee triggers a separate get_project_role call (a membership query, plus a groups query on fallback). For tasks with several assignees this is avoidable with a single batched membership/group lookup.

♻️ Suggested batched validation
     def validate_assignees(self, users):
         project = self.context["project"]
-        for user in users:
-            if get_project_role(user, project) is None:
-                raise serializers.ValidationError(
-                    f"{user.username} is not a member of this project."
-                )
+        member_ids = set(
+            ProjectMember.objects.filter(
+                project=project, user__in=users, left_at__isnull=True
+            ).values_list("user_id", flat=True)
+        )
+        group_member_ids = set()
+        if project.group_id is not None:
+            group_member_ids = {
+                u.pk for u in users if u.groups.filter(pk=project.group_id).exists()
+            }
+        for user in users:
+            if user.pk not in member_ids and user.pk not in group_member_ids:
+                raise serializers.ValidationError(
+                    f"{user.username} is not a member of this project."
+                )
         return users
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workspace/projects/serializers.py` around lines 147 - 154, Update
validate_assignees to replace the per-user get_project_role calls with one
batched membership/group lookup for all assignees in the project. Validate each
user against the batched result and preserve the existing
serializers.ValidationError message for non-members.
workspace/projects/services/tasks.py (1)

14-50: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Wrap create_task multi-write operations in transaction.atomic()

Task.objects.create(), task.assignees.set(), task.labels.set(), and the conditional task.save() are separate DB writes. If any M2M operation fails after the task is created, the task persists in a partial state (e.g., created but missing assignees or labels). The reorder_tasks function already uses transaction.atomic() for consistency, so this function should follow the same pattern.

♻️ Proposed fix
 def create_task(
   project,
   user,
   *,
   title,
   description="",
   status=None,
   priority=Task.Priority.MEDIUM,
   due_date=None,
   assignees=(),
   labels=(),
 ):
   """Create a task; defaults to the end of the project's backlog column."""
   if status is None:
     status = (
       project.statuses.filter(category=TaskStatus.Category.BACKLOG)
       .order_by("position", "created_at")
       .first()
     ) or project.statuses.order_by("position", "created_at").first()
-  task = Task.objects.create(
-    project=project,
-    title=title,
-    description=description,
-    status=status,
-    priority=priority,
-    due_date=due_date,
-    created_by=user,
-    position=next_position(project, status),
-  )
-  if assignees:
-    task.assignees.set(assignees)
-  if labels:
-    task.labels.set(labels)
-  if status.category == TaskStatus.Category.DONE:
-    task.completed_at = timezone.now()
-    task.save(update_fields=["completed_at"])
-  return task
+  with transaction.atomic():
+    task = Task.objects.create(
+      project=project,
+      title=title,
+      description=description,
+      status=status,
+      priority=priority,
+      due_date=due_date,
+      created_by=user,
+      position=next_position(project, status),
+    )
+    if assignees:
+      task.assignees.set(assignees)
+    if labels:
+      task.labels.set(labels)
+    if status.category == TaskStatus.Category.DONE:
+      task.completed_at = timezone.now()
+      task.save(update_fields=["completed_at"])
+    return task
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workspace/projects/services/tasks.py` around lines 14 - 50, Wrap the full
multi-write body of create_task—including Task.objects.create(), optional
assignees.set(), labels.set(), and the conditional completed_at save—in
transaction.atomic(). Keep status selection and the existing task field values
unchanged, and ensure any failure rolls back all writes together.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@workspace/projects/services/members.py`:
- Around line 14-19: Serialize admin-affecting operations per project to
eliminate the TOCTOU race. Update change_member_role and remove_member to lock
the project row (or otherwise lock the full active-admin set) before checking
_other_active_admins(member).exists() and saving, ensuring concurrent role
changes or removals on the same project queue and always preserve at least one
active admin.

In `@workspace/projects/services/tasks.py`:
- Around line 77-83: Update the transaction logic around the in_status and
listed queries to use one select_for_update() query filtered by Q(status=status)
| Q(uuid__in=ordered_uuids), ensuring all relevant tasks are locked in a single
pass. Preserve the existing status-based and UUID-based collections needed by
the reorder logic, deriving them from the combined locked result rather than
issuing two locking queries.

In `@workspace/projects/ui/static/projects/ui/js/board.js`:
- Around line 147-167: Wrap the fetch-driven bodies of openTask, saveTask, and
deleteTask in try/catch blocks, matching the existing boundary-handling pattern
used by saveOrder, sendToBoard, and fetchActions. Preserve their current success
and generation-check behavior, and route network or other request failures into
the existing formError feedback path instead of allowing unhandled rejections;
retain any necessary finally cleanup in saveTask.

---

Nitpick comments:
In `@workspace/projects/serializers.py`:
- Around line 147-154: Update validate_assignees to replace the per-user
get_project_role calls with one batched membership/group lookup for all
assignees in the project. Validate each user against the batched result and
preserve the existing serializers.ValidationError message for non-members.

In `@workspace/projects/services/tasks.py`:
- Around line 14-50: Wrap the full multi-write body of create_task—including
Task.objects.create(), optional assignees.set(), labels.set(), and the
conditional completed_at save—in transaction.atomic(). Keep status selection and
the existing task field values unchanged, and ensure any failure rolls back all
writes together.

In `@workspace/projects/tests/test_queries.py`:
- Around line 12-27: Extract the identical fixture setup from
UserProjectIdsTests.setUp and GetProjectRoleTests.setUp into a shared local
mixin or helper in this test module. Have both test classes reuse it while
preserving the existing admin, member, outsider, project, and membership
attributes and setup behavior.

In `@workspace/projects/ui/static/projects/ui/js/board.js`:
- Around line 4-9: The current coverage targets moveUuid through
window.projectBoardHelpers but does not validate the production reorder flow in
onDrop, which directly updates the DOM and listOrder. Add tests that exercise
onDrop and verify the resulting order, or remove the projectBoardHelpers export
if moveUuid is only retained for unit tests.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e14d61e5-ae52-46df-8289-3f264da608f7

📥 Commits

Reviewing files that changed from the base of the PR and between f2fc675 and ed39567.

📒 Files selected for processing (51)
  • .github/workflows/tests.yml
  • workspace/common/static/css/app.css
  • workspace/projects/__init__.py
  • workspace/projects/actions/__init__.py
  • workspace/projects/actions/base.py
  • workspace/projects/actions/project.py
  • workspace/projects/actions/task.py
  • workspace/projects/apps.py
  • workspace/projects/migrations/0001_initial.py
  • workspace/projects/migrations/0002_alter_task_status.py
  • workspace/projects/migrations/__init__.py
  • workspace/projects/models.py
  • workspace/projects/queries.py
  • workspace/projects/serializers.py
  • workspace/projects/services/__init__.py
  • workspace/projects/services/members.py
  • workspace/projects/services/projects.py
  • workspace/projects/services/tasks.py
  • workspace/projects/tests/__init__.py
  • workspace/projects/tests/base.py
  • workspace/projects/tests/js/board.test.js
  • workspace/projects/tests/test_actions.py
  • workspace/projects/tests/test_api_actions.py
  • workspace/projects/tests/test_api_labels.py
  • workspace/projects/tests/test_api_members.py
  • workspace/projects/tests/test_api_projects.py
  • workspace/projects/tests/test_api_reorder.py
  • workspace/projects/tests/test_api_tasks.py
  • workspace/projects/tests/test_apps.py
  • workspace/projects/tests/test_models.py
  • workspace/projects/tests/test_queries.py
  • workspace/projects/tests/test_services.py
  • workspace/projects/tests/test_services_tasks.py
  • workspace/projects/tests/test_ui.py
  • workspace/projects/ui/__init__.py
  • workspace/projects/ui/apps.py
  • workspace/projects/ui/static/projects/ui/js/board.js
  • workspace/projects/ui/templates/projects/ui/index.html
  • workspace/projects/ui/templates/projects/ui/partials/backlog.html
  • workspace/projects/ui/templates/projects/ui/partials/board.html
  • workspace/projects/ui/templates/projects/ui/partials/project_list.html
  • workspace/projects/ui/templates/projects/ui/partials/task_card.html
  • workspace/projects/ui/templates/projects/ui/partials/task_modal.html
  • workspace/projects/ui/templates/projects/ui/project.html
  • workspace/projects/ui/urls.py
  • workspace/projects/ui/views.py
  • workspace/projects/urls.py
  • workspace/projects/views_actions.py
  • workspace/projects/viewsets.py
  • workspace/settings.py
  • workspace/urls.py

Comment thread workspace/projects/services/members.py Outdated
Comment thread workspace/projects/services/tasks.py Outdated
Comment thread workspace/projects/ui/static/projects/ui/js/board.js
@pchopinet pchopinet merged commit e29a072 into main Jul 12, 2026
33 checks passed
@pchopinet pchopinet deleted the feat/projects-module branch July 12, 2026 14:35
pchopinet added a commit that referenced this pull request Jul 12, 2026
…tion (#387)

## Summary

Follow-ups logged during the projects module final review (#386):

- **Membership removal**: `remove_member` is now a no-op on
already-departed rows, preserving the original departure timestamp
instead of overwriting it on replay.
- **Reorder timestamps**: `reorder_tasks` stamps `updated_at` on moved
tasks; `bulk_update` bypasses `save()` so `auto_now` left the timestamp
stale.
- **Task deletion**: deleting a task from the board modal now asks for
confirmation through `AppDialog.confirm`, matching the convention used
by every other module.
- **`members_data` rename**: key `uuid` -> `id`; the value is the stock
User integer pk, not a UUID.
- **Pinning tests**: departed member with group access keeps `member`
role and project visibility (group access is independent of membership
rows, files precedent).

Two follow-ups from the review list needed no action: the
`ProjectRuleError` -> 400 helper already exists (`_rule_error_response`)
and `moveUuid` was already removed.

## Test plan

- [x] Each behavioral fix has a regression test verified to fail against
the pre-fix code (including the JS confirm tests, checked via
stash/restore)
- [x] `uv run python manage.py test workspace.projects`: 124/124 OK
- [x] `node --test "workspace/*/tests/js/**/*.test.js"`: 186/186 OK
- [x] ruff format + check clean
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants