Show active users.
authorRadek Czajka <rczajka@rczajka.pl>
Wed, 9 Oct 2024 09:50:47 +0000 (11:50 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Wed, 9 Oct 2024 09:50:47 +0000 (11:50 +0200)
src/redakcja/static/css/master.css
src/redakcja/static/js/wiki/loader.js
src/redakcja/static/js/wiki/wikiapi.js
src/team/admin.py
src/team/migrations/0003_alter_profile_options_and_more.py [new file with mode: 0644]
src/team/models.py
src/wiki/templates/wiki/document_details_base.html
src/wiki/views.py

index 9d3744b..6bdf190 100644 (file)
@@ -228,3 +228,13 @@ img.tabclose {
 #source-editor .CodeMirror-wrap pre.CodeMirror-line-like {
        word-wrap: anywhere;
 }
+
+
+#people img {
+    margin-left: -10px;
+    border-radius: 100%;
+    opacity: .5;
+}
+#people img.active {
+    opacity: 1;
+}
index 7ea9b40..e9cf2b1 100644 (file)
@@ -113,10 +113,10 @@ $(function() {
         };
 
         $('body').mousemove(function(e) {
-            CurrentDocument.active = true;
+            CurrentDocument.active = new Date();
         });
         $('body').keydown(function(e) {
-            CurrentDocument.active = true;
+            CurrentDocument.active = new Date();
         });
 
         console.log("Fetching document's text");
@@ -141,13 +141,15 @@ $(function() {
                 console.log("Initial tab is:", active_tab)
                 $.wiki.switchToTab(active_tab);
 
-                /* every minute check for a newer version */
+                /* check for a newer version */
+                CurrentDocument.checkRevision({outdated: function(){
+                    $('#header').addClass('out-of-date');
+                }});
                 var revTimer = setInterval(function() {
                     CurrentDocument.checkRevision({outdated: function(){
                         $('#header').addClass('out-of-date');
-                        clearInterval(revTimer);
                     }});
-                }, 60 * 1000);
+                }, 5 * 1000);
             },
             failure: function() {
                 $('#loading-overlay').fadeOut();
index 14b8337..4a28d63 100644 (file)
 
            this.text = null;
            this.has_local_changes = false;
-            this.active = true;
+            this.active = new Date();
            this._lock = -1;
            this._context_lock = -1;
            this._lock_count = 0;
         checkRevision(params) {
             /* this doesn't modify anything, so no locks */
             var self = this;
-            let active = self.active;
-            self.active = false;
+            let active = new Date() - self.active < 30 * 1000;
             $.ajax({
                 method: "GET",
                 url: reverse("ajax_document_rev", self.id),
                 data: {
                     'a': active,
+                    'new': 1,
                 },
-                dataType: 'text',
+                dataType: 'json',
                 success: function(data) {
                     if (data == '') {
                         if (params.error)
                             params.error();
                     }
-                    else if (data != self.revision)
-                        params.outdated();
+                    else {
+                        let people = $('<div>');
+                        data.people.forEach((p) => {
+                            let item = $('<img>');
+                            item.attr('src', p.gravatar),
+                            item.attr(
+                                'title',
+                                p.name  + ' (' +
+                                    (p.active ? 'akt.' : 'nieakt.') +
+                                    ' od ' + p.since + ')')
+                            if (p.active) {
+                                item.addClass('active');
+                            }
+                            people.append(item);
+                        });
+                        $("#people").html(people);
+
+                        if (data.rev != self.revision) {
+                            params.outdated();
+                        }
+                   }
                 }
             });
         }
index b8c448a..6bdca87 100644 (file)
@@ -22,6 +22,7 @@ admin.site.register(User, CustomUserAdmin)
 
 
 @admin.register(models.Presence)
-class ProfileAdmin(admin.ModelAdmin):
-    list_display = ['user', 'timestamp', 'active']
+class PresenceAdmin(admin.ModelAdmin):
+    list_display = ['session_key', 'chunk', 'user', 'since', 'until', 'active']
+    raw_id_fields = ['chunk', 'user']
 
diff --git a/src/team/migrations/0003_alter_profile_options_and_more.py b/src/team/migrations/0003_alter_profile_options_and_more.py
new file mode 100644 (file)
index 0000000..f4e01d3
--- /dev/null
@@ -0,0 +1,47 @@
+# Generated by Django 4.1.9 on 2024-10-09 10:25
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ("team", "0002_profile_approve_by_default"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="profile",
+            options={"verbose_name": "profil", "verbose_name_plural": "profil"},
+        ),
+        migrations.RenameField(
+            model_name="presence",
+            old_name="timestamp",
+            new_name="since",
+        ),
+        migrations.AddField(
+            model_name="presence",
+            name="session_key",
+            field=models.CharField(default="", max_length=255),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="presence",
+            name="until",
+            field=models.DateTimeField(db_index=True, default="1970-01-01"),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name="presence",
+            name="user",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+    ]
index 6e53dd3..9bd4bf5 100644 (file)
@@ -1,3 +1,4 @@
+from datetime import timedelta
 from django.conf import settings
 from django.db import models
 from django.utils.timezone import now
@@ -16,18 +17,46 @@ class Profile(models.Model):
 
 
 class Presence(models.Model):
-    user = models.ForeignKey(settings.AUTH_USER_MODEL, models.CASCADE)
+    GAP_THRESHOLD = 60
+
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, models.CASCADE, null=True, blank=True)
+    session_key = models.CharField(max_length=255)
     chunk = models.ForeignKey('documents.Chunk', models.SET_NULL, blank=True, null=True)
