From a210e19fd00681371daa17fea3d57dcc7cd0d6d8 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Mon, 6 Oct 2025 11:27:50 +0330 Subject: [PATCH] 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. --- .env.prod | 2 +- adjustemnts.md | 607 ++++++++++++++++ .../commands/assign_professor_slugs.py | 28 + .../migrations/0006_auto_20251006_1101.py | 45 ++ apps/account/models/user.py | 71 +- apps/course/serializers/__init__.py | 4 +- apps/course/serializers/online.py | 21 + apps/course/serializers/professor.py | 27 + apps/course/signals.py | 8 + apps/course/tests/__init__.py | 1 + apps/course/tests/test_professor_api.py | 113 +++ apps/course/urls.py | 5 + apps/course/views/__init__.py | 3 +- apps/course/views/course.py | 159 +++- apps/course/views/professor.py | 171 +++++ config/settings/base.py | 2 + config/settings/test.py | 12 + docs/MultiLanguageJSONWidget.md | 3 + docs/calendar_prayer_guide.fa.md | 263 +++++++ docs/online_class_entry_flow.md | 91 +++ prayer_times_calculation_guide.html | 678 ++++++++++++++++++ .../utils/widgets/multilang_json_widget.html | 3 + utils/multilang_json_widget.py | 3 + utils/redis.py | 52 +- 24 files changed, 2363 insertions(+), 9 deletions(-) create mode 100644 adjustemnts.md create mode 100644 apps/account/management/commands/assign_professor_slugs.py create mode 100644 apps/account/migrations/0006_auto_20251006_1101.py create mode 100644 apps/course/serializers/online.py create mode 100644 apps/course/serializers/professor.py create mode 100644 apps/course/tests/__init__.py create mode 100644 apps/course/tests/test_professor_api.py create mode 100644 apps/course/views/professor.py create mode 100644 config/settings/test.py create mode 100644 docs/calendar_prayer_guide.fa.md create mode 100644 docs/online_class_entry_flow.md create mode 100644 prayer_times_calculation_guide.html diff --git a/.env.prod b/.env.prod index 23ee488..26f17ca 100644 --- a/.env.prod +++ b/.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="" diff --git a/adjustemnts.md b/adjustemnts.md new file mode 100644 index 0000000..9717968 --- /dev/null +++ b/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 + ] + } + } +] \ No newline at end of file diff --git a/apps/account/management/commands/assign_professor_slugs.py b/apps/account/management/commands/assign_professor_slugs.py new file mode 100644 index 0000000..4724e03 --- /dev/null +++ b/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).") + ) diff --git a/apps/account/migrations/0006_auto_20251006_1101.py b/apps/account/migrations/0006_auto_20251006_1101.py new file mode 100644 index 0000000..c4ae8fc --- /dev/null +++ b/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), + ] diff --git a/apps/account/models/user.py b/apps/account/models/user.py index 5fe258f..2b3bb05 100644 --- a/apps/account/models/user.py +++ b/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): @@ -193,6 +201,67 @@ class User(AbstractUser): if course and self.has_role('professor'): 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: diff --git a/apps/course/serializers/__init__.py b/apps/course/serializers/__init__.py index e86b7ee..bd6a92c 100644 --- a/apps/course/serializers/__init__.py +++ b/apps/course/serializers/__init__.py @@ -1,3 +1,5 @@ from .course import * from .lesson import * -from .participant import * \ No newline at end of file +from .participant import * +from .online import * +from .professor import * \ No newline at end of file diff --git a/apps/course/serializers/online.py b/apps/course/serializers/online.py new file mode 100644 index 0000000..86e0188 --- /dev/null +++ b/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 diff --git a/apps/course/serializers/professor.py b/apps/course/serializers/professor.py new file mode 100644 index 0000000..37fd68a --- /dev/null +++ b/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'])) diff --git a/apps/course/signals.py b/apps/course/signals.py index d0edf96..0d6f50d 100644 --- a/apps/course/signals.py +++ b/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 @@ -15,5 +16,12 @@ def create_room_message_for_course(sender, instance, created, **kwargs): course=instance, room_type=RoomMessage.RoomTypeChoices.GROUP ) + + +@receiver(post_save, sender=Course) +def ensure_professor_role(sender, instance, **kwargs): + professor = getattr(instance, 'professor', None) + if professor: + professor.ensure_professor_profile() \ No newline at end of file diff --git a/apps/course/tests/__init__.py b/apps/course/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/course/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/course/tests/test_professor_api.py b/apps/course/tests/test_professor_api.py new file mode 100644 index 0000000..72bd79d --- /dev/null +++ b/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) diff --git a/apps/course/urls.py b/apps/course/urls.py index ff96734..79f466b 100644 --- a/apps/course/urls.py +++ b/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//courses/', views.ProfessorCourseListAPIView.as_view(), name='course-professor-course-list'), + path('professors//', views.ProfessorDetailAPIView.as_view(), name='course-professor-detail'), + path('/online/token/', views.CourseOnlineClassTokenAPIView.as_view(), name='course-online-token'), + path('online/token/validate/', views.CourseOnlineClassTokenValidateAPIView.as_view(), name='course-online-token-validate'), path('/', views.CourseDetailAPIView.as_view(), name='course-detail'), path('/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py index e86b7ee..edc809c 100644 --- a/apps/course/views/__init__.py +++ b/apps/course/views/__init__.py @@ -1,3 +1,4 @@ from .course import * from .lesson import * -from .participant import * \ No newline at end of file +from .participant import * +from .professor import * \ No newline at end of file diff --git a/apps/course/views/course.py b/apps/course/views/course.py index e268c76..35ba5e2 100644 --- a/apps/course/views/course.py +++ b/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): @@ -266,4 +281,140 @@ class GlossaryListAPIView(ListAPIView): ).filter(course=course) + +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": "", + "url": "https://frontend.example.com?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(), + } \ No newline at end of file diff --git a/apps/course/views/professor.py b/apps/course/views/professor.py new file mode 100644 index 0000000..317a66d --- /dev/null +++ b/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) diff --git a/config/settings/base.py b/config/settings/base.py index bfc2811..97d28bd 100755 --- a/config/settings/base.py +++ b/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) diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..34653b3 --- /dev/null +++ b/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' diff --git a/docs/MultiLanguageJSONWidget.md b/docs/MultiLanguageJSONWidget.md index 4420eb2..425c883 100644 --- a/docs/MultiLanguageJSONWidget.md +++ b/docs/MultiLanguageJSONWidget.md @@ -61,3 +61,6 @@ + + + diff --git a/docs/calendar_prayer_guide.fa.md b/docs/calendar_prayer_guide.fa.md new file mode 100644 index 0000000..9da2d8e --- /dev/null +++ b/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> map; + + LunarAdjustConfig({required this.adjust, required this.map}); + + factory LunarAdjustConfig.fromJson(Map json) { + return LunarAdjustConfig( + adjust: json['adjust'] as int, + map: (json['map'] as Map).map( + (key, value) => MapEntry(key, List.from(value)), + ), + ); + } +} + +Future> fetchAdjustments() async { + final res = await http.get(Uri.parse('https://example.com/api/calendar/adjustemnts/')); + final data = jsonDecode(res.body) as List; + 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(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` استفاده کرد که با ورودی‌های تاریخ، مختصات و روش محاسبه، زمان‌های اذان را بازمی‌گرداند. +- نمونه کدهای فلاتر و جاوااسکریپت نشان می‌دهند چگونه می‌توان در کلاینت هم تاریخ‌های قمری را به میلادی تبدیل کرد و هم اوقات شرعی را به‌صورت محلی محاسبه نمود. diff --git a/docs/online_class_entry_flow.md b/docs/online_class_entry_flow.md new file mode 100644 index 0000000..75fcc7f --- /dev/null +++ b/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` نشان می‌دهد لینک چه مدت اعتبار دارد؛ بهتر است شمارش معکوس یا تلاش خودکار برای تمدید توکن داشته باشید. diff --git a/prayer_times_calculation_guide.html b/prayer_times_calculation_guide.html new file mode 100644 index 0000000..e9c38e4 --- /dev/null +++ b/prayer_times_calculation_guide.html @@ -0,0 +1,678 @@ + + + + + + راهنمای محاسبه اوقات شرعی - PrayTimes Class + + + +
+
+

