Skip to content

Commit 31e9b5a

Browse files
authored
Merge pull request #2583 from gtech-mulearn/production-07-09-2025
feat: Add icon upload and icon_url support for achievements
2 parents c284dc9 + 2cef155 commit 31e9b5a

File tree

2 files changed

+139
-6
lines changed

2 files changed

+139
-6
lines changed

api/dashboard/achievement/achievement_serializer.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
11
from rest_framework import serializers
2+
from django.conf import settings
23
from db.achievement import Achievement, UserAchievementsLog
34
# from db.user import User
45

56
class AchievementSerializer(serializers.ModelSerializer):
7+
icon_url = serializers.SerializerMethodField()
68

79
class Meta:
810
model = Achievement
911
fields = '__all__'
12+
13+
def get_icon_url(self, obj):
14+
if obj.icon:
15+
# Check if it's already a full URL
16+
if obj.icon.startswith('http://') or obj.icon.startswith('https://'):
17+
return obj.icon
18+
# Return full media URL for uploaded files
19+
return f"{settings.MEDIA_URL}{obj.icon}"
20+
return None
1021

1122
class AchievementBasicSerializer(serializers.ModelSerializer):
1223
achievement_name = serializers.CharField(source='name')
24+
icon_url = serializers.SerializerMethodField()
1325

1426
class Meta:
1527
model = Achievement
16-
fields = ['id', 'achievement_name', 'description', 'icon', 'level_id', 'tags', 'template_id']
28+
fields = ['id', 'achievement_name', 'description', 'icon', 'icon_url', 'level_id', 'tags', 'template_id']
29+
30+
def get_icon_url(self, obj):
31+
if obj.icon:
32+
if obj.icon.startswith('http://') or obj.icon.startswith('https://'):
33+
return obj.icon
34+
return f"{settings.MEDIA_URL}{obj.icon}"
35+
return None
1736

1837
class UserAchievementsSerializer(serializers.ModelSerializer):
1938
achievement = AchievementBasicSerializer(source='achievement_id', read_only=True)

api/dashboard/achievement/achievement_views.py

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ def get(self, request):
4646

4747

4848
class AchievementCreateAPIView(APIView):
49+
from rest_framework.parsers import MultiPartParser, FormParser
50+
parser_classes = [MultiPartParser, FormParser]
51+
4952
def post(self, request):
5053
user_id = JWTUtils.fetch_user_id(request)
5154

@@ -61,14 +64,31 @@ def post(self, request):
6164
).get_failure_response()
6265

6366
data = request.data
64-
required_fields = ["name", "description", "icon", "tags", "type", "has_vc"]
65-
67+
# Icon can be either a file upload or a text URL
68+
icon_file = request.FILES.get("icon")
69+
icon_url = data.get("icon", "") if not icon_file else ""
70+
71+
required_fields = ["name", "description", "tags", "type", "has_vc"]
6672
missing_fields = [field for field in required_fields if field not in data]
6773
if missing_fields:
6874
return CustomResponse(
6975
general_message=f"Missing required fields: {', '.join(missing_fields)}"
7076
).get_failure_response()
7177

78+
# Parse has_vc from string to boolean (FormData sends strings)
79+
has_vc_value = data.get("has_vc")
80+
if isinstance(has_vc_value, str):
81+
has_vc_value = has_vc_value.lower() in ("true", "1", "yes")
82+
83+
# Parse tags from JSON string to list (FormData sends strings)
84+
tags_value = data.get("tags", [])
85+
if isinstance(tags_value, str):
86+
import json
87+
try:
88+
tags_value = json.loads(tags_value)
89+
except json.JSONDecodeError:
90+
tags_value = []
91+
7292
if Achievement.objects.filter(name=data["name"]).exists():
7393
return CustomResponse(
7494
general_message="Name already exists"
@@ -83,15 +103,51 @@ def post(self, request):
83103
general_message="Invalid level_id"
84104
).get_failure_response()
85105

