commit 2be3a313ada96d615dbf7ba0a27d200022043c16 Author: Beyhan Oğur Date: Sun Apr 26 22:26:46 2026 +0300 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..0c27c07 --- /dev/null +++ b/.env @@ -0,0 +1,42 @@ +# Django Settings +DEBUG=1 +SECRET_KEY=insta-django-Uq6bv1c4jcSjv50hA6sj6C8d18G3uHMowQz1EL5ljVvISPm1k8kqxZ1iMCCOy2ZPZCY23i +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,api.denizogur.com.tr,localhost:8000 +CELERY_BROKER_URL=redis://default:gg7678290@10.80.80.70:6379/5 +# Site URL - Production ve Development için buradan değiştirin +# SITE_URL=https://api.denizogur.com.tr +SITE_URL=http://localhost:8000 # Development için bu satırı aktif edin + +# .env +USERNAME_SUFFIX_MIN=2 +USERNAME_SUFFIX_MAX=999 + +# Database Settings (Mevcut PostgreSQL sunucunuz) +USE_POSTGRES=False +POSTGRES_DB=shop +POSTGRES_USER=cloud +POSTGRES_PASSWORD=gg7678290 +POSTGRES_HOST=10.80.80.70 +POSTGRES_PORT=5432 + +# Social Auth (Google) +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=your-google-oauth2-key +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=your-google-oauth2-secret + +# Social Auth (GitHub) +SOCIAL_AUTH_GITHUB_KEY=your-github-key +SOCIAL_AUTH_GITHUB_SECRET=your-github-secret + +# Email Settings (Optional) +EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST=212.64.215.243 +EMAIL_PORT=1025 +EMAIL_HOST_USER='' +EMAIL_HOST_PASSWORD='' +EMAIL_USE_TLS=False +EMAIL_USE_SSL=False +DEFAULT_FROM_EMAIL='noreply@localhost' + + +CELERY_BROKER_URL='redis://default:gg7678290@10.80.80.70:6379/3' +CELERY_RESULT_BACKEND='django-db' diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..255c969 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Django Settings +DEBUG=1 +SECRET_KEY=your-secret-key-here-change-this-in-production +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 + +# Database Settings (PostgreSQL) +USE_POSTGRES=False +POSTGRES_DB=insta_db +POSTGRES_USER=postgres_user +POSTGRES_PASSWORD=postgres_password +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 + +# Celery & Redis Settings +CELERY_BROKER_URL=redis://localhost:6379/5 + +# Social Auth (Google) +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=your-google-oauth2-key +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=your-google-oauth2-secret + +# Social Auth (GitHub) +SOCIAL_AUTH_GITHUB_KEY=your-github-key +SOCIAL_AUTH_GITHUB_SECRET=your-github-secret + +# Email Settings +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend +EMAIL_HOST=localhost +EMAIL_PORT=1025 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=False +EMAIL_USE_SSL=False +DEFAULT_FROM_EMAIL=noreply@localhost diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fe5373 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.pyc +__pycache__/ +*.sqlite3 +env/ +.venv/ +venv/ +.env.DS_Store +*.egg-info/ +dist/ +build/ +.idea/ +.vscode/ +*.log +db.sqlite3 +.coverage +htmlcov/ diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 0000000..5c9b406 --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,567 @@ +# Instagram Clone API - Detaylı Kılavuz + +## 📋 İçindekiler +1. [Proje Yapısı](#proje-yapısı) +2. [Kurulum](#kurulum) +3. [Çalıştırma](#çalıştırma) +4. [Özellikler](#özellikler) +5. [API Endpoints](#api-endpoints) +6. [Veritabanı Modelleri](#veritabanı-modelleri) +7. [Celery & Otomasyonlar](#celery--otomasyonlar) +8. [Admin Paneli](#admin-paneli) +9. [Sorun Giderme](#sorun-giderme) + +--- + +## 🏗️ Proje Yapısı + +``` +insta/ +├── core/ # Django ana ayarları +│ ├── settings.py # Proje konfigürasyonu +│ ├── urls.py # Ana URL router +│ ├── celery.py # Celery app tanımı +│ └── wsgi.py +├── accounts/ # Kullanıcı yönetimi +│ ├── models.py # CustomUser model (active_until ile) +│ ├── views.py # Auth views +│ ├── urls.py # Auth endpoints +│ ├── admin.py # Admin paneli +│ ├── middleware.py # Hesap süresi kontrol +│ ├── tasks.py # Celery görevleri +│ └── migrations/ +├── namecreate/ # ML model eğitimi +│ ├── models.py # TrainingJob model +│ ├── views.py # Model eğitim endpoints +│ ├── tasks.py # Eğitim task'ı +│ ├── admin.py # Admin paneli +│ ├── urls.py # ML endpoints +│ └── migrations/ +├── db.sqlite3 # Veritabanı (geliştirme) +├── requirements.txt # Python dependencies +└── .env # Ortam değişkenleri +``` + +--- + +## 🚀 Kurulum + +### 1. Virtual Environment Oluştur +```bash +cd /home/beyhan/Projeler/python/insta +python -m venv .venv +source .venv/bin/activate # Linux/Mac +# veya +.venv\Scripts\activate # Windows +``` + +### 2. Dependencies Yükle +```bash +pip install -r requirements.txt +``` + +### 3. Veritabanı Hazırla +```bash +python manage.py makemigrations +python manage.py migrate +``` + +### 4. Admin Kullanıcısı Oluştur +```bash +python manage.py createsuperuser +# Email: admin@example.com +# Password: *** +``` + +### 5. .env Dosyası Oluştur +```bash +cat > .env << EOF +DEBUG=True +SECRET_KEY=your-secret-key-here +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1 + +# Redis (Celery) +CELERY_BROKER_URL=redis://localhost:6379/5 + +# Go Servisi (opsiyonel) +GO_SERVICE_URL=http://localhost:8080 + +# Email +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend +EOF +``` + +--- + +## ⚙️ Çalıştırma + +### Terminal 1: Django Development Server +```bash +source .venv/bin/activate +python manage.py runserver +# Erişim: http://localhost:8000 +``` + +### Terminal 2: Celery Worker +```bash +source .venv/bin/activate +celery -A core worker -l info +``` + +### Terminal 3: Celery Beat (Scheduler) +```bash +source .venv/bin/activate +celery -A core beat -l info +``` + +### Terminal 4: Redis (Gerekli) +```bash +redis-server +# veya +docker run -d -p 6379:6379 redis:latest +``` + +--- + +## ⭐ Özellikler + +### 1. **Admin-Only Kullanıcı Kaydı** +- Sadece admin kullanıcılar yeni hesap oluşturabilir +- `/api/v1/auth/users/` - POST (admin only) +- `/api/v1/auth/users/activation/` - POST (admin only) +- `/api/v1/auth/users/resend_activation/` - POST (admin only) + +### 2. **Zamanlı Hesap Aktifliği** +- Kullanıcılara sınırlı kullanım süresi verilebilir +- `active_until` alanı ile kontrol edilir +- Otomatik pasife çekilir (middleware + Celery) + +### 3. **Model Eğitimi (ML)** +- RandomForest modeli eğitilir +- ONNX formatına kaydedilir +- Metrikleri kaydedilir (accuracy, precision, recall, f1) +- Go servisine bildirilir + +### 4. **Celery Otomasyonları** +- Süresi dolmuş hesapları günlük deaktif etme +- Model eğitimini arka planda yapma +- Database scheduler ile yönetim + +### 5. **Admin Panelden Terminalsiz Uretim (2 Mod)** +- Terminal erisimi olmadan admin panelden kisi uretimi yapabilirsiniz. +- Yol: `Admin > Namecreate > Training jobs` +- Bir veya birden fazla `TrainingJob` secin, sonra `Action` menusunden secin: + - `Secili job(lar) icin 100 kisi uret (Istatistiksel)` + - `Secili job(lar) icin 100 kisi uret (LLM + fallback)` + - `Secili job(lar) icin 1000 kisi uret (Istatistiksel)` + - `Secili job(lar) icin 1000 kisi uret (LLM + fallback)` +- LLM servisi kapaliysa `LLM + fallback` secenegi otomatik olarak istatistiksel uretime duser. + +--- + +## 🔌 API Endpoints + +### **Kimlik Doğrulama (Djoser)** + +#### Kayıt (Admin Only) +```bash +POST /api/v1/auth/users/ +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "strongpass123", + "re_password": "strongpass123", + "first_name": "John", + "last_name": "Doe" +} + +# Yanıt (201 Created) +{ + "id": 1, + "email": "user@example.com", + "first_name": "John", + "last_name": "Doe" +} +``` + +#### Login +```bash +POST /api/v1/auth/jwt/create/ +Content-Type: application/json + +{ + "email": "user@example.com", + "password": "strongpass123" +} + +# Yanıt +{ + "access": "eyJ0eXAiOiJKV1QiLCJhbGc...", + "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..." +} +``` + +#### Profil Bilgileri +```bash +GET /api/v1/auth/users/me/ +Authorization: Bearer + +# Yanıt +{ + "id": 1, + "email": "user@example.com", + "first_name": "John", + "last_name": "Doe", + "is_active": true, + "date_joined": "2026-03-27T12:00:00Z" +} +``` + +### **ML Model Eğitimi** + +#### Eğitim Başlat +```bash +POST /api/v1/ml/train-model/ +Authorization: Bearer + +# Yanıt (Task başlatıldı) +{ + "status": "queued", + "message": "Model eğitim görevi başlatıldı.", + "task_id": "550e8400-e29b-41d4-a716-446655440000", + "celery_task_id": "abc123xyz" +} +``` + +#### Eğitim Durumunu Sorgula +```bash +GET /api/v1/ml/training-status/?task_id=550e8400-e29b-41d4-a716-446655440000 +Authorization: Bearer + +# Yanıt (Tamamlandı) +{ + "task_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "completed", + "created_at": "2026-03-27T12:00:00Z", + "started_at": "2026-03-27T12:00:05Z", + "completed_at": "2026-03-27T12:01:30Z", + "model_version": "2026-03-27T12-00-00", + "metrics": { + "accuracy": 0.96, + "precision": 0.97, + "recall": 0.95, + "f1_score": 0.96 + } +} +``` + +--- + +## 💾 Veritabanı Modelleri + +### **CustomUser (accounts/models.py)** +```python +class CustomUser(AbstractBaseUser, PermissionsMixin): + email # Unique email + first_name # İsim + last_name # Soyisim + is_active # Aktif mi? + is_staff # Admin mi? + is_superuser # Superuser mi? + active_until # ⭐ Hesap bitiş tarihi + date_joined # Kayıt tarihi + last_login # Son giriş + + # Metodlar: + is_expired() # Süresi doldu mu? + deactivate_if_expired() # Pasif yap + set_active_for_days(days) # N gün için aktif et +``` + +### **TrainingJob (namecreate/models.py)** +```python +class TrainingJob(models.Model): + task_id # Celery task ID + status # pending, running, completed, failed + model_type # Model tipi (RandomForest) + model_version # Timestamp (versiyonlama) + model_path # Dosya yolu + + # Metrikleri + accuracy # Doğruluk + precision # Kesinlik + recall # Geri çağırma + f1_score # F1 skoru + + # Zaman damgaları + created_at # Oluşturulma + started_at # Başlama + completed_at # Tamamlanma + + # Go Servisi + go_service_notified # Bildirildi mi? + + # Hata + error_message # Hata mesajı +``` + +--- + +## 🤖 Celery & Otomasyonlar + +### **Otomatik Prosesler** + +#### 1. Süresi Dolmuş Hesapları Deaktif Etme +**Periyodiklik:** Her 24 saatte 1 kez +**Task:** `accounts.tasks.deactivate_expired_users_task()` +**Veritabanı:** `active_until <= now()` olan users pasif olur + +Manuel çalıştırmak: +```bash +python manage.py deactivate_expired_users +``` + +#### 2. Model Eğitimi +**Periyodiklik:** İsteğe bağlı (manual trigger) +**Task:** `namecreate.tasks.train_model_task(task_id)` +**İşler:** +1. Veriyi yükle (Iris dataset) +2. Modeli eğit (RandomForest) +3. Metrikleri hesapla +4. ONNX formatına kaydet (versiyonlu) +5. Go servisine bildir + +### **Celery Settings (core/settings.py)** +```python +CELERY_BROKER_URL = 'redis://localhost:6379/5' +CELERY_RESULT_BACKEND = 'django-db' +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' + +CELERY_BEAT_SCHEDULE = { + 'deactivate-expired-users-daily': { + 'task': 'accounts.tasks.deactivate_expired_users_task', + 'schedule': 60 * 60 * 24, # Her 24 saat + }, +} +``` + +### **Task Monitoring** + +Celery Flower ile gerçek zamanlı monitoring: +```bash +pip install flower +celery -A core flower +# Erişim: http://localhost:5555 +``` + +--- + +## 👨‍💼 Admin Paneli + +### **Admin Erişimi** +``` +http://localhost:8000/admin/ +Email: admin@example.com +Şifre: *** +``` + +### **Admin Sayfalarında Neleri Yönetebilirsin?** + +#### 1. **Kullanıcılar** (`/admin/accounts/customuser/`) +- Yeni kullanıcı oluştur +- `active_until` tarihi belirle +- Hesabı aktif/pasif yap +- Filtreleme: İçinden çıkış tarihi, aktif durum, staff status + +#### 2. **Eğitim Görevleri** (`/admin/namecreate/trainingjob/`) +- Tüm eğitim görevlerini görüntüle +- Status takibi: pending → running → completed/failed +- Metrikleri kontrol et (accuracy, f1, vb.) +- Error loglarını göster (başarısız task'lar) +- Go servisine bildirilip bildirilmediğini kontrol et + +#### 3. **Periyodik Görevler** (`/admin/django_celery_beat/periodictask/`) +- Celery Beat task'larını yönet +- Schedule'ı özelleştir +- Yeni periyodik görev ekle + +--- + +## 🔍 Sorun Giderme + +### **Problem: Celery Worker hata veriyor** + +**Çözüm 1:** Redis çalışıyor mu? +```bash +redis-cli ping +# Yanıt: PONG ise OK +``` + +**Çözüm 2:** Hata logu kontrol et +```bash +celery -A core worker -l debug +``` + +**Çözüm 3:** Database migrations kontrol et +```bash +python manage.py migrate +``` + +--- + +### **Problem: Model eğitimi başlamıyor** + +**Çözüm 1:** Task'ı manuel test et +```bash +python manage.py shell +>>> from namecreate.tasks import train_model_task +>>> task_id = 'test-123' +>>> from namecreate.models import TrainingJob +>>> job = TrainingJob.objects.create(task_id=task_id) +>>> result = train_model_task.delay(task_id) +>>> result.get() +``` + +**Çözüm 2:** Worker loglarını kontrol et (`celery -A core worker -l info`) + +--- + +### **Problem: Admin panelinde TrainingJob görmüyorum** + +**Çözüm:** Migration'ı çalıştır +```bash +python manage.py migrate namecreate +``` + +--- + +### **Problem: Djoser endpoints 403 Forbidden dönem** + +**Sebep:** Admin değilsin (register/activation admin-only'dir) +- Admin kullanıcı ile yapabilirsin +- Veya superuser token'ı kullan + +**Test:** +```bash +# Superuser ile +TOKEN=$(curl -X POST http://localhost:8000/api/v1/auth/jwt/create/ \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","password":"***"}' | jq -r '.access') + +curl -X POST http://localhost:8000/api/v1/auth/users/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "email":"newuser@example.com", + "password":"pass123", + "re_password":"pass123" + }' +``` + +--- + +## 📊 Örnek İş Akışı + +### **Senaryo 1: Yeni Kullanıcı Ekleme (Admin)** + +```bash +# 1. Admin token al +curl -X POST http://localhost:8000/api/v1/auth/jwt/create/ \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","password":"adminpass123"}' \ + | jq . + +# Response: access token vs refresh token + +# 2. Yeni kullanıcı oluştur +curl -X POST http://localhost:8000/api/v1/auth/users/ \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "email":"john@example.com", + "password":"johnpass123", + "re_password":"johnpass123", + "first_name":"John", + "last_name":"Doe" + }' + +# 3. Admin panelinden active_until tarihi belirle +# http://localhost:8000/admin/accounts/customuser/ +# John'ın profiline gir → active_until = 2026-04-27 (30 gün başlar) + +# 4. 30 gün sonra otomatik pasif olur +``` + +### **Senaryo 2: Model Eğitimi** + +```bash +# 1. Eğitim başlat +curl -X POST http://localhost:8000/api/v1/ml/train-model/ \ + -H "Authorization: Bearer " \ + | jq . + +# Response: task_id = "550e8400-e29b-41d4-a716-446655440000" + +# 2. Durum sorgula (5-10 saniye sonra) +curl http://localhost:8000/api/v1/ml/training-status/?task_id=550e8400-e29b-41d4-a716-446655440000 \ + -H "Authorization: Bearer " \ + | jq . + +# 3. Tamamlanınca: +# - Model: media/models/iris_model_2026-03-27_12-30-45.onnx +# - Metrikler: accuracy=0.96, f1=0.96 +# - Go servisi bilgilendirildi (varsa) +``` + +--- + +## 📝 Notlar + +- **Geliştirme:** SQLite kullanıyor. Production'da PostgreSQL kullan. +- **Redis:** Celery için gerekli. Docker'da `redis:latest` kullan. +- **Go Servisi:** `.env` dosyasında `GO_SERVICE_URL` tanımlarsan otomatik bildirim yapılır. +- **Emails:** Console backend kullanıyor. SMTP konfigürasyonu yapabilirsin. + +--- + +## 📚 Faydalı Commands + +```bash +# Veritabanı işlemleri +python manage.py makemigrations +python manage.py migrate +python manage.py flush # Tüm veriyi sil (DİKKAT!) + +# Shell (Python repl) +python manage.py shell + +# Superuser oluştur +python manage.py createsuperuser + +# Celery test +python manage.py celery -A core worker -l info + +# Datab Shell (SQL) +python manage.py dbshell + +# Yeni app oluştur +python manage.py startapp appname +``` + +--- + +## 🎯 Sonraki Adımlar + +- [ ] PostgreSQL kurulumu +- [ ] Production settings +- [ ] Docker containerization +- [ ] CI/CD pipeline (GitHub Actions) +- [ ] Monitoring & logging +- [ ] API Rate limiting +- [ ] WebSocket entegrasyonu (asenkron güncellemeler) + +--- + +**Hazırlandı:** 27 Mart 2026 +**Versiyon:** 1.0 diff --git a/accounts/__init__.py b/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/admin.py b/accounts/admin.py new file mode 100644 index 0000000..6d83482 --- /dev/null +++ b/accounts/admin.py @@ -0,0 +1,37 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.translation import gettext_lazy as _ +from .models import CustomUser + + +@admin.register(CustomUser) +class CustomUserAdmin(BaseUserAdmin): + """ + Custom admin panel configuration for CustomUser model. + """ + + # Fields to display in the user list + list_display = ('email', 'first_name', 'last_name', 'is_staff', 'is_active', 'active_until', 'date_joined') + list_filter = ('is_staff', 'is_superuser', 'is_active', 'active_until', 'date_joined') + search_fields = ('email', 'first_name', 'last_name') + ordering = ('-date_joined',) + + # Fields to display on the user detail/edit page + fieldsets = ( + (None, {'fields': ('email', 'password')}), + (_('Personal info'), {'fields': ('first_name', 'last_name', 'active_until')}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined')}), + ) + + # Fields to display when creating a new user + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2', 'first_name', 'last_name', 'active_until', 'is_staff', 'is_active'), + }), + ) + + readonly_fields = ('date_joined', 'last_login') diff --git a/accounts/apps.py b/accounts/apps.py new file mode 100644 index 0000000..9b3fc5a --- /dev/null +++ b/accounts/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + name = 'accounts' diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/deactivate_expired_users.py b/accounts/management/commands/deactivate_expired_users.py new file mode 100644 index 0000000..0119e1f --- /dev/null +++ b/accounts/management/commands/deactivate_expired_users.py @@ -0,0 +1,12 @@ +from django.core.management.base import BaseCommand + +from accounts.tasks import deactivate_expired_users_task + + +class Command(BaseCommand): + help = 'Deactivate users whose active period has expired.' + + def handle(self, *args, **options): + updated_count = deactivate_expired_users_task() + + self.stdout.write(self.style.SUCCESS(f'Deactivated {updated_count} expired user(s).')) diff --git a/accounts/middleware.py b/accounts/middleware.py new file mode 100644 index 0000000..b03e8f3 --- /dev/null +++ b/accounts/middleware.py @@ -0,0 +1,54 @@ +""" +Custom middleware for social authentication. +""" + +from django.contrib.auth import logout +from django.http import HttpResponseForbidden, JsonResponse + + +class SocialAuthExceptionMiddleware: + """ + Middleware to handle social auth exceptions and redirect properly. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_exception(self, request, exception): + """Handle social auth exceptions.""" + from social_core.exceptions import AuthException + from django.http import HttpResponseRedirect + + if isinstance(exception, AuthException): + return HttpResponseRedirect(f'/api/v1/auth/social/error/?error={str(exception)}') + + return None + + +class AccountExpirationMiddleware: + """ + Deactivate users automatically when their access period has expired. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = getattr(request, 'user', None) + + if user and user.is_authenticated and hasattr(user, 'deactivate_if_expired'): + if user.deactivate_if_expired(): + logout(request) + if request.path.startswith('/api/'): + return JsonResponse( + {'detail': 'Account expired. Please contact an administrator.'}, + status=403, + ) + return HttpResponseForbidden('Account expired. Please contact an administrator.') + + return self.get_response(request) + diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..6019389 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0 on 2025-12-11 21:31 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='CustomUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('email', models.EmailField(error_messages={'unique': 'A user with that email already exists.'}, max_length=254, unique=True, verbose_name='email address')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + }, + ), + ] diff --git a/accounts/migrations/0002_customuser_active_until.py b/accounts/migrations/0002_customuser_active_until.py new file mode 100644 index 0000000..8744fa3 --- /dev/null +++ b/accounts/migrations/0002_customuser_active_until.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-03-27 19:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='active_until', + field=models.DateTimeField(blank=True, help_text='If set, the account is automatically deactivated after this date.', null=True, verbose_name='active until'), + ), + ] diff --git a/accounts/migrations/__init__.py b/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/models.py b/accounts/models.py new file mode 100644 index 0000000..1f6e008 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,129 @@ +from django.db import models +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + + +class CustomUserManager(BaseUserManager): + """ + Custom user manager where email is the unique identifier + for authentication instead of username. + """ + + def create_user(self, email, password=None, **extra_fields): + """ + Create and save a regular user with the given email and password. + """ + if not email: + raise ValueError(_('The Email field must be set')) + + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password=None, **extra_fields): + """ + Create and save a SuperUser with the given email and password. + """ + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault('is_active', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError(_('Superuser must have is_staff=True.')) + if extra_fields.get('is_superuser') is not True: + raise ValueError(_('Superuser must have is_superuser=True.')) + + return self.create_user(email, password, **extra_fields) + + +class CustomUser(AbstractBaseUser, PermissionsMixin): + """ + Custom user model where email is used instead of username. + + Fields: + - email: unique email address (used for login) + - first_name: user's first name + - last_name: user's last name + - is_staff: designates whether user can log into admin site + - is_active: designates whether user account is active + - date_joined: when the user account was created + """ + + email = models.EmailField( + _('email address'), + unique=True, + error_messages={ + 'unique': _("A user with that email already exists."), + } + ) + first_name = models.CharField(_('first name'), max_length=150, blank=True) + last_name = models.CharField(_('last name'), max_length=150, blank=True) + is_staff = models.BooleanField( + _('staff status'), + default=False, + help_text=_('Designates whether the user can log into this admin site.'), + ) + is_active = models.BooleanField( + _('active'), + default=True, + help_text=_( + 'Designates whether this user should be treated as active. ' + 'Unselect this instead of deleting accounts.' + ), + ) + active_until = models.DateTimeField( + _('active until'), + null=True, + blank=True, + help_text=_('If set, the account is automatically deactivated after this date.'), + ) + date_joined = models.DateTimeField(_('date joined'), default=timezone.now) + + # Specify that we use email as the username field + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = [] # Email is already required by USERNAME_FIELD + + objects = CustomUserManager() + + class Meta: + verbose_name = _('user') + verbose_name_plural = _('users') + + def __str__(self): + return self.email + + def get_full_name(self): + """ + Return the first_name plus the last_name, with a space in between. + """ + full_name = f'{self.first_name} {self.last_name}' + return full_name.strip() + + def get_short_name(self): + """ + Return the short name for the user. + """ + return self.first_name + + def is_expired(self): + """Return True when account usage period has ended.""" + return bool(self.active_until and timezone.now() >= self.active_until) + + def deactivate_if_expired(self, save=True): + """Deactivate account if active_until has passed.""" + if self.is_active and self.is_expired(): + self.is_active = False + if save: + self.save(update_fields=['is_active']) + return True + return False + + def set_active_for_days(self, days, save=True): + """Enable user for a fixed number of days from now.""" + self.active_until = timezone.now() + timezone.timedelta(days=days) + self.is_active = True + if save: + self.save(update_fields=['active_until', 'is_active']) diff --git a/accounts/pipeline.py b/accounts/pipeline.py new file mode 100644 index 0000000..49e8a45 --- /dev/null +++ b/accounts/pipeline.py @@ -0,0 +1,19 @@ +""" +Custom pipeline functions for Python Social Auth. +These functions are called during the social authentication process. +""" + + +def activate_user(strategy, details, user=None, *args, **kwargs): + """ + Custom pipeline step to ensure social auth users are active. + + This ensures that users who register via social login don't need + email activation - they are automatically activated since the social + provider has already verified their email. + """ + if user and not user.is_active: + user.is_active = True + user.save(update_fields=['is_active']) + return {'user': user} + diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..e4ce18e --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,74 @@ +from rest_framework import serializers +from djoser.serializers import UserCreateSerializer as BaseUserCreateSerializer +from djoser.serializers import UserSerializer as BaseUserSerializer +from .models import CustomUser + + +class CustomUserCreateSerializer(BaseUserCreateSerializer): + """ + Custom serializer for user registration. + Sets is_active=False by default so users must activate via email. + """ + + class Meta(BaseUserCreateSerializer.Meta): + model = CustomUser + fields = ('id', 'email', 'password', 're_password', 'first_name', 'last_name') + + def create(self, validated_data): + """ + Override create to ensure is_active=False for email/password registrations. + Social auth users will have is_active=True set via pipeline. + """ + # Remove re_password as it's only for validation + validated_data.pop('re_password', None) + + # Create user with is_active=False + user = CustomUser.objects.create_user( + email=validated_data['email'], + password=validated_data['password'], + first_name=validated_data.get('first_name', ''), + last_name=validated_data.get('last_name', ''), + is_active=False # Requires email activation + ) + return user + + +class CustomUserSerializer(BaseUserSerializer): + """ + Serializer for user details. + Used for current user endpoint and user profile. + """ + + class Meta(BaseUserSerializer.Meta): + model = CustomUser + fields = ('id', 'email', 'first_name', 'last_name', 'is_active', 'date_joined') + read_only_fields = ('id', 'email', 'is_active', 'date_joined') + + +class SocialLoginSerializer(serializers.Serializer): + """ + Serializer for social authentication. + Accepts provider name and access_token from frontend. + """ + provider = serializers.ChoiceField( + choices=['google-oauth2', 'github', 'facebook'], + help_text="Social auth provider name" + ) + access_token = serializers.CharField( + help_text="Access token from the social provider" + ) + id_token = serializers.CharField( + required=False, + allow_blank=True, + help_text="ID token (optional, used by some providers like Google)" + ) + + def validate_provider(self, value): + """Validate that the provider is supported.""" + valid_providers = ['google-oauth2', 'github', 'facebook'] + if value not in valid_providers: + raise serializers.ValidationError( + f"Invalid provider. Must be one of: {', '.join(valid_providers)}" + ) + return value + diff --git a/accounts/tasks.py b/accounts/tasks.py new file mode 100644 index 0000000..20e3fd9 --- /dev/null +++ b/accounts/tasks.py @@ -0,0 +1,16 @@ +from celery import shared_task +from django.contrib.auth import get_user_model +from django.utils import timezone + + +@shared_task(name='accounts.tasks.deactivate_expired_users_task') +def deactivate_expired_users_task(): + """Deactivate active users whose active_until timestamp has passed.""" + user_model = get_user_model() + now = timezone.now() + updated = user_model.objects.filter( + is_active=True, + active_until__isnull=False, + active_until__lte=now, + ).update(is_active=False) + return updated diff --git a/accounts/tests.py b/accounts/tests.py new file mode 100644 index 0000000..a8606d8 --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,112 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APITestCase +from django.utils import timezone + +from .models import CustomUser + + +class AdminOnlyRegistrationEndpointsTests(APITestCase): + def setUp(self): + self.admin_user = CustomUser.objects.create_superuser( + email='admin@example.com', + password='adminpass123', + ) + self.regular_user = CustomUser.objects.create_user( + email='user@example.com', + password='userpass123', + is_active=True, + ) + + def test_register_endpoint_rejects_non_admin(self): + self.client.force_authenticate(user=self.regular_user) + + response = self.client.post( + '/api/v1/auth/users/', + { + 'email': 'new-user@example.com', + 'password': 'strong-pass-123', + 're_password': 'strong-pass-123', + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_register_endpoint_allows_admin(self): + self.client.force_authenticate(user=self.admin_user) + + response = self.client.post( + '/api/v1/auth/users/', + { + 'email': 'created-by-admin@example.com', + 'password': 'strong-pass-123', + 're_password': 'strong-pass-123', + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_activation_endpoint_rejects_non_admin(self): + self.client.force_authenticate(user=self.regular_user) + + response = self.client.post( + '/api/v1/auth/users/activation/', + {'uid': 'invalid', 'token': 'invalid'}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_resend_activation_endpoint_rejects_non_admin(self): + self.client.force_authenticate(user=self.regular_user) + + response = self.client.post( + '/api/v1/auth/users/resend_activation/', + {'email': self.regular_user.email}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_resend_activation_endpoint_allows_admin_access(self): + self.client.force_authenticate(user=self.admin_user) + + response = self.client.post( + '/api/v1/auth/users/resend_activation/', + {'email': self.regular_user.email}, + format='json', + ) + + self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + +class AccountExpiryTests(TestCase): + def test_user_is_deactivated_when_expired(self): + user = CustomUser.objects.create_user( + email='expired@example.com', + password='pass123456', + is_active=True, + active_until=timezone.now() - timezone.timedelta(days=1), + ) + + changed = user.deactivate_if_expired() + + user.refresh_from_db() + self.assertTrue(changed) + self.assertFalse(user.is_active) + + def test_user_stays_active_before_expiry(self): + user = CustomUser.objects.create_user( + email='active@example.com', + password='pass123456', + is_active=True, + active_until=timezone.now() + timezone.timedelta(days=3), + ) + + changed = user.deactivate_if_expired() + + user.refresh_from_db() + self.assertFalse(changed) + self.assertTrue(user.is_active) diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..efaa667 --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,22 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from .views import AdminRestrictedUserViewSet, SocialLoginView, SocialAuthCallbackView, SocialAuthSuccessView + + +auth_router = DefaultRouter() +auth_router.register('users', AdminRestrictedUserViewSet, basename='user') + +urlpatterns = [ + # Djoser endpoints (registration, activation, etc.) + # /api/v1/auth/users/ - POST: Register new user + # /api/v1/auth/users/activation/ - POST: Activate account with uid/token + # /api/v1/auth/users/me/ - GET: Get current user info + # /api/v1/auth/users/resend_activation/ - POST: Resend activation email + path('auth/', include(auth_router.urls)), + + # Djoser JWT endpoints + # /api/v1/auth/jwt/create/ - POST: Login (get JWT tokens) + # /api/v1/auth/jwt/refresh/ - POST: Refresh access token + # /api/v1/auth/jwt/verify/ - POST: Verify token + path('auth/', include('djoser.urls.jwt')), +] diff --git a/accounts/urls.py.bak b/accounts/urls.py.bak new file mode 100644 index 0000000..aebff40 --- /dev/null +++ b/accounts/urls.py.bak @@ -0,0 +1,51 @@ +from django.urls import path, include +from .views import SocialLoginView, SocialAuthCallbackView, SocialAuthSuccessView + +urlpatterns = [ + # Python Social Auth URLs (MUST BE FIRST for OAuth redirect flow) + # /api/v1/social/login/github/ - GET: Start GitHub OAuth + # /api/v1/social/login/google-oauth2/ - GET: Start Google OAuth + # /api/v1/social/complete/github/ - GET: GitHub callback (handled by social-auth) + # /api/v1/social/complete/google-oauth2/ - GET: Google callback (handled by social-auth) + path('social/', include('social_django.urls', namespace='social')), + + # SPA Test Page (Main app) + path('spa/', lambda request: + __import__('django.shortcuts').shortcuts.render( + request, 'spa_test/index.html' + ), name='spa-test'), + + # SPA Activation Page (Frontend route for email links) + path('spa/activate///', lambda request, uid, token: + __import__('django.shortcuts').shortcuts.render( + request, 'spa_test/activate.html', {'uid': uid, 'token': token} + ), name='spa-activate'), + + # Django REST Framework browsable API auth + path('api-auth/', include('rest_framework.urls')), + + # Djoser endpoints (registration, activation, etc.) + # /api/v1/auth/users/ - POST: Register new user + # /api/v1/auth/users/activation/ - POST: Activate account with uid/token + # /api/v1/auth/users/me/ - GET: Get current user info + # /api/v1/auth/users/resend_activation/ - POST: Resend activation email + path('auth/', include('djoser.urls')), + + # Djoser JWT endpoints + # /api/v1/auth/jwt/create/ - POST: Login (get JWT tokens) + # /api/v1/auth/jwt/refresh/ - POST: Refresh access token + # /api/v1/auth/jwt/verify/ - POST: Verify token + path('auth/', include('djoser.urls.jwt')), + + # Social authentication endpoints (Token-based - for mobile/SPA) + # /api/v1/auth/social/google-oauth2/ - POST: Login with Google (requires access_token) + # /api/v1/auth/social/github/ - POST: Login with GitHub (requires access_token) + # /api/v1/auth/social/facebook/ - POST: Login with Facebook (requires access_token) + path('auth/social//', SocialLoginView.as_view(), name='social-login'), + + # OAuth callback handler (after social-auth completes) + path('auth/social/callback/', SocialAuthCallbackView.as_view(), name='social-callback'), + + # Success/Error pages + path('auth/social/success/', SocialAuthSuccessView.as_view(), name='social-success'), +] diff --git a/accounts/views.py b/accounts/views.py new file mode 100644 index 0000000..6ebc2ec --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,283 @@ +from django.shortcuts import redirect +from django.views import View +from djoser.views import UserViewSet +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAdminUser +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken +from social_django.utils import load_strategy, load_backend +from social_core.backends.oauth import BaseOAuth2 +from social_core.exceptions import AuthException, AuthForbidden +from .serializers import SocialLoginSerializer, CustomUserSerializer +import json + + +class AdminRestrictedUserViewSet(UserViewSet): + """ + Restrict registration and activation-related endpoints to admin users. + """ + + def get_permissions(self): + if self.action in {'create', 'activation', 'resend_activation'}: + return [IsAdminUser()] + return super().get_permissions() + + +class SocialLoginView(APIView): + """ + Social authentication endpoint. + Accepts access_token from social provider and returns JWT tokens. + + POST /api/v1/auth/social// + Body: { "access_token": "..." } + + Supported providers: google-oauth2, github, facebook + """ + permission_classes = [AllowAny] + serializer_class = SocialLoginSerializer + + def post(self, request, provider): + """ + Authenticate user with social provider token. + """ + # Validate provider + valid_providers = ['google-oauth2', 'github', 'facebook'] + if provider not in valid_providers: + return Response( + {'error': f'Invalid provider. Must be one of: {", ".join(valid_providers)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get access_token from request + access_token = request.data.get('access_token') + id_token = request.data.get('id_token', None) + + if not access_token: + return Response( + {'error': 'access_token is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Load social auth strategy and backend + strategy = load_strategy(request) + backend = load_backend( + strategy=strategy, + name=provider, + redirect_uri=None + ) + + # Verify token and get user + if isinstance(backend, BaseOAuth2): + # For OAuth2 providers, use access_token to get user info + user = backend.do_auth(access_token) + else: + return Response( + {'error': 'Unsupported authentication backend'}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not user: + return Response( + {'error': 'Authentication failed. Invalid token.'}, + status=status.HTTP_401_UNAUTHORIZED + ) + + # Check if user is active + if not user.is_active: + # This shouldn't happen for social auth users, but just in case + user.is_active = True + user.save(update_fields=['is_active']) + + # Generate JWT tokens + refresh = RefreshToken.for_user(user) + + # Serialize user data + user_serializer = CustomUserSerializer(user) + + return Response({ + 'access': str(refresh.access_token), + 'refresh': str(refresh), + 'user': user_serializer.data + }, status=status.HTTP_200_OK) + + except AuthForbidden: + return Response( + {'error': 'Authentication forbidden. Email not provided by provider or permission denied.'}, + status=status.HTTP_403_FORBIDDEN + ) + except AuthException as e: + return Response( + {'error': f'Authentication error: {str(e)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + return Response( + {'error': f'An error occurred during authentication: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class SocialAuthCallbackView(View): + """ + Callback view for OAuth flow completion. + After successful authentication, redirects to frontend with tokens. + """ + permission_classes = [AllowAny] + authentication_classes = [] # No authentication required for callback + + def get(self, request): + """Handle OAuth callback and redirect to frontend with JWT tokens.""" + from django.http import HttpResponseRedirect + + # Get the authenticated user from the session + user = request.user + + if user and user.is_authenticated: + # Generate JWT tokens + refresh = RefreshToken.for_user(user) + + # Redirect to SPA with tokens (for testing) + redirect_url = f"/api/v1/spa/?access={str(refresh.access_token)}&refresh={str(refresh)}" + + print(f"[OAuth Callback] Redirecting to: {redirect_url}") + + return HttpResponseRedirect(redirect_url) + else: + # Authentication failed + return HttpResponseRedirect("/api/v1/auth/social/error/?error=authentication_failed") + + +class SocialAuthSuccessView(APIView): + """ + Success page after social authentication. + Displays tokens for testing purposes. + """ + permission_classes = [AllowAny] + authentication_classes = [] # No authentication required + + def get(self, request): + """Display success page with tokens.""" + access_token = request.GET.get('access', '') + refresh_token = request.GET.get('refresh', '') + + # Also check if user is in session + if not access_token and request.user.is_authenticated: + refresh = RefreshToken.for_user(request.user) + access_token = str(refresh.access_token) + refresh_token = str(refresh) + + html_content = f""" + + + + Authentication Successful + + + +
+
+

