]> git.mar77i.info Git - chat/commitdiff
towards basic chatting master
authormar77i <mar77i@protonmail.ch>
Fri, 8 May 2026 07:28:43 +0000 (09:28 +0200)
committermar77i <mar77i@protonmail.ch>
Fri, 8 May 2026 07:30:37 +0000 (09:30 +0200)
15 files changed:
channel/migrations/0002_initial.py
channel/models.py
channel/serializers.py
channel/static/channel/chatutils.js [moved from chat/static/chat/chatutils.js with 100% similarity]
channel/templates/channel/main.html
channel/views.py
chat/static/chat/base.js [new file with mode: 0644]
chat/static/chat/styles.css
chat/templates/chat/base.html
rest/serializers.py
rest/urls.py
rest/views.py
user/serializers.py
user/templates/user/user.html
user/views.py

index d6dd329be25e5137c61a1de4bce95c86f8bf545c..1972dd5f1f0cb71847a59da717da59641002fbb7 100644 (file)
@@ -58,7 +58,7 @@ class Migration(migrations.Migration):
             field=models.ForeignKey(
                 editable=False,
                 on_delete=django.db.models.deletion.CASCADE,
             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,
             ),
         ),
                 to=settings.AUTH_USER_MODEL,
             ),
         ),
index a48b1767b475c1c8423f013a0429e1f1765e1bd1..09e3eab09047b0a1183922dbb8e22fc0731368e2 100644 (file)
@@ -19,7 +19,7 @@ class PrivateMessage(Model):
         User, related_name="pms_sent", on_delete=CASCADE, editable=False
     )
     recipient = ForeignKey(
         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()
 
     )
     text = TextField()
 
index 6f53eea8fa692cea61f946e11ba2d22b5f0012cd..903f702e954970e9f912d6b4a371dd5b6fae82b9 100644 (file)
@@ -13,6 +13,7 @@ class PrivateMessageSerializer(ModelSerializer):
         "recipient_id",
         "text",
     ]
         "recipient_id",
         "text",
     ]
+    update_fields = ["text"]
 
     def from_json(self, data, instance=None):
         if instance is None:
 
     def from_json(self, data, instance=None):
         if instance is None:
index 1e040502f9c568f91b446c4ccbb1a5d79322d0b0..d0599bf9ab6f7a359856932440be43783c01186a 100644 (file)
@@ -3,7 +3,7 @@
 {% block head %}
     {{ block.super }}
     <link rel="stylesheet" href="{% static 'channel/styles.css' %}">
 {% block head %}
     {{ block.super }}
     <link rel="stylesheet" href="{% static 'channel/styles.css' %}">
-    <script src="{% static 'chat/chatutils.js' %}"></script>
+    <script src="{% static 'channel/chatutils.js' %}"></script>
 {% endblock head %}
 {% block header %}
     {% if messages %}
 {% endblock head %}
 {% block header %}
     {% if messages %}
index f9566ffdc0e95f0074650671af31f1320bcb4814..857d492e36f9703527e9acef0356b48236cffc34 100644 (file)
@@ -1,5 +1,27 @@
+from django.db.models import Q
 from django.views.generic import TemplateView
 
 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 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 (file)
index 0000000..eb2c384
--- /dev/null
@@ -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);
+            }
+        }
+    );
+}());
index 9fbcca3ea80a0e1c05d34b69554cd619ed5113ab..0fb9a2df1b3ec397ab9bea9bd8c5521a0199e59a 100644 (file)
@@ -6,3 +6,59 @@
 html, body, main {
     height: 100%;
 }
 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 */
+}
index 2cf9d0d3412e45aa95c09a6441db150f35936138..8378e6d6a83bb9612ed26a626eff2d8f57bf80bb 100644 (file)
@@ -11,6 +11,7 @@
                 <meta name="keywords" content="{{ meta_keywords }}">
             {% endblock meta %}
             <link rel="stylesheet" href="{% static 'chat/styles.css' %}">
                 <meta name="keywords" content="{{ meta_keywords }}">
             {% endblock meta %}
             <link rel="stylesheet" href="{% static 'chat/styles.css' %}">
+            <script src="{% static 'chat/base.js' %}"></script>
             <title>
                 {% block title %}
                     {{ title|default:'Untitled' }}
             <title>
                 {% block title %}
                     {{ title|default:'Untitled' }}
