diff --git a/feeds/migrations/0004_item_notify.py b/feeds/migrations/0004_item_notify.py new file mode 100644 index 0000000..534f315 --- /dev/null +++ b/feeds/migrations/0004_item_notify.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1 on 2026-10-04 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('feeds', '0003_blog_flags_words'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='notify', + field=models.BooleanField(db_index=True, default=False), + ), + ] diff --git a/feeds/models.py b/feeds/models.py index 7b81112..5665cfe 100644 --- a/feeds/models.py +++ b/feeds/models.py @@ -88,6 +88,9 @@ class Item(models.Model): paywalled = models.BooleanField(default=False) image_url = models.URLField(max_length=2000, blank=True) words = models.PositiveIntegerField(default=0) # of the content: the reading time + # To be announced (a blog with the bell on): set when the item came in, not on a + # source's first fetch and not for posts older than refresh.NOTIFY_MAX_AGE. + notify = models.BooleanField(default=False, db_index=True) read_at = models.DateTimeField(null=True, blank=True) starred = models.BooleanField(default=False) story = models.ForeignKey(Story, null=True, blank=True, on_delete=models.SET_NULL, related_name='items') diff --git a/feeds/refresh.py b/feeds/refresh.py index 5f96656..ca9dc16 100644 --- a/feeds/refresh.py +++ b/feeds/refresh.py @@ -25,6 +25,7 @@ BLOG_KEEP = 1000 # items kept per blog BLOG_FIRST_UNREAD_DAYS = 7 # a new blog's older articles start out read UNDATED_FIRST_UNREAD = 3 # of a new feed without dates, only the first few GONE_KEEP_DAYS = 30 # expired news rows kept (without text) against re-adding +NOTIFY_MAX_AGE = timedelta(days=2) # older posts are never announced (feeds dig up old ones) def settings_for(user): @@ -91,6 +92,8 @@ def _apply(source, result, now, keep_days): if source.kind == Source.BLOG and first and ( old_undated or fields['published'] < now - timedelta(days=BLOG_FIRST_UNREAD_DAYS)): item.read_at = now + # The bell: never for what a first fetch brings, never for old posts. + item.notify = source.notify and not first and not undated and fields['published'] >= now - NOTIFY_MAX_AGE new.append(item) if new: try: diff --git a/feeds/tests.py b/feeds/tests.py index 4245fb8..8cc483f 100644 --- a/feeds/tests.py +++ b/feeds/tests.py @@ -334,3 +334,29 @@ class BlogFeedTest(TestCase): self.assertTrue(self.by_id()[self.rare.id]['fav']) favs = [i['title'] for i in self.client.get('/feeds/blogs/?fav=1').json()['items']] self.assertEqual(favs, ['Rare essay']) + + +class NotifyTest(TestCase): + def setUp(self): + self.u = User.objects.create_user('tester', password='pw12345678') + self.client.force_login(self.u) + self.src = Source.objects.create(user=self.u, url='https://example.org/feed', title='Blog', kind='blog', notify=True) + + def fetch(self, items): + with mock.patch('feeds.refresh._fetch_feed', return_value=fetched(rss(items))): + refresh.refresh([self.src]) + + def test_rules(self): + old = (timezone.now() - timedelta(days=5)).strftime('%a, %d %b %Y %H:%M:%S +0000') + self.fetch([{'guid': 'a', 'title': 'Schon da', 'text': LONG}]) # first fetch: never + start = self.client.get('/feeds/notifications/').json() + self.assertEqual(start['items'], []) + self.fetch([{'guid': 'a', 'title': 'Schon da', 'text': LONG}, + {'guid': 'b', 'title': 'Neuer Post', 'text': LONG}, + {'guid': 'c', 'title': 'Alter Post', 'text': LONG, 'date': old}]) + got = self.client.get(f"/feeds/notifications/?after={start['last']}").json() + self.assertEqual([i['title'] for i in got['items']], ['Neuer Post']) + # Seen up to there: nothing again; read or bell off: nothing either. + self.assertEqual(self.client.get(f"/feeds/notifications/?after={got['last']}").json()['items'], []) + Source.objects.filter(pk=self.src.pk).update(notify=False) + self.assertEqual(self.client.get(f"/feeds/notifications/?after={start['last']}").json()['items'], []) diff --git a/feeds/urls.py b/feeds/urls.py index ebbe95f..a3b5271 100644 --- a/feeds/urls.py +++ b/feeds/urls.py @@ -21,6 +21,7 @@ urlpatterns = [ path('items//', views.item, name='feeds_item'), path('items//star/', views.star, name='feeds_star'), path('settings/', views.settings, name='feeds_settings'), + path('notifications/', views.notifications, name='feeds_notifications'), path('logins/', views.save_login, name='feeds_save_login'), path('logins/delete/', views.delete_login, name='feeds_delete_login'), ] diff --git a/feeds/views.py b/feeds/views.py index de64433..9caed23 100644 --- a/feeds/views.py +++ b/feeds/views.py @@ -19,6 +19,8 @@ Items: GET /feeds/items// the article (fetches the page if t POST /feeds/items//star/ {starred} GET /feeds/starred/ Settings: POST /feeds/settings/ {news_keep_days} +Notices: GET /feeds/notifications/?after= new posts of blogs with the bell on (devices + remember the last id; without after: only that id) Logins: POST /feeds/logins/ {source, username, password} subscriber login for its site POST /feeds/logins/delete/ {source} """ @@ -477,6 +479,24 @@ def star(request, pk): return JsonResponse({'updated': n}) +@csrf_exempt +@require_http_methods(['GET']) +def notifications(request): + """What a device should announce: posts marked when they came in, still + unread, of blogs that still have the bell on, after the id the device saw + last. Without `after` (a device's first time) nothing, only where to start.""" + if (err := _auth(request)): + return err + qs = Item.objects.filter(user=request.user, notify=True) + last = qs.order_by('-id').values_list('id', flat=True).first() or 0 + after = request.GET.get('after', '') + if not after.isdigit(): + return JsonResponse({'items': [], 'last': last}) + items = (qs.filter(id__gt=int(after), read_at=None, gone=False, source__notify=True) + .select_related('source').order_by('-published')[:50]) + return JsonResponse({'items': [_item_data(i) for i in items], 'last': last}) + + @csrf_exempt @require_http_methods(['POST']) def save_login(request): diff --git a/static/js/feeds.js b/static/js/feeds.js index 35c7352..80f9fa4 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -327,6 +327,8 @@ '' + '' + + '' + ''; } if (!st.items.length) return h + '

' + (st.loading ? 'Lädt …' : (st.blogUnread ? 'Alles gelesen.' : 'Keine Artikel.')) + '

'; @@ -383,7 +385,7 @@ '

' + title + ' ' + list.length + '

' + list.map(x => x.id === st.editing ? editForm(x) : '