Skip to content

Commit 4ffb6a6

Browse files
committed
feat: #159 API key management
1 parent bd1c8f8 commit 4ffb6a6

25 files changed

Lines changed: 506 additions & 29 deletions

File tree

django_email_learning/apps.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
from django.core import checks
33

44

5+
PLATFORM_ADMIN_GROUP_NAME = "Platform Admin"
6+
7+
58
def check_site_base_url_config(app_configs, **kwargs): # type: ignore[no-untyped-def]
69
errors = []
710
from django.conf import settings

django_email_learning/decorators.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from functools import wraps
22
from django.http import JsonResponse
33
from django_email_learning.models import OrganizationUser
4+
from django_email_learning.apps import PLATFORM_ADMIN_GROUP_NAME
45
import typing
56

67

@@ -13,7 +14,9 @@ def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type:
1314

1415
if (
1516
not request.user.is_superuser
16-
and not request.user.groups.filter(name="Platform Admins").exists()
17+
and not request.user.groups.filter(
18+
name=PLATFORM_ADMIN_GROUP_NAME
19+
).exists()
1720
):
1821
return JsonResponse({"error": "Forbidden"}, status=403)
1922
return view_func(request, *view_args, **view_kwargs)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Generated by Django 6.0.1 on 2026-01-28 10:45
2+
3+
import django.core.validators
4+
import django.db.models.deletion
5+
import django_email_learning.models
6+
from django.conf import settings
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
dependencies = [
12+
("django_email_learning", "0001_initial"),
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="ApiKey",
19+
fields=[
20+
(
21+
"id",
22+
models.BigAutoField(
23+
auto_created=True,
24+
primary_key=True,
25+
serialize=False,
26+
verbose_name="ID",
27+
),
28+
),
29+
(
30+
"key",
31+
models.CharField(
32+
max_length=256,
33+
unique=True,
34+
validators=[django.core.validators.MinLengthValidator(50)],
35+
),
36+
),
37+
("created_at", models.DateTimeField(auto_now_add=True)),
38+
(
39+
"created_by",
40+
models.ForeignKey(
41+
blank=True,
42+
null=True,
43+
on_delete=django.db.models.deletion.SET_NULL,
44+
to=settings.AUTH_USER_MODEL,
45+
),
46+
),
47+
],
48+
bases=(django_email_learning.models.EncryptionMixin, models.Model),
49+
),
50+
]

django_email_learning/models.py

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@
1010
from django.core.files.storage import default_storage
1111
from django.urls import reverse
1212
from django.db import models, transaction
13-
from django.core.validators import MaxValueValidator, MinValueValidator
13+
from django.core.validators import (
14+
MaxValueValidator,
15+
MinValueValidator,
16+
MinLengthValidator,
17+
)
1418
from django.core.exceptions import ImproperlyConfigured
15-
from cryptography.fernet import Fernet
19+
from cryptography.fernet import Fernet, InvalidToken
1620
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
1721
from cryptography.hazmat.primitives import hashes
1822
from django.forms import ValidationError
@@ -103,24 +107,7 @@ def __str__(self) -> str:
103107
return f"{self.user.username} - {self.organization.name}"
104108

105109

