Skip to content
Closed
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
59 changes: 59 additions & 0 deletions backend/app/alembic/versions/add_activity_tracking_feature.py
Original file line number Diff line number Diff line change
@@ -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')
81 changes: 80 additions & 1 deletion backend/app/api/routes/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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


Expand All @@ -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))
30 changes: 30 additions & 0 deletions backend/app/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from app.core.security import get_password_hash, verify_password
from app.models import (
Item,
ItemActivity,
Message,
UpdatePassword,
User,
Expand Down Expand Up @@ -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:
"""
Expand Down
3 changes: 3 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 67 additions & 1 deletion backend/app/crud.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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)
42 changes: 42 additions & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import uuid
from datetime import datetime

from pydantic import EmailStr
from sqlmodel import Field, Relationship, SQLModel
Expand Down Expand Up @@ -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):
Expand All @@ -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
Loading
Loading