feat: add school management auth and student CRUD UI

This commit is contained in:
Sina
2026-09-24 23:34:21 +03:30
parent 12e9efcb5c
commit 8a196f8c4f
20 changed files with 326 additions and 9 deletions

24
README.md Normal file
View File

@@ -0,0 +1,24 @@
# مدرسه‌یار
سامانه‌ی مدیریت مدرسه و پرونده‌ی دانش‌آموزان با Django و Class-Based Views.
## اجرا
```powershell
py -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python manage.py migrate
python manage.py runserver
```
سپس به `http://127.0.0.1:8000/` بروید و از گزینه‌ی ثبت‌نام، حساب مدیر را بسازید.
## قابلیت‌ها
- ثبت‌نام، ورود و خروج مدیر مدرسه با سیستم احراز هویت Django
- داشبورد آماری و فهرست دانش‌آموزان
- ثبت، ویرایش و حذف دانش‌آموز با اعتبارسنجی سن
- ذخیره‌ی دائمی اطلاعات در SQLite
- رابط کاربری راست‌به‌چپ، واکنش‌گرا و فارسی
- تمام viewهای اصلی با CBV

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
Django>=5.2,<6

View File

@@ -37,6 +37,7 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'students',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -54,7 +55,7 @@ ROOT_URLCONF = 'school_manager.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@@ -102,9 +103,9 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/ # https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'fa-ir'
TIME_ZONE = 'UTC' TIME_ZONE = 'Asia/Tehran'
USE_I18N = True USE_I18N = True
@@ -115,6 +116,11 @@ USE_TZ = True
# https://docs.djangoproject.com/en/5.2/howto/static-files/ # https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'dashboard'
LOGOUT_REDIRECT_URL = 'login'
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@@ -15,8 +15,9 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('', include('students.urls')),
] ]

1
static/css/style.css Normal file

File diff suppressed because one or more lines are too long

16
static/js/app.js Normal file
View File

@@ -0,0 +1,16 @@
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.delete-action').forEach((link) => {
link.addEventListener('click', (event) => {
if (!window.confirm('آیا از حذف این پرونده مطمئن هستید؟')) {
event.preventDefault();
}
});
});
document.querySelectorAll('.message').forEach((message) => {
window.setTimeout(() => {
message.style.transition = 'opacity .3s ease';
message.style.opacity = '0';
}, 3500);
});
});

View File

@@ -1,3 +1,10 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import Student
@admin.register(Student)
class StudentAdmin(admin.ModelAdmin):
list_display = ('first_name', 'last_name', 'age', 'grade', 'created_at')
search_fields = ('first_name', 'last_name')
list_filter = ('grade',)

20
students/forms.py Normal file
View File

@@ -0,0 +1,20 @@
from django import forms
from .models import Student
class StudentForm(forms.ModelForm):
class Meta:
model = Student
fields = ('first_name', 'last_name', 'age', 'grade')
widgets = {
'first_name': forms.TextInput(attrs={'placeholder': 'مثلاً: سارا'}),
'last_name': forms.TextInput(attrs={'placeholder': 'مثلاً: احمدی'}),
'age': forms.NumberInput(attrs={'min': 5, 'max': 25, 'placeholder': 'سن'}),
}
def clean_age(self):
age = self.cleaned_data['age']
if not 5 <= age <= 25:
raise forms.ValidationError('سن باید بین ۵ تا ۲۵ سال باشد.')
return age

View File

@@ -0,0 +1,31 @@
# Generated by Django 5.2.17 on 2026-09-24 20:02
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Student',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('first_name', models.CharField(max_length=80, verbose_name='نام')),
('last_name', models.CharField(max_length=120, verbose_name='نام خانوادگی')),
('age', models.PositiveSmallIntegerField(verbose_name='سن')),
('grade', models.CharField(choices=[('اول', 'پایه اول'), ('دوم', 'پایه دوم'), ('سوم', 'پایه سوم'), ('چهارم', 'پایه چهارم'), ('پنجم', 'پایه پنجم'), ('ششم', 'پایه ششم'), ('هفتم', 'پایه هفتم'), ('هشتم', 'پایه هشتم'), ('نهم', 'پایه نهم'), ('دهم', 'پایه دهم'), ('یازدهم', 'پایه یازدهم'), ('دوازدهم', 'پایه دوازدهم')], max_length=20, verbose_name='پایه تحصیلی')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='تاریخ ثبت')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='آخرین ویرایش')),
],
options={
'verbose_name': 'دانش\u200cآموز',
'verbose_name_plural': 'دانش\u200cآموزان',
'ordering': ['-created_at'],
},
),
]

View File

