Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions alembic/versions/f6a31c8d9e42_tracker_recommendation_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Record the first profile that inspired a tracker item.

Revision ID: f6a31c8d9e42
Revises: 3c45fe305813
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

revision: str = 'f6a31c8d9e42'
down_revision: Union[str, Sequence[str], None] = '3c45fe305813'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

TABLES = ('user_movies', 'user_tv_shows', 'user_books', 'user_video_games')


def upgrade() -> None:
for table in TABLES:
op.add_column(table, sa.Column('source_user_id', sa.Integer(), nullable=True))
op.create_foreign_key(
f'fk_{table}_source_user_id_users',
table,
'users',
['source_user_id'],
['pk'],
ondelete='SET NULL',
)
op.create_index(f'ix_{table}_source_user_id', table, ['source_user_id'])


def downgrade() -> None:
for table in reversed(TABLES):
op.drop_index(f'ix_{table}_source_user_id', table_name=table)
op.drop_constraint(
f'fk_{table}_source_user_id_users', table, type_='foreignkey'
)
op.drop_column(table, 'source_user_id')
46 changes: 41 additions & 5 deletions app/db/models_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def rank_is_1_based(table_name: str) -> tuple:
"""
return (
CheckConstraint(
'rank IS NULL OR rank >= 1', name=f'ck_{table_name}_rank_1_based'
'rank IS NULL OR rank >= 1', name=f"ck_{table_name}_rank_1_based"
),
)

Expand Down Expand Up @@ -98,6 +98,9 @@ class DbUserMovie(DBBaseModel):

movie_id = Column(Integer, ForeignKey('movies.pk'), nullable=False)
user_id = Column(Integer, ForeignKey('users.pk'), nullable=False)
source_user_id = Column(
Integer, ForeignKey('users.pk', ondelete='SET NULL'), nullable=True, index=True
)

# Two independent lists: a movie may be on the watchlist, in the ranked
# list (with a rank position), or both. `completed` is retained from the
Expand All @@ -119,7 +122,13 @@ class DbUserMovie(DBBaseModel):
is_seed_data = Column(Boolean, nullable=False, default=False)

movie = relationship('DbMovie', back_populates='user_movies')
user = relationship('DbUser', backref='user_movies')
user = relationship('DbUser', foreign_keys=[user_id], backref='user_movies')
source_user = relationship('DbUser', foreign_keys=[source_user_id])

@property
def source_handle(self):
"""Return the handle of the user who inspired this tracker entry."""
return self.source_user.handle if self.source_user else None


class DbTVShow(DBBaseModel):
Expand Down Expand Up @@ -157,6 +166,9 @@ class DbUserTVShow(DBBaseModel):

tv_show_id = Column(Integer, ForeignKey('tv_shows.pk'), nullable=False)
user_id = Column(Integer, ForeignKey('users.pk'), nullable=False)
source_user_id = Column(
Integer, ForeignKey('users.pk', ondelete='SET NULL'), nullable=True, index=True
)

# Two independent lists, mirroring the Movies tracker. `status` and
# `freeze` are retained from the legacy import but no longer drive the UI.
Expand All @@ -176,7 +188,13 @@ class DbUserTVShow(DBBaseModel):
is_seed_data = Column(Boolean, nullable=False, default=False)

tv_show = relationship('DbTVShow', back_populates='user_tv_shows')
user = relationship('DbUser', backref='user_tv_shows')
user = relationship('DbUser', foreign_keys=[user_id], backref='user_tv_shows')
source_user = relationship('DbUser', foreign_keys=[source_user_id])

@property
def source_handle(self):
"""Return the handle of the user who inspired this tracker entry."""
return self.source_user.handle if self.source_user else None


class DbTVEpisode(DBBaseModel):
Expand Down Expand Up @@ -245,6 +263,9 @@ class DbUserVideoGame(DBBaseModel):

game_id = Column(Integer, ForeignKey('video_games.pk'), nullable=False)
user_id = Column(Integer, ForeignKey('users.pk'), nullable=False)
source_user_id = Column(
Integer, ForeignKey('users.pk', ondelete='SET NULL'), nullable=True, index=True
)

# Two independent lists, mirroring the Movies tracker: on_watchlist is the
# backlog, on_rankings the played-and-ranked list. `completed` is retained
Expand All @@ -265,7 +286,13 @@ class DbUserVideoGame(DBBaseModel):
is_seed_data = Column(Boolean, nullable=False, default=False)

game = relationship('DbVideoGame', back_populates='user_games')
user = relationship('DbUser', backref='user_video_games')
user = relationship('DbUser', foreign_keys=[user_id], backref='user_video_games')
source_user = relationship('DbUser', foreign_keys=[source_user_id])

@property
def source_handle(self):
"""Return the handle of the user who inspired this tracker entry."""
return self.source_user.handle if self.source_user else None


class DbBook(DBBaseModel):
Expand Down Expand Up @@ -301,6 +328,9 @@ class DbUserBook(DBBaseModel):

book_id = Column(Integer, ForeignKey('books.pk'), nullable=False)
user_id = Column(Integer, ForeignKey('users.pk'), nullable=False)
source_user_id = Column(
Integer, ForeignKey('users.pk', ondelete='SET NULL'), nullable=True, index=True
)

# Two independent lists, mirroring the Movies tracker: on_watchlist is the
# to-read list, on_rankings the read-and-ranked list. `completed` is
Expand All @@ -320,4 +350,10 @@ class DbUserBook(DBBaseModel):
is_seed_data = Column(Boolean, nullable=False, default=False)

book = relationship('DbBook', back_populates='user_books')
user = relationship('DbUser', backref='user_books')
user = relationship('DbUser', foreign_keys=[user_id], backref='user_books')
source_user = relationship('DbUser', foreign_keys=[source_user_id])

@property
def source_handle(self):
"""Return the handle of the user who inspired this tracker entry."""
return self.source_user.handle if self.source_user else None
152 changes: 152 additions & 0 deletions app/router/v1/router_comparison.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Compare the signed-in user's shelves with one visible profile (#281)."""

