Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
33 changes: 33 additions & 0 deletions ddpui/api/charts_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,10 @@ def list_charts(

total_pages = (total + page_size - 1) // page_size # Ceiling division

favorited_chart_ids = ChartService.get_favorited_chart_ids(
[chart.id for chart in charts], orguser
)

# Build response for each chart
chart_responses = [
ChartResponse(
Expand All @@ -312,6 +316,7 @@ def list_charts(
extra_config=chart.extra_config,
created_at=chart.created_at,
updated_at=chart.updated_at,
is_favorite=chart.id in favorited_chart_ids,
)
for chart in charts
]
Expand Down Expand Up @@ -1319,3 +1324,31 @@ def get_chart_dashboards(request, chart_id: int):
raise HttpError(404, "Chart not found") from None

return dashboards


@charts_router.post("/{chart_id}/favorite/", response=dict)
@has_permission(["can_view_charts"])
def favorite_chart(request, chart_id: int):
"""Mark a chart as favorited by the current user"""
orguser: OrgUser = request.orguser

try:
ChartService.favorite_chart(chart_id, orguser.org, orguser)
except ChartNotFoundError:
raise HttpError(404, "Chart not found") from None

return {"is_favorite": True}


@charts_router.delete("/{chart_id}/favorite/", response=dict)
@has_permission(["can_view_charts"])
def unfavorite_chart(request, chart_id: int):
"""Remove the current user's favorite on a chart"""
orguser: OrgUser = request.orguser

try:
ChartService.unfavorite_chart(chart_id, orguser.org, orguser)
except ChartNotFoundError:
raise HttpError(404, "Chart not found") from None

return {"is_favorite": False}
45 changes: 44 additions & 1 deletion ddpui/api/dashboard_native_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,18 @@ def list_dashboards(
is_published=is_published,
)

return [DashboardResponse(**DashboardService.get_dashboard_response(d)) for d in dashboards]
favorited_dashboard_ids = DashboardService.get_favorited_dashboard_ids(
[d.id for d in dashboards], orguser
)

return [
DashboardResponse(
**DashboardService.get_dashboard_response(
d, is_favorite=d.id in favorited_dashboard_ids
)
)
for d in dashboards
]


@dashboard_native_router.get("/{dashboard_id}/", response=DashboardResponse)
Expand Down Expand Up @@ -739,3 +750,35 @@ def resolve_user_landing_page(request):

# 3. No landing page set
return {"dashboard_id": None, "dashboard_title": None, "dashboard_type": None, "source": "none"}


@dashboard_native_router.post("/{dashboard_id}/favorite/", response=dict)
@has_permission(["can_view_dashboards"])
def favorite_dashboard(request, dashboard_id: int):
"""Mark a dashboard as favorited by the current user"""
orguser: OrgUser = request.orguser

try:
DashboardService.favorite_dashboard(dashboard_id, orguser.org, orguser)
except DashboardNotFoundError as err:
raise HttpError(404, "Dashboard not found") from err
except DashboardPermissionError as err:
raise HttpError(403, err.message) from err

return {"is_favorite": True}


@dashboard_native_router.delete("/{dashboard_id}/favorite/", response=dict)
@has_permission(["can_view_dashboards"])
def unfavorite_dashboard(request, dashboard_id: int):
"""Remove the current user's favorite on a dashboard"""
orguser: OrgUser = request.orguser

try:
DashboardService.unfavorite_dashboard(dashboard_id, orguser.org, orguser)
except DashboardNotFoundError as err:
raise HttpError(404, "Dashboard not found") from err
except DashboardPermissionError as err:
raise HttpError(403, err.message) from err

return {"is_favorite": False}
55 changes: 55 additions & 0 deletions ddpui/migrations/0177_favorite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Generated by Django 4.2 on 2026-08-18 06:33

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("ddpui", "0176_trialsignup"),
]

operations = [
migrations.CreateModel(
name="Favorite",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"resource_type",
models.CharField(
choices=[("chart", "CHART"), ("dashboard", "DASHBOARD")], max_length=20
),
),
("resource_id", models.BigIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"org_user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="favorites",
to="ddpui.orguser",
),
),
],
options={
"db_table": "favorite",
},
),
migrations.AddIndex(
model_name="favorite",
index=models.Index(
fields=["resource_type", "resource_id"], name="favorite_resourc_ad1413_idx"
),
),
migrations.AddConstraint(
model_name="favorite",
constraint=models.UniqueConstraint(
fields=("org_user", "resource_type", "resource_id"), name="unique_favorite"
),
),
]
46 changes: 46 additions & 0 deletions ddpui/models/favorite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Shared favorite model for charts, dashboards, and other favoritable resources.

