Browse Source

Add online class entry flow documentation and implement token management

- Created a new markdown file for the online class entry flow, detailing the process for obtaining and validating tokens for online classes.
- Added a new HTML file for the prayer times calculation guide, including detailed explanations, code examples, and styling.
- Updated the multilang JSON widget HTML and Python files to include additional spacing for readability.
- Implemented a new `OnlineClassTokenManager` class in the Redis utility module to handle the generation, storage, retrieval, and deletion of temporary tokens for online classes, including methods for building entry URLs.
master
mortezaei 8 months ago
parent
commit
a210e19fd0
  1. 2
      .env.prod
  2. 607
      adjustemnts.md
  3. 28
      apps/account/management/commands/assign_professor_slugs.py
  4. 45
      apps/account/migrations/0006_auto_20251006_1101.py
  5. 71
      apps/account/models/user.py
  6. 2
      apps/course/serializers/__init__.py
  7. 21
      apps/course/serializers/online.py
  8. 27
      apps/course/serializers/professor.py
  9. 8
      apps/course/signals.py
  10. 1
      apps/course/tests/__init__.py
  11. 113
      apps/course/tests/test_professor_api.py
  12. 5
      apps/course/urls.py
  13. 1
      apps/course/views/__init__.py
  14. 159
      apps/course/views/course.py
  15. 171
      apps/course/views/professor.py
  16. 2
      config/settings/base.py
  17. 12
      config/settings/test.py
  18. 3
      docs/MultiLanguageJSONWidget.md
  19. 263
      docs/calendar_prayer_guide.fa.md
  20. 91
      docs/online_class_entry_flow.md
  21. 678
      prayer_times_calculation_guide.html
  22. 3
      templates/utils/widgets/multilang_json_widget.html
  23. 3
      utils/multilang_json_widget.py
  24. 50
      utils/redis.py

2
.env.prod

@ -22,5 +22,5 @@ PLAUSIBLE_DOMAIN='http://127.0.0.1:8000/'
#[captcha] #[captcha]
captcha_public_key="6LdgCjseAAAAAIwg41-kyyulmwDtqD2Gk3THIwy2" captcha_public_key="6LdgCjseAAAAAIwg41-kyyulmwDtqD2Gk3THIwy2"
captcha_private_key="6LdgCjseAAAAAPHMsIHuQgYAGTJ7_QlhqG4G0NyS" captcha_private_key="6LdgCjseAAAAAPHMsIHuQgYAGTJ7_QlhqG4G0NyS"
ONLINE_CLASS_FRONTEND_DOMAIN="imamjavad.newhorizonco.uk"
FCM_API_KEY="" FCM_API_KEY=""

607
adjustemnts.md

@ -0,0 +1,607 @@
عالی! حالا که نمونه واقعی از تنظیمات adjustment را دیدم، می‌توانم توضیح کاملی ارائه دهم:
## 📅 **توضیح کامل تنظیمات تقویم قمری (Lunar Calendar Adjustments)**
### 🎯 **هدف کلی**
این تنظیمات برای **تطبیق تقویم قمری** با تقویم میلادی و مدیریت اختلافات محاسباتی بین تقویم‌های مختلف طراحی شده است.
### 📊 **ساختار داده‌ها**
#### **1. آرایه اصلی**
```json
[
{ "adjust": 0, "current": 0, "map": {...} }, // تنظیمات پیش‌فرض
{ "adjust": -1, "current": 0, "map": {...} }, // تنظیمات منفی
{ "adjust": 1, "current": 0, "map": {...} } // تنظیمات مثبت
]
```
#### **2. فیلدهای هر تنظیم**
- **`adjust`**: مقدار تطبیق (0, -1, +1)
- **`current`**: وضعیت فعلی (همیشه 0)
- **`map`**: نقشه سال‌های قمری
### 🗓️ **نقشه سال‌های قمری**
```json
"map": {
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29],
"1445": [354, 30, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 29],
"1446": [355, 30, 30, 30, 29, 30, 30, 29, 30, 29, 29, 29, 30],
"1447": [355, 29, 30, 30, 29, 30, 30, 29, 30, 29, 29, 30, 29]
}
```
### 🔢 **تفسیر اعداد**
#### **ساختار هر سال:**
- **عدد اول**: تعداد کل روزهای سال (354 یا 355)
- **12 عدد بعدی**: تعداد روزهای هر ماه (29 یا 30)
#### **مثال سال 1444:**
```json
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29]
```
- **354 روز** کل سال
- **محرم**: 30 روز
- **صفر**: 30 روز
- **ربیع‌الاول**: 29 روز
- **ربیع‌الثانی**: 30 روز
- **جمادی‌الاول**: 29 روز
- **جمادی‌الثانی**: 29 روز
- **رجب**: 30 روز
- **شعبان**: 29 روز
- **رمضان**: 30 روز
- **شوال**: 29 روز
- **ذی‌القعده**: 30 روز
- **ذی‌الحجه**: 29 روز
### ⚙️ **سه حالت تطبیق**
#### **1. حالت پیش‌فرض (`adjust: 0`)**
- بدون تطبیق اضافی
- محاسبات استاندارد تقویم قمری
#### **2. حالت تطبیق منفی (`adjust: -1`)**
- یک روز از محاسبات کم می‌شود
- برای تصحیح اختلافات محاسباتی
#### **3. حالت تطبیق مثبت (`adjust: 1`)**
- یک روز به محاسبات اضافه می‌شود
- برای تصحیح اختلافات محاسباتی
### 🔄 **نحوه استفاده در API**
```python
# در کد Python
adjustment_config = get_config('calendar__Adjustment')
config_data = json.loads(adjustment_config)
# انتخاب تنظیمات بر اساس نیاز
for config in config_data:
if config['adjust'] == 0: # حالت پیش‌فرض
lunar_calendar_map = config['map']
break
```
### 🎯 **کاربرد عملی**
#### **1. تبدیل تاریخ**
```javascript
// تبدیل تاریخ میلادی به قمری
function convertToLunar(georgianDate, adjustment = 0) {
const config = adjustmentConfigs.find(c => c.adjust === adjustment);
const lunarMap = config.map;
// محاسبات تبدیل با استفاده از نقشه قمری
}
```
#### **2. محاسبه مناسبت‌ها**
```javascript
// محاسبه تاریخ عید فطر
function calculateEidFitr(year) {
const config = getAdjustmentConfig();
const lunarYear = config.map[year];
// محاسبه بر اساس روزهای ماه رمضان
}
```
### 📱 **مثال استفاده در فرانت‌اند**
```javascript
// دریافت تنظیمات از API
fetch('/calendar/adjustemnts/')
.then(response => response.json())
.then(configs => {
// انتخاب تنظیمات مناسب
const defaultConfig = configs.find(c => c.adjust === 0);
const lunarCalendar = defaultConfig.map;
// استفاده برای نمایش تقویم
displayLunarCalendar(lunarCalendar);
});
```
### 🔧 **مدیریت تنظیمات**
- **تغییر تنظیمات**: از طریق پنل ادمین Django
- **بروزرسانی**: تنظیمات فوراً در API اعمال می‌شود
- **پشتیبان‌گیری**: تنظیمات در دیتابیس ذخیره می‌شوند
### 🎨 **مزایای این سیستم**
1. **انعطاف‌پذیری**: امکان تطبیق با روش‌های مختلف محاسبه
2. **دقت**: تصحیح اختلافات محاسباتی
3. **سازگاری**: پشتیبانی از تقویم‌های مختلف
4. **مدیریت آسان**: تغییر تنظیمات بدون تغییر کد
این سیستم به شما امکان مدیریت دقیق تقویم قمری و تطبیق آن با نیازهای مختلف را می‌دهد.
## 🔍 **توضیح عمیق و جامع سیستم تطبیق تقویم قمری**
### 🌙 **چرا این سیستم وجود دارد؟**
#### **1. مشکل اصلی: اختلاف در محاسبه تقویم قمری**
تقویم قمری بر اساس چرخه ماه (29.5 روز) محاسبه می‌شود، اما:
- **سال قمری**: 354 یا 355 روز
- **سال میلادی**: 365 یا 366 روز
- **اختلاف سالانه**: حدود 11 روز
#### **2. چالش‌های محاسباتی**
```javascript
// مشکل: محاسبه دقیق تقویم قمری
const lunarMonth = 29.53059; // روز
const lunarYear = lunarMonth * 12; // 354.36708 روز
// اما سال قمری باید عدد صحیح باشد!
```
#### **3. روش‌های مختلف محاسبه**
- **روش نجومی**: بر اساس رصد ماه
- **روش محاسباتی**: الگوریتم‌های ریاضی
- **روش تقریبی**: فرمول‌های ساده‌شده
### 🎯 **کاربردهای عملی**
#### **1. مدیریت مناسبت‌های مذهبی**
```javascript
// محاسبه تاریخ عید فطر
function calculateEidFitr(year) {
const config = getAdjustmentConfig();
const lunarMap = config.map[year];
// رمضان همیشه 29 یا 30 روز است
const ramadanDays = lunarMap[9]; // ماه نهم (رمضان)
if (ramadanDays === 29) {
return "عید فطر در روز 29 رمضان";
} else {
return "عید فطر در روز 30 رمضان";
}
}
```
#### **2. تبدیل تاریخ‌ها**
```javascript
// تبدیل تاریخ میلادی به قمری
function convertToLunar(georgianDate, adjustment = 0) {
const config = getAdjustmentConfig(adjustment);
const lunarMap = config.map;
// محاسبه روزهای گذشته از ابتدای سال
let totalDays = calculateDaysFromStart(georgianDate);
// تطبیق با تقویم قمری
totalDays += adjustment; // اعمال تنظیمات
// پیدا کردن ماه و روز قمری
return findLunarMonthAndDay(totalDays, lunarMap);
}
```
#### **3. نمایش تقویم ترکیبی**
```javascript
// نمایش همزمان تقویم میلادی و قمری
function displayHybridCalendar(year) {
const config = getAdjustmentConfig();
const lunarMap = config.map[year];
// ایجاد تقویم میلادی
const georgianCalendar = createGeorgianCalendar(year);
// اضافه کردن تاریخ‌های قمری
georgianCalendar.forEach(day => {
day.lunarDate = convertToLunar(day.date, config.adjust);
});
return georgianCalendar;
}
```
### 🔧 **سه حالت تطبیق و کاربرد آنها**
#### **1. حالت پیش‌فرض (`adjust: 0`)**
```javascript
// استفاده برای:
// - نمایش عمومی تقویم
// - محاسبات استاندارد
// - اکثر کاربران
const standardConfig = {
adjust: 0,
current: 0,
map: {
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29]
}
};
```
#### **2. حالت تطبیق منفی (`adjust: -1`)**
```javascript
// استفاده برای:
// - تصحیح اختلافات محاسباتی
// - تطبیق با رصدهای نجومی
// - مناطق جغرافیایی خاص
const negativeAdjustConfig = {
adjust: -1,
current: 0,
map: {
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29]
}
};
// مثال: اگر رصد ماه نشان دهد که رمضان 28 روز است
// اما محاسبات 29 روز نشان می‌دهد
```
#### **3. حالت تطبیق مثبت (`adjust: 1`)**
```javascript
// استفاده برای:
// - تصحیح اختلافات محاسباتی
// - تطبیق با تقویم‌های رسمی
// - مناطق جغرافیایی خاص
const positiveAdjustConfig = {
adjust: 1,
current: 0,
map: {
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29]
}
};
// مثال: اگر تقویم رسمی کشور رمضان 31 روز نشان دهد
// اما محاسبات 30 روز نشان می‌دهد
```
### 🌍 **کاربردهای جغرافیایی**
#### **1. مناطق مختلف جهان**
```javascript
// تنظیمات بر اساس منطقه جغرافیایی
const regionalConfigs = {
"iran": { adjust: 0, name: "تقویم رسمی ایران" },
"saudi": { adjust: -1, name: "تقویم عربستان" },
"turkey": { adjust: 1, name: "تقویم ترکیه" },
"malaysia": { adjust: 0, name: "تقویم مالزی" }
};
```
#### **2. تطبیق با تقویم‌های رسمی**
```javascript
// تطبیق با تقویم رسمی کشورها
function getOfficialCalendar(country, year) {
const regionalConfig = regionalConfigs[country];
const baseConfig = getAdjustmentConfig(regionalConfig.adjust);
return {
country: country,
year: year,
calendar: baseConfig.map[year],
adjustment: regionalConfig.adjust
};
}
```
### 📱 **کاربرد در اپلیکیشن‌ها**
#### **1. اپلیکیشن‌های مذهبی**
```javascript
// محاسبه زمان نماز
function calculatePrayerTimes(date, location) {
const lunarDate = convertToLunar(date, getAdjustmentForLocation(location));
// محاسبه زمان نماز بر اساس تاریخ قمری
return {
fajr: calculateFajrTime(lunarDate),
dhuhr: calculateDhuhrTime(lunarDate),
asr: calculateAsrTime(lunarDate),
maghrib: calculateMaghribTime(lunarDate),
isha: calculateIshaTime(lunarDate)
};
}
```
#### **2. اپلیکیشن‌های تقویم**
```javascript
// نمایش تقویم ترکیبی
function displayCalendar(year, month) {
const config = getAdjustmentConfig();
const lunarMap = config.map[year];
// ایجاد تقویم میلادی
const georgianDays = getGeorgianDays(year, month);
// اضافه کردن تاریخ‌های قمری
const hybridDays = georgianDays.map(day => ({
...day,
lunar: convertToLunar(day.date, config.adjust),
isHoliday: isLunarHoliday(day.date, lunarMap)
}));
return hybridDays;
}
```
### 🔄 **مدیریت پویای تنظیمات**
#### **1. تغییر تنظیمات در زمان اجرا**
```javascript
// تغییر تنظیمات بدون restart
function updateAdjustmentConfig(newConfig) {
// ذخیره در دیتابیس
saveConfigToDatabase(newConfig);
// بروزرسانی کش
updateCache('calendar__Adjustment', newConfig);
// اطلاع‌رسانی به کلاینت‌ها
broadcastConfigUpdate(newConfig);
}
```
#### **2. A/B Testing تنظیمات**
```javascript
// تست تنظیمات مختلف
function testAdjustmentConfigs(userGroup) {
const configs = {
'group_a': { adjust: 0, name: 'پیش‌فرض' },
'group_b': { adjust: -1, name: 'تطبیق منفی' },
'group_c': { adjust: 1, name: 'تطبیق مثبت' }
};
return configs[userGroup] || configs['group_a'];
}
```
### 🎨 **مزایای این سیستم**
#### **1. انعطاف‌پذیری**
- امکان تطبیق با روش‌های مختلف محاسبه
- پشتیبانی از مناطق جغرافیایی مختلف
- قابلیت تغییر بدون تغییر کد
#### **2. دقت**
- تصحیح اختلافات محاسباتی
- تطبیق با رصدهای نجومی
- پشتیبانی از تقویم‌های رسمی
#### **3. مقیاس‌پذیری**
- پشتیبانی از سال‌های متعدد
- امکان اضافه کردن تنظیمات جدید
- مدیریت آسان از طریق پنل ادمین
#### **4. سازگاری**
- کار با تقویم‌های مختلف
- پشتیبانی از زبان‌های مختلف
- تطبیق با استانداردهای بین‌المللی
### 🚀 **نتیجه‌گیری**
این سیستم یک راه‌حل جامع برای مدیریت تقویم قمری است که:
- **مشکلات محاسباتی** را حل می‌کند
- **انعطاف‌پذیری** لازم را فراهم می‌کند
- **دقت** مورد نیاز را تضمین می‌کند
- **مدیریت آسان** تنظیمات را امکان‌پذیر می‌سازد
این سیستم به شما امکان مدیریت دقیق و انعطاف‌پذیر تقویم قمری را می‌دهد و می‌تواند با نیازهای مختلف کاربران و مناطق جغرافیایی تطبیق یابد.
نمونه پاسخ:
[
{
"adjust": 0,
"current": 0,
"map": {
"1444": [
354,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
30,
29
],
"1445": [
354,
30,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
29
],
"1446": [
355,
30,
30,
30,
29,
30,
30,
29,
30,
29,
29,
29,
30
],
"1447": [
355,
29,
30,
30,
29,
30,
30,
29,
30,
29,
29,
30,
29
]
}
},
{
"adjust": -1,
"current": 0,
"map": {
"1444": [
354,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
30,
29
],
"1445": [
354,
30,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
30
],
"1446": [
355,
30,
30,
30,
29,
30,
30,
29,
30,
29,
30,
29,
29
],
"1447": [
355,
29,
30,
30,
29,
30,
30,
29,
30,
29,
29,
30,
29
]
}
},
{
"adjust": 1,
"current": 0,
"map": {
"1444": [
354,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
30,
29
],
"1445": [
354,
30,
30,
30,
29,
30,
29,
29,
30,
29,
30,
29,
29
],
"1446": [
355,
30,
30,
30,
29,
30,
30,
29,
30,
29,
30,
29,
29
],
"1447": [
355,
29,
30,
30,
29,
30,
30,
29,
30,
29,
29,
30,
29
]
}
}
]

