From 4ffb6a6d799f44fb2da5697a8b069a9283b3bc54 Mon Sep 17 00:00:00 2001 From: Payam Date: Wed, 28 Jan 2026 17:36:24 +0400 Subject: [PATCH] feat: #159 API key management --- django_email_learning/apps.py | 3 + django_email_learning/decorators.py | 5 +- .../migrations/0002_apikey.py | 50 ++++++ django_email_learning/models.py | 77 ++++++--- .../platform/api/serializers.py | 21 +++ django_email_learning/platform/api/urls.py | 8 + django_email_learning/platform/api/views.py | 43 +++++ django_email_learning/platform/urls.py | 4 +- django_email_learning/platform/views.py | 11 ++ django_email_learning/signals.py | 5 +- .../templates/platform/base.html | 2 + .../templates/platform/settings_api_keys.html | 26 +++ docs/images/api-keys.png | Bin 0 -> 55569 bytes docs/source/index.rst | 1 + docs/source/platform/api_keys.rst | 51 ++++++ .../platform/settings_api_keys/ApiKeys.jsx | 161 ++++++++++++++++++ .../platform/settings_api_keys/index.html | 12 ++ frontend/src/components/MenuBar.jsx | 4 + frontend/vite.config.js | 1 + pyproject.toml | 2 +- scripts/build.py | 1 + tests/conftest.py | 5 +- .../api/test_views/test_api_key_view.py | 31 ++++ .../api/test_views/test_organizations_view.py | 4 +- tests/test_models/test_imap_connection.py | 7 + 25 files changed, 506 insertions(+), 29 deletions(-) create mode 100644 django_email_learning/migrations/0002_apikey.py create mode 100644 django_email_learning/templates/platform/settings_api_keys.html create mode 100644 docs/images/api-keys.png create mode 100644 docs/source/platform/api_keys.rst create mode 100644 frontend/platform/settings_api_keys/ApiKeys.jsx create mode 100644 frontend/platform/settings_api_keys/index.html create mode 100644 tests/platform/api/test_views/test_api_key_view.py diff --git a/django_email_learning/apps.py b/django_email_learning/apps.py index 469f14ab..51d9e1c9 100644 --- a/django_email_learning/apps.py +++ b/django_email_learning/apps.py @@ -2,6 +2,9 @@ from django.core import checks +PLATFORM_ADMIN_GROUP_NAME = "Platform Admin" + + def check_site_base_url_config(app_configs, **kwargs): # type: ignore[no-untyped-def] errors = [] from django.conf import settings diff --git a/django_email_learning/decorators.py b/django_email_learning/decorators.py index 91dec8a7..d85d149c 100644 --- a/django_email_learning/decorators.py +++ b/django_email_learning/decorators.py @@ -1,6 +1,7 @@ from functools import wraps from django.http import JsonResponse from django_email_learning.models import OrganizationUser +from django_email_learning.apps import PLATFORM_ADMIN_GROUP_NAME import typing @@ -13,7 +14,9 @@ def _wrapped_view(request, *view_args, **view_kwargs) -> JsonResponse: # type: if ( not request.user.is_superuser - and not request.user.groups.filter(name="Platform Admins").exists() + and not request.user.groups.filter( + name=PLATFORM_ADMIN_GROUP_NAME + ).exists() ): return JsonResponse({"error": "Forbidden"}, status=403) return view_func(request, *view_args, **view_kwargs) diff --git a/django_email_learning/migrations/0002_apikey.py b/django_email_learning/migrations/0002_apikey.py new file mode 100644 index 00000000..a527e441 --- /dev/null +++ b/django_email_learning/migrations/0002_apikey.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.1 on 2026-01-28 10:45 + +import django.core.validators +import django.db.models.deletion +import django_email_learning.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("django_email_learning", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="ApiKey", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "key", + models.CharField( + max_length=256, + unique=True, + validators=[django.core.validators.MinLengthValidator(50)], + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + bases=(django_email_learning.models.EncryptionMixin, models.Model), + ), + ] diff --git a/django_email_learning/models.py b/django_email_learning/models.py index f3b605c3..c32be6f8 100644 --- a/django_email_learning/models.py +++ b/django_email_learning/models.py @@ -10,9 +10,13 @@ from django.core.files.storage import default_storage from django.urls import reverse from django.db import models, transaction -from django.core.validators import MaxValueValidator, MinValueValidator +from django.core.validators import ( + MaxValueValidator, + MinValueValidator, + MinLengthValidator, +) from django.core.exceptions import ImproperlyConfigured -from cryptography.fernet import Fernet +from cryptography.fernet import Fernet, InvalidToken from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes from django.forms import ValidationError @@ -103,24 +107,7 @@ def __str__(self) -> str: return f"{self.user.username} - {self.organization.name}" -class ImapConnection(models.Model): - 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) - password = models.CharField(max_length=200) - organization = models.ForeignKey(Organization, on_delete=models.CASCADE) - - def __str__(self) -> str: - return f"{self.email}|{self.server}:{self.port}" - - def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] - if self.password: - self.password = self._encrypt_password(self.password) - if self.server: - self.server = self.server.lower() - self.full_clean() - super().save(*args, **kwargs) - +class EncryptionMixin: def _fernet(self) -> Fernet: kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=FIXED_SALT, iterations=100000 @@ -143,6 +130,29 @@ def decrypt_password(self, encrypted_password: str) -> str: return f.decrypt(encrypted_password.encode()).decode() +class ImapConnection(EncryptionMixin, models.Model): + 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) + password = models.CharField(max_length=200) + organization = models.ForeignKey(Organization, on_delete=models.CASCADE) + + def __str__(self) -> str: + return f"{self.email}|{self.server}:{self.port}" + + def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] + if self.password: + try: + self.decrypt_password(self.password) + # Password is already encrypted + except InvalidToken: + self.password = self._encrypt_password(self.password) + if self.server: + self.server = self.server.lower() + self.full_clean() + super().save(*args, **kwargs) + + class Course(models.Model): title = models.CharField(max_length=200) slug = models.SlugField( @@ -654,3 +664,30 @@ def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] ) self.full_clean() super().save(*args, **kwargs) + + +class ApiKey(EncryptionMixin, models.Model): + key = models.CharField( + max_length=256, unique=True, validators=[MinLengthValidator(50)] + ) + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True + ) + + @classmethod + def generate_key(cls) -> str: + return ( + base64.urlsafe_b64encode(uuid.uuid4().bytes + uuid.uuid4().bytes) + .decode() + .rstrip("=") + ) + + def save(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] + try: + self.decrypt_password(self.key) + # Key is already encrypted + except InvalidToken: + self.key = self._encrypt_password(self.key) + self.full_clean() + super().save(*args, **kwargs) diff --git a/django_email_learning/platform/api/serializers.py b/django_email_learning/platform/api/serializers.py index 7888ca32..77f067b5 100644 --- a/django_email_learning/platform/api/serializers.py +++ b/django_email_learning/platform/api/serializers.py @@ -10,6 +10,7 @@ from typing import Optional, Literal, Any, Callable from django.urls import reverse from django_email_learning.models import ( + ApiKey, DeliveryStatus, Organization, ImapConnection, @@ -26,6 +27,26 @@ import enum +class ApiKeyResponse(BaseModel): + id: int + key: str + created_at: datetime + created_by: Optional[str] = None + + @staticmethod + def from_django_model(api_key: ApiKey) -> "ApiKeyResponse": + return ApiKeyResponse.model_validate( + { + "id": api_key.id, # type: ignore[attr-defined] + "key": api_key.decrypt_password(api_key.key), + "created_at": api_key.created_at, + "created_by": api_key.created_by.username + if api_key.created_by + else None, + } + ) + + class CreateCourseRequest(BaseModel): title: str = Field(min_length=1, examples=["Introduction to Python"]) slug: str = Field( diff --git a/django_email_learning/platform/api/urls.py b/django_email_learning/platform/api/urls.py index 6119d388..edb47336 100644 --- a/django_email_learning/platform/api/urls.py +++ b/django_email_learning/platform/api/urls.py @@ -1,6 +1,8 @@ from django.urls import path from django.views.defaults import page_not_found from django_email_learning.platform.api.views import ( + ApiKeyView, + SingleApiKeyView, CourseView, EnrollmentView, EnrollmentsStatisticsView, @@ -81,6 +83,12 @@ SingleOrganizationView.as_view(), name="single_organization_view", ), + path("api_keys/", ApiKeyView.as_view(), name="api_key_view"), + path( + "api_keys//", + SingleApiKeyView.as_view(), + name="single_api_key_view", + ), path("session", UpdateSessionView.as_view(), name="update_session_view"), path("", page_not_found, name="root"), ] diff --git a/django_email_learning/platform/api/views.py b/django_email_learning/platform/api/views.py index 2c79d720..c0c49e26 100644 --- a/django_email_learning/platform/api/views.py +++ b/django_email_learning/platform/api/views.py @@ -15,6 +15,7 @@ from django_email_learning.platform.api import serializers from django_email_learning.platform.api.pagniated_api_mixin import PaginatedApiMixin from django_email_learning.models import ( + ApiKey, Course, CourseContent, Enrollment, @@ -592,6 +593,48 @@ def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-unty return JsonResponse({"statistics": stats}, status=200) +@method_decorator(is_platform_admin(), name="post") +@method_decorator(is_platform_admin(), name="get") +class ApiKeyView(View): + def post(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + try: + key = ApiKey.generate_key() + api_key = ApiKey(key=key, created_by=request.user) + api_key.save() + return JsonResponse( + serializers.ApiKeyResponse.from_django_model(api_key).model_dump(), + status=201, + ) + except ValidationError as e: + return JsonResponse({"error": e.json()}, status=400) + except IntegrityError as e: + return JsonResponse({"error": str(e)}, status=409) + + def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] + api_keys = ApiKey.objects.all() # type: ignore[attr-defined] + response_list = [] + for api_key in api_keys: + response_list.append( + serializers.ApiKeyResponse.from_django_model(api_key).model_dump() + ) + return JsonResponse({"api_keys": response_list}, status=200) + + +@method_decorator(is_platform_admin(), name="delete") +class SingleApiKeyView(View): + def delete(self, request, *args, **kwargs): # type: ignore[no-untyped-def] + try: + api_key = ApiKey.objects.get(id=kwargs["api_key_id"]) + api_key.delete() + return JsonResponse({"message": "API Key deleted successfully"}, status=200) + except ApiKey.DoesNotExist: + return JsonResponse({"error": "API Key not found"}, status=404) + except ValidationError as e: + return JsonResponse({"error": e.json()}, status=400) + except IntegrityError as e: + return JsonResponse({"error": str(e)}, status=409) + + class RootView(View): def get(self, request, *args, **kwargs) -> JsonResponse: # type: ignore[no-untyped-def] return JsonResponse({"message": "Email Learning API is running."}, status=200) diff --git a/django_email_learning/platform/urls.py b/django_email_learning/platform/urls.py index 4f4825fb..6d562de3 100644 --- a/django_email_learning/platform/urls.py +++ b/django_email_learning/platform/urls.py @@ -5,15 +5,17 @@ Courses, Organizations, Learners, + ApiKeys, ) -app_name = "email_learning" +app_name = "django_email_learning" urlpatterns = [ path("courses/", Courses.as_view(), name="courses_view"), path("courses//", CourseView.as_view(), name="course_detail_view"), path("organizations/", Organizations.as_view(), name="organizations_view"), path("learners/", Learners.as_view(), name="learners_view"), + path("settings/api_keys/", ApiKeys.as_view(), name="api_keys_view"), path( "", RedirectView.as_view( diff --git a/django_email_learning/platform/views.py b/django_email_learning/platform/views.py index 573617e1..14e8217a 100644 --- a/django_email_learning/platform/views.py +++ b/django_email_learning/platform/views.py @@ -108,3 +108,14 @@ def get_context_data(self, **kwargs): # type: ignore[no-untyped-def] context = super().get_context_data(**kwargs) context["page_title"] = _("Learners") return context + + +@method_decorator(login_required, name="dispatch") +@method_decorator(is_platform_admin(), name="dispatch") +class ApiKeys(BasePlatformView): + template_name = "platform/settings_api_keys.html" + + def get_context_data(self, **kwargs): # type: ignore[no-untyped-def] + context = super().get_context_data(**kwargs) + context["page_title"] = _("API Keys") + return context diff --git a/django_email_learning/signals.py b/django_email_learning/signals.py index 85708d6f..4af6cde8 100644 --- a/django_email_learning/signals.py +++ b/django_email_learning/signals.py @@ -2,6 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_migrate from django.dispatch import receiver +from django_email_learning.apps import PLATFORM_ADMIN_GROUP_NAME @receiver(post_migrate) @@ -79,10 +80,10 @@ def create_platform_admin_group(sender, **kwargs) -> None: # type: ignore[no-un ) platform_admin_group, created = Group.objects.get_or_create( - name="Email Learning Platform Admins" + name=PLATFORM_ADMIN_GROUP_NAME ) platform_admin_group.permissions.set(perms) - print("Platform Admin group created.") + print(f"{PLATFORM_ADMIN_GROUP_NAME} group created.") @receiver(post_migrate) diff --git a/django_email_learning/templates/platform/base.html b/django_email_learning/templates/platform/base.html index 813d2cd3..1beae06c 100644 --- a/django_email_learning/templates/platform/base.html +++ b/django_email_learning/templates/platform/base.html @@ -18,6 +18,8 @@ "organizations": "{% translate 'Organizations' %}", "course_management": "{% translate 'Course Management' %}", "learners": "{% translate 'Learners' %}", + "settings": "{% translate 'Settings' %}", + "api_keys": "{% translate 'API Keys' %}", } {% block extra_head %}{% endblock %} diff --git a/django_email_learning/templates/platform/settings_api_keys.html b/django_email_learning/templates/platform/settings_api_keys.html new file mode 100644 index 00000000..d955ed5e --- /dev/null +++ b/django_email_learning/templates/platform/settings_api_keys.html @@ -0,0 +1,26 @@ +{% extends "platform/base.html" %} +{% load django_vite %} +{% load i18n %} +{% block extra_head %} + + {% vite_asset 'platform/settings_api_keys/ApiKeys.jsx' %} +{% endblock %} diff --git a/docs/images/api-keys.png b/docs/images/api-keys.png new file mode 100644 index 0000000000000000000000000000000000000000..add25b71beed7ed591b70064c51aeaec13b6c293 GIT binary patch literal 55569 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 ZMREy<9bI!z@2CZ>uG#%s|I6Lq{|A%$Vfg?6 literal 0 HcmV?d00001 diff --git a/docs/source/index.rst b/docs/source/index.rst index 646a6b02..6f6ab39e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -22,6 +22,7 @@ Django Email Learning documentation platform/organizations platform/courses platform/learners + platform/api_keys .. toctree:: :maxdepth: 2 diff --git a/docs/source/platform/api_keys.rst b/docs/source/platform/api_keys.rst new file mode 100644 index 00000000..e98fd3ce --- /dev/null +++ b/docs/source/platform/api_keys.rst @@ -0,0 +1,51 @@ +API Keys +======== + +API Keys provide a secure way to authenticate programmatic access to the Django Email Learning platform. They are designed to enable automated job execution without the need to set up cron jobs directly on the server. + +.. image:: ../../images/api-keys.png + :alt: API Keys Management Interface + :align: center + +Overview +-------- + +API Keys allow you to: + +* Run scheduled jobs programmatically (e.g., ``deliver_contents``) +* Automate content delivery without server-level cron access +* Manage multiple keys for different automation scenarios +* Track which user created each key for audit purposes + +This is particularly useful when: + +* You don't have direct server access to configure cron jobs +* You want to trigger jobs from external systems or CI/CD pipelines +* You need to integrate Django Email Learning with other automation tools +* You prefer managing scheduled tasks through external job schedulers + +Access Requirements +------------------- + +Only **Platform Administrators** can create and manage API keys. This includes: + +* Platform Admins (members of the "Platform Admin" group) +* Superusers + +Creating an API Key +------------------- + +1. Navigate to **Settings** → **API Keys** from the platform navigation menu +2. Click the **Add API Key** button +3. The system will generate a secure, random API key + + +Managing API Keys +----------------- + +The API Keys page displays all existing keys with the following information: + +* **Key** - A partial view of the API key (for security, only a portion is shown after creation) +* **Created At** - Timestamp when the key was created +* **Created By** - The username of the administrator who created the key +* **Actions** - Delete button to revoke the key diff --git a/frontend/platform/settings_api_keys/ApiKeys.jsx b/frontend/platform/settings_api_keys/ApiKeys.jsx new file mode 100644 index 00000000..9d393336 --- /dev/null +++ b/frontend/platform/settings_api_keys/ApiKeys.jsx @@ -0,0 +1,161 @@ + +import Base from "../../src/components/Base"; +import { Box, Button, IconButton, Grid, Dialog, Typography, TableContainer, Table, TableHead, TableRow,TableBody, TableCell } from "@mui/material"; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import render from "../../src/render"; +import { useState, useEffect } from "react"; +import { getCookie } from "../../src/utils.js"; + + +const apiBaseUrl = localStorage.getItem('apiBaseUrl'); + + +const DeleteConfirmationDialog = ({apiKey, onCancel, onSuccess}) => { + return ( + + + {localeMessages["confirm_deletion"]} + + + {localeMessages["are_you_sure_delete_key"]} + + + + + + + ); +} + +const ApiKeys = () => { + const [dialogOpen, setDialogOpen] = useState(false); + const [dialogContent, setDialogContent] = useState(null); + const [apiKeyList, setApiKeyList] = useState([]); + const [loaded, setLoaded] = useState(false); + + + + + useEffect(() => { + // Fetch API keys from the backend + if (!loaded) { + fetch(`${apiBaseUrl}/api_keys/`, { + method: 'GET', + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + }, + }) + .then(response => response.json()) + .then(data => { + setApiKeyList(data.api_keys.map((key) => ({ + id: key.id, + key: key.key, + created_by: key.created_by, + created_at: key.created_at, + visible: false, + }))); + }) + .finally(() => { + setLoaded(true); + }); + } + }, [loaded]); + + const addApiKey = () => { + fetch(`${apiBaseUrl}/api_keys/`, { + method: 'POST', + headers: { + 'X-CSRFToken': getCookie('csrftoken'), + }, + }) + .then(response => response.json()) + .then(data => { + data.visible = false; + setApiKeyList([...apiKeyList, data]); + }); + } + + return ( + + + {localeMessages["api_key_intro"]} + + { apiKeyList.length > 0 && ( + + + + {localeMessages["key"]} + {localeMessages["created_by"]} + {localeMessages["created_at"]} + {localeMessages["actions"]} + + + + { apiKeyList.map((key) => ( + + { key.visible ? key.key : '••••••••••••••••' } + {key.created_by} + {key.created_at} + + {setDialogContent( setDialogOpen(false)} onSuccess={() => { + setLoaded(false); + setDialogOpen(false); + }} />); setDialogOpen(true);}}> + {key.visible ? + { + setApiKeyList(apiKeyList.map((k) => { + if (k.id === key.id) { + return {...k, visible: false}; + } + return k; + })); + }}> : + { + setApiKeyList(apiKeyList.map((k) => { + if (k.id === key.id) { + return {...k, visible: true}; + } + return k; + })); + }}> + } + + + ))} + +
+
+ + )} +
+
+ setDialogOpen(false)} maxWidth="sm" fullWidth> + { dialogContent } + + ) +} + +render({children: }); diff --git a/frontend/platform/settings_api_keys/index.html b/frontend/platform/settings_api_keys/index.html new file mode 100644 index 00000000..3b772a9c --- /dev/null +++ b/frontend/platform/settings_api_keys/index.html @@ -0,0 +1,12 @@ + + + + + + API Keys + + +
+ + + diff --git a/frontend/src/components/MenuBar.jsx b/frontend/src/components/MenuBar.jsx index 0ee8c14f..b5036499 100644 --- a/frontend/src/components/MenuBar.jsx +++ b/frontend/src/components/MenuBar.jsx @@ -4,6 +4,7 @@ import IconButton from '@mui/material/IconButton'; import SchoolIcon from '@mui/icons-material/School'; import PeopleIcon from '@mui/icons-material/People'; import BarChartIcon from '@mui/icons-material/BarChart'; +import VpnKeyIcon from '@mui/icons-material/VpnKey'; import Diversity3Icon from '@mui/icons-material/Diversity3'; import MenuIcon from '@mui/icons-material/Menu'; import logoHorizontalLightUrl from '../assets/logo-h-light.png' @@ -90,6 +91,9 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza pages.push({ name: localeMessages["course_management"], icon: , href: platformBaseUrl + '/courses/' }); pages.push({ name: localeMessages["learners"], icon: , href: platformBaseUrl + '/learners/' }); // pages.push({ name: 'Analytics', icon: , href: platformBaseUrl + '/analytics/' }); + if (localStorage.getItem('isPlatformAdmin') == 'true') { + pages.push({ name: localeMessages["api_keys"], icon: , href: platformBaseUrl + '/settings/api_keys' }); + } const toggleMenuDrawer = (newOpen) => () => { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index d5bd91c5..7ec6127a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -28,6 +28,7 @@ export default defineConfig({ course: resolve(__dirname, 'platform/course/index.html'), organizations: resolve(__dirname, 'platform/organizations/index.html'), learners: resolve(__dirname, 'platform/learners/index.html'), + settings_api_keys: resolve(__dirname, 'platform/settings_api_keys/index.html'), organization: resolve(__dirname, 'public/organization/index.html'), quiz_public: resolve(__dirname, "personalised/quiz_public/index.html"), verify_enrollment: resolve(__dirname, "personalised/verify_enrollment/index.html"), diff --git a/pyproject.toml b/pyproject.toml index d34f90b7..6da92f09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "django-email-learning" -version = "0.1.22" +version = "0.1.23" description = "A platform for creating and delivering learning materials via email within a Django application. It provides tools for content management, user role-based administration, and scheduler integration for automated content delivery." authors = [ {name = "Payam Najafizadeh",email = "payam.nj@gmail.com"} diff --git a/scripts/build.py b/scripts/build.py index db8163c1..0a00d1ec 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -9,6 +9,7 @@ ["platform", "course"], ["platform", "organizations"], ["platform", "learners"], + ["platform", "settings_api_keys"], ["personalised", "quiz_public"], ["personalised", "verify_enrollment"], ["public", "organization"], diff --git a/tests/conftest.py b/tests/conftest.py index 4738b46d..c1678db0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ -from django.contrib.auth.models import User +from django.contrib.auth.models import User, Group from django_email_learning.models import OrganizationUser +from django_email_learning.apps import PLATFORM_ADMIN_GROUP_NAME from django.test import Client from django_email_learning.models import ( ImapConnection, @@ -43,6 +44,8 @@ def users(db): id=4, username="viewer", email="viewer@example.com", password="viewerpass" ) User.objects.bulk_create([superadmin, editor_user, platform_admin, viewer_user]) + group = Group.objects.get(name=PLATFORM_ADMIN_GROUP_NAME) + platform_admin.groups.add(group) editor = OrganizationUser(user=editor_user, organization_id=1, role="editor") admin = OrganizationUser(user=platform_admin, organization_id=1, role="admin") viewer = OrganizationUser(user=viewer_user, organization_id=1, role="viewer") diff --git a/tests/platform/api/test_views/test_api_key_view.py b/tests/platform/api/test_views/test_api_key_view.py new file mode 100644 index 00000000..42725a8a --- /dev/null +++ b/tests/platform/api/test_views/test_api_key_view.py @@ -0,0 +1,31 @@ +from django.urls import reverse + +URL = reverse("django_email_learning:api_platform:api_key_view") + + +def test_create_api_key(superadmin_client): + response = superadmin_client.post(URL) + assert response.status_code == 201 + data = response.json() + assert "id" in data + assert "key" in data + assert "created_at" in data + assert data["created_by"] == "superadmin" + created_key = data["key"] + + response = superadmin_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert "api_keys" in data + api_keys = data["api_keys"] + assert any(api_key["key"] == created_key for api_key in api_keys) + + +def test_organization_user_cannot_create_api_key(editor_client): + response = editor_client.post(URL) + assert response.status_code == 403 + + +def test_platform_admin_can_create_api_key(platform_admin_client): + response = platform_admin_client.post(URL) + assert response.status_code == 201 diff --git a/tests/platform/api/test_views/test_organizations_view.py b/tests/platform/api/test_views/test_organizations_view.py index fc03ab12..46155505 100644 --- a/tests/platform/api/test_views/test_organizations_view.py +++ b/tests/platform/api/test_views/test_organizations_view.py @@ -81,9 +81,7 @@ def test_create_organization_for_existing_logo_file( assert response.json().get("logo").endswith(f"/{existing_logo_path}") -@pytest.mark.parametrize( - "client", ["viewer", "editor", "platform_admin"], indirect=True -) +@pytest.mark.parametrize("client", ["viewer", "editor"], indirect=True) def test_post_organizations_view_as_organization_user(client): payload = {"name": "Another Org", "description": "Should not be created"} response = client.post(get_url(), data=payload, content_type="application/json") diff --git a/tests/test_models/test_imap_connection.py b/tests/test_models/test_imap_connection.py index 42250f89..a50f3f96 100644 --- a/tests/test_models/test_imap_connection.py +++ b/tests/test_models/test_imap_connection.py @@ -9,6 +9,13 @@ def test_encrypt_decrypt_password(imap_connection): assert decrypted_password == "my_secret_password" +def test_saving_encrypted_password(imap_connection, db): + imap_connection.save() + retrieved = ImapConnection.objects.get(id=imap_connection.id) + assert retrieved.password == imap_connection.password + assert retrieved.decrypt_password(retrieved.password) == "my_secret_password" + + def test_str_representation(imap_connection): assert str(imap_connection) == "user@example.com|imap.example.com:993"