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)