106-
class ImapConnection(models.Model):
107-
server = models.CharField(max_length=200, validators=[is_domain_or_ip])
108-
port = models.IntegerField(db_default=993)
109-
email = models.EmailField(max_length=200, unique=True)
110-
password = models.CharField(max_length=200)
111-
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
112-
113-
def __str__(self) -> str:
114-
return f"{self.email}|{self.server}:{self.port}"
115-
116-
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
117-
if self.password:
118-
self.password = self._encrypt_password(self.password)
119-
if self.server:
120-
self.server = self.server.lower()
121-
self.full_clean()
122-
super().save(*args, **kwargs)
123-
110+
class EncryptionMixin:
124111
def _fernet(self) -> Fernet:
125112
kdf = PBKDF2HMAC(
126113
algorithm=hashes.SHA256(), length=32, salt=FIXED_SALT, iterations=100000
@@ -143,6 +130,29 @@ def decrypt_password(self, encrypted_password: str) -> str:
143130
return f.decrypt(encrypted_password.encode()).decode()
144131

145132

133+
class ImapConnection(EncryptionMixin, models.Model):
134+
server = models.CharField(max_length=200, validators=[is_domain_or_ip])
135+
port = models.IntegerField(db_default=993)
136+
email = models.EmailField(max_length=200, unique=True)
137+
password = models.CharField(max_length=200)
138+
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
139+
140+
def __str__(self) -> str:
141+
return f"{self.email}|{self.server}:{self.port}"
142+
143+
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
144+
if self.password:
145+
try:
146+
self.decrypt_password(self.password)
147+
# Password is already encrypted
148+
except InvalidToken:
149+
self.password = self._encrypt_password(self.password)
150+
if self.server:
151+
self.server = self.server.lower()
152+
self.full_clean()
153+
super().save(*args, **kwargs)
154+
155+
146156
class Course(models.Model):
147157
title = models.CharField(max_length=200)
148158
slug = models.SlugField(
@@ -654,3 +664,30 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
654664
)
655665
self.full_clean()
656666
super().save(*args, **kwargs)
667+
668+
669+
class ApiKey(EncryptionMixin, models.Model):
670+
key = models.CharField(
671+
max_length=256, unique=True, validators=[MinLengthValidator(50)]
672+
)
673+
created_at = models.DateTimeField(auto_now_add=True)
674+
created_by = models.ForeignKey(
675+
User, on_delete=models.SET_NULL, null=True, blank=True
676+
)
677+
678+
@classmethod
679+
def generate_key(cls) -> str:
680+
return (
681+
base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes)
682+
.decode()
683+
.rstrip("=")
684+
)
685+
686+
def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
687+
try:
688+
self.decrypt_password(self.key)
689+
# Key is already encrypted
690+
except InvalidToken:
691+
self.key = self._encrypt_password(self.key)
692+
self.full_clean()
693+
super().save(*args, **kwargs)

