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/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/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 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)