Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions django_email_learning/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ class Meta:
abstract = True


class ImapConnection(EncryptionMixin, models.Model):
class ImapConnection(EncryptionMixin):
server = models.CharField(max_length=200, validators=[is_domain_or_ip])
port = models.IntegerField(db_default=993)
email = models.EmailField(max_length=200, unique=True)
Expand Down Expand Up @@ -849,7 +849,7 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def]
super().save(*args, **kwargs)


class ApiKey(EncryptionMixin, models.Model):
class ApiKey(EncryptionMixin):
key = models.CharField(
max_length=256, unique=True, validators=[MinLengthValidator(50)]
)
Expand Down
Empty file.
6 changes: 6 additions & 0 deletions django_email_learning/oauth_integrations/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class OauthIntegrationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "django_email_learning.oauth_integrations"
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Generated by Django 6.0.3 on 2026-03-09 10:27

from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="Session",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("session_id", models.CharField(max_length=255, unique=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("jwt_token", models.TextField()),
(
"state",
models.CharField(
choices=[
("PENDING", "Pending"),
("PROCESSING", "Processing"),
("COMPLETED", "Completed"),
("FAILED", "Failed"),
],
default="PENDING",
max_length=255,
),
),
],
),
]
Empty file.
26 changes: 26 additions & 0 deletions django_email_learning/oauth_integrations/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import uuid

from django.db import models


class SessionState(models.TextChoices):
PENDING = "PENDING", "Pending"
PROCESSING = "PROCESSING", "Processing"
COMPLETED = "COMPLETED", "Completed"
FAILED = "FAILED", "Failed"


