Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 35 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ cd scripts/frontend && npm run build:vault && npm run build:vault-onboarding

## Module Map

Each Django app under `workspace/` follows the same shape (`models.py`, `views.py`, `services/`, `tests/`, `ui/`, `urls.py`):
Each Django app under `workspace/` follows the same shape (`models.py`, `views.py` or `views/`, `services/`, `tests/`, `ui/`, `urls.py`):

| Module | Purpose |
|---|---|
Expand Down Expand Up @@ -290,6 +290,39 @@ Examples in the codebase: `files/services/{files,mime,thumbnails,sharing,events}

`@patch('workspace.<module>.services.<name>.symbol')` patches the symbol at its **definition site**. Patch there, not at a re-export alias - patches at an alias site bind a different name and the actual call site keeps running unmocked.

### Views - one file while it fits, a `views/` package once it doesn't

A module starts with a flat `views.py`. The moment it needs a second view module, it becomes a
`views/` **package** with an empty `__init__.py` - never a sprawl of `views_<topic>.py` siblings at
the module root. `chat`, `mail`, `files`, `core`, `projects` and `calendar` are already there;
`vault` still has the flat pair and will convert when it grows a third.

```
workspace/<module>/
├── views/
│ ├── __init__.py # empty - DO NOT re-export
│ ├── <topic1>.py
│ └── <topic2>.py
└── urls.py
```

- File names are the topic alone, never prefixed: `chat/views/messages.py`, not
`chat/views/views_messages.py` and not `chat/views_messages.py`.
- The module's original `views.py` gets a name too, after what it actually holds
(`chat/views/conversations.py`, `mail/views/accounts.py`, `files/views/files.py`). A file called
`views/views.py` is never the answer.
- `urls.py` imports the submodules, not the package: `from .views import messages, pins`.
- Moving a view one directory deeper changes every relative import inside it - `from .models import X`
becomes `from ..models import X`, including the lazy ones inside function bodies. Cross-app imports
that were `from ..common.x import y` become absolute `from workspace.common.x import y` rather than
growing a third dot.
- Sibling views import each other by their real module (`from .conversations import _trigger_bot_response`).
`from ..views import ...` would resolve to the empty `__init__.py` and fail.
- The same applies to `@patch` targets: `workspace.chat.views.conversations._trigger_bot_response`,
never `workspace.chat.views._trigger_bot_response`. The latter raises `AttributeError` against the
empty package, so a stale patch string fails loudly rather than silently - but only if a test
exercises it.

### Re-exports - ask before adding

Re-exporting a symbol (via `__all__`, a top-level `from .x import y` whose only purpose is to surface `y` from a different module, or any other indirection that lets a caller `from workspace.A import X` when `X` is actually defined in `workspace.B`) creates a "where is this defined?" maze. It also breaks `@patch` at the call site (see *Test patches* above) and makes refactors that move the definition silently leak the old path.
Expand Down Expand Up @@ -540,7 +573,7 @@ with source.content.open('rb') as f:
- Always pin this with a regression test that asserts `dest.content.name != source.content.name` after the copy AND that the bytes round-trip. Don't rely on a "content equality" check alone - the buggy version with a shared blob also passes a content check (it's the same blob).
- Wrap the open + save in `try/except (FileNotFoundError, OSError)` whenever copying user-uploaded content. A vanished blob otherwise surfaces as a bare 500 with no breadcrumbs. Mirror the response code of the closest read endpoint (404 for chat / mail attachment paths) and log the path through `scrub()` before re-raising or returning.
- `ContentFile(source.read(), ...)` happens to be _committed=False so it copies correctly, but it buffers the entire file in memory before re-emitting it. For anything that could grow (>1MB), prefer the `DjangoFile(open_stream, ...)` idiom.
- Existing precedent in the codebase: `workspace/files/webdav/resources.py:_copy_as` (already correct), `workspace/chat/views_attachments.py:AttachmentSaveToFilesView`, `workspace/mail/views.py:MailAttachmentSaveToFilesView`, `workspace/files/services/_storage_ops.py:copy_node`.
- Existing precedent in the codebase: `workspace/files/webdav/resources.py:_copy_as` (already correct), `workspace/chat/views/attachments.py:AttachmentSaveToFilesView`, `workspace/mail/views/attachments.py:MailAttachmentSaveToFilesView`, `workspace/files/services/_storage_ops.py:copy_node`.

### Prefer the standard library over hand-rolled collection plumbing

Expand Down
4 changes: 2 additions & 2 deletions workspace/calendar/tests/test_external_calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -600,7 +600,7 @@ def test_unauthenticated_rejected(self):
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, http_status.HTTP_403_FORBIDDEN)

@patch("workspace.calendar.views_external.sync_external_calendar_task")
@patch("workspace.calendar.views.external.sync_external_calendar_task")
def test_create_external_calendar(self, mock_task):
mock_task.delay = MagicMock()

Expand Down Expand Up @@ -668,7 +668,7 @@ def test_cannot_access_others_external_calendar(self):
resp = self.client.put(f"{self.url}/{ext.uuid}", {"name": "Hacked"})
self.assertEqual(resp.status_code, http_status.HTTP_404_NOT_FOUND)

@patch("workspace.calendar.views_external.sync_external_calendar_task")
@patch("workspace.calendar.views.external.sync_external_calendar_task")
def test_manual_sync(self, mock_task):
mock_task.delay = MagicMock()
cal = Calendar.objects.create(name="SyncMe", owner=self.user)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Polls UI: list, create, edit, detail (voting grid), finalize, share,
// and invitee management. Backend already lives in calendar/views_polls.py.
// and invitee management. Backend already lives in calendar/views/polls.py.
// Calls into pollUtils helpers from polls.js for vote-class/icon and
// slot date/time formatting (kept in polls.js because they're shared with
// the standalone /polls/<token> guest-vote page).
Expand Down
32 changes: 16 additions & 16 deletions workspace/calendar/urls.py
Original file line number Diff line number Diff line change
@@ -1,75 +1,75 @@
from django.urls import path

from . import views, views_external, views_polls
from .views import calendars, events, external, polls

urlpatterns = [
path(
"api/v1/calendars",
views.CalendarListView.as_view(),
calendars.CalendarListView.as_view(),
name="calendar-list",
),
path(
"api/v1/calendars/<uuid:calendar_id>",
views.CalendarDetailView.as_view(),
calendars.CalendarDetailView.as_view(),
name="calendar-detail",
),
path("api/v1/events", views.EventListView.as_view(), name="calendar-events"),
path("api/v1/events", events.EventListView.as_view(), name="calendar-events"),
path(
"api/v1/events/<uuid:event_id>",
views.EventDetailView.as_view(),
events.EventDetailView.as_view(),
name="calendar-event-detail",
),
path(
"api/v1/events/<uuid:event_id>/respond",
views.EventRespondView.as_view(),
events.EventRespondView.as_view(),
name="calendar-event-respond",
),
# Polls
path("api/v1/polls", views_polls.PollListView.as_view(), name="poll-list"),
path("api/v1/polls", polls.PollListView.as_view(), name="poll-list"),
path(
"api/v1/polls/shared/<str:token>",
views_polls.SharedPollView.as_view(),
polls.SharedPollView.as_view(),
name="poll-shared",
),
path(
"api/v1/polls/shared/<str:token>/vote",
views_polls.SharedPollVoteView.as_view(),
polls.SharedPollVoteView.as_view(),
name="poll-shared-vote",
),
path(
"api/v1/polls/<uuid:poll_id>",
views_polls.PollDetailView.as_view(),
polls.PollDetailView.as_view(),
name="poll-detail",
),
path(
"api/v1/polls/<uuid:poll_id>/vote",
views_polls.PollVoteView.as_view(),
polls.PollVoteView.as_view(),
name="poll-vote",
),
path(
"api/v1/polls/<uuid:poll_id>/invite",
views_polls.PollInviteView.as_view(),
polls.PollInviteView.as_view(),
name="poll-invite",
),
path(
"api/v1/polls/<uuid:poll_id>/finalize",
views_polls.PollFinalizeView.as_view(),
polls.PollFinalizeView.as_view(),
name="poll-finalize",
),
# External calendars
path(
"api/v1/external-calendars",
views_external.ExternalCalendarListView.as_view(),
external.ExternalCalendarListView.as_view(),
name="external-calendar-list",
),
path(
"api/v1/external-calendars/<uuid:ext_id>",
views_external.ExternalCalendarDetailView.as_view(),
external.ExternalCalendarDetailView.as_view(),
name="external-calendar-detail",
),
path(
"api/v1/external-calendars/<uuid:ext_id>/sync",
views_external.ExternalCalendarSyncView.as_view(),
external.ExternalCalendarSyncView.as_view(),
name="external-calendar-sync",
),
]
Empty file.
74 changes: 74 additions & 0 deletions workspace/calendar/views/calendars.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from workspace.common.cache import cached_response, invalidate
from workspace.common.mixins import CacheControlMixin

from ..models import Calendar
from ..queries import visible_calendars
from ..serializers import CalendarCreateSerializer, CalendarSerializer


@extend_schema(tags=["Calendar"])
class CalendarListView(CacheControlMixin, APIView):
permission_classes = [IsAuthenticated]
cache_max_age = 300

@extend_schema(summary="List user's calendars (owned + subscribed)")
@cached_response(300)
def get(self, request):
owned, subscribed = visible_calendars(request.user)

return Response(
{
"owned": CalendarSerializer(owned, many=True).data,
"subscribed": CalendarSerializer(subscribed, many=True).data,
}
)

@extend_schema(summary="Create a calendar", request=CalendarCreateSerializer)
def post(self, request):
ser = CalendarCreateSerializer(data=request.data)
ser.is_valid(raise_exception=True)
cal = Calendar.objects.create(owner=request.user, **ser.validated_data)
cal = Calendar.objects.select_related("owner").get(pk=cal.pk)
invalidate("CalendarListView", user=request.user)
return Response(CalendarSerializer(cal).data, status=status.HTTP_201_CREATED)


@extend_schema(tags=["Calendar"])
class CalendarDetailView(APIView):
permission_classes = [IsAuthenticated]

@extend_schema(summary="Update a calendar", request=CalendarCreateSerializer)
def put(self, request, calendar_id):
try:
cal = Calendar.objects.get(pk=calendar_id, owner=request.user)
except Calendar.DoesNotExist:
return Response(
{"detail": "Calendar not found."}, status=status.HTTP_404_NOT_FOUND
)

ser = CalendarCreateSerializer(data=request.data)
ser.is_valid(raise_exception=True)
for k, v in ser.validated_data.items():
setattr(cal, k, v)
cal.save()
cal = Calendar.objects.select_related("owner").get(pk=cal.pk)
invalidate("CalendarListView", user=request.user)
return Response(CalendarSerializer(cal).data)

@extend_schema(summary="Delete a calendar")
def delete(self, request, calendar_id):
try:
cal = Calendar.objects.get(pk=calendar_id, owner=request.user)
except Calendar.DoesNotExist:
return Response(
{"detail": "Calendar not found."}, status=status.HTTP_404_NOT_FOUND
)
cal.delete()
invalidate("CalendarListView", user=request.user)
return Response(status=status.HTTP_204_NO_CONTENT)
87 changes: 8 additions & 79 deletions workspace/calendar/views.py → workspace/calendar/views/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,34 +11,31 @@
from rest_framework.response import Response
from rest_framework.views import APIView

from workspace.common.cache import cached_response, invalidate
from workspace.common.mixins import CacheControlMixin
from workspace.notifications.services.notifications import (
mark_source_read,
mark_sources_read,
notify_many,
)

from .models import Calendar, Event, EventMember
from .queries import member_event_ids, visible_calendar_ids, visible_calendars
from .recurrence import expand_recurring_events, make_virtual_occurrence
from .serializers import (
CalendarCreateSerializer,
CalendarSerializer,
from ..models import Calendar, Event, EventMember
from ..queries import member_event_ids, visible_calendar_ids
from ..recurrence import expand_recurring_events, make_virtual_occurrence
from ..serializers import (
EventCreateSerializer,
EventRespondSerializer,
EventSerializer,
EventUpdateSerializer,
)
from .services.event_scope import (
from ..services.event_scope import (
EventScopeError,
cancel_event,
is_external_calendar,
update_event,
)
from .services.invitations import NotInvitedError, respond_to_invitation
from .services.timezones import current_timezone_name
from .upcoming import get_upcoming_page
from ..services.invitations import NotInvitedError, respond_to_invitation
from ..services.timezones import current_timezone_name
from ..upcoming import get_upcoming_page


def _mark_displayed_events_read(user, event_pks):
Expand Down Expand Up @@ -103,74 +100,6 @@ def _prefetch_event(qs):
)


# ---------- Calendar CRUD ----------


@extend_schema(tags=["Calendar"])
class CalendarListView(CacheControlMixin, APIView):
permission_classes = [IsAuthenticated]
cache_max_age = 300

@extend_schema(summary="List user's calendars (owned + subscribed)")
@cached_response(300)
def get(self, request):
owned, subscribed = visible_calendars(request.user)

return Response(
{
"owned": CalendarSerializer(owned, many=True).data,
"subscribed": CalendarSerializer(subscribed, many=True).data,
}
)

@extend_schema(summary="Create a calendar", request=CalendarCreateSerializer)
def post(self, request):
ser = CalendarCreateSerializer(data=request.data)
ser.is_valid(raise_exception=True)
cal = Calendar.objects.create(owner=request.user, **ser.validated_data)
cal = Calendar.objects.select_related("owner").get(pk=cal.pk)
invalidate("CalendarListView", user=request.user)
return Response(CalendarSerializer(cal).data, status=status.HTTP_201_CREATED)


@extend_schema(tags=["Calendar"])
class CalendarDetailView(APIView):
permission_classes = [IsAuthenticated]

@extend_schema(summary="Update a calendar", request=CalendarCreateSerializer)
def put(self, request, calendar_id):
try:
cal = Calendar.objects.get(pk=calendar_id, owner=request.user)
except Calendar.DoesNotExist:
return Response(
{"detail": "Calendar not found."}, status=status.HTTP_404_NOT_FOUND
)

ser = CalendarCreateSerializer(data=request.data)
ser.is_valid(raise_exception=True)
for k, v in ser.validated_data.items():
setattr(cal, k, v)
cal.save()
cal = Calendar.objects.select_related("owner").get(pk=cal.pk)
invalidate("CalendarListView", user=request.user)
return Response(CalendarSerializer(cal).data)

@extend_schema(summary="Delete a calendar")
def delete(self, request, calendar_id):
try:
cal = Calendar.objects.get(pk=calendar_id, owner=request.user)
except Calendar.DoesNotExist:
return Response(
{"detail": "Calendar not found."}, status=status.HTTP_404_NOT_FOUND
)
cal.delete()
invalidate("CalendarListView", user=request.user)
return Response(status=status.HTTP_204_NO_CONTENT)


# ---------- Event CRUD ----------


@extend_schema(tags=["Calendar"])
class EventListView(CacheControlMixin, APIView):
permission_classes = [IsAuthenticated]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
from rest_framework.response import Response
from rest_framework.views import APIView

from .models import Calendar
from .models_external import ExternalCalendar
from .serializers_external import (
from ..models import Calendar
from ..models_external import ExternalCalendar
from ..serializers_external import (
ExternalCalendarCreateSerializer,
ExternalCalendarSerializer,
ExternalCalendarUpdateSerializer,
)
from .tasks import sync_external_calendar_task
from ..tasks import sync_external_calendar_task

logger = logging.getLogger(__name__)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
)
from workspace.users.services.settings import get_setting

from .models import Calendar, Event, EventMember, Poll, PollInvitee, PollSlot, PollVote
from .serializers_polls import (
from ..models import Calendar, Event, EventMember, Poll, PollInvitee, PollSlot, PollVote
from ..serializers_polls import (
GuestVoteSubmitSerializer,
PollCreateSerializer,
PollFinalizeSerializer,
Expand Down
Loading