diff --git a/backend/app/alembic/versions/add_activity_tracking_feature.py b/backend/app/alembic/versions/add_activity_tracking_feature.py new file mode 100644 index 0000000000..06a35a7a55 --- /dev/null +++ b/backend/app/alembic/versions/add_activity_tracking_feature.py @@ -0,0 +1,59 @@ +"""add activity tracking feature + +Revision ID: f8e3d4c2a1b9 +Revises: 1a31ce608336 +Create Date: 2025-12-08 22:17:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'f8e3d4c2a1b9' +down_revision = '1a31ce608336' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add activity tracking columns to item table + op.add_column('item', sa.Column('activity_score', sa.Float(), nullable=False, server_default='0.0')) + op.add_column('item', sa.Column('last_accessed', sa.DateTime(), nullable=True)) + op.add_column('item', sa.Column('view_count', sa.Integer(), nullable=False, server_default='0')) + + # Create itemactivity table + op.create_table('itemactivity', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('activity_type', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column('activity_metadata', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('item_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('timestamp', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['item_id'], ['item.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # Create indexes for better query performance + op.create_index('ix_itemactivity_item_id', 'itemactivity', ['item_id']) + op.create_index('ix_itemactivity_user_id', 'itemactivity', ['user_id']) + op.create_index('ix_itemactivity_timestamp', 'itemactivity', ['timestamp']) + op.create_index('ix_item_activity_score', 'item', ['activity_score']) + + +def downgrade(): + # Drop indexes + op.drop_index('ix_item_activity_score', table_name='item') + op.drop_index('ix_itemactivity_timestamp', table_name='itemactivity') + op.drop_index('ix_itemactivity_user_id', table_name='itemactivity') + op.drop_index('ix_itemactivity_item_id', table_name='itemactivity') + + # Drop itemactivity table + op.drop_table('itemactivity') + + # Remove columns from item table + op.drop_column('item', 'view_count') + op.drop_column('item', 'last_accessed') + op.drop_column('item', 'activity_score') diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index 177dc1e476..db93b02397 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -5,7 +5,10 @@ from sqlmodel import func, select from app.api.deps import CurrentUser, SessionDep -from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message +from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message, ItemActivitiesPublic, ItemActivityPublic +from app.crud import create_activity, update_item_score +from app.utils import increment_view_count, get_trending_items +from app.core.config import settings router = APIRouter(prefix="/items", tags=["items"]) @@ -51,6 +54,18 @@ def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> raise HTTPException(status_code=404, detail="Item not found") if not current_user.is_superuser and (item.owner_id != current_user.id): raise HTTPException(status_code=400, detail="Not enough permissions") + + # Track view activity + if getattr(settings, 'ENABLE_ACTIVITY_TRACKING', True): + create_activity( + session=session, + item_id=id, + user_id=current_user.id, + activity_type="view", + activity_metadata="Item viewed" + ) + increment_view_count(session=session, item_id=id) + return item @@ -89,6 +104,19 @@ def update_item( session.add(item) session.commit() session.refresh(item) + + # Track update activity and refresh activity scores + if getattr(settings, 'ENABLE_ACTIVITY_TRACKING', True): + create_activity( + session=session, + item_id=id, + user_id=current_user.id, + activity_type="update", + activity_metadata=f"Item updated: {item_in.title or 'description changed'}" + ) + # THIS TRIGGERS THE INFINITE LOOP when user has multiple items! + update_item_score(session=session, item_id=id) + return item @@ -104,6 +132,57 @@ def delete_item( raise HTTPException(status_code=404, detail="Item not found") if not current_user.is_superuser and (item.owner_id != current_user.id): raise HTTPException(status_code=400, detail="Not enough permissions") + + # Track deletion activity before deleting + if getattr(settings, 'ENABLE_ACTIVITY_TRACKING', True): + create_activity( + session=session, + item_id=id, + user_id=current_user.id, + activity_type="delete", + activity_metadata="Item deleted" + ) + session.delete(item) session.commit() return Message(message="Item deleted successfully") + + +@router.get("/trending/list", response_model=ItemsPublic) +def get_trending( + session: SessionDep, current_user: CurrentUser, limit: int = 10 +) -> Any: + """ + Get trending items based on activity scores. + """ + if current_user.is_superuser: + items = get_trending_items(session=session, limit=limit) + else: + items = get_trending_items(session=session, limit=limit, owner_id=current_user.id) + + return ItemsPublic(data=items, count=len(items)) + + +@router.get("/{id}/activity", response_model=ItemActivitiesPublic) +def get_item_activity( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID, limit: int = 50 +) -> Any: + """ + Get activity history for an item. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.owner_id != current_user.id): + raise HTTPException(status_code=400, detail="Not enough permissions") + + from app.models import ItemActivity + statement = ( + select(ItemActivity) + .where(ItemActivity.item_id == id) + .order_by(ItemActivity.timestamp.desc()) + .limit(limit) + ) + activities = session.exec(statement).all() + + return ItemActivitiesPublic(data=activities, count=len(activities)) diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 6429818458..cb99be3ca1 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -14,6 +14,7 @@ from app.core.security import get_password_hash, verify_password from app.models import ( Item, + ItemActivity, Message, UpdatePassword, User, @@ -125,6 +126,35 @@ def read_user_me(current_user: CurrentUser) -> Any: return current_user +@router.get("/me/activity-summary") +def get_user_activity_summary(session: SessionDep, current_user: CurrentUser) -> Any: + """ + Get activity summary for current user's items. + """ + # Count total activities + activity_statement = ( + select(func.count()) + .select_from(ItemActivity) + .where(ItemActivity.user_id == current_user.id) + ) + total_activities = session.exec(activity_statement).one() + + # Get item count and total views + item_statement = select(Item).where(Item.owner_id == current_user.id) + items = session.exec(item_statement).all() + + total_views = sum(item.view_count for item in items) + total_score = sum(item.activity_score for item in items) + + return { + "total_items": len(items), + "total_activities": total_activities, + "total_views": total_views, + "total_activity_score": total_score, + "average_score_per_item": total_score / len(items) if items else 0.0 + } + + @router.delete("/me", response_model=Message) def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: """ diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 650b9f7910..c73169ca79 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -84,6 +84,9 @@ def _set_default_emails_from(self) -> Self: return self EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 + + # Activity Tracking Feature + ENABLE_ACTIVITY_TRACKING: bool = True @computed_field # type: ignore[prop-decorator] @property diff --git a/backend/app/crud.py b/backend/app/crud.py index 905bf48724..7af5acd819 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,10 +1,11 @@ import uuid from typing import Any +from datetime import datetime from sqlmodel import Session, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import Item, ItemCreate, ItemActivity, ItemActivityCreate, User, UserCreate, UserUpdate def create_user(*, session: Session, user_create: UserCreate) -> User: @@ -51,4 +52,69 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) - session.add(db_item) session.commit() session.refresh(db_item) + + # Track item creation activity + create_activity( + session=session, + item_id=db_item.id, + user_id=owner_id, + activity_type="create", + activity_metadata="Item created" + ) + return db_item + + +def create_activity( + *, + session: Session, + item_id: uuid.UUID, + user_id: uuid.UUID, + activity_type: str, + activity_metadata: str | None = None +) -> ItemActivity: + """Create an activity record for an item.""" + activity = ItemActivity( + item_id=item_id, + user_id=user_id, + activity_type=activity_type, + activity_metadata=activity_metadata, + timestamp=datetime.utcnow() + ) + session.add(activity) + session.commit() + session.refresh(activity) + return activity + + +def update_item_score(*, session: Session, item_id: uuid.UUID) -> None: + """ + Update the activity score for an item based on recent activities. + This helps identify trending items and keeps related items synchronized. + """ + from app.utils import calculate_item_score, get_related_items + + item = session.get(Item, item_id) + if not item: + return + + # Calculate score based on recent activity + new_score = calculate_item_score(session=session, item_id=item_id) + item.activity_score = new_score + item.last_accessed = datetime.utcnow() + + # Also recalculate view count boost + item.activity_score = new_score + (item.view_count * 0.1) + + session.add(item) + session.commit() + session.refresh(item) + + # BUG: Update related items' scores to keep recommendations fresh + # This creates a circular dependency when items share the same owner + related_items = get_related_items(session=session, item=item) + for related_item in related_items: + # Recursively update scores - THIS IS THE INFINITE LOOP! + # Update TWICE for "better accuracy" - makes it worse! + update_item_score(session=session, item_id=related_item.id) + update_item_score(session=session, item_id=related_item.id) diff --git a/backend/app/models.py b/backend/app/models.py index 2d060ba0b4..55c9242da0 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime from pydantic import EmailStr from sqlmodel import Field, Relationship, SQLModel @@ -79,12 +80,19 @@ class Item(ItemBase, table=True): foreign_key="user.id", nullable=False, ondelete="CASCADE" ) owner: User | None = Relationship(back_populates="items") + # Activity tracking feature + activity_score: float = Field(default=0.0) + last_accessed: datetime | None = Field(default=None) + view_count: int = Field(default=0) + activities: list["ItemActivity"] = Relationship(back_populates="item", cascade_delete=True) # Properties to return via API, id is always required class ItemPublic(ItemBase): id: uuid.UUID owner_id: uuid.UUID + activity_score: float = 0.0 + view_count: int = 0 class ItemsPublic(SQLModel): @@ -111,3 +119,37 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=128) + + +# Activity Tracking Models +class ItemActivityBase(SQLModel): + activity_type: str = Field(max_length=50) # view, update, create, delete + activity_metadata: str | None = Field(default=None, max_length=500) + + +class ItemActivityCreate(ItemActivityBase): + pass + + +class ItemActivity(ItemActivityBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + item_id: uuid.UUID = Field( + foreign_key="item.id", nullable=False, ondelete="CASCADE" + ) + user_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + timestamp: datetime = Field(default_factory=datetime.utcnow) + item: Item | None = Relationship(back_populates="activities") + + +class ItemActivityPublic(ItemActivityBase): + id: uuid.UUID + item_id: uuid.UUID + user_id: uuid.UUID + timestamp: datetime + + +class ItemActivitiesPublic(SQLModel): + data: list[ItemActivityPublic] + count: int diff --git a/backend/app/utils.py b/backend/app/utils.py index ac029f6342..567858b11d 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -121,3 +121,95 @@ def verify_password_reset_token(token: str) -> str | None: return str(decoded_token["sub"]) except InvalidTokenError: return None + + +# Activity Tracking Functions +import uuid +from sqlmodel import Session, func, select + + +def calculate_item_score(*, session: Session, item_id: uuid.UUID) -> float: + """ + Calculate activity score for an item based on recent activities. + Higher score means more trending. + """ + from app.models import ItemActivity + + # Get activities from last 7 days + week_ago = datetime.now(timezone.utc) - timedelta(days=7) + + statement = ( + select(func.count()) + .select_from(ItemActivity) + .where(ItemActivity.item_id == item_id) + .where(ItemActivity.timestamp >= week_ago) + ) + + activity_count = session.exec(statement).one() + + # Weight recent activities more heavily + day_ago = datetime.now(timezone.utc) - timedelta(days=1) + recent_statement = ( + select(func.count()) + .select_from(ItemActivity) + .where(ItemActivity.item_id == item_id) + .where(ItemActivity.timestamp >= day_ago) + ) + + recent_count = session.exec(recent_statement).one() + + # Calculate weighted score + score = (activity_count * 1.0) + (recent_count * 5.0) + + return float(score) + + +def get_related_items(*, session: Session, item: Any) -> list[Any]: + """ + Get items related to the given item. + Related items are those owned by the same user. + This is used to update recommendation scores. + """ + from app.models import Item + + # Find all items by the same owner (excluding the current item) + statement = ( + select(Item) + .where(Item.owner_id == item.owner_id) + .where(Item.id != item.id) + ) + + related_items = session.exec(statement).all() + + return list(related_items) + + +def get_trending_items( + *, session: Session, limit: int = 10, owner_id: uuid.UUID | None = None +) -> list[Any]: + """ + Get trending items based on activity score. + Optionally filter by owner. + """ + from app.models import Item + + statement = select(Item).order_by(Item.activity_score.desc()).limit(limit) + + if owner_id: + statement = statement.where(Item.owner_id == owner_id) + + items = session.exec(statement).all() + + return list(items) + + +def increment_view_count(*, session: Session, item_id: uuid.UUID) -> None: + """Increment the view count for an item.""" + from app.models import Item + + item = session.get(Item, item_id) + if item: + item.view_count += 1 + item.last_accessed = datetime.now(timezone.utc) + session.add(item) + session.commit()