Skip to content

Commit b490d67

Browse files
authored
Merge pull request #180 from AvaCodeSolutions/feat/159/deliver-contents-endpoint
feat: #159 Add api endpoit for deliver_contents
2 parents 3cb0f3d + a09273d commit b490d67

21 files changed

Lines changed: 507 additions & 2082 deletions

File tree

django_email_learning/decorators.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from django.http import JsonResponse
33
from django_email_learning.models import OrganizationUser
44
from django_email_learning.apps import PLATFORM_ADMIN_GROUP_NAME
5+
from django_email_learning.services.jwt_service import decode_jwt, InvalidTokenException
6+
from django_email_learning.models import ApiKey
57
import typing
68

79

@@ -68,3 +70,54 @@ def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type:
6870
return _wrapped_view
6971

7072
return decorator
73+
74+
75+
def check_api_key() -> typing.Callable:
76+
def decorator(view_func: typing.Callable) -> typing.Callable:
77+
@wraps(view_func)
78+
def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
79+
authorization_header = request.headers.get("Authorization")
80+
if not authorization_header:
81+
return JsonResponse(
82+
{"error": "Authorization header missing"}, status=401
83+
)
84+
authorization_header_parts = authorization_header.split(" ")
85+
if (
86+
len(authorization_header_parts) != 2
87+
or authorization_header_parts[0] != "Bearer"
88+
):
89+
return JsonResponse(
90+
{
91+
"error": "Invalid Authorization header format. Expected: Bearer <API_KEY>"
92+
},
93+
status=401,
94+
)
95+
api_key = authorization_header_parts[1]
96+
try:
97+
key_data = decode_jwt(api_key)
98+
possible_keys = ApiKey.objects.filter(salt=key_data["salt"])
99+
key_matched = False
100+
for possible_key in possible_keys:
101+
key_value = possible_key.decrypt_password(possible_key.key)
102+
if key_value == key_data["key"]:
103+
key_matched = True
104+
break
105+
if not key_matched:
106+
return JsonResponse({"error": "Invalid API key"}, status=401)
107+
except InvalidTokenException:
108+
return JsonResponse({"error": "Invalid Json Web Token"}, status=401)
109+
except KeyError:
110+
return JsonResponse(
111+
{"error": "Json Web Token missing required fields"}, status=401
112+
)
113+
114+
try:
115+
api_key = ApiKey.objects.get(salt=key_data["salt"])
116+
except ApiKey.DoesNotExist:
117+
return JsonResponse({"error": "Invalid API key"}, status=401)
118+
119+
return view_func(request, *view_args, **view_kwargs)
120+
121+
return _wrapped_view
122+
123+
return decorator

django_email_learning/jobs/api/__init__.py

Whitespace-only changes.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django.urls import path
2+
from django_email_learning.jobs.api.views import DeliverContentsJobView
3+
4+
app_name = "django_email_learning"
5+
6+
urlpatterns = [
7+
path(
8+
"deliver_contents/", DeliverContentsJobView.as_view(), name="deliver_contents"
9+
),
10+
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django.views import View
2+
from django_email_learning.decorators import check_api_key
3+
from django_email_learning.jobs.deliver_contents_job import DeliverContentsJob
4+
from django.utils.decorators import method_decorator
5+
from django.http import JsonResponse
6+
7+
8+
@method_decorator(check_api_key(), name="get")
9+
class DeliverContentsJobView(View):
10+
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
11+
job = DeliverContentsJob()
12+
job.run()
13+
return JsonResponse({"status": "DeliverContentsJob triggered"}, status=202)

django_email_learning/jobs/deliver_contents_job.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
SendQuizCommand,
99
QuizNotFoundError,
1010
)
11+
from django_email_learning.models import JobExecution, JobName, JobStatus
1112
from django.utils.module_loading import import_string
1213
from django.conf import settings
14+
from django.utils import timezone
1315
import logging
1416
import datetime
1517

@@ -22,11 +24,27 @@ def __init__(self) -> None:
2224
self.delivery_queue: DeliveryQueueProtocol = self.get_delivery_queue()
2325

