commit 3de0ca1fb53e0cbb591958e5834a3a6b7032a2c4 Author: Beyhan Oğur Date: Sun Apr 26 22:23:47 2026 +0300 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..f821b9a --- /dev/null +++ b/.env @@ -0,0 +1,35 @@ +# Environment variables for MySQL database connection +MYSQL_DATABASE=dj_beyhan +MYSQL_USER=dj_beyhan +MYSQL_PASSWORD=gg7678290 +MYSQL_HOST=10.80.80.70 +MYSQL_PORT=3306 + +# Celery and Redis settings +REDIS_URL=redis://default:gg7678290@10.80.80.70:6379/4 +CELERY_BROKER_URL=redis://default:gg7678290@10.80.80.70:6379/4 +CELERY_RESULT_BACKEND=django-db + +# 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=10.80.80.70 +EMAIL_PORT=1025 +EMAIL_HOST_USER='' +EMAIL_HOST_PASSWORD='' +EMAIL_USE_TLS=False +EMAIL_USE_SSL=False +DEFAULT_FROM_EMAIL='noreply@localhost' + + +# Django settings +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,web_beyhan +SECRET_KEY=django-insecure-FxIJkTCbCfj9VRywq1beYkfHqsbIB9RLqH7TxqyQJhvtceB9m8sfv04j15oHw2q0 +DEBUG=True diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..471b386 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +# Python 3.14.2 base image kullan +FROM python:3.14.2-slim + +# Çalışma ortamı değişkenlerini ayarla +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Çalışma dizinini oluştur +WORKDIR /app + +# Sistem bağımlılıklarını yükle (PostgreSQL ve diğer gerekli paketler için) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + postgresql-client \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Python bağımlılıklarını kopyala ve yükle +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Proje dosyalarını kopyala +COPY . . + +# Static dosyaları topla +RUN python manage.py collectstatic --noinput --clear || true + +# Media ve staticfiles dizinlerini oluştur +RUN mkdir -p /app/media /app/staticfiles + +# Port 8000'i aç +EXPOSE 8000 + +# Entrypoint scriptini çalıştırılabilir yap +RUN chmod +x /app/entrypoint.sh || true + +# Entrypoint ve varsayılan komut +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] 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..c35c598 --- /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', 'date_joined') + list_filter = ('is_staff', 'is_superuser', 'is_active', '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')}), + (_('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', '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/middleware.py b/accounts/middleware.py new file mode 100644 index 0000000..1852a1f --- /dev/null +++ b/accounts/middleware.py @@ -0,0 +1,27 @@ +""" +Custom middleware for social authentication. +""" + + +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 + diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..83ee054 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.2 on 2026-02-13 20:29 + +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/__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..3135264 --- /dev/null +++ b/accounts/models.py @@ -0,0 +1,103 @@ +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.' + ), + ) + 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 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/tests.py b/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..aebff40 --- /dev/null +++ b/accounts/urls.py @@ -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..b022205 --- /dev/null +++ b/accounts/views.py @@ -0,0 +1,271 @@ +from django.shortcuts import redirect +from django.views import View +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import AllowAny +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 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/backup/__init__.py b/backup/__init__.py new file mode 100644 index 0000000..78cead9 --- /dev/null +++ b/backup/__init__.py @@ -0,0 +1,2 @@ +default_app_config = 'backup.apps.BackupConfig' + diff --git a/backup/admin.py b/backup/admin.py new file mode 100644 index 0000000..8794e9d --- /dev/null +++ b/backup/admin.py @@ -0,0 +1,368 @@ +from django.contrib import admin +from django.utils.html import format_html +from django.contrib import messages +from django.utils import timezone +from django.http import FileResponse, HttpResponse +from django.shortcuts import get_object_or_404, render, redirect +from django.urls import path, reverse +from django.conf import settings +import os +from datetime import datetime +from .models import DatabaseBackup +from .views import BackupManager + + +@admin.register(DatabaseBackup) +class DatabaseBackupAdmin(admin.ModelAdmin): + list_display = ['name', 'status_badge', 'backup_type', 'file_size_display', 'download_link', 'created_by', 'created_at', 'completed_at'] + list_filter = ['status', 'backup_type', 'created_at'] + search_fields = ['name', 'notes', 'error_message'] + readonly_fields = ['file_path', 'file_size', 'status', 'created_by', 'created_at', 'completed_at', 'error_message', 'file_size_display_field'] + + fieldsets = ( + ('Temel Bilgiler', { + 'fields': ('name', 'backup_type', 'status', 'notes') + }), + ('Yedek Dosya Bilgileri', { + 'fields': ('file_path', 'file_size_display_field') + }), + ('Zaman Bilgileri', { + 'fields': ('created_by', 'created_at', 'completed_at') + }), + ('Hata Bilgileri', { + 'fields': ('error_message',), + 'classes': ('collapse',) + }), + ) + + actions = ['create_new_backup', 'restore_selected_backup', 'download_backup', 'delete_backup_files'] + + def status_badge(self, obj): + """Durum için renkli badge gösterir""" + colors = { + 'pending': '#FFA500', + 'in_progress': '#2196F3', + 'completed': '#4CAF50', + 'failed': '#F44336', + } + color = colors.get(obj.status, '#999') + return format_html( + '{}', + color, + obj.get_status_display() + ) + status_badge.short_description = 'Durum' + + def file_size_display(self, obj): + """Dosya boyutunu gösterir""" + return obj.get_file_size_display() + file_size_display.short_description = 'Dosya Boyutu' + + def file_size_display_field(self, obj): + """Read-only field için dosya boyutu""" + return obj.get_file_size_display() + file_size_display_field.short_description = 'Dosya Boyutu' + + def download_link(self, obj): + """İndir butonu gösterir""" + if obj.file_path and obj.status == 'completed' and os.path.isfile(obj.file_path): + url = f'/admin/backup/databasebackup/{obj.pk}/download/' + return format_html( + '📥 İndir', + url + ) + return format_html('-', '#999') + download_link.short_description = 'İndir' + + def get_urls(self): + """Admin için özel URL'ler ekler""" + urls = super().get_urls() + custom_urls = [ + path('create-backup/', self.admin_site.admin_view(self.create_backup_view), name='backup_create'), + path('upload-backup/', self.admin_site.admin_view(self.upload_backup_view), name='backup_upload'), + path('/download/', self.admin_site.admin_view(self.download_backup_file), name='backup_download'), + ] + return custom_urls + urls + + def changelist_view(self, request, extra_context=None): + """Change list view'a ekstra context ekler""" + extra_context = extra_context or {} + extra_context['show_create_backup_button'] = True + extra_context['show_upload_backup_button'] = True + return super().changelist_view(request, extra_context=extra_context) + + def create_backup_view(self, request): + """Yeni yedek oluşturma view'i""" + from django.shortcuts import redirect + from django.urls import reverse + + # Yeni bir backup objesi oluştur + timestamp = timezone.now().strftime('%Y%m%d_%H%M%S') + backup = DatabaseBackup.objects.create( + name=f"Manuel Yedek - {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}", + backup_type='manual', + created_by=request.user, + status='pending' + ) + + # Yedekleme işlemini başlat + manager = BackupManager() + success, message = manager.create_backup(backup) + + if success: + self.message_user(request, message, messages.SUCCESS) + else: + self.message_user(request, message, messages.ERROR) + + # Liste sayfasına yönlendir + return redirect(reverse('admin:backup_databasebackup_changelist')) + + def upload_backup_view(self, request): + """Yedek dosyası yükleme view'i""" + if request.method == 'POST': + uploaded_file = request.FILES.get('backup_file') + backup_name = request.POST.get('backup_name', '') + + if not uploaded_file: + self.message_user(request, "Lütfen bir dosya seçin", messages.ERROR) + return redirect(reverse('admin:backup_upload')) + + # Dosya uzantısı kontrolü + if not uploaded_file.name.endswith('.sql'): + self.message_user(request, "Sadece .sql uzantılı dosyalar yüklenebilir", messages.ERROR) + return redirect(reverse('admin:backup_upload')) + + # Dosya boyutu kontrolü (max 500MB) + max_size = 500 * 1024 * 1024 # 500MB in bytes + if uploaded_file.size > max_size: + self.message_user(request, "Dosya çok büyük. Maksimum 500MB olabilir", messages.ERROR) + return redirect(reverse('admin:backup_upload')) + + try: + # Backup klasörünü kontrol et + manager = BackupManager() + backup_dir = manager.backup_dir + + # Dosya adını oluştur (timestamp ekle) + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + original_name = os.path.splitext(uploaded_file.name)[0] + filename = f"uploaded_{original_name}_{timestamp}.sql" + file_path = os.path.join(backup_dir, filename) + + # Dosyayı kaydet + with open(file_path, 'wb+') as destination: + for chunk in uploaded_file.chunks(): + destination.write(chunk) + + # Veritabanı kaydı oluştur + if not backup_name: + backup_name = f"Yüklenen Yedek - {uploaded_file.name}" + + backup = DatabaseBackup.objects.create( + name=backup_name, + file_path=file_path, + file_size=uploaded_file.size, + status='completed', + backup_type='manual', + created_by=request.user, + completed_at=timezone.now(), + notes=f"Dosya yüklendi: {uploaded_file.name}" + ) + + self.message_user( + request, + f"Yedek dosyası başarıyla yüklendi: {uploaded_file.name} ({backup.get_file_size_display()})", + messages.SUCCESS + ) + return redirect(reverse('admin:backup_databasebackup_changelist')) + + except Exception as e: + self.message_user(request, f"Dosya yüklenirken hata oluştu: {str(e)}", messages.ERROR) + return redirect(reverse('admin:backup_upload')) + + # GET request - form göster + context = { + **self.admin_site.each_context(request), + 'title': 'Yedek Dosyası Yükle', + 'opts': self.model._meta, + 'has_view_permission': self.has_view_permission(request), + } + return render(request, 'admin/backup/upload_backup.html', context) + + def download_backup_file(self, request, backup_id): + """Yedek dosyasını indirir""" + backup = get_object_or_404(DatabaseBackup, pk=backup_id) + + if not backup.file_path: + self.message_user(request, "Yedek dosyası bulunamadı", messages.ERROR) + return HttpResponse("Dosya bulunamadı", status=404) + + if not os.path.isfile(backup.file_path): + self.message_user(request, "Yedek dosyası disk üzerinde bulunamadı", messages.ERROR) + return HttpResponse("Dosya disk üzerinde bulunamadı", status=404) + + # Dosyayı indir + try: + response = FileResponse(open(backup.file_path, 'rb'), content_type='application/sql') + filename = os.path.basename(backup.file_path) + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + except Exception as e: + return HttpResponse(f"Dosya indirilemedi: {str(e)}", status=500) + + def save_model(self, request, obj, form, change): + """Model kaydedilirken created_by alanını otomatik doldur""" + if not change: # Yeni kayıt + obj.created_by = request.user + super().save_model(request, obj, form, change) + + def create_new_backup(self, request, queryset): + """Yeni bir yedek oluşturur""" + # Yeni bir backup objesi oluştur + timestamp = timezone.now().strftime('%Y-%m-%d %H:%M:%S') + backup = DatabaseBackup.objects.create( + name=f"Manuel Yedek - {timestamp}", + backup_type='manual', + created_by=request.user, + status='pending' + ) + + # Yedekleme işlemini başlat + manager = BackupManager() + success, message = manager.create_backup(backup) + + if success: + self.message_user(request, message, messages.SUCCESS) + else: + self.message_user(request, message, messages.ERROR) + + create_new_backup.short_description = "Yeni Yedek Oluştur" + + def restore_selected_backup(self, request, queryset): + """Seçili yedeği geri yükler""" + if queryset.count() != 1: + self.message_user( + request, + "Lütfen geri yüklemek için sadece bir yedek seçin", + messages.WARNING + ) + return + + backup = queryset.first() + + if backup.status != 'completed': + self.message_user( + request, + "Sadece tamamlanmış yedekler geri yüklenebilir", + messages.WARNING + ) + return + + if not backup.file_path: + self.message_user( + request, + "Yedek dosya yolu bulunamadı", + messages.ERROR + ) + return + + # Restore işlemi (migration'lar da dahil) + manager = BackupManager() + success, message = manager.restore_backup(backup.file_path) + + if success: + # Otomatik migration çalıştır + try: + from django.core.management import call_command + import io + call_command('migrate', '--noinput', stdout=io.StringIO(), stderr=io.StringIO()) + self.message_user(request, f"{message} Migration'lar uygulandı. Sayfayı yenileyin.", messages.SUCCESS) + except: + self.message_user(request, f"{message} Sayfayı yenileyin.", messages.SUCCESS) + else: + self.message_user(request, message, messages.ERROR) + + restore_selected_backup.short_description = "Seçili Yedeği Geri Yükle" + + def download_backup(self, request, queryset): + """Seçili yedeği indirir""" + if queryset.count() != 1: + self.message_user( + request, + "Lütfen indirmek için sadece bir yedek seçin", + messages.WARNING + ) + return + + backup = queryset.first() + + if backup.status != 'completed': + self.message_user( + request, + "Sadece tamamlanmış yedekler indirilebilir", + messages.WARNING + ) + return + + if not backup.file_path or not os.path.isfile(backup.file_path): + self.message_user( + request, + "Yedek dosyası bulunamadı", + messages.ERROR + ) + return + + # Dosyayı indir + try: + response = FileResponse(open(backup.file_path, 'rb'), content_type='application/sql') + filename = os.path.basename(backup.file_path) + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response + except Exception as e: + self.message_user( + request, + f"Dosya indirilemedi: {str(e)}", + messages.ERROR + ) + return + + download_backup.short_description = "Seçili Yedeği İndir" + + def delete_backup_files(self, request, queryset): + """Seçili yedeklerin dosyalarını siler""" + deleted_count = 0 + error_count = 0 + + manager = BackupManager() + + for backup in queryset: + if backup.file_path: + success, message = manager.delete_backup_file(backup.file_path) + if success: + backup.file_path = None + backup.file_size = None + backup.save() + deleted_count += 1 + else: + error_count += 1 + + if deleted_count > 0: + self.message_user( + request, + f"{deleted_count} yedek dosyası silindi", + messages.SUCCESS + ) + + if error_count > 0: + self.message_user( + request, + f"{error_count} yedek dosyası silinemedi", + messages.WARNING + ) + + delete_backup_files.short_description = "Yedek Dosyalarını Sil" + + def has_delete_permission(self, request, obj=None): + """Silme iznini kontrol et - Tüm admin kullanıcıları silebilir""" + return request.user.is_staff diff --git a/backup/apps.py b/backup/apps.py new file mode 100644 index 0000000..d850a7d --- /dev/null +++ b/backup/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class BackupConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'backup' + + def ready(self): + """Uygulama hazır olduğunda sinyalleri import et""" + import backup.models # Sinyalleri kaydetmek için import et diff --git a/backup/migrations/0001_initial.py b/backup/migrations/0001_initial.py new file mode 100644 index 0000000..af18653 --- /dev/null +++ b/backup/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0 on 2025-12-22 16:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DatabaseBackup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Yedek Adı')), + ('file_path', models.CharField(blank=True, max_length=500, null=True, verbose_name='Dosya Yolu')), + ('file_size', models.BigIntegerField(blank=True, null=True, verbose_name='Dosya Boyutu (bytes)')), + ('status', models.CharField(choices=[('pending', 'Bekliyor'), ('in_progress', 'İşleniyor'), ('completed', 'Tamamlandı'), ('failed', 'Başarısız')], default='pending', max_length=20, verbose_name='Durum')), + ('backup_type', models.CharField(choices=[('manual', 'Manuel'), ('automatic', 'Otomatik')], default='manual', max_length=20, verbose_name='Yedek Tipi')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Tamamlanma Tarihi')), + ('error_message', models.TextField(blank=True, null=True, verbose_name='Hata Mesajı')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Notlar')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Oluşturan')), + ], + options={ + 'verbose_name': 'Veritabanı Yedeği', + 'verbose_name_plural': 'Veritabanı Yedekleri', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backup/migrations/__init__.py b/backup/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backup/models.py b/backup/models.py new file mode 100644 index 0000000..595573e --- /dev/null +++ b/backup/models.py @@ -0,0 +1,68 @@ +from django.db import models +from django.contrib.auth import get_user_model +from django.db.models.signals import post_delete +from django.dispatch import receiver +import os + +User = get_user_model() + + +class DatabaseBackup(models.Model): + """Veritabanı yedekleme kayıtlarını tutar""" + + STATUS_CHOICES = [ + ('pending', 'Bekliyor'), + ('in_progress', 'İşleniyor'), + ('completed', 'Tamamlandı'), + ('failed', 'Başarısız'), + ] + + BACKUP_TYPE_CHOICES = [ + ('manual', 'Manuel'), + ('automatic', 'Otomatik'), + ] + + name = models.CharField(max_length=255, verbose_name='Yedek Adı') + file_path = models.CharField(max_length=500, verbose_name='Dosya Yolu', blank=True, null=True) + file_size = models.BigIntegerField(verbose_name='Dosya Boyutu (bytes)', null=True, blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name='Durum') + backup_type = models.CharField(max_length=20, choices=BACKUP_TYPE_CHOICES, default='manual', verbose_name='Yedek Tipi') + created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name='Oluşturan') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi') + completed_at = models.DateTimeField(null=True, blank=True, verbose_name='Tamamlanma Tarihi') + error_message = models.TextField(blank=True, null=True, verbose_name='Hata Mesajı') + notes = models.TextField(blank=True, null=True, verbose_name='Notlar') + + class Meta: + verbose_name = 'Veritabanı Yedeği' + verbose_name_plural = 'Veritabanı Yedekleri' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.name} - {self.get_status_display()}" + + def get_file_size_display(self): + """Dosya boyutunu okunabilir formatta döndürür""" + if not self.file_size: + return "N/A" + + size = self.file_size + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024.0: + return f"{size:.2f} {unit}" + size /= 1024.0 + return f"{size:.2f} TB" + + +@receiver(post_delete, sender=DatabaseBackup) +def delete_backup_file(sender, instance, **kwargs): + """ + Backup kaydı silindiğinde, ilişkili fiziksel dosyayı da siler + """ + if instance.file_path and os.path.isfile(instance.file_path): + try: + os.remove(instance.file_path) + print(f"Yedek dosyası silindi: {instance.file_path}") + except Exception as e: + print(f"Dosya silinirken hata oluştu: {e}") + diff --git a/backup/templates/admin/backup/databasebackup/change_list.html b/backup/templates/admin/backup/databasebackup/change_list.html new file mode 100644 index 0000000..6222682 --- /dev/null +++ b/backup/templates/admin/backup/databasebackup/change_list.html @@ -0,0 +1,21 @@ +{% extends "admin/change_list.html" %} +{% load i18n admin_urls static %} + +{% block object-tools-items %} + {{ block.super }} + {% if show_create_backup_button %} +
  • + + 🔄 Yeni Yedek Al + +
  • + {% endif %} + {% if show_upload_backup_button %} +
  • + + 📤 Yedek Yükle + +
  • + {% endif %} +{% endblock %} + diff --git a/backup/templates/admin/backup/upload_backup.html b/backup/templates/admin/backup/upload_backup.html new file mode 100644 index 0000000..568f516 --- /dev/null +++ b/backup/templates/admin/backup/upload_backup.html @@ -0,0 +1,174 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +

    📤 Yedek Dosyası Yükle

    + +
    +

    ℹ️ Bilgilendirme

    +
      +
    • Sadece .sql uzantılı dosyalar yüklenebilir
    • +
    • Maksimum dosya boyutu: 500 MB
    • +
    • Yüklenen dosya backups/ klasörüne kaydedilecektir
    • +
    • Dosya otomatik olarak timestamp ile adlandırılacaktır
    • +
    +
    + +
    + {% csrf_token %} + +
    + + + + Boş bırakılırsa dosya adı kullanılacaktır + +
    + +
    + + + + PostgreSQL SQL dump dosyası (.sql) + +
    + +
    + + + ❌ İptal + +
    +
    +
    + + +{% endblock %} + diff --git a/backup/tests.py b/backup/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backup/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backup/views.py b/backup/views.py new file mode 100644 index 0000000..067f4e0 --- /dev/null +++ b/backup/views.py @@ -0,0 +1,328 @@ +import os +from datetime import datetime +from django.conf import settings +from django.utils import timezone +from .models import DatabaseBackup + + +try: + import psycopg2 + from psycopg2 import sql + PSYCOPG2_AVAILABLE = True +except ImportError: + PSYCOPG2_AVAILABLE = False + + +class BackupManager: + """PostgreSQL veritabanı yedekleme işlemlerini yönetir - Sadece psycopg2 kullanarak""" + + def __init__(self): + self.backup_dir = os.path.join(settings.BASE_DIR, 'backups') + if not os.path.exists(self.backup_dir): + os.makedirs(self.backup_dir) + + def get_db_config(self): + """Veritabanı yapılandırmasını alır""" + db_config = settings.DATABASES['default'] + return { + 'dbname': db_config.get('NAME'), + 'user': db_config.get('USER'), + 'password': db_config.get('PASSWORD'), + 'host': db_config.get('HOST', 'localhost'), + 'port': db_config.get('PORT', '5432'), + } + + def get_connection(self): + """PostgreSQL bağlantısı oluşturur""" + if not PSYCOPG2_AVAILABLE: + raise Exception("psycopg2 kütüphanesi yüklü değil") + + db_config = self.get_db_config() + return psycopg2.connect( + dbname=db_config['dbname'], + user=db_config['user'], + password=db_config['password'], + host=db_config['host'], + port=db_config['port'] + ) + + #@task + def create_backup(self, backup_obj): + """ + PostgreSQL veritabanının yedeğini oluşturur + Sadece psycopg2 kullanarak SQL dump oluşturur + """ + try: + backup_obj.status = 'in_progress' + backup_obj.save() + + db_config = self.get_db_config() + + # Yedek dosyası adını oluştur + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_filename = f"backup_{db_config['dbname']}_{timestamp}.sql" + backup_path = os.path.join(self.backup_dir, backup_filename) + + # Veritabanına bağlan + conn = self.get_connection() + cursor = conn.cursor() + + with open(backup_path, 'w', encoding='utf-8') as f: + # Header + f.write("-- PostgreSQL Database Backup\n") + f.write(f"-- Database: {db_config['dbname']}\n") + f.write(f"-- Date: {datetime.now()}\n") + f.write("-- Created by Django Backup System using psycopg2\n\n") + f.write("SET client_encoding = 'UTF8';\n") + f.write("SET standard_conforming_strings = on;\n") + f.write("SET check_function_bodies = false;\n") + f.write("SET client_min_messages = warning;\n\n") + + # Tüm tabloları al + cursor.execute(""" + SELECT tablename FROM pg_tables + WHERE schemaname = 'public' + ORDER BY tablename; + """) + tables = cursor.fetchall() + + for (table_name,) in tables: + f.write(f"\n-- Table: {table_name}\n") + + # Tablo yapısını al - kolon bilgilerini çek + cursor.execute(""" + SELECT + column_name, + data_type, + character_maximum_length, + is_nullable, + column_default, + is_identity + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = %s + ORDER BY ordinal_position; + """, [table_name]) + + columns_info = cursor.fetchall() + + if columns_info: + f.write(f"DROP TABLE IF EXISTS \"{table_name}\" CASCADE;\n") + f.write(f"CREATE TABLE \"{table_name}\" (\n") + + col_defs = [] + for col_name, data_type, max_length, is_nullable, col_default, is_identity in columns_info: + col_def = f" \"{col_name}\" " + + # Serial kontrolü (Nextval veya Identity) + is_serial = False + if (col_default and 'nextval' in col_default) or is_identity == 'YES': + if data_type == 'integer': + col_def += "SERIAL" + is_serial = True + elif data_type == 'bigint': + col_def += "BIGSERIAL" + is_serial = True + + if not is_serial: + # Veri tipini ekle + if max_length and data_type == 'character varying': + col_def += f"VARCHAR({max_length})" + elif max_length and data_type == 'character': + col_def += f"CHAR({max_length})" + else: + col_def += data_type.upper() + + # NOT NULL + if is_nullable == 'NO': + col_def += " NOT NULL" + + # DEFAULT değer + if col_default: + col_def += f" DEFAULT {col_default}" + + col_defs.append(col_def) + + f.write(",\n".join(col_defs)) + f.write("\n);\n\n") + + # Veriyi al ve INSERT komutları oluştur + # Kolon isimlerini al + cursor.execute(sql.SQL(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = %s + ORDER BY ordinal_position; + """), [table_name]) + columns = [row[0] for row in cursor.fetchall()] + + if not columns: + continue + + cursor.execute(sql.SQL("SELECT * FROM {}").format(sql.Identifier(table_name))) + rows = cursor.fetchall() + + if rows: + f.write(f"-- Data for table: {table_name}\n") + + # INSERT şablonu hazırla + cols_str = ', '.join([f'"{c}"' for c in columns]) # Identifier quoting + placeholders = ', '.join(['%s'] * len(columns)) + insert_template = f"INSERT INTO \"{table_name}\" ({cols_str}) VALUES ({placeholders})" + + for row in rows: + # mogrify kullanarak güvenli SQL oluştur + try: + # mogrify bytes döndürür, decode etmemiz lazım + safe_sql = cursor.mogrify(insert_template, row).decode('utf-8') + f.write(f"{safe_sql};\n") + except Exception as row_err: + print(f"Row error in {table_name}: {row_err}") + continue + + f.write("\n") + + # Sequence'leri sıfırla + f.write("\n-- Reset sequences\n") + cursor.execute(""" + SELECT + c.relname as sequence_name, + t.relname as table_name, + a.attname as column_name + FROM pg_class c + JOIN pg_depend d ON d.objid = c.oid + JOIN pg_class t ON d.refobjid = t.oid + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid + WHERE c.relkind = 'S' + AND t.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public'); + """) + sequences = cursor.fetchall() + for seq_name, tbl_name, col_name in sequences: + f.write(f"SELECT setval('{seq_name}', (SELECT COALESCE(MAX(\"{col_name}\"), 1) FROM \"{tbl_name}\"));\n") + + cursor.close() + conn.close() + + # Başarılı + file_size = os.path.getsize(backup_path) + backup_obj.file_path = backup_path + backup_obj.file_size = file_size + backup_obj.status = 'completed' + backup_obj.completed_at = timezone.now() + backup_obj.save() + return True, f"Yedekleme başarıyla tamamlandı: {backup_filename}" + + except Exception as e: + backup_obj.status = 'failed' + backup_obj.error_message = str(e) + backup_obj.save() + return False, f"Yedekleme hatası: {str(e)}" + + def restore_backup(self, backup_path): + """ + TAMAMEN OTOMATIK FULL RESTORE + Manuel işlem gerektirmez! + """ + try: + if not os.path.exists(backup_path): + return False, "Yedek dosyası bulunamadı" + + with open(backup_path, 'r', encoding='utf-8') as f: + sql_content = f.read() + + # HOTFIX 1: 'order' gibi keywordlerin tırnak içine alınmaması sorununu düzelt + import re + sql_content = re.sub(r'(\s+)order(\s+[A-Z]+)', r'\1"order"\2', sql_content) + + # HOTFIX 2: SERIAL/Sequence düzeltmesi + # "id INTEGER NOT NULL DEFAULT nextval(...)" -> "id SERIAL" + sql_content = re.sub(r'INTEGER\s+NOT\s+NULL\s+DEFAULT\s+nextval\(\'[^\']+\'(:?::regclass)?\)', 'SERIAL', sql_content) + sql_content = re.sub(r'BIGINT\s+NOT\s+NULL\s+DEFAULT\s+nextval\(\'[^\']+\'(:?::regclass)?\)', 'BIGSERIAL', sql_content) + + # HOTFIX 3: "id" kolonları INTEGER/BIGINT NOT NULL ise (ve default yoksa) SERIAL yap + # Bu durum Identity kolonlarının yanlış yedeklenmesi sonucu oluşur + sql_content = re.sub(r'"id"\s+INTEGER\s+NOT\s+NULL(?!(\s+DEFAULT))', '"id" SERIAL', sql_content) + sql_content = re.sub(r'"id"\s+BIGINT\s+NOT\s+NULL(?!(\s+DEFAULT))', '"id" BIGSERIAL', sql_content) + + # HOTFIX 4: setval satırlarını kaldır (çünkü biz kendimiz yeniden ayarlıyoruz ve isimler değişmiş olabilir) + # Lines starting with SELECT setval... + sql_lines = [] + for line in sql_content.split('\n'): + if 'SELECT setval' in line and 'django_migrations' in line or 'SELECT setval' in line: + continue # Skip setvals from file + sql_lines.append(line) + sql_content = '\n'.join(sql_lines) + + conn = self.get_connection() + conn.autocommit = True + cursor = conn.cursor() + + try: + print("=" * 60) + print("TAM OTOMATIK RESTORE (YENI VERSIYON)") + print("=" * 60) + + # 1. DROP tüm tablolar + print("\n1. Temizleniyor...") + cursor.execute("SELECT tablename FROM pg_tables WHERE schemaname = 'public';") + tables = cursor.fetchall() + for (t,) in tables: + print(f" Dropping {t}...") + cursor.execute(f'DROP TABLE IF EXISTS "{t}" CASCADE;') + print(" ✓ Temizlendi") + + # 2. SQL Execution - Tek seferde çalıştır + print("\n2. SQL Dosyası Çalıştırılıyor...") + # execute() methodu çoklu sorguları çalıştırabilir (psycopg2 özelliği) + try: + cursor.execute(sql_content) + print(" ✓ SQL Script çalıştırıldı") + except Exception as sql_err: + print(f" SQL HATA: {sql_err}") + raise sql_err + print(" ✓ SQL Script çalıştırıldı") + + # 3. Sequence'ler (SQL script içinde genelde vardır ama garanti olsun) + print("\n3. Sequence'ler Kontrol Ediliyor...") + cursor.execute(""" + SELECT c.relname, t.relname, a.attname + FROM pg_class c + JOIN pg_depend d ON d.objid = c.oid + JOIN pg_class t ON d.refobjid = t.oid + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid + WHERE c.relkind = 'S' AND t.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public'); + """) + for seq, tbl, col in cursor.fetchall(): + try: + cursor.execute(f"SELECT setval('{seq}', COALESCE((SELECT MAX({col}) FROM {tbl}), 1));") + except: + pass + print(" ✓ Ayarlandı") + + cursor.close() + conn.close() + + print("\n" + "=" * 60) + print("RESTORE TAMAMLANDI!") + print("=" * 60) + + return True, "Restore başarıyla tamamlandı!" + + except Exception as e: + print(f"\nHATA: {e}") + cursor.close() + conn.close() + raise + + except Exception as e: + return False, f"Geri yükleme hatası: {str(e)}" + + def delete_backup_file(self, backup_path): + """Yedek dosyasını fiziksel olarak siler""" + try: + if os.path.exists(backup_path): + os.remove(backup_path) + return True, "Yedek dosyası silindi" + else: + return False, "Yedek dosyası bulunamadı" + except Exception as e: + return False, f"Dosya silme hatası: {str(e)}" diff --git a/blog/__init__.py b/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/admin.py b/blog/admin.py new file mode 100644 index 0000000..3e09be2 --- /dev/null +++ b/blog/admin.py @@ -0,0 +1,101 @@ +from django.contrib import admin +from django.utils.safestring import mark_safe + +from blog.models import Category, Tags, Post, Comment, CategoryView + + +# Register your models here. +class PostAdmin(admin.ModelAdmin): + list_display = ('title', 'post_resim', 'is_active', 'post_kategorileri', 'slug') + list_filter = ('is_active', 'categories') + search_fields = ('title', 'is_active', 'slug', 'content') + list_editable = ('is_active', 'slug',) # Removed 'price' as it is not a field + + class Meta: + model = Post + + def save_model(self, request, obj, form, change): + """Admin'de kaydetme sırasında image'ı thumb'a da kopyala""" + # Model save metodu zaten bu işi yapıyor, buradaki koda gerek yok aslında + # ama form üzerinden gelen veriyi kontrol etmek için bırakılabilir. + # Ancak model save metodundaki mantık daha sağlam olduğu için burayı sadeleştiriyoruz. + super().save_model(request, obj, form, change) + + def formatted_hit_count(self, obj): + return obj.current_hit_count if obj.current_hit_count > 0 else '-' + + formatted_hit_count.admin_order_field = 'hit_count' + formatted_hit_count.short_description = 'Hits' + + def post_tags(self, obj): + tags = '
      ' + for tag in obj.tags.all(): + tags += '
    • ' + tag.tag + '
    • ' + tags += '
    ' + return mark_safe(tags) + + def post_kategorileri(self, obj): + html = '
      ' + for category in obj.categories.all(): + html += '
    • ' + category.title + '
    • ' + html += '
    ' + return mark_safe(html) + + def post_resim(self, obj): + if obj.image: + # Uygulama adı 'blog' olduğu için URL yapısı /admin/blog/post/... olmalı + return mark_safe( + ''.format( + obj.id, obj.image.url)) + return mark_safe('Resim Yok') + + post_resim.short_description = 'Kurs Resmi' + + +admin.site.register(Post, PostAdmin) + + +class CategoryAdmin(admin.ModelAdmin): + list_display = ('title', 'parent_category', 'is_active', 'created_at', 'order') # Removed 'view_count' and 'unique_view_count' + list_filter = ('title', 'is_active', 'created_at', 'parent') + search_fields = ('title', 'is_active', 'slug') + list_editable = ('is_active', 'order') + + class Meta: + model = Category + + def parent_category(self, obj): + if obj.parent: + return obj.parent.title + return "Ana Kategori" + + parent_category.short_description = 'Üst Kategori' + + +admin.site.register(Category, CategoryAdmin) + + +class TagsAdmin(admin.ModelAdmin): + list_display = ('tag', 'created_at',) + list_filter = ('tag',) + search_fields = ('tag',) + + class Meta: + model = Tags + + +admin.site.register(Tags, TagsAdmin) + + +class CategoryViewAdmin(admin.ModelAdmin): + list_display = ('category', 'ip_address', 'created_at') + list_filter = ('created_at', 'category') + search_fields = ('ip_address', 'category__title') + readonly_fields = ('category', 'ip_address', 'user_agent', 'created_at') + + class Meta: + model = CategoryView + + +admin.site.register(CategoryView, CategoryViewAdmin) +admin.site.register(Comment) diff --git a/blog/apps.py b/blog/apps.py new file mode 100644 index 0000000..b9a09fd --- /dev/null +++ b/blog/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + name = 'blog' + default_auto_field = 'django.db.models.BigAutoField' + + def ready(self): + import blog.signals diff --git a/blog/management/__init__.py b/blog/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/management/commands/__init__.py b/blog/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/management/commands/create_fake_posts.py b/blog/management/commands/create_fake_posts.py new file mode 100644 index 0000000..0a93d51 --- /dev/null +++ b/blog/management/commands/create_fake_posts.py @@ -0,0 +1,85 @@ +import os +import random +import string +import requests +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.core.files.base import ContentFile +from blog.models import Post, Category, Tags + +try: + from faker import Faker + fake = Faker() + HAS_FAKER = True +except ImportError: + HAS_FAKER = False + +class Command(BaseCommand): + help = 'Creates 300 fake posts with random images' + + def handle(self, *args, **kwargs): + User = get_user_model() + # Prefer an existing staff user, otherwise any existing user, otherwise create a fallback user + user = User.objects.filter(is_staff=True).first() or User.objects.first() + if not user: + self.stdout.write('Hiç kullanıcı bulunamadı, `fakeuser` oluşturuluyor...') + user = User.objects.create(username='fakeuser', email='fake@example.com') + user.set_unusable_password() + user.save() + categories = list(Category.objects.all()) + if not categories: + self.stdout.write(self.style.ERROR('Lütfen önce en az bir kategori oluşturun!')) + return + + tags = list(Tags.objects.all()) + if not tags: + self.stdout.write('Tag bulunamadı, oluşturuluyor...') + for i in range(5): + tag_name = self.get_random_string(8) if not HAS_FAKER else fake.word() + Tags.objects.create(tag=tag_name) + tags = list(Tags.objects.all()) + + self.stdout.write('300 adet fake post oluşturuluyor...') + + for i in range(300): + if HAS_FAKER: + title = fake.sentence(nb_words=6).replace('.', '') + content = '\n\n'.join(fake.paragraphs(nb=5)) + keywords = ", ".join(fake.words(nb=5)) + else: + title = self.get_random_string(30) + content = self.get_random_string(500) + keywords = self.get_random_string(20) + + post = Post( + user=user, + title=title, + content=content, + keywords=keywords, + video='none', + is_active=True, + is_front=True + ) + post.save() + + # ManyToMany ilişkileri + post.categories.add(random.choice(categories)) + post.tags.add(random.choice(tags)) + + # Resim ekle + try: + # Picsum'dan rastgele resim (800x600) + img_url = f"https://picsum.photos/seed/{random.randint(1, 10000)}/800/600" + response = requests.get(img_url, timeout=10) + if response.status_code == 200: + file_name = f"fake_post_{i}_{random.randint(1000,9999)}.jpg" + post.image.save(file_name, ContentFile(response.content), save=True) + self.stdout.write(f'Post {i+1}/300 oluşturuldu: {title} (Resimli)') + else: + self.stdout.write(f'Post {i+1}/300 oluşturuldu: {title} (Resimsiz - İndirme hatası)') + except Exception as e: + self.stdout.write(f'Post {i+1}/300 oluşturuldu: {title} (Resimsiz - Hata: {str(e)})') + + def get_random_string(self, length): + letters = string.ascii_letters + string.digits + ' ' + return ''.join(random.choice(letters) for i in range(length)) diff --git a/blog/management/commands/seed_data.py b/blog/management/commands/seed_data.py new file mode 100644 index 0000000..12c8453 --- /dev/null +++ b/blog/management/commands/seed_data.py @@ -0,0 +1,121 @@ +import random +import requests +from django.core.management.base import BaseCommand +from django.core.files import File +from django.core.files.temp import NamedTemporaryFile +from django.contrib.auth import get_user_model +from faker import Faker +from blog.models import Category, Tags, Post + +User = get_user_model() + +class Command(BaseCommand): + help = 'Seeds the database with fake data for blog app' + + def handle(self, *args, **kwargs): + self.stdout.write('Seeding data...') + fake = Faker() + + # Ensure a user exists + user, created = User.objects.get_or_create( + email='admin@example.com', + defaults={'first_name': 'Admin', 'is_staff': True, 'is_superuser': True} + ) + if created: + user.set_password('admin') + user.save() + self.stdout.write(self.style.SUCCESS(f'Created user: {user.email}')) + else: + self.stdout.write(self.style.SUCCESS(f'Using existing user: {user.email}')) + + # Create Categories + categories_data = { + 'Teknoloji': ['Yazılım', 'Donanım', 'Yapay Zeka'], + 'Yaşam': ['Seyahat', 'Sağlık', 'Yemek'], + 'Spor': ['Futbol', 'Basketbol', 'Voleybol'], + 'Eğlence': ['Sinema', 'Müzik', 'Oyun'], + } + + created_categories = [] + for parent_name, children in categories_data.items(): + parent, _ = Category.objects.get_or_create( + title=parent_name, + defaults={ + 'keywords': f'{parent_name}, blog, kategori', + 'description': f'{parent_name} kategorisi açıklaması', + 'is_active': True + } + ) + created_categories.append(parent) + self.stdout.write(f'Created/Found Category: {parent.title}') + + for child_name in children: + child, _ = Category.objects.get_or_create( + title=child_name, + parent=parent, + defaults={ + 'keywords': f'{child_name}, {parent_name}, blog', + 'description': f'{child_name} alt kategorisi açıklaması', + 'is_active': True + } + ) + created_categories.append(child) + self.stdout.write(f' - Created/Found Subcategory: {child.title}') + + # Create Tags + tags_list = ['Python', 'Django', 'Web Development', 'Coding', 'Tech', 'News', 'Tutorial', 'Tips', 'Health', 'Travel'] + created_tags = [] + for tag_name in tags_list: + tag, _ = Tags.objects.get_or_create( + tag=tag_name, + defaults={'is_active': True} + ) + created_tags.append(tag) + self.stdout.write(f'Created/Found Tag: {tag.tag}') + + # Create Posts + self.stdout.write('Creating posts...') + for i in range(30): # Create 30 posts + title = fake.sentence(nb_words=6).replace('.', '') + content = f"

    {fake.paragraph(nb_sentences=10)}

    {fake.paragraph(nb_sentences=5)}

    " + + post, created = Post.objects.get_or_create( + title=title, + defaults={ + 'user': user, + 'content': content, + 'keywords': ', '.join(fake.words(nb=5)), + 'is_active': True, + 'is_front': True, + 'video': 'none' + } + ) + + if created: + # Add Categories + post_cats = random.sample(created_categories, k=random.randint(1, 3)) + post.categories.set(post_cats) + + # Add Tags + post_tags = random.sample(created_tags, k=random.randint(1, 4)) + post.tags.set(post_tags) + + # Fetch and save image + image_url = f"https://picsum.photos/800/600?random={i}" + try: + response = requests.get(image_url, timeout=10) + if response.status_code == 200: + img_temp = NamedTemporaryFile(delete=True) + img_temp.write(response.content) + img_temp.flush() + post.image.save(f"post_{i}.jpg", File(img_temp), save=True) + self.stdout.write(f' - Downloaded image for post: {title}') + except Exception as e: + self.stdout.write(self.style.WARNING(f' - Could not download image for post {title}: {e}')) + + post.save() + self.stdout.write(self.style.SUCCESS(f'Created Post: {post.title}')) + else: + self.stdout.write(f'Post already exists: {post.title}') + + self.stdout.write(self.style.SUCCESS('Data seeding completed successfully!')) diff --git a/blog/migrations/0001_initial.py b/blog/migrations/0001_initial.py new file mode 100644 index 0000000..b6d95b7 --- /dev/null +++ b/blog/migrations/0001_initial.py @@ -0,0 +1,127 @@ +# Generated by Django 6.0.2 on 2026-02-13 21:12 + +import autoslug.fields +import core.utils.utils +import django.db.models.deletion +import imagekit.models.fields +import tinymce.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Tags', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tag', models.CharField(max_length=254, verbose_name='Post Tagları')), + ('slug', autoslug.fields.AutoSlugField(blank=True, editable=True, max_length=250, populate_from='tag', unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')), + ('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Yayındamı')), + ], + options={ + 'verbose_name': 'Post Tagı', + 'verbose_name_plural': 'Post Tagları', + 'db_table': 'tags', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=254, verbose_name='Kategori')), + ('keywords', models.CharField(max_length=254, verbose_name='Seo Kelimeleri Aralarına Virgül Koyunuz')), + ('description', models.CharField(max_length=254, verbose_name='Açıklama')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')), + ('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Yayındamı')), + ('order', models.IntegerField(db_index=True, default=1, verbose_name='Görüntülenme Sırası')), + ('slug', autoslug.fields.AutoSlugField(blank=True, editable=True, max_length=250, populate_from='title', unique=True)), + ('image', imagekit.models.fields.ProcessedImageField(blank=True, null=True, upload_to=core.utils.utils.UniquePathAndRename('uploads/category'), verbose_name='Resim 630 x 653 Olmali ve Transparan PNG Olmali')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child', to='blog.category', verbose_name='Üst Kategorisi')), + ], + options={ + 'verbose_name': 'Post Kategori', + 'verbose_name_plural': 'Post Kategorilerileri', + 'db_table': 'categories', + 'ordering': ['order'], + 'unique_together': {('slug', 'parent')}, + }, + ), + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=254, verbose_name='Post Başlığı')), + ('content', tinymce.models.HTMLField(blank=True, null=True, verbose_name='Post İçeriği')), + ('keywords', models.CharField(max_length=254, verbose_name='Seo Kelimeleri Aralarına Virgül Koyunuz')), + ('image', imagekit.models.fields.ProcessedImageField(blank=True, null=True, upload_to=core.utils.utils.UniquePathAndRename('uploads/post'))), + ('thumb', imagekit.models.fields.ProcessedImageField(blank=True, editable=False, null=True, upload_to=core.utils.utils.UniquePathAndRename('uploads/post/thumb'))), + ('video', models.CharField(blank=True, default='none', max_length=254, null=True, verbose_name='Video')), + ('slug', autoslug.fields.AutoSlugField(blank=True, editable=True, max_length=250, populate_from='title', unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')), + ('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Yayındamı ?')), + ('is_front', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Önde Görünsünmü ?')), + ('categories', models.ManyToManyField(related_name='c_categories', to='blog.category', verbose_name='Post Kategorisi')), + ('parent', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child', to='blog.post', verbose_name='Konular')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL)), + ('tags', models.ManyToManyField(related_name='tags', to='blog.tags', verbose_name='Post Tagları')), + ], + options={ + 'verbose_name': 'Post', + 'verbose_name_plural': 'Posts', + 'db_table': 'posts', + 'ordering': ['created_at'], + 'unique_together': {('slug',)}, + }, + ), + migrations.CreateModel( + name='CategoryView', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip_address', models.GenericIPAddressField(verbose_name='IP Adresi')), + ('user_agent', models.TextField(blank=True, null=True, verbose_name='User Agent')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Ziyaret Tarihi')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_views', to='blog.category')), + ], + options={ + 'verbose_name': 'Kategori Ziyareti', + 'verbose_name_plural': 'Kategori Ziyaretleri', + 'db_table': 'category_views', + 'indexes': [models.Index(fields=['category', 'ip_address', 'created_at'], name='category_vi_categor_234334_idx')], + }, + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=254, verbose_name='Yorum Başlığı')), + ('body', models.TextField(verbose_name='Yorum')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')), + ('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Yayındamı')), + ('slug', autoslug.fields.AutoSlugField(editable=False, populate_from='title', unique=True)), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child', to='blog.comment')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cuser', to=settings.AUTH_USER_MODEL)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='_product', to='blog.post')), + ], + options={ + 'verbose_name': 'Post Yorum', + 'verbose_name_plural': 'Post Yorumları', + 'db_table': 'comments', + 'ordering': ['-created_at'], + 'unique_together': {('slug', 'parent')}, + }, + ), + ] diff --git a/blog/migrations/__init__.py b/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/blog/models.py b/blog/models.py new file mode 100644 index 0000000..7b9bcdc --- /dev/null +++ b/blog/models.py @@ -0,0 +1,239 @@ +import os +from autoslug import AutoSlugField +from django.conf import settings +from django.core.files.base import ContentFile +from django.db import models +from imagekit.models import ProcessedImageField +from tinymce.models import HTMLField +from core.utils.utils import image_optimizer + + +# Create your models here. +class Category(models.Model): + aktif = ( + (True, 'Evet'), + (False, 'Hayır'), + ) + title = models.CharField(max_length=254, verbose_name="Kategori") + keywords = models.CharField(max_length=254, verbose_name="Seo Kelimeleri Aralarına Virgül Koyunuz") + description = models.CharField(max_length=254, verbose_name="Açıklama") + created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi") + updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi") + is_active = models.BooleanField(default=True, verbose_name='Yayındamı', choices=aktif) + order = models.IntegerField(verbose_name='Görüntülenme Sırası', default=1, db_index=True) + slug = AutoSlugField(populate_from='title', null=False, unique=True, editable=True, db_index=True, max_length=250, + blank=True) + parent = models.ForeignKey('self', related_name='child', on_delete=models.CASCADE, blank=True, null=True, + verbose_name='Üst Kategorisi') + image = ProcessedImageField(**image_optimizer('uploads/category', 300, 300, 85, 'PNG'), + verbose_name='Resim 630 x 653 Olmali ve Transparan PNG Olmali', blank=True, null=True) + + class Meta: + ordering = ["order"] + db_table = 'categories' + verbose_name_plural = "Post Kategorilerileri" + verbose_name = "Post Kategori" + unique_together = ('slug', 'parent',) + + def get_slug(self): + slug = self.title.replace('ı', "i").replace('İ', 'i') + number = 1 + while Category.objects.filter(slug=slug).exists(): + slug = '{}-{}'.format(slug, number) + number += 1 + return slug + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = self.get_slug() + super().save(*args, **kwargs) + + def __str__(self): + full_path = [self.title] + k = self.parent + while k is not None: + full_path.append(k.title) + k = k.parent + return ' -> '.join(full_path[::-1]) + + +class Tags(models.Model): + aktif = ( + (True, 'Evet'), + (False, 'Hayır'), + ) + tag = models.CharField(max_length=254, verbose_name="Post Tagları") + slug = AutoSlugField(populate_from='tag', null=False, unique=True, editable=True, db_index=True, max_length=250, + blank=True) + created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi") + updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi") + is_active = models.BooleanField(default=True, verbose_name='Yayındamı', choices=aktif) + + class Meta: + ordering = ["-created_at"] + db_table = 'tags' + verbose_name_plural = "Post Tagları" + verbose_name = "Post Tagı" + + def get_slug(self): + slug = self.tag.replace('ı', "i").replace('İ', 'i') + number = 1 + while Tags.objects.filter(slug=slug).exists(): + slug = '{}-{}'.format(slug, number) + number += 1 + return slug + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = self.get_slug() + super().save(*args, **kwargs) + + def __str__(self): + return self.tag + + +class Post(models.Model): + aktif = ( + (True, 'Evet'), + (False, 'Hayır'), + ) + + title = models.CharField(max_length=254, verbose_name="Post Başlığı") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts', null=True, blank=True) + content = HTMLField(blank=True, null=True, verbose_name='Post İçeriği') + categories = models.ManyToManyField(Category, verbose_name="Post Kategorisi", related_name='c_categories') + keywords = models.CharField(max_length=254, verbose_name="Seo Kelimeleri Aralarına Virgül Koyunuz") + tags = models.ManyToManyField(Tags, verbose_name="Post Tagları", related_name='tags') + image = ProcessedImageField(**image_optimizer('uploads/post', 1170, 580, 85, 'avif'), null=True, blank=True) + thumb = ProcessedImageField(**image_optimizer('uploads/post/thumb', 348, 160, 85, 'avif'), null=True, blank=True,editable=False) + + video = models.CharField(verbose_name="Video", null=True, blank=True, max_length=254, default='none') + slug = AutoSlugField(populate_from='title', null=False, unique=True, editable=True, db_index=True, max_length=250, + blank=True) + created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi") + updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi") + is_active = models.BooleanField(default=True, verbose_name='Yayındamı ?', choices=aktif) + is_front = models.BooleanField(default=True, verbose_name='Önde Görünsünmü ?', choices=aktif) + parent = models.ForeignKey('self', related_name='child', on_delete=models.CASCADE, blank=True, null=True, + editable=False, + verbose_name='Konular') + + class Meta: + ordering = ["created_at"] + db_table = 'posts' + verbose_name_plural = "Posts" + verbose_name = "Post" + unique_together = ('slug',) + + def get_slug(self): + slug = self.title.replace('ı', "i").replace('İ', 'i') + number = 1 + while Post.objects.filter(slug=slug).exists(): + slug = '{}-{}'.format(slug, number) + number += 1 + return slug + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = self.get_slug() + + if self.image: + # Eğer yeni bir kayıt ise veya resim değişmişse + update_thumb = False + if not self.pk: + update_thumb = True + else: + try: + old_instance = self.__class__.objects.get(pk=self.pk) + if self.image != old_instance.image: + update_thumb = True + except self.__class__.DoesNotExist: + pass + + if update_thumb: + try: + if hasattr(self.image, 'closed') and self.image.closed: + self.image.open() + + if hasattr(self.image, 'seek'): + self.image.seek(0) + + content = self.image.read() + filename = os.path.basename(self.image.name) + + # Doğrudan alana ata, super().save() işleyecek + self.thumb = ContentFile(content, name=filename) + except Exception: + pass + finally: + if hasattr(self.image, 'seek'): + self.image.seek(0) + + super().save(*args, **kwargs) + + def __str__(self): + return f"Postlar: {self.title}" + + +class CategoryView(models.Model): + """Kategori ziyaretlerini takip etmek için model""" + category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name='category_views') + ip_address = models.GenericIPAddressField(verbose_name='IP Adresi') + user_agent = models.TextField(blank=True, null=True, verbose_name='User Agent') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Ziyaret Tarihi') + + class Meta: + db_table = 'category_views' + verbose_name = 'Kategori Ziyareti' + verbose_name_plural = 'Kategori Ziyaretleri' + # unique_together kısıtlamasını kaldırdık - artık günlük bazda kontrol edeceğiz + indexes = [ + models.Index(fields=['category', 'ip_address', 'created_at']), + ] + + def __str__(self): + return f"{self.category.title} - {self.ip_address} - {self.created_at.strftime('%Y-%m-%d %H:%M')}" + + +class Comment(models.Model): + aktif = ( + (True, 'Evet'), + (False, 'Hayır'), + ) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='cuser') + product = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='_product') + title = models.CharField(max_length=254, verbose_name="Yorum Başlığı") + body = models.TextField(verbose_name='Yorum') + created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi") + updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi") + is_active = models.BooleanField(default=True, verbose_name='Yayındamı', choices=aktif) + slug = AutoSlugField(populate_from='title', null=False, unique=True, editable=False, db_index=True) + parent = models.ForeignKey('self', related_name='child', on_delete=models.CASCADE, blank=True, null=True) + + class Meta: + ordering = ["-created_at"] + db_table = 'comments' + verbose_name_plural = "Post Yorumları" + verbose_name = "Post Yorum" + unique_together = ('slug', 'parent',) + + def get_slug(self): + slug = self.title.replace('ı', "i").replace('İ', 'i') + number = 1 + while Comment.objects.filter(slug=slug).exists(): + slug = '{}-{}'.format(slug, number) + number += 1 + return slug + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = self.get_slug() + super().save(*args, **kwargs) + + def __str__(self): + full_path = [self.title] + k = self.parent + while k is not None: + full_path.append(k.title) + k = k.parent + return ' -> '.join(full_path[::-1]) diff --git a/blog/serializers.py b/blog/serializers.py new file mode 100644 index 0000000..8bffc69 --- /dev/null +++ b/blog/serializers.py @@ -0,0 +1,89 @@ +from rest_framework import serializers + +from blog.models import Category, Post, Tags + + +class CateSerializer(serializers.ModelSerializer): + parent = serializers.StringRelatedField() # ID yerine __str__ metodundaki değeri döndürür + + class Meta: + model = Category + fields = ['title', 'parent', 'is_active', 'created_at', 'order', 'slug', 'image', 'keywords', 'description'] + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tags + fields = ['tag', 'slug'] + + +class PostSerializer(serializers.ModelSerializer): + categories = CateSerializer(read_only=True, many=True) + # Tags için sadece tag ismini döndürmek daha temiz olabilir, ama mevcut yapıyı koruyalım + # Eğer sadece isim listesi istenirse: tags = serializers.SlugRelatedField(many=True, read_only=True, slug_field='tag') + tags = TagSerializer(read_only=True, many=True) + + class Meta: + model = Post + fields = ['title', 'content', 'categories', 'keywords', 'tags', 'image', 'thumb', 'video', + 'slug', 'created_at', 'updated_at', 'is_active', 'is_front'] + # fields = '__all__' + + +class PostSYalinerializer(serializers.ModelSerializer): + class Meta: + model = Post + fields = ['slug', ] + # fields = '__all__' + + +class CategorySerializer(serializers.ModelSerializer): + + posts = PostSYalinerializer(source='c_categories', read_only=True, many=True) + child = serializers.SerializerMethodField() + + class Meta: + model = Category + fields = ['title', 'parent', 'is_active', 'created_at', 'order', 'slug', 'image', 'keywords', 'description', + 'posts', 'child'] + + def get_child(self, obj): + serializer = self.__class__(obj.child.all(), many=True, context=self.context) + return serializer.data + + +class CategoryPostSerializer(serializers.ModelSerializer): + + posts = serializers.SerializerMethodField() + child = serializers.SerializerMethodField() + + class Meta: + model = Category + fields = ['title', 'parent', 'is_active', 'created_at', 'order', 'slug', 'image', 'keywords', 'description', + 'posts', 'child'] + + def get_posts(self, obj): + # Pagination context'ini al + paginator = self.context.get('paginator') + request = self.context.get('request') + + posts = obj.c_categories.all() + + if paginator and request: + # Pagination uygula + paginated_posts = paginator.paginate_queryset(posts, request) + serializer = PostSerializer(paginated_posts, many=True, context=self.context) + return { + 'results': serializer.data, + 'count': posts.count(), + 'next': paginator.get_next_link(), + 'previous': paginator.get_previous_link(), + } + else: + # Pagination yoksa normal döndür + serializer = PostSerializer(posts, many=True, context=self.context) + return serializer.data + + def get_child(self, obj): + serializer = self.__class__(obj.child.all(), many=True, context=self.context) + return serializer.data \ No newline at end of file diff --git a/blog/signals.py b/blog/signals.py new file mode 100644 index 0000000..7fa83b9 --- /dev/null +++ b/blog/signals.py @@ -0,0 +1,52 @@ +from django.db.models.signals import pre_save +from django.dispatch import receiver +from django.core.files.base import ContentFile +from .models import Post + + +@receiver(pre_save, sender=Post) +def update_post_thumb(sender, instance, **kwargs): + """ + Post kaydedilmeden önce, image alanı doluysa thumb'ı da güncelle + """ + if instance.image: + # Yeni kayıt veya image güncellenmiş mi kontrol et + should_update_thumb = False + + if instance.pk: + try: + old_instance = Post.objects.get(pk=instance.pk) + # Image değişmişse thumb'ı da güncelle + if str(old_instance.image) != str(instance.image): + should_update_thumb = True + except Post.DoesNotExist: + # Kayıt bulunamadı, yeni kayıt gibi davran + should_update_thumb = True + else: + # Yeni kayıt (pk yok) + should_update_thumb = True + + if should_update_thumb and hasattr(instance.image, 'file'): + # Image dosyasını thumb alanına kopyala + try: + # Image dosyasının içeriğini oku + instance.image.file.seek(0) + image_content = instance.image.file.read() + instance.image.file.seek(0) + + # Dosya adını al + image_name = instance.image.name.split('/')[-1] + + # Thumb alanına kaydet + instance.thumb.save( + image_name, + ContentFile(image_content), + save=False + ) + except Exception as e: + print(f"Thumb oluşturma hatası: {e}") + import traceback + traceback.print_exc() + + + diff --git a/blog/tasks.py b/blog/tasks.py new file mode 100644 index 0000000..8ad70bb --- /dev/null +++ b/blog/tasks.py @@ -0,0 +1,40 @@ +from celery import shared_task +from django.core.mail import send_mail +from django.conf import settings + +@shared_task +def send_comment_notification_email(comment_title, comment_body, post_title, user_email): + """ + Yeni bir yorum yapıldığında admin'e e-posta gönderir. + """ + subject = f'Yeni Yorum: {post_title}' + message = f""" + Merhaba Admin, + + "{post_title}" başlıklı yazıya yeni bir yorum yapıldı. + + Yorum Yapan: {user_email} + Başlık: {comment_title} + Yorum: {comment_body} + + Kontrol etmek için admin paneline giriş yapabilirsiniz. + """ + + # Admin e-posta adresini settings'den veya doğrudan buraya yazabilirsiniz + # Örnek olarak settings.DEFAULT_FROM_EMAIL kullanıldı, admin listesi de kullanılabilir + admin_email = settings.DEFAULT_FROM_EMAIL + + # Eğer settings.ADMINS tanımlıysa oradaki ilk kişiye de atılabilir + if hasattr(settings, 'ADMINS') and settings.ADMINS: + recipient_list = [email for name, email in settings.ADMINS] + else: + # Fallback olarak bir email + recipient_list = ['admin@example.com'] + + send_mail( + subject, + message, + settings.DEFAULT_FROM_EMAIL, + recipient_list, + fail_silently=False, + ) diff --git a/blog/tests.py b/blog/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/blog/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/blog/urls.py b/blog/urls.py new file mode 100644 index 0000000..5bd6531 --- /dev/null +++ b/blog/urls.py @@ -0,0 +1,10 @@ +from django.urls import path + +from blog.views import CategoryList, CategoryDetail, PostDetail, PostList + +urlpatterns = [ + path('categories/', CategoryList.as_view(), name='categories.list'), + path('categories//', CategoryDetail.as_view(), name='categories.details'), + path('post/', PostList.as_view(), name='post.list'), + path('post//', PostDetail.as_view(), name='post.details'), +] diff --git a/blog/views.py b/blog/views.py new file mode 100644 index 0000000..31310e2 --- /dev/null +++ b/blog/views.py @@ -0,0 +1,47 @@ +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.pagination import PageNumberPagination + +from blog.models import Post, Category +from blog.serializers import PostSerializer, CategorySerializer, CategoryPostSerializer +from core.utils.Permission import ReadOnly + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + max_page_size = 100 + + +# Create your views here. +class CategoryList(ListAPIView): + permission_classes = [ReadOnly] + queryset = Category.objects.order_by('order').filter(is_active=True, parent__isnull=True).all() + # serializer_class = ParentSerializer + serializer_class = CategorySerializer + + +class CategoryDetail(RetrieveAPIView): + permission_classes = [ReadOnly] + queryset = Category.objects.order_by('order').filter(is_active=True).all() + serializer_class = CategoryPostSerializer + lookup_field = 'slug' # Slug ile arama yapılacak + + def get_serializer_context(self): + context = super().get_serializer_context() + context['paginator'] = StandardResultsSetPagination() + return context + + +# Create your views here. +class PostList(ListAPIView): + permission_classes = [ReadOnly] + queryset = Post.objects.all() + serializer_class = PostSerializer + pagination_class = StandardResultsSetPagination + + +class PostDetail(RetrieveAPIView): + permission_classes = [ReadOnly] + queryset = Post.objects.all() + serializer_class = PostSerializer + lookup_field = 'slug' # Slug ile arama yapılacak diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..eaf2095 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,43 @@ +# Python 3.14.2 base image kullan +FROM python:3.14.2-slim + +# Çalışma ortamı değişkenlerini ayarla +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Çalışma dizinini oluştur +WORKDIR /app + +# Sistem bağımlılıklarını yükle (PostgreSQL ve diğer gerekli paketler için) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + pkg-config \ + default-libmysqlclient-dev \ + postgresql-client \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Python bağımlılıklarını kopyala ve yükle +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Proje dosyalarını kopyala +COPY . . + +# Static dosyaları topla +RUN python manage.py collectstatic --noinput --clear || true + +# Media ve staticfiles dizinlerini oluştur +RUN mkdir -p /app/media /app/staticfiles + +# Port 8000'i aç +EXPOSE 8000 + +# Entrypoint scriptini çalıştırılabilir yap +RUN chmod +x /app/entrypoint.sh || true + +# Entrypoint ve varsayılan komut +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["gunicorn", "core.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"] diff --git a/build/requirements.txt b/build/requirements.txt new file mode 100644 index 0000000..a9bd29f --- /dev/null +++ b/build/requirements.txt @@ -0,0 +1,75 @@ +amqp==5.3.1 +asgiref==3.11.1 +billiard==4.2.4 +celery==5.6.2 +celery-types==0.24.0 +certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +cryptography==46.0.5 +defusedxml==0.7.1 +Django==6.0.2 +django-appconf==1.2.0 +django-autoslug==1.9.9 +django-celery-beat==2.1.0 +django-ckeditor-5==0.2.19 +django-cleanup==9.0.0 +django-colorfield==0.14.0 +django-cors-headers==4.9.0 +django-cropper-image==1.0.5 +django-environ==0.12.0 +django-filter==25.2 +django-imagekit==6.0.0 +django-redis==6.0.0 +django-stubs==5.2.9 +django-stubs-ext==5.2.9 +django-timezone-field==4.2.3 +django-tinymce==5.0.0 +django_celery_results==2.6.0 +djangorestframework==3.16.1 +djangorestframework-stubs==3.16.8 +djangorestframework_simplejwt==5.5.1 +djoser==2.3.3 +Faker==40.4.0 +flower==2.0.1 +gunicorn==25.1.0 +hiredis==3.3.0 +humanize==4.15.0 +idna==3.11 +kombu==5.6.2 +Markdown==3.10.2 +mysqlclient==2.2.8 +oauthlib==3.3.1 +packaging==26.0 +pilkit==3.0 +pillow==12.1.1 +prometheus_client==0.24.1 +prompt_toolkit==3.0.52 +psycopg2-binary==2.9.11 +pycparser==3.0 +PyJWT==2.11.0 +python-crontab==3.3.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +python3-openid==3.2.0 +pytz==2025.2 +redis==7.1.1 +requests==2.32.5 +requests-oauthlib==2.0.0 +six==1.17.0 +social-auth-app-django==5.7.0 +social-auth-core==4.8.5 +sqlparse==0.5.5 +tornado==6.5.4 +types-PyYAML==6.0.12.20250915 +typing_extensions==4.15.0 +tzdata==2025.3 +tzlocal==5.3.1 +urllib3==2.6.3 +vine==5.1.0 +wcwidth==0.6.0 +whitenoise==6.11.0 diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..9c2c97f --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,28 @@ +:80 { + # Büyük upload limiti (Nginx'teki client_max_body_size 100M eşdeğeri) + request_body { + max_size 100MB + } + + # Static dosyalar + handle_path /static/* { + root * /app/staticfiles + header Cache-Control "public, immutable" + file_server + } + + # Media dosyalar + handle_path /media/* { + root * /app/media + header Cache-Control "public" + file_server + } + + # Diğer tüm istekler Django'ya + reverse_proxy web_beyhan:8000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} diff --git a/caddy/Dockerfile b/caddy/Dockerfile new file mode 100644 index 0000000..bd5a648 --- /dev/null +++ b/caddy/Dockerfile @@ -0,0 +1,4 @@ +FROM caddy:2 + +# Build context repo root'u (.) olduğu için caddy/Caddyfile yolu kullanıyoruz +COPY caddy/Caddyfile /etc/caddy/Caddyfile diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..a86dffb --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,5 @@ +# Bu dosya Django projesinin başlangıç noktasıdır. +# Celery'yi Django ile entegre etmek için gerekli +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..6cf275a --- /dev/null +++ b/core/celery.py @@ -0,0 +1,22 @@ +import os +from celery import Celery +from django.conf import settings + +# Django ayarlarını yükle +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') + +app = Celery('core') + +# Django settings'ten Celery ayarlarını yükle +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Tüm Django app'lerinden task'ları otomatik keşfet +app.autodiscover_tasks() + +# Timezone ayarını zorla +app.conf.enable_utc = settings.CELERY_ENABLE_UTC +app.conf.timezone = settings.CELERY_TIMEZONE + +@app.task(bind=True) +def debug_task(self): + print(f'Request: {self.request!r}') diff --git a/core/settings.py b/core/settings.py new file mode 100644 index 0000000..4e38625 --- /dev/null +++ b/core/settings.py @@ -0,0 +1,225 @@ +""" +Django settings for core project. + +Generated by 'django-admin startproject' using Django 6.0.2. + +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/ +""" + +from pathlib import Path +import os +import environ + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +env = environ.Env() +environ.Env.read_env(BASE_DIR / '.env') + +# 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 = env('SECRET_KEY', + default='django-insecure-FxIJkTCbCfj9VRywq1beYkfHqsbIB9RLqH7TxqyQJhvtceB9m8sfv04j15oHw2q0') + +# SECURITY WARNING: don't run with debug turned on in production! +# DEBUG = env.bool('DEBUG', default=True) +DEBUG = True +ALLOWED_HOSTS = env.list( + 'DJANGO_ALLOWED_HOSTS', + default=['localhost', '127.0.0.1', 'web_beyhan', 'caddy_beyhan'], +) + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # Third-party apps + 'rest_framework', + 'rest_framework_simplejwt', + 'drf_spectacular', + # 'drf_spectacular_sidecar', + 'djoser', + 'corsheaders', + 'django_filters', + 'django_ckeditor_5', + 'colorfield', + 'social_django', + 'django_celery_beat', + 'django_celery_results', + 'imagekit', + 'django_cleanup', + 'timezone_field', + 'autoslug', + 'tinymce', + + 'accounts', + 'settings', + 'blog', + 'backup', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', # Added CorsMiddleware + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'core.urls' +SITE_ID = 1 +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', + 'social_django.context_processors.backends', + 'social_django.context_processors.login_redirect', + ], + }, + }, +] + +WSGI_APPLICATION = 'core.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', + 'NAME': env('MYSQL_DATABASE', default='dj_beyhan'), + 'USER': env('MYSQL_USER', default='dj_beyhan'), + 'PASSWORD': env('MYSQL_PASSWORD', default='gg7678290'), + 'HOST': env('MYSQL_HOST', default='10.80.80.70'), + 'PORT': env('MYSQL_PORT', default='3306'), + # opsiyonel: kalıcı bağlantı (saniye), None = kapalı + 'CONN_MAX_AGE': 600, + 'OPTIONS': { + 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", + 'charset': 'utf8mb4', + }, + 'TEST': { + 'CHARSET': 'utf8mb4', + 'COLLATION': 'utf8mb4_general_ci', + }, + } +} +CACHES = { + 'default': { + 'BACKEND': 'django_redis.cache.RedisCache', + # 'LOCATION': 'redis://default:1923btO**@ares-redis-xrot7z:6379', + 'LOCATION': os.getenv('REDIS_URL', 'redis://default:gg7678290@10.80.80.70:6379'), + # 'LOCATION': os.getenv('REDIS_URL', 'redis://127.0.0.1:6379/1'), + 'OPTIONS': { + 'CLIENT_CLASS': 'django_redis.client.DefaultClient', + }, + 'KEY_PREFIX': 'dj52', + 'TIMEOUT': 300, # 5 dakika default timeout + } +} +# 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 = 'tr' + +# TIME_ZONE = 'Europe/Istanbul' +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' +AUTH_USER_MODEL = 'accounts.CustomUser' + +# CKEditor 5 settings +CKEDITOR_5_CONFIGS = { + 'default': { + 'toolbar': ['heading', '|', 'bold', 'italic', 'link', + 'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', ], + + } +} + +# REST Framework settings +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} +SPECTACULAR_SETTINGS = { + 'TITLE': 'Your Project API', + 'DESCRIPTION': 'Your project description', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + # OTHER SETTINGS +} + +# Celery settings +CELERY_BROKER_URL = env('CELERY_BROKER_URL', default='redis://localhost:6379/0') +CELERY_RESULT_BACKEND = 'django-db' +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE +CELERY_ENABLE_UTC = True # Celery'nin UTC kullanmasını zorla +# django-celery-beat 2.1.0 expects pytz.localize; disable tz-aware mode for compatibility with zoneinfo. +DJANGO_CELERY_BEAT_TZ_AWARE = False + +# CORS settings +CORS_ALLOW_ALL_ORIGINS = True # For development only, configure properly for production diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..1acb7d1 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,40 @@ +""" +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.conf import settings +from django.conf.urls.static import static + +from django.contrib import admin +from django.urls import path, include +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +urlpatterns = [ + # YOUR PATTERNS + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + # Optional UI: + path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + path('admin/', admin.site.urls), + path('api/v1/', include('accounts.urls')), + path('api/v1/', include('settings.urls')), + path('api/v1/', include('blog.urls')), + path('tinymce/', include('tinymce.urls')), +] +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/utils/Permission.py b/core/utils/Permission.py new file mode 100644 index 0000000..18ccd5f --- /dev/null +++ b/core/utils/Permission.py @@ -0,0 +1,9 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + +class ReadOnly(BasePermission): + """ + Yalnızca okuma işlemlerine izin verir. + """ + def has_permission(self, request, view): + # SAFE_METHODS: ('GET', 'HEAD', 'OPTIONS') + return request.method in SAFE_METHODS \ No newline at end of file diff --git a/core/utils/__init__.py b/core/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/utils/utils.py b/core/utils/utils.py new file mode 100644 index 0000000..307c08f --- /dev/null +++ b/core/utils/utils.py @@ -0,0 +1,40 @@ +import os +import uuid +from django.utils.deconstruct import deconstructible +from imagekit.processors import ResizeToFill + + +class ConvertToRGBA(object): + """Converts an image to RGBA mode.""" + def process(self, img): + if img.mode not in ('RGBA', 'LA'): + img = img.convert('RGBA') + return img + + +@deconstructible +class UniquePathAndRename(object): + def __init__(self, upload_to): + self.upload_to = upload_to + + def __call__(self, instance, filename): + ext = filename.split('.')[-1] + new_filename = f"{uuid.uuid4().hex}.{ext}" + return os.path.join(self.upload_to, new_filename) + + +def image_optimizer(upload_to, width, height, quality, img_format): + """ + ProcessedImageField için gerekli olan `upload_to`, `processors`, `format` + ve `options` parametrelerini dinamik olarak oluşturur. + """ + processors = [ResizeToFill(width, height)] + if img_format == 'PNG': + processors.insert(0, ConvertToRGBA()) + + return { + 'upload_to': UniquePathAndRename(upload_to), + 'processors': processors, + 'format': img_format, + 'options': {'quality': quality} + } 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/db.sqlite3 b/db.sqlite3 new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.beat.yml b/docker-compose.beat.yml new file mode 100644 index 0000000..8565dc6 --- /dev/null +++ b/docker-compose.beat.yml @@ -0,0 +1,132 @@ +services: + web_beyhan: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: web_beyhan + command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3 + dns: + - 8.8.8.8 + - 8.8.4.4 + volumes: + - static_volume:/app/staticfiles + - media_volume:/app/media + expose: + - 8000 + networks: + - dokploy-network + environment: + - DEBUG=0 + - SECRET_KEY=${SECRET_KEY} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_HOST=${MYSQL_HOST} + - MYSQL_PORT=${MYSQL_PORT} + - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + # Celery ayarları + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db} + # Email Settings (Optional) + - EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend} + - EMAIL_HOST=${EMAIL_HOST:-10.80.80.70} + - EMAIL_PORT=${EMAIL_PORT:-1025} + - EMAIL_HOST_USER=${EMAIL_HOST_USER} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD} + - EMAIL_USE_TLS=${EMAIL_USE_TLS:-False} + - EMAIL_USE_SSL=${EMAIL_USE_SSL:-True} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@localhost} + restart: unless-stopped + + celery_beyhan: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: celery_beyhan + command: celery -A core worker -l info + dns: + - 8.8.8.8 + - 8.8.4.4 + volumes: + - media_volume:/app/media + networks: + - dokploy-network + environment: + - DEBUG=0 + - SECRET_KEY=${SECRET_KEY} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_HOST=${MYSQL_HOST} + - MYSQL_PORT=${MYSQL_PORT} + - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db} + depends_on: + - web_beyhan + restart: unless-stopped + + celery_beat_beyhan: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: celery_beat_beyhan + command: celery -A core beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler --pidfile= + dns: + - 8.8.8.8 + - 8.8.4.4 + volumes: + - media_volume:/app/media + networks: + - dokploy-network + environment: + - DEBUG=0 + - SECRET_KEY=${SECRET_KEY} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_HOST=${MYSQL_HOST} + - MYSQL_PORT=${MYSQL_PORT} + - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db} + # Email Settings (Optional) + - EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend} + - EMAIL_HOST=${EMAIL_HOST:-10.80.80.70} + - EMAIL_PORT=${EMAIL_PORT:-1025} + - EMAIL_HOST_USER=${EMAIL_HOST_USER} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD} + - EMAIL_USE_TLS=${EMAIL_USE_TLS:-False} + - EMAIL_USE_SSL=${EMAIL_USE_SSL:-True} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@localhost} + depends_on: + - web_beyhan + restart: unless-stopped + + caddy_beyhan: + build: + context: . + dockerfile: ./caddy/Dockerfile + container_name: caddy_beyhan + ports: + - "${CADDY_HTTP_PORT:-8080}:80" + networks: + - dokploy-network + volumes: + - static_volume:/app/staticfiles:ro + - media_volume:/app/media:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - web_beyhan + restart: unless-stopped + +volumes: + static_volume: + media_volume: + caddy_data: + caddy_config: + +networks: + dokploy-network: + external: true diff --git a/docker-compose.cool.yml b/docker-compose.cool.yml new file mode 100644 index 0000000..98f0e78 --- /dev/null +++ b/docker-compose.cool.yml @@ -0,0 +1,111 @@ +services: + web_beyhan: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: web_beyhan + command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3 + dns: + - 8.8.8.8 + - 8.8.4.4 + volumes: + - static_volume:/app/staticfiles + - media_volume:/app/media + expose: + - 8000 + environment: + - DEBUG=0 + - SECRET_KEY=${SECRET_KEY} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_HOST=${MYSQL_HOST} + - MYSQL_PORT=${MYSQL_PORT} + - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + # Celery ayarları + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db} + # Email Settings (Optional) + - EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend} + - EMAIL_HOST=${EMAIL_HOST:-10.80.80.70} + - EMAIL_PORT=${EMAIL_PORT:-1025} + - EMAIL_HOST_USER=${EMAIL_HOST_USER} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD} + - EMAIL_USE_TLS=${EMAIL_USE_TLS:-False} + - EMAIL_USE_SSL=${EMAIL_USE_SSL:-True} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@localhost} + restart: unless-stopped + + celery_beyhan: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: celery_beyhan + command: celery -A core worker -l info + dns: + - 8.8.8.8 + - 8.8.4.4 + volumes: + - media_volume:/app/media + environment: + - DEBUG=0 + - SECRET_KEY=${SECRET_KEY} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_HOST=${MYSQL_HOST} + - MYSQL_PORT=${MYSQL_PORT} + - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND} + depends_on: + - web_beyhan + restart: unless-stopped + + celery_beat_beyhan: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: celery_beat_beyhan + command: celery -A core beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler --pidfile= + dns: + - 8.8.8.8 + - 8.8.4.4 + volumes: + - media_volume:/app/media + environment: + - DEBUG=0 + - SECRET_KEY=${SECRET_KEY} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_HOST=${MYSQL_HOST} + - MYSQL_PORT=${MYSQL_PORT} + - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db} + depends_on: + - web_beyhan + restart: unless-stopped + + caddy_beyhan: + build: + context: . + dockerfile: ./caddy/Dockerfile + container_name: caddy_beyhan + #ports: + # - "${CADDY_HTTP_PORT:-8080}:80" + volumes: + - static_volume:/app/staticfiles:ro + - media_volume:/app/media:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - web_beyhan + restart: unless-stopped + +volumes: + static_volume: + media_volume: + caddy_data: + caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fe9253f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,123 @@ +services: + web_beyhan: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: web_beyhan + command: gunicorn core.wsgi:application --bind 0.0.0.0:8000 --workers 3 + dns: + - 8.8.8.8 + - 8.8.4.4 + volumes: + - static_volume:/app/staticfiles + - media_volume:/app/media + expose: + - 8000 + networks: + - dokploy-network + environment: + - DEBUG=0 + - SECRET_KEY=${SECRET_KEY} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_HOST=${MYSQL_HOST} + - MYSQL_PORT=${MYSQL_PORT} + - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + # Celery ayarları + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db} + # Email Settings (Optional) + - EMAIL_BACKEND=${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend} + - EMAIL_HOST=${EMAIL_HOST:-10.80.80.70} + - EMAIL_PORT=${EMAIL_PORT:-1025} + - EMAIL_HOST_USER=${EMAIL_HOST_USER} + - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD} + - EMAIL_USE_TLS=${EMAIL_USE_TLS:-False} + - EMAIL_USE_SSL=${EMAIL_USE_SSL:-True} + - DEFAULT_FROM_EMAIL=${DEFAULT_FROM_EMAIL:-noreply@localhost} + restart: unless-stopped + + celery_beyhan: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: celery_beyhan + command: celery -A core worker -l info + dns: + - 8.8.8.8 + - 8.8.4.4 + volumes: + - media_volume:/app/media + networks: + - dokploy-network + environment: + - DEBUG=0 + - SECRET_KEY=${SECRET_KEY} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_HOST=${MYSQL_HOST} + - MYSQL_PORT=${MYSQL_PORT} + - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND} + depends_on: + - web_beyhan + restart: unless-stopped + + celery_beat_beyhan: + build: + context: . + dockerfile: ./build/Dockerfile + container_name: celery_beat_beyhan + command: celery -A core beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler --pidfile= + dns: + - 8.8.8.8 + - 8.8.4.4 + volumes: + - media_volume:/app/media + networks: + - dokploy-network + environment: + - DEBUG=0 + - SECRET_KEY=${SECRET_KEY} + - MYSQL_DATABASE=${MYSQL_DATABASE} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_HOST=${MYSQL_HOST} + - MYSQL_PORT=${MYSQL_PORT} + - DJANGO_ALLOWED_HOSTS=${DJANGO_ALLOWED_HOSTS} + - CELERY_BROKER_URL=${CELERY_BROKER_URL} + - CELERY_RESULT_BACKEND=${CELERY_RESULT_BACKEND:-django-db} + depends_on: + - web_beyhan + restart: unless-stopped + + caddy_beyhan: + build: + context: . + dockerfile: ./caddy/Dockerfile + container_name: caddy_beyhan + #ports: + # - "${CADDY_HTTP_PORT:-8080}:80" + networks: + - dokploy-network + volumes: + - static_volume:/app/staticfiles:ro + - media_volume:/app/media:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - web_beyhan + restart: unless-stopped + +volumes: + static_volume: + media_volume: + caddy_data: + caddy_config: + +networks: + dokploy-network: + external: true diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..a9717da --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Hata durumunda scripti durdur +set -e + +# MySQL bağlantısını kontrol et (mevcut sunucu için) +echo "Checking MySQL connection..." +# Not: Mevcut MySQL sunucunuz zaten çalışıyor olmalı (10.80.80.70:3306) + +# Veritabanı migrasyonlarını uygula +echo "Applying database migrations..." +python manage.py migrate --noinput + +# Superuser oluştur (eğer yoksa) +echo "Creating superuser if it doesn't exist..." +python manage.py shell -c " +from django.contrib.auth import get_user_model +User = get_user_model() +if not User.objects.filter(email='admin@example.com').exists(): + User.objects.create_superuser('admin@example.com', 'admin') + print('Superuser created: admin@example.com / admin') +else: + print('Superuser already exists') +" || true + +# Static dosyaları topla +echo "Collecting static files..." +python manage.py collectstatic --noinput --clear + +echo "Starting server..." +exec "$@" diff --git a/manage.py b/manage.py new file mode 100644 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/uploads/logo/18d11cf76c60f0b453aaea8da0838d3b.png b/media/uploads/logo/18d11cf76c60f0b453aaea8da0838d3b.png new file mode 100644 index 0000000..d7010f5 Binary files /dev/null and b/media/uploads/logo/18d11cf76c60f0b453aaea8da0838d3b.png differ diff --git a/media/uploads/logo/18d11cf76c60f0b453aaea8da0838d3b_KoNqrIl.png b/media/uploads/logo/18d11cf76c60f0b453aaea8da0838d3b_KoNqrIl.png new file mode 100644 index 0000000..d7010f5 Binary files /dev/null and b/media/uploads/logo/18d11cf76c60f0b453aaea8da0838d3b_KoNqrIl.png differ diff --git a/media/uploads/post/06a81ce12001473f8522182344a29632.avif b/media/uploads/post/06a81ce12001473f8522182344a29632.avif new file mode 100644 index 0000000..8b50c65 Binary files /dev/null and b/media/uploads/post/06a81ce12001473f8522182344a29632.avif differ diff --git a/media/uploads/post/07c30a28270d4171bc78753343c828b3.avif b/media/uploads/post/07c30a28270d4171bc78753343c828b3.avif new file mode 100644 index 0000000..ba165c8 Binary files /dev/null and b/media/uploads/post/07c30a28270d4171bc78753343c828b3.avif differ diff --git a/media/uploads/post/088b49773c254e35af45e3d86922fcf1.avif b/media/uploads/post/088b49773c254e35af45e3d86922fcf1.avif new file mode 100644 index 0000000..ede04cc Binary files /dev/null and b/media/uploads/post/088b49773c254e35af45e3d86922fcf1.avif differ diff --git a/media/uploads/post/08a032ca97194d01b8115e76b3c0a0f4.avif b/media/uploads/post/08a032ca97194d01b8115e76b3c0a0f4.avif new file mode 100644 index 0000000..532d49a Binary files /dev/null and b/media/uploads/post/08a032ca97194d01b8115e76b3c0a0f4.avif differ diff --git a/media/uploads/post/0d5df0cbdf994124b22fbb0065c0e89b.avif b/media/uploads/post/0d5df0cbdf994124b22fbb0065c0e89b.avif new file mode 100644 index 0000000..c07f288 Binary files /dev/null and b/media/uploads/post/0d5df0cbdf994124b22fbb0065c0e89b.avif differ diff --git a/media/uploads/post/1278cf56a2a440a4ade6b4a49269c465.avif b/media/uploads/post/1278cf56a2a440a4ade6b4a49269c465.avif new file mode 100644 index 0000000..d80efd9 Binary files /dev/null and b/media/uploads/post/1278cf56a2a440a4ade6b4a49269c465.avif differ diff --git a/media/uploads/post/14269ded500f4b11b625f2eae1df9d44.avif b/media/uploads/post/14269ded500f4b11b625f2eae1df9d44.avif new file mode 100644 index 0000000..8b50be5 Binary files /dev/null and b/media/uploads/post/14269ded500f4b11b625f2eae1df9d44.avif differ diff --git a/media/uploads/post/1b504378135d41c08e096d287182fab7.avif b/media/uploads/post/1b504378135d41c08e096d287182fab7.avif new file mode 100644 index 0000000..2cc01b0 Binary files /dev/null and b/media/uploads/post/1b504378135d41c08e096d287182fab7.avif differ diff --git a/media/uploads/post/27fc12f8e5784d3598680587edb4f04d.avif b/media/uploads/post/27fc12f8e5784d3598680587edb4f04d.avif new file mode 100644 index 0000000..4046f76 Binary files /dev/null and b/media/uploads/post/27fc12f8e5784d3598680587edb4f04d.avif differ diff --git a/media/uploads/post/2c414db84b8042c78b6ccf21610c1b1a.avif b/media/uploads/post/2c414db84b8042c78b6ccf21610c1b1a.avif new file mode 100644 index 0000000..6ae279c Binary files /dev/null and b/media/uploads/post/2c414db84b8042c78b6ccf21610c1b1a.avif differ diff --git a/media/uploads/post/2df5ffcee9954efd99c2f34656950951.avif b/media/uploads/post/2df5ffcee9954efd99c2f34656950951.avif new file mode 100644 index 0000000..4f6ad90 Binary files /dev/null and b/media/uploads/post/2df5ffcee9954efd99c2f34656950951.avif differ diff --git a/media/uploads/post/349d4c7ce59b4149bae1db1a346c528e.avif b/media/uploads/post/349d4c7ce59b4149bae1db1a346c528e.avif new file mode 100644 index 0000000..f0b1ce6 Binary files /dev/null and b/media/uploads/post/349d4c7ce59b4149bae1db1a346c528e.avif differ diff --git a/media/uploads/post/382e91f2a45e4e0986de61ea33d49a2b.avif b/media/uploads/post/382e91f2a45e4e0986de61ea33d49a2b.avif new file mode 100644 index 0000000..601e131 Binary files /dev/null and b/media/uploads/post/382e91f2a45e4e0986de61ea33d49a2b.avif differ diff --git a/media/uploads/post/40cd9cd2276445e280f1516e4ebc01c2.avif b/media/uploads/post/40cd9cd2276445e280f1516e4ebc01c2.avif new file mode 100644 index 0000000..b7d18de Binary files /dev/null and b/media/uploads/post/40cd9cd2276445e280f1516e4ebc01c2.avif differ diff --git a/media/uploads/post/4b376031cf60417594dbc6d3691bec54.avif b/media/uploads/post/4b376031cf60417594dbc6d3691bec54.avif new file mode 100644 index 0000000..1aa29dc Binary files /dev/null and b/media/uploads/post/4b376031cf60417594dbc6d3691bec54.avif differ diff --git a/media/uploads/post/4d47c942a5d548729e4dbccfc61c83b5.avif b/media/uploads/post/4d47c942a5d548729e4dbccfc61c83b5.avif new file mode 100644 index 0000000..9fa51a1 Binary files /dev/null and b/media/uploads/post/4d47c942a5d548729e4dbccfc61c83b5.avif differ diff --git a/media/uploads/post/5257c7b2f1f64d55aee0731eb31bee8b.avif b/media/uploads/post/5257c7b2f1f64d55aee0731eb31bee8b.avif new file mode 100644 index 0000000..2c6d831 Binary files /dev/null and b/media/uploads/post/5257c7b2f1f64d55aee0731eb31bee8b.avif differ diff --git a/media/uploads/post/527574b4a5d246ec852ea7094ea8ecf9.avif b/media/uploads/post/527574b4a5d246ec852ea7094ea8ecf9.avif new file mode 100644 index 0000000..32a9a15 Binary files /dev/null and b/media/uploads/post/527574b4a5d246ec852ea7094ea8ecf9.avif differ diff --git a/media/uploads/post/54830c8982d749d4a1a4149af49666f7.avif b/media/uploads/post/54830c8982d749d4a1a4149af49666f7.avif new file mode 100644 index 0000000..5a849b7 Binary files /dev/null and b/media/uploads/post/54830c8982d749d4a1a4149af49666f7.avif differ diff --git a/media/uploads/post/54a42a715666423e9bf2239703611467.avif b/media/uploads/post/54a42a715666423e9bf2239703611467.avif new file mode 100644 index 0000000..49bb033 Binary files /dev/null and b/media/uploads/post/54a42a715666423e9bf2239703611467.avif differ diff --git a/media/uploads/post/56b4d4bd3a5540ce91ea2feec20ca1a5.avif b/media/uploads/post/56b4d4bd3a5540ce91ea2feec20ca1a5.avif new file mode 100644 index 0000000..6f18464 Binary files /dev/null and b/media/uploads/post/56b4d4bd3a5540ce91ea2feec20ca1a5.avif differ diff --git a/media/uploads/post/604d327b085641a884132c4e0bd23576.avif b/media/uploads/post/604d327b085641a884132c4e0bd23576.avif new file mode 100644 index 0000000..64dcfa0 Binary files /dev/null and b/media/uploads/post/604d327b085641a884132c4e0bd23576.avif differ diff --git a/media/uploads/post/613d1f16356d4dd8ac4d489e9bdafb2b.avif b/media/uploads/post/613d1f16356d4dd8ac4d489e9bdafb2b.avif new file mode 100644 index 0000000..76904d1 Binary files /dev/null and b/media/uploads/post/613d1f16356d4dd8ac4d489e9bdafb2b.avif differ diff --git a/media/uploads/post/6af9e8348c444947a1e9616e6d8484d3.avif b/media/uploads/post/6af9e8348c444947a1e9616e6d8484d3.avif new file mode 100644 index 0000000..f4d09d6 Binary files /dev/null and b/media/uploads/post/6af9e8348c444947a1e9616e6d8484d3.avif differ diff --git a/media/uploads/post/6c09b02d1fb44c79910424febb71aece.avif b/media/uploads/post/6c09b02d1fb44c79910424febb71aece.avif new file mode 100644 index 0000000..50ed360 Binary files /dev/null and b/media/uploads/post/6c09b02d1fb44c79910424febb71aece.avif differ diff --git a/media/uploads/post/7155f45d030746788c8cd624df10bd05.avif b/media/uploads/post/7155f45d030746788c8cd624df10bd05.avif new file mode 100644 index 0000000..d74d5f9 Binary files /dev/null and b/media/uploads/post/7155f45d030746788c8cd624df10bd05.avif differ diff --git a/media/uploads/post/74985a61dd2a49f8931fc6ef23a8319f.avif b/media/uploads/post/74985a61dd2a49f8931fc6ef23a8319f.avif new file mode 100644 index 0000000..5b6c22d Binary files /dev/null and b/media/uploads/post/74985a61dd2a49f8931fc6ef23a8319f.avif differ diff --git a/media/uploads/post/7cc54523f0f6451f80e7ab9835733ecb.avif b/media/uploads/post/7cc54523f0f6451f80e7ab9835733ecb.avif new file mode 100644 index 0000000..4662ba0 Binary files /dev/null and b/media/uploads/post/7cc54523f0f6451f80e7ab9835733ecb.avif differ diff --git a/media/uploads/post/81594c54662a49ef9e462bfbe697dc7c.avif b/media/uploads/post/81594c54662a49ef9e462bfbe697dc7c.avif new file mode 100644 index 0000000..98e158b Binary files /dev/null and b/media/uploads/post/81594c54662a49ef9e462bfbe697dc7c.avif differ diff --git a/media/uploads/post/8c7db9ed50524a2ba4231c3bfc23e6d0.avif b/media/uploads/post/8c7db9ed50524a2ba4231c3bfc23e6d0.avif new file mode 100644 index 0000000..ff1913f Binary files /dev/null and b/media/uploads/post/8c7db9ed50524a2ba4231c3bfc23e6d0.avif differ diff --git a/media/uploads/post/8cda64264fc344fea300a9fd2b900689.avif b/media/uploads/post/8cda64264fc344fea300a9fd2b900689.avif new file mode 100644 index 0000000..69ccfd4 Binary files /dev/null and b/media/uploads/post/8cda64264fc344fea300a9fd2b900689.avif differ diff --git a/media/uploads/post/8ea61cce0b624972b4123f861f552e68.avif b/media/uploads/post/8ea61cce0b624972b4123f861f552e68.avif new file mode 100644 index 0000000..c03f224 Binary files /dev/null and b/media/uploads/post/8ea61cce0b624972b4123f861f552e68.avif differ diff --git a/media/uploads/post/a2840b378e0841b9badb0fa29690e8cb.avif b/media/uploads/post/a2840b378e0841b9badb0fa29690e8cb.avif new file mode 100644 index 0000000..2f589bc Binary files /dev/null and b/media/uploads/post/a2840b378e0841b9badb0fa29690e8cb.avif differ diff --git a/media/uploads/post/a3180991052c49919420365fd1854a60.avif b/media/uploads/post/a3180991052c49919420365fd1854a60.avif new file mode 100644 index 0000000..03234c5 Binary files /dev/null and b/media/uploads/post/a3180991052c49919420365fd1854a60.avif differ diff --git a/media/uploads/post/ad7c2793ea1d4694a0f5f1fa0bc49e6d.avif b/media/uploads/post/ad7c2793ea1d4694a0f5f1fa0bc49e6d.avif new file mode 100644 index 0000000..e1dbf4c Binary files /dev/null and b/media/uploads/post/ad7c2793ea1d4694a0f5f1fa0bc49e6d.avif differ diff --git a/media/uploads/post/c10aaa871ff84bb58aed9573a470b28b.avif b/media/uploads/post/c10aaa871ff84bb58aed9573a470b28b.avif new file mode 100644 index 0000000..34be5fc Binary files /dev/null and b/media/uploads/post/c10aaa871ff84bb58aed9573a470b28b.avif differ diff --git a/media/uploads/post/c17c0fb0cabf485a85f8d9f021480973.avif b/media/uploads/post/c17c0fb0cabf485a85f8d9f021480973.avif new file mode 100644 index 0000000..2466663 Binary files /dev/null and b/media/uploads/post/c17c0fb0cabf485a85f8d9f021480973.avif differ diff --git a/media/uploads/post/c99e16f92585487381bc21834d836c41.avif b/media/uploads/post/c99e16f92585487381bc21834d836c41.avif new file mode 100644 index 0000000..ee48028 Binary files /dev/null and b/media/uploads/post/c99e16f92585487381bc21834d836c41.avif differ diff --git a/media/uploads/post/cd14234741d049859a9dffc957096249.avif b/media/uploads/post/cd14234741d049859a9dffc957096249.avif new file mode 100644 index 0000000..d9e89ba Binary files /dev/null and b/media/uploads/post/cd14234741d049859a9dffc957096249.avif differ diff --git a/media/uploads/post/ecdaa70561b643db8cbea99c18b77574.avif b/media/uploads/post/ecdaa70561b643db8cbea99c18b77574.avif new file mode 100644 index 0000000..00806f9 Binary files /dev/null and b/media/uploads/post/ecdaa70561b643db8cbea99c18b77574.avif differ diff --git a/media/uploads/post/ed17d075616944e08006ed02082aeb5e.avif b/media/uploads/post/ed17d075616944e08006ed02082aeb5e.avif new file mode 100644 index 0000000..b615d34 Binary files /dev/null and b/media/uploads/post/ed17d075616944e08006ed02082aeb5e.avif differ diff --git a/media/uploads/post/ef5b9cf3613b4920927aacd7e7ebbe58.avif b/media/uploads/post/ef5b9cf3613b4920927aacd7e7ebbe58.avif new file mode 100644 index 0000000..e20b6a1 Binary files /dev/null and b/media/uploads/post/ef5b9cf3613b4920927aacd7e7ebbe58.avif differ diff --git a/media/uploads/post/f5dbb7bc93e541c59c0d7aaf7c7eedf1.avif b/media/uploads/post/f5dbb7bc93e541c59c0d7aaf7c7eedf1.avif new file mode 100644 index 0000000..4174325 Binary files /dev/null and b/media/uploads/post/f5dbb7bc93e541c59c0d7aaf7c7eedf1.avif differ diff --git a/media/uploads/post/faad2c8393834f71b04d89c735cb9ba0.avif b/media/uploads/post/faad2c8393834f71b04d89c735cb9ba0.avif new file mode 100644 index 0000000..7884d26 Binary files /dev/null and b/media/uploads/post/faad2c8393834f71b04d89c735cb9ba0.avif differ diff --git a/media/uploads/post/fd91ada2bc76468397101e998955794b.avif b/media/uploads/post/fd91ada2bc76468397101e998955794b.avif new file mode 100644 index 0000000..03234c5 Binary files /dev/null and b/media/uploads/post/fd91ada2bc76468397101e998955794b.avif differ diff --git a/media/uploads/post/thumb/0995c9bbd7114dfb8eda008072e39985.avif b/media/uploads/post/thumb/0995c9bbd7114dfb8eda008072e39985.avif new file mode 100644 index 0000000..85439e0 Binary files /dev/null and b/media/uploads/post/thumb/0995c9bbd7114dfb8eda008072e39985.avif differ diff --git a/media/uploads/post/thumb/0be1d698dff04a7fbb5f83141db3a316.avif b/media/uploads/post/thumb/0be1d698dff04a7fbb5f83141db3a316.avif new file mode 100644 index 0000000..76c05f1 Binary files /dev/null and b/media/uploads/post/thumb/0be1d698dff04a7fbb5f83141db3a316.avif differ diff --git a/media/uploads/post/thumb/0f9cda51591d4d298277492f9a92ecb2.avif b/media/uploads/post/thumb/0f9cda51591d4d298277492f9a92ecb2.avif new file mode 100644 index 0000000..fbb969a Binary files /dev/null and b/media/uploads/post/thumb/0f9cda51591d4d298277492f9a92ecb2.avif differ diff --git a/media/uploads/post/thumb/11341e3f08414fbab270323a336c6e53.avif b/media/uploads/post/thumb/11341e3f08414fbab270323a336c6e53.avif new file mode 100644 index 0000000..3169aa8 Binary files /dev/null and b/media/uploads/post/thumb/11341e3f08414fbab270323a336c6e53.avif differ diff --git a/media/uploads/post/thumb/1f41e211f71846bb9ee773856cf5dfa2.avif b/media/uploads/post/thumb/1f41e211f71846bb9ee773856cf5dfa2.avif new file mode 100644 index 0000000..d6f91b6 Binary files /dev/null and b/media/uploads/post/thumb/1f41e211f71846bb9ee773856cf5dfa2.avif differ diff --git a/media/uploads/post/thumb/229aef97fc254bb78a4451994aa22417.avif b/media/uploads/post/thumb/229aef97fc254bb78a4451994aa22417.avif new file mode 100644 index 0000000..ec3a85a Binary files /dev/null and b/media/uploads/post/thumb/229aef97fc254bb78a4451994aa22417.avif differ diff --git a/media/uploads/post/thumb/34265a9200804893b2945e2149db0c89.avif b/media/uploads/post/thumb/34265a9200804893b2945e2149db0c89.avif new file mode 100644 index 0000000..e55b7a3 Binary files /dev/null and b/media/uploads/post/thumb/34265a9200804893b2945e2149db0c89.avif differ diff --git a/media/uploads/post/thumb/3b89d5528bfb4e6dba084c1b851a3ab4.avif b/media/uploads/post/thumb/3b89d5528bfb4e6dba084c1b851a3ab4.avif new file mode 100644 index 0000000..7b04e04 Binary files /dev/null and b/media/uploads/post/thumb/3b89d5528bfb4e6dba084c1b851a3ab4.avif differ diff --git a/media/uploads/post/thumb/42f94db1874a410aa3a5b563cb577b8a.avif b/media/uploads/post/thumb/42f94db1874a410aa3a5b563cb577b8a.avif new file mode 100644 index 0000000..d580ac6 Binary files /dev/null and b/media/uploads/post/thumb/42f94db1874a410aa3a5b563cb577b8a.avif differ diff --git a/media/uploads/post/thumb/4821d553aee74566a72cee108d42e8c1.avif b/media/uploads/post/thumb/4821d553aee74566a72cee108d42e8c1.avif new file mode 100644 index 0000000..4947e36 Binary files /dev/null and b/media/uploads/post/thumb/4821d553aee74566a72cee108d42e8c1.avif differ diff --git a/media/uploads/post/thumb/4e1d1ba5da0043a7ab9819cc1005417e.avif b/media/uploads/post/thumb/4e1d1ba5da0043a7ab9819cc1005417e.avif new file mode 100644 index 0000000..4861fdc Binary files /dev/null and b/media/uploads/post/thumb/4e1d1ba5da0043a7ab9819cc1005417e.avif differ diff --git a/media/uploads/post/thumb/52482372912840aea6d67a0c35138d93.avif b/media/uploads/post/thumb/52482372912840aea6d67a0c35138d93.avif new file mode 100644 index 0000000..21b3492 Binary files /dev/null and b/media/uploads/post/thumb/52482372912840aea6d67a0c35138d93.avif differ diff --git a/media/uploads/post/thumb/566efccae3e94ac99d237bf55e1040ee.avif b/media/uploads/post/thumb/566efccae3e94ac99d237bf55e1040ee.avif new file mode 100644 index 0000000..9308610 Binary files /dev/null and b/media/uploads/post/thumb/566efccae3e94ac99d237bf55e1040ee.avif differ diff --git a/media/uploads/post/thumb/62291d4aada94dca8df3267ab9dff551.avif b/media/uploads/post/thumb/62291d4aada94dca8df3267ab9dff551.avif new file mode 100644 index 0000000..05a16e1 Binary files /dev/null and b/media/uploads/post/thumb/62291d4aada94dca8df3267ab9dff551.avif differ diff --git a/media/uploads/post/thumb/68e03bf0e5dc4bdc9c3e623e3579ff9b.avif b/media/uploads/post/thumb/68e03bf0e5dc4bdc9c3e623e3579ff9b.avif new file mode 100644 index 0000000..7363074 Binary files /dev/null and b/media/uploads/post/thumb/68e03bf0e5dc4bdc9c3e623e3579ff9b.avif differ diff --git a/media/uploads/post/thumb/696ec3d98b354c55ad5c3394d5a96ff9.avif b/media/uploads/post/thumb/696ec3d98b354c55ad5c3394d5a96ff9.avif new file mode 100644 index 0000000..6d1382f Binary files /dev/null and b/media/uploads/post/thumb/696ec3d98b354c55ad5c3394d5a96ff9.avif differ diff --git a/media/uploads/post/thumb/6c6fdb765246413bb21a3c4f7f42d5e5.avif b/media/uploads/post/thumb/6c6fdb765246413bb21a3c4f7f42d5e5.avif new file mode 100644 index 0000000..6155527 Binary files /dev/null and b/media/uploads/post/thumb/6c6fdb765246413bb21a3c4f7f42d5e5.avif differ diff --git a/media/uploads/post/thumb/6d885423fa3545f292022835b421c91c.avif b/media/uploads/post/thumb/6d885423fa3545f292022835b421c91c.avif new file mode 100644 index 0000000..2991aa5 Binary files /dev/null and b/media/uploads/post/thumb/6d885423fa3545f292022835b421c91c.avif differ diff --git a/media/uploads/post/thumb/82ee7bc5e7474700a77fc355c5793ed1.avif b/media/uploads/post/thumb/82ee7bc5e7474700a77fc355c5793ed1.avif new file mode 100644 index 0000000..e6a9bbf Binary files /dev/null and b/media/uploads/post/thumb/82ee7bc5e7474700a77fc355c5793ed1.avif differ diff --git a/media/uploads/post/thumb/8eca880acedd4a76a71b34cc993e17f7.avif b/media/uploads/post/thumb/8eca880acedd4a76a71b34cc993e17f7.avif new file mode 100644 index 0000000..006b4ff Binary files /dev/null and b/media/uploads/post/thumb/8eca880acedd4a76a71b34cc993e17f7.avif differ diff --git a/media/uploads/post/thumb/9574f080b67e42b18de56a8592bc8976.avif b/media/uploads/post/thumb/9574f080b67e42b18de56a8592bc8976.avif new file mode 100644 index 0000000..0c4dfd9 Binary files /dev/null and b/media/uploads/post/thumb/9574f080b67e42b18de56a8592bc8976.avif differ diff --git a/media/uploads/post/thumb/9702441b6d1e49e38126725f97d2fb36.avif b/media/uploads/post/thumb/9702441b6d1e49e38126725f97d2fb36.avif new file mode 100644 index 0000000..25c7e0a Binary files /dev/null and b/media/uploads/post/thumb/9702441b6d1e49e38126725f97d2fb36.avif differ diff --git a/media/uploads/post/thumb/9b0489192a794887b0dbea54501dc5a2.avif b/media/uploads/post/thumb/9b0489192a794887b0dbea54501dc5a2.avif new file mode 100644 index 0000000..2fb273d Binary files /dev/null and b/media/uploads/post/thumb/9b0489192a794887b0dbea54501dc5a2.avif differ diff --git a/media/uploads/post/thumb/a41a3c4d027040ecab1c35702a58804a.avif b/media/uploads/post/thumb/a41a3c4d027040ecab1c35702a58804a.avif new file mode 100644 index 0000000..06297e8 Binary files /dev/null and b/media/uploads/post/thumb/a41a3c4d027040ecab1c35702a58804a.avif differ diff --git a/media/uploads/post/thumb/a4e4f8a0267847c787272a1a5d6d658b.avif b/media/uploads/post/thumb/a4e4f8a0267847c787272a1a5d6d658b.avif new file mode 100644 index 0000000..0d93121 Binary files /dev/null and b/media/uploads/post/thumb/a4e4f8a0267847c787272a1a5d6d658b.avif differ diff --git a/media/uploads/post/thumb/a50c268fb2964395b8d07fd0cc6aa7e9.avif b/media/uploads/post/thumb/a50c268fb2964395b8d07fd0cc6aa7e9.avif new file mode 100644 index 0000000..02f4a8b Binary files /dev/null and b/media/uploads/post/thumb/a50c268fb2964395b8d07fd0cc6aa7e9.avif differ diff --git a/media/uploads/post/thumb/a8caf4eb69a24c6eac6f9808f31f85fa.avif b/media/uploads/post/thumb/a8caf4eb69a24c6eac6f9808f31f85fa.avif new file mode 100644 index 0000000..e6b02d9 Binary files /dev/null and b/media/uploads/post/thumb/a8caf4eb69a24c6eac6f9808f31f85fa.avif differ diff --git a/media/uploads/post/thumb/ab8fdfe4df3748c6a0bad644a835eedd.avif b/media/uploads/post/thumb/ab8fdfe4df3748c6a0bad644a835eedd.avif new file mode 100644 index 0000000..ae10435 Binary files /dev/null and b/media/uploads/post/thumb/ab8fdfe4df3748c6a0bad644a835eedd.avif differ diff --git a/media/uploads/post/thumb/acb75f17937144948285e50f40cf157d.avif b/media/uploads/post/thumb/acb75f17937144948285e50f40cf157d.avif new file mode 100644 index 0000000..89ad98b Binary files /dev/null and b/media/uploads/post/thumb/acb75f17937144948285e50f40cf157d.avif differ diff --git a/media/uploads/post/thumb/b7ad1ef2569743d786f0de98bb51313d.avif b/media/uploads/post/thumb/b7ad1ef2569743d786f0de98bb51313d.avif new file mode 100644 index 0000000..ff32cad Binary files /dev/null and b/media/uploads/post/thumb/b7ad1ef2569743d786f0de98bb51313d.avif differ diff --git a/media/uploads/post/thumb/b8b5402cf0cb465cab13f43ea86b37db.avif b/media/uploads/post/thumb/b8b5402cf0cb465cab13f43ea86b37db.avif new file mode 100644 index 0000000..266291c Binary files /dev/null and b/media/uploads/post/thumb/b8b5402cf0cb465cab13f43ea86b37db.avif differ diff --git a/media/uploads/post/thumb/bd8367e442b14000a28d51771feca200.avif b/media/uploads/post/thumb/bd8367e442b14000a28d51771feca200.avif new file mode 100644 index 0000000..d30db9f Binary files /dev/null and b/media/uploads/post/thumb/bd8367e442b14000a28d51771feca200.avif differ diff --git a/media/uploads/post/thumb/c0060a2cb4f043dbaeef292bc5a6a2d5.avif b/media/uploads/post/thumb/c0060a2cb4f043dbaeef292bc5a6a2d5.avif new file mode 100644 index 0000000..d0fbfd6 Binary files /dev/null and b/media/uploads/post/thumb/c0060a2cb4f043dbaeef292bc5a6a2d5.avif differ diff --git a/media/uploads/post/thumb/c1fe38ec6b6b4bfba39601d83fd586ac.avif b/media/uploads/post/thumb/c1fe38ec6b6b4bfba39601d83fd586ac.avif new file mode 100644 index 0000000..5ff62e2 Binary files /dev/null and b/media/uploads/post/thumb/c1fe38ec6b6b4bfba39601d83fd586ac.avif differ diff --git a/media/uploads/post/thumb/c305b6db8bba4c39b0f556004958e9df.avif b/media/uploads/post/thumb/c305b6db8bba4c39b0f556004958e9df.avif new file mode 100644 index 0000000..9308610 Binary files /dev/null and b/media/uploads/post/thumb/c305b6db8bba4c39b0f556004958e9df.avif differ diff --git a/media/uploads/post/thumb/ce7e2c336e42462db5098d5a32a5a31e.avif b/media/uploads/post/thumb/ce7e2c336e42462db5098d5a32a5a31e.avif new file mode 100644 index 0000000..2ec4758 Binary files /dev/null and b/media/uploads/post/thumb/ce7e2c336e42462db5098d5a32a5a31e.avif differ diff --git a/media/uploads/post/thumb/cfe821893fe145918f919f3ef1c25069.avif b/media/uploads/post/thumb/cfe821893fe145918f919f3ef1c25069.avif new file mode 100644 index 0000000..2414efc Binary files /dev/null and b/media/uploads/post/thumb/cfe821893fe145918f919f3ef1c25069.avif differ diff --git a/media/uploads/post/thumb/d95ada958af5487fbf640c3b11663bfb.avif b/media/uploads/post/thumb/d95ada958af5487fbf640c3b11663bfb.avif new file mode 100644 index 0000000..738ee47 Binary files /dev/null and b/media/uploads/post/thumb/d95ada958af5487fbf640c3b11663bfb.avif differ diff --git a/media/uploads/post/thumb/d9954fcf01684c1dba7c38e0a55ded15.avif b/media/uploads/post/thumb/d9954fcf01684c1dba7c38e0a55ded15.avif new file mode 100644 index 0000000..dfe82ac Binary files /dev/null and b/media/uploads/post/thumb/d9954fcf01684c1dba7c38e0a55ded15.avif differ diff --git a/media/uploads/post/thumb/e46bcefa67a54eb186e6daa46aff26d6.avif b/media/uploads/post/thumb/e46bcefa67a54eb186e6daa46aff26d6.avif new file mode 100644 index 0000000..d8b2fd1 Binary files /dev/null and b/media/uploads/post/thumb/e46bcefa67a54eb186e6daa46aff26d6.avif differ diff --git a/media/uploads/post/thumb/e532b5b88f694cf3b48a0da5a5c99ebc.avif b/media/uploads/post/thumb/e532b5b88f694cf3b48a0da5a5c99ebc.avif new file mode 100644 index 0000000..d29dcdd Binary files /dev/null and b/media/uploads/post/thumb/e532b5b88f694cf3b48a0da5a5c99ebc.avif differ diff --git a/media/uploads/post/thumb/f11859e9dbbe473d8b76230a8fa38e68.avif b/media/uploads/post/thumb/f11859e9dbbe473d8b76230a8fa38e68.avif new file mode 100644 index 0000000..0c3255e Binary files /dev/null and b/media/uploads/post/thumb/f11859e9dbbe473d8b76230a8fa38e68.avif differ diff --git a/media/uploads/post/thumb/f152c3e063874e9e99a2722204c38df5.avif b/media/uploads/post/thumb/f152c3e063874e9e99a2722204c38df5.avif new file mode 100644 index 0000000..8eaec9f Binary files /dev/null and b/media/uploads/post/thumb/f152c3e063874e9e99a2722204c38df5.avif differ diff --git a/media/uploads/post/thumb/f5a0d7cb363e422ca3c171478dd63b2d.avif b/media/uploads/post/thumb/f5a0d7cb363e422ca3c171478dd63b2d.avif new file mode 100644 index 0000000..1c21b84 Binary files /dev/null and b/media/uploads/post/thumb/f5a0d7cb363e422ca3c171478dd63b2d.avif differ diff --git a/media/uploads/post/thumb/f99b1a11f0744fbd987f21e96cd49849.avif b/media/uploads/post/thumb/f99b1a11f0744fbd987f21e96cd49849.avif new file mode 100644 index 0000000..f1c67c4 Binary files /dev/null and b/media/uploads/post/thumb/f99b1a11f0744fbd987f21e96cd49849.avif differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..63ee308 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,78 @@ +amqp==5.3.1 +asgiref==3.11.1 +billiard==4.2.4 +celery==5.6.2 +celery-types==0.24.0 +certifi==2026.1.4 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +cryptography==46.0.5 +defusedxml==0.7.1 +Django==6.0.2 +django-appconf==1.2.0 +django-autoslug==1.9.9 +django-celery-beat==2.1.0 +django-ckeditor-5==0.2.19 +django-cleanup==9.0.0 +django-colorfield==0.14.0 +django-cors-headers==4.9.0 +django-cropper-image==1.0.5 +django-environ==0.12.0 +django-filter==25.2 +django-imagekit==6.0.0 +django-redis==6.0.0 +django-stubs==5.2.9 +django-stubs-ext==5.2.9 +django-timezone-field==4.2.3 +django-tinymce==5.0.0 +django_celery_results==2.6.0 +djangorestframework==3.16.1 +djangorestframework-stubs==3.16.8 +djangorestframework_simplejwt==5.5.1 +djoser==2.3.3 +Faker==40.4.0 +flower==2.0.1 +gunicorn==25.1.0 +hiredis==3.3.0 +humanize==4.15.0 +idna==3.11 +kombu==5.6.2 +Markdown==3.10.2 +mysqlclient==2.2.8 +oauthlib==3.3.1 +packaging==26.0 +pilkit==3.0 +pillow==12.1.1 +prometheus_client==0.24.1 +prompt_toolkit==3.0.52 +psycopg2-binary==2.9.11 +pycparser==3.0 +PyJWT==2.11.0 +python-crontab==3.3.0 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +python3-openid==3.2.0 +pytz==2025.2 +redis==7.1.1 +requests==2.32.5 +requests-oauthlib==2.0.0 +six==1.17.0 +social-auth-app-django==5.7.0 +social-auth-core==4.8.5 +sqlparse==0.5.5 +tornado==6.5.4 +types-PyYAML==6.0.12.20250915 +typing_extensions==4.15.0 +tzdata==2025.3 +tzlocal==5.3.1 +urllib3==2.6.3 +vine==5.1.0 +wcwidth==0.6.0 +whitenoise==6.11.0 + + +drf-spectacular \ No newline at end of file diff --git a/schema.yml b/schema.yml new file mode 100644 index 0000000..87226a8 --- /dev/null +++ b/schema.yml @@ -0,0 +1,1131 @@ +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: v1_auth_jwt_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: + - v1 + 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: v1_auth_jwt_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: + - v1 + 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: v1_auth_jwt_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: + - v1 + 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: '' + /api/v1/auth/social/{provider}/: + post: + operationId: v1_auth_social_create + description: Authenticate user with social provider token. + parameters: + - in: path + name: provider + schema: + type: string + required: true + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SocialLogin' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SocialLogin' + multipart/form-data: + schema: + $ref: '#/components/schemas/SocialLogin' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SocialLogin' + description: '' + /api/v1/auth/social/success/: + get: + operationId: v1_auth_social_success_retrieve + description: Display success page with tokens. + tags: + - v1 + security: + - {} + responses: + '200': + description: No response body + /api/v1/auth/users/: + get: + operationId: v1_auth_users_list + tags: + - v1 + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + description: '' + post: + operationId: v1_auth_users_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UserCreate' + multipart/form-data: + schema: + $ref: '#/components/schemas/UserCreate' + required: true + security: + - jwtAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreate' + description: '' + /api/v1/auth/users/{id}/: + get: + operationId: v1_auth_users_retrieve + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this user. + required: true + tags: + - v1 + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: '' + put: + operationId: v1_auth_users_update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this user. + required: true + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + multipart/form-data: + schema: + $ref: '#/components/schemas/User' + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: '' + patch: + operationId: v1_auth_users_partial_update + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this user. + required: true + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedUser' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedUser' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedUser' + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: '' + delete: + operationId: v1_auth_users_destroy + parameters: + - in: path + name: id + schema: + type: integer + description: A unique integer value identifying this user. + required: true + tags: + - v1 + security: + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/auth/users/activation/: + post: + operationId: v1_auth_users_activation_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Activation' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Activation' + multipart/form-data: + schema: + $ref: '#/components/schemas/Activation' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Activation' + description: '' + /api/v1/auth/users/me/: + get: + operationId: v1_auth_users_me_retrieve + tags: + - v1 + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: '' + put: + operationId: v1_auth_users_me_update + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + multipart/form-data: + schema: + $ref: '#/components/schemas/User' + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: '' + patch: + operationId: v1_auth_users_me_partial_update + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedUser' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedUser' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedUser' + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: '' + delete: + operationId: v1_auth_users_me_destroy + tags: + - v1 + security: + - jwtAuth: [] + responses: + '204': + description: No response body + /api/v1/auth/users/resend_activation/: + post: + operationId: v1_auth_users_resend_activation_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SendEmailReset' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SendEmailReset' + multipart/form-data: + schema: + $ref: '#/components/schemas/SendEmailReset' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SendEmailReset' + description: '' + /api/v1/auth/users/reset_email/: + post: + operationId: v1_auth_users_reset_email_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SendEmailReset' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SendEmailReset' + multipart/form-data: + schema: + $ref: '#/components/schemas/SendEmailReset' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SendEmailReset' + description: '' + /api/v1/auth/users/reset_email_confirm/: + post: + operationId: v1_auth_users_reset_email_confirm_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UsernameResetConfirm' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UsernameResetConfirm' + multipart/form-data: + schema: + $ref: '#/components/schemas/UsernameResetConfirm' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UsernameResetConfirm' + description: '' + /api/v1/auth/users/reset_password/: + post: + operationId: v1_auth_users_reset_password_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SendEmailReset' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SendEmailReset' + multipart/form-data: + schema: + $ref: '#/components/schemas/SendEmailReset' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SendEmailReset' + description: '' + /api/v1/auth/users/reset_password_confirm/: + post: + operationId: v1_auth_users_reset_password_confirm_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordResetConfirm' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PasswordResetConfirm' + multipart/form-data: + schema: + $ref: '#/components/schemas/PasswordResetConfirm' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordResetConfirm' + description: '' + /api/v1/auth/users/set_email/: + post: + operationId: v1_auth_users_set_email_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SetUsername' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SetUsername' + multipart/form-data: + schema: + $ref: '#/components/schemas/SetUsername' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SetUsername' + description: '' + /api/v1/auth/users/set_password/: + post: + operationId: v1_auth_users_set_password_create + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SetPassword' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SetPassword' + multipart/form-data: + schema: + $ref: '#/components/schemas/SetPassword' + required: true + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/SetPassword' + description: '' + /api/v1/categories/: + get: + operationId: v1_categories_list + tags: + - v1 + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Category' + description: '' + /api/v1/categories/{slug}/: + get: + operationId: v1_categories_retrieve + parameters: + - in: path + name: slug + schema: + type: string + required: true + tags: + - v1 + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/CategoryPost' + description: '' + /api/v1/post/: + get: + operationId: v1_post_list + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + tags: + - v1 + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedPostList' + description: '' + /api/v1/post/{slug}/: + get: + operationId: v1_post_retrieve + parameters: + - in: path + name: slug + schema: + type: string + required: true + tags: + - v1 + security: + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + description: '' + /api/v1/settings/: + get: + operationId: v1_settings_retrieve + tags: + - v1 + security: + - jwtAuth: [] + responses: + '200': + description: No response body +components: + schemas: + Activation: + type: object + properties: + uid: + type: string + token: + type: string + required: + - token + - uid + Cate: + type: object + properties: + title: + type: string + title: Kategori + maxLength: 254 + parent: + type: string + readOnly: true + is_active: + allOf: + - $ref: '#/components/schemas/IsActiveEnum' + title: Yayındamı + created_at: + type: string + format: date-time + readOnly: true + title: Oluşturulma Tarihi + order: + type: integer + maximum: 2147483647 + minimum: -2147483648 + title: Görüntülenme Sırası + slug: + type: string + maxLength: 250 + pattern: ^[-a-zA-Z0-9_]+$ + image: + type: string + format: uri + nullable: true + title: Resim 630 x 653 Olmali ve Transparan PNG Olmali + keywords: + type: string + title: Seo Kelimeleri Aralarına Virgül Koyunuz + maxLength: 254 + description: + type: string + title: Açıklama + maxLength: 254 + required: + - created_at + - description + - keywords + - parent + - slug + - title + Category: + type: object + properties: + title: + type: string + title: Kategori + maxLength: 254 + parent: + type: integer + nullable: true + title: Üst Kategorisi + is_active: + allOf: + - $ref: '#/components/schemas/IsActiveEnum' + title: Yayındamı + created_at: + type: string + format: date-time + readOnly: true + title: Oluşturulma Tarihi + order: + type: integer + maximum: 2147483647 + minimum: -2147483648 + title: Görüntülenme Sırası + slug: + type: string + maxLength: 250 + pattern: ^[-a-zA-Z0-9_]+$ + image: + type: string + format: uri + nullable: true + title: Resim 630 x 653 Olmali ve Transparan PNG Olmali + keywords: + type: string + title: Seo Kelimeleri Aralarına Virgül Koyunuz + maxLength: 254 + description: + type: string + title: Açıklama + maxLength: 254 + posts: + type: array + items: + $ref: '#/components/schemas/PostSYalinerializer' + readOnly: true + child: + type: string + readOnly: true + required: + - child + - created_at + - description + - keywords + - posts + - slug + - title + CategoryPost: + type: object + properties: + title: + type: string + title: Kategori + maxLength: 254 + parent: + type: integer + nullable: true + title: Üst Kategorisi + is_active: + allOf: + - $ref: '#/components/schemas/IsActiveEnum' + title: Yayındamı + created_at: + type: string + format: date-time + readOnly: true + title: Oluşturulma Tarihi + order: + type: integer + maximum: 2147483647 + minimum: -2147483648 + title: Görüntülenme Sırası + slug: + type: string + maxLength: 250 + pattern: ^[-a-zA-Z0-9_]+$ + image: + type: string + format: uri + nullable: true + title: Resim 630 x 653 Olmali ve Transparan PNG Olmali + keywords: + type: string + title: Seo Kelimeleri Aralarına Virgül Koyunuz + maxLength: 254 + description: + type: string + title: Açıklama + maxLength: 254 + posts: + type: string + readOnly: true + child: + type: string + readOnly: true + required: + - child + - created_at + - description + - keywords + - posts + - slug + - title + IsActiveEnum: + enum: + - true + - false + type: boolean + description: |- + * `True` - Evet + * `False` - Hayır + IsFrontEnum: + enum: + - true + - false + type: boolean + description: |- + * `True` - Evet + * `False` - Hayır + PaginatedPostList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/Post' + PasswordResetConfirm: + type: object + properties: + uid: + type: string + token: + type: string + new_password: + type: string + required: + - new_password + - token + - uid + PatchedUser: + type: object + properties: + id: + type: integer + readOnly: true + email: + type: string + format: email + readOnly: true + title: Email address + Post: + type: object + properties: + title: + type: string + title: Post Başlığı + maxLength: 254 + content: + type: string + nullable: true + title: Post İçeriği + categories: + type: array + items: + $ref: '#/components/schemas/Cate' + readOnly: true + keywords: + type: string + title: Seo Kelimeleri Aralarına Virgül Koyunuz + maxLength: 254 + tags: + type: array + items: + $ref: '#/components/schemas/Tag' + readOnly: true + image: + type: string + format: uri + nullable: true + thumb: + type: string + format: uri + readOnly: true + nullable: true + video: + type: string + nullable: true + maxLength: 254 + slug: + type: string + maxLength: 250 + pattern: ^[-a-zA-Z0-9_]+$ + created_at: + type: string + format: date-time + readOnly: true + title: Oluşturulma Tarihi + updated_at: + type: string + format: date-time + readOnly: true + title: Güncelleme Tarihi + is_active: + allOf: + - $ref: '#/components/schemas/IsActiveEnum' + title: Yayındamı ? + is_front: + allOf: + - $ref: '#/components/schemas/IsFrontEnum' + title: Önde Görünsünmü ? + required: + - categories + - created_at + - keywords + - slug + - tags + - thumb + - title + - updated_at + PostSYalinerializer: + type: object + properties: + slug: + type: string + maxLength: 250 + pattern: ^[-a-zA-Z0-9_]+$ + required: + - slug + ProviderEnum: + enum: + - google-oauth2 + - github + - facebook + type: string + description: |- + * `google-oauth2` - google-oauth2 + * `github` - github + * `facebook` - facebook + SendEmailReset: + type: object + properties: + email: + type: string + format: email + required: + - email + SetPassword: + type: object + properties: + new_password: + type: string + current_password: + type: string + required: + - current_password + - new_password + SetUsername: + type: object + properties: + current_password: + type: string + new_email: + type: string + format: email + title: Email address + maxLength: 254 + required: + - current_password + - new_email + SocialLogin: + type: object + description: |- + Serializer for social authentication. + Accepts provider name and access_token from frontend. + properties: + provider: + allOf: + - $ref: '#/components/schemas/ProviderEnum' + description: |- + Social auth provider name + + * `google-oauth2` - google-oauth2 + * `github` - github + * `facebook` - facebook + access_token: + type: string + description: Access token from the social provider + id_token: + type: string + description: ID token (optional, used by some providers like Google) + required: + - access_token + - provider + Tag: + type: object + properties: + tag: + type: string + title: Post Tagları + maxLength: 254 + slug: + type: string + maxLength: 250 + pattern: ^[-a-zA-Z0-9_]+$ + required: + - tag + 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 + User: + type: object + properties: + id: + type: integer + readOnly: true + email: + type: string + format: email + readOnly: true + title: Email address + required: + - email + - id + UserCreate: + type: object + properties: + email: + type: string + format: email + title: Email address + maxLength: 254 + id: + type: integer + readOnly: true + password: + type: string + writeOnly: true + required: + - email + - id + - password + UsernameResetConfirm: + type: object + properties: + new_email: + type: string + format: email + title: Email address + maxLength: 254 + required: + - new_email + securitySchemes: + jwtAuth: + type: http + scheme: bearer + bearerFormat: JWT diff --git a/settings/__init__.py b/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/settings/admin.py b/settings/admin.py new file mode 100644 index 0000000..4ae1464 --- /dev/null +++ b/settings/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from settings.models import Setting, Banner + + +# Register your models here. +class SettingAdmin(admin.ModelAdmin): + list_display = ('title', 'is_active', 'created_at') + list_editable = ('is_active',) + + +admin.site.register(Setting, SettingAdmin) + + +class BannerAdmin(admin.ModelAdmin): + list_display = ('title', 'is_active', 'created_at') + list_editable = ('is_active',) + + +admin.site.register(Banner, BannerAdmin) diff --git a/settings/apps.py b/settings/apps.py new file mode 100644 index 0000000..e3fbbf0 --- /dev/null +++ b/settings/apps.py @@ -0,0 +1,14 @@ +from django.apps import AppConfig + + +class SettingsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'settings' + verbose_name = 'Site Ayarlar' + + def ready(self): + from . import signals # noqa + """def ready(self): + from core.signal import invalidate_setting_cache + from django.db.models.signals import post_save, post_delete""" + diff --git a/settings/management/commands/__init__.py b/settings/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/settings/management/commands/clear_cache.py b/settings/management/commands/clear_cache.py new file mode 100644 index 0000000..bafb7dd --- /dev/null +++ b/settings/management/commands/clear_cache.py @@ -0,0 +1,10 @@ +from django.core.management.base import BaseCommand +from django.core.cache import cache + + +class Command(BaseCommand): + help = 'Clear the cache' + + def handle(self, *args, **options): + cache.clear() + self.stdout.write(self.style.SUCCESS('Cache cleared successfully')) diff --git a/settings/migrations/0001_initial.py b/settings/migrations/0001_initial.py new file mode 100644 index 0000000..be2b445 --- /dev/null +++ b/settings/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 6.0 on 2025-12-13 15:41 + +import colorfield.fields +import imagekit.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Banner', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('color', colorfield.fields.ColorField(default='#FFFFFF', image_field=None, max_length=25, samples=None, verbose_name='Yazı Rengi')), + ('title', models.CharField(max_length=254, null=True, verbose_name='Baner Adı')), + ('text1', models.CharField(max_length=254, null=True, verbose_name='Baner Küçük Yazı 1')), + ('text2', models.CharField(max_length=254, null=True, verbose_name='Baner Büyük Yazı 1')), + ('text4', models.CharField(max_length=254, null=True, verbose_name='Baner Küçük Yazı 2')), + ('text5', models.CharField(max_length=254, null=True, verbose_name='Baner Düğme Yazısı')), + ('image', imagekit.models.fields.ProcessedImageField(upload_to='uploads/banner/%Y')), + ('image_k', imagekit.models.fields.ProcessedImageField(blank=True, null=True, upload_to='uploads/banner/kucuk/%Y')), + ('image_k_txt', models.CharField(max_length=254, null=True, verbose_name='Küçük Resim Yazisi')), + ('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=True, verbose_name='Yayındamı ?')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')), + ], + options={ + 'verbose_name': 'Banner', + 'verbose_name_plural': 'Bannerler', + 'db_table': 'banners', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Setting', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=254, verbose_name='Ayar Başlığı')), + ('meta_title', models.CharField(default='Meta Title', max_length=254, verbose_name='Meta Title')), + ('meta_description', models.CharField(default='Meta Description', max_length=254, verbose_name='Meta Description')), + ('phone', models.CharField(max_length=254, verbose_name='Telefon')), + ('url', models.CharField(blank=True, default='https://beyhanogur.com.tr', max_length=254, null=True, verbose_name='Site İnternet Adresi')), + ('email', models.EmailField(max_length=254, verbose_name='E-Posta')), + ('facebook', models.CharField(blank=True, default='https://www.facebook.com', max_length=254, null=True, verbose_name='Facebook')), + ('x', models.CharField(blank=True, default='https://www.twitter.com', max_length=254, null=True, verbose_name='Twitter')), + ('instagram', models.CharField(blank=True, default='https://www.instagram.com', max_length=254, null=True, verbose_name='Instagram')), + ('whatsapp', models.CharField(blank=True, default='https://www.whatsapp.com', max_length=254, null=True, verbose_name='Whatsapp')), + ('slogan', models.CharField(blank=True, default='Dondurma', max_length=254, null=True, verbose_name='Başlık Solaganı')), + ('w_logo', imagekit.models.fields.ProcessedImageField(blank=True, null=True, upload_to='uploads/logo')), + ('b_logo', imagekit.models.fields.ProcessedImageField(blank=True, null=True, upload_to='uploads/logo')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Oluşturulma Tarihi')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Güncelleme Tarihi')), + ('is_active', models.BooleanField(choices=[(True, 'Evet'), (False, 'Hayır')], default=False, verbose_name='Yayındamı')), + ], + options={ + 'verbose_name': 'Site Ayarı', + 'verbose_name_plural': 'Site Ayarları', + 'db_table': 'settings', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/settings/migrations/__init__.py b/settings/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/settings/models.py b/settings/models.py new file mode 100644 index 0000000..b00ce96 --- /dev/null +++ b/settings/models.py @@ -0,0 +1,78 @@ +from django.db import models +from colorfield.fields import ColorField +from imagekit.models import ProcessedImageField +from imagekit.processors import ResizeToFill + +class Setting(models.Model): + aktif = ( + (True, 'Evet'), + (False, 'Hayır'), + ) + title = models.CharField(max_length=254, verbose_name="Ayar Başlığı") + meta_title = models.CharField(max_length=254, verbose_name="Meta Title", default='Meta Title') + meta_description = models.CharField(max_length=254, verbose_name="Meta Description", default='Meta Description') + phone = models.CharField(max_length=254, verbose_name="Telefon") + url = models.CharField(max_length=254, verbose_name="Site İnternet Adresi", blank=True, null=True, + default='https://beyhanogur.com.tr') + email = models.EmailField(max_length=254, verbose_name="E-Posta") + facebook = models.CharField(max_length=254, verbose_name="Facebook", default='https://www.facebook.com', null=True, + blank=True) + x = models.CharField(max_length=254, verbose_name="Twitter", default='https://www.twitter.com', null=True, + blank=True) + instagram = models.CharField(max_length=254, verbose_name="Instagram", default='https://www.instagram.com', + null=True, blank=True) + whatsapp = models.CharField(max_length=254, verbose_name="Whatsapp", default='https://www.whatsapp.com', null=True, + blank=True) + slogan = models.CharField(max_length=254, verbose_name="Başlık Solaganı", default='Dondurma', null=True, blank=True) + w_logo = ProcessedImageField(upload_to='uploads/logo', null=True, blank=True, processors=[ResizeToFill(165, 54)], + format='PNG', options={'quality': 85}) + b_logo = ProcessedImageField(upload_to='uploads/logo', null=True, blank=True, processors=[ResizeToFill(165, 54)], + format='PNG', options={'quality': 85}) + created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi") + updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi") + is_active = models.BooleanField(default=False, verbose_name='Yayındamı', choices=aktif) + + + class Meta: + ordering = ["-created_at"] + db_table = 'settings' + verbose_name_plural = "Site Ayarları" + verbose_name = "Site Ayarı" + + def __str__(self): + return self.title + + +class Banner(models.Model): + aktif = ( + (True, 'Evet'), + (False, 'Hayır'), + ) + color = ColorField(default='#FFFFFF', verbose_name='Yazı Rengi') + title = models.CharField(max_length=254, verbose_name='Baner Adı', null=True) + text1 = models.CharField(max_length=254, verbose_name='Baner Küçük Yazı 1', null=True) + text2 = models.CharField(max_length=254, verbose_name='Baner Büyük Yazı 1', null=True) + text4 = models.CharField(max_length=254, verbose_name='Baner Küçük Yazı 2', null=True) + text5 = models.CharField(max_length=254, verbose_name='Baner Düğme Yazısı', null=True) + image = ProcessedImageField(upload_to='uploads/banner/%Y', + processors=[ResizeToFill(1880, 950)], + format='JPEG', + options={'quality': 90}) + image_k = ProcessedImageField(upload_to='uploads/banner/kucuk/%Y', + processors=[ResizeToFill(48, 48)], + format='PNG', + options={'quality': 90}, null=True, blank=True) + image_k_txt = models.CharField(max_length=254, verbose_name='Küçük Resim Yazisi', null=True) + is_active = models.BooleanField(default=True, verbose_name='Yayındamı ?', choices=aktif) + + created_at = models.DateTimeField(auto_now_add=True, editable=False, verbose_name="Oluşturulma Tarihi") + updated_at = models.DateTimeField(auto_now=True, editable=False, verbose_name="Güncelleme Tarihi") + + class Meta: + ordering = ["-created_at"] + db_table = 'banners' + verbose_name_plural = "Bannerler" + verbose_name = "Banner" + + def __str__(self): + return str(self.title) diff --git a/settings/serializers.py b/settings/serializers.py new file mode 100644 index 0000000..0e34d5d --- /dev/null +++ b/settings/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +# from product.models import Category, Product, Images, Tags +from settings.models import Setting + +class SettingSerializer(serializers.ModelSerializer): + b_logo = serializers.SerializerMethodField() + w_logo = serializers.SerializerMethodField() + + class Meta: + model = Setting + fields = ['title', 'meta_title', 'meta_description', 'phone', 'url', 'email', 'facebook', 'x', + 'instagram', 'whatsapp', 'slogan', 'w_logo', 'b_logo', 'created_at', + 'updated_at', 'is_active'] + + def get_w_logo(self, obj): + if obj.w_logo: + request = self.context.get('request') + if request: + return obj.w_logo.url + else: + # Fallback olarak manuel URL oluşturma + return None + return None + def get_b_logo(self, obj): + if obj.b_logo: + request = self.context.get('request') + if request: + return obj.b_logo.url + else: + # Fallback olarak manuel URL oluşturma + return None + return None diff --git a/settings/signals.py b/settings/signals.py new file mode 100644 index 0000000..ec8635d --- /dev/null +++ b/settings/signals.py @@ -0,0 +1,11 @@ +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver +from settings.models import Setting +from django.core.cache import cache + + +@receiver([post_save, post_delete], sender=Setting) +def invalidate_setting_cache(sender, instance, **kwargs): + # Sadece active_setting cache'ini temizle + cache.delete('active_setting') + print('Cache cleared for active_setting') diff --git a/settings/tests.py b/settings/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/settings/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/settings/urls.py b/settings/urls.py new file mode 100644 index 0000000..ee3e3fb --- /dev/null +++ b/settings/urls.py @@ -0,0 +1,7 @@ +from django.urls import path, include +from .views import SettingDetailView + +urlpatterns = [ # Success/Error pages + path('settings/', SettingDetailView.as_view(), name='settings'), + +] diff --git a/settings/views.py b/settings/views.py new file mode 100644 index 0000000..09332cf --- /dev/null +++ b/settings/views.py @@ -0,0 +1,37 @@ +from django.core.cache import cache +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from core.utils.Permission import ReadOnly +from settings.models import Setting +from settings.serializers import SettingSerializer +from django.utils.decorators import method_decorator +from django.views.decorators.cache import cache_page + + +# Create your views here. +class SettingDetailView(APIView): + permission_classes = [ReadOnly] + + @method_decorator(cache_page(60 * 5)) + def get(self, request): + try: + # Cache'den veri kontrolü + cache_key = 'active_setting' + cached_data = cache.get(cache_key) + + if cached_data: + return Response(cached_data) + + # Cache'de yoksa veritabanından al + setting = Setting.objects.filter(is_active=True).first() + if setting: + serializer = SettingSerializer(setting, context={'request': request}) + # 5 dakika (300 saniye) cache'le + cache.set(cache_key, serializer.data, timeout=300) + return Response(serializer.data) + else: + return Response({"error": "No settings found"}, status=status.HTTP_404_NOT_FOUND) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/static/main.css b/static/main.css new file mode 100644 index 0000000..e69de29