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 10 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_public_key="6LdgCjseAAAAAIwg41-kyyulmwDtqD2Gk3THIwy2"
captcha_private_key="6LdgCjseAAAAAPHMsIHuQgYAGTJ7_QlhqG4G0NyS"
ONLINE_CLASS_FRONTEND_DOMAIN="imamjavad.newhorizonco.uk"
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 secrets
from dj_language.field import LanguageField
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from django.utils import timezone
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)
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_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)
@ -83,8 +87,12 @@ class User(AbstractUser):
def save(self, *args, **kwargs):
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}'
if self.user_type == self.UserType.PROFESSOR:
self._ensure_professor_slug()
return super().save(*args, **kwargs)
def get_full_name(self):
@ -194,6 +202,67 @@ class User(AbstractUser):
return course.professor == self
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:
ordering = ("-id",)

2
apps/course/serializers/__init__.py

@ -1,3 +1,5 @@
from .course import *
from .lesson 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.dispatch import receiver
from apps.course.models import Course
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('my-courses/', views.MyCourseListAPIView.as_view(), name='course-my-courses-list'),
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>/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'),

1
apps/course/views/__init__.py

@ -1,3 +1,4 @@
from .course import *
from .lesson 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 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.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.permissions import IsAuthenticated
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 (
CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer,
CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer
CourseAttachmentSerializer, CourseGlossarySerializer, MyCourseListSerializer,
OnlineClassTokenCreateSerializer, OnlineClassTokenVerifySerializer
)
from apps.course.models import Course, CourseCategory, CourseAttachment, CourseGlossary, Participant
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):
@ -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_INDEX_TITLE = 'Imam Javad Administration'
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)

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 secrets
from datetime import datetime, timedelta
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
from redis.exceptions import RedisError
from django.conf import settings
from config.redis_config import RedisConfig
from utils.exceptions import ServiceUnavailableException, NotFoundException
@ -68,3 +74,47 @@ class RedisManager(RedisConfig):
def generate_otp_code() -> int:
random_code = random.randint(10000, 99999)
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