Mirrors Apache Superset's own `favstar` table design (superset/models/core.py):
one shared table keyed by (user, resource_type, resource_id) instead of a
separate table per resource type.
"""

from enum import Enum
from django.db import models
from ddpui.models.org_user import OrgUser


class FavoriteResourceType(str, Enum):
"""Resource types that can be favorited"""

CHART = "chart"
DASHBOARD = "dashboard"

@classmethod
def choices(cls):
"""django model definition needs an iterable for `choices`"""
return [(key.value, key.name) for key in cls]


class Favorite(models.Model):
"""Tracks which org users have favorited which resources. Favoriting is
personal — one user's favorite has no effect on any other user."""

org_user = models.ForeignKey(OrgUser, on_delete=models.CASCADE, related_name="favorites")
resource_type = models.CharField(max_length=20, choices=FavoriteResourceType.choices())
resource_id = models.BigIntegerField()

@himanshudube97 himanshudube97 Aug 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If i delete a chart then it won't delete the rows in ths table right ? Same with the dashboard too.
Is this the correct design choice ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, it should be like what you are suggesting. Updated, thanks
Now, when deleting the chart/dashboard, entry for that chart/dashboard will also be removed from the favorite table

created_at = models.DateTimeField(auto_now_add=True)

class Meta:
db_table = "favorite"
constraints = [
models.UniqueConstraint(
fields=["org_user", "resource_type", "resource_id"], name="unique_favorite"
)
]
indexes = [
models.Index(fields=["resource_type", "resource_id"]),
]

def __str__(self):
return f"{self.org_user.user.email} favorited {self.resource_type} {self.resource_id}"
1 change: 1 addition & 0 deletions ddpui/schemas/chart_schemas/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class ChartResponse(Schema):
extra_config: dict
created_at: datetime
updated_at: datetime
is_favorite: bool = False


class ChartConfig(Schema):
Expand Down
1 change: 1 addition & 0 deletions ddpui/schemas/dashboard_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class DashboardResponse(Schema):
published_at: Optional[datetime] = None
is_locked: bool = False
locked_by: Optional[str] = None
is_favorite: bool = False
created_by: Optional[str] = None # creator's email; None if the creator was deleted
org_id: int
last_modified_by: Optional[str] = None
Expand Down
47 changes: 46 additions & 1 deletion ddpui/services/chart_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
separating it from the API layer for better testability and maintainability.
"""

from typing import Dict, List, Optional, Tuple, Any
from typing import Dict, List, Optional, Set, Tuple, Any
from dataclasses import dataclass

from django.db.models import Q

from ddpui.core.ownership import can_delete_resource
from ddpui.models.visualization import Chart
from ddpui.models.favorite import FavoriteResourceType
from ddpui.models.org import Org
from ddpui.models.org_user import OrgUser
from ddpui.models.dashboard import Dashboard, DashboardComponentType
from ddpui.services.favorite_service import FavoriteService
from ddpui.utils.custom_logger import CustomLogger

logger = CustomLogger("ddpui.chart_service")
Expand Down Expand Up @@ -342,3 +344,46 @@ def get_chart_dashboards(chart_id: int, org: Org) -> List[Dict[str, Any]]:
break

return dashboards_with_chart

@staticmethod
def favorite_chart(chart_id: int, org: Org, orguser: OrgUser) -> None:
"""Mark a chart as favorited by this user.

Args:
chart_id: The chart ID
org: The organization
orguser: The user favoriting the chart

Raises:
ChartNotFoundError: If chart doesn't exist or doesn't belong to org
"""
ChartService.get_chart(chart_id, org) # raises ChartNotFoundError if not in org
FavoriteService.add_favorite(FavoriteResourceType.CHART, chart_id, orguser)

@staticmethod
def unfavorite_chart(chart_id: int, org: Org, orguser: OrgUser) -> None:
"""Remove this user's favorite on a chart, if any.

Args:
chart_id: The chart ID
org: The organization
orguser: The user unfavoriting the chart

Raises:
ChartNotFoundError: If chart doesn't exist or doesn't belong to org
"""
ChartService.get_chart(chart_id, org) # raises ChartNotFoundError if not in org
FavoriteService.remove_favorite(FavoriteResourceType.CHART, chart_id, orguser)

@staticmethod
def get_favorited_chart_ids(chart_ids: List[int], orguser: OrgUser) -> Set[int]:
"""Return the subset of chart_ids this user has favorited.

