diff --git a/pyproject.toml b/pyproject.toml index 3ae3d15..4b74c2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ "psycopg[binary]~=3.2.7", "django-types~=0.20.0", "django-ipware~=7.0.1", + "djangorestframework>=3.16.1", + "djangorestframework-stubs>=3.16.6", ] [tool.ruff.lint] diff --git a/trojstenid/settings.py b/trojstenid/settings.py index 2a8e6b7..b185da7 100644 --- a/trojstenid/settings.py +++ b/trojstenid/settings.py @@ -42,6 +42,8 @@ "django.contrib.sites", "django.contrib.staticfiles", "django.contrib.postgres", + "rest_framework", + "rest_framework.authtoken", "debug_toolbar", "trojstenid.users", "trojstenid.profiles", @@ -211,6 +213,13 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", + ], +} + LOGGING = { "version": 1, diff --git a/trojstenid/urls.py b/trojstenid/urls.py index 4eb1e36..e142ec2 100644 --- a/trojstenid/urls.py +++ b/trojstenid/urls.py @@ -30,6 +30,7 @@ path("accounts/profile/", views.ProfileView.as_view(), name="account_profile"), path("accounts/", include("allauth.urls")), path("oauth/", include("trojstenid.users.urls_oauth", namespace="oauth2_provider")), + path("api/", include("trojstenid.users.urls_api", namespace="api")), ] if settings.DEBUG: diff --git a/trojstenid/users/serializers.py b/trojstenid/users/serializers.py new file mode 100644 index 0000000..78ff2e5 --- /dev/null +++ b/trojstenid/users/serializers.py @@ -0,0 +1,63 @@ +from allauth.account.models import EmailAddress +from django.contrib.auth.models import Group +from django.urls import reverse +from rest_framework import serializers + +from trojstenid.users.models import User + + +class UserSchoolRecordSerializer(serializers.BaseSerializer): + def to_representation(self, instance): + return instance.to_dict() + + +class EmailAddressSerializer(serializers.ModelSerializer): + class Meta: # type:ignore + model = EmailAddress + fields = ["email", "verified", "primary"] + + +class UserSerializer(serializers.ModelSerializer): + current_school_record = serializers.SerializerMethodField() + groups = serializers.SlugRelatedField( + slug_field="name", many=True, queryset=Group.objects.get_queryset() + ) + emails = EmailAddressSerializer(many=True, source="emailaddress_set") + avatar = serializers.SerializerMethodField() + + class Meta: # type:ignore + model = User + fields = [ + "id", + "username", + "email", + "first_name", + "last_name", + "is_staff", + "is_active", + "date_joined", + "last_login", + "avatar", + "current_school_record", + "groups", + "emails", + ] + + def get_current_school_record(self, obj): + record = obj.get_current_school_record() + return UserSchoolRecordSerializer(record).data if record else None + + def get_avatar(self, obj): + return reverse("profile_avatar", kwargs={"user": obj.username}) + + +class UserListSerializer(UserSerializer): + class Meta(UserSerializer.Meta): + fields = [ + "id", + "username", + "email", + "first_name", + "last_name", + "avatar", + ] diff --git a/trojstenid/users/urls_api.py b/trojstenid/users/urls_api.py new file mode 100644 index 0000000..90e7de2 --- /dev/null +++ b/trojstenid/users/urls_api.py @@ -0,0 +1,16 @@ +from django.urls import path + +from trojstenid.users.views_api import ( + UserDetailView, + UserListView, +) + +app_name = "api" + +urlpatterns = [ + path("users/", UserListView.as_view(), name="user-list"), + path("users//", UserDetailView.as_view(), name="user-detail-id"), + path( + "users//", UserDetailView.as_view(), name="user-detail-username" + ), +] diff --git a/trojstenid/users/views_api.py b/trojstenid/users/views_api.py new file mode 100644 index 0000000..46bd62f --- /dev/null +++ b/trojstenid/users/views_api.py @@ -0,0 +1,60 @@ +from django.db.models import Q +from rest_framework import generics, permissions + +from trojstenid.users.models import User +from trojstenid.users.serializers import UserListSerializer, UserSerializer + + +class UserListView(generics.ListAPIView): + """ + List all users. + Search by username, email, or name using ?search=. + """ + + serializer_class = UserListSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + user: User = self.request.user # type:ignore + search_query = self.request.GET.get("search", None) + + if user.is_staff: + qs = User.objects.all() + else: + qs = User.objects.filter(id=user.id) + + if search_query: + qs = qs.filter( + Q(username__icontains=search_query) + | Q(email__icontains=search_query) + | Q(first_name__unaccent__icontains=search_query) + | Q(last_name__unaccent__icontains=search_query) + ) + + return qs + + +class UserDetailView(generics.RetrieveAPIView): + """ + Retrieve a single user by ID or username. + """ + + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + lookup_field = "id" + + def get_object(self): + user: User = self.request.user # type:ignore + lookup_value = self.kwargs.get(self.lookup_field) or self.kwargs.get("username") + + qs = User.objects.prefetch_related( + "userschoolrecord_set", "groups", "emailaddress_set" + ) + + if not user.is_staff: + qs = qs.filter(id=user.id) + + try: + return qs.get(id=int(lookup_value)) + except (ValueError, User.DoesNotExist): + return qs.get(username=lookup_value) diff --git a/uv.lock b/uv.lock index e9d978e..7f9f1c7 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.12.*" [[package]] @@ -263,6 +263,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/e7/0765f98e4953e26573512284995648c439b0eb252877b655683d28c90be2/django_recaptcha-4.1.0-py3-none-any.whl", hash = "sha256:463aa65967e973de466b28ce8e8b6abb2faffb1ce0c7faa1bf0e5b041e0497cb", size = 39473, upload-time = "2025-03-28T13:52:24.92Z" }, ] +[[package]] +name = "django-stubs" +version = "5.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-stubs-ext" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/75/97626224fd8f1787bb6f7f06944efcfddd5da7764bf741cf7f59d102f4a0/django_stubs-5.2.8.tar.gz", hash = "sha256:9bba597c9a8ed8c025cae4696803d5c8be1cf55bfc7648a084cbf864187e2f8b", size = 257709, upload-time = "2025-12-01T08:13:09.569Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3f/7c9543ad5ade5ce1d33d187a3abd82164570314ebee72c6206ab5c044ebf/django_stubs-5.2.8-py3-none-any.whl", hash = "sha256:a3c63119fd7062ac63d58869698d07c9e5ec0561295c4e700317c54e8d26716c", size = 508136, upload-time = "2025-12-01T08:13:07.963Z" }, +] + +[[package]] +name = "django-stubs-ext" +version = "5.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/a2/d67f4a5200ff7626b104eddceaf529761cba4ed318a73ffdb0677551be73/django_stubs_ext-5.2.8.tar.gz", hash = "sha256:b39938c46d7a547cd84e4a6378dbe51a3dd64d70300459087229e5fee27e5c6b", size = 6487, upload-time = "2025-12-01T08:12:37.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/2d/cb0151b780c3730cf0f2c0fcb1b065a5e88f877cf7a9217483c375353af1/django_stubs_ext-5.2.8-py3-none-any.whl", hash = "sha256:1dd5470c9675591362c78a157a3cf8aec45d0e7a7f0cf32f227a1363e54e0652", size = 9949, upload-time = "2025-12-01T08:12:36.397Z" }, +] + [[package]] name = "django-types" version = "0.20.0" @@ -284,6 +312,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/6a/6cb6deb5c38b785c77c3ba66f53051eada49205979c407323eb666930915/django_widget_tweaks-1.5.0-py3-none-any.whl", hash = "sha256:a41b7b2f05bd44d673d11ebd6c09a96f1d013ee98121cb98c384fe84e33b881e", size = 8960, upload-time = "2023-08-25T15:29:05.644Z" }, ] +[[package]] +name = "djangorestframework" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, +] + +[[package]] +name = "djangorestframework-stubs" +version = "3.16.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django-stubs" }, + { name = "requests" }, + { name = "types-pyyaml" }, + { name = "types-requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ed/6e16dbe8e79af9d2cdbcbd89553e59d18ecab7e9820ebb751085fc29fc0e/djangorestframework_stubs-3.16.6.tar.gz", hash = "sha256:b8d3e73604280f69c628ff7900f0e84703d9ff47cd050fccb5f751438e4c5813", size = 32274, upload-time = "2025-12-03T22:26:23.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/e3/d75f9e06d13d7fe8ed25473627c277992b7fad80747a4eaa1c7faa97e09e/djangorestframework_stubs-3.16.6-py3-none-any.whl", hash = "sha256:9bf2e5c83478edca3b8eb5ffd673737243ade16ce4b47b633a4ea62fe6924331", size = 56506, upload-time = "2025-12-03T22:26:21.88Z" }, +] + [[package]] name = "filelock" version = "3.18.0" @@ -572,6 +628,8 @@ dependencies = [ { name = "django-recaptcha" }, { name = "django-types" }, { name = "django-widget-tweaks" }, + { name = "djangorestframework" }, + { name = "djangorestframework-stubs" }, { name = "gunicorn" }, { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, @@ -598,6 +656,8 @@ requires-dist = [ { name = "django-recaptcha", specifier = "~=4.1.0" }, { name = "django-types", specifier = "~=0.20.0" }, { name = "django-widget-tweaks", specifier = "~=1.5.0" }, + { name = "djangorestframework", specifier = ">=3.16.1" }, + { name = "djangorestframework-stubs", specifier = ">=3.16.6" }, { name = "gunicorn", specifier = "~=23.0.0" }, { name = "pillow", specifier = "~=11.2.1" }, { name = "psycopg", extras = ["binary"], specifier = "~=3.2.7" }, @@ -620,6 +680,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/df/9bb191e6e1662e8697d56fad044381df3bce2f5aabc57334063dc5a48907/types_psycopg2-2.9.21.20250718-py3-none-any.whl", hash = "sha256:bcf085d4293bda48f5943a46dadf0389b2f98f7e8007722f7e1c12ee0f541858", size = 24878, upload-time = "2025-07-18T03:22:37.798Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250913" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1"