diff --git a/.gitignore b/.gitignore index 5031041..d975346 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ env/ db.sqlite3 media/ staticfiles/ +*/migrations/__pycache__/ # Tools/IDE .vscode/ diff --git a/README.md b/README.md index f3a5de6..016a5ff 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # NOJ new back_end -[README for developers](docs/developers.MD) \ No newline at end of file +[README for backend_developers](docs/developers.MD) +[README for frontend](docs/frontend.MD) \ No newline at end of file diff --git a/back_end/__pycache__/__init__.cpython-310.pyc b/back_end/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index a0a6833..0000000 Binary files a/back_end/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/back_end/__pycache__/settings.cpython-310.pyc b/back_end/__pycache__/settings.cpython-310.pyc deleted file mode 100644 index d40a5bf..0000000 Binary files a/back_end/__pycache__/settings.cpython-310.pyc and /dev/null differ diff --git a/back_end/settings.py b/back_end/settings.py index 60065df..7e31d3a 100644 --- a/back_end/settings.py +++ b/back_end/settings.py @@ -44,6 +44,7 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'user.apps.UserConfig', "rest_framework", "corsheaders", "user", @@ -54,6 +55,13 @@ "submissions", ] +AUTH_USER_MODEL = 'user.User' +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ) +} + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -130,6 +138,8 @@ # https://docs.djangoproject.com/en/5.2/howto/static-files/ STATIC_URL = 'static/' +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field diff --git a/back_end/urls.py b/back_end/urls.py index 9020c65..fc6279f 100644 --- a/back_end/urls.py +++ b/back_end/urls.py @@ -15,8 +15,14 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static urlpatterns = [ path('admin/', admin.site.urls), + path('auth/', include('user.urls')), ] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/docs/frontend.MD b/docs/frontend.MD new file mode 100644 index 0000000..16f2964 --- /dev/null +++ b/docs/frontend.MD @@ -0,0 +1,229 @@ +# 後端 API 說明(提供給前端) + +本專案後端使用 **Django + Django REST Framework + Simple JWT**,所有 API 採 **JSON** 傳輸。 + +> 本文件僅涵蓋「使用者認證」端點;**目前不啟用 reCAPTCHA**。 + +--- + +## 使用者註冊 API + +**路徑**:`POST /auth/signup/` + +**說明**:建立新使用者帳號。 + +### 請求格式(JSON) + +| 欄位名 | 型別 | 必填 | 說明 | +| ------------ | ------ | :-: | ----------------------------------------------------------- | +| `username` | string | ✅ | 最多 150 字元,**唯一** | +| `password` | string | ✅ | 使用者密碼(後端會雜湊儲存) | +| `email` | string | ✅ | 有效 Email,**唯一** | +| `real_name` | string | ✅ | 最多 150 字元 | +| `identity` | string | ✅ | 身分:`teacher` / `admin` / `student`(**必填**;模型預設仍為 `student`) | +| `student_id` | string | ❌ | 最多 50 字元(唯一)。若無可省略 | +| `bio` | string | ❌ | 自我介紹 | + +**請求範例**: + +```json +{ + "username": "momo", + "email": "momo@example.com", + "password": "abc12345", + "real_name": "小桃", + "identity": "student", + "student_id": "1160001", + "bio": "我愛娃娃" +} +``` + +**成功回應(201, JSON)** *回傳欄位可能依後端序列化調整,`password` 不會回傳: + +```json +{ + "id": "b1b6c3f4-6e9b-4bfa-9a78-9e1d1e6b8a21", + "username": "momo", + "email": "momo@example.com", + "real_name": "小桃", + "identity": "student", + "date_joined": "2025-10-03T07:15:14.123Z", + "last_login": null +} +``` + +**失敗回應(400, JSON)** + +* 已存在: + + ```json + {"username":["這個 username 在 user 已經存在。"],"email":["這個 email 在 user 已經存在。"]} + ``` +* `username` 未填: + + ```json + {"username":["此為必需欄位。"]} + ``` +* Email 格式錯誤: + + ```json + {"email":["請輸入有效的電子郵件地址。"]} + ``` +* `identity` 未填或值錯誤: + + ```json + {"identity":["此為必需欄位。或值必須為 teacher/admin/student 之一"]} + ``` + +**cURL 測試**: + +```bash +curl -X POST http://127.0.0.1:8000/auth/signup/ \ + -H "Content-Type: application/json" \ + -d '{ + "username":"momo", + "email":"momo@example.com", + "password":"abc12345", + "real_name":"小桃", + "identity":"student" + }' +``` + +--- + +## 使用者登入 API + +**路徑**:`POST /auth/session/` + +**說明**:以帳號密碼登入,取得 JWT。 + +### 請求格式(JSON) + +| 欄位名 | 型別 | 必填 | 說明 | +| ---------- | ------ | :-: | --------------------- | +| `username` | string | ✅ | 登入帳號(目前以 username 登入) | +| `password` | string | ✅ | 密碼 | + +**成功請求範例**: + +```json +{ "username": "momo", "password": "abc12345" } +``` + +**成功回應(200, JSON)** + +```json +{ "refresh": "", "access": "" } +``` + +**失敗回應** + +* 認證失敗(401): + + ```json + { "detail": "No active account found with the given credentials" } + ``` +* 缺少欄位(400): + + ```json + { "username": ["This field is required."] } + ``` + +**cURL 測試**: + +```bash +curl -X POST http://127.0.0.1:8000/auth/session/ \ + -H "Content-Type: application/json" \ + -d '{"username":"momo","password":"abc12345"}' +``` + +> 欄位說明: +> +> * `access`:短效 token,之後的 API 需帶 `Authorization: Bearer `。 +> * `refresh`:用於在 access 過期時換取新 access。 + +--- + +## 取得新 access token(refresh 機制) + +**路徑**:`POST /auth/refresh` + +**請求(JSON)** + +```json +{ "refresh": "<你的 refresh token>" } +``` + +**成功回應(200, JSON)** + +```json +{ "access": "" } +``` + +**失敗(refresh 無效/過期)**: + +```json +{ "detail": "Token is invalid or expired", "code": "token_not_valid" } +``` + +**cURL 測試**: + +```bash +curl -X POST http://127.0.0.1:8000/auth/refresh \ + -H "Content-Type: application/json" \ + -d '{"refresh":"<你的 refresh token>"}' +``` + +--- + +## 取得自己的資料 + +**路徑**:`GET /auth/me` + +**Headers**:`Authorization: Bearer ` + +**成功回應(200, JSON)**(示例) + +```json +{ + "id": "b1b6c3f4-6e9b-4bfa-9a78-9e1d1e6b8a21", + "username": "momo", + "email": "momo@example.com", + "real_name": "小桃", + "identity": "student", + "date_joined": "2025-10-03T07:15:14.123Z", + "last_login": "2025-10-03T07:20:18.000Z", + "profile": { + "student_id": "1160001", + "bio": "我愛娃娃", + "avatar": null, + "email_verified": false, + "updated_at": "2025-10-03T07:15:14.123Z" + } +} +``` + +**失敗回應(401)**: + +```json +{ "detail": "Authentication credentials were not provided." } +``` + +**cURL 測試**: + +```bash +curl http://127.0.0.1:8000/auth/me -H "Authorization: Bearer " +``` + +--- + +## 附錄:常見錯誤總表 + +| 錯誤情境 | HTTP 狀態 | 回應訊息 | +| ------------------- | :-----: | ----------------------------------------------------------------------------- | +| 帳密錯誤(登入) | 401 | `{"detail":"No active account found with the given credentials"}` | +| 未帶 access(/auth/me) | 401 | `{"detail":"Authentication credentials were not provided."}` | +| 帳號/信箱已存在(註冊) | 400 | `{"username":["這個 username 在 user 已經存在。"],"email":["這個 email 在 user 已經存在。"]}` | +| Email 格式錯誤(註冊) | 400 | `{"email":["請輸入有效的電子郵件地址。"]}` | +| 欄位缺漏(註冊/登入) | 400 | `{"username":["This field is required."]}` | +| `identity` 缺漏或值錯誤 | 400 | `{"identity":["此為必需欄位。或值必須為 teacher/admin/student 之一"]}` | diff --git a/user/admin.py b/user/admin.py index 8c38f3f..79bc766 100644 --- a/user/admin.py +++ b/user/admin.py @@ -1,3 +1,25 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from .models import User, UserProfile -# Register your models here. +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + fieldsets = ( + (None, {'fields': ('username', 'password')}), + ('Personal info', {'fields': ('real_name', 'email')}), + ('Roles', {'fields': ('identity',)}), + ('Permissions', {'fields': ('is_active','is_staff','is_superuser','groups','user_permissions')}), + ('Important dates', {'fields': ('last_login','date_joined')}), + ) + add_fieldsets = ( + (None, {'classes': ('wide',), + 'fields': ('username','email','real_name','identity','password1','password2')}), + ) + list_display = ('username','email','real_name','identity','is_staff') + search_fields = ('username','email','real_name') + ordering = ('date_joined',) + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ('user','student_id','email_verified','updated_at') + search_fields = ('user__username','student_id') \ No newline at end of file diff --git a/user/apps.py b/user/apps.py index 36cce4c..7bb5785 100644 --- a/user/apps.py +++ b/user/apps.py @@ -4,3 +4,6 @@ class UserConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'user' + + def ready(self): + from . import signals \ No newline at end of file diff --git a/user/migrations/0001_initial.py b/user/migrations/0001_initial.py new file mode 100644 index 0000000..e986ccd --- /dev/null +++ b/user/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.6 on 2025-10-03 03:21 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +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=[ + ('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')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('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')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=254, unique=True)), + ('real_name', models.CharField(max_length=150)), + ('identity', models.CharField(choices=[('teacher', 'Teacher'), ('admin', 'Admin'), ('student', 'Student')], default='student', max_length=16)), + ('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, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), + ('student_id', models.CharField(blank=True, max_length=50, null=True, unique=True)), + ('bio', models.TextField(blank=True)), + ('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')), + ('email_verified', models.BooleanField(default=False)), + ('updated_at', models.DateTimeField(default=django.utils.timezone.now)), + ], + ), + ] diff --git a/user/models.py b/user/models.py index 71a8362..80cb8d6 100644 --- a/user/models.py +++ b/user/models.py @@ -1,3 +1,47 @@ +import uuid from django.db import models +from django.contrib.auth.models import AbstractUser +from django.utils import timezone +from django.conf import settings -# Create your models here. +class User(AbstractUser): + """ + 自訂使用者: + - 主鍵改成 UUID + - 保留 username(你表格有 username) + - 新增 real_name、identity + - email 設為 unique(和你需求一致) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + + # Django AbstractUser 已有: username, password, email(非唯一), first_name, last_name, is_staff... + email = models.EmailField(max_length=254, unique=True) + real_name = models.CharField(max_length=150) + + class Identity(models.TextChoices): + TEACHER = 'teacher', 'Teacher' + ADMIN = 'admin', 'Admin' + STUDENT = 'student', 'Student' + identity = models.CharField(max_length=16, choices=Identity.choices, default=Identity.STUDENT) + + # 對齊你的欄位:date_joined & last_login 其實 AbstractUser 已內建 + # date_joined = models.DateTimeField(default=timezone.now) + # last_login = models.DateTimeField(blank=True, null=True) + + def __str__(self): + return f"{self.username} ({self.identity})" + + +class UserProfile(models.Model): + """ + 對應 user_profiles 表 + """ + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, primary_key=True) + student_id = models.CharField(max_length=50, unique=True, null=True, blank=True) + bio = models.TextField(blank=True) + avatar = models.ImageField(upload_to='avatars/', null=True, blank=True) # 需要 Pillow + email_verified = models.BooleanField(default=False) + updated_at = models.DateTimeField(default=timezone.now) + + def __str__(self): + return f"Profile<{self.user.username}>" \ No newline at end of file diff --git a/user/serializers.py b/user/serializers.py new file mode 100644 index 0000000..d4d0ba5 --- /dev/null +++ b/user/serializers.py @@ -0,0 +1,69 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model +from .models import UserProfile + +User = get_user_model() + +class RegisterSerializer(serializers.ModelSerializer): + identity = serializers.ChoiceField( + choices=[c.value for c in User.Identity], # ['teacher','admin','student'] + required=True + ) + student_id = serializers.CharField(required=False, allow_blank=True, allow_null=True) + bio = serializers.CharField(required=False, allow_blank=True) + + class Meta: + model = User + fields = ['id','username','email','password','real_name','identity','student_id','bio'] + extra_kwargs = {'password': {'write_only': True}} + + def create(self, validated_data): + student_id = validated_data.pop('student_id', None) + bio = validated_data.pop('bio', '') + password = validated_data.pop('password') + + user = User(**validated_data) + user.set_password(password) + user.save() + + profile, _ = UserProfile.objects.get_or_create(user=user) + if student_id is not None: + profile.student_id = student_id + if bio: + profile.bio = bio + profile.save() + return user + + +class MeSerializer(serializers.ModelSerializer): + class ProfileSerializer(serializers.Serializer): + student_id = serializers.CharField() + bio = serializers.CharField() + avatar = serializers.ImageField() + email_verified = serializers.BooleanField() + updated_at = serializers.DateTimeField() + + profile = serializers.SerializerMethodField() + + class Meta: + model = User + fields = ['id','username','email','real_name','identity','date_joined','last_login','profile'] + + def get_profile(self, obj): + try: + p = obj.userprofile + return { + "student_id": p.student_id, + "bio": p.bio, + "avatar": p.avatar.url if p.avatar else None, + "email_verified": p.email_verified, + "updated_at": p.updated_at, + } + except UserProfile.DoesNotExist: + return { + "student_id": "", + "bio": "", + "avatar": None, + "email_verified": False, + "updated_at": None, + } \ No newline at end of file diff --git a/user/signals.py b/user/signals.py new file mode 100644 index 0000000..68b2d98 --- /dev/null +++ b/user/signals.py @@ -0,0 +1,9 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.conf import settings +from .models import UserProfile + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def create_profile(sender, instance, created, **kwargs): + if created: + UserProfile.objects.create(user=instance) \ No newline at end of file diff --git a/user/urls.py b/user/urls.py new file mode 100644 index 0000000..d785bd7 --- /dev/null +++ b/user/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from .views import RegisterView, MeView + +urlpatterns = [ + path('signup/', RegisterView.as_view(), name='register'), + path('session/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('me/', MeView.as_view(), name='me'), +] diff --git a/user/views.py b/user/views.py index 91ea44a..2a8d12c 100644 --- a/user/views.py +++ b/user/views.py @@ -1,3 +1,18 @@ from django.shortcuts import render +from rest_framework import generics, permissions +from rest_framework.response import Response +from rest_framework.views import APIView +from django.contrib.auth import get_user_model +from .serializers import RegisterSerializer, MeSerializer -# Create your views here. +User = get_user_model() + +class RegisterView(generics.CreateAPIView): + queryset = User.objects.all() + serializer_class = RegisterSerializer + permission_classes = [permissions.AllowAny] + +class MeView(APIView): + permission_classes = [permissions.IsAuthenticated] + def get(self, request): + return Response(MeSerializer(request.user).data)