diff --git a/django_email_learning/services/jwt_service.py b/django_email_learning/services/jwt_service.py new file mode 100644 index 0000000..22091d4 --- /dev/null +++ b/django_email_learning/services/jwt_service.py @@ -0,0 +1,18 @@ +from django.conf import settings +from datetime import datetime, timedelta +import jwt + +SECRET = settings.SECRET_KEY +ALGORITHM = "HS256" + + +def generate_jwt(payload: dict, expiration_seconds: int = 3600) -> str: + payload_copy = payload.copy() + payload_copy["exp"] = datetime.utcnow() + timedelta(seconds=expiration_seconds) + token = jwt.encode(payload_copy, SECRET, algorithm=ALGORITHM) + return token + + +def decode_jwt(token: str) -> dict: + decoded = jwt.decode(token, SECRET, algorithms=[ALGORITHM]) + return decoded diff --git a/frontend/course/components/ContentTable.jsx b/frontend/course/components/ContentTable.jsx index 6744be1..bc9043e 100644 --- a/frontend/course/components/ContentTable.jsx +++ b/frontend/course/components/ContentTable.jsx @@ -147,7 +147,7 @@ const ContentTable = ({ courseId, eventHandler, loaded = false }) => { onClick={() => {let event = {type: 'content_clicked', content_id: content.id}; eventHandler(event);}} color='primary.dark' sx={{ cursor: 'pointer'}}>{content.title} {formatPeriod(content.waiting_period)} - {content.type} + {content.type.charAt(0).toUpperCase() + content.type.slice(1)} TogglePublishContent(content.id, !content.is_published)} disabled={userRole == 'viewer'} /> {userRole !== 'viewer' && deleteContent(content.id)}> diff --git a/frontend/src/components/MenuBar.jsx b/frontend/src/components/MenuBar.jsx index 9efc490..70e1fc3 100644 --- a/frontend/src/components/MenuBar.jsx +++ b/frontend/src/components/MenuBar.jsx @@ -86,7 +86,7 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza } pages.push({ name: 'Course Management', icon: , href: platformBaseUrl + '/courses/' }); - pages.push({ name: 'Users', icon: , href: platformBaseUrl + '/users/' }); + pages.push({ name: 'Learners', icon: , href: platformBaseUrl + '/users/' }); pages.push({ name: 'Analytics', icon: , href: platformBaseUrl + '/analytics/' }); @@ -95,48 +95,10 @@ function MenuBar({activeOrganizationId, changeOrganizationCallback, showOrganiza }; return ( - - // return ( - // - // - // Logo - // - // - // Email Learning - // { - // showOrganizationSwitcher && organizations.length > 0 && - // } - // - // - // - // - // - // - // - - // - // {pages.map((page) => ( - // - // ))} - // - // - - Logo + + Logo diff --git a/frontend/src/components/ThemeSwitcher.jsx b/frontend/src/components/ThemeSwitcher.jsx index 12fae67..e0f0308 100644 --- a/frontend/src/components/ThemeSwitcher.jsx +++ b/frontend/src/components/ThemeSwitcher.jsx @@ -72,7 +72,7 @@ const ThemeSwitcher = () => { }; return ( - + ); diff --git a/poetry.lock b/poetry.lock index e92312e..68f10b7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -525,6 +525,21 @@ files = [ {file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"}, ] +[[package]] +name = "freezegun" +version = "1.5.5" +description = "Let your Python tests travel through time" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "freezegun-1.5.5-py3-none-any.whl", hash = "sha256:cd557f4a75cf074e84bc374249b9dd491eaeacd61376b9eb3c423282211619d2"}, + {file = "freezegun-1.5.5.tar.gz", hash = "sha256:ac7742a6cc6c25a2c35e9292dfd554b897b517d2dec26891a2e8debf205cb94a"}, +] + +[package.dependencies] +python-dateutil = ">=2.7" + [[package]] name = "identify" version = "2.6.15" @@ -1130,6 +1145,24 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + [[package]] name = "pytest" version = "9.0.2" @@ -1193,6 +1226,21 @@ pytest = ">=7.0.0" docs = ["sphinx", "sphinx_rtd_theme"] testing = ["Django", "django-configurations (>=2.0)"] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + [[package]] name = "pyyaml" version = "6.0.3" @@ -1324,6 +1372,18 @@ files = [ {file = "ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4"}, ] +[[package]] +name = "six" +version = "1.17.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["dev"] +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + [[package]] name = "sqlparse" version = "0.5.4" @@ -1482,4 +1542,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "1a5e66f72edcc170e089b5e9b163c3b90aa88fe96dece215bd14bf147952988b" +content-hash = "5941696826834e3934e3b0f2fb8c74ebcb46719d809d2637842d6f421a5ea926" diff --git a/pyproject.toml b/pyproject.toml index b638eb6..92f2913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "django-email-learning" -version = "0.1.13" +version = "0.1.14" 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"} @@ -13,6 +13,7 @@ dependencies = [ "cryptography (>=46.0.3,<47.0.0)", "pillow (>=12.0.0,<13.0.0)", "pydantic (>=2.12.4,<3.0.0)", + "pyjwt (>=2.10.1,<3.0.0)", ] classifiers = [ "Development Status :: 3 - Alpha", @@ -43,7 +44,8 @@ dev = [ "django-cors-headers (>=4.9.0,<5.0.0)", "pre-commit (>=4.4.0,<5.0.0)", "bandit (>=1.8.6,<2.0.0)", - "pytest-cov (>=7.0.0,<8.0.0)" + "pytest-cov (>=7.0.0,<8.0.0)", + "freezegun (>=1.5.5,<2.0.0)" ] [build-system] diff --git a/tests/services/test_jwt_service.py b/tests/services/test_jwt_service.py new file mode 100644 index 0000000..d174300 --- /dev/null +++ b/tests/services/test_jwt_service.py @@ -0,0 +1,41 @@ +from django_email_learning.services import jwt_service +from freezegun import freeze_time +from datetime import timedelta, datetime +import jwt +import pytest + + +def test_jwt_service_generate_and_decode_jwt(): + payload = {"user_id": 123, "email": "test@example.com"} + token = jwt_service.generate_jwt(payload) + decoded_payload = jwt_service.decode_jwt(token) + assert decoded_payload["user_id"] == payload["user_id"] + assert decoded_payload["email"] == payload["email"] + + +def test_jwt_service_token_expiration(): + payload = {"user_id": 456} + + # Freeze time at a specific moment + with freeze_time("2023-01-01 12:00:00") as frozen_time: + token = jwt_service.generate_jwt(payload, expiration_seconds=3600) + + # Fast forward time by 4000 seconds + frozen_time.tick(delta=timedelta(seconds=4000)) + + # Token should now be expired + with pytest.raises(jwt.ExpiredSignatureError): + jwt_service.decode_jwt(token) + + +def test_jwt_service_invalid_token(): + payload = {"user_id": 789} + payload_copy = payload.copy() + payload_copy["exp"] = datetime.utcnow() + timedelta(seconds=3600) + # Create an invalid token by altering the signature + invalid_token = jwt.encode( + payload_copy, "INVALID_SECRET", algorithm=jwt_service.ALGORITHM + ) + + with pytest.raises(jwt.InvalidSignatureError): + jwt_service.decode_jwt(invalid_token)