Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
19 changes: 19 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.git
.github
.claude
.venv
.env
.env.*
__pycache__
*.pyc
*.pyo
certs/
data/
logs/
docs/
scripts/
tests/
*.log
.DS_Store
.vscode
VIDEO_DESCRIPTION_FIX.md
51 changes: 51 additions & 0 deletions .github/workflows/container-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Container Release

on:
pull_request:
branches: [main]
push:
branches: [main]
tags: ["v*"]

permissions:
contents: read
packages: write

jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/killrvideo/kv-be-python-fastapi-dataapi-table
tags: |
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.env
.python-version
.DS_Store
.DS_Store

# Python
__pycache__/
Expand All @@ -15,6 +15,11 @@ __pycache__/

*.log

# Data files (source of truth is in killrvideo-data project)
data/
logs/
dsbulk.conf

certs/

.vscode/
Expand Down
29 changes: 29 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
FROM python:3.12-slim AS builder

WORKDIR /app

RUN pip install --no-cache-dir poetry && \
poetry config virtualenvs.in-project true

COPY pyproject.toml poetry.lock ./
RUN poetry install --only main --no-root --no-interaction

COPY README.md ./
COPY app/ app/
RUN poetry install --only main --no-interaction

# ---------------------------------------------------------------------------

FROM python:3.12-slim

WORKDIR /app

COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/app /app/app

ENV PATH="/app/.venv/bin:$PATH" \
PYTHONUNBUFFERED=1

EXPOSE 8000

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
51 changes: 51 additions & 0 deletions app/api/v1/endpoints/user_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""API endpoint for querying user activity timelines."""

from __future__ import annotations

from typing import Annotated, Literal, Optional
from uuid import UUID

from fastapi import APIRouter, Depends, Query

from app.api.v1.dependencies import PaginationParams
from app.models.common import PaginatedResponse, Pagination
from app.models.user_activity import UserActivityResponse
from app.services import user_activity_service

router = APIRouter(tags=["User Activity"])


@router.get(
"/users/{user_id_path}/activity",
response_model=PaginatedResponse[UserActivityResponse],
summary="Get user activity timeline",
)
async def get_user_activity(
user_id_path: UUID,
pagination: Annotated[PaginationParams, Depends()],
activity_type: Optional[Literal["view", "comment", "rate"]] = Query(
None, description="Filter by activity type (view, comment, rate)"
),
):
"""Return a paginated timeline of a user's activity over the last 30 days."""

activities, total = await user_activity_service.list_user_activity(
userid=user_id_path,
page=pagination.page,
page_size=pagination.pageSize,
activity_type=activity_type,
)

total_pages = (total + pagination.pageSize - 1) // pagination.pageSize

response_items = [UserActivityResponse.model_validate(a) for a in activities]

return PaginatedResponse[UserActivityResponse](
data=response_items,
pagination=Pagination(
currentPage=pagination.page,
pageSize=pagination.pageSize,
totalItems=total,
totalPages=total_pages,
),
)
5 changes: 4 additions & 1 deletion app/api/v1/endpoints/video_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,10 @@ async def record_view(
)

# READY – record the view
await video_service.record_video_view(video_id_path)
await video_service.record_video_view(
video_id_path,
viewer_user_id=current_user.userid if current_user else None,
)
return Response(status_code=status.HTTP_204_NO_CONTENT)


Expand Down
2 changes: 2 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
reco_internal,
flags,
moderation,
user_activity,
)

# --------------------------------------------------------------
Expand Down Expand Up @@ -67,6 +68,7 @@
api_router_v1.include_router(reco_internal.router)
api_router_v1.include_router(flags.router)
api_router_v1.include_router(moderation.router)
api_router_v1.include_router(user_activity.router)

app.include_router(api_router_v1)

Expand Down
46 changes: 46 additions & 0 deletions app/models/user_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Pydantic models for user activity tracking."""

from __future__ import annotations

from datetime import datetime, timezone
from typing import Literal, Optional
from uuid import UUID

from pydantic import BaseModel, Field, ConfigDict

from app.models.common import UserID

ACTIVITY_TYPES = Literal["view", "comment", "rate"]


class UserActivity(BaseModel):
"""Internal representation of a user activity row.

Field names match DB column names (snake_case) exactly — no aliases needed.
"""

model_config = ConfigDict(populate_by_name=True, from_attributes=True)

userid: UserID
day: str
activity_type: ACTIVITY_TYPES
activity_id: UUID
activity_timestamp: datetime


class UserActivityResponse(BaseModel):
"""API response representation for a single user activity item."""

model_config = ConfigDict(populate_by_name=True, from_attributes=True)

userId: UserID = Field(..., validation_alias="userid")
activityType: str = Field(..., validation_alias="activity_type")
activityId: UUID = Field(..., validation_alias="activity_id")
activityTimestamp: datetime = Field(..., validation_alias="activity_timestamp")


__all__ = [
"ACTIVITY_TYPES",
"UserActivity",
"UserActivityResponse",
]
16 changes: 16 additions & 0 deletions app/services/comment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import logging
from typing import Optional, List, Tuple
from uuid import UUID, uuid1

Expand All @@ -15,10 +16,13 @@
from app.external_services.sentiment_mock import MockSentimentAnalyzer
import inspect # local import to avoid new dependency
from app.utils.db_helpers import safe_count
from app.services.user_activity_service import record_user_activity

# testing mocks
from unittest.mock import AsyncMock, MagicMock

logger = logging.getLogger(__name__)

COMMENTS_BY_VIDEO_TABLE_NAME = "comments"
COMMENTS_BY_USER_TABLE_NAME = "comments_by_user"

Expand Down Expand Up @@ -90,6 +94,18 @@ async def add_comment_to_video(
await comments_by_video_table.insert_one(document=comment_doc)
await comments_by_user_table.insert_one(document=comment_doc)

# Track in user_activity (never fail the comment operation)
try:
await record_user_activity(
userid=current_user.userid,
activity_type="comment",
activity_id=comment_id,
)
except Exception:
logger.warning(
"user_activity insert failed for comment; ignoring", exc_info=True
)

return new_comment


Expand Down
Loading