@@ -1,3 +1,33 @@
from django.db import models from django.db import models
# Create your models here.
class Student(models.Model):
GRADE_CHOICES = [
('اول', 'پایه اول'),
('دوم', 'پایه دوم'),
('سوم', 'پایه سوم'),
('چهارم', 'پایه چهارم'),
('پنجم', 'پایه پنجم'),
('ششم', 'پایه ششم'),
('هفتم', 'پایه هفتم'),
('هشتم', 'پایه هشتم'),
('نهم', 'پایه نهم'),
('دهم', 'پایه دهم'),
('یازدهم', 'پایه یازدهم'),
('دوازدهم', 'پایه دوازدهم'),
]
first_name = models.CharField('نام', max_length=80)
last_name = models.CharField('نام خانوادگی', max_length=120)
age = models.PositiveSmallIntegerField('سن')
grade = models.CharField('پایه تحصیلی', max_length=20, choices=GRADE_CHOICES)
created_at = models.DateTimeField('تاریخ ثبت', auto_now_add=True)
updated_at = models.DateTimeField('آخرین ویرایش', auto_now=True)
class Meta:
ordering = ['-created_at']
verbose_name = 'دانش‌آموز'
verbose_name_plural = 'دانش‌آموزان'
def __str__(self):
return f'{self.first_name} {self.last_name}'

View File

@@ -1,3 +1,42 @@
from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
# Create your tests here. from .models import Student
class StudentManagementTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(username='manager', password='Strong-pass-123')
self.student = Student.objects.create(first_name='سارا', last_name='احمدی', age=10, grade='چهارم')
def test_dashboard_requires_login(self):
response = self.client.get(reverse('dashboard'))
self.assertRedirects(response, f'{reverse("login")}?next={reverse("dashboard")}')
def test_manager_can_create_update_and_delete_student(self):
self.client.force_login(self.user)
create_response = self.client.post(reverse('student_add'), {
'first_name': 'علی', 'last_name': 'رضایی', 'age': 11, 'grade': 'پنجم',
})
self.assertRedirects(create_response, reverse('dashboard'))
new_student = Student.objects.get(first_name='علی')
update_response = self.client.post(reverse('student_edit', args=[new_student.pk]), {
'first_name': 'علی', 'last_name': 'کریمی', 'age': 12, 'grade': 'ششم',
})
self.assertRedirects(update_response, reverse('dashboard'))
new_student.refresh_from_db()
self.assertEqual(new_student.last_name, 'کریمی')
delete_response = self.client.post(reverse('student_delete', args=[new_student.pk]))
self.assertRedirects(delete_response, reverse('dashboard'))
self.assertFalse(Student.objects.filter(pk=new_student.pk).exists())
def test_invalid_age_is_rejected(self):
self.client.force_login(self.user)
response = self.client.post(reverse('student_add'), {
'first_name': 'نوید', 'last_name': 'محمدی', 'age': 3, 'grade': 'اول',
})
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'سن باید بین ۵ تا ۲۵ سال باشد')

21
students/urls.py Normal file
View File

@@ -0,0 +1,21 @@
from django.urls import path
from .views import (
DashboardView,
ManagerLoginView,
ManagerLogoutView,
ManagerSignupView,
StudentCreateView,
StudentDeleteView,
StudentUpdateView,
)
urlpatterns = [
path('', DashboardView.as_view(), name='dashboard'),
path('login/', ManagerLoginView.as_view(), name='login'),
path('signup/', ManagerSignupView.as_view(), name='signup'),
path('logout/', ManagerLogoutView.as_view(), name='logout'),
path('students/add/', StudentCreateView.as_view(), name='student_add'),
path('students/<int:pk>/edit/', StudentUpdateView.as_view(), name='student_edit'),
path('students/<int:pk>/delete/', StudentDeleteView.as_view(), name='student_delete'),
]

View File

@@ -1,3 +1,65 @@
from django.shortcuts import render from django.contrib.auth import login
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import reverse_lazy
from django.views.generic import CreateView, DeleteView, ListView, TemplateView, UpdateView
# Create your views here. from .forms import StudentForm
from .models import Student
class DashboardView(LoginRequiredMixin, ListView):
model = Student
template_name = 'students/dashboard.html'
context_object_name = 'students'
def get_queryset(self):
return Student.objects.all()
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['total_students'] = Student.objects.count()
context['average_age'] = round(sum(student.age for student in context['students']) / context['total_students'], 1) if context['total_students'] else 0
context['grade_count'] = Student.objects.values('grade').distinct().count()
return context
class ManagerLoginView(LoginView):
template_name = 'registration/login.html'
redirect_authenticated_user = True
class ManagerSignupView(CreateView):
form_class = UserCreationForm
template_name = 'registration/signup.html'
success_url = reverse_lazy('dashboard')
def form_valid(self, form):
response = super().form_valid(form)
login(self.request, self.object)
return response
class ManagerLogoutView(LogoutView):
next_page = reverse_lazy('login')
class StudentCreateView(LoginRequiredMixin, CreateView):
model = Student
form_class = StudentForm
template_name = 'students/student_form.html'
success_url = reverse_lazy('dashboard')
class StudentUpdateView(LoginRequiredMixin, UpdateView):
model = Student
form_class = StudentForm
template_name = 'students/student_form.html'
success_url = reverse_lazy('dashboard')
class StudentDeleteView(LoginRequiredMixin, DeleteView):
model = Student
template_name = 'students/student_confirm_delete.html'
success_url = reverse_lazy('dashboard')

