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 00000000..add25b71 Binary files /dev/null and b/docs/images/api-keys.png differ 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"