From d5cd77a04f129e3ea231c093f9609544d2e95599 Mon Sep 17 00:00:00 2001 From: Josiah White Date: Wed, 8 Jul 2026 11:22:22 -0700 Subject: [PATCH 1/8] Creating feed files --- activity/feeds.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++ subjects/feeds.py | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 activity/feeds.py create mode 100644 subjects/feeds.py diff --git a/activity/feeds.py b/activity/feeds.py new file mode 100644 index 00000000..31cbe294 --- /dev/null +++ b/activity/feeds.py @@ -0,0 +1,74 @@ +from itertools import chain +from operator import attrgetter + +from django.contrib.syndication.views import Feed +from django.utils.feedgenerator import Rss201rev2Feed + + +from activity.models import ( + GeoreferenceGroup, + SitewideMilestone, + SubjectIntroduction, + UserMilestone, +) + +MAX_ITEMS = 50 + + +class SitewideActivityFeed(Feed): + """ + Site-wide RSS feed showing the latest image additions. + """ + feed_type = Rss201rev2Feed + title = "Yesterdays - Site-wide Activity" + link = "/activity/" # Update with your main activity page URL + description = "The latest historical images, georeferences, and tags from Yesterdays." + + def items(self): + # Fetch the most recent items of each activity type. + # Adjust 'created_at' to match your actual datetime field (e.g., 'timestamp'). + georefs = GeoreferenceGroup.objects.all().order_by('-created_at')[:MAX_ITEMS] + subjects = SubjectIntroduction.objects.all().order_by('-created_at')[:MAX_ITEMS] + user_milestones = UserMilestone.objects.all().order_by('-created_at')[:MAX_ITEMS] + sitewide_milestones = SitewideMilestone.objects.all().order_by('-created_at')[:MAX_ITEMS] + + # Chain them together and sort chronologically + combined = sorted( + chain(georefs, subjects, user_milestones, sitewide_milestones), + key=attrgetter('created_at'), + reverse=True + ) + return combined[:MAX_ITEMS] + + def item_title(self, item): + if isinstance(item, GeoreferenceGroup): + return f"Images georeferenced by {getattr(item, 'user', 'a user')}" + elif isinstance(item, SubjectIntroduction): + return f"New subject added: {item.subject.title}" + elif isinstance(item, UserMilestone): + return f"{item.user.username} reached {item.count} georeferences!" + elif isinstance(item, SitewideMilestone): + return f"Yesterdays reached {item.count} images georeferenced!" + return str(item) + + def item_description(self, item): + if isinstance(item, GeoreferenceGroup): + count = getattr(item, 'count', 'Multiple') + return f"{count} images were recently georeferenced." + elif isinstance(item, SubjectIntroduction): + return "A new subject was introduced to the catalog." + elif isinstance(item, UserMilestone): + return "A user has reached a new georeferencing milestone." + elif isinstance(item, SitewideMilestone): + return "The community has reached a new site-wide milestone." + return str(item) + + def item_link(self, item): + if hasattr(item, 'get_absolute_url'): + return item.get_absolute_url() + if isinstance(item, SubjectIntroduction) and hasattr(item, 'subject'): + return f"/subjects/{item.subject.pk}/" + return "/activity/" + + def item_pubdate(self, item): + return getattr(item, 'created_at', None) diff --git a/subjects/feeds.py b/subjects/feeds.py new file mode 100644 index 00000000..f104e0b2 --- /dev/null +++ b/subjects/feeds.py @@ -0,0 +1,52 @@ +from django.contrib.syndication.views import Feed +from django.shortcuts import get_object_or_404 +from django.utils.feedgenerator import Rss201rev2Feed + +from images.models import Image +from subjects.models import Subject + +class SubjectActivityFeed(Feed): + """ + Subject-specific RSS feed showing new photos tagged with a given subject. + """ + feed_type = Rss201rev2Feed + + def get_object(self, request, subject_id): + # Grabs the subject object when the URL is requested + return get_object_or_404(Subject, pk=subject_id) + + def title(self, obj): + name = getattr(obj, 'name', f"Subject {obj.id}") + return f"Yesterdays - New images for {name}" + + def link(self, obj): + if hasattr(obj, 'get_absolute_url'): + return obj.get_absolute_url() + return f"/subjects/{obj.pk}/" + + def description(self, obj): + name = getattr(obj, 'name', f"Subject {obj.id}") + return f"Latest images tagged with: {name}." + + def items(self, obj): + # Assuming SubjectMapping links Image and Subject via a ForeignKey to Subject. + # The related lookup name (`subjectmapping__subject`) might need to be tweaked + # depending on your exact ForeignKey setup in SubjectMapping. + return Image.objects.filter( + subjectmapping__subject=obj + ).distinct().order_by('-created_at')[:50] + + def item_title(self, item): + title = getattr(item, 'title', f"Image {item.id}") + return f"New image tagged: {title}" + + def item_description(self, item): + return getattr(item, 'description', 'A historical image was tagged with this subject.') + + def item_link(self, item): + if hasattr(item, 'get_absolute_url'): + return item.get_absolute_url() + return f"/images/{item.pk}/" + + def item_pubdate(self, item): + return getattr(item, 'created_at', None) From 8bb5f28463d232f233759f4ca28f42c7adcf59a0 Mon Sep 17 00:00:00 2001 From: Josiah White Date: Wed, 8 Jul 2026 11:22:31 -0700 Subject: [PATCH 2/8] Adding feeds to URLs --- activity/urls.py | 4 +++- subjects/urls.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/activity/urls.py b/activity/urls.py index 82045c86..2bed5cce 100644 --- a/activity/urls.py +++ b/activity/urls.py @@ -1,9 +1,11 @@ from django.urls import path -from . import views +from activity import views +from activity.feeds import SitewideActivityFeed app_name = "activity" urlpatterns = [ path("", views.activity_feed, name="feed"), + path("feed/",SitewideActivityFeed(), name="site-feed") ] diff --git a/subjects/urls.py b/subjects/urls.py index 5c05308d..d4c0a6b5 100644 --- a/subjects/urls.py +++ b/subjects/urls.py @@ -1,6 +1,7 @@ from django.urls import path -from . import views +from subjects import views +from subjects.feeds import SubjectActivityFeed app_name = "subjects" @@ -9,6 +10,7 @@ path("", views.browse_subjects, name="browse_subjects"), path("map/", views.subjects_map, name="subjects_map"), path("/", views.subject_detail, name="subject_detail"), + path("/feed/", SubjectActivityFeed(), name="subject_feed"), path( "/similar/", views.find_similar_images_to_subject, From 6a4c4b7479ccc2f28cd96f9730feee4e03bd414a Mon Sep 17 00:00:00 2001 From: Josiah White Date: Wed, 8 Jul 2026 11:35:53 -0700 Subject: [PATCH 3/8] Adding collection introduction, sprucing up description --- activity/feeds.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/activity/feeds.py b/activity/feeds.py index 31cbe294..4f000b69 100644 --- a/activity/feeds.py +++ b/activity/feeds.py @@ -10,6 +10,7 @@ SitewideMilestone, SubjectIntroduction, UserMilestone, + CollectionIntroduction, ) MAX_ITEMS = 50 @@ -17,24 +18,24 @@ class SitewideActivityFeed(Feed): """ - Site-wide RSS feed showing the latest image additions. + Site-wide RSS feed showing the latest georeferences, subjects, and milestones. """ feed_type = Rss201rev2Feed title = "Yesterdays - Site-wide Activity" link = "/activity/" # Update with your main activity page URL - description = "The latest historical images, georeferences, and tags from Yesterdays." + description = "The latest georeferences subjects, and milestones in Yesterdays." def items(self): # Fetch the most recent items of each activity type. - # Adjust 'created_at' to match your actual datetime field (e.g., 'timestamp'). - georefs = GeoreferenceGroup.objects.all().order_by('-created_at')[:MAX_ITEMS] - subjects = SubjectIntroduction.objects.all().order_by('-created_at')[:MAX_ITEMS] - user_milestones = UserMilestone.objects.all().order_by('-created_at')[:MAX_ITEMS] + georefs = GeoreferenceGroup.objects.all().order_by('-ended_at')[:MAX_ITEMS] + user_milestones = UserMilestone.objects.all().order_by('-reached_at')[:MAX_ITEMS] sitewide_milestones = SitewideMilestone.objects.all().order_by('-created_at')[:MAX_ITEMS] + subjects = SubjectIntroduction.objects.all().order_by('-reached_at')[:MAX_ITEMS] + collections = CollectionIntroduction.objects.all().order_by('-created_at')[:MAX_ITEMS] # Chain them together and sort chronologically combined = sorted( - chain(georefs, subjects, user_milestones, sitewide_milestones), + chain(georefs, subjects, user_milestones, sitewide_milestones, collections), key=attrgetter('created_at'), reverse=True ) @@ -49,6 +50,8 @@ def item_title(self, item): return f"{item.user.username} reached {item.count} georeferences!" elif isinstance(item, SitewideMilestone): return f"Yesterdays reached {item.count} images georeferenced!" + elif isinstance(item, CollectionIntroduction): + return f"New collection added: {item.collection.name}" return str(item) def item_description(self, item): @@ -61,6 +64,8 @@ def item_description(self, item): return "A user has reached a new georeferencing milestone." elif isinstance(item, SitewideMilestone): return "The community has reached a new site-wide milestone." + elif isinstance(item, CollectionIntroduction): + return "A new collection was introduced to the catalog." return str(item) def item_link(self, item): From 2ba443f02cc67e2fe19685b9976160116b6f533e Mon Sep 17 00:00:00 2001 From: Josiah White Date: Wed, 8 Jul 2026 11:36:25 -0700 Subject: [PATCH 4/8] Using slug, not ID. Description and title changes --- subjects/feeds.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/subjects/feeds.py b/subjects/feeds.py index f104e0b2..356853a8 100644 --- a/subjects/feeds.py +++ b/subjects/feeds.py @@ -11,22 +11,20 @@ class SubjectActivityFeed(Feed): """ feed_type = Rss201rev2Feed - def get_object(self, request, subject_id): + def get_object(self, request, subject_slug): # Grabs the subject object when the URL is requested - return get_object_or_404(Subject, pk=subject_id) + return get_object_or_404(Subject, slug=subject_slug) - def title(self, obj): - name = getattr(obj, 'name', f"Subject {obj.id}") - return f"Yesterdays - New images for {name}" + def title(self, subject): + return f"Yesterdays - New images for {subject.name}" def link(self, obj): if hasattr(obj, 'get_absolute_url'): return obj.get_absolute_url() return f"/subjects/{obj.pk}/" - def description(self, obj): - name = getattr(obj, 'name', f"Subject {obj.id}") - return f"Latest images tagged with: {name}." + def description(self, subject): + return f"Latest images tagged with: {subject.name}." def items(self, obj): # Assuming SubjectMapping links Image and Subject via a ForeignKey to Subject. @@ -37,11 +35,10 @@ def items(self, obj): ).distinct().order_by('-created_at')[:50] def item_title(self, item): - title = getattr(item, 'title', f"Image {item.id}") - return f"New image tagged: {title}" + return f"New image tagged: {item.title}" def item_description(self, item): - return getattr(item, 'description', 'A historical image was tagged with this subject.') + return item.description def item_link(self, item): if hasattr(item, 'get_absolute_url'): From ed2c65b883b347adb3bce3a73367fd914758e14f Mon Sep 17 00:00:00 2001 From: Josiah White Date: Wed, 8 Jul 2026 12:06:22 -0700 Subject: [PATCH 5/8] Adding RSS feed tests --- api/tests.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/api/tests.py b/api/tests.py index c8d82075..ca294767 100644 --- a/api/tests.py +++ b/api/tests.py @@ -1108,3 +1108,41 @@ def test_cannot_revoke_other_users_consent(self): ) self.assertEqual(resp.status_code, 404) self.assertTrue(ApplicationConsent.objects.filter(pk=consent.pk).exists()) + + +# --------------------------------------------------------------------------- +# RSS Feeds +# --------------------------------------------------------------------------- + + +class TestSitewideActivityFeed(ApiFixturesMixin, TestCase): + def test_feed_status_and_type(self): + resp = self.client.get("/activity/feed/") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp["Content-Type"], "application/rss+xml; charset=utf-8") + + def test_feed_content(self): + resp = self.client.get("/activity/feed/") + content = resp.content.decode("utf-8") + + self.assertIn('Yesterdays - Site-wide Activity", content) + # Based on the test fixtures, we should have a milestone in the activity feed + self.assertIn("milestone", content.lower()) + + +class TestSubjectActivityFeed(ApiFixturesMixin, TestCase): + def test_feed_status_and_type(self): + resp = self.client.get(f"/subjects/{self.subject.slug}/feed/") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp["Content-Type"], "application/rss+xml; charset=utf-8") + + def test_feed_content(self): + resp = self.client.get(f"/subjects/{self.subject.slug}/feed/") + content = resp.content.decode("utf-8") + self.assertIn(' Date: Wed, 8 Jul 2026 12:50:01 -0700 Subject: [PATCH 6/8] Improving link, pubdate, guid --- activity/feeds.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/activity/feeds.py b/activity/feeds.py index 4f000b69..c5ff5a36 100644 --- a/activity/feeds.py +++ b/activity/feeds.py @@ -1,7 +1,9 @@ +import uuid from itertools import chain from operator import attrgetter from django.contrib.syndication.views import Feed +from django.urls import reverse from django.utils.feedgenerator import Rss201rev2Feed @@ -36,8 +38,8 @@ def items(self): # Chain them together and sort chronologically combined = sorted( chain(georefs, subjects, user_milestones, sitewide_milestones, collections), - key=attrgetter('created_at'), - reverse=True + key=attrgetter('created_at', 'reached_at', 'ended_at'), + reverse=True, ) return combined[:MAX_ITEMS] @@ -69,11 +71,25 @@ def item_description(self, item): return str(item) def item_link(self, item): + # Default to method if the model has one. Covers Image, Subject, Collection if hasattr(item, 'get_absolute_url'): return item.get_absolute_url() - if isinstance(item, SubjectIntroduction) and hasattr(item, 'subject'): - return f"/subjects/{item.subject.pk}/" + elif isinstance(item, SubjectIntroduction): + return item.subject.get_absolute_url() + elif isinstance(item, UserMilestone): + return reverse("user_profile", kwargs={"username": item.user.username}) + elif isinstance(item, CollectionIntroduction): + return item.collection.get_absolute_url() + + # GeoreferenceGroup and SitewideMilestone go nowhere? return "/activity/" def item_pubdate(self, item): - return getattr(item, 'created_at', None) + return getattr(item, 'created_at', None) or getattr(item, 'reached_at', None) or getattr(item, 'ended_at', None) + + def item_guid(self, item): + """ + Set a random UUID for each item, so that the exact same session can appear in + multiple feeds if necessary and won't be filtered by RSS clients. + """ + return str(uuid.uuid4()) From 1262427aa846f055f358f9ac677c141f7d5fb635 Mon Sep 17 00:00:00 2001 From: Josiah White Date: Wed, 8 Jul 2026 12:52:52 -0700 Subject: [PATCH 7/8] Improving items --- subjects/feeds.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/subjects/feeds.py b/subjects/feeds.py index 356853a8..45e75072 100644 --- a/subjects/feeds.py +++ b/subjects/feeds.py @@ -18,32 +18,28 @@ def get_object(self, request, subject_slug): def title(self, subject): return f"Yesterdays - New images for {subject.name}" - def link(self, obj): - if hasattr(obj, 'get_absolute_url'): - return obj.get_absolute_url() - return f"/subjects/{obj.pk}/" + def link(self, subject): + return subject.get_absolute_url() def description(self, subject): return f"Latest images tagged with: {subject.name}." - def items(self, obj): + def items(self, subject): # Assuming SubjectMapping links Image and Subject via a ForeignKey to Subject. # The related lookup name (`subjectmapping__subject`) might need to be tweaked # depending on your exact ForeignKey setup in SubjectMapping. return Image.objects.filter( - subjectmapping__subject=obj + subjectmapping__subject=subject, ).distinct().order_by('-created_at')[:50] - def item_title(self, item): - return f"New image tagged: {item.title}" + def item_title(self, image): + return f"New image tagged: {image.title}" - def item_description(self, item): - return item.description + def item_description(self, image): + return image.description def item_link(self, item): - if hasattr(item, 'get_absolute_url'): - return item.get_absolute_url() - return f"/images/{item.pk}/" + return item.get_absolute_url() - def item_pubdate(self, item): - return getattr(item, 'created_at', None) + def item_pubdate(self, image): + return image.created_at From 4340b34fabff99d6d930ad986ce2cf39a2391ace Mon Sep 17 00:00:00 2001 From: Josiah White Date: Wed, 8 Jul 2026 12:53:17 -0700 Subject: [PATCH 8/8] Adding guid --- subjects/feeds.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/subjects/feeds.py b/subjects/feeds.py index 45e75072..bd10c198 100644 --- a/subjects/feeds.py +++ b/subjects/feeds.py @@ -1,3 +1,5 @@ +import uuid + from django.contrib.syndication.views import Feed from django.shortcuts import get_object_or_404 from django.utils.feedgenerator import Rss201rev2Feed @@ -43,3 +45,10 @@ def item_link(self, item): def item_pubdate(self, image): return image.created_at + + def item_guid(self, subject): + """ + Set a random UUID for each item, so that the exact same session can appear in + multiple feeds if necessary and won't be filtered by RSS clients. + """ + return str(uuid.uuid4())