Args:
chart_ids: Chart IDs to check
orguser: The user whose favorites to look up

Returns:
Set of chart IDs favorited by this user
"""
return FavoriteService.get_favorited_ids(FavoriteResourceType.CHART, chart_ids, orguser)
59 changes: 57 additions & 2 deletions ddpui/services/dashboard_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
separating it from the API layer for better testability and maintainability.
"""

from typing import Dict, List, Optional, Any, Union, Tuple
from typing import Dict, List, Optional, Any, Set, Union, Tuple
from datetime import datetime, timedelta
from dataclasses import dataclass
import json
Expand All @@ -25,9 +25,11 @@
DashboardComponentType,
DashboardFilterType,
)
from ddpui.models.favorite import FavoriteResourceType
from ddpui.models.org import Org, OrgWarehouse
from ddpui.models.org_user import OrgUser
from ddpui.models.visualization import Chart
from ddpui.services.favorite_service import FavoriteService
from ddpui.utils.warehouse.client.warehouse_factory import WarehouseFactory
from ddpui.utils.warehouse.client.warehouse_interface import Warehouse
from ddpui.core.charts.charts_service import (
Expand Down Expand Up @@ -238,11 +240,12 @@ def get_dashboard(dashboard_id: int, org: Org) -> Dashboard:
raise DashboardNotFoundError(dashboard_id)

@staticmethod
def get_dashboard_response(dashboard: Dashboard) -> Dict[str, Any]:
def get_dashboard_response(dashboard: Dashboard, is_favorite: bool = False) -> Dict[str, Any]:
"""Convert dashboard model to response dict.

Args:
dashboard: The dashboard instance
is_favorite: Whether the requesting user has favorited this dashboard

Returns:
Dictionary containing dashboard response data
Expand All @@ -254,6 +257,7 @@ def get_dashboard_response(dashboard: Dashboard) -> Dict[str, Any]:
response_data["locked_by"] = (
lock.locked_by.user.email if lock and not lock.is_expired() else None
)
response_data["is_favorite"] = is_favorite

# Add filters without position data in settings
filters_data = []
Expand Down Expand Up @@ -300,6 +304,57 @@ def list_dashboards(

return list(Dashboard.objects.filter(query).order_by("-updated_at"))

@staticmethod
def favorite_dashboard(dashboard_id: int, org: Org, orguser: OrgUser) -> None:
"""Mark a dashboard as favorited by this user.

Args:
dashboard_id: The dashboard ID
org: The organization
orguser: The user favoriting the dashboard

Raises:
DashboardNotFoundError: If dashboard doesn't exist or doesn't belong to org
DashboardPermissionError: If orguser doesn't belong to org
"""
if orguser.org_id != org.id:
raise DashboardPermissionError("User does not belong to this organization.")
DashboardService.get_dashboard(dashboard_id, org) # raises if not in org
FavoriteService.add_favorite(FavoriteResourceType.DASHBOARD, dashboard_id, orguser)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

@staticmethod
def unfavorite_dashboard(dashboard_id: int, org: Org, orguser: OrgUser) -> None:
"""Remove this user's favorite on a dashboard, if any.

Args:
dashboard_id: The dashboard ID
org: The organization
orguser: The user unfavoriting the dashboard

Raises:
DashboardNotFoundError: If dashboard doesn't exist or doesn't belong to org
DashboardPermissionError: If orguser doesn't belong to org
"""
if orguser.org_id != org.id:
raise DashboardPermissionError("User does not belong to this organization.")
DashboardService.get_dashboard(dashboard_id, org) # raises if not in org
FavoriteService.remove_favorite(FavoriteResourceType.DASHBOARD, dashboard_id, orguser)

@staticmethod
def get_favorited_dashboard_ids(dashboard_ids: List[int], orguser: OrgUser) -> Set[int]:
"""Return the subset of dashboard_ids this user has favorited.

Args:
dashboard_ids: Dashboard IDs to check
orguser: The user whose favorites to look up

Returns:
Set of dashboard IDs favorited by this user
"""
return FavoriteService.get_favorited_ids(
FavoriteResourceType.DASHBOARD, dashboard_ids, orguser
)

@staticmethod
def create_dashboard(data: DashboardData, orguser: OrgUser) -> Dashboard:
"""Create a new dashboard.
Expand Down
Loading
Loading