Skip to content

Commit 86a6daa

Browse files
authored
Merge pull request #10 from btaquee/optimizer
Optimizer
2 parents 5999755 + 13f6384 commit 86a6daa

File tree

9 files changed

+254
-5
lines changed

9 files changed

+254
-5
lines changed

cards/serializers.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from rest_framework import serializers
22
from .models import Card, RewardRule, UserCard, CardBenefit
3+
from rest_framework.validators import UniqueTogetherValidator
34

45
class RewardRuleSerializer(serializers.ModelSerializer):
56
class Meta:
@@ -34,7 +35,22 @@ class Meta:
3435
fields = ("id", "issuer", "name", "annual_fee", "ftf", "reward_rules", "benefits")
3536

3637
class UserCardSerializer(serializers.ModelSerializer):
38+
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
39+
is_active = serializers.BooleanField(required=False, default=True)
40+
card_name = serializers.SerializerMethodField(read_only=True)
41+
42+
def get_card_name(self, obj):
43+
return f"{obj.card.name} ({obj.card.issuer})"
44+
3745
class Meta:
3846
model = UserCard
39-
fields = ("id", "card", "notes", "is_active")
47+
fields = ("id", "card", "card_id", "card_name", "user", "notes", "is_active")
4048
read_only_fields = ()
49+
50+
validators = [
51+
UniqueTogetherValidator(
52+
queryset=UserCard.objects.all(),
53+
fields=("user", "card"),
54+
message="You already added this card to your wallet.",
55+
)
56+
]

db.sqlite3

4 KB
Binary file not shown.

optimizer/admin.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
11
from django.contrib import admin
2+
from .models import UserCategorySelection
23

34
# Register your models here.
5+
@admin.register(UserCategorySelection)
6+
class UserCategorySelectionAdmin(admin.ModelAdmin):
7+
list_display = ("user", "category_tag")
8+
fields = ("user", "category_tag")
9+
ordering = ["user", "category_tag"]
10+
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Generated by Django 5.2.7 on 2025-10-24 06:08
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
initial = True
11+
12+
dependencies = [
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name='UserCategorySelection',
19+
fields=[
20+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21+
('category_tag', models.CharField(choices=[('SELECTED_CATEGORIES', 'Selected Categories'), ('RENT', 'Rent'), ('ONLINE_SHOPPING', 'Online Shopping'), ('DINING', 'Dining'), ('GROCERIES', 'Groceries'), ('PHARMACY', 'Pharmacy'), ('GAS', 'Gas'), ('GENERAL_TRAVEL', 'General Travel'), ('AIRLINE_TRAVEL', 'Airline Travel'), ('HOTEL_TRAVEL', 'Hotel Travel'), ('TRANSIT', 'Transit'), ('ENTERTAINMENT', 'Entertainment'), ('OTHER', 'Other')], max_length=255)),
22+
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='optimizer_selections', to=settings.AUTH_USER_MODEL)),
23+
],
24+
options={
25+
'indexes': [models.Index(fields=['user'], name='optimizer_u_user_id_5f6de0_idx')],
26+
'constraints': [models.UniqueConstraint(fields=('user', 'category_tag'), name='uniq_user_category_selection')],
27+
},
28+
),
29+
]

optimizer/models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
11
from django.db import models
2+
from django.conf import settings
3+
from cards.models import RewardRule
24

35
# Create your models here.
6+
# This function get card user have(make api call for http://127.0.0.1:8000/api/cards/user-cards/)
7+
class UserCategorySelection(models.Model):
8+
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="optimizer_selections")
9+
category_tag = models.CharField(max_length=255, choices=RewardRule.CATEGORY_CHOICES)
10+
11+
class Meta:
12+
constraints = [
13+
models.UniqueConstraint(
14+
fields=["user", "category_tag"],
15+
name="uniq_user_category_selection",
16+
),
17+
]
18+
indexes = [
19+
models.Index(fields=["user"]),
20+
]
21+
22+
def __str__(self):
23+
return f"{self.user} · {self.category_tag}"

