diff --git a/CLAUDE.md b/CLAUDE.md index 5ee53c7df..da4d7cb87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | |---|---| @@ -290,6 +290,39 @@ Examples in the codebase: `files/services/{files,mime,thumbnails,sharing,events} `@patch('workspace..services..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_.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// +├── views/ +│ ├── __init__.py # empty - DO NOT re-export +│ ├── .py +│ └── .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. @@ -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 diff --git a/workspace/calendar/tests/test_external_calendar.py b/workspace/calendar/tests/test_external_calendar.py index 0e2d80718..9cfeeb94d 100644 --- a/workspace/calendar/tests/test_external_calendar.py +++ b/workspace/calendar/tests/test_external_calendar.py @@ -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() @@ -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) diff --git a/workspace/calendar/ui/static/calendar/ui/js/calendar_polls.js b/workspace/calendar/ui/static/calendar/ui/js/calendar_polls.js index dace6f361..43d7330d8 100644 --- a/workspace/calendar/ui/static/calendar/ui/js/calendar_polls.js +++ b/workspace/calendar/ui/static/calendar/ui/js/calendar_polls.js @@ -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/ guest-vote page). diff --git a/workspace/calendar/urls.py b/workspace/calendar/urls.py index 96152c518..525b89982 100644 --- a/workspace/calendar/urls.py +++ b/workspace/calendar/urls.py @@ -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/", - 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/", - views.EventDetailView.as_view(), + events.EventDetailView.as_view(), name="calendar-event-detail", ), path( "api/v1/events//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/", - views_polls.SharedPollView.as_view(), + polls.SharedPollView.as_view(), name="poll-shared", ), path( "api/v1/polls/shared//vote", - views_polls.SharedPollVoteView.as_view(), + polls.SharedPollVoteView.as_view(), name="poll-shared-vote", ), path( "api/v1/polls/", - views_polls.PollDetailView.as_view(), + polls.PollDetailView.as_view(), name="poll-detail", ), path( "api/v1/polls//vote", - views_polls.PollVoteView.as_view(), + polls.PollVoteView.as_view(), name="poll-vote", ), path( "api/v1/polls//invite", - views_polls.PollInviteView.as_view(), + polls.PollInviteView.as_view(), name="poll-invite", ), path( "api/v1/polls//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/", - views_external.ExternalCalendarDetailView.as_view(), + external.ExternalCalendarDetailView.as_view(), name="external-calendar-detail", ), path( "api/v1/external-calendars//sync", - views_external.ExternalCalendarSyncView.as_view(), + external.ExternalCalendarSyncView.as_view(), name="external-calendar-sync", ), ] diff --git a/workspace/calendar/views/__init__.py b/workspace/calendar/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/workspace/calendar/views/calendars.py b/workspace/calendar/views/calendars.py new file mode 100644 index 000000000..3f9f6ce27 --- /dev/null +++ b/workspace/calendar/views/calendars.py @@ -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) diff --git a/workspace/calendar/views.py b/workspace/calendar/views/events.py similarity index 83% rename from workspace/calendar/views.py rename to workspace/calendar/views/events.py index cede0aa80..8cf6d0920 100644 --- a/workspace/calendar/views.py +++ b/workspace/calendar/views/events.py @@ -11,7 +11,6 @@ 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, @@ -19,26 +18,24 @@ 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): @@ -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] diff --git a/workspace/calendar/views_external.py b/workspace/calendar/views/external.py similarity index 96% rename from workspace/calendar/views_external.py rename to workspace/calendar/views/external.py index 2bdbc3c2a..a6e3b8233 100644 --- a/workspace/calendar/views_external.py +++ b/workspace/calendar/views/external.py @@ -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__) diff --git a/workspace/calendar/views_polls.py b/workspace/calendar/views/polls.py similarity index 99% rename from workspace/calendar/views_polls.py rename to workspace/calendar/views/polls.py index 23ec50400..68673ddc1 100644 --- a/workspace/calendar/views_polls.py +++ b/workspace/calendar/views/polls.py @@ -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, diff --git a/workspace/chat/tests/test_views_interactions.py b/workspace/chat/tests/test_interactions.py similarity index 95% rename from workspace/chat/tests/test_views_interactions.py rename to workspace/chat/tests/test_interactions.py index e64015f3c..5d6fb5abe 100644 --- a/workspace/chat/tests/test_views_interactions.py +++ b/workspace/chat/tests/test_interactions.py @@ -92,7 +92,7 @@ def test_400_when_option_index_out_of_range(self): resp = self.client.post(self.url, {"option_index": -1}, format="json") self.assertEqual(resp.status_code, 400) - @patch("workspace.chat.views_interactions._trigger_bot_response") + @patch("workspace.chat.views.interactions._trigger_bot_response") def test_201_nominal_creates_answer_and_locks(self, mock_trigger): resp = self.client.post(self.url, {"option_index": 0}, format="json") self.assertEqual(resp.status_code, 201) @@ -106,7 +106,7 @@ def test_201_nominal_creates_answer_and_locks(self, mock_trigger): self.assertEqual(answer.reply_to, self.msg) mock_trigger.assert_called_once() - @patch("workspace.chat.views_interactions._trigger_bot_response") + @patch("workspace.chat.views.interactions._trigger_bot_response") def test_409_when_already_answered_by_other(self, mock_trigger): self.client.post(self.url, {"option_index": 0}, format="json") bob = User.objects.create_user( @@ -119,7 +119,7 @@ def test_409_when_already_answered_by_other(self, mock_trigger): resp = self.client.post(self.url, {"option_index": 1}, format="json") self.assertEqual(resp.status_code, 409) - @patch("workspace.chat.views_interactions._trigger_bot_response") + @patch("workspace.chat.views.interactions._trigger_bot_response") def test_200_idempotent_when_same_user_same_choice(self, mock_trigger): first = self.client.post(self.url, {"option_index": 0}, format="json") self.assertEqual(first.status_code, 201) @@ -127,7 +127,7 @@ def test_200_idempotent_when_same_user_same_choice(self, mock_trigger): self.assertEqual(second.status_code, 200) mock_trigger.assert_called_once() - @patch("workspace.chat.views_interactions._trigger_bot_response") + @patch("workspace.chat.views.interactions._trigger_bot_response") def test_409_when_same_user_different_choice(self, mock_trigger): self.client.post(self.url, {"option_index": 0}, format="json") resp = self.client.post(self.url, {"option_index": 1}, format="json") @@ -166,7 +166,7 @@ def setUp(self): ConversationMember.objects.create(conversation=self.conv, user=self.bot) self.msg, self.interaction = _make_question(self.bot, self.conv) - @patch("workspace.chat.views_interactions._trigger_bot_response") + @patch("workspace.chat.views.interactions._trigger_bot_response") def test_concurrent_clicks_one_wins(self, mock_trigger): url = f"/api/v1/chat/messages/{self.msg.uuid}/answer" results = [] diff --git a/workspace/chat/tests/test_message_posting.py b/workspace/chat/tests/test_message_posting.py index 49d40f2f3..26afeda46 100644 --- a/workspace/chat/tests/test_message_posting.py +++ b/workspace/chat/tests/test_message_posting.py @@ -84,11 +84,11 @@ def setUp(self): def _send(self, body="hello"): # The bot member would otherwise answer eagerly (Celery runs inline in # tests), fail on the missing API key, and post an error message whose - # own side effects would land in these assertions. views_messages + # own side effects would land in these assertions. views/messages.py # imports the trigger lazily, so the definition site is the one to - # patch; views_interactions binds it at import time and needs its own. + # patch; views/interactions.py binds it at import time and needs its own. with self.captureOnCommitCallbacks(execute=True): - with patch("workspace.chat.views._trigger_bot_response"): + with patch("workspace.chat.views.conversations._trigger_bot_response"): return self.client.post(self.url, {"body": body}, format="json") def test_increments_unread_for_others_and_not_the_author(self, _push, _sse): @@ -132,7 +132,7 @@ def setUp(self): def _send(self): with self.captureOnCommitCallbacks(execute=True): - with patch("workspace.chat.views._trigger_bot_response"): + with patch("workspace.chat.views.conversations._trigger_bot_response"): return self.client.post(self.url, {"body": "hello"}, format="json") @patch( @@ -239,7 +239,7 @@ def setUp(self): def _answer(self): with self.captureOnCommitCallbacks(execute=True): - with patch("workspace.chat.views_interactions._trigger_bot_response"): + with patch("workspace.chat.views.interactions._trigger_bot_response"): return self.client.post(self.url, {"option_index": 0}, format="json") def test_posts_the_answer_as_a_message(self, _push, _sse): diff --git a/workspace/chat/urls.py b/workspace/chat/urls.py index 84b720eee..733d5402e 100644 --- a/workspace/chat/urls.py +++ b/workspace/chat/urls.py @@ -1,238 +1,238 @@ from django.urls import path -from . import ( - views, - views_attachments, - views_avatar, - views_bots, - views_calls, - views_goals, - views_interactions, - views_messages, - views_pins, - views_scheduled, - views_search, - views_threads, - views_typing, +from .views import ( + attachments, + avatar, + bots, + calls, + conversations, + goals, + interactions, + messages, + pins, + scheduled, + search, + threads, + typing, ) urlpatterns = [ # Conversations path( "api/v1/chat/conversations", - views.ConversationListView.as_view(), + conversations.ConversationListView.as_view(), name="chat-conversations", ), path( "api/v1/chat/conversations/", - views.ConversationDetailView.as_view(), + conversations.ConversationDetailView.as_view(), name="chat-conversation-detail", ), # Threads path( "api/v1/chat/threads//read", - views_threads.ThreadReadView.as_view(), + threads.ThreadReadView.as_view(), name="chat-thread-read", ), # Members path( "api/v1/chat/conversations//members", - views.ConversationMembersView.as_view(), + conversations.ConversationMembersView.as_view(), name="chat-conversation-members", ), path( "api/v1/chat/conversations//members/", - views.ConversationMemberRemoveView.as_view(), + conversations.ConversationMemberRemoveView.as_view(), name="chat-conversation-member-remove", ), # Per-member notification level path( "api/v1/chat/conversations//notification-level", - views.ConversationNotificationLevelView.as_view(), + conversations.ConversationNotificationLevelView.as_view(), name="chat-conversation-notification-level", ), # Messages path( "api/v1/chat/conversations//messages/search", - views_search.ConversationMessageSearchView.as_view(), + search.ConversationMessageSearchView.as_view(), name="chat-message-search", ), path( "api/v1/chat/conversations//messages", - views_messages.MessageListView.as_view(), + messages.MessageListView.as_view(), name="chat-messages", ), path( "api/v1/chat/conversations//messages/", - views_messages.MessageDetailView.as_view(), + messages.MessageDetailView.as_view(), name="chat-message-detail", ), path( "api/v1/chat/conversations//messages//readers", - views_messages.MessageReadersView.as_view(), + messages.MessageReadersView.as_view(), name="chat-message-readers", ), # Reactions path( "api/v1/chat/messages//reactions", - views_messages.ReactionToggleView.as_view(), + messages.ReactionToggleView.as_view(), name="chat-reaction-toggle", ), # Stats path( "api/v1/chat/conversations//stats", - views_search.ConversationStatsView.as_view(), + search.ConversationStatsView.as_view(), name="chat-conversation-stats", ), # Media gallery path( "api/v1/chat/conversations//media", - views_search.ConversationMediaView.as_view(), + search.ConversationMediaView.as_view(), name="chat-conversation-media", ), # Read / Unread path( "api/v1/chat/conversations//read", - views_messages.MarkReadView.as_view(), + messages.MarkReadView.as_view(), name="chat-mark-read", ), path( "api/v1/chat/unread-counts", - views_typing.UnreadCountsView.as_view(), + typing.UnreadCountsView.as_view(), name="chat-unread-counts", ), # Typing indicator path( "api/v1/chat/conversations//typing", - views_typing.TypingIndicatorView.as_view(), + typing.TypingIndicatorView.as_view(), name="chat-typing", ), # Calls # Call connection diagnostic (user-scoped, no conversation) path( "api/v1/chat/call/diagnostic/signal", - views_calls.CallDiagnosticSignalView.as_view(), + calls.CallDiagnosticSignalView.as_view(), name="chat-call-diagnostic-signal", ), path( "api/v1/chat/conversations//call", - views_calls.CallStateView.as_view(), + calls.CallStateView.as_view(), name="chat-call-state", ), path( "api/v1/chat/conversations//call/join", - views_calls.CallJoinView.as_view(), + calls.CallJoinView.as_view(), name="chat-call-join", ), path( "api/v1/chat/conversations//call/leave", - views_calls.CallLeaveView.as_view(), + calls.CallLeaveView.as_view(), name="chat-call-leave", ), path( "api/v1/chat/conversations//call/signal", - views_calls.CallSignalView.as_view(), + calls.CallSignalView.as_view(), name="chat-call-signal", ), path( "api/v1/chat/conversations//call/heartbeat", - views_calls.CallHeartbeatView.as_view(), + calls.CallHeartbeatView.as_view(), name="chat-call-heartbeat", ), # Group avatars path( "api/v1/chat/conversations//avatar", - views_avatar.GroupAvatarUploadView.as_view(), + avatar.GroupAvatarUploadView.as_view(), name="chat-group-avatar-upload", ), path( "api/v1/chat/conversations//avatar/image", - views_avatar.GroupAvatarRetrieveView.as_view(), + avatar.GroupAvatarRetrieveView.as_view(), name="chat-group-avatar-retrieve", ), # Pinning (pin-reorder before uuid patterns to avoid ambiguity) path( "api/v1/chat/conversations/pin-reorder", - views_pins.ConversationPinReorderView.as_view(), + pins.ConversationPinReorderView.as_view(), name="chat-conversation-pin-reorder", ), path( "api/v1/chat/conversations//pin", - views_pins.ConversationPinView.as_view(), + pins.ConversationPinView.as_view(), name="chat-conversation-pin", ), # Message pinning path( "api/v1/chat/messages//pin", - views_pins.MessagePinToggleView.as_view(), + pins.MessagePinToggleView.as_view(), name="chat-message-pin-toggle", ), path( "api/v1/chat/conversations//pinned-messages", - views_pins.ConversationPinnedMessagesView.as_view(), + pins.ConversationPinnedMessagesView.as_view(), name="chat-conversation-pinned-messages", ), # Interactive AI questions path( "api/v1/chat/messages//answer", - views_interactions.MessageInteractionAnswerView.as_view(), + interactions.MessageInteractionAnswerView.as_view(), name="chat-message-interaction-answer", ), # Clear conversation path( "api/v1/chat/conversations//clear", - views_typing.ConversationClearView.as_view(), + typing.ConversationClearView.as_view(), name="chat-conversation-clear", ), # Scheduled messages path( "api/v1/chat/conversations//schedules", - views_scheduled.ScheduledMessageListView.as_view(), + scheduled.ScheduledMessageListView.as_view(), name="chat-scheduled-messages", ), path( "api/v1/chat/conversations//schedules/", - views_scheduled.ScheduledMessageDetailView.as_view(), + scheduled.ScheduledMessageDetailView.as_view(), name="chat-scheduled-message-detail", ), # Agent goals path( "api/v1/chat/conversations//goals", - views_goals.AgentGoalListView.as_view(), + goals.AgentGoalListView.as_view(), name="chat-agent-goals", ), path( "api/v1/chat/conversations//goals/", - views_goals.AgentGoalDetailView.as_view(), + goals.AgentGoalDetailView.as_view(), name="chat-agent-goal-detail", ), # Bot retry path( "api/v1/chat/conversations//messages//retry", - views_bots.BotRetryView.as_view(), + bots.BotRetryView.as_view(), name="chat-bot-retry", ), # Bot cancel path( "api/v1/chat/conversations//bot-cancel", - views_bots.BotCancelView.as_view(), + bots.BotCancelView.as_view(), name="chat-bot-cancel", ), # Regenerate AI conversation title path( "api/v1/chat/conversations//regenerate-title", - views_bots.ConversationRegenerateTitleView.as_view(), + bots.ConversationRegenerateTitleView.as_view(), name="chat-conversation-regenerate-title", ), # Attachments path( "api/v1/chat/attachments/", - views_attachments.AttachmentDownloadView.as_view(), + attachments.AttachmentDownloadView.as_view(), name="chat-attachment-download", ), path( "api/v1/chat/attachments//save-to-files", - views_attachments.AttachmentSaveToFilesView.as_view(), + attachments.AttachmentSaveToFilesView.as_view(), name="chat-attachment-save-to-files", ), ] diff --git a/workspace/chat/views/__init__.py b/workspace/chat/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/workspace/chat/views_attachments.py b/workspace/chat/views/attachments.py similarity index 98% rename from workspace/chat/views_attachments.py rename to workspace/chat/views/attachments.py index 32b7cad41..11ba1640f 100644 --- a/workspace/chat/views_attachments.py +++ b/workspace/chat/views/attachments.py @@ -12,8 +12,8 @@ from workspace.common.logging import scrub from workspace.common.uuids import parse_uuid_or_none -from .models import MessageAttachment -from .services.conversations import get_active_membership +from ..models import MessageAttachment +from ..services.conversations import get_active_membership logger = logging.getLogger(__name__) diff --git a/workspace/chat/views_avatar.py b/workspace/chat/views/avatar.py similarity index 98% rename from workspace/chat/views_avatar.py rename to workspace/chat/views/avatar.py index 4b5b7ab3e..0b48ccfbe 100644 --- a/workspace/chat/views_avatar.py +++ b/workspace/chat/views/avatar.py @@ -11,9 +11,9 @@ from workspace.common.mixins import CacheControlMixin -from .models import Conversation -from .services import avatar as group_avatar_service -from .services.conversations import get_active_membership +from ..models import Conversation +from ..services import avatar as group_avatar_service +from ..services.conversations import get_active_membership logger = logging.getLogger(__name__) diff --git a/workspace/chat/views_bots.py b/workspace/chat/views/bots.py similarity index 96% rename from workspace/chat/views_bots.py rename to workspace/chat/views/bots.py index bbf954281..082aa9cff 100644 --- a/workspace/chat/views_bots.py +++ b/workspace/chat/views/bots.py @@ -7,8 +7,8 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .models import Message -from .services.conversations import get_active_membership, is_bot_conversation +from ..models import Message +from ..services.conversations import get_active_membership, is_bot_conversation logger = logging.getLogger(__name__) @@ -20,7 +20,7 @@ class BotRetryView(APIView): @extend_schema(summary="Retry a failed bot response") def post(self, request, conversation_id, message_id): # Lazy import to avoid circular dependency with views.py - from .views import _trigger_bot_response + from .conversations import _trigger_bot_response membership = get_active_membership(request.user, conversation_id) if not membership: diff --git a/workspace/chat/views_calls.py b/workspace/chat/views/calls.py similarity index 98% rename from workspace/chat/views_calls.py rename to workspace/chat/views/calls.py index 0ac000fef..7ebad632e 100644 --- a/workspace/chat/views_calls.py +++ b/workspace/chat/views/calls.py @@ -7,13 +7,13 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .services import calls -from .services.call_signaling import ( +from ..services import calls +from ..services.call_signaling import ( DIAGNOSTIC_LANES, send_diagnostic_signal, send_signal, ) -from .services.conversations import ( +from ..services.conversations import ( get_active_membership, is_active_member, is_bot_conversation, diff --git a/workspace/chat/views.py b/workspace/chat/views/conversations.py similarity index 98% rename from workspace/chat/views.py rename to workspace/chat/views/conversations.py index cbde4509a..b186791f9 100644 --- a/workspace/chat/views.py +++ b/workspace/chat/views/conversations.py @@ -14,21 +14,21 @@ from workspace.common.logging import scrub from workspace.common.mixins import CacheControlMixin -from .models import Conversation, ConversationMember, Message, PinnedConversation -from .serializers import ( +from ..models import Conversation, ConversationMember, Message, PinnedConversation +from ..serializers import ( ConversationCreateSerializer, ConversationDetailSerializer, ConversationListSerializer, NotificationLevelSerializer, ) -from .services.conversations import ( +from ..services.conversations import ( get_active_membership, get_or_create_dm, get_unread_counts, user_conversation_ids, ) -from .services.group_sync import create_group_conversation, is_group_linked -from .services.threads import mark_conversation_threads_read +from ..services.group_sync import create_group_conversation, is_group_linked +from ..services.threads import mark_conversation_threads_read User = get_user_model() logger = logging.getLogger(__name__) diff --git a/workspace/chat/views_goals.py b/workspace/chat/views/goals.py similarity index 97% rename from workspace/chat/views_goals.py rename to workspace/chat/views/goals.py index e1315b424..838dc5feb 100644 --- a/workspace/chat/views_goals.py +++ b/workspace/chat/views/goals.py @@ -6,15 +6,15 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .serializers import AgentGoalCreateSerializer, AgentGoalSerializer -from .services.conversations import get_active_membership +from ..serializers import AgentGoalCreateSerializer, AgentGoalSerializer +from ..services.conversations import get_active_membership logger = logging.getLogger(__name__) def _get_bot_member(conversation_id): """Return the first bot User active in the conversation, or None.""" - from .models import ConversationMember + from ..models import ConversationMember member = ( ConversationMember.objects.filter( diff --git a/workspace/chat/views_interactions.py b/workspace/chat/views/interactions.py similarity index 93% rename from workspace/chat/views_interactions.py rename to workspace/chat/views/interactions.py index 9523056b0..3a10e54c8 100644 --- a/workspace/chat/views_interactions.py +++ b/workspace/chat/views/interactions.py @@ -9,12 +9,12 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .models import Message, MessageInteraction, Reaction -from .serializers import MessageSerializer -from .services.conversations import get_active_membership -from .services.posting import deliver_message -from .services.rendering import render_message_body -from .views import _trigger_bot_response +from ..models import Message, MessageInteraction, Reaction +from ..serializers import MessageSerializer +from ..services.conversations import get_active_membership +from ..services.posting import deliver_message +from ..services.rendering import render_message_body +from .conversations import _trigger_bot_response logger = logging.getLogger(__name__) diff --git a/workspace/chat/views_messages.py b/workspace/chat/views/messages.py similarity index 97% rename from workspace/chat/views_messages.py rename to workspace/chat/views/messages.py index 31d91733d..822a7096c 100644 --- a/workspace/chat/views_messages.py +++ b/workspace/chat/views/messages.py @@ -15,29 +15,29 @@ from workspace.chat.services.mentions import build_mention_map from workspace.chat.services.reactions import invalidate_quick_reactions +from workspace.common.logging import scrub from workspace.common.mixins import CacheControlMixin from workspace.common.uuids import parse_uuid_or_none -from ..common.logging import scrub -from .models import ( +from ..models import ( Conversation, ConversationMember, Message, MessageAttachment, Reaction, ) -from .serializers import ( +from ..serializers import ( MessageCreateSerializer, MessageEditSerializer, MessageSerializer, ReactionToggleSerializer, ) -from .services.conversations import get_active_membership -from .services.deletion import purge_message_content -from .services.notifications import notify_conversation_members -from .services.posting import deliver_message -from .services.rendering import render_message_body -from .services.threads import ( +from ..services.conversations import get_active_membership +from ..services.deletion import purge_message_content +from ..services.notifications import notify_conversation_members +from ..services.posting import deliver_message +from ..services.rendering import render_message_body +from ..services.threads import ( mark_conversation_threads_read, recount_thread, resolve_thread_root, @@ -154,7 +154,7 @@ def post(self, request, conversation_id): from workspace.files.services.files import FileService # Lazy import to avoid circular dependency with views.py - from .views import _trigger_bot_response + from .conversations import _trigger_bot_response membership = get_active_membership(request.user, conversation_id) if not membership: @@ -335,7 +335,7 @@ def post(self, request, conversation_id): enqueue_caption_if_image(att) # Clear typing indicator now that the message is sent - from .services.typing import clear_typing + from ..services.typing import clear_typing clear_typing(conversation_id, request.user.id) @@ -344,11 +344,11 @@ def post(self, request, conversation_id): # Enqueue link preview fetching for URLs in the message body if body: - from .services.link_preview import extract_urls + from ..services.link_preview import extract_urls urls = extract_urls(body) if urls: - from .tasks import fetch_link_previews + from ..tasks import fetch_link_previews fetch_link_previews.delay(str(message.pk), urls) diff --git a/workspace/chat/views_pins.py b/workspace/chat/views/pins.py similarity index 96% rename from workspace/chat/views_pins.py rename to workspace/chat/views/pins.py index 84309847b..d1ee918d8 100644 --- a/workspace/chat/views_pins.py +++ b/workspace/chat/views/pins.py @@ -9,10 +9,10 @@ from workspace.common.uuids import parse_uuid_or_none -from .models import Message, PinnedConversation, PinnedMessage -from .serializers import PinnedMessageSerializer -from .services.conversations import get_active_membership -from .services.notifications import notify_conversation_members +from ..models import Message, PinnedConversation, PinnedMessage +from ..serializers import PinnedMessageSerializer +from ..services.conversations import get_active_membership +from ..services.notifications import notify_conversation_members logger = logging.getLogger(__name__) diff --git a/workspace/chat/views_scheduled.py b/workspace/chat/views/scheduled.py similarity index 97% rename from workspace/chat/views_scheduled.py rename to workspace/chat/views/scheduled.py index a90b098ef..e4b247bc9 100644 --- a/workspace/chat/views_scheduled.py +++ b/workspace/chat/views/scheduled.py @@ -6,8 +6,8 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .serializers import ScheduledMessageSerializer -from .services.conversations import get_active_membership +from ..serializers import ScheduledMessageSerializer +from ..services.conversations import get_active_membership logger = logging.getLogger(__name__) diff --git a/workspace/chat/views_search.py b/workspace/chat/views/search.py similarity index 98% rename from workspace/chat/views_search.py rename to workspace/chat/views/search.py index 9c0460c92..0b30476cd 100644 --- a/workspace/chat/views_search.py +++ b/workspace/chat/views/search.py @@ -11,9 +11,9 @@ from workspace.common.mixins import CacheControlMixin -from .models import Message, MessageAttachment, Reaction -from .services.conversations import get_active_membership -from .services.message_search import search_messages_qs +from ..models import Message, MessageAttachment, Reaction +from ..services.conversations import get_active_membership +from ..services.message_search import search_messages_qs logger = logging.getLogger(__name__) diff --git a/workspace/chat/views_threads.py b/workspace/chat/views/threads.py similarity index 92% rename from workspace/chat/views_threads.py rename to workspace/chat/views/threads.py index bd895b6ab..dcc35cc7d 100644 --- a/workspace/chat/views_threads.py +++ b/workspace/chat/views/threads.py @@ -8,9 +8,9 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .models import ConversationMember, Message -from .services.conversations import get_active_membership -from .services.threads import mark_thread_read +from ..models import ConversationMember, Message +from ..services.conversations import get_active_membership +from ..services.threads import mark_thread_read class ThreadReadSerializer(serializers.Serializer): diff --git a/workspace/chat/views_typing.py b/workspace/chat/views/typing.py similarity index 89% rename from workspace/chat/views_typing.py rename to workspace/chat/views/typing.py index 3c173b8d6..3422012a3 100644 --- a/workspace/chat/views_typing.py +++ b/workspace/chat/views/typing.py @@ -5,10 +5,10 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .models import Conversation, ConversationMember, Message, MessageAttachment -from .services.conversations import get_active_membership, get_unread_counts -from .services.deletion import discard_attachment_files -from .services.notifications import notify_conversation_members +from ..models import Conversation, ConversationMember, Message, MessageAttachment +from ..services.conversations import get_active_membership, get_unread_counts +from ..services.deletion import discard_attachment_files +from ..services.notifications import notify_conversation_members @extend_schema(tags=["Chat - Conversations"]) @@ -24,7 +24,7 @@ def post(self, request, conversation_id): status=status.HTTP_403_FORBIDDEN, ) - from .services.typing import set_typing + from ..services.typing import set_typing set_typing( conversation_id, diff --git a/workspace/common/tests/e2e/base.py b/workspace/common/tests/e2e/base.py index 019a7cb5f..d5c5f3278 100644 --- a/workspace/common/tests/e2e/base.py +++ b/workspace/common/tests/e2e/base.py @@ -83,7 +83,7 @@ def tearDownClass(cls): cls._playwright.stop() super().tearDownClass() - # The SSE handler in ``workspace/core/views_sse.py`` is a 600s long-poll + # The SSE handler in ``workspace/core/views/sse.py`` is a 600s long-poll # that runs in a server worker thread. The thread doesn't observe # client disconnect between ``time.sleep(1)`` ticks, so when a test # ends its in-flight stream keeps holding a DB connection. On Windows diff --git a/workspace/core/tests/test_views_sse.py b/workspace/core/tests/test_sse.py similarity index 75% rename from workspace/core/tests/test_views_sse.py rename to workspace/core/tests/test_sse.py index 83c769de0..129c1aeca 100644 --- a/workspace/core/tests/test_views_sse.py +++ b/workspace/core/tests/test_sse.py @@ -1,4 +1,4 @@ -"""Tests for workspace.core.views_sse global SSE stream generators. +"""Tests for workspace.core.sse global SSE stream generators. Validates that both the Pub/Sub and polling generators honor the _MAX_CONNECTION_SECONDS budget — they must return cleanly so the browser @@ -12,7 +12,7 @@ from django.test import RequestFactory, TestCase from prometheus_client import REGISTRY -from workspace.core import views_sse +from workspace.core.views import sse User = get_user_model() @@ -33,11 +33,11 @@ def setUp(self): def test_polling_stream_returns_when_max_duration_reached(self): with ( - patch.object(views_sse, "_MAX_CONNECTION_SECONDS", -1), - patch.object(views_sse, "_init_providers", return_value={}), - patch("workspace.core.views_sse.time.sleep"), + patch.object(sse, "_MAX_CONNECTION_SECONDS", -1), + patch.object(sse, "_init_providers", return_value={}), + patch("workspace.core.views.sse.time.sleep"), ): - chunks = list(views_sse._event_stream_polling(self.request)) + chunks = list(sse._event_stream_polling(self.request)) # No providers -> no initial events; budget exhausted -> exit before any keepalive/poll. self.assertEqual(chunks, []) @@ -49,10 +49,10 @@ def test_pubsub_stream_returns_and_cleans_up_when_max_duration_reached(self): fake_redis.pubsub.return_value = fake_pubsub with ( - patch.object(views_sse, "_MAX_CONNECTION_SECONDS", -1), - patch.object(views_sse, "_init_providers", return_value={}), + patch.object(sse, "_MAX_CONNECTION_SECONDS", -1), + patch.object(sse, "_init_providers", return_value={}), ): - chunks = list(views_sse._event_stream_pubsub(self.request, fake_redis)) + chunks = list(sse._event_stream_pubsub(self.request, fake_redis)) self.assertEqual(chunks, []) # finally block must run: subscribe was set up, unsubscribe + close must mirror it. @@ -62,7 +62,7 @@ def test_pubsub_stream_returns_and_cleans_up_when_max_duration_reached(self): class MetricsTests(TestCase): - """Counters/Histograms exposed on /metrics by views_sse.""" + """Counters/Histograms exposed on /metrics by sse.""" @classmethod def setUpTestData(cls): @@ -81,7 +81,7 @@ def test_emit_initial_events_increments_events_emitted(self): labels = {"provider": "chat", "event": "message_new"} before = _sample("sse_events_emitted_total", labels) - chunks = list(views_sse._emit_initial_events({"chat": provider}, self.user.id)) + chunks = list(sse._emit_initial_events({"chat": provider}, self.user.id)) self.assertEqual(len(chunks), 2) self.assertEqual(_sample("sse_events_emitted_total", labels) - before, 2) @@ -96,9 +96,7 @@ def test_poll_provider_observes_duration_and_increments_events(self): ) before_events = _sample("sse_events_emitted_total", ev_labels) - chunks = list( - views_sse._poll_provider("calendar", provider, None, self.user.id) - ) + chunks = list(sse._poll_provider("calendar", provider, None, self.user.id)) self.assertEqual(len(chunks), 1) after_count = _sample( @@ -113,11 +111,11 @@ def test_poll_provider_observes_duration_and_increments_events(self): def test_polling_stream_increments_forced_reconnect_when_budget_exhausted(self): before = _sample("sse_forced_reconnects_total", {"transport": "polling"}) with ( - patch.object(views_sse, "_MAX_CONNECTION_SECONDS", -1), - patch.object(views_sse, "_init_providers", return_value={}), - patch("workspace.core.views_sse.time.sleep"), + patch.object(sse, "_MAX_CONNECTION_SECONDS", -1), + patch.object(sse, "_init_providers", return_value={}), + patch("workspace.core.views.sse.time.sleep"), ): - list(views_sse._event_stream_polling(self.request)) + list(sse._event_stream_polling(self.request)) after = _sample("sse_forced_reconnects_total", {"transport": "polling"}) self.assertEqual(after - before, 1) @@ -128,10 +126,10 @@ def test_pubsub_stream_increments_forced_reconnect_when_budget_exhausted(self): fake_redis = MagicMock() fake_redis.pubsub.return_value = fake_pubsub with ( - patch.object(views_sse, "_MAX_CONNECTION_SECONDS", -1), - patch.object(views_sse, "_init_providers", return_value={}), + patch.object(sse, "_MAX_CONNECTION_SECONDS", -1), + patch.object(sse, "_init_providers", return_value={}), ): - list(views_sse._event_stream_pubsub(self.request, fake_redis)) + list(sse._event_stream_pubsub(self.request, fake_redis)) after = _sample("sse_forced_reconnects_total", {"transport": "pubsub"}) self.assertEqual(after - before, 1) @@ -151,14 +149,14 @@ def test_pubsub_stream_increments_pubsub_messages_on_real_message(self): # We patch monotonic to advance past the budget after the first message. times = iter([0.0, 0.0, 999.0, 999.0, 999.0]) with ( - patch.object(views_sse, "_MAX_CONNECTION_SECONDS", 1), - patch.object(views_sse, "_init_providers", return_value={"chat": provider}), + patch.object(sse, "_MAX_CONNECTION_SECONDS", 1), + patch.object(sse, "_init_providers", return_value={"chat": provider}), patch( - "workspace.core.views_sse.time.monotonic", + "workspace.core.views.sse.time.monotonic", side_effect=lambda: next(times), ), ): - list(views_sse._event_stream_pubsub(self.request, fake_redis)) + list(sse._event_stream_pubsub(self.request, fake_redis)) after = _sample("sse_pubsub_messages_total") self.assertEqual(after - before, 1) @@ -168,7 +166,7 @@ class StreamConstantTests(TestCase): def test_max_connection_seconds_is_aligned_with_nginx_proxy_timeout(self): # nginx ingress proxy-read-timeout is 600s; staying below it avoids the # proxy cutting our stream while the budget is still ticking server-side. - self.assertLessEqual(views_sse._MAX_CONNECTION_SECONDS, 600) + self.assertLessEqual(sse._MAX_CONNECTION_SECONDS, 600) # Anything under a minute is sub-calibrated: forces too many reconnects # which re-emit all initial snapshots and re-init providers. - self.assertGreaterEqual(views_sse._MAX_CONNECTION_SECONDS, 60) + self.assertGreaterEqual(sse._MAX_CONNECTION_SECONDS, 60) diff --git a/workspace/core/urls.py b/workspace/core/urls.py index 41d987656..81dec616e 100644 --- a/workspace/core/urls.py +++ b/workspace/core/urls.py @@ -1,13 +1,14 @@ from django.urls import path -from workspace.core.activity_views import ( +from workspace.core.views.activity import ( ActivityDailyCountsView, ActivityRecentView, ActivityStatsView, ) -from workspace.core.views import ModulesView, UnifiedSearchView -from workspace.core.views_changelog import changelog_partial -from workspace.core.views_sse import global_stream +from workspace.core.views.changelog import changelog_partial +from workspace.core.views.modules import ModulesView +from workspace.core.views.search import UnifiedSearchView +from workspace.core.views.sse import global_stream urlpatterns = [ path("api/v1/modules", ModulesView.as_view(), name="modules-list"), diff --git a/workspace/core/views/__init__.py b/workspace/core/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/workspace/core/activity_views.py b/workspace/core/views/activity.py similarity index 100% rename from workspace/core/activity_views.py rename to workspace/core/views/activity.py diff --git a/workspace/core/views_changelog.py b/workspace/core/views/changelog.py similarity index 100% rename from workspace/core/views_changelog.py rename to workspace/core/views/changelog.py diff --git a/workspace/core/views_health.py b/workspace/core/views/health.py similarity index 100% rename from workspace/core/views_health.py rename to workspace/core/views/health.py diff --git a/workspace/core/views/modules.py b/workspace/core/views/modules.py new file mode 100644 index 000000000..1ee8772b7 --- /dev/null +++ b/workspace/core/views/modules.py @@ -0,0 +1,23 @@ +from dataclasses import asdict + +from drf_spectacular.utils import extend_schema +from rest_framework.response import Response +from rest_framework.views import APIView + +from workspace.common.cache import cached_response +from workspace.common.mixins import CacheControlMixin +from workspace.core.module_registry import registry + + +class ModulesView(CacheControlMixin, APIView): + cache_max_age = 3600 + + @extend_schema( + tags=["Modules"], + summary="List workspace modules", + description="Returns all registered workspace modules.", + ) + @cached_response(3600, per_user=False) + def get(self, request): + modules = [asdict(m) for m in registry.get_all()] + return Response({"results": modules}) diff --git a/workspace/core/views.py b/workspace/core/views/search.py similarity index 80% rename from workspace/core/views.py rename to workspace/core/views/search.py index 8848fbcad..5cee8b013 100644 --- a/workspace/core/views.py +++ b/workspace/core/views/search.py @@ -1,5 +1,3 @@ -from dataclasses import asdict - from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.response import Response from rest_framework.views import APIView @@ -7,24 +5,9 @@ from workspace.common.cache import cached_response from workspace.common.limits import clamp_limit from workspace.common.mixins import CacheControlMixin -from workspace.core.module_registry import registry from workspace.core.services import search as search_service -class ModulesView(CacheControlMixin, APIView): - cache_max_age = 3600 - - @extend_schema( - tags=["Modules"], - summary="List workspace modules", - description="Returns all registered workspace modules.", - ) - @cached_response(3600, per_user=False) - def get(self, request): - modules = [asdict(m) for m in registry.get_all()] - return Response({"results": modules}) - - class UnifiedSearchView(CacheControlMixin, APIView): @extend_schema( tags=["Search"], diff --git a/workspace/core/views_sse.py b/workspace/core/views/sse.py similarity index 99% rename from workspace/core/views_sse.py rename to workspace/core/views/sse.py index 1e1a4bf83..5ed7463b5 100644 --- a/workspace/core/views_sse.py +++ b/workspace/core/views/sse.py @@ -6,7 +6,7 @@ from workspace.common.metrics import safe_counter, safe_gauge, safe_histogram -from .sse_registry import sse_registry +from ..sse_registry import sse_registry logger = logging.getLogger(__name__) diff --git a/workspace/files/tests/test_locking.py b/workspace/files/tests/test_locking.py index 3b5b0adf8..46f8d75e6 100644 --- a/workspace/files/tests/test_locking.py +++ b/workspace/files/tests/test_locking.py @@ -224,7 +224,7 @@ def test_is_expired_true_at_exact_boundary(self): lock_expires_at=boundary, ) self.client.force_authenticate(self.user) - with patch("workspace.files.views.timezone.now", return_value=boundary): + with patch("workspace.files.views.files.timezone.now", return_value=boundary): resp = self.client.get(self._url()) self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertTrue(resp.data["is_expired"]) diff --git a/workspace/files/urls.py b/workspace/files/urls.py index b59ff5037..d201e1701 100644 --- a/workspace/files/urls.py +++ b/workspace/files/urls.py @@ -1,17 +1,17 @@ from django.urls import include, path from rest_framework.routers import SimpleRouter -from .views import FileViewSet -from .views_graph import FileGraphView -from .views_share_links import ( +from .views.files import FileViewSet +from .views.graph import FileGraphView +from .views.share_links import ( SharedFileContentView, SharedFileDownloadView, SharedFileMetaView, SharedFileVerifyView, ) -from .views_tags import FileTagView, TagViewSet -from .views_thumbnails import GenerateThumbnailsView -from .views_wopi import WopiFileContentsView, WopiFileView +from .views.tags import FileTagView, TagViewSet +from .views.thumbnails import GenerateThumbnailsView +from .views.wopi import WopiFileContentsView, WopiFileView router = SimpleRouter(trailing_slash=False) router.register(r"files", FileViewSet, basename="file") diff --git a/workspace/files/views/__init__.py b/workspace/files/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/workspace/files/views.py b/workspace/files/views/files.py similarity index 98% rename from workspace/files/views.py rename to workspace/files/views/files.py index 623126431..edd9284a5 100644 --- a/workspace/files/views.py +++ b/workspace/files/views/files.py @@ -29,19 +29,19 @@ from workspace.files.services.content_hash import find_duplicates from workspace.notifications.services.notifications import notify, notify_many -from .models import File, FileShare -from .serializers import FileSerializer -from .viewsets.actions import ActionsMixin -from .viewsets.comments import CommentsMixin -from .viewsets.content import ContentMixin -from .viewsets.copy import CopyMixin -from .viewsets.events import EventsMixin -from .viewsets.extract import ExtractMixin -from .viewsets.favorites import FavoritesMixin -from .viewsets.share import ShareMixin -from .viewsets.storage import StorageMixin -from .viewsets.sync import SyncMixin -from .viewsets.trash import TrashMixin +from ..models import File, FileShare +from ..serializers import FileSerializer +from ..viewsets.actions import ActionsMixin +from ..viewsets.comments import CommentsMixin +from ..viewsets.content import ContentMixin +from ..viewsets.copy import CopyMixin +from ..viewsets.events import EventsMixin +from ..viewsets.extract import ExtractMixin +from ..viewsets.favorites import FavoritesMixin +from ..viewsets.share import ShareMixin +from ..viewsets.storage import StorageMixin +from ..viewsets.sync import SyncMixin +from ..viewsets.trash import TrashMixin logger = logging.getLogger(__name__) User = get_user_model() diff --git a/workspace/files/views_graph.py b/workspace/files/views/graph.py similarity index 98% rename from workspace/files/views_graph.py rename to workspace/files/views/graph.py index 764702d15..842c1bb2b 100644 --- a/workspace/files/views_graph.py +++ b/workspace/files/views/graph.py @@ -10,7 +10,7 @@ from workspace.common.booleans import is_truthy from workspace.common.uuids import parse_uuid_or_none -from .services.graph import build_file_graph +from ..services.graph import build_file_graph _VALID_SCOPES = {"mine", "all"} diff --git a/workspace/files/views_share_links.py b/workspace/files/views/share_links.py similarity index 100% rename from workspace/files/views_share_links.py rename to workspace/files/views/share_links.py diff --git a/workspace/files/views_tags.py b/workspace/files/views/tags.py similarity index 97% rename from workspace/files/views_tags.py rename to workspace/files/views/tags.py index 0e0f1bf93..34a5d6f06 100644 --- a/workspace/files/views_tags.py +++ b/workspace/files/views/tags.py @@ -7,8 +7,8 @@ from workspace.common.uuids import parse_uuid_or_none from workspace.files.services import FileService -from .models import FileTag, Tag -from .serializers_tags import TagSerializer +from ..models import FileTag, Tag +from ..serializers_tags import TagSerializer @extend_schema_view( diff --git a/workspace/files/views_thumbnails.py b/workspace/files/views/thumbnails.py similarity index 100% rename from workspace/files/views_thumbnails.py rename to workspace/files/views/thumbnails.py diff --git a/workspace/files/views_wopi.py b/workspace/files/views/wopi.py similarity index 100% rename from workspace/files/views_wopi.py rename to workspace/files/views/wopi.py diff --git a/workspace/mail/services/notifications.py b/workspace/mail/services/notifications.py index c6563c1af..c4658dae6 100644 --- a/workspace/mail/services/notifications.py +++ b/workspace/mail/services/notifications.py @@ -157,7 +157,7 @@ def settle_message_notifications(user, message_pks) -> int: Lightweight, unsaved ``MailMessage`` instances stand in for the rows, mirroring the pattern already used for a single source in - ``chat/views_messages.py``. + ``chat/views/messages.py``. """ message_pks = list(message_pks) if not message_pks: diff --git a/workspace/mail/tests/test_attachment_save_to_files.py b/workspace/mail/tests/test_attachment_save_to_files.py index 06915d187..0ab333691 100644 --- a/workspace/mail/tests/test_attachment_save_to_files.py +++ b/workspace/mail/tests/test_attachment_save_to_files.py @@ -122,7 +122,7 @@ def test_destination_save_failure_is_not_404(self): """ from rest_framework.test import APIRequestFactory, force_authenticate - from workspace.mail.views_attachments import MailAttachmentSaveToFilesView + from workspace.mail.views.attachments import MailAttachmentSaveToFilesView factory = APIRequestFactory() request = factory.post(self.url, data={}, format="json") diff --git a/workspace/mail/tests/test_oauth2.py b/workspace/mail/tests/test_oauth2.py index 3070fa141..f16483a41 100644 --- a/workspace/mail/tests/test_oauth2.py +++ b/workspace/mail/tests/test_oauth2.py @@ -362,7 +362,7 @@ def setUp(self): OAUTH_GENERIC_CLIENT_SECRET="", ) def test_returns_available_providers(self): - from workspace.mail.views_oauth2 import OAuthProvidersView + from workspace.mail.views.oauth2 import OAuthProvidersView request = self.factory.get("/api/v1/mail/oauth2/providers") request.user = self.user @@ -523,8 +523,8 @@ def setUp(self): last_sync_error="Authorization expired or revoked.", ) - @patch("workspace.mail.views_oauth2.fetch_userinfo") - @patch("workspace.mail.views_oauth2.exchange_code") + @patch("workspace.mail.views.oauth2.fetch_userinfo") + @patch("workspace.mail.views.oauth2.exchange_code") def test_callback_reactivates_existing_account(self, mock_exchange, mock_userinfo): mock_exchange.return_value = { "access_token": "new_tok", diff --git a/workspace/mail/tests/test_rules_actions.py b/workspace/mail/tests/test_rules_actions.py index 3d762882d..9cd3213b8 100644 --- a/workspace/mail/tests/test_rules_actions.py +++ b/workspace/mail/tests/test_rules_actions.py @@ -138,7 +138,7 @@ def test_unstar(self, mock_imap): ) def test_mark_read_imap_failure_still_writes_db(self, _mock): """IMAP failure must not abort the DB update (existing pattern in - views_messages.MailMessageDetailView).""" + views/messages.py MailMessageDetailView).""" action = parse_actions([{"type": "mark_read"}])[0] result = apply_action(action, self.msg) self.assertTrue(result["ok"]) # DB write succeeded diff --git a/workspace/mail/ui/urls.py b/workspace/mail/ui/urls.py index 74d235548..e36d0b461 100644 --- a/workspace/mail/ui/urls.py +++ b/workspace/mail/ui/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from workspace.mail.views_oauth2 import oauth2_callback +from workspace.mail.views.oauth2 import oauth2_callback from . import views diff --git a/workspace/mail/urls.py b/workspace/mail/urls.py index 9227a0c5e..d5e8abaec 100644 --- a/workspace/mail/urls.py +++ b/workspace/mail/urls.py @@ -1,160 +1,158 @@ from django.urls import path -from . import ( - views, - views_attachments, - views_compose, - views_contacts, - views_extractions, - views_folders, - views_labels, - views_messages, - views_oauth2, - views_rules, +from .views import ( + accounts, + attachments, + compose, + contacts, + extractions, + folders, + labels, + messages, + oauth2, + rules, ) urlpatterns = [ path( "api/v1/mail/autodiscover", - views.MailAutodiscoverView.as_view(), + accounts.MailAutodiscoverView.as_view(), name="mail-autodiscover", ), path( "api/v1/mail/accounts", - views.MailAccountListView.as_view(), + accounts.MailAccountListView.as_view(), name="mail-account-list", ), path( "api/v1/mail/accounts/", - views.MailAccountDetailView.as_view(), + accounts.MailAccountDetailView.as_view(), name="mail-account-detail", ), path( "api/v1/mail/accounts//test", - views.MailAccountTestView.as_view(), + accounts.MailAccountTestView.as_view(), name="mail-account-test", ), path( "api/v1/mail/accounts//sync", - views.MailAccountSyncView.as_view(), + accounts.MailAccountSyncView.as_view(), name="mail-account-sync", ), path( "api/v1/mail/labels", - views_labels.MailLabelListView.as_view(), + labels.MailLabelListView.as_view(), name="mail-label-list", ), path( "api/v1/mail/labels/", - views_labels.MailLabelDetailView.as_view(), + labels.MailLabelDetailView.as_view(), name="mail-label-detail", ), path( "api/v1/mail/rules", - views_rules.MailRuleListView.as_view(), + rules.MailRuleListView.as_view(), name="mail-rule-list", ), path( "api/v1/mail/rules/", - views_rules.MailRuleDetailView.as_view(), + rules.MailRuleDetailView.as_view(), name="mail-rule-detail", ), path( "api/v1/mail/rules//reorder", - views_rules.MailRuleReorderView.as_view(), + rules.MailRuleReorderView.as_view(), name="mail-rule-reorder", ), path( "api/v1/mail/rules//apply", - views_rules.MailRuleApplyView.as_view(), + rules.MailRuleApplyView.as_view(), name="mail-rule-apply", ), path( "api/v1/mail/rules/test", - views_rules.MailRuleTestView.as_view(), + rules.MailRuleTestView.as_view(), name="mail-rule-test", ), path( "api/v1/mail/rules//logs", - views_rules.MailRuleLogsView.as_view(), + rules.MailRuleLogsView.as_view(), name="mail-rule-logs", ), path( "api/v1/mail/folders", - views_folders.MailFolderListView.as_view(), + folders.MailFolderListView.as_view(), name="mail-folder-list", ), path( "api/v1/mail/folders/", - views_folders.MailFolderUpdateView.as_view(), + folders.MailFolderUpdateView.as_view(), name="mail-folder-update", ), path( "api/v1/mail/folders//mark-read", - views_folders.MailFolderMarkReadView.as_view(), + folders.MailFolderMarkReadView.as_view(), name="mail-folder-mark-read", ), path( "api/v1/mail/contacts/autocomplete", - views_contacts.ContactAutocompleteView.as_view(), + contacts.ContactAutocompleteView.as_view(), name="mail-contact-autocomplete", ), path( "api/v1/mail/messages", - views_messages.MailMessageListView.as_view(), + messages.MailMessageListView.as_view(), name="mail-message-list", ), - path( - "api/v1/mail/drafts", views_compose.MailDraftView.as_view(), name="mail-draft" - ), + path("api/v1/mail/drafts", compose.MailDraftView.as_view(), name="mail-draft"), path( "api/v1/mail/drafts/", - views_compose.MailDraftView.as_view(), + compose.MailDraftView.as_view(), name="mail-draft-detail", ), path( "api/v1/mail/messages/send", - views_compose.MailSendView.as_view(), + compose.MailSendView.as_view(), name="mail-send", ), path( "api/v1/mail/messages/batch-action", - views_messages.MailBatchActionView.as_view(), + messages.MailBatchActionView.as_view(), name="mail-batch-action", ), path( "api/v1/mail/messages//labels", - views_labels.MailMessageLabelView.as_view(), + labels.MailMessageLabelView.as_view(), name="mail-message-labels", ), path( "api/v1/mail/messages/", - views_messages.MailMessageDetailView.as_view(), + messages.MailMessageDetailView.as_view(), name="mail-message-detail", ), path( "api/v1/mail/extractions/", - views_extractions.ExtractionDetailView.as_view(), + extractions.ExtractionDetailView.as_view(), name="mail-extraction-detail", ), path( "api/v1/mail/attachments/", - views_attachments.MailAttachmentDownloadView.as_view(), + attachments.MailAttachmentDownloadView.as_view(), name="mail-attachment-download", ), path( "api/v1/mail/attachments//save-to-files", - views_attachments.MailAttachmentSaveToFilesView.as_view(), + attachments.MailAttachmentSaveToFilesView.as_view(), name="mail-attachment-save-to-files", ), path( "api/v1/mail/oauth2/providers", - views_oauth2.OAuthProvidersView.as_view(), + oauth2.OAuthProvidersView.as_view(), name="mail-oauth2-providers", ), path( "api/v1/mail/oauth2/authorize", - views_oauth2.OAuthAuthorizeView.as_view(), + oauth2.OAuthAuthorizeView.as_view(), name="mail-oauth2-authorize", ), ] diff --git a/workspace/mail/views/__init__.py b/workspace/mail/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/workspace/mail/views.py b/workspace/mail/views/accounts.py similarity index 96% rename from workspace/mail/views.py rename to workspace/mail/views/accounts.py index 201039286..8bcee8620 100644 --- a/workspace/mail/views.py +++ b/workspace/mail/views/accounts.py @@ -8,8 +8,8 @@ from workspace.common.logging import scrub -from .models import MailAccount -from .serializers import ( +from ..models import MailAccount +from ..serializers import ( MailAccountCreateSerializer, MailAccountSerializer, MailAccountUpdateSerializer, @@ -157,8 +157,8 @@ def post(self, request, uuid): except MailAccount.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) - from .services.imap_connection import test_imap_connection - from .services.smtp import test_smtp_connection + from ..services.imap_connection import test_imap_connection + from ..services.smtp import test_smtp_connection imap_ok, imap_error = test_imap_connection(account) smtp_ok, smtp_error = test_smtp_connection(account) @@ -197,7 +197,7 @@ def post(self, request, uuid): except MailAccount.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) - from .services.imap_sync import sync_account + from ..services.imap_sync import sync_account try: sync_account(account) diff --git a/workspace/mail/views_attachments.py b/workspace/mail/views/attachments.py similarity index 98% rename from workspace/mail/views_attachments.py rename to workspace/mail/views/attachments.py index 19168053c..2c1e3713d 100644 --- a/workspace/mail/views_attachments.py +++ b/workspace/mail/views/attachments.py @@ -10,8 +10,8 @@ from workspace.common.logging import scrub from workspace.common.uuids import parse_uuid_or_none -from .models import MailAttachment -from .queries import user_account_ids +from ..models import MailAttachment +from ..queries import user_account_ids logger = logging.getLogger(__name__) diff --git a/workspace/mail/views_compose.py b/workspace/mail/views/compose.py similarity index 95% rename from workspace/mail/views_compose.py rename to workspace/mail/views/compose.py index bf6cbf693..658ff6a5f 100644 --- a/workspace/mail/views_compose.py +++ b/workspace/mail/views/compose.py @@ -10,8 +10,8 @@ from workspace.common.closing import close_all from workspace.common.logging import scrub -from .models import MailAccount, MailMessage -from .serializers import ( +from ..models import MailAccount, MailMessage +from ..serializers import ( DraftSaveSerializer, MailMessageDetailSerializer, SendEmailSerializer, @@ -38,7 +38,7 @@ def post(self, request): status=status.HTTP_404_NOT_FOUND, ) - from .services.sending import deliver_email + from ..services.sending import deliver_email attachments = list(request.FILES.getlist("attachments", [])) @@ -118,7 +118,7 @@ def post(self, request): status=status.HTTP_404_NOT_FOUND, ) - from .services.drafts import save_composed_draft + from ..services.drafts import save_composed_draft try: mail_msg = save_composed_draft( @@ -168,7 +168,7 @@ def delete(self, request, uuid=None): if msg.account.owner != request.user: return Response(status=status.HTTP_404_NOT_FOUND) - from .services.imap_messages import delete_draft + from ..services.imap_messages import delete_draft try: delete_draft(msg.account, msg) @@ -185,7 +185,7 @@ def delete(self, request, uuid=None): msg.deleted_at = timezone.now() msg.save(update_fields=["deleted_at", "updated_at"]) - from .services.counts import refresh_folder_counts + from ..services.counts import refresh_folder_counts refresh_folder_counts(msg.folder) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/workspace/mail/views_contacts.py b/workspace/mail/views/contacts.py similarity index 98% rename from workspace/mail/views_contacts.py rename to workspace/mail/views/contacts.py index d5860479c..5ad7ebd84 100644 --- a/workspace/mail/views_contacts.py +++ b/workspace/mail/views/contacts.py @@ -11,8 +11,8 @@ from workspace.common.mixins import CacheControlMixin from workspace.common.uuids import parse_uuid_or_none -from .models import MailMessage -from .queries import user_account_ids +from ..models import MailMessage +from ..queries import user_account_ids logger = logging.getLogger(__name__) diff --git a/workspace/mail/views_extractions.py b/workspace/mail/views/extractions.py similarity index 100% rename from workspace/mail/views_extractions.py rename to workspace/mail/views/extractions.py diff --git a/workspace/mail/views_folders.py b/workspace/mail/views/folders.py similarity index 95% rename from workspace/mail/views_folders.py rename to workspace/mail/views/folders.py index 968930fd6..b1cf78de4 100644 --- a/workspace/mail/views_folders.py +++ b/workspace/mail/views/folders.py @@ -11,8 +11,8 @@ from workspace.common.logging import scrub from workspace.common.uuids import parse_uuid_or_none -from .models import MailAccount, MailFolder, MailMessage -from .serializers import ( +from ..models import MailAccount, MailFolder, MailMessage +from ..serializers import ( MailFolderCreateSerializer, MailFolderSerializer, MailFolderUpdateSerializer, @@ -69,7 +69,7 @@ def post(self, request): except MailAccount.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) - from .services.imap_folders import create_folder + from ..services.imap_folders import create_folder try: folder = create_folder( @@ -133,7 +133,7 @@ def patch(self, request, uuid): {"detail": "Cannot move a special folder"}, status=status.HTTP_400_BAD_REQUEST, ) - from .services.imap_folders import move_folder + from ..services.imap_folders import move_folder try: move_folder(folder.account, folder, parent_name) @@ -151,7 +151,7 @@ def patch(self, request, uuid): # Rename on IMAP if display_name changed (only when not moving) new_display_name = ser.validated_data.pop("display_name", None) if new_display_name and new_display_name != folder.display_name: - from .services.imap_folders import rename_folder + from ..services.imap_folders import rename_folder # rename_folder needs the FULL mailbox name. For nested folders # (Parent/Old) we must keep the parent path so we end up with @@ -201,7 +201,7 @@ def delete(self, request, uuid): status=status.HTTP_400_BAD_REQUEST, ) - from .services.imap_folders import delete_folder + from ..services.imap_folders import delete_folder try: delete_folder(folder.account, folder) @@ -249,7 +249,7 @@ def post(self, request, uuid): folder.unread_count = 0 folder.save(update_fields=["unread_count", "updated_at"]) - from .services.label_counts import refresh_labels_for_messages + from ..services.label_counts import refresh_labels_for_messages refresh_labels_for_messages(affected_ids) diff --git a/workspace/mail/views_labels.py b/workspace/mail/views/labels.py similarity index 97% rename from workspace/mail/views_labels.py rename to workspace/mail/views/labels.py index ca0cb0c2f..77fb7cc29 100644 --- a/workspace/mail/views_labels.py +++ b/workspace/mail/views/labels.py @@ -9,8 +9,8 @@ from workspace.common.uuids import parse_uuid_or_none -from .models import MailAccount, MailLabel, MailMessage, MailMessageLabel -from .serializers import ( +from ..models import MailAccount, MailLabel, MailMessage, MailMessageLabel +from ..serializers import ( MailLabelAssignSerializer, MailLabelCreateSerializer, MailLabelSerializer, @@ -168,7 +168,7 @@ def post(self, request, uuid): [MailMessageLabel(message=msg, label=lbl) for lbl in labels], ignore_conflicts=True, ) - from .services.counts import refresh_label_counts + from ..services.counts import refresh_label_counts refresh_label_counts(labels) return Response({"status": "ok"}) @@ -203,7 +203,7 @@ def delete(self, request, uuid): message=msg, label_id__in=ser.validated_data["label_ids"], ).delete() - from .services.counts import refresh_label_counts + from ..services.counts import refresh_label_counts refresh_label_counts(affected_labels) return Response({"status": "ok"}) diff --git a/workspace/mail/views_messages.py b/workspace/mail/views/messages.py similarity index 95% rename from workspace/mail/views_messages.py rename to workspace/mail/views/messages.py index 395a2102e..6e0ae1da1 100644 --- a/workspace/mail/views_messages.py +++ b/workspace/mail/views/messages.py @@ -15,9 +15,9 @@ from workspace.common.uuids import parse_uuid_or_none from workspace.notifications.services.notifications import mark_sources_read -from .models import MailFolder, MailLabel, MailMessage, MailMessageLabel -from .queries import user_account_ids -from .serializers import ( +from ..models import MailFolder, MailLabel, MailMessage, MailMessageLabel +from ..queries import user_account_ids +from ..serializers import ( BatchActionSerializer, MailMessageDetailSerializer, MailMessageListSerializer, @@ -210,7 +210,7 @@ def patch(self, request, uuid): ser = MailMessageUpdateSerializer(data=request.data) ser.is_valid(raise_exception=True) - from .services.triage import set_flag + from ..services.triage import set_flag for field, flags in ( ("is_read", ("unread", "read")), @@ -233,12 +233,12 @@ def delete(self, request, uuid): if not msg: return Response(status=status.HTTP_404_NOT_FOUND) - from .services.counts import ( + from ..services.counts import ( refresh_folder_counts, refresh_message_label_counts, ) - from .services.imap_messages import delete_message - from .services.notifications import settle_message_notifications + from ..services.imap_messages import delete_message + from ..services.notifications import settle_message_notifications with transaction.atomic(): msg.deleted_at = timezone.now() @@ -277,8 +277,8 @@ def post(self, request): deleted_at__isnull=True, ).select_related("account", "folder") - from .services.imap_messages import delete_message - from .services.triage import flag_operations, move_to_folder + from ..services.imap_messages import delete_message + from ..services.triage import flag_operations, move_to_folder # Resolve target folder for move action target_folder = None @@ -359,9 +359,9 @@ def post(self, request): "Batch action '%s' failed for message %s", scrub(action), msg.uuid ) - from .services.counts import refresh_folders_counts_bulk - from .services.label_counts import refresh_labels_for_messages - from .services.notifications import settle_message_notifications + from ..services.counts import refresh_folders_counts_bulk + from ..services.label_counts import refresh_labels_for_messages + from ..services.notifications import settle_message_notifications with transaction.atomic(): if to_bulk_update: diff --git a/workspace/mail/views_oauth2.py b/workspace/mail/views/oauth2.py similarity index 100% rename from workspace/mail/views_oauth2.py rename to workspace/mail/views/oauth2.py diff --git a/workspace/mail/views_rules.py b/workspace/mail/views/rules.py similarity index 96% rename from workspace/mail/views_rules.py rename to workspace/mail/views/rules.py index ba976c097..ec7bd4ae4 100644 --- a/workspace/mail/views_rules.py +++ b/workspace/mail/views/rules.py @@ -9,9 +9,9 @@ from workspace.common.uuids import parse_uuid_or_none -from .models import MailAccount, MailFolder, MailMessage, MailRule, MailRuleLog -from .queries import user_account_ids -from .serializers import ( +from ..models import MailAccount, MailFolder, MailMessage, MailRule, MailRuleLog +from ..queries import user_account_ids +from ..serializers import ( MailRuleApplySerializer, MailRuleCreateSerializer, MailRuleLogSerializer, @@ -20,9 +20,9 @@ MailRuleTestSerializer, MailRuleUpdateSerializer, ) -from .services.rules.conditions import evaluate_node -from .services.rules.engine import apply_rule_to_folder -from .services.rules.schema import SchemaError, parse_conditions +from ..services.rules.conditions import evaluate_node +from ..services.rules.engine import apply_rule_to_folder +from ..services.rules.schema import SchemaError, parse_conditions logger = logging.getLogger(__name__) diff --git a/workspace/projects/tests/test_api_attachments.py b/workspace/projects/tests/test_api_attachments.py index d31f81cd9..ff6f4ae7d 100644 --- a/workspace/projects/tests/test_api_attachments.py +++ b/workspace/projects/tests/test_api_attachments.py @@ -152,7 +152,7 @@ def test_oversized_upload_is_rejected(self): from unittest.mock import patch self.client.force_authenticate(self.member) - with patch("workspace.projects.viewsets.MAX_UPLOAD_BYTES", 4): + with patch("workspace.projects.views.viewsets.MAX_UPLOAD_BYTES", 4): resp = self.client.post( self.url, data={"files": [SimpleUploadedFile("big.bin", b"12345")]}, diff --git a/workspace/projects/urls.py b/workspace/projects/urls.py index eb197e69b..dba078e8a 100644 --- a/workspace/projects/urls.py +++ b/workspace/projects/urls.py @@ -1,10 +1,10 @@ from django.urls import include, path from rest_framework.routers import SimpleRouter -from .views_actions import ProjectActionsView -from .views_calendar import TaskCalendarView -from .views_search import TaskSearchView -from .viewsets import ( +from .views.actions import ProjectActionsView +from .views.calendar import TaskCalendarView +from .views.search import TaskSearchView +from .views.viewsets import ( EpicViewSet, LabelViewSet, MemberViewSet, diff --git a/workspace/projects/views/__init__.py b/workspace/projects/views/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/workspace/projects/views_actions.py b/workspace/projects/views/actions.py similarity index 96% rename from workspace/projects/views_actions.py rename to workspace/projects/views/actions.py index 47c6bd09a..e0871ceac 100644 --- a/workspace/projects/views_actions.py +++ b/workspace/projects/views/actions.py @@ -6,9 +6,9 @@ from workspace.common.uuids import parse_uuid_or_none -from .actions import ProjectActionRegistry -from .models import Project, Task -from .queries import get_project_role +from ..actions import ProjectActionRegistry +from ..models import Project, Task +from ..queries import get_project_role @extend_schema( diff --git a/workspace/projects/views_calendar.py b/workspace/projects/views/calendar.py similarity index 97% rename from workspace/projects/views_calendar.py rename to workspace/projects/views/calendar.py index 321befb0c..a484e7574 100644 --- a/workspace/projects/views_calendar.py +++ b/workspace/projects/views/calendar.py @@ -5,8 +5,8 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .queries import tasks_due_between -from .serializers import TaskCalendarSerializer +from ..queries import tasks_due_between +from ..serializers import TaskCalendarSerializer # No calendar view paints more than a year at once, and the result set is # bounded only by the window: a wider range is a client bug, not a request diff --git a/workspace/projects/views_search.py b/workspace/projects/views/search.py similarity index 97% rename from workspace/projects/views_search.py rename to workspace/projects/views/search.py index baecd26e0..5f895761e 100644 --- a/workspace/projects/views_search.py +++ b/workspace/projects/views/search.py @@ -6,7 +6,7 @@ from workspace.common.uuids import parse_uuid_or_none -from .services.search import reference_tasks_qs, search_tasks_qs +from ..services.search import reference_tasks_qs, search_tasks_qs MAX_RESULTS = 10 diff --git a/workspace/projects/viewsets.py b/workspace/projects/views/viewsets.py similarity index 97% rename from workspace/projects/viewsets.py rename to workspace/projects/views/viewsets.py index 8e497210d..1a550c132 100644 --- a/workspace/projects/viewsets.py +++ b/workspace/projects/views/viewsets.py @@ -16,7 +16,7 @@ from workspace.common.uuids import parse_uuid_or_none from workspace.files.services import FileService -from .models import ( +from ..models import ( Epic, Label, Project, @@ -31,8 +31,8 @@ TaskLink, TaskStatus, ) -from .queries import get_project_role, project_users, user_project_ids -from .serializers import ( +from ..queries import get_project_role, project_users, user_project_ids +from ..serializers import ( EpicSerializer, LabelSerializer, MemberRoleSerializer, @@ -57,35 +57,35 @@ TaskStatusSerializer, TaskWatchSerializer, ) -from .services.assignments import notify_assigned -from .services.attachments import ( +from ..services.assignments import notify_assigned +from ..services.attachments import ( MAX_ATTACHMENTS_PER_REQUEST, MAX_UPLOAD_BYTES, create_attachments, remove_attachment, ) -from .services.comments import add_comment, notify_comment_edited -from .services.conversion import convert_project_type -from .services.estimates import format_estimate -from .services.events import record_task_event -from .services.links import create_link, delete_link, links_for_task -from .services.members import ( +from ..services.comments import add_comment, notify_comment_edited +from ..services.conversion import convert_project_type +from ..services.estimates import format_estimate +from ..services.events import record_task_event +from ..services.links import create_link, delete_link, links_for_task +from ..services.members import ( ProjectRuleError, add_member, change_member_role, remove_member, ) -from .services.projects import create_project -from .services.sprints import assign_tasks_to_sprint, complete_sprint, start_sprint -from .services.statuses import create_status, delete_status, reorder_statuses -from .services.subtasks import create_subtask, reorder_subtasks -from .services.task_filters import ( +from ..services.projects import create_project +from ..services.sprints import assign_tasks_to_sprint, complete_sprint, start_sprint +from ..services.statuses import create_status, delete_status, reorder_statuses +from ..services.subtasks import create_subtask, reorder_subtasks +from ..services.task_filters import ( ORDERABLE_FIELDS, TaskFilterError, apply_task_filters, apply_task_ordering, ) -from .services.tasks import ( +from ..services.tasks import ( apply_status_change, create_task, delete_task, @@ -94,7 +94,7 @@ reorder_tasks, settle_task_notifications, ) -from .services.watchers import auto_watch, clear_watch_state, set_watch_state +from ..services.watchers import auto_watch, clear_watch_state, set_watch_state User = get_user_model() diff --git a/workspace/urls.py b/workspace/urls.py index cea9c0370..3d22a8a8a 100644 --- a/workspace/urls.py +++ b/workspace/urls.py @@ -32,7 +32,7 @@ from mozilla_django_oidc import views as oidc_views from workspace.core.metrics_auth import metrics_basic_auth -from workspace.core.views_health import LiveView, ReadyView, StartupView +from workspace.core.views.health import LiveView, ReadyView, StartupView from workspace.users.ui.views import WorkspaceLoginView api_urlpatterns = [