From: mar77i Date: Fri, 8 May 2026 07:28:43 +0000 (+0200) Subject: towards basic chatting X-Git-Url: https://git.mar77i.info/?a=commitdiff_plain;h=03e6f3c5212822ec8e54b27593ea8292f12a9ef4;p=chat towards basic chatting --- diff --git a/channel/migrations/0002_initial.py b/channel/migrations/0002_initial.py index d6dd329..1972dd5 100644 --- a/channel/migrations/0002_initial.py +++ b/channel/migrations/0002_initial.py @@ -58,7 +58,7 @@ class Migration(migrations.Migration): field=models.ForeignKey( editable=False, on_delete=django.db.models.deletion.CASCADE, - related_name="pns_recieved", + related_name="pms_recieved", to=settings.AUTH_USER_MODEL, ), ), diff --git a/channel/models.py b/channel/models.py index a48b176..09e3eab 100644 --- a/channel/models.py +++ b/channel/models.py @@ -19,7 +19,7 @@ class PrivateMessage(Model): User, related_name="pms_sent", on_delete=CASCADE, editable=False ) recipient = ForeignKey( - User, related_name="pns_recieved", on_delete=CASCADE, editable=False + User, related_name="pms_recieved", on_delete=CASCADE, editable=False ) text = TextField() diff --git a/channel/serializers.py b/channel/serializers.py index 6f53eea..903f702 100644 --- a/channel/serializers.py +++ b/channel/serializers.py @@ -13,6 +13,7 @@ class PrivateMessageSerializer(ModelSerializer): "recipient_id", "text", ] + update_fields = ["text"] def from_json(self, data, instance=None): if instance is None: diff --git a/chat/static/chat/chatutils.js b/channel/static/channel/chatutils.js similarity index 100% rename from chat/static/chat/chatutils.js rename to channel/static/channel/chatutils.js diff --git a/channel/templates/channel/main.html b/channel/templates/channel/main.html index 1e04050..d0599bf 100644 --- a/channel/templates/channel/main.html +++ b/channel/templates/channel/main.html @@ -3,7 +3,7 @@ {% block head %} {{ block.super }} - + {% endblock head %} {% block header %} {% if messages %} diff --git a/channel/views.py b/channel/views.py index f9566ff..857d492 100644 --- a/channel/views.py +++ b/channel/views.py @@ -1,5 +1,27 @@ +from django.db.models import Q from django.views.generic import TemplateView +from rest.views import ModelRestView, PrivilegeRequiredMixin +from .serializers import ChannelSerializer, PrivateMessageSerializer + class ChannelMainView(TemplateView): template_name = "channel/main.html" + + +class PrivateMessageRestView(ModelRestView): + serializer = PrivateMessageSerializer + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(Q(sender=self.request.user.pk) | Q(recipient=self.request.user.pk)) + ) + + +class ChannelRestView(PrivilegeRequiredMixin, ModelRestView): + serializer = ChannelSerializer + + def get_queryset(self): + return super().get_queryset().filter(users=self.request.user.pk) diff --git a/chat/static/chat/base.js b/chat/static/chat/base.js new file mode 100644 index 0000000..eb2c384 --- /dev/null +++ b/chat/static/chat/base.js @@ -0,0 +1,29 @@ +(function () { + function fade_messages(event) { + var li = event.target; + if (li.tagName !== "LI" || li.classList.contains("fade-out")) { + return; + } + li.classList.add("fade-out"); + li.addEventListener( + "transitionend", + function () { + li.remove(); + }, + {once: true} + ); + } + document.addEventListener( + "readystatechange", + function () { + var sel; + if (document.readyState !== "complete") { + return; + } + sel = document.querySelector("ul.messages"); + if (sel !== null) { + sel.addEventListener("click", fade_messages); + } + } + ); +}()); diff --git a/chat/static/chat/styles.css b/chat/static/chat/styles.css index 9fbcca3..0fb9a2d 100644 --- a/chat/static/chat/styles.css +++ b/chat/static/chat/styles.css @@ -6,3 +6,59 @@ html, body, main { height: 100%; } + +ul.messages { + position: fixed; + top: 0; + left: 0; + width: 100%; + padding: 10px; + list-style: none; +} + +/* +DEBUG = 10 +INFO = 20 +SUCCESS = 25 +WARNING = 30 +ERROR = 40 +*/ + +ul.messages li { + color: midnightblue; + padding: 12px 20px; + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + transition: opacity 0.5s ease; + opacity: 1; + cursor: pointer; + user-select: none; /* Prevents text highlighting on quick clicks */ +} + +ul.messages li.debug { + background-color: lightcyan; +} + +ul.messages li.info { + background-color: lightblue; +} + +ul.messages li.success { + background-color: lightgreen; +} + +ul.messages li.warning { + background-color: orange; +} + +ul.messages li.error { + background-color: lightcoral; +} + +ul.messages li.fade-out { + transition-delay: 0.5s; + opacity: 0; + pointer-events: none; /* Prevents double-clicks during the fade */ +} diff --git a/chat/templates/chat/base.html b/chat/templates/chat/base.html index 2cf9d0d..8378e6d 100644 --- a/chat/templates/chat/base.html +++ b/chat/templates/chat/base.html @@ -11,6 +11,7 @@ {% endblock meta %} + {% block title %} {{ title|default:'Untitled' }} diff --git a/rest/serializers.py b/rest/serializers.py index 954e756..05e9b2a 100644 --- a/rest/serializers.py +++ b/rest/serializers.py @@ -14,8 +14,10 @@ from django.urls import reverse class ModelSerializer: model: Type[Model] fields = ["id", "url"] + create_fields = None + update_fields = None - def __init__(self, request): + def __init__(self, request=None): self.request = request def field_to_json(self, field_name, instance): @@ -36,13 +38,20 @@ class ModelSerializer: def to_json(self, instance): return {key: self.field_to_json(key, instance) for key in self.fields} - def from_json(self, data, instance=None): - if instance is None: - instance = self.model() + def get_fields_attr(self): + if self.request.method == "POST" and self.create_fields is not None: + return "create_fields" + elif self.request.method == "PUT" and self.update_fields is not None: + return "update_fields" + return "fields" + + def from_json(self, data, instance): m2m = {} - for field_name, value in data.items(): - if field_name not in self.fields or field_name in ("id", "url"): + fields_attr = self.get_fields_attr() + for field_name in getattr(self, fields_attr): + if field_name not in data or field_name in ("id", "url"): continue + value = data[field_name] field = self.model._meta.get_field(field_name) if isinstance(field, DateTimeField): value = datetime.fromisoformat(value) @@ -50,10 +59,17 @@ class ModelSerializer: m2m[field_name] = value continue if isinstance(field, ForeignKey): - # disallow reassigning FKs on update - if instance.pk is not None: + # when no update_fields are specified, disallow assigning FKs on update + if fields_attr == "fields" and instance.pk is not None: raise ValueError(f"Not allowed to update: {field_name}") + # TODO: validate field lists elsewhere if not field_name.endswith("_id") or not isinstance(value, int): raise KeyError(f"Use fk_id fields with an id: {field_name}") setattr(instance, field_name, value) return instance, m2m + + def save(self, data, instance): + instance, m2m = self.from_json(data, instance) + instance.save() + for field_name, value in m2m.items(): + getattr(instance, field_name).set(value) diff --git a/rest/urls.py b/rest/urls.py index ceac71c..ca95163 100644 --- a/rest/urls.py +++ b/rest/urls.py @@ -5,20 +5,15 @@ urlpatterns = [] def get_urls(view_class, name, *extra_patterns): - urlpatterns.extend( - ( + for method_map in ( + {"get": "list", "post": "create"}, + {"get": "detail", "put": "update", "delete": "delete"}, + ): + urlpatterns.append( path( f"{name}/", - view_class.as_view(method_map={"get": "list", "post": "create"}), + view_class.as_view(method_map=method_map), name=f"api-{name}-list", - ), - path( - f"{name}/<int:id>/", - view_class.as_view( - method_map={"get": "detail", "put": "update", "delete": "delete"} - ), - name=f"api-{name}-detail", - ), - *extra_patterns, + ) ) - ) + urlpatterns.extend(extra_patterns) diff --git a/rest/views.py b/rest/views.py index e7416fc..0135228 100644 --- a/rest/views.py +++ b/rest/views.py @@ -1,13 +1,10 @@ import json from functools import cached_property from typing import Type -from urllib.parse import urlunsplit from django.conf import settings -from django.contrib.auth import logout from django.db.models import QuerySet -from django.http import HttpResponse -from django.urls import reverse +from django.http import HttpResponse, HttpResponseNotAllowed from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt @@ -17,16 +14,14 @@ from .serializers import ModelSerializer @method_decorator(csrf_exempt, name="dispatch") class ModelRestView(View): - http_method_names = [] - list_all = False method_map = {} - serializer: Type[ModelSerializer] def get_queryset(self): return QuerySet(self.serializer.model).all() - safe_chars = "/#%[]=:;$&()+,!?*@'~" + def get_object(self): + return self.get_queryset().get(pk=self.kwargs["id"]) def paginate(self, queryset, to_json): query = self.request.GET.copy() @@ -36,7 +31,7 @@ class ModelRestView(View): if "since" in query: queryset = queryset.filter(pk__gte=int(query["since"])) page_size = queryset.count() - elif not "list_all": + elif "list_all" not in query: page_size = settings.DEFAULT_PAGE_SIZE queryset = queryset[max(count - page_size, 0) :] else: @@ -44,9 +39,7 @@ class ModelRestView(View): if count > page_size: query["before"] = queryset[0].pk prev_url = self.request.build_absolute_uri( - urlunsplit( - ("", "", self.request.path, query.urlencode(self.safe_chars), "") - ), + f"{self.request.path}?{query.urlencode('/:@[]')}" ) else: prev_url = None @@ -56,9 +49,6 @@ class ModelRestView(View): "result": [to_json(instance) for instance in queryset], } - def get_object(self): - return self.get_queryset().get(pk=self.kwargs["id"]) - @staticmethod def get_json_dump_kwargs(): if settings.DEBUG: # pragma: no cover @@ -76,7 +66,14 @@ class ModelRestView(View): ) def create(self): - return self.create_or_update(status=201) + serializer = self.serializer(self.request) + instance = serializer.model() + serializer.save(json.load(self.request), instance) + return HttpResponse( + json.dumps(serializer.to_json(instance)), + content_type="application/json", + status=201, + ) def detail(self): return HttpResponse( @@ -88,21 +85,13 @@ class ModelRestView(View): ) def update(self): - return self.create_or_update(self.get_object()) - - def create_or_update(self, *args, **kwargs): serializer = self.serializer(self.request) - instance, m2m = serializer.from_json(json.load(self.request), *args) - instance.save() - for field_name, value in m2m.items(): - getattr(instance, field_name).set(value) - if settings.REST_CREATE_UPDATE_RETURN_RESULT: - return HttpResponse( - json.dumps(serializer.to_json(instance)), - content_type="application/json", - **kwargs, - ) - return HttpResponse(status=204) # pragma: no cover + instance = self.get_object() + serializer.save(json.load(self.request), instance) + return HttpResponse( + json.dumps(serializer.to_josn(instance)), + content_type="application/json", + ) def delete(self): self.get_object().delete() @@ -113,8 +102,6 @@ class ModelRestView(View): return self.method_map[self.request.method.lower()] def dispatch(self, request, *args, **kwargs): - if not request.user.is_authenticated: - return self.handle_no_permission() try: return getattr(self, self.action)() except Exception as e: @@ -124,10 +111,14 @@ class ModelRestView(View): status=500, ) - def handle_no_permission(self): - logout(self.request) - return HttpResponse( - json.dumps({"Location": reverse("login")}), - content_type="application/json", - status=401, - ) + +class PrivilegeRequiredMixin: + def dispatch(self, request, *args, **kwargs): + if request.method not in (permitted_methods := self.get_permitted_methods()): + return HttpResponseNotAllowed(permitted_methods) + return super().dispatch(request, *args, **kwargs) + + def get_permitted_methods(self): + if self.request.user.is_privileged(): + return ["GET", "PUT", "DELETE"] if "id" in self.kwargs else ["GET", "POST"] + return ["GET"] diff --git a/user/serializers.py b/user/serializers.py index a04c4a2..0a49e37 100644 --- a/user/serializers.py +++ b/user/serializers.py @@ -15,6 +15,7 @@ class UserSerializer(ModelSerializer): "date_joined", "channels", ] + update_fields = ["username", "first_name", "last_name"] def to_json(self, instance): result = super().to_json(instance) diff --git a/user/templates/user/user.html b/user/templates/user/user.html index 28885a9..3645107 100644 --- a/user/templates/user/user.html +++ b/user/templates/user/user.html @@ -6,7 +6,7 @@ {% endblock head %} {% block main %} <h1>{{ title }}</h1> - {% if messages %} + {% if messages or True %} <ul class="messages"> {% for message in messages %} <li class="{{ message.tags|default:'' }}">{{ message }}</li> diff --git a/user/views.py b/user/views.py index 0e19fca..c2c3e2d 100644 --- a/user/views.py +++ b/user/views.py @@ -16,7 +16,7 @@ from django.views.decorators.http import require_GET from django.views.generic import FormView, RedirectView from chat.bridge import bridge -from rest.views import ModelRestView +from rest.views import ModelRestView, PrivilegeRequiredMixin from .email import ( send_password_reset_email, send_register_confirmation_email, @@ -172,7 +172,7 @@ class ResetPasswordView(FormView): return super().form_valid(form) -class UserRestView(ModelRestView): +class UserRestView(PrivilegeRequiredMixin, ModelRestView): serializer = UserSerializer def get_queryset(self): @@ -182,12 +182,10 @@ class UserRestView(ModelRestView): user_id = self.request.user.pk if self.request.resolver_match.view_name == "api-user-current": self.kwargs["id"] = user_id - if ( - self.action in ("create", "delete") and not request.user.is_privileged() - ) or ( - self.action == "update" - and not request.user.is_privileged() - and self.kwargs["id"] != user_id - ): - return self.handle_no_permission() return super().dispatch(request, *args, **kwargs) + + def get_permitted_methods(self): + user = self.request.user + if not user.is_privileged() and self.kwargs.get("id") == user.pk: + return ["GET", "PUT"] + return super().get_permitted_methods()