Compare commits
No commits in common. "65b6ab26e7ccea7f777da65a66646e02b6f058e0" and "63e51fa017f2f7f4398ae5cc90c6c21cc8873421" have entirely different histories.
65b6ab26e7
...
63e51fa017
7 changed files with 12 additions and 294 deletions
|
|
@ -1,25 +1,16 @@
|
||||||
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, joinedload
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, Session
|
||||||
|
|
||||||
from src.infra.db import Base
|
from src.infra.db import Base
|
||||||
from src.services.publications import (
|
from src.services.publications import Publication, Order, Entry, Sequence, PublicationProfile, PublicationRepo
|
||||||
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')
|
||||||
|
|
@ -64,13 +55,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, title=db_pub_entry.title)
|
return Entry(id=db_pub_entry.id, text=db_pub_entry.text)
|
||||||
|
|
||||||
|
|
||||||
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).to_profile(),
|
entry=model_to_entry(db_pub_position.entry),
|
||||||
duration=db_pub_position.duration,
|
duration=db_pub_position.duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -83,20 +74,13 @@ 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(lambda m: model_to_order_profile(m), db_pub.orders)),
|
orders=list(map(model_to_order, db_pub.orders)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,7 +96,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, options=[joinedload(PublicationModel.orders)])
|
publication = session.get(PublicationModel, pub_id)
|
||||||
|
|
||||||
if publication:
|
if publication:
|
||||||
return model_to_publication(publication)
|
return model_to_publication(publication)
|
||||||
|
|
@ -127,22 +111,3 @@ 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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
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, relationship
|
from sqlalchemy.orm import mapped_column, Mapped, Session
|
||||||
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'
|
||||||
|
|
@ -21,8 +17,6 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
from .data import Publication, PublicationProfile, Order, Sequence, Entry, OrderProfile
|
from .data import Publication, PublicationProfile, Order, Sequence, Entry
|
||||||
from .service import PublicationService
|
from .service import PublicationService
|
||||||
from .repo import PublicationRepo
|
from .repo import PublicationRepo
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ['Publication', 'PublicationProfile', 'Order', 'Sequence', 'Entry', 'PublicationService', 'PublicationRepo']
|
||||||
'Publication',
|
|
||||||
'PublicationProfile',
|
|
||||||
'Order',
|
|
||||||
'OrderProfile',
|
|
||||||
'Sequence',
|
|
||||||
'Entry',
|
|
||||||
'PublicationService',
|
|
||||||
'PublicationRepo',
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -2,44 +2,25 @@ 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: EntryProfile
|
entry: Entry
|
||||||
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:
|
||||||
|
|
@ -54,7 +35,7 @@ class Publication:
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
by_line: str
|
by_line: str
|
||||||
orders: list[OrderProfile] = field(default_factory=list)
|
orders: list[Order] = 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:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import abc
|
import abc
|
||||||
|
|
||||||
from .data import Publication, PublicationProfile, Order, Entry
|
from .data import Publication, PublicationProfile
|
||||||
|
|
||||||
|
|
||||||
class PublicationRepo(abc.ABC):
|
class PublicationRepo(abc.ABC):
|
||||||
|
|
@ -15,11 +15,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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)
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue