diff --git a/Makefile b/Makefile index 5832d918..e9355d90 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ up: docker compose -f docker-compose.yml up -d down: - docker compose -f docker-compose.yml down \ No newline at end of file + docker compose -f docker-compose.yml down + +run-local: + poetry run daphne -b 0.0.0.0 -p 8000 procollab.asgi:application diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index 13dd954d..4861e435 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -77,7 +77,15 @@ async def connect(self): await self.channel_layer.group_add( EventGroupType.GENERAL_EVENTS, self.channel_name ) - await self.accept() + # Confirm selected subprotocol so browser clients finish handshake. + subprotocol = None + if ( + self.scope.get("subprotocols") + and len(self.scope["subprotocols"]) >= 1 + ): + subprotocol = self.scope["subprotocols"][0] + + await self.accept(subprotocol=subprotocol) async def disconnect(self, close_code): """User disconnected from websocket""" diff --git a/core/auth/__init__.py b/core/auth/__init__.py new file mode 100644 index 00000000..2469dbdf --- /dev/null +++ b/core/auth/__init__.py @@ -0,0 +1,3 @@ +""" +Authentication utilities for ASGI/WebSocket middleware. +""" diff --git a/chats/middleware.py b/core/auth/middleware.py similarity index 82% rename from chats/middleware.py rename to core/auth/middleware.py index 593c182e..247baeea 100644 --- a/chats/middleware.py +++ b/core/auth/middleware.py @@ -1,5 +1,3 @@ -from urllib.parse import parse_qs - import jwt from channels.db import database_sync_to_async from django.conf import settings @@ -97,33 +95,37 @@ def get_user(scope): class TokenAuthMiddleware: """ - Custom middleware that takes a token from the query string and authenticates via Django Rest Framework authtoken. + Custom middleware that takes a token from WebSocket subprotocols and authenticates via JWT. """ + SUBPROTOCOL_KEYWORD = "Bearer" + def __init__(self, app): # Store the ASGI application we were passed self.app = app async def __call__(self, scope, receive, send): - # Look up user from query string - - # TODO: (you should also do things like - # checking if it is a valid user ID, or if scope["user" ] is already - # populated). - - query_string = scope["query_string"].decode() - query_dict = parse_qs(query_string) - try: - token = query_dict["token"][0] - if token is None: - raise ValueError("Token is missing from headers") + # Extract token from Sec-WebSocket-Protocol header. + token = self._extract_token_from_subprotocol(scope.get("subprotocols", [])) + if token: scope["token"] = token scope["user"] = await get_user(scope) - except (ValueError, KeyError, IndexError): - # Token is missing from query string + else: from django.contrib.auth.models import AnonymousUser scope["user"] = AnonymousUser() return await self.app(scope, receive, send) + + def _extract_token_from_subprotocol(self, subprotocols: list[str]) -> str | None: + """ + Expect subprotocols in the form ["Bearer", ""]. + """ + if not subprotocols: + return None + + if len(subprotocols) >= 2 and subprotocols[0] == self.SUBPROTOCOL_KEYWORD: + return subprotocols[1] + + return None diff --git a/core/log/middleware.py b/core/log/middleware.py index 55462ef9..f20e7951 100644 --- a/core/log/middleware.py +++ b/core/log/middleware.py @@ -1,30 +1,34 @@ -from loguru import logger -from django.conf import settings +import copy import logging + +from django.conf import settings +from loguru import logger + from core.log.utils import InterceptHandler +def _add_logger_handler(path: str, level: str) -> None: + """ + Attach loguru handler, falling back to synchronous mode if multiprocessing + queues are not permitted (e.g. limited dev envs). + """ + kwargs = copy.deepcopy(settings.LOGURU_LOGGING) + try: + logger.add(path, level=level, **kwargs) + except PermissionError: + kwargs.pop("enqueue", None) + logger.add(path, level=level, **kwargs) + + class CustomLoguruMiddleware: def __init__(self, get_response): self.get_response = get_response logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) if settings.DEBUG: - logger.add( - f"{settings.BASE_DIR}/log/debug.log", - level="DEBUG", - **settings.LOGURU_LOGGING, - ) - logger.add( - f"{settings.BASE_DIR}/log/info.log", - level="INFO", - **settings.LOGURU_LOGGING, - ) - logger.add( - f"{settings.BASE_DIR}/log/warning.log", - level="WARNING", - **settings.LOGURU_LOGGING, - ) + _add_logger_handler(f"{settings.BASE_DIR}/log/debug.log", "DEBUG") + _add_logger_handler(f"{settings.BASE_DIR}/log/info.log", "INFO") + _add_logger_handler(f"{settings.BASE_DIR}/log/warning.log", "WARNING") def __call__(self, request): response = self.get_response(request) diff --git a/files/models.py b/files/models.py index 7c43149c..ea7393cf 100644 --- a/files/models.py +++ b/files/models.py @@ -1,10 +1,8 @@ import reprlib -from django.contrib.auth import get_user_model +from django.conf import settings from django.db import models -User = get_user_model() - class UserFile(models.Model): """ @@ -20,7 +18,11 @@ class UserFile(models.Model): """ link = models.URLField(primary_key=True, null=False) - user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + ) datetime_uploaded = models.DateTimeField(auto_now_add=True) name = models.TextField(blank=False, default="file") extension = models.TextField(blank=True, default="") diff --git a/procollab/asgi.py b/procollab/asgi.py index 5ba84539..3551aebe 100644 --- a/procollab/asgi.py +++ b/procollab/asgi.py @@ -1,16 +1,19 @@ import os -import chats.routing from channels.routing import ProtocolTypeRouter, URLRouter from django.core.asgi import get_asgi_application -from chats.middleware import TokenAuthMiddleware - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings") +# Ensure Django app registry is loaded before importing project routes. +django_asgi_app = get_asgi_application() + +from core.auth.middleware import TokenAuthMiddleware # noqa: E402 +from procollab.websocket_routing import websocket_urlpatterns # noqa: E402 + application = ProtocolTypeRouter( { - "http": get_asgi_application(), - "websocket": TokenAuthMiddleware(URLRouter(chats.routing.websocket_urlpatterns)), + "http": django_asgi_app, + "websocket": TokenAuthMiddleware(URLRouter(websocket_urlpatterns)), } ) diff --git a/procollab/websocket_routing.py b/procollab/websocket_routing.py new file mode 100644 index 00000000..5da70ebe --- /dev/null +++ b/procollab/websocket_routing.py @@ -0,0 +1,4 @@ +from chats.routing import websocket_urlpatterns as chat_websocket_urlpatterns + +websocket_urlpatterns = [] +websocket_urlpatterns += chat_websocket_urlpatterns diff --git a/projects/views.py b/projects/views.py index 5820f58a..2e4f3181 100644 --- a/projects/views.py +++ b/projects/views.py @@ -671,6 +671,43 @@ def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response: class DuplicateProjectView(APIView): permission_classes = [IsAuthenticated, CanBindProjectToProgram] + @staticmethod + def _copy_collaborators(original_project: Project, new_project: Project) -> None: + """ + Copy all collaborators from the source project to the duplicated one. + Keep the leader collaborator (auto-created by signal) in sync with the original. + """ + leader_id = new_project.leader_id + collaborators_to_create: list[Collaborator] = [] + leader_collaborator = None + + for collaborator in original_project.collaborator_set.select_related("user").all(): + if collaborator.user_id == leader_id: + leader_collaborator = collaborator + continue + + collaborators_to_create.append( + Collaborator( + user=collaborator.user, + project=new_project, + role=collaborator.role, + specialization=collaborator.specialization, + ) + ) + + if collaborators_to_create: + Collaborator.objects.bulk_create(collaborators_to_create) + + if leader_collaborator: + Collaborator.objects.update_or_create( + user_id=leader_id, + project=new_project, + defaults={ + "role": leader_collaborator.role, + "specialization": leader_collaborator.specialization, + }, + ) + @swagger_auto_schema( request_body=ProjectDuplicateRequestSerializer, responses={201: ProjectDuplicateRequestSerializer(), 400: "Validation error"}, @@ -706,6 +743,8 @@ def post(self, request): cover=original_project.cover, ) + self._copy_collaborators(original_project, new_project) + program_link = PartnerProgramProject.objects.create( partner_program=partner_program, project=new_project ) diff --git a/scripts/startup.sh b/scripts/startup.sh index 3b4eef7a..6c35ebf8 100644 --- a/scripts/startup.sh +++ b/scripts/startup.sh @@ -2,4 +2,6 @@ python manage.py migrate python manage.py collectstatic --no-input -python manage.py runserver 0.0.0.0:8000 \ No newline at end of file + +# Use Daphne ASGI server instead of Django's dev server. +exec daphne -b 0.0.0.0 -p 8000 procollab.asgi:application