38
templates/base.html Normal file
View File

@@ -0,0 +1,38 @@
{% load static %}
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}مدرسه‌یار{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{% static 'css/style.css' %}">
</head>
<body>
<div class="app-shell">
{% if user.is_authenticated %}
<aside class="sidebar">
<a class="brand" href="{% url 'dashboard' %}">
<span class="brand-mark">م</span>
<span><strong>مدرسه‌یار</strong><small>مدیریت هوشمند مدرسه</small></span>
</a>
<nav class="main-nav">
<a class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}" href="{% url 'dashboard' %}"><span>▦</span> داشبورد</a>
<a class="nav-link {% if request.resolver_match.url_name == 'student_add' %}active{% endif %}" href="{% url 'student_add' %}"><span>+</span> ثبت دانش‌آموز</a>
</nav>
<div class="sidebar-footer">
<div class="profile-mini"><span class="avatar">{{ user.username|first|upper }}</span><span><b>{{ user.username }}</b><small>مدیر مدرسه</small></span></div>
<form action="{% url 'logout' %}" method="post">{% csrf_token %}<button class="logout-link" type="submit">خروج از حساب <span>↪</span></button></form>
</div>
</aside>
{% endif %}
<main class="main-content {% if not user.is_authenticated %}full-content{% endif %}">
{% if messages %}<div class="messages">{% for message in messages %}<div class="message {{ message.tags }}">{{ message }}</div>{% endfor %}</div>{% endif %}
{% block content %}{% endblock %}
</main>
</div>
<script src="{% static 'js/app.js' %}"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
{% for field in form %}<div class="field-group">{{ field.label_tag }}{{ field }}{% if field.help_text %}<small class="help-text">{{ field.help_text|safe }}</small>{% endif %}{% for error in field.errors %}<small class="error-text">{{ error }}</small>{% endfor %}</div>{% endfor %}{% if form.non_field_errors %}<div class="error-text">{{ form.non_field_errors }}</div>{% endif %}

View File

@@ -0,0 +1,5 @@
{% extends 'base.html' %}
{% block title %}ورود مدیر | مدرسه‌یار{% endblock %}
{% block content %}
<section class="auth-layout"><div class="auth-intro"><span class="eyebrow">پنل مدیریت مدرسه</span><h1>مدرسه را با آرامش مدیریت کنید.</h1><p>اطلاعات دانش‌آموزان را یک‌جا، دقیق و همیشه در دسترس داشته باشید.</p><div class="intro-note"><span>✦</span><span>فضایی امن برای مدیریت روزانه مدرسه</span></div></div><div class="auth-card"><div class="auth-icon">◈</div><h2>خوش آمدید</h2><p class="muted">برای ورود به پنل مدیریت اطلاعات خود را وارد کنید.</p><form method="post" class="form-stack">{% csrf_token %}{% include 'registration/form_fields.html' with form=form %}<button class="button button-primary button-wide" type="submit">ورود به داشبورد <span>←</span></button></form><p class="auth-footer">حساب مدیر ندارید؟ <a href="{% url 'signup' %}">ثبت‌نام کنید</a></p></div></section>
{% endblock %}

View File

@@ -0,0 +1,5 @@
{% extends 'base.html' %}
{% block title %}ثبت‌نام مدیر | مدرسه‌یار{% endblock %}
{% block content %}
<section class="auth-layout"><div class="auth-intro"><span class="eyebrow">شروعی ساده و سریع</span><h1>پنل اختصاصی مدرسه‌تان را بسازید.</h1><p>با چند قدم کوتاه، مدیریت اطلاعات دانش‌آموزان را منظم‌تر کنید.</p></div><div class="auth-card"><div class="auth-icon">✦</div><h2>ساخت حساب مدیر</h2><p class="muted">اطلاعات ورود خود را برای استفاده از پنل بسازید.</p><form method="post" class="form-stack">{% csrf_token %}{% include 'registration/form_fields.html' with form=form %}<button class="button button-primary button-wide" type="submit">ساخت حساب و ورود <span>←</span></button></form><p class="auth-footer">قبلاً ثبت‌نام کرده‌اید؟ <a href="{% url 'login' %}">وارد شوید</a></p></div></section>
{% endblock %}

