Skip to content

Commit 8781f3e

Browse files
feat: Add aws integration and modify email usage
1 parent 07bfc35 commit 8781f3e

7 files changed

Lines changed: 145 additions & 16 deletions

File tree

mono/acm/settings.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,4 +281,7 @@
281281
# Skyroom
282282
SKYROOM_BASEURL = env("SKYROOM_BASEURL", default="")
283283
SKYROOM_APIKEY = env("SKYROOM_APIKEY", default="")
284-
SKYROOM_ROOMID = env("SKYROOM_ROOMID", default="")
284+
SKYROOM_ROOMID = env("SKYROOM_ROOMID", default="")
285+
286+
# Competition
287+
COMPETITION_APPROVAL_REDIRECT_URL = env("COMPETITION_APPROVAL_REDIRECT_URL", default="https://aut-icpc.ir/DAMN")

mono/acm/storage_utils.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import os, uuid, mimetypes
2+
from django.utils import timezone
3+
from django.core.files.base import ContentFile, File
4+
from django.core.files.storage import default_storage
5+
6+
class SaveResult(dict):
7+
@property
8+
def url(self): return self["url"]
9+
@property
10+
def key(self): return self["key"]
11+
12+
def s3_save_and_get_url(file_obj, *, folder="uploads", filename=None, content_type=None, overwrite=False) -> SaveResult:
13+
original_name = getattr(file_obj, "name", None)
14+
if isinstance(file_obj, (bytes, bytearray)):
15+
content = ContentFile(file_obj)
16+
elif hasattr(file_obj, "read"):
17+
content = file_obj if isinstance(file_obj, File) else File(file_obj)
18+
elif isinstance(file_obj, str) and os.path.exists(file_obj):
19+
with open(file_obj, "rb") as fh:
20+
content = ContentFile(fh.read())
21+
original_name = os.path.basename(file_obj)
22+
else:
23+
raise TypeError("file_obj must be bytes, a file-like object, or a valid file path")
24+
25+
folder = (folder or "uploads").strip("/")
26+
if not filename:
27+
ext = os.path.splitext(original_name or "")[1].lower()
28+
if not ext and content_type:
29+
ext = mimetypes.guess_extension(content_type) or ""
30+
filename = f"{uuid.uuid4().hex}{ext}"
31+
32+
dated = timezone.now().strftime("%Y/%m/%d")
33+
key = f"{folder}/{dated}/{filename}"
34+
35+
if not content_type:
36+
content_type = getattr(file_obj, "content_type", None) or (mimetypes.guess_type(filename)[0] or "application/octet-stream")
37+
if not hasattr(content, "content_type"):
38+
setattr(content, "content_type", content_type)
39+
40+
if overwrite and default_storage.exists(key):
41+
default_storage.delete(key)
42+
43+
saved_key = default_storage.save(key, content)
44+
url = default_storage.url(saved_key)
45+
return SaveResult(key=saved_key, url=url)

mono/acm/urls.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
2121

2222
from accounts.views_oauth import GithubLoginView, GithubCallbackView
23+
from acm.views_uploads import UploadView
2324

2425

2526
# Simple healthcheck api
@@ -43,5 +44,8 @@ def healthz(_): return HttpResponse("ok")
4344

4445
# swagger shits
4546
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
46-
path("api/swagger/", SpectacularSwaggerView.as_view(url_name="schema"))
47+
path("api/swagger/", SpectacularSwaggerView.as_view(url_name="schema")),
48+
49+
# Storage utils
50+
path('api/upload/', UploadView.as_view(), name='api-upload'),
4751
]