راهنمای محاسبه اوقات شرعی

+

تحلیل کامل کلاس PrayTimes و الگوریتم‌های محاسبه اوقات اذان

+
+ +
+ +
+

مرحله اول: معرفی کلی سیستم

+ +

کلاس PrayTimes یک سیستم پیشرفته برای محاسبه اوقات شرعی است که بر اساس موقعیت جغرافیایی، تاریخ و روش‌های مختلف محاسبه عمل می‌کند.

+ +

ویژگی‌های کلیدی:

+
    +
  • محاسبه دقیق بر اساس Julian Date
  • +
  • پشتیبانی از روش‌های مختلف محاسبه (MWL, ISNA, Karachi و...)
  • +
  • تنظیم خودکار برای عرض‌های جغرافیایی بالا
  • +
  • خروجی به صورت اعداد اعشاری (ساعت از روز)
  • +
+ +
+
// نمونه ایجاد instance از کلاس PrayTimes
+PrayTimes prayTimes = PrayTimes(
+    calendar: DateTime(2024, 3, 15),           // تاریخ
+    coordinates: Coordinates(                   // موقعیت جغرافیایی
+        latitude: 35.6892,                     // عرض جغرافیایی تهران
+        longitude: 51.3890                     // طول جغرافیایی تهران
+    ),
+    method: CalculationMethod.Tehran,           // روش محاسبه
+    highLatitudesMethod: HighLatitudesMethod.NightMiddle,
+    in12Hours: false                           // فرمت 24 ساعته
+);
+
+ +
+ هر زمان اذان به صورت یک عدد اعشاری بین 0 تا 24 نمایش داده می‌شود که نشان‌دهنده ساعت از ابتدای روز است. +
+
+ + +
+