View File

@@ -0,0 +1,3 @@
{% extends 'base.html' %}
{% block title %}داشبورد | مدرسه‌یار{% endblock %}
{% block content %}<header class="topbar"><div><span class="eyebrow">مرکز کنترل</span><h1>سلام، {{ user.username }} <span class="wave">✦</span></h1><p>امروز چه چیزی را مدیریت می‌کنیم؟</p></div><a class="button button-primary" href="{% url 'student_add' %}"><span>+</span> ثبت دانش‌آموز</a></header><section class="stats-grid"><article class="stat-card stat-main"><div class="stat-icon">♧</div><div><span>کل دانش‌آموزان</span><strong>{{ total_students }}</strong><small>پرونده ثبت‌شده در مدرسه</small></div></article><article class="stat-card"><div class="stat-icon soft-blue">⌁</div><div><span>میانگین سنی</span><strong>{{ average_age }}</strong><small>سال</small></div></article><article class="stat-card"><div class="stat-icon soft-gold">◌</div><div><span>پایه‌های فعال</span><strong>{{ grade_count }}</strong><small>پایه تحصیلی</small></div></article></section><section class="list-section"><div class="section-heading"><div><h2>فهرست دانش‌آموزان</h2><p>آخرین اطلاعات ثبت‌شده در سیستم</p></div><span class="count-badge">{{ total_students }} نفر</span></div>{% if students %}<div class="table-wrap"><table><thead><tr><th>دانش‌آموز</th><th>سن</th><th>پایه تحصیلی</th><th>تاریخ ثبت</th><th><span class="sr-only">عملیات</span></th></tr></thead><tbody>{% for student in students %}<tr><td><div class="student-cell"><span class="student-avatar avatar-{{ forloop.counter|divisibleby:3|yesno:'pink,blue' }}">{{ student.first_name|first }}</span><div><strong>{{ student.first_name }} {{ student.last_name }}</strong><small>شناسه #{{ student.pk|stringformat:'04d' }}</small></div></div></td><td>{{ student.age }} سال</td><td><span class="grade-pill">{{ student.get_grade_display }}</span></td><td>{{ student.created_at|date:'Y/m/d' }}</td><td><div class="row-actions"><a href="{% url 'student_edit' student.pk %}" title="ویرایش">✎</a><a class="delete-action" href="{% url 'student_delete' student.pk %}" title="حذف">⌫</a></div></td></tr>{% endfor %}</tbody></table></div>{% else %}<div class="empty-state"><div class="empty-icon">♧</div><h3>هنوز دانش‌آموزی ثبت نشده است</h3><p>اولین پرونده را ثبت کنید تا فهرست مدرسه شکل بگیرد.</p><a class="button button-primary" href="{% url 'student_add' %}">ثبت اولین دانش‌آموز</a></div>{% endif %}</section>{% endblock %}

View File

@@ -0,0 +1,3 @@
{% extends 'base.html' %}
{% block title %}حذف دانش‌آموز | مدرسه‌یار{% endblock %}
{% block content %}<section class="confirm-panel"><div class="danger-icon">!</div><span class="eyebrow">حذف پرونده</span><h1>حذف {{ object }}؟</h1><p>این اطلاعات برای همیشه حذف می‌شود و امکان بازگردانی آن وجود ندارد.</p><form method="post" class="form-actions">{% csrf_token %}<a class="button button-ghost" href="{% url 'dashboard' %}">انصراف</a><button class="button button-danger" type="submit">بله، حذف شود</button></form></section>{% endblock %}

View File

@@ -0,0 +1,3 @@
{% extends 'base.html' %}
{% block title %}{% if object %}ویرایش دانش‌آموز{% else %}ثبت دانش‌آموز{% endif %} | مدرسه‌یار{% endblock %}
{% block content %}<div class="page-header"><div><span class="eyebrow">اطلاعات دانش‌آموز</span><h1>{% if object %}ویرایش اطلاعات{% else %}ثبت دانش‌آموز جدید{% endif %}</h1><p>اطلاعات را با دقت وارد کنید تا پرونده دانش‌آموز کامل بماند.</p></div><a class="button button-ghost" href="{% url 'dashboard' %}">بازگشت به فهرست</a></div><section class="form-panel"><form method="post" class="student-form">{% csrf_token %}<div class="form-grid">{% for field in form %}<div class="field-group">{{ field.label_tag }}{{ field }}{% for error in field.errors %}<small class="error-text">{{ error }}</small>{% endfor %}</div>{% endfor %}</div><div class="form-actions"><a class="button button-ghost" href="{% url 'dashboard' %}">انصراف</a><button class="button button-primary" type="submit">{% if object %}ذخیره تغییرات{% else %}ثبت دانش‌آموز{% endif %} <span>←</span></button></div></form></section>{% endblock %}