28
apps/account/management/commands/assign_professor_slugs.py

@ -0,0 +1,28 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from apps.account.models import User
class Command(BaseCommand):
help = "Assign slugs to all professor users that currently lack one."
def handle(self, *args, **options):
professors = User.objects.filter(
user_type=User.UserType.PROFESSOR
).filter(Q(slug__isnull=True) | Q(slug=""))
if not professors.exists():
self.stdout.write(self.style.SUCCESS("All professor users already have slugs."))
return
updated = 0
with transaction.atomic():
for professor in professors.iterator():
if professor.ensure_professor_profile():
updated += 1
self.stdout.write(
self.style.SUCCESS(f"Assigned slugs to {updated} professor user(s).")
)

45
apps/account/migrations/0006_auto_20251006_1101.py

@ -0,0 +1,45 @@
# Generated by Django 3.2.4 on 2025-10-06 11:01
from django.db import migrations, models
from django.utils.text import slugify
def generate_professor_slugs(apps, schema_editor):
User = apps.get_model('account', 'User')
qs = User.objects.filter(user_type='professor').filter(models.Q(slug__isnull=True) | models.Q(slug=''))
for user in qs.iterator():
base = slugify(user.fullname, allow_unicode=True) if user.fullname else ''
base = base[:250] or f"professor-{user.pk}"
slug = base
counter = 1
while User.objects.filter(slug=slug).exclude(pk=user.pk).exists():
slug = f"{base}-{counter}"[:255]
counter += 1
user.slug = slug
user.save(update_fields=['slug'])
def remove_professor_slugs(apps, schema_editor):
User = apps.get_model('account', 'User')
User.objects.filter(user_type='professor').update(slug=None)
class Migration(migrations.Migration):
dependencies = [
('account', '0005_alter_user_unique_together'),
]
operations = [
migrations.AddField(
model_name='user',
name='experience_years',
field=models.PositiveIntegerField(default=0, verbose_name='Experience years'),
),
migrations.AddField(
model_name='user',
name='slug',
field=models.SlugField(blank=True, max_length=255, null=True, unique=True),
),
migrations.RunPython(generate_professor_slugs, remove_professor_slugs),
]

71
apps/account/models/user.py

@ -1,7 +1,9 @@
import random import random
import secrets
from dj_language.field import LanguageField from dj_language.field import LanguageField
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.utils import timezone from django.utils import timezone
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
@ -54,6 +56,8 @@ class User(AbstractUser):
device_os = models.CharField(choices=DeviceOs.choices, null=True, max_length=16) device_os = models.CharField(choices=DeviceOs.choices, null=True, max_length=16)
fcm = models.CharField(max_length=512, null=True, blank=True) fcm = models.CharField(max_length=512, null=True, blank=True)
slug = models.SlugField(max_length=255, unique=True, null=True, blank=True)
experience_years = models.PositiveIntegerField(default=0, verbose_name=_('Experience years'))
is_staff = models.BooleanField(default=False) is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True, verbose_name="Active", help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.") is_active = models.BooleanField(default=True, verbose_name="Active", help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.")
deleted_at = models.DateTimeField(null=True, blank=True) deleted_at = models.DateTimeField(null=True, blank=True)
@ -83,8 +87,12 @@ class User(AbstractUser):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.username = self.email self.username = self.email
if User.objects.filter(username=self.email).count():
if User.objects.filter(username=self.email).exclude(pk=self.pk).exists():
self.username = f'{self.email}:{self.id}' self.username = f'{self.email}:{self.id}'
if self.user_type == self.UserType.PROFESSOR:
self._ensure_professor_slug()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
def get_full_name(self): def get_full_name(self):
@ -194,6 +202,67 @@ class User(AbstractUser):
return course.professor == self return course.professor == self
return False return False
def ensure_professor_profile(self, commit: bool = True) -> bool:
"""تضمین می‌کند کاربر نقش استاد دارد، اسلاگ دارد و در گروه استاد است."""
updated_fields = set()
if self.user_type != self.UserType.PROFESSOR:
self.user_type = self.UserType.PROFESSOR
updated_fields.add('user_type')
if not self.slug:
self._ensure_professor_slug()
if self.slug:
updated_fields.add('slug')
from django.contrib.auth.models import Group
group, _ = Group.objects.get_or_create(name="Professor Group")
group_added = False
if not self.groups.filter(id=group.id).exists():
self.groups.add(group)
group_added = True
if commit and updated_fields:
self.save(update_fields=list(updated_fields))
return bool(updated_fields or group_added)
def _ensure_professor_slug(self):
if self.slug:
return
base_candidates = [
self.fullname,
(self.email.split('@')[0] if self.email else None),
self.username,
]
for candidate in base_candidates:
if candidate:
self.slug = self._build_unique_slug(candidate)
if self.slug:
return
self.slug = self._build_unique_slug(f"professor-{secrets.token_hex(4)}")
def _build_unique_slug(self, seed: str) -> str:
base_slug = slugify(seed, allow_unicode=True)
if not base_slug:
base_slug = f"professor-{secrets.token_hex(4)}"
slug = base_slug
counter = 1
qs = User.objects.all()
if self.pk:
qs = qs.exclude(pk=self.pk)
while qs.filter(slug=slug).exists():
slug = f"{base_slug}-{counter}"
counter += 1
return slug[:255]
class Meta: class Meta:
ordering = ("-id",) ordering = ("-id",)

