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 from datetime import timedelta
import logging import logging
from sqlalchemy import String, Text, ForeignKey, Engine, Interval, UniqueConstraint 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.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): class PublicationEntryModel(Base):
__tablename__ = 'publication_entry' __tablename__ = 'publication_entry'
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256))
text: Mapped[str] = mapped_column(Text()) text: Mapped[str] = mapped_column(Text())
sequences: Mapped[list['PublicationSequenceModel']] = relationship(back_populates='entry') sequences: Mapped[list['PublicationSequenceModel']] = relationship(back_populates='entry')
@ -55,13 +64,13 @@ class PublicationModel(Base):
def model_to_entry(db_pub_entry: PublicationEntryModel) -> Entry: 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: def model_to_sequence(db_pub_position: PublicationSequenceModel) -> Sequence:
return Sequence( return Sequence(
position=db_pub_position.position, 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, 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: def model_to_publication(db_pub: PublicationModel) -> Publication:
return Publication( return Publication(
id=db_pub.id, id=db_pub.id,
title=db_pub.title, title=db_pub.title,
description=db_pub.description, description=db_pub.description,
by_line=db_pub.by_line, 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: def get_publication_by_id(self, pub_id) -> Publication | None:
with Session(self._db) as session: with Session(self._db) as session:
publication = session.get(PublicationModel, pub_id) publication = session.get(PublicationModel, pub_id, options=[joinedload(PublicationModel.orders)])
if publication: if publication:
return model_to_publication(publication) return model_to_publication(publication)
@ -111,3 +127,22 @@ class PublicationRepoImpl(PublicationRepo):
def get_all_publications(self) -> list[PublicationProfile]: def get_all_publications(self) -> list[PublicationProfile]:
with Session(self._db) as session: with Session(self._db) as session:
return list(map(model_to_publication_profile, session.query(PublicationModel).all())) 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 import logging
from argon2.exceptions import VerifyMismatchError from argon2.exceptions import VerifyMismatchError
from src.utils.secret import SecretBox from src.utils.secret import SecretBox
from src.config.email import EmailAddress from src.config.email import EmailAddress
from sqlalchemy import String, VARCHAR, Engine, select 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 argon2 import PasswordHasher
from src.infra.db import Base from src.infra.db import Base
from src.services.users.data import UserDTO, User from src.services.users.data import UserDTO, User
from src.services.users.repo import UserRepo from src.services.users.repo import UserRepo
if TYPE_CHECKING:
from src.infra.subscription import SubscriptionModel
class UserModel(Base): class UserModel(Base):
__tablename__ = 'user' __tablename__ = 'user'
@ -17,6 +21,8 @@ class UserModel(Base):
email_confirmed: Mapped[bool] = mapped_column() email_confirmed: Mapped[bool] = mapped_column()
password_hash: Mapped[str] = mapped_column(VARCHAR(255)) password_hash: Mapped[str] = mapped_column(VARCHAR(255))
subscriptions: Mapped[list['SubscriptionModel']] = relationship(back_populates='user')
def model_to_user(db_user: UserModel) -> User: def model_to_user(db_user: UserModel) -> User:
return 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 .service import PublicationService
from .repo import PublicationRepo 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 from dataclasses import dataclass, field
@dataclass
class EntryProfile:
id: int
title: str
@dataclass @dataclass
class Entry: class Entry:
id: int id: int
title: str
text: str text: str
def to_profile(self) -> EntryProfile:
return EntryProfile(self.id, self.title)
@dataclass @dataclass
class Sequence: class Sequence:
position: int position: int
entry: Entry entry: EntryProfile
duration: timedelta | None duration: timedelta | None
@dataclass
class OrderProfile:
id: int
title: str
@dataclass @dataclass
class Order: class Order:
id: int id: int
title: str title: str
sequence_entries: list[Sequence] sequence_entries: list[Sequence]
def to_profile(self) -> OrderProfile:
return OrderProfile(id=self.id, title=self.title)
@dataclass @dataclass
class PublicationProfile: class PublicationProfile:
@ -35,7 +54,7 @@ class Publication:
title: str title: str
description: str description: str
by_line: 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 # Rather than using a subclass I think this makes sense
def to_profile(self) -> PublicationProfile: def to_profile(self) -> PublicationProfile:

View file

@ -1,6 +1,6 @@
import abc import abc
from .data import Publication, PublicationProfile from .data import Publication, PublicationProfile, Order, Entry
class PublicationRepo(abc.ABC): class PublicationRepo(abc.ABC):
@ -15,3 +15,11 @@ class PublicationRepo(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
def get_all_publications(self) -> list[PublicationProfile]: def get_all_publications(self) -> list[PublicationProfile]:
pass 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)