django_email_learning/platform/api/serializers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import Optional, Literal, Any, Callable
1111
from django.urls import reverse
1212
from django_email_learning.models import (
13+
ApiKey,
1314
DeliveryStatus,
1415
Organization,
1516
ImapConnection,
@@ -26,6 +27,26 @@
2627
import enum
2728

2829

30+
class ApiKeyResponse(BaseModel):
31+
id: int
32+
key: str
33+
created_at: datetime
34+
created_by: Optional[str] = None
35+
36+
@staticmethod
37+
def from_django_model(api_key: ApiKey) -> "ApiKeyResponse":
38+
return ApiKeyResponse.model_validate(
39+
{
40+
"id": api_key.id, # type: ignore[attr-defined]
41+
"key": api_key.decrypt_password(api_key.key),
42+
"created_at": api_key.created_at,
43+
"created_by": api_key.created_by.username
44+
if api_key.created_by
45+
else None,
46+
}
47+
)
48+
49+
2950
class CreateCourseRequest(BaseModel):
3051
title: str = Field(min_length=1, examples=["Introduction to Python"])
3152
slug: str = Field(

django_email_learning/platform/api/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from django.urls import path
22
from django.views.defaults import page_not_found
33
from django_email_learning.platform.api.views import (
4+
ApiKeyView,
5+
SingleApiKeyView,
46
CourseView,
57
EnrollmentView,
68
EnrollmentsStatisticsView,
@@ -81,6 +83,12 @@
8183
SingleOrganizationView.as_view(),
8284
name="single_organization_view",
8385
),
86+
path("api_keys/", ApiKeyView.as_view(), name="api_key_view"),
87+
path(
88+
"api_keys/<int:api_key_id>/",
89+
SingleApiKeyView.as_view(),
90+
name="single_api_key_view",
91+
),
8492
path("session", UpdateSessionView.as_view(), name="update_session_view"),
8593
path("", page_not_found, name="root"),
8694
]

django_email_learning/platform/api/views.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from django_email_learning.platform.api import serializers
1616
from django_email_learning.platform.api.pagniated_api_mixin import PaginatedApiMixin
1717
from django_email_learning.models import (
18+
ApiKey,
1819
Course,
1920
CourseContent,
2021
Enrollment,
@@ -592,6 +593,48 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty
592593
return JsonResponse({"statistics": stats}, status=200)
593594

594595

596+
@method_decorator(is_platform_admin(), name="post")
597+
@method_decorator(is_platform_admin(), name="get")
598+
class ApiKeyView(View):
599+
def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
600+
try:
601+
key = ApiKey.generate_key()
602+
api_key = ApiKey(key=key, created_by=request.user)
603+
api_key.save()
604+
return JsonResponse(
605+
serializers.ApiKeyResponse.from_django_model(api_key).model_dump(),
606+
status=201,
607+
)
608+
except ValidationError as e:
609+
return JsonResponse({"error": e.json()}, status=400)
610+
except IntegrityError as e:
611+
return JsonResponse({"error": str(e)}, status=409)
612+
613+
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
614+
api_keys = ApiKey.objects.all() # type: ignore[attr-defined]
615+
response_list = []
616+
for api_key in api_keys:
617+
response_list.append(
618+
serializers.ApiKeyResponse.from_django_model(api_key).model_dump()
619+
)
620+
return JsonResponse({"api_keys": response_list}, status=200)
621+
622+
623+
@method_decorator(is_platform_admin(), name="delete")
624+
class SingleApiKeyView(View):
625+
def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
626+
try:
627+
api_key = ApiKey.objects.get(id=kwargs["api_key_id"])
628+
api_key.delete()
629+
return JsonResponse({"message": "API Key deleted successfully"}, status=200)
630+
except ApiKey.DoesNotExist:
631+
return JsonResponse({"error": "API Key not found"}, status=404)
632+
except ValidationError as e:
633+
return JsonResponse({"error": e.json()}, status=400)
634+
except IntegrityError as e:
635+
return JsonResponse({"error": str(e)}, status=409)
636+
637+
595638
class RootView(View):
596639
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
597640
return JsonResponse({"message": "Email Learning API is running."}, status=200)

django_email_learning/platform/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55
Courses,
66
Organizations,
77
Learners,
8+
ApiKeys,
89
)
910

10-
app_name = "email_learning"
11+
app_name = "django_email_learning"
1112

1213
urlpatterns = [
1314
path("courses/", Courses.as_view(), name="courses_view"),
1415
path("courses/<int:course_id>/", CourseView.as_view(), name="course_detail_view"),
1516
path("organizations/", Organizations.as_view(), name="organizations_view"),
1617
path("learners/", Learners.as_view(), name="learners_view"),
18+
path("settings/api_keys/", ApiKeys.as_view(), name="api_keys_view"),
1719
path(
1820
"",
1921
RedirectView.as_view(

django_email_learning/platform/views.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,14 @@ def get_context_data(self, **kwargs): # type: ignore[no-untyped-def]
108108
context = super().get_context_data(**kwargs)
109109
context["page_title"] = _("Learners")
110110
return context
111+
112+
113+
@method_decorator(login_required, name="dispatch")
114+
@method_decorator(is_platform_admin(), name="dispatch")
115+
class ApiKeys(BasePlatformView):
116+
template_name = "platform/settings_api_keys.html"
117+
118+
def get_context_data(self, **kwargs): # type: ignore[no-untyped-def]
119+
context = super().get_context_data(**kwargs)
120+
context["page_title"] = _("API Keys")
121+
return context

django_email_learning/signals.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.contrib.contenttypes.models import ContentType
33
from django.db.models.signals import post_migrate
44
from django.dispatch import receiver
5+
from django_email_learning.apps import PLATFORM_ADMIN_GROUP_NAME
56

67

78
@receiver(post_migrate)
@@ -79,10 +80,10 @@ def create_platform_admin_group(sender, **kwargs) -> None: # type: ignore[no-un
7980
)
8081

8182
platform_admin_group, created = Group.objects.get_or_create(
82-
name="Email Learning Platform Admins"
83+
name=PLATFORM_ADMIN_GROUP_NAME
8384
)
8485
platform_admin_group.permissions.set(perms)
85-
print("Platform Admin group created.")
86+
print(f"{PLATFORM_ADMIN_GROUP_NAME} group created.")
8687

8788

8889
@receiver(post_migrate)

0 commit comments

Comments
 (0)