FAQ | This is a LIVE service | Changelog

Skip to content
Commits on Source (8)
# Changelog
## [0.3.1](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/compare/0.3.0...0.3.1) (2024-10-30)
### Bug Fixes
* allow revoking tokens ([ef459ee](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/ef459ee2404750525e7ac50f778e5f6ee4e3a848))
* print mock user details on creation ([b3305a9](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/b3305a970503988f32b0a842c5ac16d38c5cd152))
## [0.3.0](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/compare/0.2.1...0.3.0) (2024-10-28)
### Features
......
......@@ -21,5 +21,6 @@ class AccountDetailsFactory(factory.django.DjangoModelFactory):
name = factory.Faker("name")
college = factory.Faker("company")
affiliation = factory.Faker("company")
terms_accepted = False
account = factory.SubFactory(AccountFactory)
from pprint import pprint
from django.core.management.base import BaseCommand
from activate_account.factories import AccountDetailsFactory
from api.v1alpha.serializers import AccountSerializer
class Command(BaseCommand):
......@@ -10,6 +13,13 @@ class Command(BaseCommand):
self.stdout.write(self.style.SUCCESS("Starting to initialize mock data..."))
for _ in range(10):
AccountDetailsFactory()
details = AccountDetailsFactory()
print("Created user:")
pprint(
{
**AccountSerializer(details.account).data,
"registration_code": details.account.code,
}
)
self.stdout.write(self.style.SUCCESS("Successfully initialized mock data."))
# Generated by Django 4.2.14 on 2024-10-28 16:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("activate_account", "0002_accountdetails"),
]
operations = [
migrations.AddField(
model_name="accountdetails",
name="terms_accepted",
field=models.BooleanField(default=False),
),
]
......@@ -17,6 +17,7 @@ class AccountDetails(models.Model):
account = models.OneToOneField(
Account, related_name="account_details", on_delete=models.CASCADE, primary_key=True
)
terms_accepted = models.BooleanField(default=False, null=False)
# These fields are taken as-is from Jackdaw, no additional special handling or constraints are
# required. They are displayed to the user for confirmation, and no further processing is
......
from rest_framework import serializers
from activate_account.models import Account
from activate_account.models import Account, AccountDetails
class AccountSerializer(serializers.ModelSerializer):
name = serializers.CharField(source="account_details.name", default="")
affiliation = serializers.CharField(source="account_details.affiliation", default="")
college = serializers.CharField(source="account_details.college", default="")
terms_accepted = serializers.BooleanField(
source="account_details.terms_accepted", default=False
)
def update(self, instance, validated_data):
account_details_data = validated_data.get("account_details", {})
if account_details_data.get("terms_accepted"):
account_details, created = AccountDetails.objects.get_or_create(account=instance)
account_details.terms_accepted = True
account_details.save(update_fields=["terms_accepted"])
return instance
class Meta:
model = Account
fields = ("crsid", "last_name", "date_of_birth", "name", "affiliation", "college")
fields = (
"crsid",
"last_name",
"date_of_birth",
"name",
"affiliation",
"college",
"terms_accepted",
)
read_only_fields = (
"crsid",
"last_name",
"date_of_birth",
"name",
"affiliation",
"college",
)
import pytest
from django.urls import reverse
from rest_framework import status
@pytest.fixture
......@@ -10,7 +11,7 @@ def url():
def test_account_details(authenticated_api_client, account, account_details, url):
response = authenticated_api_client.get(url)
assert response.status_code == 200
assert response.status_code == status.HTTP_200_OK
assert response.data == {
"crsid": account.crsid,
"last_name": account.last_name,
......@@ -18,4 +19,60 @@ def test_account_details(authenticated_api_client, account, account_details, url
"name": account_details.name,
"affiliation": account_details.affiliation,
"college": account_details.college,
"terms_accepted": account_details.terms_accepted,
}
def test_update_terms_accepted(authenticated_api_client, account, url):
# Ensure AccountDetails does not exist initially
assert not hasattr(account, "account_details"), "AccountDetails should not exist initially"
# Send PATCH request to update the terms_accepted field to True
update_data_true = {"terms_accepted": True}
patch_response_true = authenticated_api_client.patch(url, update_data_true, format="json")
# Check response and update status to True
assert (
patch_response_true.status_code == status.HTTP_200_OK
), f"Expected 200 OK, got {patch_response_true.status_code}"
assert patch_response_true.data["terms_accepted"] is True, "terms_accepted should now be True"
# Refresh the account instance and check if AccountDetails was created
account.refresh_from_db()
assert hasattr(account, "account_details"), "AccountDetails should have been created"
assert (
account.account_details.terms_accepted is True
), "terms_accepted should be True in the newly created AccountDetails instance"
# Attempt to revert terms_accepted to False using PATCH
update_data_false = {"terms_accepted": False}
patch_response_false = authenticated_api_client.patch(url, update_data_false, format="json")
# Check that the update to False is not accepted
assert (
patch_response_false.status_code == status.HTTP_200_OK
), f"Expected 200 OK, got {patch_response_false.status_code}"
assert (
patch_response_false.data["terms_accepted"] is True
), "terms_accepted should remain True, revert attempt should not succeed"
# Use PUT method to attempt a full update including terms_accepted set to False
full_data_put_false = {
"terms_accepted": False,
"name": "Updated Name",
"affiliation": "Updated Affiliation",
"college": "Updated College",
}
put_response_false = authenticated_api_client.put(url, full_data_put_false, format="json")
# Check that the PUT method does not change terms_accepted to False
assert (
put_response_false.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
), f"Expected 405, got {put_response_false.status_code}"
# Fetch again via GET to confirm persistence of True state
refreshed_response = authenticated_api_client.get(url)
assert (
refreshed_response.data["terms_accepted"] is True
), "Updated state should persist as True after refresh"
......@@ -11,11 +11,12 @@ from api.v1alpha.serializers import AccountSerializer
@extend_schema(
tags=["Account details"],
summary="Retrieve account details of the current logged in account",
description="Endpoint that returns the details of the current logged in account.",
summary="Retrieve or update account details of the current logged in account",
description="Endpoint that returns or updates the details of the current logged in account.",
)
class AccountDetailsView(generics.RetrieveAPIView):
class AccountDetailsView(generics.RetrieveUpdateAPIView):
serializer_class = AccountSerializer
http_method_names = ["get", "patch", "options", "head"]
def get_object(self):
return self.request.user
import random
import pytest
from django.urls import reverse
from rest_framework import status
from authentication.models import AuthToken
from authentication.tests.factories import AuthTokenFactory
def assert_is_unauthorized(response):
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.parametrize(
"viewname,revoked_all", [("knox_logout", False), ("knox_logoutall", True)]
)
def test_logout(authenticated_api_client, account, viewname, revoked_all):
n_tokens = random.randrange(10, 21)
AuthTokenFactory.create_batch(n_tokens, user=account)
# Also create some tokens that are not for this user
n_other_tokens = random.randrange(10, 21)
AuthTokenFactory.create_batch(n_other_tokens)
# authenticated_api_client also created an authentication token for the user
assert account.auth_token_set.count() == n_tokens + 1
url = reverse(viewname)
response = authenticated_api_client.post(url)
assert response.status_code == status.HTTP_204_NO_CONTENT
expected_count = 0 if revoked_all else n_tokens
assert account.auth_token_set.count() == expected_count
# Tokens from other users should not be touched
assert AuthToken.objects.exclude(user=account).count() == n_other_tokens
# We are unauthorised now, so trying to logout again will fail even while we provide an access
# token.
assert_is_unauthorized(authenticated_api_client.post(url))
@pytest.mark.parametrize("viewname", ["knox_logout", "knox_logoutall"])
def test_logout_unauthenticated(api_client, viewname):
assert_is_unauthorized(api_client.post(reverse(viewname)))
from django.urls import path
from knox import views as knox_views
from .views import LoginView
from .views import LoginView, LogoutAllView, LogoutView
urlpatterns = [
path("", LoginView.as_view(), name="knox_login"),
path("revoke/", knox_views.LogoutView.as_view(versioning_class=None), name="knox_logout"),
path(
"revoke/all/",
knox_views.LogoutAllView.as_view(versioning_class=None),
name="knox_logoutall",
),
path("revoke/", LogoutView.as_view(), name="knox_logout"),
path("revoke/all/", LogoutAllView.as_view(), name="knox_logoutall"),
]
from knox.views import LoginView as KnoxLoginView
from rest_framework import parsers, renderers, serializers, status
from rest_framework import parsers, renderers, serializers, status, views
from rest_framework.response import Response
from authentication.errors import OAuth2Error
......@@ -68,3 +68,29 @@ class LoginView(KnoxLoginView):
return default_handler(exc, context)
return exception_handler
class LogoutView(views.APIView):
throttle_classes = ()
versioning_class = None
renderer_classes = (renderers.JSONRenderer,)
def get_post_response(self, request):
return Response(None, status=status.HTTP_204_NO_CONTENT)
def post(self, request, format=None):
request._auth.delete()
return self.get_post_response(request)
class LogoutAllView(views.APIView):
throttle_classes = ()
versioning_class = None
renderer_classes = (renderers.JSONRenderer,)
def get_post_response(self, request):
return Response(None, status=status.HTTP_204_NO_CONTENT)
def post(self, request, format=None):
request.user.auth_token_set.all().delete()
return self.get_post_response(request)
[tool.poetry]
name = "activate_account"
version = "0.3.0"
version = "0.3.1"
description = ""
authors = [ ]
readme = "README.md"
......