Compare commits

...

2 commits

Author SHA1 Message Date
Campbell Alden
65b6ab26e7 Add basic subscriptions and a partial implementation of the infra backing 2026-08-21 00:24:32 +09:00
Campbell Alden
86b0d88874 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.
2026-08-21 00:24:14 +09:00
7 changed files with 294 additions and 12 deletions

View file

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

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

@ -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',
]

View file

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

View file

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

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)