Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ env/
db.sqlite3
media/
staticfiles/
*/migrations/__pycache__/

# Tools/IDE
.vscode/
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
# NOJ new back_end

[README for developers](docs/developers.MD)
[README for backend_developers](docs/developers.MD)
[README for frontend](docs/frontend.MD)
Binary file removed back_end/__pycache__/__init__.cpython-310.pyc
Binary file not shown.
Binary file removed back_end/__pycache__/settings.cpython-310.pyc
Binary file not shown.
10 changes: 10 additions & 0 deletions back_end/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'user.apps.UserConfig',
"rest_framework",
"corsheaders",
"user",
Expand All @@ -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',
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion back_end/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
229 changes: 229 additions & 0 deletions docs/frontend.MD
Original file line number Diff line number Diff line change
@@ -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": "<refresh.jwt>", "access": "<access.jwt>" }
```

**失敗回應**

* 認證失敗(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 <access>`。
> * `refresh`:用於在 access 過期時換取新 access。

---

## 取得新 access token(refresh 機制)

**路徑**:`POST /auth/refresh`

**請求(JSON)**

```json
{ "refresh": "<你的 refresh token>" }
```

**成功回應(200, JSON)**

```json
{ "access": "<new.access.jwt>" }
```

**失敗(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 <access>`

**成功回應(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 <access>"
```

---

## 附錄:常見錯誤總表

| 錯誤情境 | 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 之一"]}` |
24 changes: 23 additions & 1 deletion user/admin.py
Original file line number Diff line number Diff line change
@@ -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')
3 changes: 3 additions & 0 deletions user/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
class UserConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'user'

def ready(self):
from . import signals
60 changes: 60 additions & 0 deletions user/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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)),
],
),
]
Loading