first commit
35
.env
Normal file
@@ -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
|
||||||
41
Dockerfile
Normal file
@@ -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"]
|
||||||
0
accounts/__init__.py
Normal file
37
accounts/admin.py
Normal file
@@ -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')
|
||||||
5
accounts/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
name = 'accounts'
|
||||||
27
accounts/middleware.py
Normal file
@@ -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
|
||||||
|
|
||||||
37
accounts/migrations/0001_initial.py
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
accounts/migrations/__init__.py
Normal file
103
accounts/models.py
Normal file
@@ -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
|
||||||
19
accounts/pipeline.py
Normal file
@@ -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}
|
||||||
|
|
||||||
74
accounts/serializers.py
Normal file
@@ -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
|
||||||
|
|
||||||
3
accounts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
51
accounts/urls.py
Normal file
@@ -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/<str:uid>/<str:token>/', 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/<str:provider>/', 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'),
|
||||||
|
]
|
||||||
271
accounts/views.py
Normal file
@@ -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/<provider>/
|
||||||
|
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"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Successful</title>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
color: #28a745;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}}
|
||||||
|
.success-icon {{
|
||||||
|
text-align: center;
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}}
|
||||||
|
.token-box {{
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 2px solid #e1e4e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 15px 0;
|
||||||
|
word-break: break-all;
|
||||||
|
}}
|
||||||
|
.token-label {{
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}}
|
||||||
|
.token-value {{
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}}
|
||||||
|
.btn {{
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}}
|
||||||
|
.btn:hover {{
|
||||||
|
background: #5568d3;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="success-icon">✅</div>
|
||||||
|
<h1>Authentication Successful!</h1>
|
||||||
|
<p style="text-align: center; color: #666; margin-bottom: 30px;">
|
||||||
|
You have successfully authenticated with your social account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="token-box">
|
||||||
|
<div class="token-label">Access Token:</div>
|
||||||
|
<div class="token-value" id="accessToken">{access_token}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="token-box">
|
||||||
|
<div class="token-label">Refresh Token:</div>
|
||||||
|
<div class="token-value" id="refreshToken">{refresh_token}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn" onclick="copyTokens()">Copy Tokens to Clipboard</button>
|
||||||
|
<button class="btn" onclick="window.close()" style="background: #6c757d;">Close Window</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyTokens() {{
|
||||||
|
const tokens = {{
|
||||||
|
access: "{access_token}",
|
||||||
|
refresh: "{refresh_token}"
|
||||||
|
}};
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(tokens, null, 2))
|
||||||
|
.then(() => alert('Tokens copied to clipboard!'))
|
||||||
|
.catch(err => alert('Failed to copy: ' + err));
|
||||||
|
}}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
return HttpResponse(html_content)
|
||||||
2
backup/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
default_app_config = 'backup.apps.BackupConfig'
|
||||||
|
|
||||||
368
backup/admin.py
Normal file
@@ -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(
|
||||||
|
'<span style="background-color: {}; color: white; padding: 3px 10px; border-radius: 3px; font-weight: bold;">{}</span>',
|
||||||
|
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(
|
||||||
|
'<a href="{}" class="button" style="background-color: #4CAF50; color: white; padding: 5px 10px; '
|
||||||
|
'text-decoration: none; border-radius: 3px; display: inline-block;">📥 İndir</a>',
|
||||||
|
url
|
||||||
|
)
|
||||||
|
return format_html('<span style="color: {};">-</span>', '#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('<int:backup_id>/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
|
||||||
10
backup/apps.py
Normal file
@@ -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
|
||||||
38
backup/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backup/migrations/__init__.py
Normal file
68
backup/models.py
Normal file
@@ -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}")
|
||||||
|
|
||||||
@@ -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 %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'admin:backup_create' %}" class="addlink" style="background-color: #4CAF50; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
|
||||||
|
🔄 Yeni Yedek Al
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if show_upload_backup_button %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'admin:backup_upload' %}" class="addlink" style="background-color: #2196F3; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px; display: inline-block; font-weight: bold;">
|
||||||
|
📤 Yedek Yükle
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
174
backup/templates/admin/backup/upload_backup.html
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n static %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
{{ block.super }}
|
||||||
|
<style>
|
||||||
|
.upload-form-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.form-group input[type="file"] {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.help-text {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.btn-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #2196F3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #1976D2;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #757575;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #616161;
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
background-color: #E3F2FD;
|
||||||
|
border-left: 4px solid #2196F3;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.info-box h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #1976D2;
|
||||||
|
}
|
||||||
|
.info-box ul {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a>
|
||||||
|
› <a href="{% url 'admin:backup_databasebackup_changelist' %}">Veritabanı Yedekleri</a>
|
||||||
|
› Yedek Yükle
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="upload-form-container">
|
||||||
|
<h1>📤 Yedek Dosyası Yükle</h1>
|
||||||
|
|
||||||
|
<div class="info-box">
|
||||||
|
<h4>ℹ️ Bilgilendirme</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Sadece <strong>.sql</strong> uzantılı dosyalar yüklenebilir</li>
|
||||||
|
<li>Maksimum dosya boyutu: <strong>500 MB</strong></li>
|
||||||
|
<li>Yüklenen dosya <code>backups/</code> klasörüne kaydedilecektir</li>
|
||||||
|
<li>Dosya otomatik olarak timestamp ile adlandırılacaktır</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="backup_name">Yedek Adı:</label>
|
||||||
|
<input type="text"
|
||||||
|
id="backup_name"
|
||||||
|
name="backup_name"
|
||||||
|
placeholder="Örn: Production Yedek - 2024-12-24"
|
||||||
|
maxlength="255">
|
||||||
|
<span class="help-text">
|
||||||
|
Boş bırakılırsa dosya adı kullanılacaktır
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="backup_file">Yedek Dosyası: *</label>
|
||||||
|
<input type="file"
|
||||||
|
id="backup_file"
|
||||||
|
name="backup_file"
|
||||||
|
accept=".sql"
|
||||||
|
required>
|
||||||
|
<span class="help-text">
|
||||||
|
PostgreSQL SQL dump dosyası (.sql)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-container">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
📤 Yükle
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'admin:backup_databasebackup_changelist' %}" class="btn btn-secondary">
|
||||||
|
❌ İptal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dosya seçildiğinde boyut kontrolü
|
||||||
|
document.getElementById('backup_file').addEventListener('change', function(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const maxSize = 500 * 1024 * 1024; // 500MB
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
alert('Dosya çok büyük! Maksimum 500MB olabilir.');
|
||||||
|
e.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dosya adını backup_name alanına otomatik doldur (eğer boşsa)
|
||||||
|
const nameField = document.getElementById('backup_name');
|
||||||
|
if (!nameField.value) {
|
||||||
|
const fileName = file.name.replace('.sql', '');
|
||||||
|
nameField.value = 'Yüklenen Yedek - ' + fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dosya boyutunu göster
|
||||||
|
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
|
||||||
|
console.log('Dosya boyutu: ' + sizeInMB + ' MB');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
3
backup/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
328
backup/views.py
Normal file
@@ -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)}"
|
||||||
0
blog/__init__.py
Normal file
101
blog/admin.py
Normal file
@@ -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 = '<ul>'
|
||||||
|
for tag in obj.tags.all():
|
||||||
|
tags += '<li>' + tag.tag + '</li>'
|
||||||
|
tags += '</ul>'
|
||||||
|
return mark_safe(tags)
|
||||||
|
|
||||||
|
def post_kategorileri(self, obj):
|
||||||
|
html = '<ul>'
|
||||||
|
for category in obj.categories.all():
|
||||||
|
html += '<li>' + category.title + '</li>'
|
||||||
|
html += '</ul>'
|
||||||
|
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(
|
||||||
|
'<a href="/admin/blog/post/{}/change/"><img src="{}" width="50" height="50" style="object-fit: cover;" /></a>'.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)
|
||||||
9
blog/apps.py
Normal file
@@ -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
|
||||||
0
blog/management/__init__.py
Normal file
0
blog/management/commands/__init__.py
Normal file
85
blog/management/commands/create_fake_posts.py
Normal file
@@ -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))
|
||||||
121
blog/management/commands/seed_data.py
Normal file
@@ -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"<p>{fake.paragraph(nb_sentences=10)}</p><p>{fake.paragraph(nb_sentences=5)}</p>"
|
||||||
|
|
||||||
|
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!'))
|
||||||
127
blog/migrations/0001_initial.py
Normal file
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
blog/migrations/__init__.py
Normal file
239
blog/models.py
Normal file
@@ -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])
|
||||||
89
blog/serializers.py
Normal file
@@ -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
|
||||||
52
blog/signals.py
Normal file
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
40
blog/tasks.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
3
blog/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
10
blog/urls.py
Normal file
@@ -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/<slug:slug>/', CategoryDetail.as_view(), name='categories.details'),
|
||||||
|
path('post/', PostList.as_view(), name='post.list'),
|
||||||
|
path('post/<slug:slug>/', PostDetail.as_view(), name='post.details'),
|
||||||
|
]
|
||||||
47
blog/views.py
Normal file
@@ -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
|
||||||
43
build/Dockerfile
Normal file
@@ -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"]
|
||||||
75
build/requirements.txt
Normal file
@@ -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
|
||||||
28
caddy/Caddyfile
Normal file
@@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
caddy/Dockerfile
Normal file
@@ -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
|
||||||
5
core/__init__.py
Normal file
@@ -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',)
|
||||||
16
core/asgi.py
Normal file
@@ -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()
|
||||||
22
core/celery.py
Normal file
@@ -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}')
|
||||||
225
core/settings.py
Normal file
@@ -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
|
||||||
40
core/urls.py
Normal file
@@ -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)
|
||||||
9
core/utils/Permission.py
Normal file
@@ -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
|
||||||
0
core/utils/__init__.py
Normal file
40
core/utils/utils.py
Normal file
@@ -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}
|
||||||
|
}
|
||||||
16
core/wsgi.py
Normal file
@@ -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()
|
||||||
0
db.sqlite3
Normal file
132
docker-compose.beat.yml
Normal file
@@ -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
|
||||||
111
docker-compose.cool.yml
Normal file
@@ -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:
|
||||||
123
docker-compose.yml
Normal file
@@ -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
|
||||||
31
entrypoint.sh
Normal file
@@ -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 "$@"
|
||||||
22
manage.py
Normal file
@@ -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()
|
||||||
BIN
media/uploads/logo/18d11cf76c60f0b453aaea8da0838d3b.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
media/uploads/logo/18d11cf76c60f0b453aaea8da0838d3b_KoNqrIl.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
media/uploads/post/06a81ce12001473f8522182344a29632.avif
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
media/uploads/post/07c30a28270d4171bc78753343c828b3.avif
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
media/uploads/post/088b49773c254e35af45e3d86922fcf1.avif
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
media/uploads/post/08a032ca97194d01b8115e76b3c0a0f4.avif
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
media/uploads/post/0d5df0cbdf994124b22fbb0065c0e89b.avif
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
media/uploads/post/1278cf56a2a440a4ade6b4a49269c465.avif
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
media/uploads/post/14269ded500f4b11b625f2eae1df9d44.avif
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
media/uploads/post/1b504378135d41c08e096d287182fab7.avif
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
media/uploads/post/27fc12f8e5784d3598680587edb4f04d.avif
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
media/uploads/post/2c414db84b8042c78b6ccf21610c1b1a.avif
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
media/uploads/post/2df5ffcee9954efd99c2f34656950951.avif
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
media/uploads/post/349d4c7ce59b4149bae1db1a346c528e.avif
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
media/uploads/post/382e91f2a45e4e0986de61ea33d49a2b.avif
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
media/uploads/post/40cd9cd2276445e280f1516e4ebc01c2.avif
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
media/uploads/post/4b376031cf60417594dbc6d3691bec54.avif
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
media/uploads/post/4d47c942a5d548729e4dbccfc61c83b5.avif
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
media/uploads/post/5257c7b2f1f64d55aee0731eb31bee8b.avif
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
media/uploads/post/527574b4a5d246ec852ea7094ea8ecf9.avif
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
media/uploads/post/54830c8982d749d4a1a4149af49666f7.avif
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
media/uploads/post/54a42a715666423e9bf2239703611467.avif
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
media/uploads/post/56b4d4bd3a5540ce91ea2feec20ca1a5.avif
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
media/uploads/post/604d327b085641a884132c4e0bd23576.avif
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
media/uploads/post/613d1f16356d4dd8ac4d489e9bdafb2b.avif
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
media/uploads/post/6af9e8348c444947a1e9616e6d8484d3.avif
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
media/uploads/post/6c09b02d1fb44c79910424febb71aece.avif
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
media/uploads/post/7155f45d030746788c8cd624df10bd05.avif
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
media/uploads/post/74985a61dd2a49f8931fc6ef23a8319f.avif
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
media/uploads/post/7cc54523f0f6451f80e7ab9835733ecb.avif
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
media/uploads/post/81594c54662a49ef9e462bfbe697dc7c.avif
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
media/uploads/post/8c7db9ed50524a2ba4231c3bfc23e6d0.avif
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
media/uploads/post/8cda64264fc344fea300a9fd2b900689.avif
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
media/uploads/post/8ea61cce0b624972b4123f861f552e68.avif
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
media/uploads/post/a2840b378e0841b9badb0fa29690e8cb.avif
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
media/uploads/post/a3180991052c49919420365fd1854a60.avif
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
media/uploads/post/ad7c2793ea1d4694a0f5f1fa0bc49e6d.avif
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
media/uploads/post/c10aaa871ff84bb58aed9573a470b28b.avif
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
media/uploads/post/c17c0fb0cabf485a85f8d9f021480973.avif
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
media/uploads/post/c99e16f92585487381bc21834d836c41.avif
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
media/uploads/post/cd14234741d049859a9dffc957096249.avif
Normal file
|
After Width: | Height: | Size: 60 KiB |