optimizer/serializers.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from rest_framework import serializers
2+
from django.db import IntegrityError
3+
from rest_framework.exceptions import ValidationError
4+
from .models import UserCategorySelection
5+
6+
class UserCategorySelectionSerializer(serializers.ModelSerializer):
7+
class Meta:
8+
model = UserCategorySelection
9+
fields = ("id", "user", "category_tag")
10+
read_only_fields = ("user",)
11+
12+
def create(self, validated_data):
13+
validated_data['user'] = self.context['request'].user
14+
try:
15+
return super().create(validated_data)
16+
except IntegrityError:
17+
# Handle the case where the combination already exists
18+
raise ValidationError({
19+
'category_tag': 'You have already selected this category.'
20+
})

optimizer/services.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from decimal import Decimal
2+
from cards.models import RewardRule, UserCard, Card
3+
4+
5+
# Most of code logic are generated by AI, but with human fix the code and correct logic.
6+
# eg, at first the code is calculate the best card for a category from card database,
7+
# but it should be calculate the best card for a category from user's cards.
8+
9+
SELECTED = "SELECTED_CATEGORIES"
10+
BASE_ANYWHERE = "OTHER"
11+
BIG = Decimal("1000000000") # treat None cap as "very large"
12+
13+
def _rank(items):
14+
# items: list of (card, multiplier_float, cap_or_BIG)
15+
# Sort by: higher multiplier → higher cap → lower annual fee → issuer/name
16+
items.sort(
17+
key=lambda t: (t[1], t[2], -float(t[0].annual_fee or 0), t[0].issuer, t[0].name),
18+
reverse=True,
19+
)
20+
21+
# Best card = first after sort
22+
best_card, best_mult, _ = items[0]
23+
24+
# Collect other cards that tie with the best multiplier (exclude best itself)
25+
ties = [
26+
(c, m, cap)
27+
for (c, m, cap) in items[1:] # skip the best one
28+
if m == best_mult
29+
][:2] # +2 because we already have one best → total 3 max
30+
31+
# Prepare top3 list = alternatives (no duplication of the best card)
32+
top3 = [
33+
{"card_id": c.id, "card_name": f"{c.issuer} {c.name}", "multiplier": m}
34+
for (c, m, _cap) in ties
35+
]
36+
37+
return best_card, best_mult, top3
38+
39+
40+
def best_cards_for_category(category_tag: str, user) -> dict:
41+
# 0) user's active cards
42+
my_card_ids = list(
43+
UserCard.objects.filter(user=user, is_active=True).values_list("card_id", flat=True)
44+
)
45+
if not my_card_ids:
46+
return {
47+
"best_card": None,
48+
"multiplier": 1.0,
49+
"rationale": "You have no active cards. Showing baseline 1.0× recommendation.",
50+
"top3": [],
51+
}
52+
53+
# 1) exact category among user's cards
54+
qs = (
55+
RewardRule.objects
56+
.exclude(category__contains=SELECTED)
57+
.filter(category__contains=category_tag, card_id__in=my_card_ids)
58+
.select_related("card")
59+
)
60+
primary = []
61+
for r in qs:
62+
m = float(r.multiplier or 0)
63+
cap = r.cap_amount if r.cap_amount is not None else BIG
64+
primary.append((r.card, m, cap))
65+
66+
if primary:
67+
best_card, best_mult, top3 = _rank(primary)
68+
return {
69+
"best_card": {"card_id": best_card.id, "card_name": f"{best_card.issuer} {best_card.name}"},
70+
"multiplier": best_mult,
71+
"rationale": f"{best_mult}× on {category_tag} (from your wallet).",
72+
"top3": top3,
73+
}
74+
75+
# 2) fallback: use only each card's base/anywhere rate (OTHER)
76+
alt_qs = (
77+
RewardRule.objects
78+
.exclude(category__contains=SELECTED)
79+
.filter(card_id__in=my_card_ids, category__contains=BASE_ANYWHERE)
80+
.select_related("card")
81+
)
82+
83+
# pick the best OTHER rule per card (dedupe by card)
84+
by_card = {}
85+
for r in alt_qs:
86+
c = r.card
87+
m = float(r.multiplier or 0)
88+
cap = r.cap_amount if r.cap_amount is not None else BIG
89+
cur = by_card.get(c.id)
90+
if cur is None or (m, cap) > (cur[1], cur[2]):
91+
by_card[c.id] = (c, m, cap)
92+
93+
if by_card:
94+
items = list(by_card.values())
95+
best_card, best_mult, top3 = _rank(items)
96+
return {
97+
"best_card": {"card_id": best_card.id, "card_name": f"{best_card.issuer} {best_card.name}"},
98+
"multiplier": best_mult,
99+
"rationale": (
100+
f"No {category_tag} bonus among your cards. "
101+
f"Showing your best base rate (OTHER): {best_mult}×."
102+
),
103+
"top3": top3,
104+
}
105+
106+
# 3) final fallback: baseline 1.0× on lowest-AF card
107+
any_card = (
108+
Card.objects.filter(id__in=my_card_ids).order_by("annual_fee", "issuer", "name").first()
109+
)
110+
return {
111+
"best_card": (
112+
{"card_id": any_card.id, "card_name": f"{any_card.issuer} {any_card.name}"}
113+
if any_card else None
114+
),
115+
"multiplier": 1.0,
116+
"rationale": (
117+
f"No base (OTHER) rates found for your cards. Showing baseline 1.0×"
118+
f"{' on ' + any_card.name if any_card else ''}."
119+
),
120+
"top3": [],
121+
}