مرحله دوم: ساختار کلاس و متدهای اصلی

+ +

متغیرهای اصلی کلاس:

+
+
class PrayTimes {
+    // اوقات به صورت اعداد اعشاری (0-24)
+    late double imsak;      // امساک
+    late double fajr;       // فجر
+    late double sunrise;    // طلوع آفتاب
+    late double dhuhr;      // ظهر
+    late double asr;        // عصر
+    late double sunset;     // غروب آفتاب
+    late double maghrib;    // مغرب
+    late double isha;       // عشا
+    late double midnight;   // نیمه شب
+
+    List<double> allTimes = [];        // لیست تمام اوقات
+    List<DateTime?> allinDateTime = []; // تبدیل به DateTime
+}
+
+ +

مراحل محاسبه در Constructor:

+ +
+ 1 + محاسبه Julian Date: +
+ jdate = julian(year, month, day) - longitude / (15.0 * 24.0) +
+

Julian Date یک سیستم شمارش روزها از تاریخ مشخصی است که در نجوم استفاده می‌شود.

+
+ +
+ 2 + محاسبه اوقات اولیه: +
+
// محاسبه هر یک از اوقات بر اساس زاویه خورشید
+double fajr = sunAngleTime(jdate, method.fajr, _DEFAULT_FAJR, true, coordinates);
+double sunrise = sunAngleTime(jdate, riseSetAngle(coordinates), _DEFAULT_SUNRISE, true, coordinates);
+double dhuhr = midDay(jdate, _DEFAULT_DHUHR);
+double asr = asrTime(jdate, asrMethod.asrFactor, _DEFAULT_ASR, coordinates);
+double sunset = sunAngleTime(jdate, riseSetAngle(coordinates), _DEFAULT_SUNSET, false, coordinates);
+double maghrib = sunAngleTime(jdate, method.maghrib, _DEFAULT_MAGHRIB, false, coordinates);
+double isha = sunAngleTime(jdate, method.isha, _DEFAULT_ISHA, false, coordinates);
+
+
+ +
+ 3 + تنظیم TimeZone: +
+
// محاسبه offset برای timezone و longitude
+double offset = DateTime.now().timeZoneOffset.inMilliseconds / (60 * 60 * 1000.0);
+double addToAll = offset - coordinates.longitude / 15.0;
+
+// اعمال offset به تمام اوقات
+fajr += addToAll;
+sunrise += addToAll;
+dhuhr += addToAll;
+// ... سایر اوقات
+
+
+
+ + +
+

