Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changes/next_release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

- ff-707 (gh-480) — unified display of entry published dates.
2 changes: 0 additions & 2 deletions changes/unreleased.md

This file was deleted.

15 changes: 8 additions & 7 deletions ffun/ffun/api/spa/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ class Entry(BaseEntity):
markers: list[Marker] = []
score: int
scoreContributions: dict[TagId, int]
publishedAt: datetime.datetime
effectivePublishedAt: datetime.datetime
firstSeenAt: datetime.datetime
sourcePublishedAt: datetime.datetime
body: str | None = None
references: list[Reference] | None = None

Expand All @@ -177,12 +179,9 @@ def from_internal( # noqa: CFQ002
markers=list(markers),
score=score,
scoreContributions=score_contributions,
# THIS IS AN INTENDED BEHAVIOR
# we set publishedAt for the frontend as global entry creation time
# because it is the only reliable time with "published" meaning
# actual published_at is absolutely unreliable because comes from the third-party sources
# and can be broken in numerous ways.
publishedAt=entry.created_at,
effectivePublishedAt=entry.effective_published_at,
firstSeenAt=entry.created_at,
sourcePublishedAt=entry.published_at,
# Some APIs return full entry info, some return shorter info to save traffic and speed up the response.
body=entry.body if with_body else None,
references=[Reference.from_internal(reference) for reference in entry.references] if with_body else None,
Expand Down Expand Up @@ -571,6 +570,7 @@ def validate_period(cls, v: None | datetime.timedelta) -> None | datetime.timede

class GetLastEntriesResponse(api.APISuccess):
entries: list[Entry]
fallbackUsed: bool
tagsMapping: dict[TagId, TagUid]


Expand All @@ -588,6 +588,7 @@ def validate_period(cls, v: None | datetime.timedelta) -> None | datetime.timede

class GetLastCollectionEntriesResponse(api.APISuccess):
entries: list[Entry]
fallbackUsed: bool
tagsMapping: dict[TagId, TagUid]


Expand Down
16 changes: 10 additions & 6 deletions ffun/ffun/api/spa/http_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,18 +222,20 @@ async def api_get_last_collection_entries(

feed_ids = [feed_info.feed_id for feed_info in collection.feeds if feed_info.feed_id is not None]

entries = await l_domain.get_entries_by_filter_with_fallback(
load_result = await l_domain.get_entries_by_filter_with_fallback(
feeds_ids=feed_ids,
period=request.period,
limit=settings.max_returned_entries,
fallback_limit=settings.news_outside_period,
)

external_entries, tags_mapping = await _external_entries(
entries, with_body=False, user_id=None, min_tag_count=request.minTagCount
load_result.entries, with_body=False, user_id=None, min_tag_count=request.minTagCount
)

return entities.GetLastCollectionEntriesResponse(entries=external_entries, tagsMapping=tags_mapping)
return entities.GetLastCollectionEntriesResponse(
entries=external_entries, fallbackUsed=load_result.fallback_used, tagsMapping=tags_mapping
)


@api_public.post("/get-entries-by-ids") # type: ignore
Expand Down Expand Up @@ -444,18 +446,20 @@ async def api_get_last_entries(request: entities.GetLastEntriesRequest, user: Us

linked_feeds_ids = [link.feed_id for link in linked_feeds]

entries = await l_domain.get_entries_by_filter_with_fallback(
load_result = await l_domain.get_entries_by_filter_with_fallback(
feeds_ids=linked_feeds_ids,
period=request.period,
limit=settings.max_returned_entries,
fallback_limit=settings.news_outside_period,
)

external_entries, tags_mapping = await _external_entries(
entries, with_body=False, user_id=user.id, min_tag_count=request.minTagCount
load_result.entries, with_body=False, user_id=user.id, min_tag_count=request.minTagCount
)

return entities.GetLastEntriesResponse(entries=external_entries, tagsMapping=tags_mapping)
return entities.GetLastEntriesResponse(
entries=external_entries, fallbackUsed=load_result.fallback_used, tagsMapping=tags_mapping
)


@api_private.post("/create-or-update-rule") # type: ignore
Expand Down
21 changes: 21 additions & 0 deletions ffun/ffun/api/spa/tests/test_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from ffun.api.spa.entities import (
EntitlementKind,
Entry,
Feed,
FeedInfo,
Marker,
Expand All @@ -31,6 +32,7 @@
from ffun.entitlements.tests.make import make_effective_entitlement_interval, make_source_entitlement
from ffun.feeds.entities import Feed as InternalFeed
from ffun.feeds.entities import FeedError
from ffun.library.entities import CollectedEntry
from ffun.parsers import entities as p_entities
from ffun.product.entities import Credit, Resource
from ffun.resources import entities as r_entities
Expand Down Expand Up @@ -87,6 +89,25 @@ def test_from_internal__with_last_error(self, loaded_feed: InternalFeed) -> None
assert external_feed.lastError == error.name


class TestEntry:
def test_from_internal__maps_entry_dates(self, new_entry: CollectedEntry) -> None:
first_seen_at = utils.now()
source_published_at = first_seen_at + datetime.timedelta(days=1)
internal_entry = new_entry.replace(published_at=source_published_at).fake_entry(first_seen_at)

external_entry = Entry.from_internal(
internal_entry,
tags=[],
markers=[],
score=0,
score_contributions={},
)

assert external_entry.effectivePublishedAt == first_seen_at
assert external_entry.firstSeenAt == first_seen_at
assert external_entry.sourcePublishedAt == source_published_at


class TestFeedInfo:
def test_from_internal__keeps_site_url(self) -> None:
feed_url = str_to_feed_url("https://example.com/feed")
Expand Down
2 changes: 1 addition & 1 deletion ffun/ffun/cli/commands/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ async def _profile_get_last_entries() -> GetLastEntriesResponse:

logger.info("almost_finishied", entries_number=len(external_entries), tags_number=len(tags_mapping))

return GetLastEntriesResponse(entries=external_entries, tagsMapping=tags_mapping)
return GetLastEntriesResponse(entries=external_entries, fallbackUsed=False, tagsMapping=tags_mapping)


async def run_profile() -> None:
Expand Down
8 changes: 4 additions & 4 deletions ffun/ffun/library/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from ffun.domain.entities import EntryId, FeedId, UnknownUrl
from ffun.feeds import domain as f_domain
from ffun.library import operations
from ffun.library.entities import Entry, EntryChange, FeedEntryLink
from ffun.library.entities import EntriesLoadResult, Entry, EntryChange, FeedEntryLink

logger = logging.get_module_logger()

Expand Down Expand Up @@ -94,17 +94,17 @@ async def normalize_entry(entry: Entry, apply: bool = False) -> list[EntryChange

async def get_entries_by_filter_with_fallback(
feeds_ids: list[FeedId], period: datetime.timedelta | None, limit: int, fallback_limit: int
) -> list[Entry]:
) -> EntriesLoadResult:

entries = await get_entries_by_filter(feeds_ids=feeds_ids, period=period, limit=limit)

if entries:
return entries
return EntriesLoadResult(entries=entries, fallback_used=False)

# if there is no news in requested interval try to get some older news
entries = await get_entries_by_filter(feeds_ids=feeds_ids, period=_fallback_period, limit=fallback_limit)

return entries
return EntriesLoadResult(entries=entries, fallback_used=True)


@run_in_transaction
Expand Down
11 changes: 8 additions & 3 deletions ffun/ffun/library/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ class Entry(BaseEntry):
created_at: datetime.datetime

@property
def published_at_for_processing(self) -> datetime.datetime:
"""Entry published_at that is used to determine whether the entry is too old for processing.
def effective_published_at(self) -> datetime.datetime:
"""Best available published_at value for product behavior.

We use the smart algorithm to determine the "real" published_at value
because an actual third-party published_at is absolutely unreliable.
Expand Down Expand Up @@ -141,7 +141,7 @@ def published_at_for_processing(self) -> datetime.datetime:

@property
def age_for_processing(self) -> datetime.timedelta:
return utils.now() - self.published_at_for_processing
return utils.now() - self.effective_published_at

def collected_entry(self) -> "CollectedEntry":
return CollectedEntry(
Expand All @@ -157,6 +157,11 @@ def collected_entry(self) -> "CollectedEntry":
)


class EntriesLoadResult(BaseEntity):
entries: list[Entry]
fallback_used: bool


class CollectedEntry(BaseEntry):
def fake_entry(self, created_at: datetime.datetime) -> Entry:
return Entry(
Expand Down
34 changes: 20 additions & 14 deletions ffun/ffun/library/tests/test_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,12 @@ async def prepared_entries(self, loaded_feed: Feed, time_border: datetime.dateti

@pytest.mark.asyncio
async def test_no_entries_at_all(self, time_delta: datetime.timedelta) -> None:
entries = await get_entries_by_filter_with_fallback(
result = await get_entries_by_filter_with_fallback(
feeds_ids=[], period=time_delta, limit=10, fallback_limit=10
)

assert entries == []
assert result.entries == []
assert result.fallback_used

@pytest.mark.asyncio
async def test_has_new_entries(
Expand All @@ -159,11 +160,12 @@ async def test_has_new_entries(

await helpers.update_entry_created_time([entries[-1].id], time_border - datetime.timedelta(seconds=10))

loaded_entries = await get_entries_by_filter_with_fallback(
result = await get_entries_by_filter_with_fallback(
feeds_ids=[loaded_feed.id], period=time_delta, limit=10, fallback_limit=10
)

assert {entry.id for entry in loaded_entries} == {entry.id for entry in entries if entry.id != entries[-1].id}
assert {entry.id for entry in result.entries} == {entry.id for entry in entries if entry.id != entries[-1].id}
assert not result.fallback_used

@pytest.mark.asyncio
async def test_has_new_entries__limit(
Expand All @@ -173,12 +175,13 @@ async def test_has_new_entries__limit(

await helpers.update_entry_created_time([entries[-1].id], time_border - datetime.timedelta(seconds=10))

loaded_entries = await get_entries_by_filter_with_fallback(
result = await get_entries_by_filter_with_fallback(
feeds_ids=[loaded_feed.id], period=time_delta, limit=1, fallback_limit=10
)

assert len(loaded_entries) == 1
assert loaded_entries[0].id in {entry.id for entry in entries if entry.id != entries[-1].id}
assert len(result.entries) == 1
assert result.entries[0].id in {entry.id for entry in entries if entry.id != entries[-1].id}
assert not result.fallback_used

@pytest.mark.asyncio
async def test_no_new_entries(
Expand All @@ -190,11 +193,12 @@ async def test_no_new_entries(
[entry.id for entry in entries], time_border - datetime.timedelta(seconds=10)
)

loaded_entries = await get_entries_by_filter_with_fallback(
result = await get_entries_by_filter_with_fallback(
feeds_ids=[loaded_feed.id], period=time_delta, limit=1, fallback_limit=10
)

assert {entry.id for entry in loaded_entries} == {entry.id for entry in entries}
assert {entry.id for entry in result.entries} == {entry.id for entry in entries}
assert result.fallback_used

@pytest.mark.asyncio
async def test_fallback_returns_entries_older_than_max_entry_age(
Expand All @@ -207,11 +211,12 @@ async def test_fallback_returns_entries_older_than_max_entry_age(
utils.now() - settings.max_entry_age - datetime.timedelta(days=1),
)

loaded_entries = await get_entries_by_filter_with_fallback(
result = await get_entries_by_filter_with_fallback(
feeds_ids=[loaded_feed.id], period=time_delta, limit=1, fallback_limit=10
)

assert {entry.id for entry in loaded_entries} == {entry.id for entry in entries}
assert {entry.id for entry in result.entries} == {entry.id for entry in entries}
assert result.fallback_used

@pytest.mark.asyncio
async def test_no_new_entries__limit(
Expand All @@ -223,12 +228,13 @@ async def test_no_new_entries__limit(
[entry.id for entry in entries], time_border - datetime.timedelta(seconds=10)
)

loaded_entries = await get_entries_by_filter_with_fallback(
result = await get_entries_by_filter_with_fallback(
feeds_ids=[loaded_feed.id], period=time_delta, limit=1, fallback_limit=1
)

assert len(loaded_entries) == 1
assert loaded_entries[0].id in {entry.id for entry in entries}
assert len(result.entries) == 1
assert result.entries[0].id in {entry.id for entry in entries}
assert result.fallback_used


class TestShrinkFeed:
Expand Down
12 changes: 6 additions & 6 deletions ffun/ffun/library/tests/test_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class TestEntry:
),
],
)
def test_published_at_for_processing(
def test_effective_published_at(
self,
new_entry: CollectedEntry,
published_at: datetime.datetime,
Expand All @@ -49,20 +49,20 @@ def test_published_at_for_processing(
) -> None:
entry = new_entry.replace(published_at=published_at).fake_entry(created_at)

assert entry.published_at_for_processing == expected_published_at
assert entry.effective_published_at == expected_published_at

def test_published_at_for_processing__asserts_timezone_in_published_at(self, new_entry: CollectedEntry) -> None:
def test_effective_published_at__asserts_timezone_in_published_at(self, new_entry: CollectedEntry) -> None:
created_at = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
entry = new_entry.replace(published_at=datetime.datetime(2026, 1, 2, 3, 4, 4)).fake_entry(created_at)

with pytest.raises(AssertionError):
_ = entry.published_at_for_processing
_ = entry.effective_published_at

def test_published_at_for_processing__asserts_timezone_in_created_at(self, new_entry: CollectedEntry) -> None:
def test_effective_published_at__asserts_timezone_in_created_at(self, new_entry: CollectedEntry) -> None:
entry = new_entry.fake_entry(datetime.datetime(2026, 1, 2, 3, 4, 5))

with pytest.raises(AssertionError):
_ = entry.published_at_for_processing
_ = entry.effective_published_at

def test_age_for_processing(self, new_entry: CollectedEntry) -> None:
created_at = datetime.datetime(2026, 1, 2, 3, 4, 5, tzinfo=datetime.UTC)
Expand Down
19 changes: 16 additions & 3 deletions site/src/components/EntriesList.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
<template>
<div>
<template v-if="entriesToShow.length > 0">
<div class="mb-1 flex border-b pb-1 text-xs text-gray-500">
<div class="mr-2 w-4 flex-shrink-0" />

<div
v-if="showScore"
class="w-8 flex-shrink-0 pr-1 text-center">
Score
</div>

<div class="w-8 flex-shrink-0 pr-1" />

<div class="flex-grow">News</div>

<div class="w-16 flex-shrink-0 text-right">Published</div>
</div>

<ul>
<li
v-for="entryId in entriesToShow"
Expand All @@ -9,7 +25,6 @@
<entry-for-list
:show-score="showScore"
:entryId="entryId"
:time-field="timeField"
:tags-count="tagsCount" />
</li>
</ul>
Expand All @@ -33,12 +48,10 @@
<script lang="ts" setup>
import {computed, ref} from "vue";
import type * as t from "@/logic/types";
import {computedAsync} from "@vueuse/core";

const properties = defineProps<{
loading: boolean;
entriesIds: Array<t.EntryId>;
timeField: string;
showFromStart: number;
showPerPage: number;
showScore: boolean;
Comment on lines 52 to 57
Expand Down
Loading
Loading