Rafał Pitoń 10 лет назад
Родитель
Сommit
c046a46568

+ 1 - 0
docs/developers/index.rst

@@ -42,6 +42,7 @@ Following references cover everything you want to know about writing your own ap
    forms
    mails
    markup
+   notifications
    settings
    shortcuts
    template_tags

+ 33 - 0
docs/developers/notifications.rst

@@ -0,0 +1,33 @@
+=============
+Notifications
+=============
+
+
+Modern site in which users interact with each other needs quick and efficient notifications system to let users know of each other actions as quickly as possible.
+
+Misago implements such system and exposes simple as part of it, located in :py:mod:`misago.notifications`:
+
+
+notify_user
+-----------
+
+.. function:: notify_user(user, message, url, trigger, formats=None, sender=None, update_user=True)
+
+* ``user:`` User to notify.
+* ``message:`` Notification message.
+* ``trigger:`` short text used to identify this message for ``read_user_notification`` function.
+* ``formats:`` Optional. Dict of formats for ``message`` argument that should be boldened.
+* ``sender:`` Optional. User that notification origins from.
+* ``update_user:``Optional. Boolean controlling if to call ``user.update`` after setting notification, or not.
+
+
+read_user_notification
+----------------------
+
+.. function:: read_user_notification(user, trigger, atomic=True)
+
+Sets user notification identified by ``trigger`` as read.
+
+* ``user:`` User to whom notification belongs to
+* ``trigger:`` Short text used to identify messages to trigger as read.
+* ``atomic:`` Lets you control if you should wrap this in dedicated transaction.

+ 6 - 0
docs/developers/settings.rst

@@ -219,6 +219,12 @@ MISAGO_MARKUP_EXTENSIONS
 List of python modules extending Misago markup.
 
 
+MISAGO_NOTIFICATIONS_MAX_AGE
+----------------------------
+
+Max age, in days, of notifications stored in database. Notifications older than this will be delted.
+
+
 MISAGO_RANKING_LENGTH
 ---------------------
 

+ 1 - 0
docs/index.rst

@@ -31,6 +31,7 @@ Table of Contents
    developers/forms
    developers/mails
    developers/markup
+   developers/notifications
    developers/settings
    developers/shortcuts
    developers/template_tags

+ 6 - 0
misago/conf/defaults.py