مرحله سوم: الگوریتم‌های محاسبه دقیق

+ +

1. محاسبه موقعیت خورشید (sunPosition):

+
+
DeclEqt sunPosition(double jd) {
+    double D = jd - 2451545.0;                    // روزهای گذشته از epoch
+    double g = (357.529 + 0.98560028 * D) % 360;  // Mean anomaly
+    double q = (280.459 + 0.98564736 * D) % 360;  // Mean longitude
+
+    // محاسبه True longitude
+    double L = (q + 1.915 * sin(dtr(g)) + 0.020 * sin(dtr(2.0 * g))) % 360;
+
+    double e = 23.439 - 0.00000036 * D;           // Obliquity of ecliptic
+
+    // محاسبه Right Ascension و Equation of Time
+    double RA = rtd(atan2(cos(dtr(e)) * sin(dtr(L)), cos(dtr(L)))) / 15.0;
+    double eqt = q / 15.0 - fixHour(RA);
+    double decl = asin(sin(dtr(e)) * sin(dtr(L))); // Declination
+
+    return DeclEqt(decl, eqt);
+}
+
+ +

2. محاسبه زمان بر اساس زاویه خورشید (sunAngleTime):

+
+
double sunAngleTime(double jdate, MinuteOrAngleDouble angle, double time,
+                   bool ccw, Coordinates coordinates) {
+    double decl = sunPosition(jdate + time).declination;
+    double noon = dtr(midDay(jdate, time));
+
+    // فرمول اصلی محاسبه زمان بر اساس زاویه
+    double t = acos((-sin(dtr(angle.value)) -
+                    sin(decl) * sin(dtr(coordinates.latitude))) /
+                   (cos(decl) * cos(dtr(coordinates.latitude)))) / 15.0;
+
+    return rtd(noon + (ccw ? -t : t));
+}
+
+ +
+ پارامتر ccw (Counter Clock Wise) تعیین می‌کند که آیا زمان قبل از ظهر (true) یا بعد از ظهر (false) محاسبه شود. +
+ +

3. محاسبه زمان عصر (asrTime):

+
+
double asrTime(double jdate, double factor, double time, Coordinates coordinates) {
+    double decl = sunPosition(jdate + time).declination;
+
+    // محاسبه زاویه بر اساس فاکتور عصر (1 برای استاندارد، 2 برای حنفی)
+    double angle = -atan(1 / (factor + tan(abs(dtr(coordinates.latitude) - decl))));
+
+    return sunAngleTime(jdate, MinuteOrAngleDouble.deg(rtd(angle)), time, false, coordinates);
+}
+
+
+ + +
+

مرحله چهارم: روش‌های محاسبه مختلف

+ +

هر روش محاسبه دارای زوایای مختلفی برای فجر، مغرب و عشا است:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
روش محاسبهزاویه فجرزاویه عشامغربکاربرد
MWL (Muslim World League)18°17°غروب + دقیقهاروپا، آمریکا
ISNA (North America)15°15°غروب + دقیقهآمریکای شمالی
University of Karachi18°18°غروب + دقیقهپاکستان، هند
Umm Al-Qura (مکه)18.5°90 دقیقه بعد مغربغروب + دقیقهعربستان سعودی
Egyptian Authority19.5°17.5°غروب + دقیقهمصر، خاورمیانه
Institute of Tehran17.7°14°4.5°ایران
Ithna Ashari16°14°شیعه
+ +
+
// نمونه تعریف روش محاسبه در enum
+enum CalculationMethod {
+    IthnaAshari,    // اثنی عشری
+    Karachi,        // کراچی
+    NorthAmerica,   // آمریکای شمالی
+    MWL,            // رابطه جهانی اسلامی
+    UmmAlQura,      // ام القری
+    Egyptian,       // مصری
+    Tehran,         // تهران
+}
+
+
+ + +
+

مرحله پنجم: تنظیمات عرض‌های جغرافیایی بالا

+ +

در عرض‌های جغرافیایی بالا (بالای 49 درجه)، ممکن است برخی اوقات قابل محاسبه نباشند. برای حل این مشکل از روش‌های مختلفی استفاده می‌شود:

+ +

روش‌های تنظیم:

+ +
+ 1 + NightMiddle (وسط شب): +