2
apps/course/serializers/__init__.py

@ -1,3 +1,5 @@
from .course import * from .course import *
from .lesson import * from .lesson import *
from .participant import * from .participant import *
from .online import *
from .professor import *

21
apps/course/serializers/online.py

@ -0,0 +1,21 @@
from rest_framework import serializers
class OnlineClassTokenCreateSerializer(serializers.Serializer):
redirect_path = serializers.CharField(required=False)
def validate_redirect_path(self, value: str) -> str:
value = value.strip()
if value and value.startswith("http"):
raise serializers.ValidationError("Redirect path must be relative to the frontend domain.")
return value
class OnlineClassTokenVerifySerializer(serializers.Serializer):
token = serializers.CharField(max_length=128)
def validate_token(self, value: str) -> str:
value = value.strip()
if not value:
raise serializers.ValidationError("Token is required.")
return value

27
apps/course/serializers/professor.py

@ -0,0 +1,27 @@
from django.contrib.auth import get_user_model
from rest_framework import serializers
from apps.account.serializers import UserProfileSerializer
User = get_user_model()
class ProfessorListSerializer(serializers.ModelSerializer):
course_count = serializers.IntegerField(read_only=True)
lesson_count = serializers.IntegerField(read_only=True)
class Meta:
model = User
fields = ['id', 'slug', 'fullname', 'experience_years', 'course_count', 'lesson_count']
class ProfessorDetailSerializer(UserProfileSerializer):
course_count = serializers.IntegerField(read_only=True)
lesson_count = serializers.IntegerField(read_only=True)
experience_years = serializers.IntegerField(read_only=True)
slug = serializers.CharField(read_only=True)
class Meta(UserProfileSerializer.Meta):
fields = UserProfileSerializer.Meta.fields + ['slug', 'experience_years', 'course_count', 'lesson_count']
read_only_fields = list(set(UserProfileSerializer.Meta.read_only_fields + ['slug', 'experience_years', 'course_count', 'lesson_count']))

8
apps/course/signals.py

@ -1,6 +1,7 @@
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from apps.course.models import Course from apps.course.models import Course
from apps.chat.models import RoomMessage from apps.chat.models import RoomMessage
@ -17,3 +18,10 @@ def create_room_message_for_course(sender, instance, created, **kwargs):
) )
@receiver(post_save, sender=Course)
def ensure_professor_role(sender, instance, **kwargs):
professor = getattr(instance, 'professor', None)
if professor:
professor.ensure_professor_profile()

1
apps/course/tests/__init__.py

@ -0,0 +1 @@

113
apps/course/tests/test_professor_api.py

@ -0,0 +1,113 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.urls import reverse
from rest_framework.test import APITestCase
from apps.account.models import ProfessorUser
from apps.course.models import Course, CourseCategory, CourseLesson, Lesson
class TestProfessorAPI(APITestCase):
def setUp(self):
self.professor = ProfessorUser.objects.create(
email='professor@example.com',
fullname='استاد نمونه',
experience_years=7,
)
self.category = CourseCategory.objects.create(name='General', slug='general')
thumbnail = SimpleUploadedFile('thumb.jpg', b'filecontent', content_type='image/jpeg')
self.course = Course.objects.create(
title='Test Course',
slug='test-course',
category=self.category,
professor=self.professor,
thumbnail=thumbnail,
video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
video_link='https://example.com/video',
is_online=True,
online_link='https://example.com/classroom',
level=Course.LevelChoices.BEGINNER,
duration=10,
lessons_count=1,
description='Sample description',
short_description='Short description',
status=Course.StatusChoices.ONGOING,
is_free=True,
)
lesson = Lesson.objects.create(
title='Lesson 1',
content_type=Lesson.ContentTypeChoices.VIDEO_FILE,
duration=5,
)
CourseLesson.objects.create(course=self.course, lesson=lesson, priority=1, is_active=True)
self.professor.refresh_from_db()
def test_professor_list_api(self):
url = reverse('course-professor-list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], 1)
item = response.data['results'][0]
self.assertEqual(item['slug'], self.professor.slug)
self.assertEqual(item['fullname'], self.professor.fullname)
self.assertEqual(item['experience_years'], 7)
self.assertEqual(item['course_count'], 1)
self.assertEqual(item['lesson_count'], 1)
def test_professor_detail_api(self):
url = reverse('course-professor-detail', kwargs={'slug': self.professor.slug})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['slug'], self.professor.slug)
self.assertEqual(data['fullname'], self.professor.fullname)
self.assertEqual(data['experience_years'], 7)
self.assertEqual(data['course_count'], 1)
self.assertEqual(data['lesson_count'], 1)
def test_professor_courses_api(self):
url = reverse('course-professor-course-list', kwargs={'slug': self.professor.slug})
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], 1)
course_data = response.data['results'][0]
self.assertEqual(course_data['id'], self.course.id)
self.assertEqual(course_data['title'], self.course.title)
def test_professor_slug_generated_without_fullname(self):
professor = ProfessorUser.objects.create(
email='slugless@example.com',
fullname='',
)
self.assertTrue(professor.slug)
def test_course_creation_promotes_professor_user(self):
professor = ProfessorUser.objects.create(
email='pending@example.com',
fullname='کاربر موقت',
)
professor.user_type = ProfessorUser.UserType.CLIENT
professor.slug = None
professor.save(update_fields=['user_type', 'slug'])
thumbnail = SimpleUploadedFile('thumb2.jpg', b'filecontent', content_type='image/jpeg')
Course.objects.create(
title='Auto Promote Course',
slug='auto-promote-course',
category=self.category,
professor=professor,
thumbnail=thumbnail,
video_type=Course.VedioTypeChoices.YOUTUBE_LINK,
video_link='https://example.com/video2',
is_online=False,
level=Course.LevelChoices.BEGINNER,
duration=5,
lessons_count=0,
description='Test',
short_description='Test',
status=Course.StatusChoices.REGISTERING,
is_free=True,
)
professor.refresh_from_db()
self.assertEqual(professor.user_type, ProfessorUser.UserType.PROFESSOR)
self.assertTrue(professor.slug)

5
apps/course/urls.py