from typing import Literal

from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session

from app.auth.oauth2 import get_current_user
from app.db.database import get_db
from app.db.models import DbUser
from app.services.comparison import compare_shelf
from app.services.profile_access import viewer_relationship
from app.services.shelves import SHELVES, Shelf
from app.services.tracker_rules import default_completed_at
from app.services.visibility import admits, ceiling_for

router = APIRouter(prefix='/v1', tags=['Comparison'])


class SaveRecommendation(BaseModel):
"""The destination list; ranking placement happens later in its normal UI."""

destination: Literal['watchlist', 'rankings']


def _not_found() -> HTTPException:
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail='No visible profile here',
headers={'Vary': 'Authorization'},
)


def _target_access(db: Session, viewer: DbUser, handle: str):
target = db.query(DbUser).filter(DbUser.handle == handle.lower()).first()
if target is None or target.pk == viewer.pk:
raise _not_found()
relationship = viewer_relationship(db, target, viewer)
ceiling = ceiling_for(relationship)
if not admits(ceiling, target.visibility_profile):
raise _not_found()
visible = [
shelf
for shelf in SHELVES
if admits(ceiling, getattr(target, shelf.visibility_tier))
]
if not visible:
raise _not_found()
return target, relationship, ceiling


@router.get('/users/me/comparison/{handle}')
def compare_with_user(
handle: str,
db: Session = Depends(get_db),
current_user: list = Depends(get_current_user),
):
"""Return the four domain comparisons visible to this viewer."""
viewer = current_user[0]
target, relationship, ceiling = _target_access(db, viewer, handle)
return {
'handle': target.handle,
'display_name': target.display_name,
'relationship': relationship.value,
'domains': [
compare_shelf(db, viewer, target, shelf, ceiling) for shelf in SHELVES
],
}


def _shelf(category: str) -> Shelf:
found = next((shelf for shelf in SHELVES if shelf.category == category), None)
if found is None:
raise HTTPException(status_code=404, detail='Domain not found')
return found


@router.post(
'/users/me/comparison/{handle}/{category}/{item_id}',
status_code=status.HTTP_201_CREATED,
)
def save_recommendation( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals
handle: str,
category: str,
item_id: str,
request: SaveRecommendation,
db: Session = Depends(get_db),
current_user: list = Depends(get_current_user),
):
"""Save a visible ranked recommendation and retain its first source."""
viewer = current_user[0]
target, _, ceiling = _target_access(db, viewer, handle)
shelf = _shelf(category)
if not admits(ceiling, getattr(target, shelf.visibility_tier)):
raise _not_found()

tracker_model, catalog_model = shelf.tracker_model, shelf.catalog_model
catalog = db.query(catalog_model).filter(catalog_model.id == item_id).first()
if catalog is None:
raise HTTPException(status_code=404, detail='Item not found')
target_tracker = (
db.query(tracker_model)
.filter(
tracker_model.user_id == target.pk,
getattr(tracker_model, shelf.join_col) == catalog.pk,
tracker_model.on_rankings.is_(True),
tracker_model.rank.isnot(None),
)
.first()
)
if target_tracker is None:
raise _not_found()