106+
# Handle icon file upload
107+
icon_path = icon_url # Default to URL if provided
108+
if icon_file:
109+
import os
110+
from django.conf import settings
111+
112+
# Validate file type
113+
allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']
114+
file_ext = icon_file.name.split('.')[-1].lower()
115+
if file_ext not in allowed_extensions:
116+
return CustomResponse(
117+
general_message=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
118+
).get_failure_response()
119+
120+
# Validate file size (max 5MB)
121+
if icon_file.size > 5 * 1024 * 1024:
122+
return CustomResponse(
123+
general_message="File size exceeds 5MB limit"
124+
).get_failure_response()
125+
126+
# Create directory if it doesn't exist
127+
upload_dir = os.path.join(settings.MEDIA_ROOT, 'achievements', 'icons')
128+
os.makedirs(upload_dir, exist_ok=True)
129+
130+
# Generate unique filename
131+
unique_filename = f"{uuid.uuid4()}.{file_ext}"
132+
file_path = os.path.join(upload_dir, unique_filename)
133+
134+
# Save file
135+
with open(file_path, 'wb+') as destination:
136+
for chunk in icon_file.chunks():
137+
destination.write(chunk)
138+
139+
# Store relative path for database
140+
icon_path = f"achievements/icons/{unique_filename}"
141+
86142
achievement = Achievement.objects.create(
87143
id=str(uuid.uuid4()),
88144
name=data["name"],
89145
description=data["description"],
90-
icon=data["icon"],
91-
tags=data["tags"],
146+
icon=icon_path,
147+
tags=tags_value,
92148
type=data["type"],
93149
level_id=level,
94-
has_vc=data["has_vc"],
150+
has_vc=has_vc_value,
95151
template_id=data.get("template_id"),
96152
created_by=user,
97153
updated_by=user,
@@ -105,6 +161,9 @@ def post(self, request):
105161

106162

107163
class AchievementUpdateAPIView(APIView):
164+
from rest_framework.parsers import MultiPartParser, FormParser
165+
parser_classes = [MultiPartParser, FormParser]
166+
108167
def put(self, request, achievement_id=None):
109168
user_id = JWTUtils.fetch_user_id(request)
110169

@@ -134,6 +193,45 @@ def put(self, request, achievement_id=None):
134193
data = request.data.copy()
135194
data["updated_by"] = user_id
136195

196+
# Handle icon file upload
197+
icon_file = request.FILES.get("icon")
198+
if icon_file:
199+
import os
200+
from django.conf import settings
201+
202+
# Validate file type
203+
allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']
204+
file_ext = icon_file.name.split('.')[-1].lower()
205+
if file_ext not in allowed_extensions:
206+
return CustomResponse(
207+
general_message=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
208+
).get_failure_response()
209+
210+
# Validate file size (max 5MB)
211+
if icon_file.size > 5 * 1024 * 1024:
212+
return CustomResponse(
213+
general_message="File size exceeds 5MB limit"
214+
).get_failure_response()
215+
216+
# Create directory if it doesn't exist
217+
upload_dir = os.path.join(settings.MEDIA_ROOT, 'achievements', 'icons')
218+
os.makedirs(upload_dir, exist_ok=True)
219+
220+
# Generate unique filename
221+
unique_filename = f"{uuid.uuid4()}.{file_ext}"
222+
file_path = os.path.join(upload_dir, unique_filename)
223+
224+
# Save file
225+
with open(file_path, 'wb+') as destination:
226+
for chunk in icon_file.chunks():
227+
destination.write(chunk)
228+
229+
# Store relative path for database
230+
data["icon"] = f"achievements/icons/{unique_filename}"
231+
elif "icon" not in data or not data["icon"]:
232+
# Keep existing icon if no new file or URL provided
233+
data["icon"] = achievement.icon
234+
137235
if "level_id" in data:
138236
if data["level_id"]:
139237
try:
@@ -146,6 +244,22 @@ def put(self, request, achievement_id=None):
146244
else:
147245
data["level_id"] = None
148246

247+
# Parse has_vc from string to boolean (FormData sends strings)
248+
if "has_vc" in data:
249+
has_vc_value = data.get("has_vc")
250+
if isinstance(has_vc_value, str):
251+
data["has_vc"] = has_vc_value.lower() in ("true", "1", "yes")
252+
253+
# Parse tags from JSON string to list (FormData sends strings)
254+
if "tags" in data:
255+
tags_value = data.get("tags", [])
256+
if isinstance(tags_value, str):
257+
import json
258+
try:
259+
data["tags"] = json.loads(tags_value)
260+
except json.JSONDecodeError:
261+
data["tags"] = []
262+
149263
serializer = achievement_serializer.AchievementSerializer(
150264
achievement, data=data, partial=True
151265
)

0 commit comments

Comments
 (0)