optimizer/urls.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
from django.urls import path
1+
from django.urls import path, include
22
from . import views
3+
from rest_framework.routers import DefaultRouter
4+
from .views import UserCategorySelectionViewSet, MyOptimizerDashboardView, HealthCheckView
5+
6+
router = DefaultRouter()
7+
router.register(r'user-category-selections', UserCategorySelectionViewSet, basename='user-category-selections')
38

49
urlpatterns = [
5-
path('health/', views.HealthCheckView.as_view(), name='health'),
10+
path('', include(router.urls)),
11+
path('health/', HealthCheckView.as_view(), name='health'),
12+
path('my-optimizer-dashboard/', MyOptimizerDashboardView.as_view(), name='my-optimizer-dashboard'),
613
]

optimizer/views.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,39 @@
11
from django.shortcuts import render
22
from rest_framework.views import APIView
33
from rest_framework.response import Response
4-
from rest_framework import status
4+
from rest_framework import status, viewsets, permissions
5+
from .models import UserCategorySelection
6+
from .serializers import UserCategorySelectionSerializer
7+
from .services import best_cards_for_category
58

69
# Create your views here.
710
# Check API health
811
class HealthCheckView(APIView):
912
def get(self, request):
10-
return Response({"status": "ok"}, status=status.HTTP_200_OK)
13+
return Response({"status": "ok"}, status=status.HTTP_200_OK)
14+
15+
# AI generated code
16+
class UserCategorySelectionViewSet(viewsets.ModelViewSet):
17+
queryset = UserCategorySelection.objects.all()
18+
serializer_class = UserCategorySelectionSerializer
19+
permission_classes = [permissions.IsAuthenticated]
20+
def get_queryset(self):
21+
return UserCategorySelection.objects.filter(user=self.request.user)
22+
def perform_create(self, serializer):
23+
serializer.save(user=self.request.user)
24+
25+
class MyOptimizerDashboardView(APIView):
26+
permission_classes = [permissions.IsAuthenticated]
27+
28+
def get(self, request):
29+
tags = (UserCategorySelection.objects
30+
.filter(user=request.user)
31+
.values_list("category_tag", flat=True))
32+
33+
results = []
34+
for tag in tags:
35+
results.append({
36+
"category_tag": tag,
37+
**best_cards_for_category(tag, request.user) # ← pass user here
38+
})
39+
return Response(results)

0 commit comments

Comments
 (0)