mine = (
db.query(tracker_model)
.filter(
tracker_model.user_id == viewer.pk,
getattr(tracker_model, shelf.join_col) == catalog.pk,
)
.first()
)
created = mine is None
if mine is None:
mine = tracker_model(
user_id=viewer.pk,
source_user_id=target.pk,
**{shelf.join_col: catalog.pk},
)
db.add(mine)

was_on_rankings = bool(mine.on_rankings)
if request.destination == 'watchlist':
mine.on_watchlist = True
mine.on_rankings = False
mine.rank = None
mine.ranked_at = None
else:
mine.on_watchlist = False
mine.on_rankings = True
mine.rank = None
mine.ranked_at = None
default_completed_at(mine, was_on_rankings)
db.commit()
db.refresh(mine)
return {
'id': mine.id,
'item_id': catalog.id,
'destination': request.destination,
'source_handle': mine.source_handle,
'source_recorded': created,
}
23 changes: 2 additions & 21 deletions app/router/v1/router_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@
from app.config import get_settings
from app.db.database import get_db
from app.db.db_follow import is_following
from app.db.db_friendship import are_friends
from app.db.models import DbUser
from app.schemas.model_schemas import InVisibilityUpdate, OutVisibility
from app.services import handles
from app.services.profile_access import viewer_relationship
from app.services.shelves import SHELVES, Shelf, shelf_tier_fields
from app.services.visibility import (
PROFILE_TIER_FIELD,
Expand Down Expand Up @@ -186,25 +186,6 @@ def _assert_profile_covers_shelves(tiers: dict) -> None:
)


def _viewer_relationship(
db: Session, owner: DbUser, viewer: Optional[DbUser]
) -> ViewerRelationship:
"""
What this caller is to the owner — resolved once, in one lookup.

Ownership is checked before friendship because a user is never their own
friend (see :func:`app.db.db_friendship.are_friends`): the owner is served
by owning the profile, not by an edge in the graph.
"""
if viewer is None:
return ViewerRelationship.ANONYMOUS
if viewer.pk == owner.pk:
return ViewerRelationship.SELF
if are_friends(db, viewer.pk, owner.pk):
return ViewerRelationship.FRIEND
return ViewerRelationship.NONE


def _shelf_payload( # pylint: disable=too-many-arguments, too-many-positional-arguments, too-many-locals
db: Session,
user: DbUser,
Expand Down Expand Up @@ -424,7 +405,7 @@ def public_profile( # pylint: disable=too-many-arguments, too-many-positional-a
if user is None:
raise not_found

relationship = _viewer_relationship(db, user, viewer)
relationship = viewer_relationship(db, user, viewer)
ceiling = ceiling_for(relationship)
if not admits(ceiling, user.visibility_profile):
raise not_found
Expand Down
2 changes: 2 additions & 0 deletions app/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
router_search,
router_summary,
router_visibility,
router_comparison,
router_movies,
router_games,
router_books,
Expand Down Expand Up @@ -162,6 +163,7 @@ async def log_request_latency(request, call_next):
app.include_router(router_export.router)
app.include_router(router_import.router)
app.include_router(router_visibility.router)
app.include_router(router_comparison.router)
app.include_router(router_friends.router)
app.include_router(router_follows.router)
app.include_router(router_preferences.router)
Expand Down
4 changes: 4 additions & 0 deletions app/schemas/schemas_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ class UserMovieResponse(UserMovieBase):
movie: MovieSummary
created_at: datetime
updated_at: datetime
source_handle: Optional[str] = None
model_config = ConfigDict(from_attributes=True)


Expand Down Expand Up @@ -267,6 +268,7 @@ class UserTVShowResponse(UserTVShowBase):
tv_show: TVShowSummary
created_at: datetime
updated_at: datetime
source_handle: Optional[str] = None
model_config = ConfigDict(from_attributes=True)


Expand Down Expand Up @@ -454,6 +456,7 @@ class UserVideoGameResponse(UserVideoGameBase):
game: VideoGameSummary
created_at: datetime
updated_at: datetime
source_handle: Optional[str] = None
model_config = ConfigDict(from_attributes=True)


Expand Down Expand Up @@ -552,6 +555,7 @@ class UserBookResponse(UserBookBase):
book: BookSummary
created_at: datetime
updated_at: datetime
source_handle: Optional[str] = None
model_config = ConfigDict(from_attributes=True)


Expand Down
Loading