class Session(models.Model):
session_id = models.CharField(max_length=255, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
jwt_token = models.TextField()
state = models.CharField(
max_length=255,
choices=SessionState.choices,
default=SessionState.PENDING,
)

def save(self, *args, **kwargs): # type: ignore[no-untyped-def]
if not self.session_id:
self.session_id = str(uuid.uuid4())
super().save(*args, **kwargs)
7 changes: 7 additions & 0 deletions django_email_learning/oauth_integrations/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from pydantic import BaseModel

from django_email_learning.services.command_models.command_request import CommandRequest


class CreateSessionRequest(BaseModel):
request: CommandRequest
11 changes: 11 additions & 0 deletions django_email_learning/oauth_integrations/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.urls import path

from .views import RedirectView, SessionView, SessionsView

app_name = "oauth_integrations"

urlpatterns = [
path("sessions/", SessionsView.as_view(), name="sessions_view"),
path("sessions/<str:session_id>/", SessionView.as_view(), name="session_view"),
path("redirect/", RedirectView.as_view(), name="redirect_view"),
]
187 changes: 187 additions & 0 deletions django_email_learning/oauth_integrations/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import json

from django.http import JsonResponse, HttpResponse
from django.shortcuts import render
from django.utils.translation import get_language, get_language_info
from django.utils.translation import gettext as _
from django.views import View
from pydantic import ValidationError

from django_email_learning.apps import PLATFORM_ADMIN_GROUP_NAME
from django_email_learning.models import Course, OrganizationUser
from django_email_learning.services.command_models.enroll_from_google_directory_command import (
EnrollFromGoogleDirectoryCommand,
)
from django_email_learning.services.command_handler_service import CommandHandlerService
from django_email_learning.services.jwt_service import (
InvalidTokenException,
decode_jwt,
generate_jwt,
)

from .models import Session, SessionState
from .serializers import CreateSessionRequest


def _command_result_response( # type: ignore[no-untyped-def]
request,
*,
page_title: str,
success_message: str | None = None,
error_message: str | None = None,
status_code: int = 200,
) -> HttpResponse:
current_lang_code = get_language()
lang_info = get_language_info(current_lang_code)
return render(
request,
"personalised/command_result.html",
{
"page_title": page_title,
"appContext": {
"successMessage": success_message,
"errorMessage": error_message,
"closeWindow": True,
"direction": "rtl" if lang_info["bidi"] else "ltr",
"localeMessages": {"Confirm": _("Confirm")},
},
},
status=status_code,
)


def _has_oauth_session_access(user, organization_id: int) -> bool: # type: ignore[no-untyped-def]
if user.is_superuser:
return True
if user.groups.filter(name=PLATFORM_ADMIN_GROUP_NAME).exists():
return True
return OrganizationUser.objects.filter(
user=user,
organization_id=organization_id,
role="admin",
).exists()


class SessionsView(View):
def post(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
if not request.user.is_authenticated:
return JsonResponse({"error": "Unauthorized"}, status=401)

payload = json.loads(request.body)

try:
serializer = CreateSessionRequest.model_validate(payload)
except ValidationError as ve:
return JsonResponse({"error": ve.errors()}, status=400)

command = serializer.request.command
if not isinstance(command, EnrollFromGoogleDirectoryCommand):
return JsonResponse(
{"error": "Unsupported command for oauth session"}, status=400
)

try:
course = Course.objects.get(id=command.course_id)
except Course.DoesNotExist:
return JsonResponse({"error": "Course not found"}, status=404)

if not _has_oauth_session_access(request.user, course.organization_id):
return JsonResponse({"error": "Forbidden"}, status=403)

temp_session = Session.objects.create(jwt_token="pending")
authorization_url = command.get_authorization_url(temp_session.session_id)
command_payload = serializer.request.model_dump(mode="json")
temp_session.jwt_token = generate_jwt(command_payload)
temp_session.save(update_fields=["jwt_token"])

return JsonResponse(
{
"session_id": temp_session.session_id,
"authorization_url": authorization_url,
},
status=201,
)


class SessionView(View):
def get(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
try:
session = Session.objects.get(session_id=kwargs["session_id"])
return JsonResponse(
{"session_id": session.session_id, "state": session.state}, status=200
)
except Session.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404)


class RedirectView(View):
def get(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
session_id = request.GET.get("state")
code = request.GET.get("code")
if not session_id:
return _command_result_response(
request,
page_title=_("Authorization Error"),
error_message=_("Missing state parameter."),
status_code=400,
)

try:
session = Session.objects.get(session_id=session_id)
except Session.DoesNotExist:
return _command_result_response(
request,
page_title=_("Authorization Error"),
error_message=_("Invalid session identifier."),
status_code=404,
)

if not code:
session.state = SessionState.FAILED
session.save(update_fields=["state"])
return _command_result_response(
request,
page_title=_("Authorization Error"),
error_message=_("Missing code parameter."),
status_code=400,
)

try:
session.state = SessionState.PROCESSING
session.save(update_fields=["state"])

decoded_request = decode_jwt(session.jwt_token)
command_payload = decoded_request.get("command", {})
command_payload["code"] = code
command_payload["state"] = session_id
decoded_request["command"] = command_payload
command_handler = CommandHandlerService()
command_handler.handle_json_command(decoded_request)
session.state = SessionState.COMPLETED
session.save(update_fields=["state"])
return _command_result_response(
request,
page_title=_("Authorization Complete"),
success_message=_(
"Google authorization completed successfully. You can close this window."
),
status_code=200,
)
except InvalidTokenException:
session.state = SessionState.FAILED
session.save(update_fields=["state"])
return _command_result_response(
request,
page_title=_("Authorization Error"),
error_message=_("Invalid OAuth session token."),
status_code=400,
)
except Exception as e: # noqa: BLE001
session.state = SessionState.FAILED
session.save(update_fields=["state"])
return _command_result_response(
request,
page_title=_("Authorization Error"),
error_message=str(e),
status_code=400,
)
7 changes: 7 additions & 0 deletions django_email_learning/platform/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,13 @@ def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def]
return JsonResponse({"error": str(e)}, status=409)


class GoogleAuthSession(View):
def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def]
state = str(uuid.uuid4())
request.session["google_oauth_state"] = state
return JsonResponse({"state": state}, status=200)


@method_decorator(accessible_for(roles={"admin", "editor"}), name="post")
@method_decorator(accessible_for(roles={"admin", "editor", "viewer"}), name="get")
class ImapConnectionView(View):
Expand Down
24 changes: 20 additions & 4 deletions django_email_learning/platform/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from django.apps import apps
from django.conf.global_settings import LANGUAGES
from django.views.generic import TemplateView
from django.utils.translation import get_language_info, get_language
Expand All @@ -15,6 +16,9 @@
from django.conf import settings


DJANGO_EMAIL_LEARNING_SETTINGS: dict = getattr(settings, "DJANGO_EMAIL_LEARNING", {})


@method_decorator(login_required, name="dispatch")
class BasePlatformView(TemplateView):
"""Base view for all platform views with shared context"""
Expand All @@ -38,10 +42,6 @@ def get_shared_context(self) -> Dict[str, Any]:
current_lang_code = get_language()
lang_info = get_language_info(current_lang_code)

DJANGO_EMAIL_LEARNING_SETTINGS: dict = getattr(
settings, "DJANGO_EMAIL_LEARNING", {}
)

return {
"appContext": {
"apiBaseUrl": reverse("django_email_learning:api_platform:root")[:-1],
Expand Down Expand Up @@ -224,14 +224,29 @@ def get_context_data(self, **kwargs) -> dict: # type: ignore[no-untyped-def]
context["appContext"]["direction"] = (
"rtl" if get_language_info(course.language)["bidi"] else "ltr"
)
context["appContext"]["showGoogleWorkspaceImport"] = bool(
DJANGO_EMAIL_LEARNING_SETTINGS.get("GOOGLE_OAUTH_CLIENT_ID")
) and apps.is_installed("django_email_learning.oauth_integrations")
context["page_title"] = _("Course: %(title)s") % {"title": course.title}
return context

def get_locale_messages(self) -> Dict[str, str]:
return {
"actions": _("Actions"),
"enroll_learner": _("Enroll Learner"),
"enrollment_success": _("Learner enrolled successfully."),
"imported_from_google_success": _(
"Learners imported from Google Workspace successfully."
),
"manual_email": _("Manual Email"),
"from_google_workspace": _("Import from Google Workspace"),
"google_workspace_description": _(
"If you are an administrator of a Google Workspace domain, you can import users from your domain into the platform and enroll them in this course."
),
"authorize_description": _(
"We need read-only access to your Google Workspace user directory to get started."
),
"authorize_button": _("Authorize with Google"),
"published": _("Published"),
"type": _("Type"),
"waiting_time": _("Waiting Time"),
Expand Down Expand Up @@ -311,6 +326,7 @@ def get_locale_messages(self) -> Dict[str, str]:
"delete_content_confirmation": _(
"Are you sure you want to delete the content: CONTENT_TITLE?"
),
"total_enrollments": _("Total Enrollments"),
"unverified": _("Unverified"),
"active": _("Active"),
"deactivated": _("Deactivated"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
from django_email_learning.services.command_models.unsubscribe_command import (
UnsubscribeCommand,
)
from django_email_learning.services.command_models.enroll_from_google_directory_command import (
EnrollFromGoogleDirectoryCommand,
)


class CommandRequest(BaseModel):
command: EnrollCommand | UnsubscribeCommand = Field(
..., discriminator="command_name"
command: EnrollCommand | UnsubscribeCommand | EnrollFromGoogleDirectoryCommand = (
Field(..., discriminator="command_name")
)
Loading