Authentication Successful!

+

+ You have successfully authenticated with your social account. +

+ +
+
Access Token:
+
{access_token}
+
+ +
+
Refresh Token:
+
{refresh_token}
+
+ + + +
+ + + + + """ + + from django.http import HttpResponse + return HttpResponse(html_content) diff --git a/celerybeat-schedule b/celerybeat-schedule new file mode 100644 index 0000000..63d0485 Binary files /dev/null and b/celerybeat-schedule differ diff --git a/clery.txt b/clery.txt new file mode 100644 index 0000000..f5894f4 --- /dev/null +++ b/clery.txt @@ -0,0 +1,15 @@ +# bir kere +python manage.py migrate + +# worker +celery -A core worker -l info + +# beat (schedule çalıştırıcı) +celery -A core beat -l info + + +# 1. Request gönder ve task başlat +curl -X POST http://localhost:8000/api/v1/ml/train-model/ + +# Dönen task_id'i kopyala, sonra status sorgula: +curl "http://localhost:8000/api/v1/ml/training-status/?task_id=" \ No newline at end of file diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..fb989c4 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/core/asgi.py b/core/asgi.py new file mode 100644 index 0000000..cf099bf --- /dev/null +++ b/core/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for core project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_asgi_application() diff --git a/core/celery.py b/core/celery.py new file mode 100644 index 0000000..3b9cc77 --- /dev/null +++ b/core/celery.py @@ -0,0 +1,10 @@ +import os + +from celery import Celery + + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +app = Celery('core') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..45331c3 --- /dev/null +++ b/core/settings.py @@ -0,0 +1,260 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 6.0.3. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" + +import os +from pathlib import Path +from decouple import config + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = config('SECRET_KEY', default='django-insecure-3rtts!rrr*3om)-um$5go^(-a@a2q#7he!+j&9caava+^%rw5n') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = config('DEBUG', default=True, cast=bool) + +ALLOWED_HOSTS = config('DJANGO_ALLOWED_HOSTS', default='localhost,127.0.0.1', cast=lambda v: [s.strip() for s in v.split(',')]) + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # 3. Party + 'rest_framework', + 'djoser', + 'drf_spectacular', + 'drf_spectacular_sidecar', + 'django_celery_results', + 'django_celery_beat', + # My App + 'accounts', + 'namecreate', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'accounts.middleware.AccountExpirationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'core.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'] + , + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + +USE_POSTGRES = config('USE_POSTGRES', default=False, cast=bool) + +if USE_POSTGRES: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': config('POSTGRES_DB', default='beyhan_blog'), + 'USER': config('POSTGRES_USER', default='beyhan_blog'), + 'PASSWORD': config('POSTGRES_PASSWORD', default='1923btO**'), + 'HOST': config('POSTGRES_HOST', default='212.64.215.243'), + 'PORT': config('POSTGRES_PORT', default='5432'), + 'OPTIONS': { + 'options': '-c search_path=public' + }, + } + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } + } + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_DIRS = [ + BASE_DIR / 'static', +] +# Media files (User uploaded files) +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'Your Project API', + 'DESCRIPTION': 'Your project description', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + 'SWAGGER_UI_DIST': 'SIDECAR', # shorthand to use the sidecar instead + 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', + 'REDOC_DIST': 'SIDECAR', + 'SWAGGER_UI_SETTINGS': { + 'persistAuthorization': True, + }, +} + +DJOSER = { + 'LOGIN_FIELD': 'email', + 'USER_ID_FIELD': 'id', + 'USER_CREATE_PASSWORD_RETYPE': True, + 'SERIALIZERS': { + 'user_create': 'accounts.serializers.CustomUserCreateSerializer', + 'current_user': 'accounts.serializers.CustomUserSerializer', + 'user': 'accounts.serializers.CustomUserSerializer', + }, +} + +SIMPLE_JWT = { + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# ============================================================================== +# EMAIL CONFIGURATION +# ============================================================================== +EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend') +EMAIL_HOST = config('EMAIL_HOST', default='localhost') +EMAIL_PORT = config('EMAIL_PORT', default=1025, cast=int) +EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') +EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=False, cast=bool) +EMAIL_USE_SSL = config('EMAIL_USE_SSL', default=False, cast=bool) +DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@localhost') + +# ============================================================================== +# CELERY & REDIS CONFIGURATION +# ============================================================================== +CELERY_BROKER_URL = config('CELERY_BROKER_URL', default='redis://127.0.0.1:6379/5') +CELERY_RESULT_BACKEND = 'django-db' +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE +CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler' +CELERY_BEAT_SCHEDULE = { + 'deactivate-expired-users-daily': { + 'task': 'accounts.tasks.deactivate_expired_users_task', + 'schedule': 60 * 60 * 24, + }, +} + +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + 'LOCATION': config('CELERY_BROKER_URL', default='redis://127.0.0.1:6379/5'), + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + }, + 'KEY_PREFIX': 'insta', + 'TIMEOUT': 300, # 5 dakika + } +} + +# ============================================================================== +# CUSTOM USER MODEL +# ============================================================================== +AUTH_USER_MODEL = 'accounts.CustomUser' + +# ============================================================================== +# GO SERVICE CONFIGURATION +# ============================================================================== +GO_SERVICE_URL = config('GO_SERVICE_URL', default=None) + +# ============================================================================== +# LLM GENERATOR CONFIGURATION +# ============================================================================== +# Ollama icin ornek: http://127.0.0.1:11434/api/chat +# OpenAI uyumlu endpoint icin ornek: https://api.openai.com/v1/chat/completions +LLM_API_URL = config('LLM_API_URL', default='http://127.0.0.1:11434/api/chat') +LLM_MODEL = config('LLM_MODEL', default='llama3.1:8b') +LLM_API_KEY = config('LLM_API_KEY', default='') +LLM_TIMEOUT = config('LLM_TIMEOUT', default=30, cast=int) + +# ============================================================================== +# USERNAME REGENERATION SUFFIX RANGE +# ============================================================================== +# Yeniden uretimde rastgele sonek bu araliktan secilir. +# USERNAME_SUFFIX_MIN ve USERNAME_SUFFIX_MAX .env ile degistirilebilir. +USERNAME_SUFFIX_MIN = config('USERNAME_SUFFIX_MIN', default=2, cast=int) +USERNAME_SUFFIX_MAX = config('USERNAME_SUFFIX_MAX', default=999, cast=int) diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..409d52b --- /dev/null +++ b/core/urls.py @@ -0,0 +1,35 @@ +""" +URL configuration for core project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/6.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/v1/', include('accounts.urls')), + path('api/v1/ml/', include('namecreate.urls')), + path('api/v1/schema/', SpectacularAPIView.as_view(), name='schema'), + # Optional UI: + path('api/v1/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/v1/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), +] +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/core/wsgi.py b/core/wsgi.py new file mode 100644 index 0000000..6d36530 --- /dev/null +++ b/core/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for core project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..f2a662c --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/media/models/model_2026-03-27_19-57-58.onnx b/media/models/model_2026-03-27_19-57-58.onnx new file mode 100644 index 0000000..43d18d0 Binary files /dev/null and b/media/models/model_2026-03-27_19-57-58.onnx differ diff --git a/namecreate/__init__.py b/namecreate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/namecreate/admin.py b/namecreate/admin.py new file mode 100644 index 0000000..4b20fce --- /dev/null +++ b/namecreate/admin.py @@ -0,0 +1,250 @@ +import io +from django.contrib import admin +from django.contrib import messages +from django.core.management import call_command +from django.http import HttpResponseRedirect +from django.urls import path, reverse +from django.utils.html import format_html +from .models import TrainingJob, NameVocab, GeneratedPerson + + +class GeneratedPersonInline(admin.TabularInline): + model = GeneratedPerson + extra = 0 + fields = ( + 'first_name', 'last_name', 'username', 'username_locked', + 'birth_date', 'gender', 'confidence', 'generated_at' + ) + readonly_fields = ( + 'first_name', 'last_name', 'username', 'username_locked', + 'birth_date', 'gender', 'confidence', 'generated_at' + ) + can_delete = False + show_change_link = True + + +@admin.register(TrainingJob) +class TrainingJobAdmin(admin.ModelAdmin): + list_display = ( + 'id', + 'task_id', + 'status', + 'model_type', + 'model_file_exists', + 'generated_person_count', + 'accuracy', + 'created_at', + 'completed_at', + ) + list_filter = ('status', 'model_type', 'created_at') + search_fields = ('task_id', 'error_message') + readonly_fields = ( + 'task_id', + 'created_at', + 'started_at', + 'completed_at', + 'model_version', + 'model_file_exists', + 'generated_person_count', + ) + actions = ( + 'generate_100_statistical_action', + 'generate_100_llm_action', + 'generate_1000_statistical_action', + 'generate_1000_llm_action', + ) + inlines = (GeneratedPersonInline,) + + fieldsets = ( + ('Görev Bilgisi', { + 'fields': ('task_id', 'status', 'created_at', 'started_at', 'completed_at') + }), + ('Model', { + 'fields': ('model_type', 'model_version', 'model_path', 'model_file_exists') + }), + ('Uretilen Kayitlar', { + 'fields': ('generated_person_count',) + }), + ('Metrikler', { + 'fields': ('accuracy', 'precision', 'recall', 'f1_score') + }), + ('Go Servisi', { + 'fields': ('go_service_notified',) + }), + ('Hata', { + 'fields': ('error_message',), + 'classes': ('collapse',) + }), + ) + + def model_file_exists(self, obj): + return bool(obj.model_path and __import__('os').path.exists(obj.model_path)) + + model_file_exists.boolean = True + model_file_exists.short_description = 'Model dosyasi var' + + def generated_person_count(self, obj): + return obj.generated_persons.count() + + generated_person_count.short_description = 'Uretilen kisi sayisi' + + def _run_generate_persons(self, request, queryset, count, use_llm): + total_jobs = queryset.count() + ok_jobs = 0 + + for job in queryset: + try: + out = io.StringIO() + kwargs = { + 'count': count, + 'job_id': job.pk, + 'stdout': out, + } + if use_llm: + kwargs['use_llm'] = True + else: + kwargs['no_llm'] = True + + call_command('generate_persons', **kwargs) + ok_jobs += 1 + except Exception as exc: + self.message_user( + request, + f'Job id={job.pk} icin uretim hatasi: {exc}', + level=messages.ERROR, + ) + + mode = 'LLM (fallback ile)' if use_llm else 'Istatistiksel' + self.message_user( + request, + f'Uretim tamamlandi. Mod: {mode}. Basarili job: {ok_jobs}/{total_jobs}. Her job icin {count} kayit.', + level=messages.SUCCESS, + ) + + @admin.action(description='Secili job(lar) icin 100 kisi uret (Istatistiksel)') + def generate_100_statistical_action(self, request, queryset): + self._run_generate_persons(request, queryset, count=100, use_llm=False) + + @admin.action(description='Secili job(lar) icin 100 kisi uret (LLM + fallback)') + def generate_100_llm_action(self, request, queryset): + self._run_generate_persons(request, queryset, count=100, use_llm=True) + + @admin.action(description='Secili job(lar) icin 1000 kisi uret (Istatistiksel)') + def generate_1000_statistical_action(self, request, queryset): + self._run_generate_persons(request, queryset, count=1000, use_llm=False) + + @admin.action(description='Secili job(lar) icin 1000 kisi uret (LLM + fallback)') + def generate_1000_llm_action(self, request, queryset): + self._run_generate_persons(request, queryset, count=1000, use_llm=True) + + +@admin.register(NameVocab) +class NameVocabAdmin(admin.ModelAdmin): + list_display = ('name', 'name_type', 'gender', 'origin', 'frequency') + list_filter = ('origin', 'name_type', 'gender') + search_fields = ('name',) + ordering = ('origin', '-frequency') + + +@admin.register(GeneratedPerson) +class GeneratedPersonAdmin(admin.ModelAdmin): + list_display = ( + 'first_name', 'last_name', 'username', 'username_locked', + 'birth_date', 'gender', 'confidence', 'generated_at', 'username_ops' + ) + list_filter = ('gender', 'username_locked', 'generated_at') + search_fields = ('first_name', 'last_name', 'username') + readonly_fields = ('generated_at',) + actions = ('regenerate_username_action', 'lock_username_action', 'unlock_username_action') + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + '/regenerate-username/', + self.admin_site.admin_view(self.regenerate_username_view), + name='namecreate_generatedperson_regenerate_username', + ), + path( + '/toggle-username-lock/', + self.admin_site.admin_view(self.toggle_username_lock_view), + name='namecreate_generatedperson_toggle_username_lock', + ), + ] + return custom_urls + urls + + def regenerate_username_view(self, request, person_id): + person = self.get_object(request, person_id) + if person is None: + self.message_user(request, 'Kayit bulunamadi.', level=messages.ERROR) + return HttpResponseRedirect('../') + + if person.username_locked: + self.message_user(request, 'Username kilitli. Once kilidi acin.', level=messages.WARNING) + return HttpResponseRedirect('../../') + + old_username = person.username + person.regenerate_username(save=True) + self.message_user( + request, + f'Username guncellendi: {old_username} -> {person.username}', + level=messages.SUCCESS, + ) + return HttpResponseRedirect('../../') + + def toggle_username_lock_view(self, request, person_id): + person = self.get_object(request, person_id) + if person is None: + self.message_user(request, 'Kayit bulunamadi.', level=messages.ERROR) + return HttpResponseRedirect('../') + + person.username_locked = not person.username_locked + person.save(update_fields=['username_locked']) + state = 'kilitlendi' if person.username_locked else 'kilidi acildi' + self.message_user(request, f'Username {state}.', level=messages.SUCCESS) + return HttpResponseRedirect('../../') + + def username_ops(self, obj): + regen_url = reverse('admin:namecreate_generatedperson_regenerate_username', args=[obj.pk]) + lock_url = reverse('admin:namecreate_generatedperson_toggle_username_lock', args=[obj.pk]) + lock_label = 'Kilidi ac' if obj.username_locked else 'Kilitle' + return format_html( + 'Yeniden uret ' + '{}', + regen_url, + lock_url, + lock_label, + ) + + username_ops.short_description = 'Islemler' + + @admin.action(description='Secili kayitlarda username yeniden uret') + def regenerate_username_action(self, request, queryset): + used_usernames = set( + GeneratedPerson.objects.exclude(username='').values_list('username', flat=True) + ) + updated = 0 + skipped = 0 + for person in queryset: + if person.username_locked: + skipped += 1 + continue + + used_usernames.discard(person.username) + person.regenerate_username(used_usernames=used_usernames, force=True, save=True) + updated += 1 + + self.message_user( + request, + f'{updated} kayitta username yeniden uretildi. Kilitli oldugu icin atlanan: {skipped}.' + ) + + @admin.action(description='Secili kayitlarda username kilitle') + def lock_username_action(self, request, queryset): + count = queryset.update(username_locked=True) + self.message_user(request, f'{count} kayitta username kilitlendi.') + + @admin.action(description='Secili kayitlarda username kilidini ac') + def unlock_username_action(self, request, queryset): + count = queryset.update(username_locked=False) + self.message_user(request, f'{count} kayitta username kilidi acildi.') diff --git a/namecreate/apps.py b/namecreate/apps.py new file mode 100644 index 0000000..5d0bff2 --- /dev/null +++ b/namecreate/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NamecreateConfig(AppConfig): + name = 'namecreate' diff --git a/namecreate/llm_generator.py b/namecreate/llm_generator.py new file mode 100644 index 0000000..1522fef --- /dev/null +++ b/namecreate/llm_generator.py @@ -0,0 +1,110 @@ +import json +from datetime import date, datetime + +import requests +from django.conf import settings + + +def _build_prompt(count, min_age): + max_year = date.today().year - min_age + return ( + "Turkiye icin gercekci kisi verisi uret. " + "Turkce isim ve soyisim oncelikli olsun. " + f"{count} adet kayit uret. " + f"Her kaydin dogum yili en fazla {max_year} olsun (yani en az {min_age} yas). " + "Sadece JSON dondur. Baska metin yazma. " + "Format tam olarak su olsun: " + "{\"people\":[{\"first_name\":\"...\",\"last_name\":\"...\",\"gender\":\"E|K\",\"birth_date\":\"YYYY-MM-DD\"}]}" + ) + + +def _validate_people(items, min_age): + valid = [] + today = date.today() + for item in items: + first_name = str(item.get('first_name', '')).strip() + last_name = str(item.get('last_name', '')).strip() + gender = str(item.get('gender', '')).strip().upper() + birth_date_raw = str(item.get('birth_date', '')).strip() + + if not first_name or not last_name: + continue + if gender not in {'E', 'K'}: + continue + try: + birth_date = datetime.strptime(birth_date_raw, '%Y-%m-%d').date() + except ValueError: + continue + + age = today.year - birth_date.year - ((today.month, today.day) < (birth_date.month, birth_date.day)) + if age < min_age: + continue + + valid.append({ + 'first_name': first_name, + 'last_name': last_name, + 'gender': gender, + 'birth_date': birth_date, + }) + + return valid + + +def generate_people_with_llm(count, min_age=20): + """LLM API'den kisi verisi alir, validate edip dondurur.""" + api_url = getattr(settings, 'LLM_API_URL', None) + model = getattr(settings, 'LLM_MODEL', None) + timeout = getattr(settings, 'LLM_TIMEOUT', 30) + api_key = getattr(settings, 'LLM_API_KEY', None) + + if not api_url or not model: + raise RuntimeError('LLM_API_URL veya LLM_MODEL ayari eksik.') + + prompt = _build_prompt(count=count, min_age=min_age) + headers = {'Content-Type': 'application/json'} + if api_key: + headers['Authorization'] = f'Bearer {api_key}' + + # OpenAI uyumlu endpoint + if '/v1/chat/completions' in api_url: + payload = { + 'model': model, + 'response_format': {'type': 'json_object'}, + 'messages': [ + {'role': 'system', 'content': 'JSON disinda metin uretme.'}, + {'role': 'user', 'content': prompt}, + ], + 'temperature': 0.8, + } + resp = requests.post(api_url, headers=headers, json=payload, timeout=timeout) + resp.raise_for_status() + content = resp.json()['choices'][0]['message']['content'] + else: + # Varsayilan: Ollama /api/chat + payload = { + 'model': model, + 'stream': False, + 'format': 'json', + 'messages': [ + {'role': 'system', 'content': 'JSON disinda metin uretme.'}, + {'role': 'user', 'content': prompt}, + ], + } + resp = requests.post(api_url, headers=headers, json=payload, timeout=timeout) + resp.raise_for_status() + body = resp.json() + content = body.get('message', {}).get('content') or body.get('response') + + if not content: + raise RuntimeError('LLM bos cevap dondurdu.') + + parsed = json.loads(content) + people = parsed.get('people') if isinstance(parsed, dict) else None + if not isinstance(people, list): + raise RuntimeError('LLM cevabi beklenen JSON formatinda degil.') + + valid_people = _validate_people(people, min_age=min_age) + if not valid_people: + raise RuntimeError('LLM gecerli kisi verisi dondurmedi.') + + return valid_people diff --git a/namecreate/management/__init__.py b/namecreate/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/namecreate/management/commands/__init__.py b/namecreate/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/namecreate/management/commands/generate_persons.py b/namecreate/management/commands/generate_persons.py new file mode 100644 index 0000000..1f97d53 --- /dev/null +++ b/namecreate/management/commands/generate_persons.py @@ -0,0 +1,204 @@ +""" +NameVocab tablosundan ağırlıklı örneklemeyle kişi verisi üretir. +- Doğum tarihi: 20 yaş ve üstü (bugüne göre) +- Türkçe isimler frekans ağırlıklı olarak önce gelir +- Üretilen kayıtlar GeneratedPerson tablosuna kaydedilir + +Kullanım: + python manage.py generate_persons # 1000 kişi + python manage.py generate_persons --count 500 # 500 kişi + python manage.py generate_persons --clear # önce tabloyu temizle +""" + +import random +import calendar +from datetime import date + +from django.core.management.base import BaseCommand, CommandError +from namecreate.models import NameVocab, GeneratedPerson, TrainingJob +from namecreate.username_utils import build_unique_username +from namecreate.llm_generator import generate_people_with_llm + +# Bugün: 27 Mart 2026 — 20 yaş ve üstü → en geç 27 Mart 2006 doğum +MAX_BIRTH_YEAR = date.today().year - 20 # 2006 +MIN_BIRTH_YEAR = 1940 + + +def _weighted_pick(queryset): + """Frequency değerlerine göre ağırlıklı rastgele seçim.""" + names = list(queryset.values_list('name', 'frequency')) + if not names: + raise CommandError("NameVocab tablosu boş. Önce 'seed_name_vocab' komutunu çalıştırın.") + population = [n for n, _ in names] + weights = [f for _, f in names] + return random.choices(population, weights=weights, k=1)[0] + + +def _random_birth_date(): + """20 yaş ve üstü rastgele doğum tarihi üretir.""" + year = random.randint(MIN_BIRTH_YEAR, MAX_BIRTH_YEAR) + month = random.randint(1, 12) + # Ayın son gününü doğru hesapla (şubat, 30/31 gün farkları) + _, max_day = calendar.monthrange(year, month) + + # 2006 doğumsa mart ayı sonrasında doğanlar henüz 20 yaşında değil + if year == MAX_BIRTH_YEAR: + today = date.today() + if month > today.month: + month = random.randint(1, today.month) + if month == today.month: + _, max_day = calendar.monthrange(year, month) + max_day = min(max_day, today.day) + + _, max_day = calendar.monthrange(year, month) + day = random.randint(1, max_day) + return date(year, month, day) + + +class Command(BaseCommand): + help = '1000 kişilik sahte isim/soyisim/doğum tarihi verisi üretir (20 yaş ve üstü)' + + def add_arguments(self, parser): + parser.add_argument( + '--count', type=int, default=1000, + help='Üretilecek kişi sayısı (varsayılan: 1000)' + ) + parser.add_argument( + '--clear', action='store_true', + help='Üretmeden önce mevcut GeneratedPerson kayıtlarını sil' + ) + parser.add_argument( + '--job-id', type=int, default=None, + help='Bağlanacak TrainingJob ID (belirtilmezse en son tamamlanmış iş kullanılır)' + ) + parser.add_argument( + '--use-llm', action='store_true', + help='Uretimi LLM ile yapmayi dener; basarisiz olursa istatistiksel fallback yapar.' + ) + parser.add_argument( + '--no-llm', action='store_true', + help='LLM denemeyi kapatir, dogrudan istatistiksel uretim yapar.' + ) + parser.add_argument( + '--llm-strict', action='store_true', + help='LLM basarisiz olursa fallback yapma, komutu hata ile sonlandir.' + ) + + def handle(self, *args, **options): + count = options['count'] + + if options['clear']: + deleted, _ = GeneratedPerson.objects.all().delete() + self.stdout.write(self.style.WARNING(f'{deleted} eski kayıt silindi.')) + + # TrainingJob bağlantısını belirle + job_id = options.get('job_id') + if job_id: + try: + training_job = TrainingJob.objects.get(pk=job_id) + except TrainingJob.DoesNotExist: + raise CommandError(f'TrainingJob id={job_id} bulunamadı.') + else: + training_job = TrainingJob.objects.filter(status='completed').exclude( + model_path=None + ).order_by('-completed_at').first() + if training_job is None: + self.stdout.write(self.style.WARNING( + 'Tamamlanmış (model dosyalı) TrainingJob bulunamadı. training_job=None olarak üretilecek.' + )) + else: + self.stdout.write(f'TrainingJob kullanılıyor: id={training_job.pk} ({training_job.model_type})') + + # Erkek / kadın isimlerini bir kez çek + male_first = NameVocab.objects.filter(name_type='first', gender='E') + female_first = NameVocab.objects.filter(name_type='first', gender='K') + last_names = NameVocab.objects.filter(name_type='last') + + if not male_first.exists() or not female_first.exists() or not last_names.exists(): + raise CommandError( + "NameVocab tablosu eksik. Önce 'python manage.py seed_name_vocab' komutunu çalıştırın." + ) + + # Tüm ağırlıklı listeleri RAM'e al (1000 kayıt için yeterli) + male_pool = list(male_first.values_list('name', 'frequency')) + female_pool = list(female_first.values_list('name', 'frequency')) + last_pool = list(last_names.values_list('name', 'frequency')) + + def pick(pool): + names, weights = zip(*pool) + return random.choices(names, weights=weights, k=1)[0] + + used_usernames = set(GeneratedPerson.objects.values_list('username', flat=True).exclude(username='')) + persons = [] + source = 'statistical' + + llm_rows = None + use_llm = options.get('use_llm') or not options.get('no_llm') + if use_llm: + try: + llm_rows = generate_people_with_llm(count=count, min_age=20) + source = 'llm' + except Exception as e: + if options.get('llm_strict'): + raise CommandError(f'LLM uretimi basarisiz: {e}') + self.stdout.write(self.style.WARNING( + f'LLM uretimi basarisiz ({e}). Istatistiksel fallback kullaniliyor.' + )) + + if llm_rows: + for row in llm_rows[:count]: + first = row['first_name'] + last = row['last_name'] + gender = row['gender'] + birth = row['birth_date'] + username = build_unique_username(first, last, birth, used_usernames) + persons.append(GeneratedPerson( + first_name=first, + last_name=last.upper(), + username=username, + birth_date=birth, + gender=gender, + confidence=None, + training_job=training_job, + )) + + missing = count - len(persons) + for _ in range(missing): + gender = random.choice(['E', 'K']) + first = pick(male_pool if gender == 'E' else female_pool) + last = pick(last_pool) + birth = _random_birth_date() + username = build_unique_username(first, last, birth, used_usernames) + persons.append(GeneratedPerson( + first_name=first, + last_name=last.upper(), # soyisim büyük harf + username=username, + birth_date=birth, + gender=gender, + confidence=None, + training_job=training_job, + )) + + GeneratedPerson.objects.bulk_create(persons) + + self.stdout.write(self.style.SUCCESS( + f'\n{count} kişi başarıyla üretildi ve kaydedildi.' + )) + self.stdout.write(f' Kaynak: {source}') + self.stdout.write( + f" Erkek: {sum(1 for p in persons if p.gender == 'E')} | " + f"Kadın: {sum(1 for p in persons if p.gender == 'K')}" + ) + self.stdout.write( + f" Doğum aralığı: {MIN_BIRTH_YEAR} – {MAX_BIRTH_YEAR} (20 yaş ve üstü)" + ) + # Örnek 5 kayıt göster + self.stdout.write('\nİlk 5 örnek:') + for p in persons[:5]: + age = date.today().year - p.birth_date.year - ( + (date.today().month, date.today().day) < (p.birth_date.month, p.birth_date.day) + ) + self.stdout.write( + f" {p.first_name} {p.last_name} (@{p.username}) | {p.birth_date} | " + f"{p.get_gender_display()} | {age} yaş" + ) diff --git a/namecreate/management/commands/seed_name_vocab.py b/namecreate/management/commands/seed_name_vocab.py new file mode 100644 index 0000000..e0f762c --- /dev/null +++ b/namecreate/management/commands/seed_name_vocab.py @@ -0,0 +1,125 @@ +""" +Türkçe kökenli isimler öncelikli olarak NameVocab tablosunu doldurur. +Kullanım: python manage.py seed_name_vocab +""" +from django.core.management.base import BaseCommand +from namecreate.models import NameVocab + + +# ----------------------------------------------------------------------- +# Türkçe kökenli isimler — en yüksek öncelik +# ----------------------------------------------------------------------- +TURKCE_ERKEK = [ + ("Alp", 80), ("Alpay", 60), ("Alperen", 100), ("Altan", 70), + ("Aydın", 90), ("Baran", 85), ("Barış", 120), ("Batuhan", 95), + ("Berk", 75), ("Berkay", 80), ("Burak", 140), ("Çağan", 50), + ("Çağrı", 65), ("Deniz", 110), ("Doğan", 70), ("Doruk", 55), + ("Emre", 160), ("Enes", 90), ("Eren", 130), ("Erhan", 75), + ("Furkan", 85), ("Görkem", 60), ("Güven", 45), ("Haluk", 50), + ("İlker", 70), ("Kaan", 95), ("Kadir", 80), ("Kerem", 100), + ("Koral", 45), ("Korhan", 50), ("Mert", 130), ("Oğuz", 75), + ("Onur", 90), ("Orkun", 55), ("Selim", 85), ("Sercan", 70), + ("Serdar", 80), ("Soner", 65), ("Tarık", 75), ("Tuna", 50), + ("Tunahan", 60), ("Uğur", 85), ("Umut", 100), ("Ufuk", 55), + ("Volkan", 80), ("Yiğit", 90), ("Yunus", 95), ("Zafer", 60), +] + +TURKCE_KADIN = [ + ("Aslı", 110), ("Aylin", 100), ("Aynur", 75), ("Ayşen", 65), + ("Banu", 70), ("Bahar", 90), ("Başak", 80), ("Belgin", 55), + ("Bengü", 50), ("Berrak", 60), ("Burcu", 95), ("Büşra", 85), + ("Cansu", 100), ("Ceren", 120), ("Çiğdem", 80), ("Deniz", 90), + ("Ebru", 95), ("Elçin", 65), ("Elif", 150), ("Esra", 110), + ("Ezgi", 100), ("Gizem", 90), ("Gül", 75), ("Gülşen", 55), + ("Güneş", 50), ("Hande", 85), ("İlayda", 75), ("İpek", 80), + ("Melike", 90), ("Meltem", 95), ("Merve", 130), ("Nilay", 80), + ("Nur", 70), ("Özge", 100), ("Pınar", 90), ("Seda", 85), + ("Selin", 100), ("Sibel", 80), ("Simge", 75), ("Tuğba", 85), + ("Tülay", 60), ("Ülkü", 50), ("Yasemin", 110), ("Zeynep", 120), + ("Zümra", 55), +] + +# Türkçe soyisimler +TURKCE_SOYISIM = [ + ("Yılmaz", 200), ("Kaya", 180), ("Demir", 170), ("Çelik", 150), + ("Şahin", 140), ("Yıldız", 130), ("Arslan", 120), ("Doğan", 115), + ("Kılıç", 110), ("Aslan", 105), ("Çetin", 100), ("Bulut", 95), + ("Aydın", 90), ("Özdemir", 90), ("Demirci", 85), ("Güler", 80), + ("Erdoğan", 75), ("Çakır", 75), ("Polat", 70), ("Koç", 70), + ("Acar", 65), ("Kurt", 65), ("Yavuz", 65), ("Ateş", 60), + ("Güneş", 60), ("Işık", 60), ("Karaca", 55), ("Türk", 55), + ("Özkan", 55), ("Bay", 50), ("Toker", 50), ("Şimşek", 50), + ("Akay", 45), ("Boz", 45), ("Deniz", 45), ("Ercan", 45), + ("Güçlü", 40), ("Kaplan", 40), ("Savaş", 40), ("Turan", 40), + ("Baş", 35), ("Çam", 35), ("Kara", 35), ("Taş", 35), + ("Dağ", 30), ("Duman", 30), ("Gür", 30), ("Köse", 30), + ("Uçar", 30), ("Yurt", 30), +] + +# ----------------------------------------------------------------------- +# Batı kökenli isimler — ikinci öncelik +# ----------------------------------------------------------------------- +BATI_ERKEK = [ + ("Can", 110), ("Cem", 90), ("Cenk", 75), ("Sarp", 60), + ("Alper", 70), ("Enver", 55), +] + +BATI_KADIN = [ + ("Ece", 100), ("Derya", 85), ("Sera", 60), ("Lara", 70), + ("Nisa", 80), ("Sena", 75), +] + +# ----------------------------------------------------------------------- +# Arapça kökenli isimler — son öncelik (küçük liste) +# ----------------------------------------------------------------------- +ARAPCA_ERKEK = [ + ("Ahmet", 160), ("Ali", 150), ("Mehmet", 170), ("Hasan", 100), + ("Hüseyin", 95), ("İbrahim", 90), ("Mustafa", 140), ("Ömer", 80), +] + +ARAPCA_KADIN = [ + ("Fatma", 120), ("Ayşe", 130), ("Hatice", 90), ("Havva", 70), + ("Meryem", 80), ("Rabia", 65), +] + + +def _bulk_create(entries, name_type, gender, origin): + objs = [] + for name, freq in entries: + objs.append(NameVocab( + name=name, + name_type=name_type, + gender=gender, + origin=origin, + frequency=freq, + )) + # ignore_conflicts: aynı kayıt varsa atla + NameVocab.objects.bulk_create(objs, ignore_conflicts=True) + return len(objs) + + +class Command(BaseCommand): + help = 'NameVocab tablosunu Türkçe kökenli isimler öncelikli olarak doldurur' + + def handle(self, *args, **options): + total = 0 + + # Türkçe — birinci öncelik + total += _bulk_create(TURKCE_ERKEK, 'first', 'E', 'turkce') + total += _bulk_create(TURKCE_KADIN, 'first', 'K', 'turkce') + total += _bulk_create(TURKCE_SOYISIM, 'last', 'U', 'turkce') + + # Batı — ikinci öncelik + total += _bulk_create(BATI_ERKEK, 'first', 'E', 'bati') + total += _bulk_create(BATI_KADIN, 'first', 'K', 'bati') + + # Arapça — son öncelik + total += _bulk_create(ARAPCA_ERKEK, 'first', 'E', 'arapca') + total += _bulk_create(ARAPCA_KADIN, 'first', 'K', 'arapca') + + self.stdout.write(self.style.SUCCESS( + f'{total} isim işlendi. ' + f'Türkçe: {NameVocab.objects.filter(origin="turkce").count()}, ' + f'Batı: {NameVocab.objects.filter(origin="bati").count()}, ' + f'Arapça: {NameVocab.objects.filter(origin="arapca").count()}' + )) diff --git a/namecreate/migrations/0001_initial.py b/namecreate/migrations/0001_initial.py new file mode 100644 index 0000000..b2e2777 --- /dev/null +++ b/namecreate/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 6.0.3 on 2026-03-27 19:47 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='TrainingJob', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('task_id', models.CharField(help_text='Celery task ID', max_length=255, unique=True)), + ('status', models.CharField(choices=[('pending', 'Beklemede'), ('running', 'Eğitiliyor'), ('completed', 'Tamamlandı'), ('failed', 'Başarısız')], default='pending', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('model_type', models.CharField(default='RandomForest', max_length=100)), + ('model_version', models.DateTimeField(default=django.utils.timezone.now, help_text='Model versiyonu (timestamp)')), + ('model_path', models.FilePathField(blank=True, null=True)), + ('accuracy', models.FloatField(blank=True, null=True)), + ('precision', models.FloatField(blank=True, null=True)), + ('recall', models.FloatField(blank=True, null=True)), + ('f1_score', models.FloatField(blank=True, null=True)), + ('error_message', models.TextField(blank=True, null=True)), + ('go_service_notified', models.BooleanField(default=False)), + ], + options={ + 'verbose_name': 'Eğitim Görevi', + 'verbose_name_plural': 'Eğitim Görevleri', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/namecreate/migrations/0002_trainingjob_feature_count_trainingjob_features_and_more.py b/namecreate/migrations/0002_trainingjob_feature_count_trainingjob_features_and_more.py new file mode 100644 index 0000000..5e4a813 --- /dev/null +++ b/namecreate/migrations/0002_trainingjob_feature_count_trainingjob_features_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.3 on 2026-03-27 19:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('namecreate', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='trainingjob', + name='feature_count', + field=models.PositiveIntegerField(blank=True, help_text='Özellik (sütun) sayısı — ONNX tipi için kullanılır', null=True), + ), + migrations.AddField( + model_name='trainingjob', + name='features', + field=models.JSONField(blank=True, help_text='2D liste: her satır bir örnek, her sütun bir özellik', null=True), + ), + migrations.AddField( + model_name='trainingjob', + name='labels', + field=models.JSONField(blank=True, help_text='1D liste: her örneğin sınıf etiketi', null=True), + ), + migrations.AddField( + model_name='trainingjob', + name='sample_count', + field=models.PositiveIntegerField(blank=True, help_text='Eğitim verisi satır sayısı', null=True), + ), + ] diff --git a/namecreate/migrations/0003_generatedperson_namevocab.py b/namecreate/migrations/0003_generatedperson_namevocab.py new file mode 100644 index 0000000..7973ef4 --- /dev/null +++ b/namecreate/migrations/0003_generatedperson_namevocab.py @@ -0,0 +1,49 @@ +# Generated by Django 6.0.3 on 2026-03-27 20:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('namecreate', '0002_trainingjob_feature_count_trainingjob_features_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='GeneratedPerson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=100)), + ('last_name', models.CharField(max_length=100)), + ('birth_date', models.DateField()), + ('gender', models.CharField(choices=[('E', 'Erkek'), ('K', 'Kadın')], max_length=1)), + ('confidence', models.FloatField(blank=True, help_text='Modelin seçim güven skoru (0-1)', null=True)), + ('generated_at', models.DateTimeField(auto_now_add=True)), + ('training_job', models.ForeignKey(blank=True, help_text='Bu kişiyi üreten model versiyonu', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='generated_persons', to='namecreate.trainingjob')), + ], + options={ + 'verbose_name': 'Üretilen Kişi', + 'verbose_name_plural': 'Üretilen Kişiler', + 'ordering': ['-generated_at'], + }, + ), + migrations.CreateModel( + name='NameVocab', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='İsim veya soyisim', max_length=100)), + ('name_type', models.CharField(choices=[('first', 'İsim'), ('last', 'Soyisim')], max_length=5)), + ('gender', models.CharField(choices=[('E', 'Erkek'), ('K', 'Kadın'), ('U', 'Unisex')], default='U', max_length=1)), + ('origin', models.CharField(choices=[('turkce', 'Türkçe'), ('bati', 'Batı'), ('diger', 'Diğer'), ('arapca', 'Arapça')], default='turkce', help_text='Türkçe kökenli isimler varsayılan ve önceliklidir', max_length=10)), + ('frequency', models.PositiveIntegerField(default=1, help_text='Veri setindeki görülme sıklığı — ağırlıklı seçimde kullanılır')), + ], + options={ + 'verbose_name': 'İsim Sözlüğü', + 'verbose_name_plural': 'İsim Sözlüğü', + 'ordering': ['origin', '-frequency', 'name'], + 'unique_together': {('name', 'name_type', 'gender')}, + }, + ), + ] diff --git a/namecreate/migrations/0004_alter_trainingjob_model_path.py b/namecreate/migrations/0004_alter_trainingjob_model_path.py new file mode 100644 index 0000000..af8f58c --- /dev/null +++ b/namecreate/migrations/0004_alter_trainingjob_model_path.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-03-27 20:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('namecreate', '0003_generatedperson_namevocab'), + ] + + operations = [ + migrations.AlterField( + model_name='trainingjob', + name='model_path', + field=models.CharField(blank=True, max_length=500, null=True), + ), + ] diff --git a/namecreate/migrations/0005_generatedperson_username.py b/namecreate/migrations/0005_generatedperson_username.py new file mode 100644 index 0000000..e6bc42a --- /dev/null +++ b/namecreate/migrations/0005_generatedperson_username.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-03-27 20:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('namecreate', '0004_alter_trainingjob_model_path'), + ] + + operations = [ + migrations.AddField( + model_name='generatedperson', + name='username', + field=models.CharField(blank=True, db_index=True, max_length=150), + ), + ] diff --git a/namecreate/migrations/0006_generatedperson_username_locked.py b/namecreate/migrations/0006_generatedperson_username_locked.py new file mode 100644 index 0000000..631c6e1 --- /dev/null +++ b/namecreate/migrations/0006_generatedperson_username_locked.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-03-27 20:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('namecreate', '0005_generatedperson_username'), + ] + + operations = [ + migrations.AddField( + model_name='generatedperson', + name='username_locked', + field=models.BooleanField(default=False, help_text='Aciksa username yeniden uretilmez (force ile degistirilebilir).'), + ), + ] diff --git a/namecreate/migrations/__init__.py b/namecreate/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/namecreate/models.py b/namecreate/models.py new file mode 100644 index 0000000..68e2b73 --- /dev/null +++ b/namecreate/models.py @@ -0,0 +1,179 @@ +import random +from django.conf import settings +from django.db import models +from django.utils import timezone +from .username_utils import build_unique_username + + +class NameVocab(models.Model): + """Eğitim ve üretim için isim/soyisim sözlüğü. Türkçe kökenli isimler önceliklidir.""" + + ORIGIN_CHOICES = [ + ('turkce', 'Türkçe'), # Birinci öncelik + ('bati', 'Batı'), + ('diger', 'Diğer'), + ('arapca', 'Arapça'), # Son öncelik + ] + + NAME_TYPE_CHOICES = [ + ('first', 'İsim'), + ('last', 'Soyisim'), + ] + + GENDER_CHOICES = [ + ('E', 'Erkek'), + ('K', 'Kadın'), + ('U', 'Unisex'), + ] + + name = models.CharField(max_length=100, help_text='İsim veya soyisim') + name_type = models.CharField(max_length=5, choices=NAME_TYPE_CHOICES) + gender = models.CharField(max_length=1, choices=GENDER_CHOICES, default='U') + origin = models.CharField( + max_length=10, + choices=ORIGIN_CHOICES, + default='turkce', + help_text='Türkçe kökenli isimler varsayılan ve önceliklidir', + ) + frequency = models.PositiveIntegerField( + default=1, + help_text='Veri setindeki görülme sıklığı — ağırlıklı seçimde kullanılır' + ) + + class Meta: + ordering = ['origin', '-frequency', 'name'] + verbose_name = 'İsim Sözlüğü' + verbose_name_plural = 'İsim Sözlüğü' + unique_together = [('name', 'name_type', 'gender')] + + def __str__(self): + return f"{self.name} ({self.get_name_type_display()}, {self.get_gender_display()}, {self.get_origin_display()})" + + +class GeneratedPerson(models.Model): + """Modelin ürettiği kişi kaydı.""" + + GENDER_CHOICES = [ + ('E', 'Erkek'), + ('K', 'Kadın'), + ] + + training_job = models.ForeignKey( + 'TrainingJob', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='generated_persons', + help_text='Bu kişiyi üreten model versiyonu', + ) + first_name = models.CharField(max_length=100) + last_name = models.CharField(max_length=100) + username = models.CharField(max_length=150, blank=True, db_index=True) + username_locked = models.BooleanField( + default=False, + help_text='Aciksa username yeniden uretilmez (force ile degistirilebilir).' + ) + birth_date = models.DateField() + gender = models.CharField(max_length=1, choices=GENDER_CHOICES) + confidence = models.FloatField( + null=True, blank=True, + help_text='Modelin seçim güven skoru (0-1)' + ) + generated_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-generated_at'] + verbose_name = 'Üretilen Kişi' + verbose_name_plural = 'Üretilen Kişiler' + + def __str__(self): + return f"{self.first_name} {self.last_name} ({self.birth_date})" + + def regenerate_username(self, used_usernames=None, force=False, save=True): + """Kayit icin yeni bir username uretir. Mevcut username her zaman degisir.""" + if self.username_locked and not force: + return self.username + + if used_usernames is None: + used_usernames = set( + GeneratedPerson.objects.exclude(pk=self.pk).exclude(username='').values_list('username', flat=True) + ) + + # Mevcut username'i yasak listesine ekle ve rastgele sonek ile basla + # Boylece ayni base'e hic donulmez (ping-pong olmaz) + if self.username: + used_usernames.add(self.username) + + suffix_min = getattr(settings, 'USERNAME_SUFFIX_MIN', 2) + suffix_max = getattr(settings, 'USERNAME_SUFFIX_MAX', 999) + self.username = build_unique_username( + self.first_name, + self.last_name, + self.birth_date, + used_usernames, + _force_suffix=random.randint(suffix_min, suffix_max), + ) + if save: + self.save(update_fields=['username']) + return self.username + + +class TrainingJob(models.Model): + """Makine öğrenme model eğitim görevinin kaydı.""" + + STATUS_CHOICES = [ + ('pending', 'Beklemede'), + ('running', 'Eğitiliyor'), + ('completed', 'Tamamlandı'), + ('failed', 'Başarısız'), + ] + + # Temel bilgiler + task_id = models.CharField(max_length=255, unique=True, help_text='Celery task ID') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending') + created_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + # Model bilgileri + model_type = models.CharField(max_length=100, default='RandomForest') + model_version = models.DateTimeField(default=timezone.now, help_text='Model versiyonu (timestamp)') + model_path = models.CharField(max_length=500, null=True, blank=True) + + # Eğitim verisi + features = models.JSONField( + null=True, blank=True, + help_text='2D liste: her satır bir örnek, her sütun bir özellik' + ) + labels = models.JSONField( + null=True, blank=True, + help_text='1D liste: her örneğin sınıf etiketi' + ) + feature_count = models.PositiveIntegerField( + null=True, blank=True, + help_text='Özellik (sütun) sayısı — ONNX tipi için kullanılır' + ) + sample_count = models.PositiveIntegerField( + null=True, blank=True, + help_text='Eğitim verisi satır sayısı' + ) + + # Metrikler + accuracy = models.FloatField(null=True, blank=True) + precision = models.FloatField(null=True, blank=True) + recall = models.FloatField(null=True, blank=True) + f1_score = models.FloatField(null=True, blank=True) + + # Hata handling + error_message = models.TextField(null=True, blank=True) + + # Go servisine sinyal + go_service_notified = models.BooleanField(default=False) + + class Meta: + ordering = ['-created_at'] + verbose_name = 'Eğitim Görevi' + verbose_name_plural = 'Eğitim Görevleri' + + def __str__(self): + return f"{self.model_type} - {self.status} - {self.created_at}" diff --git a/namecreate/tasks.py b/namecreate/tasks.py new file mode 100644 index 0000000..3b34bf8 --- /dev/null +++ b/namecreate/tasks.py @@ -0,0 +1,129 @@ +import os +import requests +import numpy as np +from datetime import datetime +from celery import shared_task +from django.conf import settings +from sklearn.ensemble import RandomForestClassifier +from sklearn.datasets import load_iris +from sklearn.model_selection import train_test_split +from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score +from skl2onnx import convert_sklearn +from skl2onnx.common.data_types import FloatTensorType +from namecreate.models import TrainingJob + + +def notify_go_service(model_path, metrics): + """Go servisine model yüklenmiş olduğunu bildirir.""" + try: + go_service_url = settings.GO_SERVICE_URL + if not go_service_url: + return False + + payload = { + "model_path": model_path, + "metrics": metrics, + "timestamp": datetime.now().isoformat(), + } + + response = requests.post( + f"{go_service_url}/reload-model", + json=payload, + timeout=10 + ) + + return response.status_code == 200 + except Exception as e: + print(f"Go servisi bildirimi başarısız: {str(e)}") + return False + + +@shared_task(name='namecreate.tasks.train_model_task') +def train_model_task(task_id): + """ + Makine öğrenme modelini arka planda eğitir ve ONNX olarak kaydeder. + """ + try: + job = TrainingJob.objects.get(task_id=task_id) + job.status = 'running' + job.started_at = datetime.now() + job.save(update_fields=['status', 'started_at']) + + # 1. Veri Seti Yükleme + if job.features and job.labels: + # Kullanıcının gönderdiği veri + X = np.array(job.features, dtype=np.float32) + y = np.array(job.labels, dtype=np.int32) + else: + # Demo: Iris dataset + iris = load_iris() + X, y = iris.data.astype(np.float32), iris.target + + X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=0.2, random_state=42 + ) + feature_count = X.shape[1] + + # 2. Model Eğitimi + model = RandomForestClassifier(n_estimators=10, random_state=42) + model.fit(X_train, y_train) + + # 3. Metrikleri Hesapla + y_pred = model.predict(X_test) + accuracy = accuracy_score(y_test, y_pred) + precision = precision_score(y_test, y_pred, average='weighted') + recall = recall_score(y_test, y_pred, average='weighted') + f1 = f1_score(y_test, y_pred, average='weighted') + + # 4. ONNX Formatına Dönüştür (feature_count dinamik) + initial_type = [('float_input', FloatTensorType([None, feature_count]))] + onx = convert_sklearn(model, initial_types=initial_type) + + # 5. Dosyaya Kaydet (Versiyonlu - Timestamp ile) + timestamp = job.model_version.strftime('%Y-%m-%d_%H-%M-%S') + model_filename = f"model_{timestamp}.onnx" + model_path = os.path.join(settings.MEDIA_ROOT, 'models', model_filename) + + os.makedirs(os.path.dirname(model_path), exist_ok=True) + with open(model_path, "wb") as f: + f.write(onx.SerializeToString()) + + # 6. Go Servisine Bilder + metrics = { + 'accuracy': float(accuracy), + 'precision': float(precision), + 'recall': float(recall), + 'f1_score': float(f1), + } + go_notified = notify_go_service(model_path, metrics) + + # 7. Veritabanına Kaydet + job.status = 'completed' + job.completed_at = datetime.now() + job.model_path = model_path + job.accuracy = accuracy + job.precision = precision + job.recall = recall + job.f1_score = f1 + job.go_service_notified = go_notified + job.save() + + return { + 'status': 'success', + 'task_id': task_id, + 'model_path': model_path, + 'go_service_notified': go_notified, + 'metrics': metrics + } + + except Exception as e: + job = TrainingJob.objects.get(task_id=task_id) + job.status = 'failed' + job.error_message = str(e) + job.save(update_fields=['status', 'error_message']) + + return { + 'status': 'error', + 'task_id': task_id, + 'error': str(e) + } diff --git a/namecreate/tests.py b/namecreate/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/namecreate/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/namecreate/urls.py b/namecreate/urls.py new file mode 100644 index 0000000..0f09895 --- /dev/null +++ b/namecreate/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('train-model/', views.train_and_export_model, name='train-model'), + path('training-status/', views.get_training_status, name='training-status'), + path('training-jobs/', views.list_training_jobs, name='training-jobs'), +] diff --git a/namecreate/username_utils.py b/namecreate/username_utils.py new file mode 100644 index 0000000..602d238 --- /dev/null +++ b/namecreate/username_utils.py @@ -0,0 +1,60 @@ +import re + + +def normalize_for_username(value): + """Turkce karakterleri ASCII'ye cevirip username-safe hale getirir.""" + tr_map = str.maketrans({ + 'c': 'c', + 'C': 'c', + 'g': 'g', + 'G': 'g', + 'i': 'i', + 'I': 'i', + 'o': 'o', + 'O': 'o', + 's': 's', + 'S': 's', + 'u': 'u', + 'U': 'u', + 'ç': 'c', + 'Ç': 'c', + 'ğ': 'g', + 'Ğ': 'g', + 'ı': 'i', + 'İ': 'i', + 'ö': 'o', + 'Ö': 'o', + 'ş': 's', + 'Ş': 's', + 'ü': 'u', + 'Ü': 'u', + }) + value = value.translate(tr_map).lower() + return re.sub(r'[^a-z0-9]+', '', value) + + +def build_unique_username(first_name, last_name, birth_date, used_usernames, _force_suffix=None): + """ + ad.soyadYY formatinda username uretir, cakisma olursa sonek ekler. + _force_suffix: verilirse bare base denenmez, bu sayidan itibaren baslar + (regeneration ping-pong onlemek icin kullanilir). + """ + first = normalize_for_username(first_name or '') + last = normalize_for_username(last_name or '') + yy = str(birth_date.year)[-2:] + base = f"{first}.{last}{yy}" if first and last else f"user{yy}" + + if _force_suffix is not None: + # Yeniden uretim: bare base'i hic deneme, rastgele bir sonek ile basla + counter = _force_suffix + candidate = f"{base}{counter}" + else: + candidate = base + counter = 1 + + while candidate in used_usernames: + counter += 1 + candidate = f"{base}{counter}" + + used_usernames.add(candidate) + return candidate diff --git a/namecreate/views.py b/namecreate/views.py new file mode 100644 index 0000000..1bee75f --- /dev/null +++ b/namecreate/views.py @@ -0,0 +1,241 @@ +import csv +import io +import json +import os +import uuid + +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from rest_framework.authentication import SessionAuthentication +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.permissions import IsAdminUser +from rest_framework_simplejwt.authentication import JWTAuthentication + +from namecreate.models import TrainingJob +from namecreate.tasks import train_model_task + + +def _parse_json_data(request): + """ + JSON body'den veriyi çeker. + Beklenen format: + { + "features": [[1.2, 3.4, 5.6, 7.8], [2.1, 4.3, 6.5, 8.7], ...], + "labels": [0, 1, 2, ...] + } + """ + body = json.loads(request.body) + features = body.get('features') + labels = body.get('labels') + + if not features or not labels: + raise ValueError("'features' ve 'labels' alanları zorunludur.") + if len(features) != len(labels): + raise ValueError("'features' ve 'labels' eleman sayısı eşit olmalıdır.") + if len(features) < 10: + raise ValueError("En az 10 eğitim örneği gönderiniz.") + + # Tip güvenliği: tüm değerler sayısal olmalı + for i, row in enumerate(features): + if not all(isinstance(v, (int, float)) for v in row): + raise ValueError(f"features[{i}] içinde sayısal olmayan değer var.") + if not all(isinstance(v, (int, float)) for v in labels): + raise ValueError("labels listesi sadece sayısal değer içermelidir.") + + return features, [int(v) for v in labels] + + +def _parse_csv_data(file): + """ + CSV dosyasından veriyi çeker. + Beklenen format — son sütun label, geri kalanlar feature: + 1.2,3.4,5.6,7.8,0 + 2.1,4.3,6.5,8.7,1 + ... + Header satırı varsa otomatik atlanır. + """ + content = file.read().decode('utf-8') + reader = csv.reader(io.StringIO(content)) + + features, labels = [], [] + for lineno, row in enumerate(reader, start=1): + if not row: + continue + # Header satırını atla + try: + values = [float(v) for v in row] + except ValueError: + if lineno == 1: + continue # Başlık satırı + raise ValueError(f"CSV satır {lineno}: sayısal olmayan değer.") + + if len(values) < 2: + raise ValueError(f"CSV satır {lineno}: en az 2 sütun (özellik + etiket) gerekli.") + + features.append(values[:-1]) + labels.append(int(values[-1])) + + if len(features) < 10: + raise ValueError("En az 10 eğitim örneği gönderiniz.") + + return features, labels + + +@csrf_exempt +def train_and_export_model(request): + """ + Modeli arka planda eğitmek için Celery task'ı başlatır. + + Veri gönderme yöntemleri: + + 1) JSON body: + POST /api/v1/ml/train-model/ + Content-Type: application/json + { + "features": [[1.2, 3.4, 5.6, 7.8], ...], + "labels": [0, 1, 2, ...] + } + + 2) CSV dosyası: + POST /api/v1/ml/train-model/ + Content-Type: multipart/form-data + Form alanı: data= + (Son sütun label, geri kalanlar feature) + + 3) Veri göndermezseniz yerleşik Iris demo verisi kullanılır. + """ + if request.method != 'POST': + return JsonResponse({'error': 'Sadece POST destekleniyor.'}, status=405) + + try: + features, labels = None, None + source = 'demo' + + # --- Yöntem 1: JSON body --- + ct = request.content_type or '' + if 'application/json' in ct and request.body: + features, labels = _parse_json_data(request) + source = 'json' + + # --- Yöntem 2: CSV dosyası --- + elif 'data' in request.FILES: + features, labels = _parse_csv_data(request.FILES['data']) + source = 'csv' + + # --- Yöntem 3: Demo (Iris) --- + # features ve labels None kalır, task default veriyi kullanır + + feature_count = len(features[0]) if features else None + sample_count = len(features) if features else None + + task_id = str(uuid.uuid4()) + TrainingJob.objects.create( + task_id=task_id, + status='pending', + features=features, + labels=labels, + feature_count=feature_count, + sample_count=sample_count, + ) + + celery_task = train_model_task.delay(task_id) + + return JsonResponse({ + 'status': 'queued', + 'message': 'Model eğitim görevi başlatıldı.', + 'task_id': task_id, + 'celery_task_id': celery_task.id, + 'data_source': source, + 'sample_count': sample_count, + 'feature_count': feature_count, + }) + + except (ValueError, KeyError) as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=400) + except Exception as e: + return JsonResponse({'status': 'error', 'message': str(e)}, status=500) + + +def get_training_status(request): + """Task'ın durumunu sorgular.""" + task_id = request.GET.get('task_id') + + if not task_id: + return JsonResponse({ + "error": "task_id gerekli" + }, status=400) + + try: + job = TrainingJob.objects.get(task_id=task_id) + + response = { + "task_id": job.task_id, + "status": job.status, + "created_at": job.created_at.isoformat() if job.created_at else None, + "started_at": job.started_at.isoformat() if job.started_at else None, + "completed_at": job.completed_at.isoformat() if job.completed_at else None, + } + + # Task tamamlandığında metrikleri ekle + if job.status == 'completed': + response.update({ + "model_version": job.model_version.isoformat(), + "metrics": { + "accuracy": job.accuracy, + "precision": job.precision, + "recall": job.recall, + "f1_score": job.f1_score, + } + }) + + # Task başarısız olmuşsa hata mesajı ekle + if job.status == 'failed': + response["error_message"] = job.error_message + + return JsonResponse(response) + + except TrainingJob.DoesNotExist: + return JsonResponse({ + "error": "Task bulunamadı" + }, status=404) + + +@api_view(['GET']) +@authentication_classes([SessionAuthentication, JWTAuthentication]) +@permission_classes([IsAdminUser]) +def list_training_jobs(request): + """TrainingJob kayıtlarını listeler.""" + status_filter = request.GET.get('status') + limit = request.GET.get('limit', '50') + + try: + limit = max(1, min(int(limit), 200)) + except ValueError: + return JsonResponse({"error": "limit sayısal olmalıdır"}, status=400) + + jobs = TrainingJob.objects.all().order_by('-created_at') + if status_filter: + jobs = jobs.filter(status=status_filter) + + items = [] + for job in jobs[:limit]: + model_exists = bool(job.model_path and os.path.exists(job.model_path)) + items.append({ + 'id': job.pk, + 'task_id': job.task_id, + 'status': job.status, + 'model_type': job.model_type, + 'created_at': job.created_at.isoformat() if job.created_at else None, + 'started_at': job.started_at.isoformat() if job.started_at else None, + 'completed_at': job.completed_at.isoformat() if job.completed_at else None, + 'sample_count': job.sample_count, + 'feature_count': job.feature_count, + 'model_path': job.model_path, + 'model_exists': model_exists, + 'error_message': job.error_message, + }) + + return JsonResponse({ + 'count': len(items), + 'results': items, + }) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0a919d2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,86 @@ +amqp==5.3.1 +asgiref==3.11.1 +attrs==26.1.0 +billiard==4.2.4 +celery==5.6.3 +certifi==2026.2.25 +cffi==2.0.0 +charset-normalizer==3.4.6 +click==8.3.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +cron-descriptor==1.4.5 +cryptography==46.0.6 +defusedxml==0.7.1 +Django==6.0.3 +django-appconf==1.2.0 +django-autoslug==1.9.9 +django-celery-beat==2.9.0 +django-cleanup==9.0.0 +django-colorfield==0.14.0 +django-cors-headers==4.9.0 +django-cropper-image==1.0.5 +django-imagekit==6.1.0 +django-redis==6.0.0 +django-stubs==6.0.1 +django-stubs-ext==6.0.1 +django-timezone-field==7.2.1 +django_celery_results==2.6.0 +djangorestframework==3.17.1 +djangorestframework-stubs==3.16.8 +djangorestframework_simplejwt==5.5.1 +djoser==2.3.3 +drf-spectacular==0.29.0 +drf-spectacular-sidecar==2026.3.1 +flower==2.0.1 +hiredis==3.3.1 +humanize==4.15.0 +idna==3.11 +inflection==0.5.1 +joblib==1.5.3 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +kombu==5.6.2 +ml_dtypes==0.5.4 +numpy==2.4.3 +oauthlib==3.3.1 +onnx==1.20.1 +packaging==26.0 +pilkit==3.0 +pillow==12.1.1 +prometheus_client==0.24.1 +prompt_toolkit==3.0.52 +protobuf==7.34.1 +psycopg2-binary==2.9.11 +pycparser==3.0 +PyJWT==2.12.1 +python-crontab==3.3.0 +python-dateutil==2.9.0.post0 +python-decouple==3.8 +python3-openid==3.2.0 +pytz==2026.1.post1 +PyYAML==6.0.3 +redis==7.4.0 +referencing==0.37.0 +requests==2.33.0 +requests-oauthlib==2.0.0 +rpds-py==0.30.0 +scikit-learn==1.8.0 +scipy==1.17.1 +six==1.17.0 +skl2onnx==1.20.0 +social-auth-app-django==5.7.0 +social-auth-core==4.8.5 +sqlparse==0.5.5 +threadpoolctl==3.6.0 +tornado==6.5.5 +types-PyYAML==6.0.12.20250915 +typing_extensions==4.15.0 +tzdata==2025.3 +tzlocal==5.3.1 +uritemplate==4.2.0 +urllib3==2.6.3 +vine==5.1.0 +wcwidth==0.6.0 +whitenoise==6.12.0 diff --git a/schema.yml b/schema.yml new file mode 100644 index 0000000..37532b2 --- /dev/null +++ b/schema.yml @@ -0,0 +1,129 @@ +openapi: 3.0.3 +info: + title: Your Project API + version: 1.0.0 + description: Your project description +paths: + /api/v1/auth/jwt/create/: + post: + operationId: create_create + description: |- + Takes a set of user credentials and returns an access and refresh JSON web + token pair to prove the authentication of those credentials. + tags: + - create + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenObtainPair' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenObtainPair' + multipart/form-data: + schema: + $ref: '#/components/schemas/TokenObtainPair' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TokenObtainPair' + description: '' + /api/v1/auth/jwt/refresh/: + post: + operationId: refresh_create + description: |- + Takes a refresh type JSON web token and returns an access type JSON web + token if the refresh token is valid. + tags: + - refresh + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRefresh' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenRefresh' + multipart/form-data: + schema: + $ref: '#/components/schemas/TokenRefresh' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRefresh' + description: '' + /api/v1/auth/jwt/verify/: + post: + operationId: verify_create + description: |- + Takes a token and indicates if it is valid. This view provides no + information about a token's fitness for a particular use. + tags: + - verify + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenVerify' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TokenVerify' + multipart/form-data: + schema: + $ref: '#/components/schemas/TokenVerify' + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TokenVerify' + description: '' +components: + schemas: + TokenObtainPair: + type: object + properties: + email: + type: string + writeOnly: true + password: + type: string + writeOnly: true + access: + type: string + readOnly: true + refresh: + type: string + readOnly: true + required: + - access + - email + - password + - refresh + TokenRefresh: + type: object + properties: + access: + type: string + readOnly: true + refresh: + type: string + writeOnly: true + required: + - access + - refresh + TokenVerify: + type: object + properties: + token: + type: string + writeOnly: true + required: + - token