فجر و عشا بر اساس نیمه شب محاسبه می‌شوند.

+
+ portion = 1/2 * nightTime +
+
+ +
+ 2 + AngleBased (بر اساس زاویه): +

بر اساس زاویه مشخص شده محاسبه می‌شود.

+
+ portion = angle/60 * nightTime +
+
+ +
+ 3 + OneSeventh (یک هفتم شب): +

یک هفتم از طول شب استفاده می‌شود.

+
+ portion = 1/7 * nightTime +
+
+ +
+
// تنظیم اوقات برای عرض‌های جغرافیایی بالا
+if (highLatitudesMethod != HighLatitudesMethod.None) {
+    double nightTime = timeDiff(sunset, sunrise);
+
+    fajr = adjustHLTime(highLatitudesMethod, fajr, sunrise,
+                       method.fajr.value, nightTime, true);
+    isha = adjustHLTime(highLatitudesMethod, isha, sunset,
+                       method.isha.value, nightTime, false);
+}
+
+ +
+ در کد پروژه، اگر عرض جغرافیایی کمتر از 49 درجه باشد، به طور خودکار از روش NightMiddle استفاده می‌شود. +
+
+ + +
+

مرحله ششم: تبدیل اعداد اعشاری به زمان

+ +

خروجی کلاس PrayTimes اعداد اعشاری هستند که نشان‌دهنده ساعت از ابتدای روز می‌باشند. این اعداد باید به فرمت زمان قابل خواندن تبدیل شوند.

+ +

نحوه تبدیل:

+ +
+ 1 + جدا کردن ساعت و دقیقه: +
+
String get floatToTime24 {
+    var time = this;
+    if (time == null || time.isNaN) return "----";
+
+    time = _fixHour(time + 0.5 / 60.0); // اضافه کردن 0.5 دقیقه برای گرد کردن
+    int hours = (time).floor();          // بخش صحیح = ساعت
+    double minutes = ((time - hours) * 60.0).floorToDouble(); // بخش اعشاری × 60 = دقیقه
+
+    // فرمت کردن با صفر اضافی
+    return "${hours.toString().padLeft(2, '0')}:${minutes.round().toString().padLeft(2, '0')}";
+}
+
+
+ +
+ 2 + مثال عملی: +
+ اگر fajr = 5.25 باشد:
+ ساعت = 5 (بخش صحیح)
+ دقیقه = 0.25 × 60 = 15
+ نتیجه = "05:15" +
+
+ +

تبدیل به فرمت 12 ساعته:

+
+
String get floatToTime12 {
+    // ... محاسبه ساعت و دقیقه مشابه بالا
+
+    if (hours >= 12 && hours < 24) {
+        var hourss = hours - 12;
+        if (hourss == 0) hourss = 12;  // 12 PM نه 0 PM
+        // فرمت کردن...
+    } else {
+        var hourss = hours;
+        if (hourss == 0) hourss = 12;  // 12 AM نه 0 AM
+        // فرمت کردن...
+    }
+}
+
+String get amPm {
+    int hours = (this).floor();
+    return hours >= 12 && hours < 24 ? 'PM' : 'AM';
+}
+
+
+ + +
+

مرحله هفتم: نمونه کد کامل و کاربردی

+ +

نمونه استفاده کامل:

+
+
// تعریف موقعیت جغرافیایی تهران
+Coordinates tehranCoords = Coordinates(
+    latitude: 35.6892,
+    longitude: 51.3890,
+    elevation: 1200  // ارتفاع از سطح دریا (متر)
+);
+
+// ایجاد instance برای تاریخ امروز
+PrayTimes prayTimes = PrayTimes(
+    calendar: DateTime.now(),
+    coordinates: tehranCoords,
+    method: CalculationMethod.Tehran,
+    highLatitudesMethod: HighLatitudesMethod.NightMiddle,
+    in12Hours: false
+);
+
+// دریافت اوقات
+print("فجر: ${prayTimes.fajr.floatToTime24}");      // مثال: "05:15"
+print("طلوع: ${prayTimes.sunrise.floatToTime24}");   // مثال: "06:45"
+print("ظهر: ${prayTimes.dhuhr.floatToTime24}");      // مثال: "12:30"
+print("عصر: ${prayTimes.asr.floatToTime24}");        // مثال: "15:20"
+print("مغرب: ${prayTimes.maghrib.floatToTime24}");   // مثال: "18:15"
+print("عشا: ${prayTimes.isha.floatToTime24}");       // مثال: "19:45"
+
+// تبدیل به DateTime برای استفاده در برنامه
+DateTime fajrDateTime = DateTime(
+    prayTimes.calendar.year,
+    prayTimes.calendar.month,
+    prayTimes.calendar.day
+).add(Duration(
+    hours: prayTimes.fajr.floor(),
+    minutes: ((prayTimes.fajr - prayTimes.fajr.floor()) * 60).round()
+));
+
+ +

