Compare commits

...
44 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
40 changed files with 1339 additions and 210 deletions
-27
View File
@@ -1,27 +0,0 @@
# Use an official Python runtime as a base image
FROM python:3.13.5-slim-bookworm
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Set work directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && \
apt-get install -y build-essential libpq-dev && \
rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt /app/
RUN pip install --upgrade pip && pip install -r requirements.txt
# Copy project
COPY . /app/
# Run migrations & collect static in production (optional)
# CMD ["python", "manage.py", "migrate"] && ["python", "manage.py", "collectstatic", "--noinput"]
# Command to run the server
# CMD ["gunicorn", "freekake_api.wsgi:application", "--bind", "0.0.0.0:8000"]
-26
View File
@@ -1,26 +0,0 @@
services:
web:
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/static
expose:
- "8000"
nginx:
image: nginx:1.28-bookworm
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- static_volume:/static
depends_on:
- web
volumes:
static_volume:
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
*.pyd
*.sqlite3
env/
venv/
*.env
*.log
media/
staticfiles/
node_modules/
+4 -2
View File
@@ -71,6 +71,8 @@ media/
staticfiles/
# Migrations (optional, keep *.py if you want to version control)
# **/migrations/*.py
# **/migrations/__init__.py
**/migrations/*.pyc
**/migrations/__pycache__/
@@ -110,8 +112,8 @@ Desktop.ini
# ==========================================
# Docker (optional)
# ==========================================
**/Dockerfile
**/docker-compose*.yml
# **/Dockerfile
# **/docker-compose*.yml
# ==========================================
# Celery (if used)
+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"]
+49 -9
View File
@@ -1,19 +1,47 @@
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):
class CharacterType(models.TextChoices):
HUMAN = 'HUMAN', 'Manusia'
ANIMAL = 'ANIMAL', 'Hewan'
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=CharacterType.choices, default=CharacterType.HUMAN)
type = models.CharField(max_length=25, choices=CHARACTER_TYPE_CHOICES)
featured_image = models.CharField(max_length=1000)
featured_icon = models.CharField(max_length=1000)
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)
@@ -27,8 +55,16 @@ class CharacterSkin(models.Model):
name = models.CharField(max_length=100)
description = models.TextField(blank=True, null=True)
featured_image = models.CharField(max_length=1000)
featured_icon = models.CharField(max_length=1000)
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)
@@ -44,7 +80,11 @@ class CharacterFragment(models.Model):
fragment_number = models.PositiveSmallIntegerField()
featured_icon = models.CharField(max_length=1000)
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)
+30
View File
@@ -1,30 +1,60 @@
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
+15
View File
@@ -23,11 +23,26 @@ class CharacterSkinList(generics.ListCreateAPIView):
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
+103 -52
View File
@@ -8,10 +8,10 @@ def validate_image_size(image):
raise ValidationError("File size exceeds 1MB.")
def validate_image_ext(image):
allowed_extensions = ['png']
allowed_extensions = ['png', 'jpg', 'jpeg']
ext = image.name.split('.')[-1].lower()
if ext not in allowed_extensions:
raise ValidationError("Invalid file type. Only PNG allowed.")
raise ValidationError("Invalid file type. Only PNG or JPEG allowed.")
def validate_image(image):
try:
@@ -20,59 +20,89 @@ def validate_image(image):
except Exception:
raise ValidationError("Invalid image file.")
class ContentTheme(SoftDeleteModel):
theme = models.CharField(max_length=255, null=False)
description = models.CharField(max_length=1000)
featured_image = models.ImageField(
max_length=255,
upload_to="uploads/content_themes/",
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.theme
class ContentTopic(SoftDeleteModel):
topic = models.CharField(max_length=255, null=False)
description = models.CharField(max_length=1000)
featured_image = models.ImageField(
max_length=255,
upload_to="uploads/content_topics/",
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
theme = models.ForeignKey(ContentTheme, on_delete=models.CASCADE, null=False)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.topic
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 = [
('Text', 'Teks'),
('Infographic', 'Infografis'),
('Image', 'Gambar'),
('Video', 'Video'),
('Branching Scenario', 'Pilihan Bercabang'),
('Crossword', 'Teka-teki Silang'),
('Drag And Drop', 'Pencocokan'),
('Find the Words', 'Cari Kata'),
('Memory Game', 'Permainan Memori'),
('Personality Quiz', 'Permainan Tebak Sifat'),
('Game Map', 'Peta Permainan'),
('Multiple Choice', 'Pilihan Ganda'),
('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)
slug = models.SlugField(max_length=255, unique=True)
featured_image = models.ImageField(
max_length=255,
@@ -80,20 +110,41 @@ class Content(SoftDeleteModel):
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
theme = models.ForeignKey(ContentTheme, on_delete=models.CASCADE, null=False)
topic = models.ForeignKey(ContentTopic, on_delete=models.CASCADE, null=False)
format = models.CharField(max_length=25, choices=CONTENT_FORMAT_CHOICES)
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)
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
+96 -26
View File
@@ -1,40 +1,110 @@
from django.forms import ImageField
from django.forms import ImageField, FileField
from rest_framework import serializers
from content import models
class ContentThemeSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.ContentTheme
fields = ['id', 'theme', 'description', 'featured_image']
class ContentTopicSerializer(serializers.ModelSerializer):
featured_image = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.ContentTopic
fields = ['id', 'topic', 'description', 'featured_image', 'theme']
class ContentTopicDetailSerializer(serializers.ModelSerializer):
theme = ContentThemeSerializer(read_only=True)
featured_image = ImageField(max_length=255, allow_empty_file=False)
class Meta:
model = models.ContentTopic
fields = ['id', 'topic', 'description', 'featured_image', 'theme']
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', 'content', 'point', 'coin', 'data', 'grades']
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):
theme = ContentThemeSerializer(read_only=True)
topic = ContentTopicDetailSerializer(read_only=True)
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', 'content', 'point', 'coin', 'data', 'grades']
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']
+7 -4
View File
@@ -5,9 +5,12 @@ urlpatterns = [
path('contents/', views.ContentList.as_view()),
path('contents/<int:pk>/', views.ContentDetail.as_view()),
path('themes/', views.ContentThemeList.as_view()),
path('themes/<int:pk>/', views.ContentThemeDetail.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('topics/', views.ContentTopicList.as_view()),
path('topics/<int:pk>/', views.ContentTopicDetail.as_view()),
path('media/', views.ContentMediaList.as_view()),
path('media/<int:pk>/', views.ContentMediaDetail.as_view()),
]
+65 -36
View File
@@ -1,4 +1,5 @@
from rest_framework import generics, filters, parsers
from rest_framework.response import Response
from rest_framework import generics, filters, views
from django_filters.rest_framework import DjangoFilterBackend, FilterSet, CharFilter, ChoiceFilter
from content import models, serializers
@@ -48,42 +49,70 @@ class ContentDetail(generics.RetrieveUpdateDestroyAPIView):
return serializer_class
class ContentThemeList(generics.ListCreateAPIView):
queryset = models.ContentTheme.objects.all()
serializer_class = serializers.ContentThemeSerializer
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 = ['theme']
search_fields = ['name']
ordering_fields = '__all__'
class ContentThemeDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.ContentTheme.objects.all()
serializer_class = serializers.ContentThemeSerializer
class ContentTopicList(generics.ListCreateAPIView):
queryset = models.ContentTopic.objects.all()
serializer_class = serializers.ContentTopicSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
search_fields = ['topic']
filterset_fields = ['theme']
ordering_fields = '__all__'
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.ContentTopicDetailSerializer
return serializer_class
class ContentTopicDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = models.ContentTopic.objects.all()
serializer_class = serializers.ContentTopicSerializer
def get_serializer_class(self):
serializer_class = self.serializer_class
if self.request.method == 'GET':
serializer_class = serializers.ContentTopicDetailSerializer
return serializer_class
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'
@@ -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 %}
+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 "$@"
+56 -22
View File
@@ -11,7 +11,8 @@ https://docs.djangoproject.com/en/5.1/ref/settings/
"""
from pathlib import Path
from decouple import config
from decouple import config, Csv
from datetime import timedelta
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -27,8 +28,7 @@ SECRET_KEY = config('SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = config('DEBUG', default=False, cast=bool)
ALLOWED_HOSTS = []
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=Csv())
# Application definition
@@ -51,6 +51,8 @@ INSTALLED_APPS = [
'core',
'content',
'character',
'entertainment',
'mission',
]
MIDDLEWARE = [
@@ -73,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': [
@@ -128,7 +131,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
TIME_ZONE = config('TIME_ZONE', default='UTC')
USE_I18N = True
@@ -139,17 +142,22 @@ 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:3000",
"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':
@@ -157,29 +165,37 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend'
],
# 'DEFAULT_AUTHENTICATION_CLASSES': (
# 'rest_framework.authentication.BasicAuthentication',
# 'rest_framework.authentication.SessionAuthentication',
# 'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
# ),
# 'DEFAULT_PERMISSION_CLASSES': (
# 'rest_framework.permissions.IsAuthenticated',
# )
'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 = {
# this is the list of available scopes
'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': '#/activate/{uid}/{token}',
'ACTIVATION_URL': 'auth/activation/{uid}/{token}/',
'SEND_ACTIVATION_EMAIL': True,
'SERIALIZERS': {},
'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 = [
@@ -189,4 +205,22 @@ INTERNAL_IPS = [
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
DATA_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024 # 5MB
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')
+4 -1
View File
@@ -22,12 +22,15 @@ 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)
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)
+5 -5
View File
@@ -1,8 +1,8 @@
# nginx.conf
client_max_body_size 8M;
client_max_body_size 10M;
upstream django_app {
server freekake_api:8000;
server api:8000;
}
server {
@@ -10,16 +10,16 @@ server {
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
client_max_body_size 50M;
location / {
proxy_pass http://web:8000;
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;
}
# OPTIONAL: serve static files if needed
location /static/ {
alias /static/;
}
@@ -3,18 +3,31 @@ 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