diff --git a/changes/unreleased.md b/changes/unreleased.md index 1dd88b1d..d021ed66 100644 --- a/changes/unreleased.md +++ b/changes/unreleased.md @@ -1,3 +1,11 @@ - ff-174 — Custom API entry points for OpenAI and Gemini clients. - ff-175 — Export feeds to OPML file. +- ff-171 — Simplified rules creation: + - The rules creation form was moved to the tags filter. + - Rules now allow specifying excluded tags (will not apply if the news has any of them). + - Tags under the news caption now behave as tags in the tags filter. + - Button `[more]` on the tags line under the news caption now opens the whole news. + - Openned news items now always show all tags. + - Only a single news item now can be opened. + - Performance of the News page improved 2-3 times. diff --git a/ffun/ffun/api/entities.py b/ffun/ffun/api/entities.py index 1c056dc8..d0c94f1a 100644 --- a/ffun/ffun/api/entities.py +++ b/ffun/ffun/api/entities.py @@ -1,6 +1,5 @@ import datetime import enum -import uuid from decimal import Decimal from typing import Any, Iterable @@ -10,7 +9,7 @@ from ffun.api import front_events from ffun.core import api from ffun.core.entities import BaseEntity -from ffun.domain.entities import AbsoluteUrl, EntryId, FeedId, FeedUrl, UnknownUrl, UserId +from ffun.domain.entities import AbsoluteUrl, EntryId, FeedId, FeedUrl, RuleId, UnknownUrl, UserId from ffun.feeds import entities as f_entities from ffun.feeds_collections import entities as fc_entities from ffun.feeds_links import entities as fl_entities @@ -103,8 +102,9 @@ def from_internal( # noqa: CFQ002 class Rule(BaseEntity): - id: uuid.UUID - tags: list[str] + id: RuleId + requiredTags: list[str] + excludedTags: list[str] score: int createdAt: datetime.datetime updatedAt: datetime.datetime @@ -113,7 +113,8 @@ class Rule(BaseEntity): def from_internal(cls, rule: s_entities.Rule, tags_mapping: dict[int, str]) -> "Rule": return cls( id=rule.id, - tags=[tags_mapping[tag_id] for tag_id in rule.tags], + requiredTags=[tags_mapping[tag_id] for tag_id in rule.required_tags], + excludedTags=[tags_mapping[tag_id] for tag_id in rule.excluded_tags], score=rule.score, createdAt=rule.created_at, updatedAt=rule.updated_at, @@ -319,7 +320,8 @@ class GetEntriesByIdsResponse(api.APISuccess): class CreateOrUpdateRuleRequest(api.APIRequest): - tags: list[str] + requiredTags: list[str] + excludedTags: list[str] score: int @@ -328,7 +330,7 @@ class CreateOrUpdateRuleResponse(api.APISuccess): class DeleteRuleRequest(api.APIRequest): - id: uuid.UUID + id: RuleId class DeleteRuleResponse(api.APISuccess): @@ -336,8 +338,9 @@ class DeleteRuleResponse(api.APISuccess): class UpdateRuleRequest(api.APIRequest): - id: uuid.UUID - tags: list[str] + id: RuleId + requiredTags: list[str] + excludedTags: list[str] score: int diff --git a/ffun/ffun/api/http_handlers.py b/ffun/ffun/api/http_handlers.py index 9466ed87..96cbb0bf 100644 --- a/ffun/ffun/api/http_handlers.py +++ b/ffun/ffun/api/http_handlers.py @@ -148,9 +148,15 @@ async def api_get_entries_by_ids( async def api_create_or_update_rule( request: entities.CreateOrUpdateRuleRequest, user: User ) -> entities.CreateOrUpdateRuleResponse: - tags_ids = await o_domain.get_ids_by_uids(request.tags) - - await s_domain.create_or_update_rule(user_id=user.id, tags=set(tags_ids.values()), score=request.score) + required_tags_ids = await o_domain.get_ids_by_uids(request.requiredTags) + excluded_tags_ids = await o_domain.get_ids_by_uids(request.excludedTags) + + await s_domain.create_or_update_rule( + user_id=user.id, + score=request.score, + required_tags=required_tags_ids.values(), + excluded_tags=excluded_tags_ids.values(), + ) return entities.CreateOrUpdateRuleResponse() @@ -164,9 +170,16 @@ async def api_delete_rule(request: entities.DeleteRuleRequest, user: User) -> en @router.post("/api/update-rule") async def api_update_rule(request: entities.UpdateRuleRequest, user: User) -> entities.UpdateRuleResponse: - tags_ids = await o_domain.get_ids_by_uids(request.tags) - - await s_domain.update_rule(user_id=user.id, rule_id=request.id, score=request.score, tags=tags_ids.values()) + required_tags_ids = await o_domain.get_ids_by_uids(request.requiredTags) + excluded_tags_ids = await o_domain.get_ids_by_uids(request.excludedTags) + + await s_domain.update_rule( + user_id=user.id, + rule_id=request.id, + score=request.score, + required_tags=required_tags_ids.values(), + excluded_tags=excluded_tags_ids.values(), + ) return entities.UpdateRuleResponse() @@ -175,7 +188,8 @@ async def _prepare_rules(rules: Iterable[s_entities.Rule]) -> list[entities.Rule all_tags = set() for rule in rules: - all_tags.update(rule.tags) + all_tags.update(rule.required_tags) + all_tags.update(rule.excluded_tags) tags_mapping = await o_domain.get_tags_by_ids(all_tags) diff --git a/ffun/ffun/cli/application.py b/ffun/ffun/cli/application.py index eb0091b8..a6078b38 100644 --- a/ffun/ffun/cli/application.py +++ b/ffun/ffun/cli/application.py @@ -2,6 +2,7 @@ from ffun.cli.commands import cleaner # noqa: F401 from ffun.cli.commands import experiments # noqa: F401 +from ffun.cli.commands import fixtures # noqa: F401 from ffun.cli.commands import metrics # noqa: F401 from ffun.cli.commands import processors_quality # noqa: F401 from ffun.cli.commands import profile # noqa: F401 @@ -19,6 +20,7 @@ app.add_typer(metrics.cli_app, name="metrics") app.add_typer(profile.cli_app, name="profile") app.add_typer(experiments.cli_app, name="experiments") +app.add_typer(fixtures.cli_app, name="fixtures") if __name__ == "__main__": diff --git a/ffun/ffun/cli/commands/fixtures.py b/ffun/ffun/cli/commands/fixtures.py new file mode 100644 index 00000000..17dd2710 --- /dev/null +++ b/ffun/ffun/cli/commands/fixtures.py @@ -0,0 +1,142 @@ +""" +Development utilities for feeds. + +This code is intended to be used by developers to simplify our work, therefore: + +- It MUST NOT be used in production code. +- It MUST NOT be used in tests. +- no tests are required for this code. +""" + +import asyncio +import uuid + +import typer + +from ffun.application.application import with_app +from ffun.auth.settings import settings as a_settings +from ffun.core import logging, utils +from ffun.domain.domain import new_entry_id, new_feed_id +from ffun.domain.entities import UnknownUrl, UserId +from ffun.domain.urls import adjust_classic_relative_url, str_to_feed_url, url_to_source_uid +from ffun.feeds.domain import get_feeds, get_source_ids, save_feed +from ffun.feeds.entities import Feed, FeedState +from ffun.feeds_links.domain import add_link +from ffun.library.domain import catalog_entries, get_entries_by_ids +from ffun.library.entities import Entry +from ffun.ontology.domain import apply_tags_to_entry +from ffun.ontology.entities import ProcessorTag +from ffun.users import domain as u_domain +from ffun.users import entities as u_entities + +logger = logging.get_module_logger() + +cli_app = typer.Typer() + + +async def fake_feed() -> Feed: + + _id = uuid.uuid4().hex + + url = str_to_feed_url(f"https://{_id}.com") + + source_uid = url_to_source_uid(url) + + source_ids = await get_source_ids([source_uid]) + + timestamp = utils.now() + + feed = Feed( + id=new_feed_id(), + source_id=source_ids[source_uid], + url=url, + state=FeedState.loaded, + last_error=None, + load_attempted_at=timestamp, + loaded_at=timestamp, + title=f"Title {_id}", + description=f"Description {_id}", + ) + + feed_id = await save_feed(feed) + + feeds = await get_feeds([feed_id]) + + return feeds[0] + + +async def fake_entry(feed: Feed) -> Entry: + _id = uuid.uuid4().hex + + timestamp = utils.now() + + url = adjust_classic_relative_url(UnknownUrl(f"enrty-{_id}"), feed.url) + + assert url is not None + + entry = Entry( + id=new_entry_id(), + source_id=feed.source_id, + title=f"Title {_id}", + body=f"Body {_id}", + external_id=uuid.uuid4().hex, + external_url=url, + external_tags=set(), + published_at=timestamp, + cataloged_at=timestamp, + ) + + await catalog_entries(feed.id, [entry]) + + entries = await get_entries_by_ids([entry.id]) + + assert entry is not None + + returned_entry = entries[entry.id] + + assert returned_entry is not None + + return returned_entry + + +async def run_fill_db(feeds_number: int, entries_per_feed: int, tags_per_entry: int) -> None: + async with with_app(): + external_user_id = a_settings.single_user.external_id + + internal_user_id = await u_domain.get_or_create_user_id(u_entities.Service.single, external_user_id) + + for _ in range(feeds_number): + feed = await fake_feed() + + await add_link(internal_user_id, feed.id) + + entries = [await fake_entry(feed) for _ in range(entries_per_feed)] + + for i, entry in enumerate(entries, start=1): + + tags = [] + + for j, _ in enumerate(range(tags_per_entry), start=1): + raw_uid = f"some-long-tag-name-{j}-{i % j}" + tags.append(ProcessorTag(raw_uid=raw_uid)) + + await apply_tags_to_entry(entry.id, 100500, tags) + + +@cli_app.command() +def fill_db(feeds_number: int = 10, entries_per_feed: int = 100, tags_per_entry: int = 25) -> None: + asyncio.run(run_fill_db(feeds_number, entries_per_feed, tags_per_entry)) + + +async def run_supertokens_user_to_dev(intenal_user_id: UserId) -> None: + async with with_app(): + external_user_id = a_settings.single_user.external_id + + to_user_id = await u_domain.get_or_create_user_id(u_entities.Service.single, external_user_id) + + await u_domain.tech_move_user(from_user_id=intenal_user_id, to_user_id=to_user_id) + + +@cli_app.command() +def supertokens_user_to_dev(intenal_user_id: str) -> None: + asyncio.run(run_supertokens_user_to_dev(UserId(uuid.UUID(intenal_user_id)))) diff --git a/ffun/ffun/domain/domain.py b/ffun/ffun/domain/domain.py index c9d96fe7..ddda2690 100644 --- a/ffun/ffun/domain/domain.py +++ b/ffun/ffun/domain/domain.py @@ -1,6 +1,6 @@ import uuid -from ffun.domain.entities import CollectionId, EntryId, FeedId, SourceId, UserId +from ffun.domain.entities import CollectionId, EntryId, FeedId, RuleId, SourceId, UserId def new_user_id() -> UserId: @@ -21,3 +21,7 @@ def new_collection_id() -> CollectionId: def new_source_id() -> SourceId: return SourceId(uuid.uuid4()) + + +def new_rule_id() -> RuleId: + return RuleId(uuid.uuid4()) diff --git a/ffun/ffun/domain/entities.py b/ffun/ffun/domain/entities.py index 88eb69ec..cfe4c12c 100644 --- a/ffun/ffun/domain/entities.py +++ b/ffun/ffun/domain/entities.py @@ -6,6 +6,7 @@ FeedId = NewType("FeedId", uuid.UUID) CollectionId = NewType("CollectionId", uuid.UUID) SourceId = NewType("SourceId", uuid.UUID) +RuleId = NewType("RuleId", uuid.UUID) # URL types for better normalization control in code # conversion schemas: diff --git a/ffun/ffun/library/migrations/20240928_01_fIeAw-feeds-to-entries-many-to-many.py b/ffun/ffun/library/migrations/20240928_01_fIeAw-feeds-to-entries-many-to-many.py index 5835da9c..07cb2794 100644 --- a/ffun/ffun/library/migrations/20240928_01_fIeAw-feeds-to-entries-many-to-many.py +++ b/ffun/ffun/library/migrations/20240928_01_fIeAw-feeds-to-entries-many-to-many.py @@ -83,43 +83,26 @@ """ -# TODO: remove prints after migration applied to prod def apply_step(conn: Connection[dict[str, Any]]) -> None: cursor = conn.cursor(row_factory=dict_row) - print("Creating l_feeds_to_entries table") # noqa - cursor.execute(sql_create_feeds_to_entries_table) - print("Creating l_orphaned_entries table") # noqa - cursor.execute(sql_create_orphaned_entries_table) - print("Filling l_feeds_to_entries table") # noqa - cursor.execute(sql_fill_from_entries_table) - print("Filling l_orphaned_entries table") # noqa - cursor.execute(sql_fill_orphaned_entries) cursor.execute("CREATE INDEX l_feeds_to_entries_entry_id_idx ON l_feeds_to_entries (entry_id)") - print("Filling duplicated entries") # noqa - cursor.execute(sql_fill_duplicated_entries) - print("Removing duplicated entries") # noqa - cursor.execute(sql_remove_duplicated_entries) - print("Creating unique index on l_entries") # noqa - # external_id goes first to optimize queries cursor.execute("CREATE UNIQUE INDEX l_entries_source_id_external_id_idx ON l_entries (external_id, source_id)") - print("Removing feed_id column from l_entries") # noqa - cursor.execute("ALTER TABLE l_entries DROP COLUMN feed_id") cursor.execute( @@ -129,8 +112,6 @@ def apply_step(conn: Connection[dict[str, Any]]) -> None: cursor.execute("CREATE INDEX l_entries_created_at_idx ON l_entries (created_at ASC)") - print("Completed") # noqa - def rollback_step(conn: Connection[dict[str, Any]]) -> None: cursor = conn.cursor() diff --git a/ffun/ffun/ontology/tests/fixtures.py b/ffun/ffun/ontology/tests/fixtures.py index 593abf0d..4616d254 100644 --- a/ffun/ffun/ontology/tests/fixtures.py +++ b/ffun/ffun/ontology/tests/fixtures.py @@ -25,3 +25,25 @@ def three_tags_ids(three_tags_by_uids: dict[str, int]) -> tuple[int, int, int]: @pytest.fixture def three_processor_tags(three_tags_by_ids: dict[int, str]) -> tuple[ProcessorTag, ProcessorTag, ProcessorTag]: return tuple(ProcessorTag(raw_uid=tag) for tag in three_tags_by_ids.values()) # type: ignore + + +@pytest_asyncio.fixture +async def five_tags_by_uids() -> dict[str, int]: + return await get_ids_by_uids([uuid.uuid4().hex for _ in range(5)]) + + +@pytest.fixture +def five_tags_by_ids(five_tags_by_uids: dict[str, int]) -> dict[int, str]: + return {v: k for k, v in five_tags_by_uids.items()} + + +@pytest.fixture +def five_tags_ids(five_tags_by_uids: dict[str, int]) -> tuple[int, int, int, int, int]: + return tuple(five_tags_by_uids.values()) # type: ignore + + +@pytest.fixture +def five_processor_tags( + five_tags_by_ids: dict[int, str] +) -> tuple[ProcessorTag, ProcessorTag, ProcessorTag, ProcessorTag, ProcessorTag]: + return tuple(ProcessorTag(raw_uid=tag) for tag in five_tags_by_ids.values()) # type: ignore diff --git a/ffun/ffun/scores/domain.py b/ffun/ffun/scores/domain.py index 4bc1eaf5..dd89f459 100644 --- a/ffun/ffun/scores/domain.py +++ b/ffun/ffun/scores/domain.py @@ -1,31 +1,36 @@ -from typing import Sequence +from typing import Iterable from ffun.scores import entities, operations count_rules_per_user = operations.count_rules_per_user -def get_score_contributions(rules: Sequence[entities.BaseRule], tags: set[int]) -> tuple[int, dict[int, int]]: - score = 0 - contributions: dict[int, int] = {} +def get_score_rules(rules: Iterable[entities.Rule], tags: set[int]) -> list[entities.Rule]: + score_rules = [] for rule in rules: - if rule.tags <= tags: - score += rule.score - for tag in rule.tags: - contributions[tag] = contributions.get(tag, 0) + rule.score + if rule.excluded_tags & tags: + continue - return score, contributions + if rule.required_tags <= tags: + score_rules.append(rule) + return score_rules -def get_score_rules(rules: list[entities.Rule], tags: set[int]) -> list[entities.Rule]: - score_rules = [] - for rule in rules: - if rule.tags <= tags: - score_rules.append(rule) +def get_score_contributions(rules: Iterable[entities.Rule], tags: set[int]) -> tuple[int, dict[int, int]]: + score = 0 + contributions: dict[int, int] = {} - return score_rules + for rule in get_score_rules(rules, tags): + score += rule.score + + # We may want to think about calculating contributions for excluded tags + # but currently there is no visible need for that + for tag in rule.required_tags: + contributions[tag] = contributions.get(tag, 0) + rule.score + + return score, contributions create_or_update_rule = operations.create_or_update_rule diff --git a/ffun/ffun/scores/entities.py b/ffun/ffun/scores/entities.py index 17b0d909..db109509 100644 --- a/ffun/ffun/scores/entities.py +++ b/ffun/ffun/scores/entities.py @@ -1,18 +1,16 @@ import datetime -import uuid -import pydantic +from ffun.core.entities import BaseEntity +from ffun.domain.entities import RuleId, UserId -from ffun.domain.entities import UserId +class Rule(BaseEntity): + id: RuleId + user_id: UserId -class BaseRule(pydantic.BaseModel): - tags: set[int] + required_tags: set[int] + excluded_tags: set[int] score: int - -class Rule(BaseRule): - id: uuid.UUID - user_id: UserId created_at: datetime.datetime updated_at: datetime.datetime diff --git a/ffun/ffun/scores/errors.py b/ffun/ffun/scores/errors.py index 6d96677e..421efc07 100644 --- a/ffun/ffun/scores/errors.py +++ b/ffun/ffun/scores/errors.py @@ -7,3 +7,7 @@ class Error(errors.Error): class NoRuleFound(Error): pass + + +class TagsIntersection(Error): + pass diff --git a/ffun/ffun/scores/migrations/20241220_01_vgmap-excluded-tags-for-rules.py b/ffun/ffun/scores/migrations/20241220_01_vgmap-excluded-tags-for-rules.py new file mode 100644 index 00000000..ae97c9df --- /dev/null +++ b/ffun/ffun/scores/migrations/20241220_01_vgmap-excluded-tags-for-rules.py @@ -0,0 +1,42 @@ +""" +excluded-tags-for-rules +""" + +from typing import Any, Iterable + +from psycopg import Connection +from yoyo import step + +__depends__ = {"20230813_01_l7qop-updated-at-field"} + + +def _key_from_tags(required_tags: Iterable[int], excluded_tags: Iterable[int]) -> str: + return ",".join(map(str, required_tags)) + "|" + ",".join(map(str, excluded_tags)) + + +def apply_step(conn: Connection[Any]) -> None: + cursor = conn.cursor() + + cursor.execute("ALTER TABLE s_rules RENAME COLUMN tags TO required_tags") + cursor.execute("ALTER TABLE s_rules ADD COLUMN excluded_tags BIGINT[] DEFAULT ARRAY[]::BIGINT[]") + + result = cursor.execute("SELECT id, required_tags FROM s_rules").fetchall() + + for row in result: + cursor.execute( + "UPDATE s_rules SET key = %(key)s, excluded_tags = ARRAY[]::BIGINT[] WHERE id = %(id)s", + {"id": row[0], "key": _key_from_tags(row[1], [])}, + ) + + cursor.execute("ALTER TABLE s_rules ALTER COLUMN excluded_tags SET NOT NULL") + cursor.execute("ALTER TABLE s_rules ALTER COLUMN excluded_tags DROP DEFAULT") + + +def rollback_step(conn: Connection[dict[str, Any]]) -> None: + cursor = conn.cursor() + + cursor.execute("ALTER TABLE s_rules RENAME COLUMN required_tags TO tags") + cursor.execute("ALTER TABLE s_rules DROP COLUMN excluded_tags") + + +steps = [step(apply_step, rollback_step)] diff --git a/ffun/ffun/scores/operations.py b/ffun/ffun/scores/operations.py index c140091e..8c52b1b9 100644 --- a/ffun/ffun/scores/operations.py +++ b/ffun/ffun/scores/operations.py @@ -1,11 +1,11 @@ -import uuid from typing import Any, Iterable import psycopg from ffun.core import logging from ffun.core.postgresql import execute -from ffun.domain.entities import UserId +from ffun.domain.domain import new_rule_id +from ffun.domain.entities import RuleId, UserId from ffun.scores import errors from ffun.scores.entities import Rule @@ -16,36 +16,63 @@ def _normalize_tags(tags: Iterable[int]) -> list[int]: return list(sorted(tags)) -def _key_from_tags(tags: Iterable[int]) -> str: - return ",".join(map(str, tags)) +def _key_from_tags(required_tags: Iterable[int], excluded_tags: Iterable[int]) -> str: + return ",".join(map(str, required_tags)) + "|" + ",".join(map(str, excluded_tags)) def row_to_rule(row: dict[str, Any]) -> Rule: return Rule( id=row["id"], user_id=row["user_id"], - tags=set(row["tags"]), + required_tags=row["required_tags"], + excluded_tags=row["excluded_tags"], score=row["score"], created_at=row["created_at"], updated_at=row["updated_at"], ) -async def create_or_update_rule(user_id: UserId, tags: Iterable[int], score: int) -> Rule: - tags = _normalize_tags(tags) +async def create_or_update_rule( + user_id: UserId, required_tags: Iterable[int], excluded_tags: Iterable[int], score: int +) -> Rule: + required_tags = set(required_tags) + excluded_tags = set(excluded_tags) - key = _key_from_tags(tags) + if required_tags & excluded_tags: + raise errors.TagsIntersection() + + required_tags = _normalize_tags(required_tags) + excluded_tags = _normalize_tags(excluded_tags) + + key = _key_from_tags(required_tags, excluded_tags) sql = """ - INSERT INTO s_rules (id, user_id, tags, key, score) - VALUES (%(id)s, %(user_id)s, %(tags)s, %(key)s, %(score)s) + INSERT INTO s_rules (id, user_id, required_tags, excluded_tags, key, score) + VALUES (%(id)s, %(user_id)s, %(required_tags)s, %(excluded_tags)s, %(key)s, %(score)s) RETURNING * """ try: - result = await execute(sql, {"id": uuid.uuid4(), "user_id": user_id, "tags": tags, "key": key, "score": score}) - - logger.business_event("rule_created", user_id=user_id, rule_id=result[0]["id"], tags=tags, score=score) + result = await execute( + sql, + { + "id": new_rule_id(), + "user_id": user_id, + "required_tags": required_tags, + "excluded_tags": excluded_tags, + "key": key, + "score": score, + }, + ) + + logger.business_event( + "rule_created", + user_id=user_id, + rule_id=result[0]["id"], + required_tags=required_tags, + excluded_tags=excluded_tags, + score=score, + ) except psycopg.errors.UniqueViolation: logger.info("rule_already_exists_change_score", key=key) @@ -58,12 +85,19 @@ async def create_or_update_rule(user_id: UserId, tags: Iterable[int], score: int result = await execute(sql, {"user_id": user_id, "key": key, "score": score}) - logger.business_event("rule_updated", user_id=user_id, rule_id=result[0]["id"], tags=tags, score=score) + logger.business_event( + "rule_updated", + user_id=user_id, + rule_id=result[0]["id"], + required_tags=required_tags, + excluded_tags=excluded_tags, + score=score, + ) return row_to_rule(result[0]) -async def delete_rule(user_id: UserId, rule_id: uuid.UUID) -> None: +async def delete_rule(user_id: UserId, rule_id: RuleId) -> None: sql = """ DELETE FROM s_rules WHERE user_id = %(user_id)s AND id = %(rule_id)s @@ -76,23 +110,54 @@ async def delete_rule(user_id: UserId, rule_id: uuid.UUID) -> None: logger.business_event("rule_deleted", user_id=user_id, rule_id=rule_id) -async def update_rule(user_id: UserId, rule_id: uuid.UUID, tags: Iterable[int], score: int) -> Rule: - tags = _normalize_tags(tags) - key = _key_from_tags(tags) +async def update_rule( + user_id: UserId, rule_id: RuleId, required_tags: Iterable[int], excluded_tags: Iterable[int], score: int +) -> Rule: + required_tags = set(required_tags) + excluded_tags = set(excluded_tags) + + if required_tags & excluded_tags: + raise errors.TagsIntersection() + + required_tags = _normalize_tags(required_tags) + excluded_tags = _normalize_tags(excluded_tags) + + key = _key_from_tags(required_tags, excluded_tags) sql = """ UPDATE s_rules - SET tags = %(tags)s, key = %(key)s, score = %(score)s, updated_at = NOW() + SET required_tags = %(required_tags)s, + excluded_tags = %(excluded_tags)s, + key = %(key)s, + score = %(score)s, + updated_at = NOW() WHERE user_id = %(user_id)s AND id = %(rule_id)s returning * """ - result = await execute(sql, {"user_id": user_id, "rule_id": rule_id, "tags": tags, "key": key, "score": score}) + result = await execute( + sql, + { + "user_id": user_id, + "rule_id": rule_id, + "required_tags": required_tags, + "excluded_tags": excluded_tags, + "key": key, + "score": score, + }, + ) if not result: raise errors.NoRuleFound() - logger.business_event("rule_updated", user_id=user_id, rule_id=result[0]["id"], tags=tags, score=score) + logger.business_event( + "rule_updated", + user_id=user_id, + rule_id=result[0]["id"], + required_tags=required_tags, + excluded_tags=excluded_tags, + score=score, + ) return row_to_rule(result[0]) diff --git a/ffun/ffun/scores/tests/test_domain.py b/ffun/ffun/scores/tests/test_domain.py index e2c583a7..31cc0bfb 100644 --- a/ffun/ffun/scores/tests/test_domain.py +++ b/ffun/ffun/scores/tests/test_domain.py @@ -1,27 +1,93 @@ -import pytest - +from ffun.core import utils +from ffun.domain.domain import new_rule_id, new_user_id from ffun.scores import domain, entities -def rule(score: int, tags: set[int]) -> entities.BaseRule: - return entities.BaseRule(score=score, tags=tags) +def rule(score: int, required_tags: set[int], excluded_tags: set[int]) -> entities.Rule: + return entities.Rule( + id=new_rule_id(), + user_id=new_user_id(), + score=score, + required_tags=required_tags, + excluded_tags=excluded_tags, + created_at=utils.now(), + updated_at=utils.now(), + ) + + +class TestGetScoreRules: + + def test_no_rules(self) -> None: + assert domain.get_score_rules([], set()) == [] + assert domain.get_score_rules([], {1, 2, 3}) == [] + + def test_only_required_tags(self) -> None: + rules = [rule(2, {1, 2, 3}, set()), rule(3, {1, 3}, set()), rule(5, {2}, set())] + + assert domain.get_score_rules(rules, set()) == [] + assert domain.get_score_rules(rules, {1, 2, 3}) == rules + assert domain.get_score_rules(rules, {1, 2, 3, 4}) == rules + assert domain.get_score_rules(rules, {1, 3}) == [rules[1]] + assert domain.get_score_rules(rules, {2, 3}) == [rules[2]] + assert domain.get_score_rules(rules, {2}) == [rules[2]] + + def test_only_excluded_tags(self) -> None: + rules = [rule(2, set(), {1, 2, 3}), rule(3, set(), {1, 3}), rule(5, set(), {2})] + + assert domain.get_score_rules(rules, set()) == rules + assert domain.get_score_rules(rules, {1, 2, 3}) == [] + assert domain.get_score_rules(rules, {1, 2, 3, 4}) == [] + assert domain.get_score_rules(rules, {1, 3}) == [rules[2]] + assert domain.get_score_rules(rules, {2, 3}) == [] + assert domain.get_score_rules(rules, {2}) == [rules[1]] + assert domain.get_score_rules(rules, {1}) == [rules[2]] + + def test_required_and_excluded_tags(self) -> None: + rules = [rule(2, {1, 2, 3}, {4}), rule(3, {1, 3}, {2}), rule(5, {2}, {1, 3})] + + assert domain.get_score_rules(rules, set()) == [] + assert domain.get_score_rules(rules, {1, 2, 3}) == [rules[0]] + assert domain.get_score_rules(rules, {1, 2, 3, 4}) == [] + assert domain.get_score_rules(rules, {1, 3}) == [rules[1]] + assert domain.get_score_rules(rules, {2, 3}) == [] + assert domain.get_score_rules(rules, {2}) == [rules[2]] + assert domain.get_score_rules(rules, {1, 3, 4}) == [rules[1]] class TestGetScore: - @pytest.mark.parametrize( - "rules, tags, contributions", - [ - [[], set(), (0, {})], - [[], {1, 2, 3}, (0, {})], - [[rule(1, {1, 2, 3})], set(), (0, {})], - [[rule(2, {1, 2, 3})], {1, 2, 3}, (2, {1: 2, 2: 2, 3: 2})], - [[rule(3, {1, 2, 3})], {1, 2, 3, 4}, (3, {1: 3, 2: 3, 3: 3})], - [[rule(4, {1, 2}), rule(5, {1, 3})], {1, 2, 3, 4}, (9, {1: 9, 2: 4, 3: 5})], - [[rule(6, {1, 2}), rule(-7, {3, 5}), rule(8, {1, 4})], {2, 3, 4}, (0, {})], - [[rule(9, {1, 2}), rule(-10, {3, 5}), rule(11, {4, 5})], {2, 3, 4, 5}, (1, {3: -10, 4: 11, 5: 1})], - [[rule(9, {1, 2}), rule(-10, {3, 5}), rule(11, {4})], {2, 3, 4, 5}, (1, {3: -10, 4: 11, 5: -10})], - [[rule(12, {1, 2}), rule(-12, {2, 3})], {1, 2, 3, 4, 5}, (0, {1: 12, 2: 0, 3: -12})], - ], - ) - def test(self, rules: list[entities.BaseRule], tags: set[int], contributions: tuple[int, dict[int, int]]) -> None: - assert domain.get_score_contributions(rules, tags) == contributions + + def test_no_rules(self) -> None: + assert domain.get_score_contributions([], set()) == (0, {}) + assert domain.get_score_contributions([], {1, 2, 3}) == (0, {}) + + def test_only_required_tags(self) -> None: + rules = [rule(2, {1, 2, 3}, set()), rule(3, {1, 3}, set()), rule(5, {2}, set())] + + assert domain.get_score_contributions(rules, set()) == (0, {}) + assert domain.get_score_contributions(rules, {1, 2, 3}) == (10, {1: 5, 2: 7, 3: 5}) + assert domain.get_score_contributions(rules, {1, 2, 3, 4}) == (10, {1: 5, 2: 7, 3: 5}) + assert domain.get_score_contributions(rules, {1, 3}) == (3, {1: 3, 3: 3}) + assert domain.get_score_contributions(rules, {2, 3}) == (5, {2: 5}) + assert domain.get_score_contributions(rules, {2}) == (5, {2: 5}) + + def test_only_excluded_tags(self) -> None: + rules = [rule(2, set(), {1, 2, 3}), rule(3, set(), {1, 3}), rule(5, set(), {2})] + + assert domain.get_score_contributions(rules, set()) == (10, {}) + assert domain.get_score_contributions(rules, {1, 2, 3}) == (0, {}) + assert domain.get_score_contributions(rules, {1, 2, 3, 4}) == (0, {}) + assert domain.get_score_contributions(rules, {1, 3}) == (5, {}) + assert domain.get_score_contributions(rules, {2, 3}) == (0, {}) + assert domain.get_score_contributions(rules, {2}) == (3, {}) + assert domain.get_score_contributions(rules, {1}) == (5, {}) + + def test_required_and_excluded_tags(self) -> None: + rules = [rule(2, {1, 2, 3}, {4}), rule(3, {1, 3}, {2}), rule(5, {2}, {1, 3})] + + assert domain.get_score_contributions(rules, set()) == (0, {}) + assert domain.get_score_contributions(rules, {1, 2, 3}) == (2, {1: 2, 2: 2, 3: 2}) + assert domain.get_score_contributions(rules, {1, 2, 3, 4}) == (0, {}) + assert domain.get_score_contributions(rules, {1, 3}) == (3, {1: 3, 3: 3}) + assert domain.get_score_contributions(rules, {2, 3}) == (0, {}) + assert domain.get_score_contributions(rules, {2}) == (5, {2: 5}) + assert domain.get_score_contributions(rules, {1, 3, 4}) == (3, {1: 3, 3: 3}) diff --git a/ffun/ffun/scores/tests/test_operations.py b/ffun/ffun/scores/tests/test_operations.py index ec99dd05..e2006c02 100644 --- a/ffun/ffun/scores/tests/test_operations.py +++ b/ffun/ffun/scores/tests/test_operations.py @@ -1,5 +1,3 @@ -import uuid - import pytest from ffun.core.tests.helpers import ( @@ -9,23 +7,32 @@ assert_logs_has_no_business_event, capture_logs, ) +from ffun.domain.domain import new_rule_id from ffun.domain.entities import UserId from ffun.scores import domain, errors, operations class TestCreateOrUpdateRule: @pytest.mark.asyncio - async def test_create_new_rule(self, internal_user_id: UserId, three_tags_ids: tuple[int, int, int]) -> None: + async def test_create_new_rule( + self, internal_user_id: UserId, five_tags_ids: tuple[int, int, int, int, int] + ) -> None: + required_tags = five_tags_ids[:3] + excluded_tags = five_tags_ids[3:] + with capture_logs() as logs: async with TableSizeDelta("s_rules", delta=1): - created_rule = await operations.create_or_update_rule(internal_user_id, three_tags_ids, 13) + created_rule = await operations.create_or_update_rule( + internal_user_id, score=13, required_tags=required_tags, excluded_tags=excluded_tags + ) rules = await domain.get_rules(internal_user_id) assert rules == [created_rule] assert created_rule.user_id == internal_user_id - assert created_rule.tags == set(three_tags_ids) + assert created_rule.required_tags == set(required_tags) + assert created_rule.excluded_tags == set(excluded_tags) assert created_rule.score == 13 assert_logs_has_business_event( @@ -33,20 +40,33 @@ async def test_create_new_rule(self, internal_user_id: UserId, three_tags_ids: t "rule_created", user_id=internal_user_id, rule_id=str(created_rule.id), - tags=list(three_tags_ids), + required_tags=list(required_tags), + excluded_tags=list(excluded_tags), score=13, ) assert_logs_has_no_business_event(logs, "rule_updated") @pytest.mark.asyncio async def test_tags_order_does_not_affect_creation( - self, internal_user_id: UserId, three_tags_ids: tuple[int, int, int] + self, internal_user_id: UserId, five_tags_ids: tuple[int, int, int, int, int] ) -> None: + required_tags = list(five_tags_ids[:3]) + excluded_tags = list(five_tags_ids[3:]) + async with TableSizeDelta("s_rules", delta=1): - created_rule_1 = await operations.create_or_update_rule(internal_user_id, three_tags_ids, 13) - created_rule_2 = await operations.create_or_update_rule(internal_user_id, reversed(three_tags_ids), 17) + created_rule_1 = await operations.create_or_update_rule( + internal_user_id, required_tags=required_tags, excluded_tags=excluded_tags, score=13 + ) + + created_rule_2 = await operations.create_or_update_rule( + internal_user_id, + required_tags=reversed(required_tags), + excluded_tags=reversed(excluded_tags), + score=17, + ) - assert created_rule_1.tags == created_rule_2.tags + assert created_rule_1.required_tags == created_rule_2.required_tags + assert created_rule_1.excluded_tags == created_rule_2.excluded_tags rules = await domain.get_rules(internal_user_id) @@ -54,20 +74,28 @@ async def test_tags_order_does_not_affect_creation( @pytest.mark.asyncio async def test_update_scores_of_existed_rule( - self, internal_user_id: UserId, three_tags_ids: tuple[int, int, int] + self, internal_user_id: UserId, five_tags_ids: tuple[int, int, int, int, int] ) -> None: - await operations.create_or_update_rule(internal_user_id, three_tags_ids, 13) + required_tags = list(five_tags_ids[:3]) + excluded_tags = list(five_tags_ids[3:]) + + await operations.create_or_update_rule( + internal_user_id, score=13, required_tags=required_tags, excluded_tags=excluded_tags + ) with capture_logs() as logs: async with TableSizeNotChanged("s_rules"): - updated_rule = await operations.create_or_update_rule(internal_user_id, three_tags_ids, 17) + updated_rule = await operations.create_or_update_rule( + internal_user_id, score=17, required_tags=required_tags, excluded_tags=excluded_tags + ) rules = await domain.get_rules(internal_user_id) assert rules == [updated_rule] assert updated_rule.user_id == internal_user_id - assert updated_rule.tags == set(three_tags_ids) + assert updated_rule.required_tags == set(required_tags) + assert updated_rule.excluded_tags == set(excluded_tags) assert updated_rule.score == 17 assert_logs_has_business_event( @@ -75,26 +103,36 @@ async def test_update_scores_of_existed_rule( "rule_updated", user_id=internal_user_id, rule_id=str(updated_rule.id), - tags=list(three_tags_ids), + required_tags=required_tags, + excluded_tags=excluded_tags, score=17, ) assert_logs_has_no_business_event(logs, "rule_created") @pytest.mark.asyncio async def test_multiple_entities( - self, internal_user_id: UserId, another_internal_user_id: UserId, three_tags_ids: tuple[int, int, int] + self, internal_user_id: UserId, another_internal_user_id: UserId, five_tags_ids: tuple[int, int, int, int, int] ) -> None: - await operations.create_or_update_rule(internal_user_id, three_tags_ids[:2], 3) - await operations.create_or_update_rule(another_internal_user_id, three_tags_ids[:2], 5) - await operations.create_or_update_rule(another_internal_user_id, three_tags_ids[1:], 7) - await operations.create_or_update_rule(internal_user_id, three_tags_ids[:2], 11) + await operations.create_or_update_rule( + internal_user_id, required_tags=five_tags_ids[:2], excluded_tags=five_tags_ids[-2:], score=3 + ) + await operations.create_or_update_rule( + another_internal_user_id, required_tags=five_tags_ids[:2], excluded_tags=five_tags_ids[-2:], score=5 + ) + await operations.create_or_update_rule( + another_internal_user_id, required_tags=five_tags_ids[1:], excluded_tags=five_tags_ids[:1], score=7 + ) + await operations.create_or_update_rule( + internal_user_id, required_tags=five_tags_ids[:2], excluded_tags=five_tags_ids[-2:], score=11 + ) rules = await domain.get_rules(internal_user_id) assert len(rules) == 1 assert rules[0].user_id == internal_user_id - assert rules[0].tags == set(three_tags_ids[:2]) + assert rules[0].required_tags == set(five_tags_ids[:2]) + assert rules[0].excluded_tags == set(five_tags_ids[-2:]) assert rules[0].score == 11 rules = await domain.get_rules(another_internal_user_id) @@ -104,22 +142,52 @@ async def test_multiple_entities( assert len(rules) == 2 assert rules[0].user_id == another_internal_user_id - assert rules[0].tags == set(three_tags_ids[:2]) + assert rules[0].required_tags == set(five_tags_ids[:2]) + assert rules[0].excluded_tags == set(five_tags_ids[-2:]) assert rules[0].score == 5 assert rules[1].user_id == another_internal_user_id - assert rules[1].tags == set(three_tags_ids[1:]) + assert rules[1].required_tags == set(five_tags_ids[1:]) + assert rules[1].excluded_tags == set(five_tags_ids[:1]) assert rules[1].score == 7 + @pytest.mark.asyncio + async def test_tags_intersection( + self, internal_user_id: UserId, five_tags_ids: tuple[int, int, int, int, int] + ) -> None: + required_tags = five_tags_ids[:3] + excluded_tags = five_tags_ids[2:] + + assert len(set(required_tags) & set(excluded_tags)) == 1 + + with capture_logs() as logs: + with pytest.raises(errors.TagsIntersection): + async with TableSizeNotChanged("s_rules"): + await operations.create_or_update_rule( + internal_user_id, required_tags=required_tags, excluded_tags=excluded_tags, score=13 + ) + + rules = await domain.get_rules(internal_user_id) + + assert rules == [] + + assert_logs_has_no_business_event(logs, "rule_created") + class TestDeleteRule: @pytest.mark.asyncio async def test_delete_rule( self, internal_user_id: UserId, another_internal_user_id: UserId, three_tags_ids: tuple[int, int, int] ) -> None: - rule_to_delete = await operations.create_or_update_rule(internal_user_id, three_tags_ids, 13) - rule_2 = await operations.create_or_update_rule(internal_user_id, three_tags_ids[:2], 17) - rule_3 = await operations.create_or_update_rule(another_internal_user_id, three_tags_ids, 19) + rule_to_delete = await operations.create_or_update_rule( + internal_user_id, required_tags=three_tags_ids, excluded_tags=[], score=13 + ) + rule_2 = await operations.create_or_update_rule( + internal_user_id, required_tags=three_tags_ids[:2], excluded_tags=[], score=17 + ) + rule_3 = await operations.create_or_update_rule( + another_internal_user_id, required_tags=[], excluded_tags=three_tags_ids, score=19 + ) with capture_logs() as logs: async with TableSizeDelta("s_rules", delta=-1): @@ -136,12 +204,10 @@ async def test_delete_rule( assert rules == [rule_3] @pytest.mark.asyncio - async def test_delete_not_existed_rule( - self, internal_user_id: UserId, three_tags_ids: tuple[int, int, int] - ) -> None: + async def test_delete_not_existed_rule(self, internal_user_id: UserId) -> None: with capture_logs() as logs: async with TableSizeNotChanged("s_rules"): - await operations.delete_rule(internal_user_id, uuid.uuid4()) + await operations.delete_rule(internal_user_id, new_rule_id()) assert_logs_has_no_business_event(logs, "rule_deleted") @@ -149,7 +215,9 @@ async def test_delete_not_existed_rule( async def test_delete_for_wrong_user( self, internal_user_id: UserId, another_internal_user_id: UserId, three_tags_ids: tuple[int, int, int] ) -> None: - rule_to_delete = await operations.create_or_update_rule(internal_user_id, three_tags_ids, 13) + rule_to_delete = await operations.create_or_update_rule( + internal_user_id, required_tags=three_tags_ids, excluded_tags=[], score=13 + ) async with TableSizeNotChanged("s_rules"): await operations.delete_rule(another_internal_user_id, rule_to_delete.id) @@ -157,18 +225,28 @@ async def test_delete_for_wrong_user( class TestUpdateRule: @pytest.mark.asyncio - async def test_update_rule(self, internal_user_id: UserId, three_tags_ids: tuple[int, int, int]) -> None: - rule_to_update = await operations.create_or_update_rule(internal_user_id, three_tags_ids, 13) + async def test_update_rule(self, internal_user_id: UserId, five_tags_ids: tuple[int, int, int, int, int]) -> None: + required_tags = list(five_tags_ids[:3]) + excluded_tags = list(five_tags_ids[3:]) + + rule_to_update = await operations.create_or_update_rule( + internal_user_id, required_tags=required_tags, excluded_tags=excluded_tags, score=13 + ) with capture_logs() as logs: async with TableSizeNotChanged("s_rules"): updated_rule = await operations.update_rule( - internal_user_id, rule_to_update.id, three_tags_ids[:2], 17 + internal_user_id, + rule_to_update.id, + required_tags=five_tags_ids[:4], + excluded_tags=five_tags_ids[4:], + score=17, ) assert updated_rule.id == rule_to_update.id assert updated_rule.user_id == internal_user_id - assert updated_rule.tags == set(three_tags_ids[:2]) + assert updated_rule.required_tags == set(five_tags_ids[:4]) + assert updated_rule.excluded_tags == set(five_tags_ids[4:]) assert updated_rule.score == 17 rules = await domain.get_rules(internal_user_id) @@ -180,7 +258,8 @@ async def test_update_rule(self, internal_user_id: UserId, three_tags_ids: tuple "rule_updated", user_id=internal_user_id, rule_id=str(rule_to_update.id), - tags=list(three_tags_ids[:2]), + required_tags=list(five_tags_ids[:4]), + excluded_tags=list(five_tags_ids[4:]), score=17, ) @@ -191,7 +270,13 @@ async def test_update_not_existed_rule( with capture_logs() as logs: async with TableSizeNotChanged("s_rules"): with pytest.raises(errors.NoRuleFound): - await operations.update_rule(internal_user_id, uuid.uuid4(), three_tags_ids[:2], 17) + await operations.update_rule( + internal_user_id, + new_rule_id(), + required_tags=three_tags_ids[:2], + excluded_tags=three_tags_ids[2:], + score=17, + ) assert_logs_has_no_business_event(logs, "rule_updated") @@ -199,16 +284,50 @@ async def test_update_not_existed_rule( async def test_wrong_user( self, internal_user_id: UserId, another_internal_user_id: UserId, three_tags_ids: tuple[int, int, int] ) -> None: - rule_to_update = await operations.create_or_update_rule(internal_user_id, three_tags_ids, 13) + rule_to_update = await operations.create_or_update_rule( + internal_user_id, required_tags=three_tags_ids[:2], excluded_tags=[], score=13 + ) async with TableSizeNotChanged("s_rules"): with pytest.raises(errors.NoRuleFound): - await operations.update_rule(another_internal_user_id, rule_to_update.id, three_tags_ids, 17) + await operations.update_rule( + another_internal_user_id, + rule_to_update.id, + required_tags=three_tags_ids[2:], + excluded_tags=[], + score=17, + ) + + rules = await domain.get_rules(internal_user_id) + + assert rules == [rule_to_update] + + @pytest.mark.asyncio + async def test_tags_intersection( + self, internal_user_id: UserId, five_tags_ids: tuple[int, int, int, int, int] + ) -> None: + rule_to_update = await operations.create_or_update_rule( + internal_user_id, required_tags=five_tags_ids[:3], excluded_tags=[], score=13 + ) + + assert set(five_tags_ids[:3]) & set(five_tags_ids[2:]) + + with capture_logs() as logs: + with pytest.raises(errors.TagsIntersection): + await operations.update_rule( + internal_user_id, + rule_to_update.id, + required_tags=five_tags_ids[:3], + excluded_tags=five_tags_ids[2:], + score=17, + ) rules = await domain.get_rules(internal_user_id) assert rules == [rule_to_update] + assert_logs_has_no_business_event(logs, "rule_updated") + # most of the logic of this function is validated in other tests class TestGetRules: @@ -226,9 +345,11 @@ async def test_count_rules(self, internal_user_id: UserId, another_internal_user numbers_before = await operations.count_rules_per_user() - await operations.create_or_update_rule(internal_user_id, [1, 2], 3) - await operations.create_or_update_rule(internal_user_id, [2, 3], 5) - await operations.create_or_update_rule(another_internal_user_id, [1, 2], 7) + await operations.create_or_update_rule(internal_user_id, required_tags=[1, 2], excluded_tags=[], score=3) + await operations.create_or_update_rule(internal_user_id, required_tags=[], excluded_tags=[2, 3], score=5) + await operations.create_or_update_rule( + another_internal_user_id, required_tags=[1, 2], excluded_tags=[3, 4], score=7 + ) numbers_after = await operations.count_rules_per_user() diff --git a/ffun/ffun/users/domain.py b/ffun/ffun/users/domain.py index 0f2e795b..fa1f86be 100644 --- a/ffun/ffun/users/domain.py +++ b/ffun/ffun/users/domain.py @@ -5,6 +5,7 @@ add_mapping = operations.add_mapping get_mapping = operations.get_mapping count_total_users = operations.count_total_users +tech_move_user = operations.tech_move_user async def get_or_create_user_id(service: Service, external_id: str) -> UserId: diff --git a/ffun/ffun/users/operations.py b/ffun/ffun/users/operations.py index 380c7786..83aece80 100644 --- a/ffun/ffun/users/operations.py +++ b/ffun/ffun/users/operations.py @@ -1,7 +1,7 @@ import psycopg from ffun.core import logging -from ffun.core.postgresql import execute +from ffun.core.postgresql import ExecuteType, execute, run_in_transaction from ffun.domain.domain import new_user_id from ffun.domain.entities import UserId from ffun.users import errors @@ -48,3 +48,13 @@ async def get_mapping(service: Service, external_id: str) -> UserId: async def count_total_users() -> int: result = await execute("SELECT COUNT(*) FROM u_mapping") return result[0]["count"] # type: ignore + + +# Use only for development and testing purposes +@run_in_transaction +async def tech_move_user(execute: ExecuteType, from_user_id: UserId, to_user_id: UserId) -> None: + await execute("DELETE FROM u_mapping WHERE internal_id = %(internal_id)s", {"internal_id": from_user_id}) + await execute( + "UPDATE u_mapping SET internal_id = %(from_user_id)s WHERE internal_id = %(to_user_id)s", + {"from_user_id": from_user_id, "to_user_id": to_user_id}, + ) diff --git a/site/src/components/EntriesList.vue b/site/src/components/EntriesList.vue index 936ac24c..80e70980 100644 --- a/site/src/components/EntriesList.vue +++ b/site/src/components/EntriesList.vue @@ -9,9 +9,7 @@ + :tags-count="tagsCount" /> @@ -35,14 +33,11 @@ const properties = defineProps<{ entriesIds: Array; timeField: string; - showTags: boolean; showFromStart: number; showPerPage: number; tagsCount: {[key: string]: number}; }>(); - const emit = defineEmits(["entry:bodyVisibilityChanged"]); - const showEntries = ref(properties.showFromStart); const entriesToShow = computed(() => { @@ -51,14 +46,14 @@ } return properties.entriesIds.slice(0, showEntries.value); }); - - function onBodyVisibilityChanged({entryId, visible}: {entryId: t.EntryId; visible: boolean}) { - emit("entry:bodyVisibilityChanged", {entryId, visible}); - } diff --git a/site/src/components/EntryForList.vue b/site/src/components/EntryForList.vue index 7a01ae41..bab89e5e 100644 --- a/site/src/components/EntryForList.vue +++ b/site/src/components/EntryForList.vue @@ -1,5 +1,7 @@ diff --git a/site/src/components/TagsList.vue b/site/src/components/TagsList.vue index 72057c29..17f46de4 100644 --- a/site/src/components/TagsList.vue +++ b/site/src/components/TagsList.vue @@ -1,72 +1,53 @@ diff --git a/site/src/components/notifications/Block.vue b/site/src/components/notifications/Block.vue index 2a90cc1b..4681da64 100644 --- a/site/src/components/notifications/Block.vue +++ b/site/src/components/notifications/Block.vue @@ -6,9 +6,11 @@ diff --git a/site/src/layouts/SidePanelLayout.vue b/site/src/layouts/SidePanelLayout.vue index f1fb14e7..08b487c3 100644 --- a/site/src/layouts/SidePanelLayout.vue +++ b/site/src/layouts/SidePanelLayout.vue @@ -36,7 +36,7 @@ v-if="reloadButton" href="#" @click="globalSettings.updateDataVersion()" - >ReloadRefresh
diff --git a/site/src/logic/api.ts b/site/src/logic/api.ts index d9cc875f..566542a4 100644 --- a/site/src/logic/api.ts +++ b/site/src/logic/api.ts @@ -99,10 +99,22 @@ export async function getEntriesByIds({ids}: {ids: t.EntryId[]}) { return entries; } -export async function createOrUpdateRule({tags, score}: {tags: string[]; score: number}) { +export async function createOrUpdateRule({ + requiredTags, + excludedTags, + score +}: { + requiredTags: string[]; + excludedTags: string[]; + score: number; +}) { const response = await post({ url: API_CREATE_OR_UPDATE_RULE, - data: {tags: tags, score: score} + data: { + requiredTags: requiredTags, + excludedTags: excludedTags, + score: score + } }); return response; } @@ -112,10 +124,20 @@ export async function deleteRule({id}: {id: t.RuleId}) { return response; } -export async function updateRule({id, tags, score}: {id: t.RuleId; tags: string[]; score: number}) { +export async function updateRule({ + id, + requiredTags, + excludedTags, + score +}: { + id: t.RuleId; + requiredTags: string[]; + excludedTags: string[]; + score: number; +}) { const response = await post({ url: API_UPDATE_RULE, - data: {id: id, tags: tags, score: score} + data: {id: id, score: score, requiredTags: requiredTags, excludedTags: excludedTags} }); return response; } diff --git a/site/src/logic/asserts.ts b/site/src/logic/asserts.ts new file mode 100644 index 00000000..74fb1349 --- /dev/null +++ b/site/src/logic/asserts.ts @@ -0,0 +1,5 @@ +export function defined(value: T | null | undefined, name?: string): asserts value is T { + if (value === null || value === undefined) { + throw new Error(`${name ?? "Value"} is null or undefined`); + } +} diff --git a/site/src/logic/tagsFilterState.ts b/site/src/logic/tagsFilterState.ts index 3c17da3c..422b211f 100644 --- a/site/src/logic/tagsFilterState.ts +++ b/site/src/logic/tagsFilterState.ts @@ -1,3 +1,6 @@ +import {ref, computed, reactive} from "vue"; +import type {ComputedRef} from "vue"; + export type State = "required" | "excluded" | "none"; interface ReturnTagsForEntity { @@ -7,48 +10,97 @@ interface ReturnTagsForEntity { export class Storage { requiredTags: {[key: string]: boolean}; excludedTags: {[key: string]: boolean}; + selectedTags: ComputedRef<{[key: string]: boolean}>; + hasSelectedTags: ComputedRef; constructor() { - this.requiredTags = {}; - this.excludedTags = {}; + this.requiredTags = reactive({}); + this.excludedTags = reactive({}); + + this.selectedTags = computed(() => { + return {...this.requiredTags, ...this.excludedTags}; + }); + + this.hasSelectedTags = computed(() => { + return Object.keys(this.selectedTags.value).length > 0; + }); } onTagStateChanged({tag, state}: {tag: string; state: State}) { if (state === "required") { this.requiredTags[tag] = true; - this.excludedTags[tag] = false; + if (this.excludedTags[tag]) { + delete this.excludedTags[tag]; + } } else if (state === "excluded") { this.excludedTags[tag] = true; - this.requiredTags[tag] = false; + if (this.requiredTags[tag]) { + delete this.requiredTags[tag]; + } } else if (state === "none") { - this.excludedTags[tag] = false; - this.requiredTags[tag] = false; + if (this.requiredTags[tag]) { + delete this.requiredTags[tag]; + } + + if (this.excludedTags[tag]) { + delete this.excludedTags[tag]; + } } else { throw new Error(`Unknown tag state: ${state}`); } } + onTagReversed({tag}: {tag: string}) { + if (!(tag in this.selectedTags)) { + this.onTagStateChanged({tag: tag, state: "required"}); + } else if (this.requiredTags[tag]) { + this.onTagStateChanged({tag: tag, state: "excluded"}); + } else if (this.excludedTags[tag]) { + this.onTagStateChanged({tag: tag, state: "required"}); + } else { + throw new Error(`Unknown tag state: ${tag}`); + } + } + + onTagClicked({tag}: {tag: string}) { + if (tag in this.selectedTags) { + this.onTagStateChanged({tag: tag, state: "none"}); + } else { + this.onTagStateChanged({tag: tag, state: "required"}); + } + } + filterByTags(entities: any[], getTags: ReturnTagsForEntity) { let report = entities.slice(); + const requiredTags = Object.keys(this.requiredTags); + report = report.filter((entity) => { for (const tag of getTags(entity)) { if (this.excludedTags[tag]) { return false; } } - return true; - }); - report = report.filter((entity) => { - for (const tag of Object.keys(this.requiredTags)) { - if (this.requiredTags[tag] && !getTags(entity).includes(tag)) { + for (const tag of requiredTags) { + if (!getTags(entity).includes(tag)) { return false; } } + return true; }); return report; } + + clear() { + Object.keys(this.requiredTags).forEach((key) => { + delete this.requiredTags[key]; + }); + + Object.keys(this.excludedTags).forEach((key) => { + delete this.excludedTags[key]; + }); + } } diff --git a/site/src/logic/types.ts b/site/src/logic/types.ts index 6ffa086c..f1e34847 100644 --- a/site/src/logic/types.ts +++ b/site/src/logic/types.ts @@ -231,7 +231,9 @@ export function entryFromJSON( export type Rule = { readonly id: RuleId; - readonly tags: string[]; + readonly requiredTags: string[]; + readonly excludedTags: string[]; + readonly allTags: string[]; readonly score: number; readonly createdAt: Date; readonly updatedAt: Date; @@ -239,22 +241,27 @@ export type Rule = { export function ruleFromJSON({ id, - tags, + requiredTags, + excludedTags, score, createdAt, updatedAt }: { id: string; - tags: string[]; + requiredTags: string[]; + excludedTags: string[]; score: number; createdAt: string; updatedAt: string; }): Rule { - tags = tags.sort(); + requiredTags = requiredTags.sort(); + excludedTags = excludedTags.sort(); return { id: toRuleId(id), - tags: tags, + requiredTags: requiredTags, + excludedTags: excludedTags, + allTags: requiredTags.concat(excludedTags), score: score, createdAt: new Date(createdAt), updatedAt: new Date(updatedAt) diff --git a/site/src/stores/entries.ts b/site/src/stores/entries.ts index 65513e0a..15ca1c9b 100644 --- a/site/src/stores/entries.ts +++ b/site/src/stores/entries.ts @@ -8,12 +8,14 @@ import * as api from "@/logic/api"; import {Timer} from "@/logic/timer"; import {computedAsync} from "@vueuse/core"; import {useGlobalSettingsStore} from "@/stores/globalSettings"; +import * as events from "@/logic/events"; export const useEntriesStore = defineStore("entriesStore", () => { const globalSettings = useGlobalSettingsStore(); const entries = ref<{[key: t.EntryId]: t.Entry}>({}); const requestedEntries = ref<{[key: t.EntryId]: boolean}>({}); + const displayedEntryId = ref(null); function registerEntry(entry: t.Entry) { if (entry.id in entries.value) { @@ -95,11 +97,35 @@ export const useEntriesStore = defineStore("entriesStore", () => { } } + async function displayEntry({entryId}: {entryId: t.EntryId}) { + displayedEntryId.value = entryId; + + requestFullEntry({entryId: entryId}); + + if (!entries.value[entryId].hasMarker(e.Marker.Read)) { + await setMarker({ + entryId: entryId, + marker: e.Marker.Read + }); + } + + await events.newsBodyOpened({entryId: entryId}); + } + + function hideEntry({entryId}: {entryId: t.EntryId}) { + if (displayedEntryId.value === entryId) { + displayedEntryId.value = null; + } + } + return { entries, requestFullEntry, setMarker, removeMarker, - loadedEntriesReport + loadedEntriesReport, + displayedEntryId, + displayEntry, + hideEntry }; }); diff --git a/site/src/stores/globalSettings.ts b/site/src/stores/globalSettings.ts index b373151c..265e5b58 100644 --- a/site/src/stores/globalSettings.ts +++ b/site/src/stores/globalSettings.ts @@ -18,7 +18,6 @@ export const useGlobalSettingsStore = defineStore("globalSettings", () => { // Entries const lastEntriesPeriod = ref(e.LastEntriesPeriod.Day3); const entriesOrder = ref(e.EntriesOrder.Score); - const showEntriesTags = ref(true); const showRead = ref(true); // Feeds @@ -64,7 +63,6 @@ export const useGlobalSettingsStore = defineStore("globalSettings", () => { mainPanelMode, lastEntriesPeriod, entriesOrder, - showEntriesTags, showRead, dataVersion, updateDataVersion, diff --git a/site/src/values/Score.vue b/site/src/values/Score.vue index 770afbad..10f8eeb1 100644 --- a/site/src/values/Score.vue +++ b/site/src/values/Score.vue @@ -30,7 +30,7 @@ for (const rule of rules) { const tags = []; - for (const tagId of rule.tags) { + for (const tagId of rule.requiredTags) { const tagInfo = tagsStore.tags[tagId]; if (tagInfo) { tags.push(tagInfo.name); @@ -39,7 +39,16 @@ } } - strings.push(rule.score.toString().padStart(2, " ") + " — " + tags.join(", ")); + for (const tagId of rule.excludedTags) { + const tagInfo = tagsStore.tags[tagId]; + if (tagInfo) { + tags.push("NOT " + tagInfo.name); + } else { + tags.push("NOT " + tagId); + } + } + + strings.push(rule.score.toString().padStart(2, " ") + " — " + tags.join(" AND ")); } alert(strings.join("\n")); diff --git a/site/src/views/NewsView.vue b/site/src/views/NewsView.vue index 27b54a65..708a1fe4 100644 --- a/site/src/views/NewsView.vue +++ b/site/src/views/NewsView.vue @@ -15,15 +15,6 @@ - - diff --git a/site/src/views/RulesView.vue b/site/src/views/RulesView.vue index cc8fcccf..f3afcdd3 100644 --- a/site/src/views/RulesView.vue +++ b/site/src/views/RulesView.vue @@ -13,11 +13,21 @@ +
+

You can create new rules on the + news + tab.

+
+ @@ -25,7 +35,8 @@