FAQ | This is a LIVE service | Changelog

Skip to content
Commits on Source (4)
......@@ -115,3 +115,5 @@ venv.bak/
# Secrets
secrets.env
openapi.yaml
# Changelog
## [0.11.0](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/compare/0.10.0...0.11.0) (2024-12-13)
### Features
* add endpoint for data manager to create/update/delete accounts ([8a90ac7](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/commit/8a90ac7153d8e89573429456872a326690f82b01))
## [0.10.0](https://gitlab.developers.cam.ac.uk/uis/devops/iam/activate-account/api/compare/0.9.4...0.10.0) (2024-12-09)
### Features
......
......@@ -17,6 +17,8 @@ DATABASES = {
},
}
DATA_MANAGER_ENABLED = False
# If the EXTRA_SETTINGS_URLS environment variable is set, it is a comma-separated list of URLs from
# which to fetch additional settings as YAML-formatted documents. The documents should be
# dictionaries and top-level keys are imported into this module's global values.
......@@ -37,6 +39,7 @@ externalsettings.load_external_settings(
"EMAIL_HOST_PASSWORD",
"EMAIL_HOST_USER",
"EMAIL_PORT",
"DATA_MANAGER_ENABLED",
],
)
......
......@@ -2,6 +2,7 @@
Test basic functionality of project-specific views.
"""
from django.test import TestCase
from django.urls import reverse
......
......@@ -42,6 +42,14 @@ urlpatterns = [
path("v1alpha1/", include(("api.v1alpha.urls", "v1alpha1"), namespace="v1alpha1")),
]
if settings.DATA_MANAGER_ENABLED:
urlpatterns += [
path(
"data-manager-api/",
include(("data_manager_api.urls", "data_manager"), namespace="data_manager_api"),
),
]
# Selectively enable django debug toolbar URLs. Only if the toolbar is
# installed *and* DEBUG is True.
if HAVE_DDT and settings.DEBUG:
......
from django.apps import AppConfig
class DataManagerConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "data_manager"
from django.db import transaction
from rest_framework import serializers
from activate_account.models import Account, AccountDetails
class AccountDataSerializer(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="")
class Meta:
model = Account
fields = (
"crsid",
"last_name",
"date_of_birth",
"code",
"name",
"affiliation",
"college",
)
@transaction.atomic
def create(self, validated_data):
account_details = validated_data.pop("account_details")
account = Account.objects.create(**validated_data)
AccountDetails.objects.create(
account=account,
**account_details,
)
return account
@transaction.atomic
def update(self, instance, validated_data):
account_details = validated_data.pop("account_details")
# Update Account
for key, value in validated_data.items():
setattr(instance, key, value)
instance.save()
# Update AccountDetails
for key, value in account_details.items():
setattr(instance.account_details, key, value)
instance.account_details.save()
return instance
import pytest
from activate_account.factories import AccountDetailsFactory
from data_manager_api.serializers import AccountDataSerializer
from data_manager_api.tests.utils import get_account_data
pytestmark = pytest.mark.django_db
def test_create():
account_details = AccountDetailsFactory.build() # Do not save to the database, just build
data = get_account_data(account_details.account)
serializer = AccountDataSerializer(data=data)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
for attribute in ["crsid", "date_of_birth", "code", "last_name"]:
assert getattr(instance, attribute) == getattr(account_details.account, attribute)
for attribute in ["name", "college", "affiliation"]:
assert getattr(instance.account_details, attribute) == getattr(account_details, attribute)
@pytest.mark.parametrize("partial", [True, False])
def test_update(partial):
account_details = AccountDetailsFactory()
initial_data = get_account_data(account_details.account)
new_data = {"name": "Lee Sang-hyeok"}
assert account_details.name != new_data["name"] # Sanity check
serializer = AccountDataSerializer(
instance=account_details.account,
partial=partial,
data=new_data if partial else {**initial_data, **new_data},
)
serializer.is_valid()
instance = serializer.save()
assert instance.account_details.name == new_data["name"]
import pytest
from django.urls import reverse
from rest_framework import status
from activate_account.factories import AccountDetailsFactory
from activate_account.models import Account
from data_manager_api.tests.utils import get_account_data
pytestmark = pytest.mark.django_db
def test_account_update_create(api_client):
account_details = AccountDetailsFactory.build() # Do not save to the database, just build
instance = account_details.account
data = get_account_data(instance)
# Sanity check
assert not Account.objects.filter(crsid=instance.crsid).exists()
# We try to update while the object does not exist yet
response = api_client.put(
reverse("data_manager_api:account-detail", kwargs={"crsid": instance.crsid}), data
)
# We instead created the object
assert response.status_code == status.HTTP_201_CREATED
instance = Account.objects.get(crsid=instance.crsid)
for attribute in ["crsid", "date_of_birth", "code", "last_name"]:
assert getattr(instance, attribute) == getattr(account_details.account, attribute)
for attribute in ["name", "college", "affiliation"]:
assert getattr(instance.account_details, attribute) == getattr(account_details, attribute)
def test_account_update(api_client):
account_details = AccountDetailsFactory()
instance = account_details.account
initial_data = get_account_data(instance)
new_data = {"name": "Ryu Min-seok"}
assert account_details.name != new_data["name"] # Sanity check
response = api_client.put(
reverse("data_manager_api:account-detail", kwargs={"crsid": instance.crsid}),
{**initial_data, **new_data},
)
assert response.status_code == status.HTTP_200_OK
instance = Account.objects.get(crsid=instance.crsid)
assert instance.account_details.name == new_data["name"]
def test_account_delete(api_client):
account_details = AccountDetailsFactory()
instance = account_details.account
response = api_client.delete(
reverse("data_manager_api:account-detail", kwargs={"crsid": instance.crsid}),
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not Account.objects.filter(crsid=instance.crsid).exists()
from data_manager_api.serializers import AccountDataSerializer
def get_account_data(instance):
return AccountDataSerializer(instance=instance).to_representation(instance)
from rest_framework import routers
from data_manager_api.views import AccountViewSet
router = routers.DefaultRouter()
router.register("accounts", AccountViewSet, "account")
urlpatterns = router.urls
from django.http import Http404
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import mixins, status, viewsets
from rest_framework.response import Response
from activate_account.models import Account
from data_manager_api.serializers import AccountDataSerializer
@extend_schema(
summary="Update account data",
description=(
"Provides a way for external services to update the account data in the application. Note "
"that this is API endpoint is private and for internal use only. This endpoint will not "
"exist in the public API."
),
tags=["internal account management"],
)
class AccountViewSet(
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
permission_classes = ()
authentication_classes = ()
serializer_class = AccountDataSerializer
queryset = Account.objects.all()
versioning_class = None
lookup_url_kwarg = "crsid"
@extend_schema(
responses={
200: OpenApiResponse(
response=AccountDataSerializer,
description="Successful update of account data.",
),
201: OpenApiResponse(
response=AccountDataSerializer,
description="Successful creation of account data.",
),
},
)
def update(self, request, *args, **kwargs):
"""
We only allow a single endpoint to update the data, both for creation and updating. This
method is basically combining what mixins.CreateModelMixin and mixins.UpdateModelMixin
would do, except for the fact that we're only allowing PUT, and not POST or PATCH.
"""
instance = None
try:
instance = self.get_object()
except Http404:
# No instance found, so we know we want to create a new instance.
pass
serializer = self.get_serializer(instance=instance, data=request.data)
serializer.is_valid(raise_exception=True)
# The serializer calls `update` when it has an instance passed in, while it calls `create`
# when it has no instance passed in.
serializer.save()
if instance and getattr(instance, "_prefetched_objects_cache", None):
# If 'prefetch_related' has been applied to a queryset, we need to
# forcibly invalidate the prefetch cache on the instance.
instance._prefetched_objects_cache = {}
return Response(
serializer.data, status=status.HTTP_200_OK if instance else status.HTTP_201_CREATED
)
......@@ -7,6 +7,11 @@ name: activate_account
x-django-application-environment: &django-application-environment
EXTERNAL_SETTING_SECRET_KEY: fake-secret
EXTERNAL_SETTING_DATABASES: '{"default":{"HOST":"db","NAME":"webapp-db","USER":"webapp-user","PASSWORD":"webapp-pass"}}'
# In production, we have one Cloud Run instance which is public facing and has these endpoints
# disabled and one instance that is internal and has these endpoints enabled. In local
# development we simplify the setup and just run one instance with all endpoints, internal and
# external.
EXTERNAL_SETTING_DATA_MANAGER_ENABLED: "1"
# Disable HTTP -> HTTPS redirect when running locally.
DANGEROUS_DISABLE_HTTPS_REDIRECT: "1"
......
[tool.poetry]
name = "activate_account"
version = "0.10.0"
version = "0.11.0"
description = ""
authors = [ ]
readme = "README.md"
......@@ -143,6 +143,7 @@ env = [
"DJANGO_SETTINGS_MODULE=activate_account_project.settings.testing",
"D:R:EXTERNAL_SETTING_DATABASES={\"default\":{}}",
"D:EXTERNAL_SETTING_SECRET_KEY=fake-secret-key",
"D:EXTERNAL_SETTING_DATA_MANAGER_ENABLED=1",
"D:DANGEROUS_DISABLE_HTTPS_REDIRECT=1"
]
python_files = "tests.py test_*.py *_tests.py"
......