Browse Source

feat(location): implement geolocation features using GeoIP2 database

- Added `city_detection_ip.py` for optimized location detection by coordinates and IP.
- Introduced caching for location results to improve performance.
- Updated `LocationHistoryView` to utilize new geolocation methods.
- Added `RegionInfoView` for retrieving region information based on IP and user agent.
- Included new dependencies `geoip2` and `geopy` in `requirements.txt`.
- Created GeoLite2 database files for city and country data.
- Updated URL patterns to include a new endpoint for region information.
master
mortezaei 6 months ago
parent
commit
0303586233
  1. 1
      apps/account/urls.py
  2. 172
      apps/account/views/location_history.py
  3. 179
      city_detection_ip.py
  4. 77
      docs/CATEGORY_FILTER_EXAMPLES.md
  5. 2
      requirements.txt
  6. BIN
      utils/country_city_db/GeoLite2-City.mmdb
  7. BIN
      utils/country_city_db/GeoLite2-Country.mmdb

1
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'),

172
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

179
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

77
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)

2
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

BIN
utils/country_city_db/GeoLite2-City.mmdb

BIN
utils/country_city_db/GeoLite2-Country.mmdb

Loading…
Cancel
Save