Compare commits

..
23 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
35 changed files with 1046 additions and 48 deletions
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
*.pyc
*.pyo
*.pyd
*.sqlite3
env/
venv/
*.env
*.log
media/
staticfiles/
node_modules/
+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__/
+23 -14
View File
@@ -1,28 +1,37 @@
# 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
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=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/*
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/*
# Install Python dependencies
COPY requirements.txt /app/
RUN pip install --upgrade pip && pip install -r requirements.txt
RUN pip install --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
# Copy project
COPY . /app/
COPY . .
WORKDIR /app
# Run migrations & collect static in production (optional)
# CMD ["python", "manage.py", "migrate"] && ["python", "manage.py", "collectstatic", "--noinput"]
# 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"]
+18
View File
@@ -10,6 +10,15 @@ class CharacterSerializer(serializers.ModelSerializer):
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)
@@ -18,6 +27,15 @@ class CharacterSkinSerializer(serializers.ModelSerializer):
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)
+28 -10
View File
@@ -21,8 +21,8 @@ def validate_image(image):
raise ValidationError("Invalid image file.")
def validate_file_size(image):
if image.size > 10 * 1024 * 1024:
raise ValidationError("File size exceeds 10MB.")
if image.size > 1 * 1024 * 1024:
raise ValidationError("File size exceeds 1MB.")
class Content(SoftDeleteModel):
@@ -52,7 +52,7 @@ class Content(SoftDeleteModel):
('olahraga', 'Olahraga'),
('waktu-tidur', 'Waktu Tidur'),
('kesehatan-mental', 'Kesehatan Mental'),
('pendidikan-seksua', 'Pendidikan Seksual'),
('pendidikan-seksual', 'Pendidikan Seksual'),
('rokok-alkohol-narkoba', 'Bahaya Merokok dan Alkohol serta Pencegahan Narkoba'),
('sistem-imun', 'Sistem Imun')
],
@@ -69,7 +69,7 @@ class Content(SoftDeleteModel):
],
'keselamatan': [
('lalu-lintas', 'Keselamatan Berlalu Lintas'),
('pertolongan-pertama', 'Pertolongan Petrtama'),
('pertolongan-pertama', 'Pertolongan Pertama'),
('bencana', 'Kesiapsiagaan Bencana')
]
}
@@ -87,10 +87,22 @@ class Content(SoftDeleteModel):
('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,
@@ -98,24 +110,30 @@ class Content(SoftDeleteModel):
validators=[validate_image_size, validate_image_ext, validate_image],
null=False, blank=False)
theme = models.CharField(max_length=25, choices=CONTENT_THEME_CHOICES)
topic = models.CharField(max_length=25)
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
@@ -124,7 +142,7 @@ class Media(SoftDeleteModel):
name = models.CharField(max_length=255, null=False)
media = models.FileField(
max_length=255,
upload_to="uploads/medias/",
upload_to="uploads/media/contents/",
validators=[validate_file_size],
null=False, blank=False)
+76 -3
View File
@@ -2,20 +2,74 @@ 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']
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']
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')
@@ -29,6 +83,25 @@ class ContentDetailSerializer(serializers.ModelSerializer):
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)
+2
View File
@@ -8,6 +8,8 @@ urlpatterns = [
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()),
+20
View File
@@ -96,3 +96,23 @@ class ContentMediaDetail(generics.RetrieveUpdateDestroyAPIView):
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'
@@ -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 %}
+5 -3
View File
@@ -1,14 +1,16 @@
services:
webapp:
api:
build:
context: .
dockerfile: ./Dockerfile
# command: gunicorn freekake_api.wsgi:application --bind 0.0.0.0:8000
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:
@@ -24,7 +26,7 @@ services:
- static_volume:/static
- ./media:/media
depends_on:
- webapp
- api
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 "$@"
+46 -14
View File
@@ -12,6 +12,7 @@ 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'.
@@ -50,6 +51,8 @@ INSTALLED_APPS = [
'core',
'content',
'character',
'entertainment',
'mission',
]
MIDDLEWARE = [
@@ -72,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': [
@@ -127,7 +131,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
TIME_ZONE = config('TIME_ZONE', default='UTC')
USE_I18N = True
@@ -153,35 +157,45 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
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':
'freekake_api.pagination.CustomPagination',
'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 = [
@@ -191,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)
+3 -2
View File
@@ -2,7 +2,7 @@
client_max_body_size 10M;
upstream django_app {
server webapp:8000;
server api:8000;
}
server {
@@ -14,11 +14,12 @@ server {
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;
}
# OPTIONAL: serve static files if needed
location /static/ {
alias /static/;
}