@ -10,6 +10,11 @@ urlpatterns = [
path('', views.CourseListAPIView.as_view(), name='course-list'), path('', views.CourseListAPIView.as_view(), name='course-list'),
path('my-courses/', views.MyCourseListAPIView.as_view(), name='course-my-courses-list'), path('my-courses/', views.MyCourseListAPIView.as_view(), name='course-my-courses-list'),
path('lesson/completion/', views.LessonCompletionCreateAPIView.as_view(), name='lesson-completion'), path('lesson/completion/', views.LessonCompletionCreateAPIView.as_view(), name='lesson-completion'),
path('professors/', views.ProfessorListAPIView.as_view(), name='course-professor-list'),
path('professors/<slug:slug>/courses/', views.ProfessorCourseListAPIView.as_view(), name='course-professor-course-list'),
path('professors/<slug:slug>/', views.ProfessorDetailAPIView.as_view(), name='course-professor-detail'),
path('<int:pk>/online/token/', views.CourseOnlineClassTokenAPIView.as_view(), name='course-online-token'),
path('online/token/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-token-validate'),
path('<slug:slug>/', views.CourseDetailAPIView.as_view(), name='course-detail'), path('<slug:slug>/', views.CourseDetailAPIView.as_view(), name='course-detail'),
path('<slug:slug>/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), path('<slug:slug>/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'),

1
apps/course/views/__init__.py

@ -1,3 +1,4 @@
from .course import * from .course import *
from .lesson import * from .lesson import *
from .participant import * from .participant import *
from .professor import *

159
apps/course/views/course.py

@ -1,18 +1,33 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db.models import Count, Q, F from django.db.models import Count, Q, F
from drf_yasg.utils import swagger_auto_schema
from django.shortcuts import get_object_or_404
from django.utils import timezone
from drf_yasg import openapi from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.filters import SearchFilter from rest_framework.filters import SearchFilter
from rest_framework.generics import GenericAPIView, ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from apps.course.serializers import ( from apps.course.serializers import (
CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer, CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer,
CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer
CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer,
OnlineClassTokenCreateSerializer, OnlineClassTokenVerifySerializer
) )
from apps.course.models import Course, CourseCategory, CourseAttachment, CourseGlossary, Participant from apps.course.models import Course, CourseCategory, CourseAttachment, CourseGlossary, Participant
from apps.course.doc import * from apps.course.doc import *
from apps.account.serializers import UserProfileSerializer
from utils.exceptions import AppAPIException
from utils.redis import OnlineClassTokenManager
UserModel = get_user_model()
class CourseCategoryAPIView(ListAPIView): class CourseCategoryAPIView(ListAPIView):
@ -267,3 +282,139 @@ class GlossaryListAPIView(ListAPIView):
class CourseOnlineClassTokenAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
serializer_class = OnlineClassTokenCreateSerializer
@swagger_auto_schema(
operation_description="Generate a temporary entry token for an online class.",
request_body=OnlineClassTokenCreateSerializer,
responses={
status.HTTP_201_CREATED: openapi.Response(
description="Token generated successfully.",
examples={
"application/json": {
"token": "<temporary-token>",
"url": "https://frontend.example.com?token=<temporary-token>",
"expires_in": 300,
}
}
)
}
)
def post(self, request, pk, *args, **kwargs):
serializer = self.get_serializer(data=request.data or {})
serializer.is_valid(raise_exception=True)
course = get_object_or_404(Course, pk=pk)
if not course.is_online:
raise AppAPIException({'message': "Course is not marked as online."}, status_code=status.HTTP_400_BAD_REQUEST)
if not self._user_has_access(request.user, course):
raise AppAPIException({'message': "You do not have access to this course."}, status_code=status.HTTP_403_FORBIDDEN)
manager = OnlineClassTokenManager()
user_token, _ = Token.objects.get_or_create(user=request.user)
identifier = f"{request.user.id}:{user_token.key[:8]}"
token = manager.generate_token(course_id=course.id, user_identifier=identifier)
manager.store_token(token, {
'course_id': course.id,
'user_id': request.user.id,
'user_token': user_token.key,
'extra': {
'professor_in_class': False,
},
})
redirect_path = serializer.validated_data.get('redirect_path')
base_url = self._build_base_url(redirect_path)
entry_url = manager.build_entry_url(token, base_url=base_url)
return Response({
'token': token,
'url': entry_url,
'expires_in': getattr(settings, 'ONLINE_CLASS_TOKEN_TTL', 300),
}, status=status.HTTP_201_CREATED)
def _build_base_url(self, redirect_path=None) -> str:
domain = getattr(settings, 'ONLINE_CLASS_FRONTEND_DOMAIN', getattr(settings, 'SITE_DOMAIN', '')).rstrip('/')
if redirect_path:
sanitized = redirect_path.strip('/')
return f"{domain}/{sanitized}" if domain else f"/{sanitized}"
return domain
@staticmethod
def _user_has_access(user, course: Course) -> bool:
if user.is_staff or course.professor_id == user.id:
return True
return Participant.objects.filter(course=course, student=user).exists()
class CourseOnlineClassTokenValidateAPIView(GenericAPIView):
permission_classes = [AllowAny]
serializer_class = OnlineClassTokenVerifySerializer
@swagger_auto_schema(
operation_description="Validate an online class entry token and return course/user data.",
request_body=OnlineClassTokenVerifySerializer,
responses={
status.HTTP_200_OK: openapi.Response(
description="Token validated.",
examples={
"application/json": {
"course": {"id": 1, "title": "Sample Course"},
"user": {"id": 10, "fullname": "John Doe"},
"metadata": {
"status": "ongoing",
"has_started": True,
"professor_in_class": False,
"validated_at": "2024-01-01T10:00:00Z"
}
}
}
)
}
)
def post(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
token_value = serializer.validated_data['token']
manager = OnlineClassTokenManager()
payload = manager.get_payload(token_value)
course_id = payload.get('course_id')
user_id = payload.get('user_id')
if not course_id or not user_id:
raise AppAPIException({'message': 'Token payload is invalid.'}, status_code=status.HTTP_400_BAD_REQUEST)
detail_view = CourseDetailAPIView()
queryset = detail_view.get_queryset()
course = get_object_or_404(queryset, pk=course_id)
user = get_object_or_404(UserModel.objects.all(), pk=user_id)
course_data = CourseDetailSerializer(course, context={'request': request}).data
user_data = UserProfileSerializer(user, context={'request': request}).data
metadata = self._build_metadata(course, payload)
return Response({
'course': course_data,
'user': user_data,
'metadata': metadata,
}, status=status.HTTP_200_OK)
def _build_metadata(self, course: Course, payload: dict) -> dict:
status_value = course.status
has_started = status_value in [Course.StatusChoices.ONGOING, Course.StatusChoices.FINISHED]
timing_data = course.timing if isinstance(course.timing, dict) else {}
return {
'status': status_value,
'is_online': course.is_online,
'has_started': has_started,
'has_finished': status_value == Course.StatusChoices.FINISHED,
'professor_in_class': payload.get('extra', {}).get('professor_in_class', False),
'scheduled_times': timing_data,
'generated_at': payload.get('generated_at'),
'validated_at': timezone.now().isoformat(),
}

171
apps/course/views/professor.py

@ -0,0 +1,171 @@
from django.contrib.auth import get_user_model
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404
from rest_framework.filters import SearchFilter
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.permissions import AllowAny
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from apps.course.models import Course
from apps.course.serializers import (
CourseListSerializer,
ProfessorDetailSerializer,
ProfessorListSerializer,
)
UserModel = get_user_model()
class ProfessorListAPIView(ListAPIView):
permission_classes = [AllowAny]
serializer_class = ProfessorListSerializer
filter_backends = [SearchFilter]
search_fields = ['fullname', 'email']
@swagger_auto_schema(
operation_description='دریافت فهرست استادها به همراه تعداد دوره‌ها و درس‌های فعال هر استاد.',
responses={
200: openapi.Response(
description='فهرست صفحه‌بندی‌شده‌ی استادها.',
schema=ProfessorListSerializer(many=True),
examples={
'application/json': {
'count': 1,
'next': None,
'previous': None,
'results': [
{
'id': 7,
'slug': 'dr-rahimi',
'fullname': 'دکتر رحیمی',
'experience_years': 10,
'course_count': 4,
'lesson_count': 56,
}
],
}
},
)
},
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return (
UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR)
.annotate(
course_count=Count('courses', distinct=True),
lesson_count=Count('courses__lessons', filter=Q(courses__lessons__is_active=True), distinct=True),
)
.order_by('fullname')
)
class ProfessorDetailAPIView(RetrieveAPIView):
permission_classes = [AllowAny]
serializer_class = ProfessorDetailSerializer
lookup_field = 'slug'
@swagger_auto_schema(
operation_description='دریافت جزئیات یک استاد بر اساس اسلاگ.',
responses={
200: openapi.Response(
description='اطلاعات کامل استاد.',
schema=ProfessorDetailSerializer(),
examples={
'application/json': {
'id': 7,
'device_id': 'abc-123',
'fcm': None,
'fullname': 'دکتر رحیمی',
'avatar': None,
'email': 'rahimi@example.com',
'phone_number': '+989121234567',
'password': None,
'info': 'متخصص فیزیک پزشکی.',
'skill': 'فیزیک، تدریس آنلاین',
'city': 'تهران',
'country': 'ایران',
'birthdate': '1985-04-12',
'gender': 'male',
'slug': 'dr-rahimi',
'experience_years': 10,
'course_count': 4,
'lesson_count': 56,
}
},
)
},
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
return UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR).annotate(
course_count=Count('courses', distinct=True),
lesson_count=Count('courses__lessons', filter=Q(courses__lessons__is_active=True), distinct=True),
)
class ProfessorCourseListAPIView(ListAPIView):
permission_classes = [AllowAny]
serializer_class = CourseListSerializer
filter_backends = [SearchFilter]
search_fields = ['title', 'category__name']
@swagger_auto_schema(
operation_description='دریافت فهرست دوره‌های فعال یک استاد مشخص‌شده با اسلاگ.',
responses={
200: openapi.Response(
description='فهرست صفحه‌بندی‌شده‌ی دوره‌ها.',
schema=CourseListSerializer(many=True),
examples={
'application/json': {
'count': 1,
'next': None,
'previous': None,
'results': [
{
'id': 42,
'title': 'فیزیک پایه',
'slug': 'basic-physics',
'participant_count': 150,
'category': {
'name': 'علوم پایه',
'slug': 'basic-science',
'course_count': 12,
},
'thumbnail': None,
'is_online': True,
'online_link': 'https://example.com/live/basic-physics',
'level': 'beginner',
'duration': '12h',
'lessons_count': 24,
'short_description': 'مروری بر مفاهیم پایه فیزیک.',
'status': 'published',
'is_free': False,
'price': '250000',
'discount_percentage': 20,
'final_price': '200000',
}
],
}
},
)
},
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
slug = self.kwargs.get('slug')
professor = get_object_or_404(UserModel.objects.filter(user_type=UserModel.UserType.PROFESSOR, slug=slug))
return Course.objects.select_related('category', 'professor').prefetch_related(
'lessons__lesson',
'lessons__completions',
'participants__student',
).exclude(status=Course.StatusChoices.INACTIVE).filter(professor=professor)

2
config/settings/base.py

@ -273,6 +273,8 @@ FILER_DEBUG = True
ADMIN_TITLE = 'Imam Javad App' ADMIN_TITLE = 'Imam Javad App'
ADMIN_INDEX_TITLE = 'Imam Javad Administration' ADMIN_INDEX_TITLE = 'Imam Javad Administration'
SITE_DOMAIN = "https://imamjavad.nwhco.ir" SITE_DOMAIN = "https://imamjavad.nwhco.ir"
ONLINE_CLASS_FRONTEND_DOMAIN = env('ONLINE_CLASS_FRONTEND_DOMAIN', default=SITE_DOMAIN)
ONLINE_CLASS_TOKEN_TTL = env.int('ONLINE_CLASS_TOKEN_TTL', default=3000)
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)

12
config/settings/test.py

@ -0,0 +1,12 @@
from .base import * # noqa
DATABASES['default'] = {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
MEDIA_ROOT = BASE_DIR / 'test_media'

3
docs/MultiLanguageJSONWidget.md

@ -61,3 +61,6 @@

263
docs/calendar_prayer_guide.fa.md

@ -0,0 +1,263 @@
# راهنمای جامع تقویم دابودبی و محاسبه اوقات شرعی
این مستند برای آشنایی همکاران فنی (مانند تیم فلاتر) و غیر فنی با سازوکار تقویم پروژه «امام جواد» و همچنین منطق محاسبه اوقات شرعی تهیه شده است. مطالب شامل معرفی ساختار دیتای تقویم، توضیح کامل API تنظیمات قمری (`adjustemnts`)، نحوه بهره‌گیری در سناریوهای واقعی و نمونه‌کدهای کاربردی است.
## نمای کلی سیستم تقویم
- **ماژول:** `apps/dobodbi_calendar`
- **مدل اصلی:** `CalendarOccasions`
- فیلدهای کلیدی: `title` عنوان مناسبت، `occasion_type` نوع تاریخ (میلادی یا قمری)، `dates` لیست تاریخ‌ها، `event_type` دسته‌بندی (ملی، مذهبی و ...)، `is_yearly` تکرار سالانه، تایم‌استمپ‌های `created_at` و `updated_at`.
- **سریالایزر:** `CalendarSerializer` که تاریخ‌ها را به ساختار قابل مصرف برای کلاینت تبدیل می‌کند و نوع مناسبت را در فیلد `type` قرار می‌دهد.
- **نمای لیستی:** `CalendarList` با مسیر `/api/calendar/occasions/` که بدون صفحه‌بندی پاسخ می‌دهد و آخرین زمان به‌روزرسانی را برمی‌گرداند تا همگام‌سازی کلاینت راحت‌تر انجام شود.
- **تنظیمات پویا:** با استفاده از `dynamic_preferences` و کلید `calendar__Adjustment` ذخیره می‌شود. مقدار خام این تنظیمات در پایگاه داده نگهداری شده و از طریق پنل ادمین قابل ویرایش است.
## API تنظیمات قمری (Adjustemnts)
- **مسیر:** `GET /api/calendar/adjustemnts/`
- **منبع داده:** ترجمهٔ مقدار ذخیره شده در `dynamic_preferences` برای کلید `calendar__Adjustment`.
- **کارکرد کلی:** ارائهٔ سناریوهای از پیش تعریف‌شده برای تطبیق تقویم قمری با واقعیت رؤیت هلال یا تقویم رسمی کشورها.
### ساختار پاسخ نمونه
```json
[
{
"adjust": 0,
"current": 0,
"map": {
"1444": [354, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 30, 29],
"1445": [354, 30, 30, 30, 29, 30, 29, 29, 30, 29, 30, 29, 29],
"1446": [355, 30, 30, 30, 29, 30, 30, 29, 30, 29, 29, 29, 30],
"1447": [355, 29, 30, 30, 29, 30, 30, 29, 30, 29, 29, 30, 29]
}
},
{
"adjust": -1,
"current": 0,
"map": { "...": "..." }
},
{
"adjust": 1,
"current": 0,
"map": { "...": "..." }
}
]
```
### معنی فیلدها
| فیلد | توضیح |
| --- | --- |
| `adjust` | مقدار جبرانی: `-1` یک روز کم می‌کند، `0` حالت مرجع، `+1` یک روز اضافه می‌کند. |
| `current` | وضعیت فعال که می‌تواند توسط کلاینت یا پنل ادمین برای علامت‌گذاری حالت انتخابی استفاده شود (در نمونه فعلی صفر است). |
| `map` | دیکشنری سال قمری ↦ آرایه شامل ۱ + ۱۲ مقدار: عدد اول تعداد روزهای سال (۳۵۴ یا ۳۵۵)، ۱۲ عدد بعدی طول هر ماه قمری. |
### انتخاب حالت مناسب با مثال
| حالت | زمان استفاده پیشنهادی | مثال عملی |
| --- | --- | --- |
| `adjust = 0` | حالت مرجع و عمومی | نمایش تقویم در سامانه آموزشی بدون نیاز به تصحیح محلی. |
| `adjust = -1` | مناطقی که شروع ماه قمری را یک روز زودتر اعلام می‌کنند یا پس از رصد مشخص می‌شود هلال یک روز زودتر دیده شده | اگر در ایران رمضان ۲۹ روز اعلام شود اما محاسبه‌گر ۳۰ روزه باشد، با `-1` آخرین روز حذف می‌شود. |
| `adjust = +1` | مناطقی که آغاز ماه را دیرتر اعلام می‌کنند یا برای همگام‌سازی با تقویم رسمی نیاز به افزودن روز دارند | زمانی که کشوری عید قربان را یک روز دیرتر می‌گیرد؛ با `+1` روز اضافه می‌شود. |
### سناریوی قدم‌به‌قدم: تعیین تاریخ عید فطر
1. دریافت داده: `configs = GET /api/calendar/adjustemnts/`.
2. انتخاب حالت: `defaultConfig = configs.find(c => c.adjust === 0)`.
3. استخراج سال هدف: `ramadanProfile = defaultConfig.map['1445']`.
4. محاسبه طول رمضان: مقدار شاخص ۹ (ماه نهم). اگر ۲۹ بود → عید فطر روز ۲۹ رمضان، اگر ۳۰ بود → روز ۳۰.
5. در صورت اختلاف رسمی، کافی است پیکربندی دیگری را انتخاب کنید (مثلاً `adjust = +1`) تا روز اضافه لحاظ شود.
### سناریوی همگام‌سازی تقویم در اپلیکیشن
1. در اولین اجرا، هرسه پیکربندی را ذخیره کنید.
2. متناسب با موقعیت کاربر (یا انتخاب کاربر)، `adjust` مناسب را فعال نگه دارید. مقدار انتخابی را می‌توانید در کلاینت یا سرور ذخیره کنید.
3. برای تبدیل «روز سال قمری» به روز میلادی:
- اختلاف روز تا ابتدای سال قمری را با جمع ۱۲ ماه محاسبه کنید.
- با استفاده از تاریخ میلادی مرجع (مثلاً شروع سال قمری در تقویم رسمی)، اختلاف را اعمال کنید تا تاریخ میلادی به‌دست آید.
4. اگر داده‌های `CalendarOccasions` نوع `lunar` داشتند، از نقشه انتخاب‌شده برای محاسبه تاریخ معادل استفاده کنید و مقدار نهایی را به رابط کاربری نمایش دهید.
## راهنمای محاسبه اوقات شرعی
مبنای پروژه فایل `prayer_times_calculation_guide.html` است که مراحل استفاده از الگوریتم **PrayTimes** را تشریح کرده است. خلاصه فرآیند:
1. **ورودی‌ها:** تاریخ میلادی، مختصات (عرض/طول جغرافیایی، ارتفاع)، روش محاسبه (مثلاً Tehran، MWL)، روش جبران عرض‌های بالا و فرمت خروجی (۱۲ یا ۲۴ ساعته).
2. **محاسبه تاریخ ژولیَن (Julian Date):** `jdate = julian(year, month, day) - longitude / (15 × 24)`.
3. **موقعیت خورشید:** با تابعی مشابه `sunPosition(jdate)` زاویه میل خورشید (`declination`) و معادله زمان (`equation of time`) به‌دست می‌آید.
4. **محاسبه اوقات پایه:**
- فجر، طلوع، مغرب، عشا: با `sunAngleTime` و زاویه‌های مخصوص هر روش محاسبه می‌شوند.
- ظهر: با `midDay`.
- عصر: با `asrTime` و فاکتور مربوط به مذهب (1 برای شافعی/جعفری، 2 برای حنفی).
5. **تنظیم اختلاف طول جغرافیایی و منطقه زمانی:**
- `offset = timezoneHours - longitude / 15`
- جمع این مقدار با همه زمان‌های محاسبه‌شده.
6. **جبران عرض‌های جغرافیایی بالا:** در صورت انتخاب روش‌هایی مثل `NightMiddle` یا `AngleBased`، طول شب محاسبه و زمان‌های فجر/عشا تعدیل می‌شوند.
7. **تبدیل خروجی اعشاری به ساعت:**
- ساعت = بخش صحیح عدد.
- دقیقه = `(عدد - ساعت) × 60`.
- در صورت نیاز به فرمت ۱۲ ساعته، AM/PM مطابق قواعد اضافه می‌شود.
### چه ابزاری برای محاسبه استفاده کنیم؟
- **بک‌اند (Python/Django):** در صورت نیاز به محاسبه سمت سرور می‌توانید از پیاده‌سازی استاندارد PrayTimes یا کتابخانه‌های معتبری مانند `praytimes` (نسخه پایتونی) بهره ببرید. نتیجه را می‌توان کش کرد و فقط در صورت تغییر مختصات یا تاریخ دوباره محاسبه نمود.
- **کلاینت فلاتر:** استفاده از کلاس سفارشی پروژه یا پکیج‌های آماده نظیر [`adhan_dart`](https://pub.dev/packages/adhan_dart) برای محاسبات سریع و دقیق توصیه می‌شود.
- **وب/جاوااسکریپت:** کتابخانه‌هایی مثل [`adhan`](https://github.com/batoulapps/adhan-js) یا نسخهٔ رسمی PrayTimes.js به‌خوبی نیاز را پوشش می‌دهند.
## نمونه کد فلاتر
### دریافت و استفاده از تنظیمات قمری
```dart
import 'dart:convert';
import 'package:http/http.dart' as http;
class LunarAdjustConfig {
final int adjust;
final Map<String, List<int>> map;
LunarAdjustConfig({required this.adjust, required this.map});
factory LunarAdjustConfig.fromJson(Map<String, dynamic> json) {
return LunarAdjustConfig(
adjust: json['adjust'] as int,
map: (json['map'] as Map<String, dynamic>).map(
(key, value) => MapEntry(key, List<int>.from(value)),
),
);
}
}
Future<List<LunarAdjustConfig>> fetchAdjustments() async {
final res = await http.get(Uri.parse('https://example.com/api/calendar/adjustemnts/'));
final data = jsonDecode(res.body) as List<dynamic>;
return data.map((item) => LunarAdjustConfig.fromJson(item)).toList();
}
DateTime applyLunarOffset({
required DateTime hijriYearStart,
required LunarAdjustConfig config,
required String hijriYear,
required int hijriMonthIndex,
required int hijriDay,
}) {
final months = config.map[hijriYear] ?? [];
if (months.length != 13) {
throw ArgumentError('ساختار سال قمری نامعتبر است');
}
final daysFromYearStart = months
.sublist(1, hijriMonthIndex)
.fold<int>(0, (acc, value) => acc + value);
final totalOffset = daysFromYearStart + (hijriDay - 1) + config.adjust;
return hijriYearStart.add(Duration(days: totalOffset));
}
```
### محاسبه اوقات شرعی با `adhan_dart`
```dart
import 'package:adhan_dart/adhan_dart.dart';
PrayerTimes calculatePrayerTimes({
required DateTime date,
required Coordinates coordinates,
}) {
final params = CalculationMethod.tehran();
params.madhab = Madhab.shafi;
final times = PrayerTimes(coordinates, date, params);
return times;
}
void main() async {
final configs = await fetchAdjustments();
final defaultConfig = configs.firstWhere((c) => c.adjust == 0, orElse: () => configs.first);
final hijriStart = DateTime(2023, 7, 19); // تاریخ میلادی شروع سال 1445 به تقویم رسمی
final eidDate = applyLunarOffset(
hijriYearStart: hijriStart,
config: defaultConfig,
hijriYear: '1445',
hijriMonthIndex: 10, // ماه شوال (پس از رمضان)
hijriDay: 1,
);
final times = calculatePrayerTimes(
date: eidDate,
coordinates: Coordinates(35.6892, 51.3890),
);
print('اذان صبح: ${times.fajrTime}');
print('اذان مغرب: ${times.maghribTime}');
}
```
## نمونه کد جاوااسکریپت (وب یا Node.js)
### دریافت تنظیمات و محاسبه تاریخ قمری
```js
import fetch from 'node-fetch';
async function fetchAdjustments() {
const res = await fetch('https://example.com/api/calendar/adjustemnts/');
return res.json();
}
function applyLunarOffset({ hijriYearStart, config, hijriYear, hijriMonthIndex, hijriDay }) {
const months = config.map[hijriYear];
if (!months || months.length !== 13) {
throw new Error('ساختار سال قمری نامعتبر است');
}
const daysBeforeMonth = months.slice(1, hijriMonthIndex).reduce((sum, value) => sum + value, 0);
const totalOffset = daysBeforeMonth + (hijriDay - 1) + config.adjust;
const result = new Date(hijriYearStart);
result.setDate(result.getDate() + totalOffset);
return result;
}
// مثال استفاده
const configs = await fetchAdjustments();
const preferred = configs.find((c) => c.adjust === 1) ?? configs[0];
const eidAlAdha = applyLunarOffset({
hijriYearStart: new Date('2024-06-08'), // شروع سال 1446 در تقویم رسمی منطقه هدف
config: preferred,
hijriYear: '1446',
hijriMonthIndex: 12, // ذی‌الحجه
hijriDay: 10,
});
console.log('تاریخ میلادی عید قربان:', eidAlAdha.toISOString().slice(0, 10));
```
### محاسبه اوقات شرعی با کتابخانه `adhan`
```js
import { PrayerTimes, Coordinates, CalculationMethod } from 'adhan';
function calculatePrayerTimes(date, lat, lng) {
const params = CalculationMethod.Tehran();
const coordinates = new Coordinates(lat, lng);
const times = new PrayerTimes(coordinates, date, params);
return {
fajr: times.fajr.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
sunrise: times.sunrise.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
dhuhr: times.dhuhr.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
asr: times.asr.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
maghrib: times.maghrib.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
isha: times.isha.toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }),
};
}
const todayTimes = calculatePrayerTimes(new Date(), 35.6892, 51.3890);
console.log(todayTimes);
```
## جمع‌بندی
- تقویم پروژه از ترکیب مدل `CalendarOccasions` و تنظیمات پویا `calendar__Adjustment` تشکیل شده و API ویژهٔ `adjustemnts` سه سناریوی جبران قمری را ارائه می‌کند.
- برای محاسبهٔ اوقات شرعی می‌توان از الگوریتم مبتنی بر `PrayTimes` استفاده کرد که با ورودی‌های تاریخ، مختصات و روش محاسبه، زمان‌های اذان را بازمی‌گرداند.
- نمونه کدهای فلاتر و جاوااسکریپت نشان می‌دهند چگونه می‌توان در کلاینت هم تاریخ‌های قمری را به میلادی تبدیل کرد و هم اوقات شرعی را به‌صورت محلی محاسبه نمود.

91
docs/online_class_entry_flow.md

@ -0,0 +1,91 @@
# Online Class Entry Scenario
## 1. دریافت توکن ورود به کلاس آنلاین
- **هدف**: کاربر لاگین‌شده لینک ورود موقت به کلاس بگیرد.
- **درخواست**: `POST /api/courses/{course_id}/online/token/` به همراه توکن احراز هویت کاربر.
- **ورودی اختیاری**: `redirect_path` (مسیر نسبی در فرانت برای صفحه کلاس).
- **خروجی**: توکن یک‌بارمصرف ذخیره‌شده در Redis + آدرس نهایی ورود (دامنه فرانت + Query Param توکن).
### نمونه `curl`
```bash
curl --request POST \
--url https://api.example.com/api/courses/42/online/token/ \
--header 'Authorization: Token USER_AUTH_TOKEN' \
--header 'Content-Type: application/json' \
--data '{
"redirect_path": "online-classroom"
}'
```
### نمونه پاسخ
```json
{
"token": "5f7b8c...e1",
"url": "https://front.example.com/online-classroom?token=5f7b8c...e1",
"expires_in": 300
}
```
## 2. اعتبارسنجی توکن و دریافت داده‌های کلاس
- **هدف**: فرانت با توکن دریافتی اطلاعات کلاس، پروفایل کاربر و متادیتا را بگیرد.
- **درخواست**: `POST /api/courses/online/token/validate/`
- **ورودی**: `token`
- **خروجی**: آبجکت دوره (سریالایز کامل)، پروفایل کاربر، و متادیتا شامل وضعیت کلاس، زمان‌ها و وضعیت حضور استاد.
### نمونه `curl`
```bash
curl --request POST \
--url https://api.example.com/api/courses/online/token/validate/ \
--header 'Content-Type: application/json' \
--data '{
"token": "5f7b8c...e1"
}'
```
### نمونه پاسخ
```json
{
"course": {
"id": 42,
"title": "درس اخلاق",
"status": "ongoing",
"timing": {
"monday": "18:00",
"wednesday": "18:00"
},
"online_link": "https://meeting.example.com/class/42",
"is_online": true,
"professor": {
"id": 7,
"fullname": "استاد رضایی"
}
},
"user": {
"id": 105,
"fullname": "علی احمدی",
"email": "ali@example.com"
},
"metadata": {
"status": "ongoing",
"is_online": true,
"has_started": true,
"has_finished": false,
"professor_in_class": false,
"scheduled_times": {
"monday": "18:00",
"wednesday": "18:00"
},
"generated_at": "2024-10-05T10:15:30Z",
"validated_at": "2024-10-05T10:16:05.123456Z"
}
}
```
## نکات پیاده‌سازی در فرانت‌اند
1. پس از دریافت پاسخ مرحله‌ٔ اول، کاربر را به `url` بازگردانی کنید.
2. در صفحه کلاس، توکن از Query String خوانده شده و به مرحله‌ٔ دوم ارسال شود.
3. در صورت خطا (Expiry یا Invalid)، فرانت باید کاربر را به صفحه‌ٔ اصلی یا خطا هدایت کند و درخواست توکن جدید بدهد.
4. `expires_in` نشان می‌دهد لینک چه مدت اعتبار دارد؛ بهتر است شمارش معکوس یا تلاش خودکار برای تمدید توکن داشته باشید.

678
prayer_times_calculation_guide.html

@ -0,0 +1,678 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>راهنمای محاسبه اوقات شرعی - PrayTimes Class</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Tahoma', 'Arial', sans-serif;
line-height: 1.8;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #333;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(45deg, #2c3e50, #34495e);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2.5em;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.2em;
opacity: 0.9;
}
.content {
padding: 40px;
}
.section {
margin-bottom: 40px;
padding: 25px;
border-radius: 10px;
border-right: 5px solid #3498db;
background: #f8f9fa;
}
.section h2 {
color: #2c3e50;
font-size: 1.8em;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #ecf0f1;
}
.section h3 {
color: #34495e;
font-size: 1.4em;
margin: 20px 0 15px 0;
}
.code-block {
background: #2c3e50;
color: #ecf0f1;
padding: 20px;
border-radius: 8px;
margin: 15px 0;
overflow-x: auto;
font-family: 'Courier New', monospace;
direction: ltr;
text-align: left;
white-space: pre-wrap;
line-height: 1.4;
}
.code-block pre {
margin: 0;
padding: 0;
background: none;
border: none;
font-family: inherit;
font-size: 14px;
white-space: pre-wrap;
word-wrap: break-word;
}
.comment {
color: #95a5a6;
font-style: italic;
}
.keyword {
color: #3498db;
font-weight: bold;
}
.string {
color: #e74c3c;
}
.number {
color: #f39c12;
}
.highlight {
background: #f39c12;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-weight: bold;
}
.formula {
background: #e8f5e8;
border: 2px solid #27ae60;
padding: 15px;
border-radius: 8px;
margin: 15px 0;
font-family: 'Courier New', monospace;
direction: ltr;
text-align: center;
}
.step {
background: #fff3cd;
border-right: 4px solid #ffc107;
padding: 15px;
margin: 15px 0;
border-radius: 5px;
}
.step-number {
background: #ffc107;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
margin-left: 10px;
}
.prayer-times-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.prayer-times-table th,
.prayer-times-table td {
padding: 12px 15px;
text-align: center;
border-bottom: 1px solid #ecf0f1;
}
.prayer-times-table th {
background: #34495e;
color: white;
font-weight: bold;
}
.prayer-times-table tr:nth-child(even) {
background: #f8f9fa;
}
.note {
background: #d1ecf1;
border: 1px solid #bee5eb;
border-radius: 5px;
padding: 15px;
margin: 15px 0;
}
.note::before {
content: "💡 ";
font-size: 1.2em;
}
.warning {
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 5px;
padding: 15px;
margin: 15px 0;
}
.warning::before {
content: "⚠️ ";
font-size: 1.2em;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>راهنمای محاسبه اوقات شرعی</h1>
<p>تحلیل کامل کلاس PrayTimes و الگوریتم‌های محاسبه اوقات اذان</p>
</div>
<div class="content">
<!-- مرحله اول: معرفی کلی -->
<div class="section">
<h2>مرحله اول: معرفی کلی سیستم</h2>
<p>کلاس <span class="highlight">PrayTimes</span> یک سیستم پیشرفته برای محاسبه اوقات شرعی است که بر اساس موقعیت جغرافیایی، تاریخ و روش‌های مختلف محاسبه عمل می‌کند.</p>
<h3>ویژگی‌های کلیدی:</h3>
<ul>
<li>محاسبه دقیق بر اساس <strong>Julian Date</strong></li>
<li>پشتیبانی از روش‌های مختلف محاسبه (MWL, ISNA, Karachi و...)</li>
<li>تنظیم خودکار برای عرض‌های جغرافیایی بالا</li>
<li>خروجی به صورت اعداد اعشاری (ساعت از روز)</li>
</ul>
<div class="code-block">
<pre><span class="comment">// نمونه ایجاد instance از کلاس PrayTimes</span>
<span class="keyword">PrayTimes</span> prayTimes = <span class="keyword">PrayTimes</span>(
calendar: <span class="keyword">DateTime</span>(<span class="number">2024</span>, <span class="number">3</span>, <span class="number">15</span>), <span class="comment">// تاریخ</span>
coordinates: <span class="keyword">Coordinates</span>( <span class="comment">// موقعیت جغرافیایی</span>
latitude: <span class="number">35.6892</span>, <span class="comment">// عرض جغرافیایی تهران</span>
longitude: <span class="number">51.3890</span> <span class="comment">// طول جغرافیایی تهران</span>
),
method: <span class="keyword">CalculationMethod</span>.Tehran, <span class="comment">// روش محاسبه</span>
highLatitudesMethod: <span class="keyword">HighLatitudesMethod</span>.NightMiddle,
in12Hours: <span class="keyword">false</span> <span class="comment">// فرمت 24 ساعته</span>
);</pre>
</div>
<div class="note">
هر زمان اذان به صورت یک عدد اعشاری بین 0 تا 24 نمایش داده می‌شود که نشان‌دهنده ساعت از ابتدای روز است.
</div>
</div>
<!-- مرحله دوم: ساختار کلاس و متدهای اصلی -->
<div class="section">
<h2>مرحله دوم: ساختار کلاس و متدهای اصلی</h2>
<h3>متغیرهای اصلی کلاس:</h3>
<div class="code-block">
<pre><span class="keyword">class</span> <span class="keyword">PrayTimes</span> {
<span class="comment">// اوقات به صورت اعداد اعشاری (0-24)</span>
<span class="keyword">late double</span> imsak; <span class="comment">// امساک</span>
<span class="keyword">late double</span> fajr; <span class="comment">// فجر</span>
<span class="keyword">late double</span> sunrise; <span class="comment">// طلوع آفتاب</span>
<span class="keyword">late double</span> dhuhr; <span class="comment">// ظهر</span>
<span class="keyword">late double</span> asr; <span class="comment">// عصر</span>
<span class="keyword">late double</span> sunset; <span class="comment">// غروب آفتاب</span>
<span class="keyword">late double</span> maghrib; <span class="comment">// مغرب</span>
<span class="keyword">late double</span> isha; <span class="comment">// عشا</span>
<span class="keyword">late double</span> midnight; <span class="comment">// نیمه شب</span>
<span class="keyword">List&lt;double&gt;</span> allTimes = []; <span class="comment">// لیست تمام اوقات</span>
<span class="keyword">List&lt;DateTime?&gt;</span> allinDateTime = []; <span class="comment">// تبدیل به DateTime</span>
}</pre>
</div>
<h3>مراحل محاسبه در Constructor:</h3>
<div class="step">
<span class="step-number">1</span>
<strong>محاسبه Julian Date:</strong>
<div class="formula">
jdate = julian(year, month, day) - longitude / (15.0 * 24.0)
</div>
<p>Julian Date یک سیستم شمارش روزها از تاریخ مشخصی است که در نجوم استفاده می‌شود.</p>
</div>
<div class="step">
<span class="step-number">2</span>
<strong>محاسبه اوقات اولیه:</strong>
<div class="code-block">
<pre><span class="comment">// محاسبه هر یک از اوقات بر اساس زاویه خورشید</span>
<span class="keyword">double</span> fajr = sunAngleTime(jdate, method.fajr, _DEFAULT_FAJR, <span class="keyword">true</span>, coordinates);
<span class="keyword">double</span> sunrise = sunAngleTime(jdate, riseSetAngle(coordinates), _DEFAULT_SUNRISE, <span class="keyword">true</span>, coordinates);
<span class="keyword">double</span> dhuhr = midDay(jdate, _DEFAULT_DHUHR);
<span class="keyword">double</span> asr = asrTime(jdate, asrMethod.asrFactor, _DEFAULT_ASR, coordinates);
<span class="keyword">double</span> sunset = sunAngleTime(jdate, riseSetAngle(coordinates), _DEFAULT_SUNSET, <span class="keyword">false</span>, coordinates);
<span class="keyword">double</span> maghrib = sunAngleTime(jdate, method.maghrib, _DEFAULT_MAGHRIB, <span class="keyword">false</span>, coordinates);
<span class="keyword">double</span> isha = sunAngleTime(jdate, method.isha, _DEFAULT_ISHA, <span class="keyword">false</span>, coordinates);</pre>
</div>
</div>
<div class="step">
<span class="step-number">3</span>
<strong>تنظیم TimeZone:</strong>
<div class="code-block">
<pre><span class="comment">// محاسبه offset برای timezone و longitude</span>
<span class="keyword">double</span> offset = <span class="keyword">DateTime</span>.now().timeZoneOffset.inMilliseconds / (<span class="number">60</span> * <span class="number">60</span> * <span class="number">1000.0</span>);
<span class="keyword">double</span> addToAll = offset - coordinates.longitude / <span class="number">15.0</span>;
<span class="comment">// اعمال offset به تمام اوقات</span>
fajr += addToAll;
sunrise += addToAll;
dhuhr += addToAll;
<span class="comment">// ... سایر اوقات</span></pre>
</div>
</div>
</div>
<!-- مرحله سوم: الگوریتم‌های محاسبه -->
<div class="section">
<h2>مرحله سوم: الگوریتم‌های محاسبه دقیق</h2>
<h3>1. محاسبه موقعیت خورشید (sunPosition):</h3>
<div class="code-block">
<pre><span class="keyword">DeclEqt</span> sunPosition(<span class="keyword">double</span> jd) {
<span class="keyword">double</span> D = jd - <span class="number">2451545.0</span>; <span class="comment">// روزهای گذشته از epoch</span>
<span class="keyword">double</span> g = (<span class="number">357.529</span> + <span class="number">0.98560028</span> * D) % <span class="number">360</span>; <span class="comment">// Mean anomaly</span>
<span class="keyword">double</span> q = (<span class="number">280.459</span> + <span class="number">0.98564736</span> * D) % <span class="number">360</span>; <span class="comment">// Mean longitude</span>
<span class="comment">// محاسبه True longitude</span>
<span class="keyword">double</span> L = (q + <span class="number">1.915</span> * sin(dtr(g)) + <span class="number">0.020</span> * sin(dtr(<span class="number">2.0</span> * g))) % <span class="number">360</span>;
<span class="keyword">double</span> e = <span class="number">23.439</span> - <span class="number">0.00000036</span> * D; <span class="comment">// Obliquity of ecliptic</span>
<span class="comment">// محاسبه Right Ascension و Equation of Time</span>
<span class="keyword">double</span> RA = rtd(atan2(cos(dtr(e)) * sin(dtr(L)), cos(dtr(L)))) / <span class="number">15.0</span>;
<span class="keyword">double</span> eqt = q / <span class="number">15.0</span> - fixHour(RA);
<span class="keyword">double</span> decl = asin(sin(dtr(e)) * sin(dtr(L))); <span class="comment">// Declination</span>
<span class="keyword">return</span> <span class="keyword">DeclEqt</span>(decl, eqt);
}</pre>
</div>
<h3>2. محاسبه زمان بر اساس زاویه خورشید (sunAngleTime):</h3>
<div class="code-block">
<pre><span class="keyword">double</span> sunAngleTime(<span class="keyword">double</span> jdate, <span class="keyword">MinuteOrAngleDouble</span> angle, <span class="keyword">double</span> time,
<span class="keyword">bool</span> ccw, <span class="keyword">Coordinates</span> coordinates) {
<span class="keyword">double</span> decl = sunPosition(jdate + time).declination;
<span class="keyword">double</span> noon = dtr(midDay(jdate, time));
<span class="comment">// فرمول اصلی محاسبه زمان بر اساس زاویه</span>
<span class="keyword">double</span> t = acos((-sin(dtr(angle.value)) -
sin(decl) * sin(dtr(coordinates.latitude))) /
(cos(decl) * cos(dtr(coordinates.latitude)))) / <span class="number">15.0</span>;
<span class="keyword">return</span> rtd(noon + (ccw ? -t : t));
}</pre>
</div>
<div class="note">
پارامتر <strong>ccw</strong> (Counter Clock Wise) تعیین می‌کند که آیا زمان قبل از ظهر (true) یا بعد از ظهر (false) محاسبه شود.
</div>
<h3>3. محاسبه زمان عصر (asrTime):</h3>
<div class="code-block">
<pre><span class="keyword">double</span> asrTime(<span class="keyword">double</span> jdate, <span class="keyword">double</span> factor, <span class="keyword">double</span> time, <span class="keyword">Coordinates</span> coordinates) {
<span class="keyword">double</span> decl = sunPosition(jdate + time).declination;
<span class="comment">// محاسبه زاویه بر اساس فاکتور عصر (1 برای استاندارد، 2 برای حنفی)</span>
<span class="keyword">double</span> angle = -atan(<span class="number">1</span> / (factor + tan(abs(dtr(coordinates.latitude) - decl))));
<span class="keyword">return</span> sunAngleTime(jdate, <span class="keyword">MinuteOrAngleDouble</span>.deg(rtd(angle)), time, <span class="keyword">false</span>, coordinates);
}</pre>
</div>
</div>
<!-- مرحله چهارم: روش‌های محاسبه مختلف -->
<div class="section">
<h2>مرحله چهارم: روش‌های محاسبه مختلف</h2>
<p>هر روش محاسبه دارای زوایای مختلفی برای فجر، مغرب و عشا است:</p>
<table class="prayer-times-table">
<thead>
<tr>
<th>روش محاسبه</th>
<th>زاویه فجر</th>
<th>زاویه عشا</th>
<th>مغرب</th>
<th>کاربرد</th>
</tr>
</thead>
<tbody>
<tr>
<td>MWL (Muslim World League)</td>
<td>18°</td>
<td>17°</td>
<td>غروب + دقیقه</td>
<td>اروپا، آمریکا</td>
</tr>
<tr>
<td>ISNA (North America)</td>
<td>15°</td>
<td>15°</td>
<td>غروب + دقیقه</td>
<td>آمریکای شمالی</td>
</tr>
<tr>
<td>University of Karachi</td>
<td>18°</td>
<td>18°</td>
<td>غروب + دقیقه</td>
<td>پاکستان، هند</td>
</tr>
<tr>
<td>Umm Al-Qura (مکه)</td>
<td>18.5°</td>
<td>90 دقیقه بعد مغرب</td>
<td>غروب + دقیقه</td>
<td>عربستان سعودی</td>
</tr>
<tr>
<td>Egyptian Authority</td>
<td>19.5°</td>
<td>17.5°</td>
<td>غروب + دقیقه</td>
<td>مصر، خاورمیانه</td>
</tr>
<tr>
<td>Institute of Tehran</td>
<td>17.7°</td>
<td>14°</td>
<td>4.5°</td>
<td>ایران</td>
</tr>
<tr>
<td>Ithna Ashari</td>
<td>16°</td>
<td>14°</td>
<td></td>
<td>شیعه</td>
</tr>
</tbody>
</table>
<div class="code-block">
<pre><span class="comment">// نمونه تعریف روش محاسبه در enum</span>
<span class="keyword">enum</span> <span class="keyword">CalculationMethod</span> {
IthnaAshari, <span class="comment">// اثنی عشری</span>
Karachi, <span class="comment">// کراچی</span>
NorthAmerica, <span class="comment">// آمریکای شمالی</span>
MWL, <span class="comment">// رابطه جهانی اسلامی</span>
UmmAlQura, <span class="comment">// ام القری</span>
Egyptian, <span class="comment">// مصری</span>
Tehran, <span class="comment">// تهران</span>
}</pre>
</div>
</div>
<!-- مرحله پنجم: تنظیمات عرض‌های جغرافیایی بالا -->
<div class="section">
<h2>مرحله پنجم: تنظیمات عرض‌های جغرافیایی بالا</h2>
<p>در عرض‌های جغرافیایی بالا (بالای 49 درجه)، ممکن است برخی اوقات قابل محاسبه نباشند. برای حل این مشکل از روش‌های مختلفی استفاده می‌شود:</p>
<h3>روش‌های تنظیم:</h3>
<div class="step">
<span class="step-number">1</span>
<strong>NightMiddle (وسط شب):</strong>
<p>فجر و عشا بر اساس نیمه شب محاسبه می‌شوند.</p>
<div class="formula">
portion = 1/2 * nightTime
</div>
</div>
<div class="step">
<span class="step-number">2</span>
<strong>AngleBased (بر اساس زاویه):</strong>
<p>بر اساس زاویه مشخص شده محاسبه می‌شود.</p>
<div class="formula">
portion = angle/60 * nightTime
</div>
</div>
<div class="step">
<span class="step-number">3</span>
<strong>OneSeventh (یک هفتم شب):</strong>
<p>یک هفتم از طول شب استفاده می‌شود.</p>
<div class="formula">
portion = 1/7 * nightTime
</div>
</div>
<div class="code-block">
<pre><span class="comment">// تنظیم اوقات برای عرض‌های جغرافیایی بالا</span>
<span class="keyword">if</span> (highLatitudesMethod != <span class="keyword">HighLatitudesMethod</span>.None) {
<span class="keyword">double</span> nightTime = timeDiff(sunset, sunrise);
fajr = adjustHLTime(highLatitudesMethod, fajr, sunrise,
method.fajr.value, nightTime, <span class="keyword">true</span>);
isha = adjustHLTime(highLatitudesMethod, isha, sunset,
method.isha.value, nightTime, <span class="keyword">false</span>);
}</pre>
</div>
<div class="warning">
در کد پروژه، اگر عرض جغرافیایی کمتر از 49 درجه باشد، به طور خودکار از روش NightMiddle استفاده می‌شود.
</div>
</div>
<!-- مرحله ششم: تبدیل اعداد اعشاری به زمان -->
<div class="section">
<h2>مرحله ششم: تبدیل اعداد اعشاری به زمان</h2>
<p>خروجی کلاس PrayTimes اعداد اعشاری هستند که نشان‌دهنده ساعت از ابتدای روز می‌باشند. این اعداد باید به فرمت زمان قابل خواندن تبدیل شوند.</p>
<h3>نحوه تبدیل:</h3>
<div class="step">
<span class="step-number">1</span>
<strong>جدا کردن ساعت و دقیقه:</strong>
<div class="code-block">
<pre><span class="keyword">String</span> <span class="keyword">get</span> floatToTime24 {
<span class="keyword">var</span> time = <span class="keyword">this</span>;
<span class="keyword">if</span> (time == <span class="keyword">null</span> || time.isNaN) <span class="keyword">return</span> <span class="string">"----"</span>;
time = _fixHour(time + <span class="number">0.5</span> / <span class="number">60.0</span>); <span class="comment">// اضافه کردن 0.5 دقیقه برای گرد کردن</span>
<span class="keyword">int</span> hours = (time).floor(); <span class="comment">// بخش صحیح = ساعت</span>
<span class="keyword">double</span> minutes = ((time - hours) * <span class="number">60.0</span>).floorToDouble(); <span class="comment">// بخش اعشاری × 60 = دقیقه</span>
<span class="comment">// فرمت کردن با صفر اضافی</span>
<span class="keyword">return</span> <span class="string">"${hours.toString().padLeft(2, '0')}:${minutes.round().toString().padLeft(2, '0')}"</span>;
}</pre>
</div>
</div>
<div class="step">
<span class="step-number">2</span>
<strong>مثال عملی:</strong>
<div class="formula">
اگر fajr = 5.25 باشد:<br>
ساعت = 5 (بخش صحیح)<br>
دقیقه = 0.25 × 60 = 15<br>
نتیجه = "05:15"
</div>
</div>
<h3>تبدیل به فرمت 12 ساعته:</h3>
<div class="code-block">
<pre><span class="keyword">String</span> <span class="keyword">get</span> floatToTime12 {
<span class="comment">// ... محاسبه ساعت و دقیقه مشابه بالا</span>
<span class="keyword">if</span> (hours >= <span class="number">12</span> && hours < <span class="number">24</span>) {
<span class="keyword">var</span> hourss = hours - <span class="number">12</span>;
<span class="keyword">if</span> (hourss == <span class="number">0</span>) hourss = <span class="number">12</span>; <span class="comment">// 12 PM نه 0 PM</span>
<span class="comment">// فرمت کردن...</span>
} <span class="keyword">else</span> {
<span class="keyword">var</span> hourss = hours;
<span class="keyword">if</span> (hourss == <span class="number">0</span>) hourss = <span class="number">12</span>; <span class="comment">// 12 AM نه 0 AM</span>
<span class="comment">// فرمت کردن...</span>
}
}
<span class="keyword">String</span> <span class="keyword">get</span> amPm {
<span class="keyword">int</span> hours = (<span class="keyword">this</span>).floor();
<span class="keyword">return</span> hours >= <span class="number">12</span> && hours < <span class="number">24</span> ? <span class="string">'PM'</span> : <span class="string">'AM'</span>;
}</pre>
</div>
</div>
<!-- مرحله هفتم: نمونه کد کامل -->
<div class="section">
<h2>مرحله هفتم: نمونه کد کامل و کاربردی</h2>
<h3>نمونه استفاده کامل:</h3>
<div class="code-block">
<pre><span class="comment">// تعریف موقعیت جغرافیایی تهران</span>
<span class="keyword">Coordinates</span> tehranCoords = <span class="keyword">Coordinates</span>(
latitude: <span class="number">35.6892</span>,
longitude: <span class="number">51.3890</span>,
elevation: <span class="number">1200</span> <span class="comment">// ارتفاع از سطح دریا (متر)</span>
);
<span class="comment">// ایجاد instance برای تاریخ امروز</span>
<span class="keyword">PrayTimes</span> prayTimes = <span class="keyword">PrayTimes</span>(
calendar: <span class="keyword">DateTime</span>.now(),
coordinates: tehranCoords,
method: <span class="keyword">CalculationMethod</span>.Tehran,
highLatitudesMethod: <span class="keyword">HighLatitudesMethod</span>.NightMiddle,
in12Hours: <span class="keyword">false</span>
);
<span class="comment">// دریافت اوقات</span>
print(<span class="string">"فجر: ${prayTimes.fajr.floatToTime24}"</span>); <span class="comment">// مثال: "05:15"</span>
print(<span class="string">"طلوع: ${prayTimes.sunrise.floatToTime24}"</span>); <span class="comment">// مثال: "06:45"</span>
print(<span class="string">"ظهر: ${prayTimes.dhuhr.floatToTime24}"</span>); <span class="comment">// مثال: "12:30"</span>
print(<span class="string">"عصر: ${prayTimes.asr.floatToTime24}"</span>); <span class="comment">// مثال: "15:20"</span>
print(<span class="string">"مغرب: ${prayTimes.maghrib.floatToTime24}"</span>); <span class="comment">// مثال: "18:15"</span>
print(<span class="string">"عشا: ${prayTimes.isha.floatToTime24}"</span>); <span class="comment">// مثال: "19:45"</span>
<span class="comment">// تبدیل به DateTime برای استفاده در برنامه</span>
<span class="keyword">DateTime</span> fajrDateTime = <span class="keyword">DateTime</span>(
prayTimes.calendar.year,
prayTimes.calendar.month,
prayTimes.calendar.day
).add(<span class="keyword">Duration</span>(
hours: prayTimes.fajr.floor(),
minutes: ((prayTimes.fajr - prayTimes.fajr.floor()) * <span class="number">60</span>).round()
));</pre>
</div>
<h3>ایجاد لیست اوقات برای چندین روز:</h3>
<div class="code-block">
<pre><span class="keyword">List&lt;PrayTimeModel&gt;</span> getPrayTimesForMonth(<span class="keyword">DateTime</span> startDate, <span class="keyword">Coordinates</span> coords) {
<span class="keyword">List&lt;PrayTimeModel&gt;</span> allPrayTimes = [];
<span class="keyword">for</span> (<span class="keyword">int</span> i = <span class="number">0</span>; i < <span class="number">30</span>; i++) {
<span class="keyword">DateTime</span> currentDate = startDate.add(<span class="keyword">Duration</span>(days: i));
<span class="keyword">PrayTimes</span> pt = <span class="keyword">PrayTimes</span>(
calendar: currentDate,
coordinates: coords,
method: <span class="keyword">CalculationMethod</span>.Tehran,
highLatitudesMethod: <span class="keyword">HighLatitudesMethod</span>.NightMiddle,
in12Hours: <span class="keyword">false</span>,
);
<span class="comment">// اضافه کردن هر وقت به لیست</span>
<span class="keyword">for</span> (<span class="keyword">int</span> j = <span class="number">0</span>; j < pt.allTimes.length; j++) {
allPrayTimes.add(<span class="keyword">PrayTimeModel</span>(
enumTime: <span class="keyword">EnumTime</span>.values[j],
name: <span class="keyword">EnumTime</span>.values[j].name,
timeInString: pt.allTimes[j].floatToTime24,
dateTime: currentDate.add(<span class="keyword">Duration</span>(
hours: pt.allTimes[j].floor(),
minutes: ((pt.allTimes[j] - pt.allTimes[j].floor()) * <span class="number">60</span>).round()
)),
));
}
}
<span class="keyword">return</span> allPrayTimes;
}</pre>
</div>
</div>
<!-- خلاصه و نکات مهم -->
<div class="section">
<h2>خلاصه و نکات مهم</h2>
<h3>نکات کلیدی:</h3>
<ul>
<li><strong>دقت محاسبات:</strong> تمام محاسبات بر اساس فرمول‌های نجومی دقیق انجام می‌شود</li>
<li><strong>انعطاف‌پذیری:</strong> پشتیبانی از روش‌های مختلف محاسبه برای مناطق مختلف جهان</li>
<li><strong>تنظیم خودکار:</strong> تنظیم خودکار برای عرض‌های جغرافیایی بالا</li>
<li><strong>خروجی استاندارد:</strong> خروجی به صورت اعداد اعشاری قابل تبدیل به هر فرمت</li>
</ul>
<div class="note">
برای استفاده بهینه، توصیه می‌شود اوقات را برای چندین روز آینده محاسبه و ذخیره کنید تا از محاسبات مکرر جلوگیری شود.
</div>
<div class="warning">
دقت کنید که تغییر موقعیت جغرافیایی یا روش محاسبه نیاز به محاسبه مجدد تمام اوقات دارد.
</div>
<h3>منابع و مراجع:</h3>
<ul>
<li>الگوریتم‌های نجومی برای محاسبه موقعیت خورشید</li>
<li>استانداردهای بین‌المللی اوقات شرعی</li>
<li>فرمول‌های ریاضی برای تبدیل مختصات جغرافیایی</li>
</ul>
</div>
</div>
</div>
</body>
</html>

3
templates/utils/widgets/multilang_json_widget.html

@ -275,3 +275,6 @@

3
utils/multilang_json_widget.py

@ -188,3 +188,6 @@ class MultiLanguageJSONWidget(Widget):

50
utils/redis.py

@ -1,8 +1,14 @@
import json
import hashlib
import random import random
import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from redis.exceptions import RedisError from redis.exceptions import RedisError
from django.conf import settings
from config.redis_config import RedisConfig from config.redis_config import RedisConfig
from utils.exceptions import ServiceUnavailableException, NotFoundException from utils.exceptions import ServiceUnavailableException, NotFoundException
@ -68,3 +74,47 @@ class RedisManager(RedisConfig):
def generate_otp_code() -> int: def generate_otp_code() -> int:
random_code = random.randint(10000, 99999) random_code = random.randint(10000, 99999)
return random_code return random_code
class OnlineClassTokenManager(RedisConfig):
"""Manage temporary tokens used for joining online classes."""
KEY_PREFIX = "online_class_token:"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ttl = getattr(settings, "ONLINE_CLASS_TOKEN_TTL", 300)
def _build_key(self, token: str) -> str:
return f"{self.KEY_PREFIX}{token}"
def generate_token(self, course_id: int, user_identifier: str) -> str:
seed = f"{course_id}:{user_identifier}:{secrets.token_urlsafe(16)}"
return hashlib.sha256(seed.encode()).hexdigest()
def store_token(self, token: str, payload: dict, ttl: int | None = None) -> None:
data = {
**payload,
"generated_at": datetime.utcnow().isoformat() + "Z",
}
self.redis.set(self._build_key(token), json.dumps(data), ex=ttl or self.ttl)
def get_payload(self, token: str) -> dict:
stored = self.redis.get(self._build_key(token))
if not stored:
raise NotFoundException("Token not found or has expired.")
return json.loads(stored)
def delete_token(self, token: str) -> None:
self.redis.delete(self._build_key(token))
@staticmethod
def build_entry_url(token: str, base_url: str | None = None) -> str:
base = base_url or getattr(settings, "ONLINE_CLASS_FRONTEND_DOMAIN", getattr(settings, "SITE_DOMAIN", ""))
if not base:
return f"?token={token}"
parsed = urlparse(base)
query_params = dict(parse_qsl(parsed.query))
query_params["token"] = token
new_query = urlencode(query_params)
return urlunparse(parsed._replace(query=new_query))
Loading…
Cancel
Save