diff --git a/backend/.gitignore b/backend/.gitignore index d4ad528..494c7c3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -7,3 +7,5 @@ __pycache__/ *.pyc *.pyo *.pyd + +docs/ diff --git a/backend/config/settings.py b/backend/config/settings.py index f8f2580..ec79bcb 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -11,7 +11,9 @@ https://docs.djangoproject.com/en/6.0/ref/settings/ """ import os +from datetime import timedelta from pathlib import Path + import environ # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -42,12 +44,13 @@ INSTALLED_APPS = [ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "rest_framework", # for API - "corsheaders", # for API and Frontend connect + "rest_framework", # for API + "corsheaders", # for API and Frontend connect + "users", # custom user app ] MIDDLEWARE = [ - "corsheaders.middleware.CorsMiddleware", # allow frontend to connect + "corsheaders.middleware.CorsMiddleware", # allow frontend to connect "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -93,6 +96,22 @@ DATABASES = { CORS_ALLOWED_ORIGINS = env.list("CORS_ALLOWED_ORIGINS") +AUTH_USER_MODEL = "users.User" + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",), + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), +} + # Password validation # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators diff --git a/backend/config/urls.py b/backend/config/urls.py index 9313520..7627c81 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -16,8 +16,9 @@ Including another URLconf """ from django.contrib import admin -from django.urls import path +from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), + path("api/auth/", include("users.urls")), # user related operations ] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b85df42..753bf2e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -13,3 +13,13 @@ dependencies = [ "psycopg2-binary>=2.9.11", "ruff>=0.15.9", ] + +[tool.ruff] +target-version = "py313" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "UP", "I"] + +[tool.ruff.lint.per-file-ignores] +"**/migrations/*" = ["E501"] # boilerplate - ignore diff --git a/backend/users/__init__.py b/backend/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/admin.py b/backend/users/admin.py new file mode 100644 index 0000000..846f6b4 --- /dev/null +++ b/backend/users/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/backend/users/apps.py b/backend/users/apps.py new file mode 100644 index 0000000..3ef1284 --- /dev/null +++ b/backend/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = "users" diff --git a/backend/users/migrations/0001_initial.py b/backend/users/migrations/0001_initial.py new file mode 100644 index 0000000..b0fb7d7 --- /dev/null +++ b/backend/users/migrations/0001_initial.py @@ -0,0 +1,94 @@ +# Generated by Django 6.0.4 on 2026-04-09 08:29 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField(blank=True, null=True, verbose_name="last login"), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined"), + ), + ("full_name", models.CharField(max_length=100)), + ( + "email", + models.EmailField(max_length=254, unique=True, verbose_name="email address"), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + ), + ] diff --git a/backend/users/migrations/__init__.py b/backend/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/models.py b/backend/users/models.py new file mode 100644 index 0000000..30b4562 --- /dev/null +++ b/backend/users/models.py @@ -0,0 +1,50 @@ +from django.contrib.auth.models import AbstractUser, BaseUserManager +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class CustomUserManager(BaseUserManager): + """ + General User Model + """ + + def create_user(self, email, password=None, **extra_fields): + if not email: + raise ValueError(_("The Email must be set")) + + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save() + return user + + def create_superuser(self, email, password, **extra_fields): + """ + Admin Model + """ + extra_fields.update({"is_staff": True, "is_superuser": True, "is_active": True}) + + return self.create_user(email, password, **extra_fields) + + +class User(AbstractUser): + """ + Database table structure. + """ + + # Reset default fields + username = None + first_name = None + last_name = None + + full_name = models.CharField(max_length=100) + email = models.EmailField(_("email address"), unique=True) + + objects = CustomUserManager() + + # Login uses email instead of username + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + def __str__(self): + return self.email diff --git a/backend/users/serializers.py b/backend/users/serializers.py new file mode 100644 index 0000000..5242eda --- /dev/null +++ b/backend/users/serializers.py @@ -0,0 +1,20 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +User = get_user_model() + + +class UserSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ("id", "email", "full_name", "password") + + def create(self, validated_data): + user = User.objects.create_user( + email=validated_data["email"], + password=validated_data["password"], + full_name=validated_data.get("full_name", ""), + ) + return user diff --git a/backend/users/tests.py b/backend/users/tests.py new file mode 100644 index 0000000..a39b155 --- /dev/null +++ b/backend/users/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/backend/users/urls.py b/backend/users/urls.py new file mode 100644 index 0000000..0c275d6 --- /dev/null +++ b/backend/users/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +from .views import MeView, RegisterView + +urlpatterns = [ + path("register/", RegisterView.as_view(), name="register"), + # Login and get access and refresh tokens + path("login/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + # Get a new access token using a refresh token + path("refresh/", TokenRefreshView.as_view(), name="token_refresh"), + # Get current user info + path("me/", MeView.as_view(), name="me"), +] diff --git a/backend/users/views.py b/backend/users/views.py new file mode 100644 index 0000000..03ae8a7 --- /dev/null +++ b/backend/users/views.py @@ -0,0 +1,21 @@ +from django.contrib.auth import get_user_model +from rest_framework import generics, permissions + +from .serializers import UserSerializer + +User = get_user_model() + + +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = (permissions.AllowAny,) + serializer_class = UserSerializer + + +class MeView(generics.RetrieveAPIView): + serializer_class = UserSerializer + permission_classes = (permissions.IsAuthenticated,) + + def get_object(self): + # Returns the user associated with the JWT token in the request + return self.request.user