mono/acm/views_uploads.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from django.conf import settings
2+
from rest_framework.views import APIView
3+
from rest_framework.response import Response
4+
from rest_framework import status, permissions, serializers
5+
from drf_spectacular.utils import extend_schema, OpenApiExample, OpenApiResponse, inline_serializer
6+
7+
from .storage_utils import s3_save_and_get_url
8+
9+
10+
class UploadView(APIView):
11+
permission_classes = [permissions.IsAuthenticated]
12+
13+
@extend_schema(
14+
operation_id="upload_file_to_s3",
15+
summary="Upload a file and get its URL",
16+
description=(
17+
"Accepts **multipart/form-data** with a single `file` field. "
18+
"Backend stores it under a fixed folder and returns only the public URL."
19+
),
20+
request=inline_serializer(
21+
name="UploadRequest",
22+
fields={
23+
"file": serializers.FileField(help_text="Binary file to upload"),
24+
},
25+
),
26+
responses={
27+
201: inline_serializer(
28+
name="UploadResponse",
29+
fields={
30+
"url": serializers.URLField(help_text="Public URL of the uploaded file"),
31+
},
32+
),
33+
400: OpenApiResponse(description="No file provided"),
34+
401: OpenApiResponse(description="Unauthorized"),
35+
},
36+
examples=[
37+
OpenApiExample(
38+
"cURL Multipart",
39+
description="Basic curl example",
40+
value={"file": "(binary)"},
41+
request_only=True,
42+
),
43+
OpenApiExample(
44+
"Success Response",
45+
value={"url": "https://your-endpoint/bucket/uploads/2025/10/17/uuid.jpg"},
46+
response_only=True,
47+
),
48+
],
49+
tags=["Uploads"],
50+
)
51+
def post(self, request, *args, **kwargs):
52+
f = request.FILES.get("file")
53+
if not f:
54+
return Response({"detail": "Provide a 'file' field."}, status=status.HTTP_400_BAD_REQUEST)
55+
56+
folder = getattr(settings, "UPLOADS_DEFAULT_FOLDER", "user_uploads")
57+
58+
saved = s3_save_and_get_url(
59+
f,
60+
folder=folder,
61+
filename=None,
62+
overwrite=False,
63+
)
64+
return Response({"url": saved.url}, status=status.HTTP_201_CREATED)

mono/competitions/services.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from acm import error_codes as EC
1212

1313
from .models import Competition, CompetitionFieldConfig, TeamRequest, TeamMember, FieldRequirement
14-
from notification.services import send_status_change_email
14+
from notification.services import send_status_change_email, send_email_with_custom_template
1515

1616
User = get_user_model()
1717