-    timestamp = models.DateTimeField(auto_now_add=True, db_index=True)
+    since = models.DateTimeField(auto_now_add=True, db_index=True)
+    until = models.DateTimeField(db_index=True)
     active = models.BooleanField()
 
     @classmethod
-    def report(cls, user, chunk, active):
-        if user.is_anonymous or not hasattr(user, 'profile') or not user.profile.presence:
-            return
-        cls.objects.create(
+    def report(cls, user, session_key, chunk, active):
+        user = user if not user.is_anonymous else None
+        report = cls.objects.filter(
             user=user,
+            session_key=session_key,
+            chunk=chunk,
+            until__gt=now() - timedelta(seconds=cls.GAP_THRESHOLD)
+        ).order_by('-until').first()
+        if report is None or report.active != active:
+            report = cls.objects.create(
+                user=user,
+                session_key=session_key,
+                chunk=chunk,
+                active=active,
+                until=now(),
+            )
+        else:
+            report.until = now()
+            report.save()
+
+    @classmethod
+    def get_current(cls, session_key, chunk):
+        sessions = set()
+        presences = []
+        for p in cls.objects.filter(
             chunk=chunk,
-            timestamp=now(),
-            active=active
-        )
+            until__gt=now() - timedelta(seconds=cls.GAP_THRESHOLD)
+        ).exclude(session_key=session_key).order_by('-since'):
+            if p.session_key not in sessions:
+                sessions.add(p.session_key)
+                presences.append(p)
+        presences.reverse()
+        return presences
index 1906f01..88f837c 100644 (file)
@@ -55,6 +55,7 @@
       <a class='nav-item nav-link' href="{{ REDMINE_URL }}projects/wl-publikacje/wiki/Pomoc" target="_blank">
         {% trans "Help" %}</a>
 
+      <div id="people" class="nav-item nav-link" ></div>
 
       <div id="user-area">
         {% include "registration/head_login.html" %}
index 47f41f1..3e6fedb 100644 (file)
@@ -19,11 +19,13 @@ from django.utils.formats import localize
 from django.utils.translation import gettext as _
 from django.views.decorators.http import require_POST, require_GET
 from django.shortcuts import get_object_or_404, render
+from django_gravatar.helpers import get_gravatar_url
 from sorl.thumbnail import get_thumbnail
 
 from documents.models import Book, Chunk
 import sources.models
 from . import nice_diff
+from team.models import Presence
 from wiki import forms
 from wiki.helpers import (JSONResponse, JSONFormInvalid, JSONServerError,
                 ajax_require_permission)
@@ -317,12 +319,34 @@ def diff(request, chunk_id):
 
 @never_cache
 def revision(request, chunk_id):
+    if not request.session.session_key:
+        return HttpResponseForbidden("Not authorized.")
     doc = get_object_or_404(Chunk, pk=chunk_id)
     if not doc.book.accessible(request):
         return HttpResponseForbidden("Not authorized.")
-    Presence = apps.get_model('team', 'Presence')
-    Presence.report(request.user, doc, request.GET.get('a') == 'true')
-    return http.HttpResponse(str(doc.revision()))
+
+    Presence.report(
+        request.user, request.session.session_key,
+        doc,
+        request.GET.get('a') == 'true'
+    )
+
+    # Temporary compat for unreloaded clients.
+    if not request.GET.get('new'):
+        return http.HttpResponse(str(doc.revision()))
+
+    return JSONResponse({
+        'rev': doc.revision(),
+        'people': list([
+            {
+                'name': (p.user.first_name + ' ' + p.user.last_name) if p.user is not None else '?',
+                'gravatar': get_gravatar_url(p.user.email if p.user is not None else '-', size=26),
+                'since': p.since.strftime('%H:%M'),
+                'active': p.active,
+            }
+            for p in Presence.get_current(request.session.session_key, doc)
+        ]),
+    })
 
 
 @never_cache