ایجاد لیست اوقات برای چندین روز:

+
+
List<PrayTimeModel> getPrayTimesForMonth(DateTime startDate, Coordinates coords) {
+    List<PrayTimeModel> allPrayTimes = [];
+
+    for (int i = 0; i < 30; i++) {
+        DateTime currentDate = startDate.add(Duration(days: i));
+
+        PrayTimes pt = PrayTimes(
+            calendar: currentDate,
+            coordinates: coords,
+            method: CalculationMethod.Tehran,
+            highLatitudesMethod: HighLatitudesMethod.NightMiddle,
+            in12Hours: false,
+        );
+
+        // اضافه کردن هر وقت به لیست
+        for (int j = 0; j < pt.allTimes.length; j++) {
+            allPrayTimes.add(PrayTimeModel(
+                enumTime: EnumTime.values[j],
+                name: EnumTime.values[j].name,
+                timeInString: pt.allTimes[j].floatToTime24,
+                dateTime: currentDate.add(Duration(
+                    hours: pt.allTimes[j].floor(),
+                    minutes: ((pt.allTimes[j] - pt.allTimes[j].floor()) * 60).round()
+                )),
+            ));
+        }
+    }
+
+    return allPrayTimes;
+}
+
+
+ + +
+

خلاصه و نکات مهم

+ +

نکات کلیدی:

+
    +
  • دقت محاسبات: تمام محاسبات بر اساس فرمول‌های نجومی دقیق انجام می‌شود
  • +
  • انعطاف‌پذیری: پشتیبانی از روش‌های مختلف محاسبه برای مناطق مختلف جهان
  • +
  • تنظیم خودکار: تنظیم خودکار برای عرض‌های جغرافیایی بالا
  • +
  • خروجی استاندارد: خروجی به صورت اعداد اعشاری قابل تبدیل به هر فرمت
  • +
+ +
+ برای استفاده بهینه، توصیه می‌شود اوقات را برای چندین روز آینده محاسبه و ذخیره کنید تا از محاسبات مکرر جلوگیری شود. +
+ +
+ دقت کنید که تغییر موقعیت جغرافیایی یا روش محاسبه نیاز به محاسبه مجدد تمام اوقات دارد. +
+ +

منابع و مراجع:

+
    +
  • الگوریتم‌های نجومی برای محاسبه موقعیت خورشید
  • +
  • استانداردهای بین‌المللی اوقات شرعی
  • +
  • فرمول‌های ریاضی برای تبدیل مختصات جغرافیایی
  • +
+
+
+
+ + diff --git a/templates/utils/widgets/multilang_json_widget.html b/templates/utils/widgets/multilang_json_widget.html index e468e14..584ab37 100644 --- a/templates/utils/widgets/multilang_json_widget.html +++ b/templates/utils/widgets/multilang_json_widget.html @@ -275,3 +275,6 @@ + + + diff --git a/utils/multilang_json_widget.py b/utils/multilang_json_widget.py index 35828db..c774174 100644 --- a/utils/multilang_json_widget.py +++ b/utils/multilang_json_widget.py @@ -188,3 +188,6 @@ class MultiLanguageJSONWidget(Widget): + + + diff --git a/utils/redis.py b/utils/redis.py index 9d66dd3..0d3eb20 100644 --- a/utils/redis.py +++ b/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 @@ -67,4 +73,48 @@ class RedisManager(RedisConfig): @staticmethod def generate_otp_code() -> int: random_code = random.randint(10000, 99999) - return random_code \ No newline at end of file + 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)) \ No newline at end of file