@@ -133,20 +133,22 @@ def submit_team_request(
133133
approval_token_expires_at=expires,
134134
)
135135
# email tokenized approval link
136-
send_status_change_email(
136+
send_email_with_custom_template(
137137
to=p.get("email", ""),
138138
status_code="COMPETITION_MEMBER_APPROVAL",
139+
template="COMPETITION_MEMBER_APPROVAL",
139140
extra={
140141
"competition": competition.name,
141142
"team_name": team_name or "",
142-
"action_link": f"/api/competitions/approve?rid={tr.id}&token={token}",
143+
"approval_link": f"{settings.COMPETITION_APPROVAL_REDIRECT_URL}?rid={tr.id}&token={token}",
143144
},
144145
)
145146

146147
# notify submitter about submission
147-
send_status_change_email(
148+
send_email_with_custom_template(
148149
to=submitter.email,
149150
status_code="COMPETITION_REQUEST_SUBMITTED",
151+
template="COMPETITION_REQUEST_SUBMITTED",
150152
extra={"competition": competition.name, "team_name": team_name or ""},
151153
)
152154

@@ -190,19 +192,21 @@ def approve_or_reject_member(*, request_id: int, token: str, accept: bool) -> Te
190192
tr.status = TeamRequest.Status.REJECTED
191193
tr.save(update_fields=["status"])
192194
# notify submitter
193-
send_status_change_email(
195+
send_email_with_custom_template(
194196
to=tr.submitter.email,
195197
status_code="COMPETITION_REQUEST_REJECTED",
198+
template="COMPETITION_REQUEST_REJECTED",
196199
extra={"competition": tr.competition.name},
197200
)
198201
elif not tr.members.filter(approval_status=TeamMember.ApprovalStatus.PENDING).exists():
199202
# all approved
200203
if tr.competition.requires_backoffice_approval:
201204
tr.status = TeamRequest.Status.PENDING_INVESTIGATION
202205
tr.save(update_fields=["status"])
203-
send_status_change_email(
206+
send_email_with_custom_template(
204207
to=tr.submitter.email,
205208
status_code="COMPETITION_REQUEST_PENDING_INVESTIGATION",
209+
template="COMPETITION_REQUEST_PENDING_INVESTIGATION",
206210
extra={"competition": tr.competition.name},
207211
)
208212
else:
@@ -225,9 +229,10 @@ def approve_or_reject_member(*, request_id: int, token: str, accept: bool) -> Te
225229
tr.payment_link = result.url
226230
tr.status = TeamRequest.Status.PENDING_PAYMENT
227231
tr.save(update_fields=["payment_link", "status"])
228-
send_status_change_email(
232+
send_email_with_custom_template(
229233
to=tr.submitter.email,
230234
status_code="COMPETITION_REQUEST_PENDING_PAYMENT",
235+
template="COMPETITION_REQUEST_PENDING_PAYMENT",
231236
extra={"link": tr.payment_link},
232237
)
233238

@@ -296,9 +301,10 @@ def backoffice_approve_request(tr: TeamRequest) -> TeamRequest:
296301
tr.payment_link = result.url
297302
tr.status = TeamRequest.Status.PENDING_PAYMENT
298303
tr.save(update_fields=["payment_link", "status"])
299-
send_status_change_email(
304+
send_email_with_custom_template(
300305
to=tr.submitter.email,
301306
status_code="COMPETITION_REQUEST_PENDING_PAYMENT",
307+
template="COMPETITION_REQUEST_PENDING_PAYMENT",
302308
extra={"link": tr.payment_link},
303309
)
304310
return tr
@@ -316,9 +322,10 @@ def backoffice_reject_request(tr: TeamRequest, reason: str) -> TeamRequest:
316322
tr.save(update_fields=["status"])
317323
# notify all members
318324
for m in tr.members.all():
319-
send_status_change_email(
325+
send_email_with_custom_template(
320326
to=m.email,
321327
status_code="COMPETITION_REQUEST_REJECTED",
328+
template="COMPETITION_REQUEST_REJECTED",
322329
extra={"competition": tr.competition.name, "reason": reason},
323330
)
324331
return tr
@@ -329,9 +336,10 @@ def mark_payment_final(tr: TeamRequest) -> TeamRequest:
329336
tr.status = TeamRequest.Status.FINAL
330337
tr.save(update_fields=["status"])
331338
for m in tr.members.all():
332-
send_status_change_email(
339+
send_email_with_custom_template(
333340
to=m.email,
334341
status_code="COMPETITION_REQUEST_FINAL",
342+
template="COMPETITION_REQUEST_FINAL",
335343
extra={"competition": tr.competition.name},
336344
)
337345
return tr
@@ -341,9 +349,10 @@ def mark_payment_final(tr: TeamRequest) -> TeamRequest:
341349
def mark_payment_rejected(tr: TeamRequest) -> TeamRequest:
342350
tr.status = TeamRequest.Status.PAYMENT_REJECTED
343351
tr.save(update_fields=["status"])
344-
send_status_change_email(
352+
send_email_with_custom_template(
345353
to=tr.submitter.email,
346354
status_code="COMPETITION_PAYMENT_REJECTED",
355+
template="COMPETITION_PAYMENT_REJECTED",
347356
extra={"competition": tr.competition.name},
348357
)
349358
return tr

mono/competitions/urls.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
)
77

88
urlpatterns = [
9-
path("<slug:slug>/", CompetitionDetailView.as_view()),
10-
path("<slug:slug>/fields/", CompetitionFieldConfigView.as_view()),
119
path("request/", TeamRequestCreateView.as_view()),
1210
path("me/requests/", MyTeamRequestsView.as_view()),
1311
path("member/approve/", MemberApproveView.as_view()),
1412
path("request/cancel/", CancelRequestView.as_view()),
13+
path("<slug:slug>/", CompetitionDetailView.as_view()),
14+
path("<slug:slug>/fields/", CompetitionFieldConfigView.as_view()),
1515
]

mono/notification/services.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,8 @@ def send_otp(destination: str, code: str, channel: str = "email") -> None:
4343

4444
def send_status_change_email(to: str, *, status_code: str, extra: dict | None = None) -> None:
4545
ctx = {"status": status_code, **(extra or {})}
46-
queue_single_email(to=to, template_code="status_change", context=ctx)
46+
queue_single_email(to=to, template_code="status_change", context=ctx)
47+
48+
def send_email_with_custom_template(to: str, template: str, status_code: str, extra: dict | None = None) -> None:
49+
ctx = {"status": status_code, **(extra or {})}
50+
queue_single_email(to=to, template_code=template, context=ctx)

0 commit comments

Comments
 (0)