diff --git a/apps/account/urls.py b/apps/account/urls.py index ec6f757..89c85c8 100644 --- a/apps/account/urls.py +++ b/apps/account/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ path('exchange-token/', views.ExchangeTokenAPIView.as_view(), name='exchange-token'), path('location-update/', views.LocationHistoryView.as_view(), name='user-location-history'), + path('location-info/', views.RegionInfoView.as_view(), name='region-info'), diff --git a/apps/account/views/location_history.py b/apps/account/views/location_history.py index 0cd71f5..12e606d 100644 --- a/apps/account/views/location_history.py +++ b/apps/account/views/location_history.py @@ -1,12 +1,17 @@ + +import logging +import re +from pathlib import Path from rest_framework.mixins import CreateModelMixin from rest_framework.permissions import IsAuthenticated from rest_framework.generics import GenericAPIView from rest_framework.response import Response from rest_framework import status - from apps.account.models import LocationHistory from apps.account.serializers import LocationHistorySerializer - +import geoip2.database +import geoip2.errors +from city_detection_ip import get_location_by_coordinates, get_location_by_ip, SPECIAL_COORDINATES class LocationHistoryView(GenericAPIView, CreateModelMixin): permission_classes = [IsAuthenticated] @@ -34,3 +39,166 @@ class LocationHistoryView(GenericAPIView, CreateModelMixin): else: ip = self.request.META.get('REMOTE_ADDR') return ip + +logger = logging.getLogger(__name__) + +# GeoLite2 database path +CITY_DB_PATH = Path("utils/country_city_db/GeoLite2-City.mmdb") + + +def detect_browser_from_user_agent(user_agent): + """ + Detect browser name from User-Agent string. + + Args: + user_agent (str): The User-Agent header value + + Returns: + str or None: Browser name if detected, None if not a browser or detection fails + """ + if not user_agent: + return None + + try: + user_agent = user_agent.lower() + + # Check for Flutter/Dart app (return None for non-browser requests) + if any(keyword in user_agent for keyword in ['dart:io', 'flutter', 'dart/']): + return None + + # Check for mobile apps that might not be browsers + if any(keyword in user_agent for keyword in ['habibapp', 'mobile app']): + return None + + # Browser detection patterns (order matters - more specific first) + browser_patterns = [ + (r'edg/', 'Edge'), # Microsoft Edge (Chromium-based) + (r'edge/', 'Edge'), # Microsoft Edge (Legacy) + (r'opr/', 'Opera'), # Opera + (r'opera/', 'Opera'), # Opera + (r'chrome/', 'Chrome'), # Google Chrome + (r'chromium/', 'Chromium'), # Chromium + (r'firefox/', 'Firefox'), # Mozilla Firefox + (r'fxios/', 'Firefox'), # Firefox for iOS + (r'safari/', 'Safari'), # Safari (check after Chrome/Edge as they also contain Safari) + (r'version/.*safari', 'Safari'), # Safari with version + ] + + # Check each pattern + for pattern, browser_name in browser_patterns: + if re.search(pattern, user_agent): + # Additional check for Safari to avoid false positives + if browser_name == 'Safari': + # Make sure it's not Chrome, Edge, or other browsers that include Safari in UA + if not any(other in user_agent for other in ['chrome', 'edg', 'opr', 'opera']): + return browser_name + else: + return browser_name + + # If no specific browser detected but contains Mozilla, it might be an unknown browser + if 'mozilla' in user_agent and any(keyword in user_agent for keyword in ['gecko', 'webkit']): + return 'Unknown Browser' + + return None + + except Exception as e: + # Log the error but don't let it break the API + logger.warning(f"Error detecting browser from user agent: {e}") + return None + +class RegionInfoView(GenericAPIView): + def get(self, request, *args, **kwargs): + # Get browser information safely + browser = None + try: + user_agent = request.META.get('HTTP_USER_AGENT', '') + browser = detect_browser_from_user_agent(user_agent) + except Exception as e: + # Log the error but continue with the API response + logger.warning(f"Error detecting browser in RegionInfoView: {e}") + browser = None + + # Get IP address + ip = self.get_client_ip(request) + + # Get geolocation data from GeoIP2 database + geo_data = self.get_location_from_ip(ip) + + region_info = { + 'ip': request.META.get('HTTP_CF_CONNECTING_IP') or ip, + 'country': request.META.get('HTTP_CF_IPCOUNTRY'), + 'region': request.META.get('HTTP_CF_REGION'), + 'region_code': request.META.get('HTTP_CF_REGION_CODE'), + 'city': request.META.get('HTTP_CF_CITY'), + 'timezone': request.META.get('HTTP_CF_TIMEZONE'), + 'browser': browser, + } + + # Add geolocation data if available + if geo_data: + region_info.update({ + 'country_code': geo_data.get('country_code'), + 'latitude': geo_data.get('latitude'), + 'longitude': geo_data.get('longitude'), + 'accuracy_radius': geo_data.get('accuracy_radius'), + 'time_zone': geo_data.get('time_zone'), + 'postal_code': geo_data.get('postal_code'), + }) + + return Response(region_info) + + def get_client_ip(self, request): + """Extract client IP from request""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0].strip() + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + def get_location_from_ip(self, ip): + """Get location data from IP using GeoIP2 database""" + try: + # Skip local/private IPs + if ip in ['127.0.0.1', 'localhost'] or ip.startswith('192.168.') or ip.startswith('10.'): + logger.warning(f"Skipping local/private IP: {ip}") + return { + 'ip': ip, + 'city': None, + 'country': None, + 'country_code': None, + 'latitude': None, + 'longitude': None, + 'accuracy_radius': None, + 'time_zone': None, + 'postal_code': None, + } + + if not CITY_DB_PATH.exists(): + logger.error(f"GeoIP2 database not found at {CITY_DB_PATH}") + return None + + with geoip2.database.Reader(CITY_DB_PATH) as reader: + response = reader.city(ip) + + location_data = { + 'ip': ip, + 'city': response.city.name if response.city else None, + 'country': response.country.name if response.country else None, + 'country_code': response.country.iso_code if response.country else None, + 'latitude': response.location.latitude if response.location else None, + 'longitude': response.location.longitude if response.location else None, + 'accuracy_radius': response.location.accuracy_radius if response.location else None, + 'time_zone': response.location.time_zone if response.location else None, + 'postal_code': response.postal.code if response.postal else None, + } + + logger.info(f"Successfully found location for IP {ip}: {location_data.get('city')}, {location_data.get('country')}") + return location_data + + except geoip2.errors.AddressNotFoundError: + logger.warning(f"IP address {ip} not found in GeoIP2 database") + return None + except Exception as e: + logger.error(f"Error getting location from IP {ip}: {str(e)}") + return None \ No newline at end of file diff --git a/city_detection_ip.py b/city_detection_ip.py new file mode 100644 index 0000000..0240e41 --- /dev/null +++ b/city_detection_ip.py @@ -0,0 +1,179 @@ +import geoip2.database +from pathlib import Path +from django.db import connection +from django.core.cache import cache +import logging + +# Configure logging +logger = logging.getLogger(__name__) + +# GeoLite2 database paths +CITY_DB_PATH = Path("utils/country_city_db/GeoLite2-City.mmdb") + +# Special coordinates +SPECIAL_COORDINATES = [ + (32.616565, 44.03462), + (51.5287718, -0.2416802), + (40.3947021, 49.78492), + (55.751199, 37.614706), + (48.8589466, 2.2769956), + (40.4381311, -3.8196194), + (-6.2295712, 106.759478), + (33.6158004, 72.8059198) +] + +def get_location_by_coordinates_optimized(lat, lon): + """ + Optimized version with special coordinates handling. + Handles special coordinates correctly before geo lookup. + """ + try: + # Quick validation + if not lat or not lon: + return None + + lat, lon = float(lat), float(lon) + + # Check if coordinates are in special list - should use IP detection instead + for special_lat, special_lon in SPECIAL_COORDINATES: + if abs(lat - special_lat) < 0.001 and abs(lon - special_lon) < 0.001: + # These coordinates should use IP detection, not geo lookup + # Return None to trigger fallback to original method + logger.debug(f"Special coordinate detected: ({lat}, {lon}) - skipping geo lookup") + return None + + # Simple cache key + cache_key = f'geo_{round(lat, 2)}_{round(lon, 2)}' + + # Try cache first (no exception handling for speed) + cached_result = cache.get(cache_key) + if cached_result is not None: + return cached_result + + # Simple bounding box (larger range for better coverage) + lat_range = 3.0 # ~330km + lon_range = 3.0 + + lat_min = lat - lat_range + lat_max = lat + lat_range + lon_min = lon - lon_range + lon_max = lon + lon_range + + # Simplified query - remove population filter for better accuracy + with connection.cursor() as cursor: + cursor.execute(""" + WITH bounded_cities AS ( + SELECT name, country_code, latitude, longitude + FROM geonames_city + WHERE feature_class = 'P' + AND latitude BETWEEN %s AND %s + AND longitude BETWEEN %s AND %s + ), + distance_calc AS ( + SELECT name, country_code, + (6371 * acos(least(1, greatest(-1, + cos(radians(%s)) * cos(radians(latitude)) * + cos(radians(longitude) - radians(%s)) + + sin(radians(%s)) * sin(radians(latitude)) + )))) AS distance + FROM bounded_cities + ) + SELECT name, country_code + FROM distance_calc + WHERE distance <= 300 + ORDER BY distance + LIMIT 1 + """, [lat_min, lat_max, lon_min, lon_max, lat, lon, lat]) + + result = cursor.fetchone() + + if result: + name, country_code = result + response = { + 'status': 'success', + 'city': name, + 'countryCode': country_code + } + + # Cache for 24 hours + cache.set(cache_key, response, 86400) + return response + else: + # Cache None for 1 hour + cache.set(cache_key, None, 3600) + return None + + except Exception: + # Fallback to original method on any error + return get_location_by_coordinates_original(lat, lon) + + +def get_location_by_coordinates_original(lat, lon): + """Original implementation as fallback""" + try: + with connection.cursor() as cursor: + cursor.execute(""" + WITH distance_calc AS ( + SELECT name, country_code, latitude, longitude, + (6371 * acos(least(1, greatest(-1, cos(radians(%s)) * cos(radians(latitude)) * + cos(radians(longitude) - radians(%s)) + + sin(radians(%s)) * sin(radians(latitude)))))) AS distance + FROM geonames_city + WHERE feature_class = 'P' + ) + SELECT name, country_code, distance + FROM distance_calc + WHERE distance <= 300 + ORDER BY distance + LIMIT 1 + """, [lat, lon, lat]) + + result = cursor.fetchone() + + if result: + name, country_code, distance = result + return { + 'status': 'success', + 'city': name, + 'countryCode': country_code + } + return None + + except Exception: + return None + + +def get_location_by_coordinates(lat, lon): + """ + Main function with smart fallback strategy. + Try optimized first, fallback to original if needed. + """ + # Try optimized version first + result = get_location_by_coordinates_optimized(lat, lon) + + # If optimized fails, use original as fallback + if result is None: + result = get_location_by_coordinates_original(lat, lon) + + return result + +def get_location_by_ip(ip): + """Get location from IP using MaxMind MMDB file directly""" + try: + if not CITY_DB_PATH.exists(): + return None + + with geoip2.database.Reader(CITY_DB_PATH) as reader: + response = reader.city(ip) + if response and response.city and response.country: + return { + 'status': 'success', + 'countryCode': response.country.iso_code, + 'city': response.city.name + } + return None + + except Exception: + return None + + diff --git a/docs/CATEGORY_FILTER_EXAMPLES.md b/docs/CATEGORY_FILTER_EXAMPLES.md new file mode 100644 index 0000000..423e3d3 --- /dev/null +++ b/docs/CATEGORY_FILTER_EXAMPLES.md @@ -0,0 +1,77 @@ +# نمونه‌های فیلتر دسته‌بندی برای Article و Book + +## Article List API + +### فیلتر با یک دسته‌بندی: +```bash +curl -X GET "http://localhost:8000/api/article/list/?category=islamic-history" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر با چند دسته‌بندی (جدا شده با کاما): +```bash +curl -X GET "http://localhost:8000/api/article/list/?category=islamic-history,quran-tafsir,hadith" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر با دسته‌بندی و سایر پارامترها: +```bash +curl -X GET "http://localhost:8000/api/article/list/?category=islamic-history,quran-tafsir&sort=-view_count&search=امام" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +--- + +## Book List API + +### فیلتر با یک دسته‌بندی: +```bash +curl -X GET "http://localhost:8000/api/library/books/?category=fiqh" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر با چند دسته‌بندی (جدا شده با کاما): +```bash +curl -X GET "http://localhost:8000/api/library/books/?category=fiqh,aqaid,akhlaq" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر با دسته‌بندی و سایر پارامترها: +```bash +curl -X GET "http://localhost:8000/api/library/books/?category=fiqh,aqaid&sort=-download_count&search=احکام" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +### فیلتر دسته‌بندی + مجموعه + بوک‌مارک: +```bash +curl -X GET "http://localhost:8000/api/library/books/?category=fiqh&collection_id=5&is_bookmark=true" \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +--- + +## نکات مهم: + +1. **Slug دسته‌بندی**: باید از slug دسته‌بندی استفاده کنید (نه ID) +2. **جداکننده**: از کاما (`,`) برای جدا کردن چند دسته‌بندی استفاده کنید +3. **فاصله**: فاصله‌های قبل و بعد از کاما به صورت خودکار حذف می‌شوند +4. **نتیجه**: نتایج به صورت DISTINCT برگردانده می‌شوند (بدون تکرار) +5. **Logic**: فیلتر با منطق OR کار می‌کند (هر مقاله/کتابی که در یکی از دسته‌بندی‌های داده شده باشد) + +--- + +## پارامترهای قابل ترکیب: + +### برای Article: +- `category`: slug یک یا چند دسته‌بندی (جدا شده با کاما) +- `collection`: slug مجموعه +- `is_bookmark`: true/false +- `search`: جستجو در عنوان +- `sort`: مرتب‌سازی (created_at, -created_at, view_count, -view_count, title, -title) + +### برای Book: +- `category`: slug یک یا چند دسته‌بندی (جدا شده با کاما) +- `collection_id`: ID مجموعه +- `is_bookmark`: true/false +- `search`: جستجو در عنوان، خلاصه، ناشر، ISBN +- `sort`: مرتب‌سازی (created_at, -created_at, view_count, -view_count, download_count, -download_count, title, -title, pin, -pin) diff --git a/requirements.txt b/requirements.txt index 221c46d..f0c05d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -120,6 +120,8 @@ wrapt==1.16.0 wsproto==1.2.0 jdatetime==4.1.0 kavenegar==1.1.2 +geopy==2.3.0 +geoip2==4.7.0 # firebase-admin==6.2.0 google-auth==2.6.0 diff --git a/utils/country_city_db/GeoLite2-City.mmdb b/utils/country_city_db/GeoLite2-City.mmdb new file mode 100644 index 0000000..177e3ad Binary files /dev/null and b/utils/country_city_db/GeoLite2-City.mmdb differ diff --git a/utils/country_city_db/GeoLite2-Country.mmdb b/utils/country_city_db/GeoLite2-Country.mmdb new file mode 100644 index 0000000..84e9049 Binary files /dev/null and b/utils/country_city_db/GeoLite2-Country.mmdb differ