From 86b0d88874a443e55ffdba54a441ef6c1ca0d4eb Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Fri, 21 Aug 2026 00:24:14 +0900 Subject: [PATCH 1/2] Improve the publication table / data types to denormalize the values Returning profile values where necessary will remove the need to compute heavy joins unless the user actually wants all the data. Now Publication points at OrderProfiles which don't expose the sequences Orders->Sequence->EntryProfile avoids including the full `text` for the sequence. --- src/infra/publication.py | 47 +++++++++++++++++++++++---- src/services/publications/__init__.py | 13 ++++++-- src/services/publications/data.py | 23 +++++++++++-- src/services/publications/repo.py | 10 +++++- 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/infra/publication.py b/src/infra/publication.py index b07df6d..fb28357 100644 --- a/src/infra/publication.py +++ b/src/infra/publication.py @@ -1,16 +1,25 @@ from datetime import timedelta import logging from sqlalchemy import String, Text, ForeignKey, Engine, Interval, UniqueConstraint -from sqlalchemy.orm import Mapped, mapped_column, relationship, Session +from sqlalchemy.orm import Mapped, mapped_column, relationship, Session, joinedload from src.infra.db import Base -from src.services.publications import Publication, Order, Entry, Sequence, PublicationProfile, PublicationRepo +from src.services.publications import ( + Publication, + Order, + Entry, + Sequence, + PublicationProfile, + PublicationRepo, + OrderProfile, +) class PublicationEntryModel(Base): __tablename__ = 'publication_entry' id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(256)) text: Mapped[str] = mapped_column(Text()) sequences: Mapped[list['PublicationSequenceModel']] = relationship(back_populates='entry') @@ -55,13 +64,13 @@ class PublicationModel(Base): def model_to_entry(db_pub_entry: PublicationEntryModel) -> Entry: - return Entry(id=db_pub_entry.id, text=db_pub_entry.text) + return Entry(id=db_pub_entry.id, text=db_pub_entry.text, title=db_pub_entry.title) def model_to_sequence(db_pub_position: PublicationSequenceModel) -> Sequence: return Sequence( position=db_pub_position.position, - entry=model_to_entry(db_pub_position.entry), + entry=model_to_entry(db_pub_position.entry).to_profile(), duration=db_pub_position.duration, ) @@ -74,13 +83,20 @@ def model_to_order(db_pub_order: PublicationOrderModel) -> Order: ) +def model_to_order_profile(db_pub_order: PublicationOrderModel) -> OrderProfile: + return OrderProfile( + id=db_pub_order.id, + title=db_pub_order.title, + ) + + def model_to_publication(db_pub: PublicationModel) -> Publication: return Publication( id=db_pub.id, title=db_pub.title, description=db_pub.description, by_line=db_pub.by_line, - orders=list(map(model_to_order, db_pub.orders)), + orders=list(map(lambda m: model_to_order_profile(m), db_pub.orders)), ) @@ -96,7 +112,7 @@ class PublicationRepoImpl(PublicationRepo): def get_publication_by_id(self, pub_id) -> Publication | None: with Session(self._db) as session: - publication = session.get(PublicationModel, pub_id) + publication = session.get(PublicationModel, pub_id, options=[joinedload(PublicationModel.orders)]) if publication: return model_to_publication(publication) @@ -111,3 +127,22 @@ class PublicationRepoImpl(PublicationRepo): def get_all_publications(self) -> list[PublicationProfile]: with Session(self._db) as session: return list(map(model_to_publication_profile, session.query(PublicationModel).all())) + + def get_full_order(self, order_id: int) -> Order | None: + with Session(self._db) as session: + order = session.get( + PublicationOrderModel, order_id, options=[joinedload(PublicationOrderModel.sequence_entries)] + ) + + if order: + return model_to_order(order) + + def get_full_entry(self, entry_id: int) -> Entry | None: + with Session(self._db) as session: + order = session.get( + PublicationEntryModel, + entry_id, + ) + + if order: + return model_to_entry(order) diff --git a/src/services/publications/__init__.py b/src/services/publications/__init__.py index 451db29..b19e688 100644 --- a/src/services/publications/__init__.py +++ b/src/services/publications/__init__.py @@ -1,5 +1,14 @@ -from .data import Publication, PublicationProfile, Order, Sequence, Entry +from .data import Publication, PublicationProfile, Order, Sequence, Entry, OrderProfile from .service import PublicationService from .repo import PublicationRepo -__all__ = ['Publication', 'PublicationProfile', 'Order', 'Sequence', 'Entry', 'PublicationService', 'PublicationRepo'] +__all__ = [ + 'Publication', + 'PublicationProfile', + 'Order', + 'OrderProfile', + 'Sequence', + 'Entry', + 'PublicationService', + 'PublicationRepo', +] diff --git a/src/services/publications/data.py b/src/services/publications/data.py index 7d2bf59..c29a563 100644 --- a/src/services/publications/data.py +++ b/src/services/publications/data.py @@ -2,25 +2,44 @@ from datetime import timedelta from dataclasses import dataclass, field +@dataclass +class EntryProfile: + id: int + title: str + + @dataclass class Entry: id: int + title: str text: str + def to_profile(self) -> EntryProfile: + return EntryProfile(self.id, self.title) + @dataclass class Sequence: position: int - entry: Entry + entry: EntryProfile duration: timedelta | None +@dataclass +class OrderProfile: + id: int + title: str + + @dataclass class Order: id: int title: str sequence_entries: list[Sequence] + def to_profile(self) -> OrderProfile: + return OrderProfile(id=self.id, title=self.title) + @dataclass class PublicationProfile: @@ -35,7 +54,7 @@ class Publication: title: str description: str by_line: str - orders: list[Order] = field(default_factory=list) + orders: list[OrderProfile] = field(default_factory=list) # Rather than using a subclass I think this makes sense def to_profile(self) -> PublicationProfile: diff --git a/src/services/publications/repo.py b/src/services/publications/repo.py index 56502c6..0494fec 100644 --- a/src/services/publications/repo.py +++ b/src/services/publications/repo.py @@ -1,6 +1,6 @@ import abc -from .data import Publication, PublicationProfile +from .data import Publication, PublicationProfile, Order, Entry class PublicationRepo(abc.ABC): @@ -15,3 +15,11 @@ class PublicationRepo(abc.ABC): @abc.abstractmethod def get_all_publications(self) -> list[PublicationProfile]: pass + + @abc.abstractmethod + def get_full_order(self, order_id: int) -> Order | None: + pass + + @abc.abstractmethod + def get_full_entry(self, entry_id: int) -> Entry | None: + pass From 65b6ab26e7ccea7f777da65a66646e02b6f058e0 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Fri, 21 Aug 2026 00:24:32 +0900 Subject: [PATCH 2/2] Add basic subscriptions and a partial implementation of the infra backing --- src/infra/subscription.py | 100 +++++++++++++++++++++++++++++++++ src/infra/users.py | 8 ++- src/services/subscription.py | 105 +++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 src/infra/subscription.py create mode 100644 src/services/subscription.py diff --git a/src/infra/subscription.py b/src/infra/subscription.py new file mode 100644 index 0000000..40da20f --- /dev/null +++ b/src/infra/subscription.py @@ -0,0 +1,100 @@ +from src.infra.publication import model_to_order +from typing import TYPE_CHECKING +from datetime import datetime +from src.infra.users import UserModel, model_to_user +from sqlalchemy.orm import Mapped, mapped_column, relationship, Session, joinedload +from src.infra.db import Base +from sqlalchemy import Engine, ForeignKey, CheckConstraint, DateTime, func, UniqueConstraint, select +from src.services.subscription import ( + SubscriptionRepo, + Subscription, + SubscriptionId, + UpdateSubscription, + AvailableSubscriptionEntry, +) + +if TYPE_CHECKING: + from src.infra.publication import PublicationOrderModel + + +class SubscriptionModel(Base): + __tablename__ = 'subscription' + __table_args__ = ( + # Don't allow any weird avlues for sequence_seen + CheckConstraint('sequence_seen >= 1', name='sequence_seen_gte_1'), + # Ensure a user can't create multiple subscriptions to the same order + UniqueConstraint('order_id', 'user_id'), + ) + + id: Mapped[SubscriptionId] = mapped_column(primary_key=True) + + # The user that the subscription belongs to + user_id: Mapped[int] = mapped_column(ForeignKey('user.id')) + user: Mapped['UserModel'] = relationship(back_populates='subscriptions') + + # The order that the user is subscribing to + order_id: Mapped[int] = mapped_column(ForeignKey('publication_order.id')) + order: Mapped['PublicationOrderModel'] = relationship() + + # Which position into the sequence is available to the user + sequence_seen: Mapped[int] = mapped_column(server_default='1', default=1) + + # When the user's subscription began. + start: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + +def model_to_subscription(db_sub: SubscriptionModel) -> Subscription: + return Subscription( + id=db_sub.id, + user=model_to_user(db_sub.user).to_profile(), + publication_order=model_to_order(db_sub.order).to_profile(), + sequence_seen=db_sub.sequence_seen, + start=db_sub.start, + ) + + +class SubscriptionRepoImpl(SubscriptionRepo): + def __init__(self, db: Engine): + self._db = db + + def get_subscriptions_for_user(self, user_id: int) -> list[Subscription]: + with Session(self._db) as session: + subs = ( + session.scalars( + select(SubscriptionModel) + .where(SubscriptionModel.user_id == user_id) + .options( + joinedload(SubscriptionModel.order), + joinedload(SubscriptionModel.user), + ) + ) + .unique() + .all() + ) + return list(map(model_to_subscription, subs)) + + def get_subscription_by_id(self, sub_id: SubscriptionId) -> Subscription | None: + with Session(self._db) as session: + db_sub = session.get( + SubscriptionModel, + sub_id, + options=[joinedload(SubscriptionModel.order), joinedload(SubscriptionModel.user)], + ) + + if db_sub: + return model_to_subscription(db_sub) + + def create_subscription_for_user(self, user_id: int, order_id: int) -> Subscription: + pass + + def delete_subscription(self, subscription_id: SubscriptionId): + pass + + def update_subscription(self, update: UpdateSubscription) -> Subscription: + pass + + def get_available_entries(self, user_id: int) -> dict[SubscriptionId, list[AvailableSubscriptionEntry]]: + return {} + + def get_unseen_available_subscription_entries(self, user_id: int) -> list[AvailableSubscriptionEntry]: + pass diff --git a/src/infra/users.py b/src/infra/users.py index 0150631..f358acb 100644 --- a/src/infra/users.py +++ b/src/infra/users.py @@ -1,14 +1,18 @@ +from typing import TYPE_CHECKING import logging from argon2.exceptions import VerifyMismatchError from src.utils.secret import SecretBox from src.config.email import EmailAddress from sqlalchemy import String, VARCHAR, Engine, select -from sqlalchemy.orm import mapped_column, Mapped, Session +from sqlalchemy.orm import mapped_column, Mapped, Session, relationship from argon2 import PasswordHasher from src.infra.db import Base from src.services.users.data import UserDTO, User from src.services.users.repo import UserRepo +if TYPE_CHECKING: + from src.infra.subscription import SubscriptionModel + class UserModel(Base): __tablename__ = 'user' @@ -17,6 +21,8 @@ class UserModel(Base): email_confirmed: Mapped[bool] = mapped_column() password_hash: Mapped[str] = mapped_column(VARCHAR(255)) + subscriptions: Mapped[list['SubscriptionModel']] = relationship(back_populates='user') + def model_to_user(db_user: UserModel) -> User: return User( diff --git a/src/services/subscription.py b/src/services/subscription.py new file mode 100644 index 0000000..25f70a7 --- /dev/null +++ b/src/services/subscription.py @@ -0,0 +1,105 @@ +import abc +from datetime import datetime +from src.services.publications import ( + OrderProfile as PublicationOrderProfile, + Sequence as PublicationSequence, + Publication, +) +from src.services.users.data import UserProfile +from dataclasses import dataclass + +SubscriptionId = int + + +@dataclass +class SubscriptionCreateParams: + user_id: int + order_id: int + + +@dataclass +class Subscription: + id: SubscriptionId + user: UserProfile + publication_order: PublicationOrderProfile + sequence_seen: int + start: datetime + + +@dataclass +class UpdateSubscription: + id: int + user_id: int + publication_order_id: int | None = None + sequence_seen: int | None = None + start: datetime | None = None + + +@dataclass +class AvailableSubscriptionEntry: + publication: Publication + available_since: datetime + order: PublicationOrderProfile + sequence: PublicationSequence + + +class SubscriptionRepo(abc.ABC): + @abc.abstractmethod + def get_subscriptions_for_user(self, user_id: int) -> list[Subscription]: + pass + + @abc.abstractmethod + def get_subscription_by_id(self, sub_id: SubscriptionId) -> Subscription | None: + pass + + @abc.abstractmethod + def create_subscription_for_user(self, user_id: int, order_id: int) -> Subscription: + pass + + @abc.abstractmethod + def delete_subscription(self, subscription_id: SubscriptionId): + pass + + @abc.abstractmethod + def update_subscription(self, subscription: UpdateSubscription) -> Subscription: + pass + + @abc.abstractmethod + def get_available_entries(self, user_id: int) -> dict[SubscriptionId, list[AvailableSubscriptionEntry]]: + pass + + @abc.abstractmethod + def get_unseen_available_subscription_entries(self, user_id: int) -> list[AvailableSubscriptionEntry]: + """ + Get all publication entries that _should_ be available to the user given the timeline but are newer than the + `sequence_seen` value from the subscription + """ + pass + + +class SubscriptionService: + def __init__(self, repo: SubscriptionRepo): + self._repo = repo + + def get_subscriptions(self, user_id: int) -> list[Subscription]: + return self._repo.get_subscriptions_for_user(user_id) + + def get_updates(self, user_id: int) -> list[AvailableSubscriptionEntry]: + return self._repo.get_unseen_available_subscription_entries(user_id) + + def get_all_available_entries(self, user_id: int) -> dict[SubscriptionId, list[AvailableSubscriptionEntry]]: + return self._repo.get_available_entries(user_id) + + def create_suscription(self, params: SubscriptionCreateParams) -> Subscription: + return self._repo.create_subscription_for_user(params.user_id, params.order_id) + + def delete_subcription(self, user_id: int, subscription_id: SubscriptionId): + subscription = self._repo.get_subscription_by_id(subscription_id) + if subscription is None: + # TODO Domain error types + raise RuntimeError('not found') + if subscription.user.id != user_id: + # TODO: Domain error types + raise RuntimeError('Cannot delete a subscription for another user') + + self._repo.delete_subscription(subscription_id)