Compare commits

..
55 Commits
Author SHA1 Message Date
irwan.l.cahyono 6ca3a8c024 setting timezone 2025-10-17 11:06:08 +07:00
irwan.l.cahyono 5428cee51f mission today 2025-10-16 17:23:21 +07:00
irwan.l.cahyono e99c109053 datetime valid 2025-10-16 16:22:47 +07:00
afriadirahman 42521f893c Update date from & date to mission 2025-10-15 16:14:28 +07:00
irwan.l.cahyono c6767e726e init migrations 2025-10-08 15:47:20 +07:00
irwan.l.cahyono 267d424dcc gitignore 2025-10-08 15:44:53 +07:00
irwan.l.cahyono add9caf0f2 mission 2025-10-08 11:39:49 +07:00
irwan.l.cahyono 0216751834 entrypoint 2025-08-07 23:23:20 +07:00
irwan.l.cahyono a5e199d420 compose 2025-08-07 23:12:51 +07:00
irwan.l.cahyono d208864621 csrf 2025-08-07 22:40:36 +07:00
irwan.l.cahyono 115d921b19 entrypoint 2025-08-07 11:09:53 +07:00
irwan.l.cahyono 8f6c0792da nginx api 2025-08-07 10:55:13 +07:00
irwan.l.cahyono b6f86f001a compose api 2025-08-07 10:53:04 +07:00
irwan.l.cahyono fb889c76de docker compose 2025-08-07 10:52:25 +07:00
irwan.l.cahyono 5c53ac95d5 dockerfile netcat 2025-08-07 10:42:58 +07:00
irwan.l.cahyono 1276a8b16b config email 2025-08-07 10:20:29 +07:00
irwan.l.cahyono 4a6e0e7d5d update docker 2025-08-07 09:37:46 +07:00
irwan.l.cahyono 89661aa4b9 update docker 2025-08-07 09:24:08 +07:00
irwan.l.cahyono b7df911c36 email aktivasi, konfirmasi 2025-08-07 09:14:59 +07:00
irwan.l.cahyono b4ab7055af manga 2025-08-06 13:34:02 +07:00
irwan.l.cahyono 4c3f4c6640 simple jwt tidak dipakai 2025-07-31 13:53:45 +07:00
irwan.l.cahyono cd593b8e5c penambahan field content 2025-07-29 23:01:01 +07:00
irwan.l.cahyono e91f2bfbdf not required on pu 2025-07-28 10:47:45 +07:00
irwan.l.cahyono 044eb472da character view 2025-07-21 14:48:34 +07:00
irwan.l.cahyono 3e2521c190 character 2025-07-13 13:31:52 +07:00
irwan.l.cahyono 53fd650b4f media 2025-07-09 05:26:59 +07:00
irwan.l.cahyono b50cded82f content, ubah theme dan topic dengan enum 2025-07-07 23:01:31 +07:00
irwan.l.cahyono 11ca62eee4 color dan deskripsi konten 2025-07-07 01:33:06 +07:00
irwan.l.cahyono 3599d6d7fb settting cors 2025-07-01 16:28:28 +07:00
irwan.l.cahyono 74ea8942cc setting cors 2025-07-01 16:17:53 +07:00
irwan.l.cahyono 2655518a86 collectstatic 2025-06-30 15:44:37 +07:00
irwan.l.cahyono 973f1be58b setting allowed 2025-06-30 15:36:08 +07:00
irwan.l.cahyono 887777f0e3 port 8000 2025-06-30 15:02:13 +07:00
irwan.l.cahyono 72df3faf18 pindah docker 2025-06-30 13:35:44 +07:00
irwan.l.cahyono 06ed352804 workdir 2025-06-30 13:27:48 +07:00
irwan.l.cahyono 8c9e27662e docker compose 2025-06-30 13:23:50 +07:00
irwan.l.cahyono 81a1886ccc dockerfile 2025-06-30 13:19:52 +07:00
irwan.l.cahyono 2f2b8cd318 dockerfile 2025-06-30 13:18:05 +07:00
irwan.l.cahyono e434358201 update requirements 2025-06-30 12:47:12 +07:00
irwan.l.cahyono e26fbbeafe restart: always 2025-06-30 12:41:32 +07:00
irwan.l.cahyono f2499aef21 salah compose 2025-06-30 12:34:44 +07:00
irwan.l.cahyono 228e1bcd72 perbaikan 2025-06-30 12:31:43 +07:00
irwan.l.cahyono 8352a471a2 compose 2025-06-30 11:42:44 +07:00
irwan.l.cahyono b545aa75cc url formats dan nginx 2025-06-30 11:41:16 +07:00
irwan.l.cahyono 85e674e6d6 file uploads 2025-06-28 22:41:56 +07:00
irwan.l.cahyono 151cd89b88 debug toolbar dan environment 2025-06-28 21:38:44 +07:00
irwan.l.cahyono 0a8b9caed4 content and core 2025-06-26 14:43:08 +07:00
irwan.l.cahyono 6186a3717b character 2025-06-26 14:39:20 +07:00
irwan.l.cahyono 854a0c6177 docker 2025-06-20 13:54:37 +07:00
irwan.l.cahyono 4f22557204 open for docker 2025-06-20 13:43:21 +07:00
irwan.l.cahyono a40de07a0d ignore 2025-06-16 23:15:47 +07:00
irwan.l.cahyono bc80b77b85 ignore didalam 2025-06-16 23:11:11 +07:00
irwan.l.cahyono f9c56ca215 update ignore 2025-06-16 23:09:31 +07:00
irwan.l.cahyono c7f0b9c91c update content 2025-06-16 23:00:29 +07:00
irwan.l.cahyono 0e59ba2b43 requirements dan ignore 2025-06-16 22:59:07 +07:00
51 changed files with 2117 additions and 40 deletions
+140 -2
View File
@@ -1,2 +1,140 @@
/env
/venv
# ==========================================
# Python cache and bytecode files
# ==========================================
__pycache__/
*.py[cod]
*$py.class
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.pyd
# ==========================================
# Virtual environments
# ==========================================
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# ==========================================
# Dependency files
# ==========================================
Pipfile.lock
poetry.lock
# ==========================================
# Distribution / build
# ==========================================
build/
dist/
*.egg-info/
.eggs/
# ==========================================
# Installer logs
# ==========================================
pip-log.txt
pip-delete-this-directory.txt
# ==========================================
# Unit test / coverage
# ==========================================
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
# ==========================================
# Django
# ==========================================
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
media/
staticfiles/
# Migrations (optional, keep *.py if you want to version control)
**/migrations/*.pyc
**/migrations/__pycache__/
# ==========================================
# IDEs and editors
# ==========================================
.idea/
.vscode/
# ==========================================
# OS-specific files
# ==========================================
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
# ==========================================
# Environment variable files
# ==========================================
*.env
*.env.*
# ==========================================
# Profiles
# ==========================================
*.prof
# ==========================================
# Backup and swap files
# ==========================================
*~
*.bak
*.swp
*.swo
# ==========================================
# Docker (optional)
# ==========================================
#**/Dockerfile
#**/docker-compose*.yml
# ==========================================
# Celery (if used)
# ==========================================
celerybeat-schedule
celerybeat.pid
# ==========================================
# Sphinx docs
# ==========================================
docs/_build/
# ==========================================
# PyBuilder
# ==========================================
target/
# ==========================================
# Jupyter Notebook
# ==========================================
.ipynb_checkpoints/
# ==========================================
# pyenv
# ==========================================
.python-version
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
*.pyd
*.sqlite3
env/
venv/
*.env
*.log
media/
staticfiles/
node_modules/
+142
View File
@@ -0,0 +1,142 @@
# ==========================================
# Python cache and bytecode files
# ==========================================
__pycache__/
*.py[cod]
*$py.class
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.pyd
# ==========================================
# Virtual environments
# ==========================================
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# ==========================================
# Dependency files
# ==========================================
Pipfile.lock
poetry.lock
# ==========================================
# Distribution / build
# ==========================================
build/
dist/
*.egg-info/
.eggs/
# ==========================================
# Installer logs
# ==========================================
pip-log.txt
pip-delete-this-directory.txt
# ==========================================
# Unit test / coverage
# ==========================================
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
# ==========================================
# Django
# ==========================================
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
media/
staticfiles/
# Migrations (optional, keep *.py if you want to version control)
# **/migrations/*.py
# **/migrations/__init__.py
**/migrations/*.pyc
**/migrations/__pycache__/
# ==========================================
# IDEs and editors
# ==========================================
.idea/
.vscode/
# ==========================================
# OS-specific files
# ==========================================
.DS_Store
Thumbs.db
ehthumbs.db
Desktop.ini
# ==========================================
# Environment variable files
# ==========================================
*.env
*.env.*
# ==========================================
# Profiles
# ==========================================
*.prof
# ==========================================
# Backup and swap files
# ==========================================
*~
*.bak
*.swp
*.swo
# ==========================================
# Docker (optional)
# ==========================================
# **/Dockerfile
# **/docker-compose*.yml
# ==========================================
# Celery (if used)
# ==========================================
celerybeat-schedule
celerybeat.pid
# ==========================================
# Sphinx docs
# ==========================================
docs/_build/
# ==========================================
# PyBuilder
# ==========================================
target/
# ==========================================
# Jupyter Notebook
# ==========================================
.ipynb_checkpoints/
# ==========================================
# pyenv
# ==========================================
.python-version
+37
View File
@@ -0,0 +1,37 @@
FROM python:3.13.5-slim-bookworm
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
curl \
netcat-openbsd \
gcc \
libffi-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/
RUN pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
COPY . .
WORKDIR /app
# Optionally run migrations and collectstatic on container start
# (better done via entrypoint script for robustness)
# CMD ["sh", "-c", "python manage.py migrate && python manage.py collectstatic --noinput"]
# Use entrypoint for wait-for-db or init tasks
COPY ./entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
# Command to run the server
CMD ["gunicorn", "freekake_api.wsgi:application", "--bind", "0.0.0.0:8000"]
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CharacterConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'character'
+93
View File
@@ -0,0 +1,93 @@
from django.db import models
from django.core.exceptions import ValidationError
from django_softdelete.models import SoftDeleteModel
from PIL import Image
def validate_image_size(image):
if image.size > 1 * 1024 * 1024:
raise ValidationError("File size exceeds 1MB.")
def validate_image_ext(image):
allowed_extensions = ['png', 'jpg', 'jpeg']
ext = image.name.split('.')[-1].lower()
if ext not in allowed_extensions:
raise ValidationError("Invalid file type. Only PNG or JPEG allowed.")
def validate_image(image):
try:
img = Image.open(image)
img.verify()
except Exception:
raise ValidationError("Invalid image file.")
class Character(SoftDeleteModel):
CHARACTER_TYPE_CHOICES = [
('manusia', 'Manusia'),
('hewan', 'Hewan'),
]
name = models.CharField(max_length=100)
description = models.TextField(blank=True, null=True)
sex = models.BooleanField(blank=False, null=False)
type = models.CharField(max_length=25, choices=CHARACTER_TYPE_CHOICES)
featured_image = models.ImageField(
max_length=255,
upload_to="uploads/characters/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
featured_icon = models.ImageField(
max_length=255,
upload_to="uploads/characters/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class CharacterSkin(models.Model):
character = models.ForeignKey(Character, on_delete=models.CASCADE, related_name='skins')
name = models.CharField(max_length=100)
description = models.TextField(blank=True, null=True)
featured_image = models.ImageField(
max_length=255,
upload_to="uploads/character-skins/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
featured_icon = models.ImageField(
max_length=255,
upload_to="uploads/character-skins/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
class CharacterFragment(models.Model):
skin = models.ForeignKey(CharacterSkin, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
description = models.TextField(blank=True, null=True)
fragment_number = models.PositiveSmallIntegerField()
featured_icon = models.ImageField(
max_length=255,
upload_to="uploads/character-fragments/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
+61
View File
@@ -0,0 +1,61 @@
from django.forms import ImageField
from rest_framework import serializers
from character import models
class CharacterSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
featured_icon = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.Character
fields = ['id', 'name', 'description', 'sex', 'type', 'featured_image', 'featured_icon']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request_method = self.context.get('request').method if self.context.get('request') else None
if request_method in ['PUT', 'PATCH']:
# Jadikan optional di update
self.fields['featured_image'].required = False
self.fields['featured_icon'].required = False
class CharacterSkinSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
featured_icon = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.CharacterSkin
fields = ['id', 'name', 'description', 'featured_image', 'featured_icon', 'character']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request_method = self.context.get('request').method if self.context.get('request') else None
if request_method in ['PUT', 'PATCH']:
# Jadikan optional di update
self.fields['featured_image'].required = False
self.fields['featured_icon'].required = False
class CharacterSkinDetailSerializer(serializers.ModelSerializer):
character = CharacterSerializer(read_only=True)
featured_image = ImageField(max_length=255, allow_empty_file=False)
featured_icon = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.CharacterSkin
fields = ['id', 'name', 'description', 'featured_image', 'featured_icon', 'character']
class CharacterFragmentSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.CharacterFragment
fields = ['id', 'name', 'description', 'fragment_number', 'featured_icon', 'skin']
class CharacterFragmentDetailSerializer(serializers.ModelSerializer):
skin = CharacterSkinSerializer(read_only=True)
featured_image = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.CharacterFragment
fields = ['id', 'name', 'description', 'fragment_number', 'featured_icon', 'skin']
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+13
View File
@@ -0,0 +1,13 @@
from django.urls import path
from character import views
urlpatterns = [
path('characters/', views.CharacterList.as_view()),
path('characters/<int:pk>/', views.CharacterDetail.as_view()),
path('character-fragments/', views.CharacterFragmentList.as_view()),
path('character-fragments/<int:pk>/', views.CharacterFragmentDetail.as_view()),
path('character-skins/', views.CharacterSkinList.as_view()),
path('character-skins/<int:pk>/', views.CharacterSkinDetail.as_view()),
]
+56
View File
@@ -0,0 +1,56 @@
from rest_framework import generics, filters
from django_filters.rest_framework import DjangoFilterBackend
from character import models, serializers
class CharacterList(generics.ListCreateAPIView):
queryset = models.Character.objects.all()
serializer_class = serializers.CharacterSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['name']
filterset_fields = ['type']
ordering_fields = '__all__'
class CharacterDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.Character.objects.all()
serializer_class = serializers.CharacterSerializer
class CharacterSkinList(generics.ListCreateAPIView):
queryset = models.CharacterSkin.objects.all()
serializer_class = serializers.CharacterSkinSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['name']
filterset_fields = ['character']
ordering_fields = '__all__'
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.CharacterSkinDetailSerializer
return serializer_class
class CharacterSkinDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.CharacterSkin.objects.all()
serializer_class = serializers.CharacterSkinSerializer
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.CharacterSkinDetailSerializer
return serializer_class
class CharacterFragmentList(generics.ListCreateAPIView):
queryset = models.CharacterFragment.objects.all()
serializer_class = serializers.CharacterFragmentSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['name']
filterset_fields = ['skin']
ordering_fields = '__all__'
class CharacterFragmentDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.CharacterFragment.objects.all()
serializer_class = serializers.CharacterFragmentSerializer
+148 -1
View File
@@ -1,3 +1,150 @@
from django.db import models
from django.core.exceptions import ValidationError
from django_softdelete.models import SoftDeleteModel
from PIL import Image
# Create your models here.
def validate_image_size(image):
if image.size > 1 * 1024 * 1024:
raise ValidationError("File size exceeds 1MB.")
def validate_image_ext(image):
allowed_extensions = ['png', 'jpg', 'jpeg']
ext = image.name.split('.')[-1].lower()
if ext not in allowed_extensions:
raise ValidationError("Invalid file type. Only PNG or JPEG allowed.")
def validate_image(image):
try:
img = Image.open(image)
img.verify()
except Exception:
raise ValidationError("Invalid image file.")
def validate_file_size(image):
if image.size > 1 * 1024 * 1024:
raise ValidationError("File size exceeds 1MB.")
class Content(SoftDeleteModel):
CONTENT_THEME_CHOICES = [
('furikake', 'Furikake'),
('gizi', 'Gizi'),
('kesehatan', 'Kesehatan'),
('pendidikan', 'Pendidikan'),
('keselamatan', 'Keselamatan'),
]
CONTENT_TOPIC_CHOICES = {
'furikake': [
('sejarah-furikake', 'Sejarah Furikake'),
('kandungan-furikake', 'Kandungan Furikake'),
('pembuatan-furikake', 'Pembuatan Furikake'),
('menikmati-furikake', 'Menikmati Furikake')
],
'gizi': [
('gizi-seimbang', 'Gizi Seimbang'),
('kecukupan-gizi', 'Kecukupan Gizi'),
('kualitas-pangan', 'Kualitas Bahan Pangan'),
('budaya-tradisi-pangan', 'Budaya dan Tradisi Pangan'),
('ketersediaan-aksesibilitas-pangan', 'Ketersediaan dan Aksesibilitas Gizi dan Pangan')
],
'kesehatan': [
('olahraga', 'Olahraga'),
('waktu-tidur', 'Waktu Tidur'),
('kesehatan-mental', 'Kesehatan Mental'),
('pendidikan-seksual', 'Pendidikan Seksual'),
('rokok-alkohol-narkoba', 'Bahaya Merokok dan Alkohol serta Pencegahan Narkoba'),
('sistem-imun', 'Sistem Imun')
],
'pendidikan': [
('berpikir-kreatif', 'Berpikir Kreatif'),
('berpikir-kritis', 'Berpikir Kritis'),
('pentingnya-membaca', 'Pentingnya Membaca'),
('literasi-digital', 'Literasi Digital'),
('kesadaran-lingkungan', 'Kesadaran Lingkungan'),
('pancasila', 'Pancasila'),
('biologi', 'Biologi'),
('aritmatika', 'Aritmatika'),
('bahasa-indonesia', 'Bahasa Indonesia'),
],
'keselamatan': [
('lalu-lintas', 'Keselamatan Berlalu Lintas'),
('pertolongan-pertama', 'Pertolongan Pertama'),
('bencana', 'Kesiapsiagaan Bencana')
]
}
CONTENT_FORMAT_CHOICES = [
('teks','Teks'),
('infografis', 'Infografis'),
('gambar', 'Gambar'),
('video', 'Video'),
('branching-scenario', 'Pilihan Bercabang'),
('tts', 'Teka-teki Silang'),
('pencocokan', 'Pencocokan'),
('cari-kata', 'Cari Kata'),
('memory-game', 'Permainan Memori'),
('personality-quiz', 'Permainan Tebak Sifat'),
('game-map', 'Peta Permainan'),
('pilihan-ganda', 'Pilihan Ganda'),
('augmented-reality', 'Augmented Reality'),
]
CONTENT_TYPE_CHOICES = [
('content','Konten'),
('advertorial', 'Advertorial'),
]
CONTENT_STATUS_CHOICES = [
('draft', 'Draf'),
('published', 'Dipublikasikan'),
('archived', 'Diarsipkan'),
]
title = models.CharField(max_length=255, null=False)
slug = models.SlugField(max_length=255, unique=True)
featured_image = models.ImageField(
max_length=255,
upload_to="uploads/contents/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
theme = models.CharField(max_length=50, choices=CONTENT_THEME_CHOICES)
topic = models.CharField(max_length=50)
format = models.CharField(max_length=50, choices=CONTENT_FORMAT_CHOICES)
description = models.CharField(max_length=255, null=True, blank=True)
content = models.TextField(null=True)
data = models.JSONField(null=True)
grades = models.JSONField(null=True, blank=True)
point = models.IntegerField()
coin = models.IntegerField()
color = models.CharField(max_length=7, null=True, blank=True)
type = models.CharField(max_length=50, choices=CONTENT_TYPE_CHOICES, default='content')
status = models.CharField(max_length=50, choices=CONTENT_STATUS_CHOICES, default='draft')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
posted_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.title
class Media(SoftDeleteModel):
name = models.CharField(max_length=255, null=False)
media = models.FileField(
max_length=255,
upload_to="uploads/media/contents/",
validators=[validate_file_size],
null=False, blank=False)
def __str__(self):
return self.name
+110
View File
@@ -0,0 +1,110 @@
from django.forms import ImageField, FileField
from rest_framework import serializers
from content import models
class ContentSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.Content
fields = [
'id',
'title',
'slug',
'featured_image',
'theme',
'topic',
'format',
'description',
'content',
'point',
'coin',
'data',
'grades',
'color',
'type',
'status',
'posted_at',
'archived_at',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request_method = self.context.get('request').method if self.context.get('request') else None
if request_method in ['PUT', 'PATCH']:
self.fields['featured_image'].required = False
class ContentDetailSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
theme_display = serializers.SerializerMethodField()
topic_display = serializers.SerializerMethodField()
format_display = serializers.SerializerMethodField()
type_display = serializers.SerializerMethodField()
status_display = serializers.SerializerMethodField()
class Meta:
model = models.Content
fields = [
'id',
'title',
'slug',
'featured_image',
'theme',
'topic',
'format',
'description',
'content',
'point',
'coin',
'data',
'grades',
'color',
'type',
'status',
'posted_at',
'archived_at',
'theme_display',
'topic_display',
'format_display',
'type_display',
'status_display',
]
def validate(self, data):
theme = data.get('theme')
topic = data.get('topic')
allowed_topics = dict(models.Content.CONTENT_TOPIC_CHOICES).get(theme, [])
valid_topic_keys = [key for key, _ in allowed_topics]
if topic not in valid_topic_keys:
raise serializers.ValidationError({'topic': f'Topic "{topic}" is not valid for theme "{theme}"'})
return data
def get_theme_display(self, obj):
return dict(models.Content.CONTENT_THEME_CHOICES).get(obj.theme)
def get_topic_display(self, obj):
# Ambil label topic dari dict berdasarkan theme
topic_choices = dict(models.Content.CONTENT_TOPIC_CHOICES).get(obj.theme, [])
topic_dict = dict(topic_choices)
return topic_dict.get(obj.topic)
def get_format_display(self, obj):
return dict(models.Content.CONTENT_FORMAT_CHOICES).get(obj.format, obj.format)
def get_type_display(self, obj):
return dict(models.Content.CONTENT_TYPE_CHOICES).get(obj.type, obj.type)
def get_status_display(self, obj):
return dict(models.Content.CONTENT_STATUS_CHOICES).get(obj.status, obj.status)
class ContentMediaSerializer(serializers.ModelSerializer):
media = FileField(max_length=255, allow_empty_file=False)
class Meta:
model = models.Media
fields = ['id', 'name', 'media']
+16
View File
@@ -0,0 +1,16 @@
from django.urls import path
from content import views
urlpatterns = [
path('contents/', views.ContentList.as_view()),
path('contents/<int:pk>/', views.ContentDetail.as_view()),
path('themes/', views.ContentThemeChoices.as_view()),
path('topics/<str:theme_key>/', views.ContentTopicChoices.as_view()),
path('formats/', views.ContentFormatChoices.as_view()),
path('types/', views.ContentTypeChoices.as_view()),
path('statuses/', views.ContentStatusChoices.as_view()),
path('media/', views.ContentMediaList.as_view()),
path('media/<int:pk>/', views.ContentMediaDetail.as_view()),
]
+117 -2
View File
@@ -1,3 +1,118 @@
from django.shortcuts import render
from rest_framework.response import Response
from rest_framework import generics, filters, views
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, CharFilter, ChoiceFilter
# Create your views here.
from content import models, serializers
class ContentFilter(FilterSet):
grade = CharFilter(
method='filter_grades_contains', label="Grade/Class"
)
format_type = ChoiceFilter(choices=models.Content.CONTENT_FORMAT_CHOICES, label="Format", field_name="format")
def filter_grades_contains(self, queryset, name, value):
try:
value_int = int(value)
except ValueError:
return queryset.none()
return queryset.filter(grades__contains=[value_int])
class Meta:
model = models.Content
fields = ['format_type', 'theme', 'topic', 'grade']
class ContentList(generics.ListCreateAPIView):
queryset = models.Content.objects.all()
serializer_class = serializers.ContentSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['title']
filterset_class = ContentFilter
ordering_fields = '__all__'
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.ContentDetailSerializer
return serializer_class
class ContentDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.Content.objects.all()
serializer_class = serializers.ContentSerializer
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.ContentDetailSerializer
return serializer_class
class ContentThemeChoices(views.APIView):
def get(self, request, *args, **kwargs):
return Response({
"count": len(models.Content.CONTENT_THEME_CHOICES),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in models.Content.CONTENT_THEME_CHOICES
]
})
class ContentTopicChoices(views.APIView):
def get(self, request, theme_key):
topics = models.Content.CONTENT_TOPIC_CHOICES.get(theme_key)
if not topics:
return Response({
"count": 0,
"results": []
})
return Response({
"count": len(topics),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in topics
]
})
class ContentFormatChoices(views.APIView):
def get(self, request, *args, **kwargs):
return Response({
"count": len(models.Content.CONTENT_FORMAT_CHOICES),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in models.Content.CONTENT_FORMAT_CHOICES
]
})
class ContentMediaList(generics.ListCreateAPIView):
queryset = models.Media.objects.all()
serializer_class = serializers.ContentMediaSerializer
class ContentMediaDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.Media.objects.all()
serializer_class = serializers.ContentMediaSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['name']
ordering_fields = '__all__'
class ContentTypeChoices(views.APIView):
def get(self, request, *args, **kwargs):
return Response({
"count": len(models.Content.CONTENT_TYPE_CHOICES),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in models.Content.CONTENT_TYPE_CHOICES
]
})
class ContentStatusChoices(views.APIView):
def get(self, request, *args, **kwargs):
return Response({
"count": len(models.Content.CONTENT_STATUS_CHOICES),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in models.Content.CONTENT_STATUS_CHOICES
]
})
+7
View File
@@ -0,0 +1,7 @@
from djoser import email
class ActivationEmail(email.ActivationEmail):
template_name = 'email/activation.html'
class ConfirmationEmail(email.ConfirmationEmail):
template_name = 'email/confirmation.html'
+21 -1
View File
@@ -1,9 +1,14 @@
from django.db import models
from django.contrib.auth.models import User
from django_softdelete.models import SoftDeleteModel
class Province(models.Model):
class Province(SoftDeleteModel):
code = models.CharField(max_length=2, null=False)
name = models.CharField(max_length=255, null=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.name
@@ -30,3 +35,18 @@ class Village(models.Model):
def __str__(self):
return self.name
# Tambahan Data untuk User
class Student(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
school = models.CharField(max_length=255, null=True)
class_grade = models.SmallIntegerField(null=True)
province = models.ForeignKey(Province, on_delete=models.CASCADE, null=True)
regency_city = models.ForeignKey(RegencyCity, on_delete=models.CASCADE, null=True)
district = models.ForeignKey(District, on_delete=models.CASCADE, null=True)
village = models.ForeignKey(Village, on_delete=models.CASCADE, null=True)
address = models.CharField(max_length=1000, null=True)
phone_number = models.CharField(max_length=25, null=True)
+23 -1
View File
@@ -26,7 +26,6 @@ class DistrictSerializer(serializers.ModelSerializer):
model = models.District
fields = ['id', 'code', 'name', 'regency_city']
class DistrictDetailSerializer(serializers.ModelSerializer):
regency_city = RegencyCityDetailSerializer()
@@ -40,3 +39,26 @@ class VillageSerializer(serializers.ModelSerializer):
class Meta:
model = models.Village
fields = ['id', 'code', 'name', 'district']
class VillageDetailSerializer(serializers.ModelSerializer):
district = DistrictDetailSerializer()
class Meta:
model = models.Village
fields = ['id', 'code', 'name', 'district']
class StudentSerializer(serializers.ModelSerializer):
class Meta:
model = models.Student
fields = ['id', 'user', 'school', 'class_grade', 'province', 'regency_city', 'district', 'village', 'address', 'phone_number']
class StudentDetailSerializer(serializers.ModelSerializer):
province = ProvinceSerializer(read_only=True)
regency_city = RegencyCitySerializer(read_only=True)
district = DistrictSerializer(read_only=True)
village = VillageSerializer(read_only=True)
class Meta:
model = models.Student
fields = ['id', 'user', 'school', 'class_grade', 'province', 'regency_city', 'district', 'village', 'address', 'phone_number']
@@ -0,0 +1,24 @@
{% load i18n %}
{% block subject %}
{% blocktrans %}Aktivasi Akun {{ site_name }}{% endblocktrans %}
{% endblock subject %}
{% block text_body %}
{% blocktrans %}Terima kasih sudah mendaftar di {{ site_name }}. Kamu perlu mengaktifkan akun kamu untuk dapat memanfaatkan lebih banyak fitur di {{ site_name }}.{% endblocktrans %}
{% trans "Klik link di bawah ini untuk mengaktifkan akun kamu:" %}
{{ protocol }}://{{ domain }}/{{ url|safe }}
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
{% endblock text_body %}
{% block html_body %}
<p>{% blocktrans %}Terima kasih suda mendaftar di {{ site_name }}. Kamu perlu mengaktifkan akun kamu untuk dapat memanfaatkan lebih banyak fitur di {{ site_name }}.{% endblocktrans %}</p>
<p>{% trans "Klik link di bawah ini untuk mengaktifkan akun kamu:" %}</p>
<p><a href="{{ protocol }}://{{ domain }}/{{ url|safe }}">{{ protocol }}://{{ domain }}/{{ url|safe }}</a></p>
<p>{% blocktrans %}The {{ site_name }} team{% endblocktrans %}</p>
{% endblock html_body %}
@@ -0,0 +1,21 @@
{% load i18n %}
{% block subject %}
{% blocktrans %}{{ site_name }} - Akun kamu telah aktif!{% endblocktrans %}
{% endblock %}
{% block text_body %}
{% blocktrans %}Akun kamu telah aktif dan siap digunakan untuk belajar dan bermain melalui {{ site_name }}!{% endblocktrans %}
{% blocktrans %}Terima kasih sudah mendaftar di {{ site_name }}!{% endblocktrans %}
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
{% endblock text_body %}
{% block html_body %}
<p>{% blocktrans %}"Akun kamu telah aktif dan siap digunakan untuk belajar dan bermain melalui {{ site_name }}!{% endblocktrans %}</p>
<p>{% blocktrans %}Terima kasih sudah mendaftar di {{ site_name }}!{% endblocktrans %}</p>
<p>{% blocktrans %}The {{ site_name }} team{% endblocktrans %}</p>
{% endblock html_body %}
+6
View File
@@ -11,6 +11,12 @@ urlpatterns = [
path('districts/', views.DistrictList.as_view()),
path('districts/<int:pk>/', views.DistrictDetail.as_view()),
path('villages/', views.VillageList.as_view()),
path('villages/<int:pk>/', views.VillageDetail.as_view()),
path('students/', views.StudentList.as_view()),
path('students/<int:pk>/', views.StudentDetail.as_view()),
]
urlpatterns = format_suffix_patterns(urlpatterns)
+66 -3
View File
@@ -1,5 +1,6 @@
from django.shortcuts import render
from rest_framework import generics
from rest_framework import generics, filters
from django_filters.rest_framework import DjangoFilterBackend
from core import models, serializers
@@ -8,6 +9,7 @@ from core import models, serializers
class ProvinceList(generics.ListCreateAPIView):
queryset = models.Province.objects.all()
serializer_class = serializers.ProvinceSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['name', 'code']
filterset_fields = ['name', 'code']
ordering_fields = '__all__'
@@ -21,8 +23,9 @@ class ProvinceDetail(generics.RetrieveUpdateDestroyAPIView):
class RegencyCityList(generics.ListCreateAPIView):
queryset = models.RegencyCity.objects.all()
serializer_class = serializers.RegencyCitySerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['name', 'code']
filterset_fields = ['name', 'code']
filterset_fields = ['name', 'code', 'province']
ordering_fields = '__all__'
def get_serializer_class(self):
@@ -50,8 +53,9 @@ class RegencyCityDetail(generics.RetrieveUpdateDestroyAPIView):
class DistrictList(generics.ListCreateAPIView):
queryset = models.District.objects.all()
serializer_class = serializers.DistrictSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['name', 'code']
filterset_fields = ['name', 'code']
filterset_fields = ['name', 'code', 'regency_city']
ordering_fields = '__all__'
def get_serializer_class(self):
@@ -73,3 +77,62 @@ class DistrictDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = serializers.DistrictDetailSerializer
return serializer_class
# VILLAGE
class VillageList(generics.ListCreateAPIView):
queryset = models.Village.objects.all()
serializer_class = serializers.VillageSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['name', 'code']
filterset_fields = ['name', 'code', 'district']
ordering_fields = '__all__'
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.VillageDetailSerializer
return serializer_class
class VillageDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.Village.objects.all()
serializer_class = serializers.VillageSerializer
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.VillageDetailSerializer
return serializer_class
# STUDENT
class StudentList(generics.ListCreateAPIView):
queryset = models.Student.objects.all()
serializer_class = serializers.StudentSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['name', 'id']
filterset_fields = ['class_grade', 'province', 'regency_city', 'district', 'village']
ordering_fields = '__all__'
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.StudentDetailSerializer
return serializer_class
class StudentDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.Student.objects.all()
serializer_class = serializers.StudentSerializer
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.StudentDetailSerializer
return serializer_class
+37
View File
@@ -0,0 +1,37 @@
services:
api:
build:
context: .
dockerfile: ./Dockerfile
command: gunicorn freekake_api.wsgi:application --bind 0.0.0.0:8000
# command: sh -c "gunicorn freekake_api.wsgi:application --bind 0.0.0.0:8000"
volumes:
- .:/app
- static_volume:/app/staticfiles
env_file:
- .env
expose:
- "8000"
networks:
- freekake_net
nginx:
image: nginx:1.28-bookworm
restart: always
ports:
- "8000:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- static_volume:/static
- ./media:/media
depends_on:
- api
networks:
- freekake_net
volumes:
static_volume:
networks:
freekake_net:
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EntertainmentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'entertainment'
+101
View File
@@ -0,0 +1,101 @@
from django.db import models
from django.core.exceptions import ValidationError
from django_softdelete.models import SoftDeleteModel
from PIL import Image
def validate_image_size(image):
if image.size > 1 * 1024 * 1024:
raise ValidationError("File size exceeds 1MB.")
def validate_image_ext(image):
allowed_extensions = ['png', 'jpg', 'jpeg']
ext = image.name.split('.')[-1].lower()
if ext not in allowed_extensions:
raise ValidationError("Invalid file type. Only PNG or JPEG allowed.")
def validate_image(image):
try:
img = Image.open(image)
img.verify()
except Exception:
raise ValidationError("Invalid image file.")
def validate_file_size(image):
if image.size > 1 * 1024 * 1024:
raise ValidationError("File size exceeds 1MB.")
class Manga(SoftDeleteModel):
MANGA_GENRE_CHOICES = [
('komedi','Komedi'),
('petualangan', 'Petualangan'),
('fiksi-ilmiah', 'Fiksi Ilmiah'),
('fantasi', 'Fantasi'),
('slice-of-life', 'Kehidupan Sehari-hari'),
]
MANGA_STATUS_CHOICES = [
('draft', 'Draf'),
('published', 'Dipublikasikan'),
('archived', 'Diarsipkan'),
]
title = models.CharField(max_length=255, null=False)
slug = models.SlugField(max_length=255, unique=True)
featured_image = models.ImageField(
max_length=255,
upload_to="uploads/entertainments/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
genre = models.CharField(max_length=50, choices=MANGA_GENRE_CHOICES)
synopsis = models.CharField(max_length=1000, null=True, blank=True)
coin = models.IntegerField()
color = models.CharField(max_length=7, null=True, blank=True)
status = models.CharField(max_length=50, choices=MANGA_STATUS_CHOICES, default='draft')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
posted_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.title
class MangaChapter(SoftDeleteModel):
manga = models.ForeignKey(Manga, related_name='chapters', on_delete=models.CASCADE)
chapter = models.PositiveSmallIntegerField(null=False)
status = models.CharField(max_length=50, choices=Manga.MANGA_STATUS_CHOICES, default='draft')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
posted_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return self.manga.title + " - Chapter: " + str(self.chapter)
class MangaChapterPage(SoftDeleteModel):
chapter = models.ForeignKey(MangaChapter, related_name='media', on_delete=models.CASCADE)
order = models.PositiveSmallIntegerField(null=False)
media = models.FileField(
max_length=255,
upload_to="uploads/entertainments/manga/chapters/media/",
validators=[validate_file_size],
null=False, blank=False)
def __str__(self):
return self.chapter.manga.title + " - Chapter: " + self.chapter.chapter + " - Page: " + str(self.order)
+103
View File
@@ -0,0 +1,103 @@
from django.forms import ImageField, FileField
from rest_framework import serializers
from entertainment import models
class MangaSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.Manga
fields = [
'id',
'title',
'slug',
'featured_image',
'genre',
'synopsis',
'coin',
'color',
'status',
'posted_at',
'archived_at',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request_method = self.context.get('request').method if self.context.get('request') else None
if request_method in ['PUT', 'PATCH']:
self.fields['featured_image'].required = False
class MangaDetailSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
genre_display = serializers.SerializerMethodField()
status_display = serializers.SerializerMethodField()
class Meta:
model = models.Manga
fields = [
'id',
'title',
'slug',
'featured_image',
'genre',
'synopsis',
'coin',
'color',
'status',
'posted_at',
'archived_at',
'genre_display',
'status_display',
]
def validate(self, data):
genre = data.get('genre')
allowed_genres = dict(models.Manga.MANGA_GENRE_CHOICES).get(genre, [])
valid_genre_keys = [key for key, _ in allowed_genres]
if genre not in valid_genre_keys:
raise serializers.ValidationError({'topic': f'Genre "{genre}" is not valid for genre "{genre}"'})
return data
def get_genre_display(self, obj):
return dict(models.Manga.MANGA_GENRE_CHOICES).get(obj.genre)
def get_status_display(self, obj):
return dict(models.Manga.MANGA_STATUS_CHOICES).get(obj.status, obj.status)
class MangaChapterSerializer(serializers.ModelSerializer):
class Meta:
model = models.MangaChapter
fields = ['id', 'manga', 'chapter', 'status', 'posted_at', 'archived_at']
class MangaChapterDetailSerializer(serializers.ModelSerializer):
manga = MangaSerializer(read_only=True)
class Meta:
model = models.MangaChapter
fields = ['id', 'manga', 'chapter', 'status', 'posted_at', 'archived_at']
class MangaChapterPageSerializer(serializers.ModelSerializer):
media = FileField(max_length=255, allow_empty_file=False)
class Meta:
model = models.MangaChapterPage
fields = ['id', 'chapter', 'order', 'media']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request_method = self.context.get('request').method if self.context.get('request') else None
if request_method in ['PUT', 'PATCH']:
self.fields['media'].required = False
class MangaChapterPageDetailSerializer(serializers.ModelSerializer):
chapter = MangaChapterDetailSerializer(read_only=True)
class Meta:
model = models.MangaChapterPage
fields = ['id', 'chapter', 'order', 'media']
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+16
View File
@@ -0,0 +1,16 @@
from django.urls import path
from entertainment import views
urlpatterns = [
path('manga/', views.MangaList.as_view()),
path('manga/<int:pk>/', views.MangaDetail.as_view()),
path('genres/', views.MangaGenreChoices.as_view()),
path('statuses/', views.MangaStatusChoices.as_view()),
path('manga/<int:manga_id>/chapters/', views.MangaChapterList.as_view()),
path('manga/<int:manga_id>/chapters/<int:pk>/', views.MangaChapterDetail.as_view()),
path('manga/<int:manga_id>/chapters/<int:chapter_id>/pages/', views.MangaChapterPageList.as_view()),
path('manga/<int:manga_id>/chapters/<int:chapter_id>/pages/<int:pk>/', views.MangaChapterPageDetail.as_view()),
]
+91
View File
@@ -0,0 +1,91 @@
from rest_framework.response import Response
from rest_framework import generics, filters, views
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, CharFilter, ChoiceFilter
from entertainment import models, serializers
class MangaList(generics.ListCreateAPIView):
queryset = models.Manga.objects.all()
serializer_class = serializers.MangaSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['title']
filterset_fields = ['genre', 'status']
ordering_fields = '__all__'
def get_serializer_class(self):
if self.request.method in ['GET']:
return serializers.MangaDetailSerializer
return serializers.MangaSerializer
class MangaDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.Manga.objects.all()
serializer_class = serializers.MangaDetailSerializer
def get_serializer_class(self):
if self.request.method in ['GET']:
return serializers.MangaDetailSerializer
return serializers.MangaSerializer
class MangaGenreChoices(views.APIView):
def get(self, request, *args, **kwargs):
return Response({
"count": len(models.Manga.MANGA_GENRE_CHOICES),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in models.Manga.MANGA_GENRE_CHOICES
]
})
class MangaStatusChoices(views.APIView):
def get(self, request, *args, **kwargs):
return Response({
"count": len(models.Manga.MANGA_STATUS_CHOICES),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in models.Manga.MANGA_STATUS_CHOICES
]
})
class MangaChapterList(generics.ListCreateAPIView):
serializer_class = serializers.MangaChapterSerializer
ordering_fields = ['chapter']
def get_queryset(self):
manga_id = self.kwargs['manga_id']
return models.MangaChapter.objects.filter(manga_id=manga_id)
def get_serializer_class(self):
if self.request.method in ['GET']:
return serializers.MangaChapterDetailSerializer
return serializers.MangaChapterSerializer
class MangaChapterDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.MangaChapter.objects.all()
serializer_class = serializers.MangaChapterDetailSerializer
def get_serializer_class(self):
if self.request.method in ['GET']:
return serializers.MangaChapterDetailSerializer
return serializers.MangaChapterSerializer
class MangaChapterPageList(generics.ListCreateAPIView):
serializer_class = serializers.MangaChapterPageSerializer
ordering_fields = ['order']
def get_queryset(self):
chapter_id = self.kwargs['chapter_id']
return models.MangaChapterPage.objects.filter(chapter_id=chapter_id)
def get_serializer_class(self):
if self.request.method in ['GET']:
return serializers.MangaChapterPageDetailSerializer
return serializers.MangaChapterPageSerializer
class MangaChapterPageDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.MangaChapterPage.objects.all()
serializer_class = serializers.MangaChapterPageDetailSerializer
def get_serializer_class(self):
if self.request.method in ['GET']:
return serializers.MangaChapterPageDetailSerializer
return serializers.MangaChapterPageSerializer
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
DB_HOST=${DB_HOST:-localhost}
DB_PORT=${DB_PORT:-5432}
echo "Waiting for PostgreSQL at $DB_HOST:$DB_PORT..."
while ! nc -z "$DB_HOST" "$DB_PORT"; do
sleep 0.1
done
echo "PostgreSQL started"
python manage.py makemigrations --noinput
python manage.py migrate --noinput
python manage.py collectstatic --noinput
exec "$@"
+23
View File
@@ -0,0 +1,23 @@
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
DEFAULT_PAGE = 1
DEFAULT_PAGE_SIZE = 10
class CustomPagination(PageNumberPagination):
page = DEFAULT_PAGE
page_size = DEFAULT_PAGE_SIZE
page_size_query_param = 'page_size'
# def get_paginated_response(self, data):
# return Response({
# 'links': {
# 'next': self.get_next_link(),
# 'previous': self.get_previous_link()
# },
# 'total': self.page.paginator.count,
# 'page': int(self.request.GET.get('page', DEFAULT_PAGE)), # can not set default = self.page
# 'page_size': int(self.request.GET.get('page_size', self.page_size)),
# 'results': data
# })
+96 -17
View File
@@ -11,6 +11,9 @@ https://docs.djangoproject.com/en/5.1/ref/settings/
"""
from pathlib import Path
from decouple import config, Csv
from datetime import timedelta
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -20,13 +23,12 @@ BASE_DIR = Path(__file__).resolve().parent.parent
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-@wf_1z20edr655upw6t3tf==56!t%vk(oky=v4+n0io+om=4^x'
SECRET_KEY = config('SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
DEBUG = config('DEBUG', default=False, cast=bool)
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=Csv())
# Application definition
@@ -38,12 +40,19 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'oauth2_provider',
'rest_framework',
'djoser',
'psycopg2',
'corsheaders',
'django_filters',
'debug_toolbar',
'core',
'content',
'character',
'entertainment',
'mission',
]
MIDDLEWARE = [
@@ -57,6 +66,8 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'debug_toolbar.middleware.DebugToolbarMiddleware',
]
ROOT_URLCONF = 'freekake_api.urls'
@@ -64,7 +75,8 @@ ROOT_URLCONF = 'freekake_api.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
#'DIRS': [],
'DIRS': [BASE_DIR / 'core' / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -86,11 +98,11 @@ WSGI_APPLICATION = 'freekake_api.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': 'freekake_app_db',
'USER': 'postgres',
'PASSWORD': 'asalada123',
'HOST': 'localhost',
'PORT': '5432',
'NAME': config('DB_NAME'),
'USER': config('DB_USER'),
'PASSWORD': config('DB_PASSWORD'),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='5432'),
}
}
@@ -119,7 +131,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
TIME_ZONE = config('TIME_ZONE', default='UTC')
USE_I18N = True
@@ -130,18 +142,85 @@ USE_TZ = True
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATIC_URL = 'static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles')
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173",
"http://127.0.0.1:5173",
]
# CORS_ALLOWED_ORIGINS = [
# "http://localhost:3000",
# "http://localhost:5173",
# "http://127.0.0.1:5173",
# ]
CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS', cast=Csv(), default=[])
CORS_ALLOW_CREDENTIALS = True
CSRF_TRUSTED_ORIGINS = config('CSRF_TRUSTED_ORIGINS', cast=Csv(), default=[])
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10
'DEFAULT_PAGINATION_CLASS':
'freekake_api.pagination.CustomPagination',
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend'
],
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
# 'rest_framework_simplejwt.authentication.JWTAuthentication',
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
)
}
OAUTH2_PROVIDER = {
'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'groups': 'Access to your groups'}
}
LOGIN_URL = '/admin/login/'
DJOSER = {
'SERIALIZERS': {},
'TOKEN_MODEL': None,
'PASSWORD_RESET_CONFIRM_URL': '#/password/reset/confirm/{uid}/{token}',
'USERNAME_RESET_CONFIRM_URL': '#/username/reset/confirm/{uid}/{token}',
'ACTIVATION_URL': 'auth/activation/{uid}/{token}/',
'SEND_ACTIVATION_EMAIL': True,
'SEND_CONFIRMATION_EMAIL': True,
'EMAIL_FRONTEND_SITE_NAME': 'Freekake',
'EMAIL_FRONTEND_DOMAIN': config('FRONTEND_URL', default='http://localhost:3000'),
'EMAIL': {
'activation': 'core.email.ActivationEmail',
'confirmation': 'core.email.ConfirmationEmail',
},
}
INTERNAL_IPS = [
'127.0.0.1',
]
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
DATA_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024
SIMPLE_JWT = {
'AUTH_HEADER_TYPES': ('JWT',),
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"AUDIENCE": 'freekake_api',
"ISSUER": 'freekake_api',
}
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = config('EMAIL_HOST', default='sandbox.smtp.mailtrap.io')
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='a14db434093c11')
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='3524293f1ca830')
EMAIL_PORT = config('EMAIL_PORT', default='2525')
+14 -2
View File
@@ -16,9 +16,21 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import path, include
from oauth2_provider import urls as oauth2_urls
from debug_toolbar.toolbar import debug_toolbar_urls
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
# path('admin/', admin.site.urls),
path('admin/', admin.site.urls),
path('oauth/', include(oauth2_urls)),
path('auth/', include('djoser.urls')),
# path('auth/', include('djoser.urls.jwt')),
path('core/', include('core.urls')),
]
path('content/', include('content.urls')),
path('character/', include('character.urls')),
path('entertainment/', include('entertainment.urls')),
path('mission/', include('mission.urls')),
]+ debug_toolbar_urls() + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Executable → Regular
View File
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class MissionConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'mission'
+103
View File
@@ -0,0 +1,103 @@
from django.db import models
from django.core.exceptions import ValidationError
from django_softdelete.models import SoftDeleteModel
from PIL import Image
def validate_image_size(image):
if image.size > 1 * 1024 * 1024:
raise ValidationError("File size exceeds 1MB.")
def validate_image_ext(image):
allowed_extensions = ['png', 'jpg', 'jpeg']
ext = image.name.split('.')[-1].lower()
if ext not in allowed_extensions:
raise ValidationError("Invalid file type. Only PNG or JPEG allowed.")
def validate_image(image):
try:
img = Image.open(image)
img.verify()
except Exception:
raise ValidationError("Invalid image file.")
def validate_file_size(image):
if image.size > 1 * 1024 * 1024:
raise ValidationError("File size exceeds 1MB.")
class Mission(SoftDeleteModel):
MISSION_CATEGORY_CHOICES = [
('daily','Harian'),
('weekly', 'Mingguan'),
('monthly', 'Bulanan'),
]
MISSION_TASK_CHOICES = [
('scan-qr','Scan QR'),
('upload-photo','Upload Foto'),
]
MISSION_STATUS_CHOICES = [
('draft', 'Draf'),
('published', 'Dipublikasikan'),
('archived', 'Diarsipkan'),
]
name = models.CharField(max_length=255, null=False)
description = models.CharField(max_length=1000, null=True, blank=True)
featured_image = models.ImageField(
max_length=255,
upload_to="uploads/missions/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
coin = models.IntegerField()
point = models.IntegerField()
category = models.CharField(max_length=50, choices=MISSION_CATEGORY_CHOICES)
task = models.CharField(max_length=50, choices=MISSION_TASK_CHOICES)
# date_from = models.DateField(null=True, blank=True)
# date_to = models.DateField(null=True, blank=True)
# time_from_valid = models.TimeField(null=True, blank=True)
# time_to_valid = models.TimeField(null=True, blank=True)
date_time_from_valid = models.DateTimeField(null=True, blank=True)
date_time_to_valid = models.DateTimeField(null=True, blank=True)
status = models.CharField(max_length=50, choices=MISSION_STATUS_CHOICES, default='draft')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
posted_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
class MissionLog(SoftDeleteModel):
MISSION_LOG_STATUS_CHOICES = [
('visited', 'Baru Dikunjungi'),
('completed', 'Sudah Selesai'),
('claimed', 'Hadiah Sudah Diambil'),
]
mission = models.ForeignKey(Mission, related_name='logs', on_delete=models.CASCADE)
user_id = models.IntegerField()
status = models.CharField(max_length=50, choices=MISSION_LOG_STATUS_CHOICES)
coin = models.IntegerField()
point = models.IntegerField()
image_log = models.ImageField(
max_length=255,
upload_to="uploads/mission_logs/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
completed_at = models.DateTimeField(null=True, blank=True)
claimed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+173
View File
@@ -0,0 +1,173 @@
from django.forms import ImageField, FileField
from rest_framework import serializers
from mission import models
class MissionSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.Mission
fields = [
'id',
'name',
'description',
'featured_image',
'coin',
'point',
'category',
'task',
'status',
'date_time_from_valid',
'date_time_to_valid',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request_method = self.context.get('request').method if self.context.get('request') else None
if request_method in ['PUT', 'PATCH']:
self.fields['featured_image'].required = False
class MissionDetailSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
category_display = serializers.SerializerMethodField()
task_display = serializers.SerializerMethodField()
status_display = serializers.SerializerMethodField()
class Meta:
model = models.Mission
fields = [
'id',
'name',
'description',
'featured_image',
'coin',
'point',
'category',
'task',
'status',
'date_time_from_valid',
'date_time_to_valid',
'category_display',
'task_display',
'status_display',
]
def validate(self, data):
category = data.get('category')
task = data.get('task')
allowed_categories = dict(models.Mission.MISSION_CATEGORY_CHOICES).get(category, [])
valid_category_keys = [key for key, _ in allowed_categories]
if category not in valid_category_keys:
raise serializers.ValidationError({"category": "Invalid category."})
allowed_tasks = dict(models.Mission.MISSION_TASK_CHOICES).get(task, [])
valid_task_keys = [key for key, _ in allowed_tasks]
if task not in valid_task_keys:
raise serializers.ValidationError({"task": "Invalid task."})
return data
def get_category_display(self, obj):
return obj.get_category_display()
def get_task_display(self, obj):
return obj.get_task_display()
def get_status_display(self, obj):
return obj.get_status_display()
class MissionLogSerializer(serializers.ModelSerializer):
image_log = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.MissionLog
fields = [
'mission',
'user_id',
'status',
'coin',
'point',
'image_log',
'completed_at',
'claimed_at',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
request_method = self.context.get('request').method if self.context.get('request') else None
if request_method in ['PUT', 'PATCH']:
self.fields['image_log'].required = False
class MissionLogDetailSerializer(serializers.ModelSerializer):
image_log = ImageField(max_length=255, allow_empty_file=False)
status_display = serializers.SerializerMethodField()
class Meta:
model = models.MissionLog
fields = [
'id',
'mission',
'user_id',
'status',
'coin',
'point',
'image_log',
'completed_at',
'claimed_at',
'status_display',
]
def validate(self, data):
status = data.get('status')
allowed_statuses = dict(models.MissionLog.MISSION_LOG_STATUS_CHOICES).keys()
if status not in allowed_statuses:
raise serializers.ValidationError({"status": "Invalid status."})
return data
def get_status_display(self, obj):
return obj.get_status_display()
class MissionWithLogSerializer(serializers.ModelSerializer):
category_display = serializers.SerializerMethodField()
task_display = serializers.SerializerMethodField()
status_display = serializers.SerializerMethodField()
log_status = serializers.CharField(read_only=True)
log_completed_at = serializers.DateTimeField(read_only=True)
log_claimed_at = serializers.DateTimeField(read_only=True)
class Meta:
model = models.Mission
fields = [
'id',
'name',
'description',
'featured_image',
'coin',
'point',
'category',
'task',
'status',
'date_time_from_valid',
'date_time_to_valid',
'category_display',
'task_display',
'status_display',
'log_status', 'log_completed_at', 'log_claimed_at'
]
def get_category_display(self, obj):
return obj.get_category_display()
def get_task_display(self, obj):
return obj.get_task_display()
def get_status_display(self, obj):
return obj.get_status_display()
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+16
View File
@@ -0,0 +1,16 @@
from django.urls import path
from mission import views
urlpatterns = [
path('missions/', views.MissionList.as_view()),
path('missions/<int:pk>/', views.MissionDetail.as_view()),
path('missions/today/', views.TodayMissionListView.as_view()),
path('categories/', views.MissionCategoryChoices.as_view()),
path('tasks/', views.MissionTaskChoices.as_view()),
path('statuses/', views.MissionStatusChoices.as_view()),
path('mission-logs/', views.MissionLogList.as_view()),
path('mission-logs/<int:pk>/', views.MissionLogDetail.as_view()),
]
+109
View File
@@ -0,0 +1,109 @@
from rest_framework.response import Response
from rest_framework import generics, filters, views
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, CharFilter, ChoiceFilter
from django.db.models import OuterRef, Subquery
from django.utils import timezone
from mission import models, serializers
class MissionList(generics.ListCreateAPIView):
queryset = models.Mission.objects.all()
serializer_class = serializers.MissionSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['name']
filterset_fields = ['category', 'task', 'status']
ordering_fields = '__all__'
def get_serializer_class(self):
if self.request.method in ['GET']:
return serializers.MissionDetailSerializer
return serializers.MissionSerializer
class MissionDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.Mission.objects.all()
serializer_class = serializers.MissionDetailSerializer
def get_serializer_class(self):
if self.request.method in ['GET']:
return serializers.MissionDetailSerializer
return serializers.MissionSerializer
class MissionCategoryChoices(views.APIView):
def get(self, request, *args, **kwargs):
return Response({
"count": len(models.Mission.MISSION_CATEGORY_CHOICES),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in models.Mission.MISSION_CATEGORY_CHOICES
]
})
class MissionTaskChoices(views.APIView):
def get(self, request, *args, **kwargs):
return Response({
"count": len(models.Mission.MISSION_TASK_CHOICES),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in models.Mission.MISSION_TASK_CHOICES
]
})
class MissionStatusChoices(views.APIView):
def get(self, request, *args, **kwargs):
return Response({
"count": len(models.Mission.MISSION_STATUS_CHOICES),
"results": [
{"value": choice[0], "label": choice[1]}
for choice in models.Mission.MISSION_STATUS_CHOICES
]
})
class MissionLogList(generics.ListCreateAPIView):
queryset = models.MissionLog.objects.all()
serializer_class = serializers.MissionLogSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['user_id']
filterset_fields = ['mission', 'user_id', 'status']
ordering_fields = '__all__'
def get_serializer_class(self):
if self.request.method in ['GET']:
return serializers.MissionLogDetailSerializer
return serializers.MissionLogSerializer
class MissionLogDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.MissionLog.objects.all()
serializer_class = serializers.MissionLogSerializer
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method in ['GET']:
return serializers.MissionLogDetailSerializer
return serializer_class
class TodayMissionListView(views.APIView):
def get(self, request):
user_id = request.user.id
now = timezone.now()
missions = models.Mission.objects.filter(
status='published',
date_time_from_valid__lte=now,
date_time_to_valid__gte=now
)
mission_log_subquery = models.MissionLog.objects.filter(
mission=OuterRef('pk'),
user_id=user_id
)
missions = missions.annotate(
log_status=Subquery(mission_log_subquery.values('status')[:1]),
log_completed_at=Subquery(mission_log_subquery.values('completed_at')[:1]),
log_claimed_at=Subquery(mission_log_subquery.values('claimed_at')[:1]),
)
serializer = serializers.MissionWithLogSerializer(missions, many=True)
return Response(serializer.data)
+30
View File
@@ -0,0 +1,30 @@
# nginx.conf
client_max_body_size 10M;
upstream django_app {
server api:8000;
}
server {
listen 80;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
location / {
proxy_pass http://django_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
}
location /static/ {
alias /static/;
}
location /media/ {
alias /media/;
}
}
+33
View File
@@ -0,0 +1,33 @@
asgiref==3.8.1
certifi==2025.4.26
cffi==1.17.1
charset-normalizer==3.4.2
cryptography==45.0.3
defusedxml==0.7.1
Django==5.2.1
django-cors-headers==4.7.0
django-debug-toolbar==5.2.0
django-filter==25.1
django-oauth-toolkit==3.0.1
django-soft-delete==1.0.18
djangorestframework==3.16.0
djangorestframework_simplejwt==5.5.0
djoser==2.3.1
gunicorn==23.0.0
idna==3.10
jwcrypto==1.5.6
oauthlib==3.2.2
packaging==25.0
pillow==11.2.1
psycopg2-binary==2.9.10
pycparser==2.22
PyJWT==2.9.0
python-decouple==3.8
python3-openid==3.2.0
requests==2.32.3
requests-oauthlib==2.0.0
social-auth-app-django==5.4.3
social-auth-core==4.6.1
sqlparse==0.5.3
typing_extensions==4.14.0
urllib3==2.4.0
-6
View File
@@ -1,6 +0,0 @@
asgiref==3.8.1
Django==5.1.7
django-cors-headers==4.7.0
djangorestframework==3.15.2
psycopg2-binary==2.9.10
sqlparse==0.5.3