Skip to content

Commit 2128917

Browse files
authored
Merge pull request #576 from PROCOLLAB-github/dev
Dev
2 parents 2735076 + 38af27b commit 2128917

10 files changed

Lines changed: 116 additions & 46 deletions

File tree

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
up:
22
docker compose -f docker-compose.yml up -d
33
down:
4-
docker compose -f docker-compose.yml down
4+
docker compose -f docker-compose.yml down
5+
6+
run-local:
7+
poetry run daphne -b 0.0.0.0 -p 8000 procollab.asgi:application

chats/consumers/chat.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,15 @@ async def connect(self):
7777
await self.channel_layer.group_add(
7878
EventGroupType.GENERAL_EVENTS, self.channel_name
7979
)
80-
await self.accept()
80+
# Confirm selected subprotocol so browser clients finish handshake.
81+
subprotocol = None
82+
if (
83+
self.scope.get("subprotocols")
84+
and len(self.scope["subprotocols"]) >= 1
85+
):
86+
subprotocol = self.scope["subprotocols"][0]
87+
88+
await self.accept(subprotocol=subprotocol)
8189

8290
async def disconnect(self, close_code):
8391
"""User disconnected from websocket"""

core/auth/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Authentication utilities for ASGI/WebSocket middleware.
3+
"""
Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from urllib.parse import parse_qs
2-
31
import jwt
42
from channels.db import database_sync_to_async
53
from django.conf import settings
@@ -97,33 +95,37 @@ def get_user(scope):
9795

9896
class TokenAuthMiddleware:
9997
"""
100-
Custom middleware that takes a token from the query string and authenticates via Django Rest Framework authtoken.
98+
Custom middleware that takes a token from WebSocket subprotocols and authenticates via JWT.
10199
"""
102100

101+
SUBPROTOCOL_KEYWORD = "Bearer"
102+
103103
def __init__(self, app):
104104
# Store the ASGI application we were passed
105105
self.app = app
106106

107107
async def __call__(self, scope, receive, send):
108-
# Look up user from query string
109-
110-
# TODO: (you should also do things like
111-
# checking if it is a valid user ID, or if scope["user" ] is already
112-
# populated).
113-
114-
query_string = scope["query_string"].decode()
115-
query_dict = parse_qs(query_string)
116-
try:
117-
token = query_dict["token"][0]
118-
if token is None:
119-
raise ValueError("Token is missing from headers")
108+
# Extract token from Sec-WebSocket-Protocol header.
109+
token = self._extract_token_from_subprotocol(scope.get("subprotocols", []))
120110

111+
if token:
121112
scope["token"] = token
122113
scope["user"] = await get_user(scope)
123-
except (ValueError, KeyError, IndexError):
124-
# Token is missing from query string
114+
else:
125115
from django.contrib.auth.models import AnonymousUser
126116

127117
scope["user"] = AnonymousUser()
128118

129119
return await self.app(scope, receive, send)
120+
121+
def _extract_token_from_subprotocol(self, subprotocols: list[str]) -> str | None:
122+
"""
123+
Expect subprotocols in the form ["Bearer", "<JWT>"].
124+
"""
125+
if not subprotocols:
126+
return None
127+
128+
if len(subprotocols) >= 2 and subprotocols[0] == self.SUBPROTOCOL_KEYWORD:
129+
return subprotocols[1]
130+
131+
return None

core/log/middleware.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,34 @@
1-
from loguru import logger
2-
from django.conf import settings
1+
import copy
32
import logging
3+
4+
from django.conf import settings
5+
from loguru import logger
6+
47
from core.log.utils import InterceptHandler
58

69

10+
def _add_logger_handler(path: str, level: str) -> None:
11+
"""
12+
Attach loguru handler, falling back to synchronous mode if multiprocessing
13+
queues are not permitted (e.g. limited dev envs).
14+
"""
15+
kwargs = copy.deepcopy(settings.LOGURU_LOGGING)
16+
try:
17+
logger.add(path, level=level, **kwargs)
18+
except PermissionError:
19+
kwargs.pop("enqueue", None)
20+
logger.add(path, level=level, **kwargs)
21+
22+
723
class CustomLoguruMiddleware:
824
def __init__(self, get_response):
925
self.get_response = get_response
1026
logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True)
1127

1228
if settings.DEBUG:
13-
logger.add(
14-
f"{settings.BASE_DIR}/log/debug.log",
15-
level="DEBUG",
16-
**settings.LOGURU_LOGGING,
17-
)
18-
logger.add(
19-
f"{settings.BASE_DIR}/log/info.log",
20-
level="INFO",
21-
**settings.LOGURU_LOGGING,
22-
)
23-
logger.add(
24-
f"{settings.BASE_DIR}/log/warning.log",
25-
level="WARNING",
26-
**settings.LOGURU_LOGGING,
27-
)
29+
_add_logger_handler(f"{settings.BASE_DIR}/log/debug.log", "DEBUG")
30+
_add_logger_handler(f"{settings.BASE_DIR}/log/info.log", "INFO")
31+
_add_logger_handler(f"{settings.BASE_DIR}/log/warning.log", "WARNING")
2832

2933
def __call__(self, request):
3034
response = self.get_response(request)

files/models.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import reprlib
22

3-
from django.contrib.auth import get_user_model
3+
from django.conf import settings
44
from django.db import models
55

6-
User = get_user_model()
7-
86

97
class UserFile(models.Model):
108
"""
@@ -20,7 +18,11 @@ class UserFile(models.Model):
2018
"""
2119

2220
link = models.URLField(primary_key=True, null=False)
23-
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
21+
user = models.ForeignKey(
22+
settings.AUTH_USER_MODEL,
23+
on_delete=models.SET_NULL,
24+
null=True,
25+
)
2426
datetime_uploaded = models.DateTimeField(auto_now_add=True)
2527
name = models.TextField(blank=False, default="file")
2628
extension = models.TextField(blank=True, default="")

procollab/asgi.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import os
2-
import chats.routing
32

43
from channels.routing import ProtocolTypeRouter, URLRouter
54
from django.core.asgi import get_asgi_application
65

7-
from chats.middleware import TokenAuthMiddleware
8-
96
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "procollab.settings")
107

8+
# Ensure Django app registry is loaded before importing project routes.
9+
django_asgi_app = get_asgi_application()
10+
11+
from core.auth.middleware import TokenAuthMiddleware # noqa: E402
12+
from procollab.websocket_routing import websocket_urlpatterns # noqa: E402
13+
1114
application = ProtocolTypeRouter(
1215
{
13-
"http": get_asgi_application(),
14-
"websocket": TokenAuthMiddleware(URLRouter(chats.routing.websocket_urlpatterns)),
16+
"http": django_asgi_app,
17+
"websocket": TokenAuthMiddleware(URLRouter(websocket_urlpatterns)),
1518
}
1619
)

procollab/websocket_routing.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from chats.routing import websocket_urlpatterns as chat_websocket_urlpatterns
2+
3+
websocket_urlpatterns = []
4+
websocket_urlpatterns += chat_websocket_urlpatterns

projects/views.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,43 @@ def patch(self, request, project_pk: int, user_to_leader_pk: int) -> Response:
671671
class DuplicateProjectView(APIView):
672672
permission_classes = [IsAuthenticated, CanBindProjectToProgram]
673673

674+
@staticmethod
675+
def _copy_collaborators(original_project: Project, new_project: Project) -> None:
676+
"""
677+
Copy all collaborators from the source project to the duplicated one.
678+
Keep the leader collaborator (auto-created by signal) in sync with the original.
679+
"""
680+
leader_id = new_project.leader_id
681+
collaborators_to_create: list[Collaborator] = []
682+
leader_collaborator = None
683+
684+
for collaborator in original_project.collaborator_set.select_related("user").all():
685+
if collaborator.user_id == leader_id:
686+
leader_collaborator = collaborator
687+
continue
688+
689+
collaborators_to_create.append(
690+
Collaborator(
691+
user=collaborator.user,
692+
project=new_project,
693+
role=collaborator.role,
694+
specialization=collaborator.specialization,
695+
)
696+
)
697+
698+
if collaborators_to_create:
699+
Collaborator.objects.bulk_create(collaborators_to_create)
700+
701+
if leader_collaborator:
702+
Collaborator.objects.update_or_create(
703+
user_id=leader_id,
704+
project=new_project,
705+
defaults={
706+
"role": leader_collaborator.role,
707+
"specialization": leader_collaborator.specialization,
708+
},
709+
)
710+
674711
@swagger_auto_schema(
675712
request_body=ProjectDuplicateRequestSerializer,
676713
responses={201: ProjectDuplicateRequestSerializer(), 400: "Validation error"},
@@ -706,6 +743,8 @@ def post(self, request):
706743
cover=original_project.cover,
707744
)
708745

746+
self._copy_collaborators(original_project, new_project)
747+
709748
program_link = PartnerProgramProject.objects.create(
710749
partner_program=partner_program, project=new_project
711750
)

scripts/startup.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@
22

33
python manage.py migrate
44
python manage.py collectstatic --no-input
5-
python manage.py runserver 0.0.0.0:8000
5+
6+
# Use Daphne ASGI server instead of Django's dev server.
7+
exec daphne -b 0.0.0.0 -p 8000 procollab.asgi:application

0 commit comments

Comments
 (0)