Add basic subscriptions and a partial implementation of the infra backing

This commit is contained in:
Campbell Alden 2026-08-21 00:24:32 +09:00
parent 86b0d88874
commit 65b6ab26e7
3 changed files with 212 additions and 1 deletions

100
src/infra/subscription.py Normal file
View file

@ -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

View file

@ -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(

View file

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