2426
def run(self) -> None:
27+
if JobExecution.objects.filter(
28+
job_name=JobName.DELIVER_CONTENTS.value,
29+
status=JobStatus.RUNNING.value,
30+
).exists():
31+
logger.warning(
32+
"Another instance of DeliverContentsJob is already running. Exiting this run."
33+
)
34+
return
35+
job_execution = JobExecution.objects.create(
36+
job_name=JobName.DELIVER_CONTENTS.value,
37+
status=JobStatus.RUNNING.value,
38+
started_at=timezone.now(),
39+
)
2540
should_check_next = True
2641
while should_check_next:
2742
delivery_schedule = self.delivery_queue.next_task()
2843
if delivery_schedule is None:
2944
should_check_next = False
45+
job_execution.status = JobStatus.COMPLETED.value
46+
job_execution.finished_at = timezone.now()
47+
job_execution.save()
3048
else:
3149
self.process_delivery(delivery_schedule)
3250

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Generated by Django 6.0.1 on 2026-01-29 07:56
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("django_email_learning", "0003_apikey_salt_imapconnection_salt"),
9+
]
10+
11+
operations = [
12+
migrations.CreateModel(
13+
name="JobExecution",
14+
fields=[
15+
(
16+
"id",
17+
models.BigAutoField(
18+
auto_created=True,
19+
primary_key=True,
20+
serialize=False,
21+
verbose_name="ID",
22+
),
23+
),
24+
(
25+
"job_name",
26+
models.CharField(
27+
choices=[("DELIVER_CONTENTS", "deliver_contents")],
28+
max_length=200,
29+
),
30+
),
31+
("started_at", models.DateTimeField(auto_now_add=True)),
32+
("finished_at", models.DateTimeField(blank=True, null=True)),
33+
(
34+
"status",
35+
models.CharField(
36+
choices=[("RUNNING", "running"), ("COMPLETED", "completed")],
37+
max_length=50,
38+
),
39+
),
40+
],
41+
),
42+
migrations.AlterField(
43+
model_name="apikey",
44+
name="salt",
45+
field=models.CharField(
46+
default="727829a83ebd47f8baf0c15400d16119",
47+
editable=False,
48+
max_length=32,
49+
),
50+
),
51+
migrations.AlterField(
52+
model_name="imapconnection",
53+
name="salt",
54+
field=models.CharField(
55+
default="727829a83ebd47f8baf0c15400d16119",
56+
editable=False,
57+
max_length=32,
58+
),
59+
),
60+
]

django_email_learning/models.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,12 @@ def __str__(self) -> str:
109109
class EncryptionMixin(models.Model):
110110
salt = models.CharField(max_length=32, editable=False, default=uuid.uuid4().hex)
111111

112-
def _fernet(self) -> Fernet:
112+
@classmethod
113+
def _fernet(cls, salt: str) -> Fernet:
113114
kdf = PBKDF2HMAC(
114115
algorithm=hashes.SHA256(),
115116
length=32,
116-
salt=self.salt.encode(),
117+
salt=salt.encode(),
117118
iterations=100000,
118119
)
119120
try:
@@ -125,12 +126,17 @@ def _fernet(self) -> Fernet:
125126
key = base64.urlsafe_b64encode(kdf.derive(secret.encode()))
126127
return Fernet(key)
127128

129+
@classmethod
130+
def encrypted_value(cls, value: str, salt: str) -> str:
131+
f = cls._fernet(salt)
132+
return f.encrypt(value.encode()).decode()
133+
128134
def _encrypt_password(self, password: str) -> str:
129-
f = self._fernet()
135+
f = self._fernet(self.salt)
130136
return f.encrypt(password.encode()).decode()
131137

132138
def decrypt_password(self, encrypted_password: str) -> str:
133-
f = self._fernet()
139+
f = self._fernet(self.salt)
134140
return f.decrypt(encrypted_password.encode()).decode()
135141

136142
class Meta:
@@ -698,3 +704,29 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
698704
self.key = self._encrypt_password(self.key)
699705
self.full_clean()
700706
super().save(*args, **kwargs)
707+
708+
709+
class JobName(StrEnum):
710+
DELIVER_CONTENTS = "deliver_contents"
711+
712+
713+
class JobStatus(StrEnum):
714+
RUNNING = "running"
715+
COMPLETED = "completed"
716+
717+
718+
class JobExecution(models.Model):
719+
job_name = models.CharField(
720+
max_length=200, choices=[(job.name, job.value) for job in JobName]
721+
)
722+
started_at = models.DateTimeField(auto_now_add=True)
723+
finished_at = models.DateTimeField(null=True, blank=True)
724+
status = models.CharField(
725+
max_length=50,
726+
choices=[(status.name, status.value) for status in JobStatus],
727+
)
728+
729+
def __str__(self) -> str:
730+
return (
731+
f"Job: {self.job_name} started at {self.started_at} - Status: {self.status}"
732+
)

django_email_learning/personalised/api/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.urls import path
22
from django_email_learning.personalised.api.views import QuizSubmissionView
33

4-
app_name = "email_learning"
4+
app_name = "django_email_learning"
55

66
urlpatterns = [
77
path("quiz/", QuizSubmissionView.as_view(), name="quiz_submission"),

django_email_learning/personalised/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
VerifyEnrollmentView,
55
)
66

7-
app_name = "email_learning"
7+
app_name = "django_email_learning"
88

99
urlpatterns = [
1010
path("quiz/", QuizPublicView.as_view(), name="quiz_public_view"),

django_email_learning/platform/api/serializers.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
Enrollment,
2525
EnrollmentStatus,
2626
)
27+
from django_email_learning.services.jwt_service import generate_jwt
2728
import enum
2829

2930

@@ -35,10 +36,14 @@ class ApiKeyResponse(BaseModel):
3536

3637
@staticmethod
3738
def from_django_model(api_key: ApiKey) -> "ApiKeyResponse":
39+
decrypted_key = api_key.decrypt_password(api_key.key)
40+
salt = api_key.salt
41+
jwt_key = generate_jwt({"key": decrypted_key, "salt": salt}, exp=datetime.max)
42+
3843
return ApiKeyResponse.model_validate(
3944
{
4045
"id": api_key.id, # type: ignore[attr-defined]
41-
"key": api_key.decrypt_password(api_key.key),
46+
"key": jwt_key,
4247
"created_at": api_key.created_at,
4348
"created_by": api_key.created_by.username
4449
if api_key.created_by

0 commit comments

Comments
 (0)