@@ -232,6 +232,12 @@ MISAGO_ADMIN_NAMESPACES = (
 MISAGO_ADMIN_SESSION_EXPIRATION = 60
 
 
+# Max age of notifications in days
+# Notifications older than this are deleted
+# On very active forums its better to keep this smaller
+MISAGO_NOTIFICATIONS_MAX_AGE = 40
+
+
 # Function used for generating individual avatar for user
 MISAGO_DYNAMIC_AVATAR_DRAWER = 'misago.users.avatars.dynamic.draw_default'
 

+ 48 - 8
misago/notifications/api.py

@@ -1,14 +1,21 @@
 from django.db.models import F
-from django.db.transaction import atomic
+from django.db import transaction
 from django.utils.html import escape
 
 from misago.notifications.models import Notification
+from misago.notifications.utils import hash_trigger
 
 
-__all__ = ['notify_user']
+__all__ = [
+    'notify_user',
+    'read_user_notification',
+    'read_all_user_alerts',
+    'assert_real_new_notifications_count',
+]
 
 
-def notify_user(user, message, url, trigger, formats=None, sender=None):
+def notify_user(user, message, url, trigger, formats=None, sender=None,
+                update_user=True):
     message_escaped = escape(message)
     if formats:
         final_formats = {}
@@ -17,7 +24,7 @@ def notify_user(user, message, url, trigger, formats=None, sender=None):
         message_escaped = message_escaped % final_formats
 
     new_notification = Notification(user=user,
-                                    trigger=trigger,
+                                    trigger=hash_trigger(trigger),
                                     url=url,
                                     message=message_escaped)
 
@@ -28,12 +35,45 @@ def notify_user(user, message, url, trigger, formats=None, sender=None):
 
     new_notification.save()
     user.new_notifications = F('new_notifications') + 1
+    if update_user:
+        user.save(update_fields=['new_notifications'])
+
+
+def read_user_notification(user, trigger, atomic=True):
+    if user.new_notifications:
+        if atomic:
+            with transaction.atomic():
+                _real_read_user_notification(user, trigger)
+        else:
+            _real_read_user_notification(user, trigger)
+
+
+def _real_read_user_notification(user, trigger):
+    trigger_hash = hash_trigger(trigger)
+    update_qs = user.notifications.filter(is_new=True)
+    update_qs = update_qs.filter(trigger=trigger_hash)
+    updated = update_qs.update(is_new=False)
+
+    if updated:
+        user.new_notifications -= updated
+        if user.new_notifications < 0:
+            # Cos no. of changed rows returned via update()
+            # isn't always accurate
+            user.new_notifications = 0
     user.save(update_fields=['new_notifications'])
 
 
-#def read_user_notifications(user, trigger):
-#    pass
+def read_all_user_alerts(user):
+    locked_user = user.lock()
+    user.new_notifications = 0
+    locked_user.new_notifications = 0
+    locked_user.save(update_fields=['new_notifications'])
+    locked_user.notifications.update(is_new=False)
 
 
-#def read_all_user_alerts(user):
-#    pass
+def assert_real_new_notifications_count(user):
+    if user.new_notifications:
+        real_new_count = user.notifications.filter(is_new=True).count()
+        if real_new_count != user.new_notifications:
+            user.new_notifications = real_new_count
+            user.save(update_fields=['new_notifications'])

+ 0 - 0
misago/notifications/management/__init__.py


+ 0 - 0
misago/notifications/management/commands/__init__.py


+ 18 - 0
misago/notifications/management/commands/deleteoldnotifications.py

@@ -0,0 +1,18 @@
+from datetime import timedelta
+
+from django.conf import settings
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+
+from misago.notifications.models import Notification
+
+
+class Command(BaseCommand):
+    help = 'Deletes old notifications.'
+
+    def handle(self, *args, **options):
+        cutoff = timedelta(days=settings.MISAGO_NOTIFICATIONS_MAX_AGE)
+        cutoff_date = timezone.now() - cutoff
+
+        Notification.objects.filter(date__lte=cutoff_date).delete()
+        self.stdout.write('Old notifications have been deleted.')

+ 0 - 0
misago/notifications/tests/__init__.py


+ 82 - 0
misago/notifications/tests/test_api.py

@@ -0,0 +1,82 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from misago.notifications import api
+
+
+class NotificationsAPITests(TestCase):
+    def setUp(self):
+        User = get_user_model()
+        self.test_user = User.objects.create_user('Bob', 'bob@bob.com',
+                                                  'Pass.123')
+
+    def reload_test_user(self):
+        self.test_user = get_user_model().objects.get(id=self.test_user.id)
+
+    def test_notify_user(self):
+        """notify_user sets new notification on user"""
+        api.notify_user(self.test_user,
+                        "Test notify %(token)s",
+                        "/users/",
+                        "test",
+                        {'token': 'Bob'},
+                        self.test_user)
+
+        self.reload_test_user()
+        self.assertEqual(self.test_user.new_notifications, 1)
+
+    def test_read_user_notification(self):
+        """read_user_notification reads user notification"""
+        api.notify_user(self.test_user,
+                        "Test notify %(token)s",
+                        "/users/",
+                        "test",
+                        {'token': 'Bob'},
+                        self.test_user)
+        self.reload_test_user()
+
+        api.read_user_notification(self.test_user, "test")
+
+        self.assertEqual(self.test_user.new_notifications, 0)
+        notifications_qs = self.test_user.notifications.filter(is_new=True)
+        self.assertEqual(notifications_qs.count(), 0)
+
+    def test_read_all_user_alerts(self):
+        """read_all_user_alerts marks user notifications as read"""
+        api.notify_user(self.test_user,
+                        "Test notify %(token)s",
+                        "/users/",
+                        "test",
+                        {'token': 'Bob'},
+                        self.test_user)
+        self.reload_test_user()
+
+        api.read_all_user_alerts(self.test_user)
+        self.assertEqual(self.test_user.new_notifications, 0)
+
+        notifications_qs = self.test_user.notifications.filter(is_new=True)
+        self.assertEqual(notifications_qs.count(), 0)
+
+    def test_assert_real_new_notifications_count(self):
+        """assert_real_new_notifications_count syncs user notifications"""
+        api.notify_user(self.test_user,
+                        "Test notify %(token)s",
+                        "/users/",
+                        "test",
+                        {'token': 'Bob'},
+                        self.test_user)
+        self.reload_test_user()
+        api.read_all_user_alerts(self.test_user)
+
+        self.test_user.new_notifications = 42
+        self.test_user.save()
+
+        self.reload_test_user()
+        self.assertEqual(self.test_user.new_notifications, 42)
+
+        notifications_qs = self.test_user.notifications.filter(is_new=True)
+        self.assertEqual(notifications_qs.count(), 0)
+
+        api.assert_real_new_notifications_count(self.test_user)
+        self.reload_test_user()
+        self.assertEqual(self.test_user.new_notifications, 0)

+ 75 - 0
misago/notifications/tests/test_views.py

@@ -0,0 +1,75 @@
+from django.contrib.auth import get_user_model
+from django.core.urlresolvers import reverse
+
+from misago.admin.testutils import AdminTestCase
+
+from misago.notifications.api import notify_user
+
+
+class NotificationViewsTestCase(AdminTestCase):
+    def setUp(self):
+        self.view_link = reverse('misago:notifications')
+        self.ajax_header = {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
+        super(NotificationViewsTestCase, self).setUp()
+
+    def reload_test_admin(self):
+        self.test_admin = get_user_model().objects.get(id=self.test_admin.id)
+
+    def notify_user(self):
+        notify_user(self.test_admin,
+                    "Test notify %(token)s",
+                    "/users/",
+                    "test",
+                    {'token': 'Bob'},
+                    self.test_admin)
+        self.test_admin = get_user_model().objects.get(id=self.test_admin.id)
+
+    def test_get(self):
+        """get request to list renders list"""
+        response = self.client.get(self.view_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("have any notifications", response.content)
+
+        self.notify_user()
+
+        response = self.client.get(self.view_link)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Test notify <strong>Bob</strong>", response.content)
+
+    def test_post(self):
+        """post request to list sets all notifications as read"""
+        self.notify_user()
+
+        response = self.client.post(self.view_link)
+        self.assertEqual(response.status_code, 302)
+
+        response = self.client.get(self.view_link)
+        self.assertEqual(response.status_code, 200)
+
+        self.reload_test_admin()
+        self.assertEqual(self.test_admin.new_notifications, 0)
+
+    def test_get_ajax(self):
+        """get request to list renders list"""
+        response = self.client.get(self.view_link, **self.ajax_header)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("have any notifications", response.content)
+
+        self.notify_user()
+
+        response = self.client.get(self.view_link, **self.ajax_header)
+        self.assertEqual(response.status_code, 200)
+        self.assertIn("Test notify <strong>Bob</strong>", response.content)
+
+    def test_post_ajax(self):
+        """post request to list sets all notifications as read"""
+        self.notify_user()
+
+        response = self.client.post(self.view_link, **self.ajax_header)
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.get(self.view_link)
+        self.assertEqual(response.status_code, 200)
+
+        self.reload_test_admin()
+        self.assertEqual(self.test_admin.new_notifications, 0)

+ 0 - 1
misago/notifications/urls.py

@@ -3,5 +3,4 @@ from django.conf.urls import include, patterns, url
 
 urlpatterns = patterns('misago.notifications.views',
     url(r'^notifications/$', 'notifications', name='notifications'),
-    url(r'^notifications/new/$', 'new_notification', name='new_notification'),
 )

+ 2 - 7
misago/notifications/utils.py

@@ -1,13 +1,8 @@
 from hashlib import md5
 
 
-def target_trigger(message, obj_id=None):
-    hash_seed = [message]
-
-    if obj_pk:
-      hash_seed.append(unicode(obj_id))
-
-    return md5(hash_seed.join('+')).hexdigest[:8]
+def hash_trigger(message):
+    return md5(message).hexdigest()[:8]
 
 
 def variables_dict(plain=None, links=None, users=None, threads=None):

+ 28 - 25
misago/notifications/views.py

@@ -1,14 +1,28 @@
+from datetime import timedelta
+
+from django.contrib import messages
+from django.db.transaction import atomic
 from django.http import JsonResponse
-from django.shortcuts import render
+from django.shortcuts import redirect, render
+from django.utils import timezone
 from django.utils.translation import ugettext as _, ungettext
 
 from misago.core.uiviews import uiview
-
 from misago.users.decorators import deny_guests
 
+from misago.notifications import (read_all_user_alerts,
+                                  assert_real_new_notifications_count)
+
 
 @deny_guests
 def notifications(request):
+    if request.method == 'POST':
+        read_all(request)
+        if not request.is_ajax():
+            return redirect('misago:notifications')
+    else:
+        assert_real_new_notifications_count(request.user)
+
     if request.is_ajax():
         return dropdown(request)
     else:
@@ -17,8 +31,8 @@ def notifications(request):
 
 def dropdown(request):
     template = render(request, 'misago/notifications/dropdown.html', {
-        'items': request.user.notifications.order_by('-id').iterator(),
         'notifications_count': request.user.notifications.count(),
+        'items': request.user.notifications.order_by('-id')[:15],
     })
 
     return JsonResponse({
@@ -29,7 +43,17 @@ def dropdown(request):
 
 
 def full_page(request):
-    return render(request, 'misago/notifications/full.html')
+    return render(request, 'misago/notifications/full.html', {
+        'notifications_count': request.user.notifications.count(),
+        'items': request.user.notifications.order_by('-id'),
+    })
+
+
+@atomic
+def read_all(request):
+    if not request.is_ajax():
+        messages.success(request, _("All notifications were set as read."))
+    read_all_user_alerts(request.user)
 
 
 @uiview('misago_notifications')
@@ -47,24 +71,3 @@ def event_sender(request, resolver_match):
         'count': request.user.new_notifications,
         'message': message,
     }
-
-
-@deny_guests
-def new_notification(request):
-    from django.contrib.auth import get_user_model
-    from faker import Factory
-    faker = Factory.create()
-
-    sender = get_user_model().objects.order_by('?')[:1][0]
-
-    from misago.notifications import notify_user
-    notify_user(
-        request.user,
-        _("Replied to %(thread)s"),
-        '/',
-        'test',
-        formats={'thread': 'LoremIpsum'},
-        sender=sender,)
-
-    from django.http import HttpResponse
-    return HttpResponse('Notification set.')

+ 11 - 0
misago/static/misago/css/misago/dropdowns.less

@@ -144,6 +144,17 @@
       }
     }
 
+    .dropdown-footer {
+      background-color: @dropdown-title-bg;
+      border-top: 1px solid @dropdown-title-border;
+      border-radius: 0px 0px @border-radius-base @border-radius-base;
+      margin-bottom: 0px;
+      padding: @padding-base-vertical @padding-base-horizontal;
+
+      color: @dropdown-title-color;
+      font-weight: bold;
+    }
+
     &>li {
       .badge {
         margin-left: @line-height-computed / 2;

+ 6 - 0
misago/static/misago/css/misago/header.less

@@ -102,6 +102,12 @@
   &>.container {
     .page-actions {
       float: right;
+      margin: -3px 0px;
+
+      &>form {
+      	margin: 0px;
+      	padding: 0px;
+      }
 
       .pull-left {
         margin-left: @line-height-computed / 2;

+ 79 - 1
misago/static/misago/css/misago/notifications.less

@@ -4,9 +4,11 @@
 
 
 // Notifications dropdowned list
+//
+//==
 .user-notifications-nav {
   .dropdown-menu {
-    width: 280px;
+    width: 320px;
 
     .dropdown-title {
       margin-bottom: 0px;
@@ -21,6 +23,19 @@
         border-bottom: 1px solid @dropdown-divider-bg;
         padding: @padding-base-vertical @padding-base-horizontal;
 
+        &.new {
+          .state-icon {
+            color: @state-active;
+          }
+        }
+
+        .state-icon {
+          float: left;
+          width: @font-size-base * 1.5;
+
+          color: @state-default;
+        }
+
         a {
           display: block;
           margin: 0px;
@@ -42,10 +57,13 @@
         }
 
         footer {
+          padding-left: @font-size-base * 1.5;
+
           font-size: @font-size-small;
 
           a {
             display: inline;
+            margin-right: @line-height-computed / 4;
           }
         }
 
@@ -59,3 +77,63 @@
     }
   }
 }
+
+
+// Full notifications list
+//
+//==
+.notifications-list {
+  li {
+    border-bottom: 1px solid @hr-border;
+    overflow: auto;
+    padding: (@line-height-computed / 2) 0px;
+
+    &.new {
+      .state-icon {
+        color: @state-active;
+      }
+    }
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    .state-icon {
+      float: left;
+      margin-right: @line-height-computed / 2;
+
+      color: @state-default;
+      font-size: @font-size-large;
+    }
+
+    .message {
+      float: left;
+      font-size: @font-size-large;
+
+      &:link, &:active, &:visited {
+        color: @text-color;
+      }
+
+      &:hover {
+        background: none;
+
+        text-decoration: none;
+      }
+
+      &:active {
+        background: none;
+
+        color: @state-clicked;
+      }
+    }
+
+    footer {
+      margin-top: 3px;
+      float: right;
+
+      a {
+        margin-right: @line-height-computed / 3;
+      }
+    }
+  }
+}

+ 47 - 9
misago/static/misago/js/misago-notifications.js

@@ -20,10 +20,17 @@ $(function() {
         $badge.fadeOut();
     }
     $link.attr("title", data.message);
-    $link.tooltip('fixTitle');
 
     if (ajax_cache != null && data.count != ajax_cache.count) {
-      ajax_cache = null
+      ajax_cache = null;
+      if ($container.hasClass('open')) {
+        $container.find('.btn-refresh').fadeIn();
+        if (data.count > 0) {
+          $container.find('.btn-read-all').fadeIn();
+        } else {
+          $container.find('.btn-read-all').fadeOut();
+        }
+      }
     }
   }
 
@@ -34,14 +41,45 @@ $(function() {
   var $display = $container.find('.display');
   var $loader = $container.find('.loader');
 
+  function handle_list_response(data) {
+    ajax_cache = data;
+    $loader.hide();
+    $display.html(data.html);
+    $display.show();
+    $.misago_dom().changed();
+    $link.tooltip('destroy');
+  }
+
+  function fetch_list() {
+    $.get($link.attr('href'), function(data) {
+      handle_list_response(data)
+    });
+  }
+
+  $container.on('click', '.btn-refresh', function() {
+    $display.hide();
+    $loader.show();
+    fetch_list();
+    $link.tooltip('destroy');
+  });
   $container.on('show.bs.dropdown', function () {
     if (ajax_cache == null) {
-      $.get($link.attr('href'), function(data) {
-        ajax_cache = data;
-        $loader.hide();
-        $display.html(data.html);
-        $.misago_dom().changed();
-      });
+      fetch_list();
+    } else {
+      $link.tooltip('destroy');
     }
-  })
+  });
+  $container.on('hide.bs.dropdown', function() {
+    misago_tooltip($link);
+    $container.find('.btn-refresh').hide();
+  });
+  $container.on('submit', '.read-all-notifications', function() {
+    $display.hide();
+    $loader.show();
+    $.post($link.attr('href'), $(this).serialize(), function(data) {
+      handle_list_response(data);
+      $.misago_ui().query_server();
+    });
+    return false;
+  });
 });

+ 18 - 0
misago/static/misago/js/misago-tooltips.js

@@ -7,3 +7,21 @@ $(function() {
     $('.tooltip-right').tooltip({placement: 'right', container: 'body'});
   });
 });
+
+// Helper for registering tooltips
+function misago_tooltip(element) {
+  placement = null;
+  if (element.hasClass('tooltip-top')) {
+    placement = 'top';
+  } else if (element.hasClass('tooltip-bottom')) {
+    placement = 'bottom';
+  } else if (element.hasClass('tooltip-left')) {
+    placement = 'left';
+  } else if (element.hasClass('tooltip-right')) {
+    placement = 'right';
+  }
+
+  if (placement) {
+    element.tooltip({placement: placement, container: 'body'})
+  }
+}

+ 21 - 0
misago/templates/misago/notifications/dropdown.html

@@ -23,6 +23,13 @@
 <ul class="list-unstyled">
   {% for item in items %}
   <li{% if item.is_new %} class="new"{% endif %}>
+    <div class="state-icon">
+      {% if item.is_new %}
+      <span class="fa fa-circle"></span>
+      {% else %}
+      <span class="fa fa-circle-o"></span>
+      {% endif %}
+    </div>
     <a href="{{ item.url }}">
       {{ item.message|safe }}
     </a>
@@ -45,3 +52,17 @@
   </li>
   {% endfor %}
 </ul>
+<div class="dropdown-footer">
+  <form action="{% url 'misago:notifications' %}" class="read-all-notifications" method="POST">
+    {% csrf_token %}
+    <a href="{% url 'misago:notifications' %}" name="read-all" class="btn btn-default btn-sm">
+      {% trans "See all" %}
+    </a>
+    <button type="button" class="btn btn-default btn-refresh btn-sm" style="display: none;">
+      {% trans "Refresh" %}
+    </button>
+    <button type="submit" class="btn btn-default btn-read-all btn-sm pull-right" {% if not user.new_notifications %}style="display: none;"{% endif %}>
+      {% trans "Mark all as read" %}
+    </button>
+  </form>
+</div>

+ 81 - 1
misago/templates/misago/notifications/full.html

@@ -1 +1,81 @@
-FULL ALERTS LIST!
+{% extends "misago/base.html" %}
+{% load humanize i18n %}
+
+
+{% block title %}{% blocktrans trimmed with notifications=notifications_count|intcomma count counter=notifications_count %}
+You have {{ notifications }} notification
+{% plural %}
+You have {{ notifications }} notifications
+{% endblocktrans %} | {{ block.super }}{% endblock title %}
+
+
+{% block content %}
+<div class="page-header">
+  <div class="container">
+    <h1 class="pull-left">
+      <span class="fa fa-bell-o"></span>
+      {% if user.new_notifications %}
+        {% blocktrans trimmed with notifications=user.new_notifications count counter=user.new_notifications%}
+        You have {{ notifications }} new notification
+        {% plural %}
+        You have {{ notifications }} new notifications
+        {% endblocktrans %}
+      {% else %}
+        {% blocktrans trimmed with notifications=notifications_count count counter=user.new_notifications%}
+        You have {{ notifications }} notification
+        {% plural %}
+        You have {{ notifications }} notifications
+        {% endblocktrans %}
+      {% endif %}
+    </h1>
+
+    <div class="page-actions">
+      <form method="post">
+        {% csrf_token %}
+        <button type="submit" class="btn btn-default">
+          {% trans "Mark all read" %}
+        </button>
+      </form>
+    </div>
+
+  </div>
+</div>
+<div class="container">
+  {% if notifications_count %}
+  <ul class="list-unstyled notifications-list">
+    {% for item in items %}
+    <li{% if item.is_new %} class="new"{% endif %}>
+
+      <div class="state-icon">
+        {% if item.is_new %}
+        <span class="fa fa-circle tooltip-top" title="{% trans "New notification" %}"></span>
+        {% else %}
+        <span class="fa fa-circle-o tooltip-top" title="{% trans "Old notification" %}"></span>
+        {% endif %}
+      </div>
+
+      <a href="{{ item.url }}" class="message">{{ item.message|safe }}</a>
+
+      <footer class="text-muted">
+        {% if item.sender_username %}
+          {% if item.sender_id %}
+          <a href="{% url USER_PROFILE_URL user_slug=item.sender_slug user_id=item.sender_id %}" class="item-title">{{ item.sender_username }}</a>
+          {% else %}
+          <strong class="item-title">{{ item.sender_username }}</strong>
+          {% endif %}
+        {% endif %}
+        <abbr class="tooltip-top dynamic time-ago" title="{{ item.date }}" data-timestamp="{{ item.date|date:"c" }}">
+          {{ item.date|date }}
+        </abbr>
+      </footer>
+
+    </li>
+    {% endfor %}
+  </ul>
+  {% else %}
+  <p class="lead">
+    {% trans "You don't have any notifications." %}
+  </p>
+  {% endif %}
+</div>
+{% endblock content %}