index 954e756f60ebe492a009c4efc8231777e11f213d..05e9b2a6f2842c724f5b5c907905615f6ceb090f 100644 (file)
@@ -14,8 +14,10 @@ from django.urls import reverse
 class ModelSerializer:
     model: Type[Model]
     fields = ["id", "url"]
 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):
         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 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 = {}
         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
                 continue
+            value = data[field_name]
             field = self.model._meta.get_field(field_name)
             if isinstance(field, DateTimeField):
                 value = datetime.fromisoformat(value)
             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):
                 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}")
                     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
                 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)
index ceac71c58984c042d6a90e080f44a782e1526a44..ca951630c6dcf036cc4c301221daab11bcb760db 100644 (file)
@@ -5,20 +5,15 @@ urlpatterns = []
 
 
 def get_urls(view_class, name, *extra_patterns):
 
 
 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}/",
             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",
                 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)
index e7416fcdf04e51cf7e07f1f4e681f02831570983..0135228a9358918a5c726a014df489efced0d131 100644 (file)
@@ -1,13 +1,10 @@
 import json
 from functools import cached_property
 from typing import Type
 import json
 from functools import cached_property
 from typing import Type
-from urllib.parse import urlunsplit
 
 from django.conf import settings
 
 from django.conf import settings
-from django.contrib.auth import logout
 from django.db.models import QuerySet
 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
 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):
 
 @method_decorator(csrf_exempt, name="dispatch")
 class ModelRestView(View):
-    http_method_names = []
-    list_all = False
     method_map = {}
     method_map = {}
-
     serializer: Type[ModelSerializer]
 
     def get_queryset(self):
         return QuerySet(self.serializer.model).all()
 
     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()
 
     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()
         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:
             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(
         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
             )
         else:
             prev_url = None
@@ -56,9 +49,6 @@ class ModelRestView(View):
             "result": [to_json(instance) for instance in queryset],
         }
 
             "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
     @staticmethod
     def get_json_dump_kwargs():
         if settings.DEBUG:  # pragma: no cover
@@ -76,7 +66,14 @@ class ModelRestView(View):
         )
 
     def create(self):
         )
 
     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(
 
     def detail(self):
         return HttpResponse(
@@ -88,21 +85,13 @@ class ModelRestView(View):
         )
 
     def update(self):
         )
 
     def update(self):
-        return self.create_or_update(self.get_object())
-
-    def create_or_update(self, *args, **kwargs):
         serializer = self.serializer(self.request)
         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()
 
     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):
         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:
         try:
             return getattr(self, self.action)()
         except Exception as e:
@@ -124,10 +111,14 @@ class ModelRestView(View):
                 status=500,
             )
 
                 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"]
index a04c4a2aad639129dc6e869b4c22456ba92afdc5..0a49e37792092d242a33180c0193f40875488fd5 100644 (file)
@@ -15,6 +15,7 @@ class UserSerializer(ModelSerializer):
         "date_joined",
         "channels",
     ]
         "date_joined",
         "channels",
     ]
+    update_fields = ["username", "first_name", "last_name"]
 
     def to_json(self, instance):
         result = super().to_json(instance)
 
     def to_json(self, instance):
         result = super().to_json(instance)
index 28885a9d8e365bb9d6053815753a0d7b2c10e1b5..36451075eca02b75a1584db99d1a19494a06ee4c 100644 (file)
@@ -6,7 +6,7 @@
 {% endblock head %}
 {% block main %}
     <h1>{{ title }}</h1>
 {% 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>
         <ul class="messages">
             {% for message in messages %}
                 <li class="{{ message.tags|default:'' }}">{{ message }}</li>
index 0e19fca0667891d4491ea207473413d0565228d4..c2c3e2d6ffc9f080f5dbf44d9a94f3487dc7b4e2 100644 (file)
@@ -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 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,
 from .email import (
     send_password_reset_email,
     send_register_confirmation_email,
@@ -172,7 +172,7 @@ class ResetPasswordView(FormView):
         return super().form_valid(form)
 
 
         return super().form_valid(form)
 
 
-class UserRestView(ModelRestView):
+class UserRestView(PrivilegeRequiredMixin, ModelRestView):
     serializer = UserSerializer
 
     def get_queryset(self):
     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
         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)
         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()