From 95d8000b968d6f90831f6e0ea12afea59f4ffa02 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmoghazy <44530157+leimo2011@users.noreply.github.com> Date: Tue, 10 Mar 2026 00:01:10 +0800 Subject: [PATCH] feat(api): Sprint 0 - Django project scaffold, settings, requirements #T-004 --- .env.example | 15 +++ .gitignore | 215 +++------------------------------ apps/__init__.py | 0 apps/categories/__init__.py | 0 apps/categories/admin.py | 10 ++ apps/categories/models.py | 22 ++++ apps/categories/serializers.py | 8 ++ apps/categories/urls.py | 10 ++ apps/categories/views.py | 12 ++ apps/stores/__init__.py | 0 apps/stores/admin.py | 15 +++ apps/stores/models.py | 62 ++++++++++ apps/stores/serializers.py | 50 ++++++++ apps/stores/urls.py | 11 ++ apps/stores/views.py | 45 +++++++ apps/users/__init__.py | 0 apps/users/admin.py | 22 ++++ apps/users/models.py | 61 ++++++++++ apps/users/serializers.py | 52 ++++++++ apps/users/urls.py | 10 ++ apps/users/views.py | 52 ++++++++ liemo/__init__.py | 0 liemo/settings/__init__.py | 0 liemo/settings/base.py | 130 ++++++++++++++++++++ liemo/settings/local.py | 7 ++ liemo/settings/production.py | 9 ++ liemo/urls.py | 18 +++ liemo/wsgi.py | 5 + manage.py | 22 ++++ requirements.txt | 19 +++ 30 files changed, 687 insertions(+), 195 deletions(-) create mode 100644 .env.example create mode 100644 apps/__init__.py create mode 100644 apps/categories/__init__.py create mode 100644 apps/categories/admin.py create mode 100644 apps/categories/models.py create mode 100644 apps/categories/serializers.py create mode 100644 apps/categories/urls.py create mode 100644 apps/categories/views.py create mode 100644 apps/stores/__init__.py create mode 100644 apps/stores/admin.py create mode 100644 apps/stores/models.py create mode 100644 apps/stores/serializers.py create mode 100644 apps/stores/urls.py create mode 100644 apps/stores/views.py create mode 100644 apps/users/__init__.py create mode 100644 apps/users/admin.py create mode 100644 apps/users/models.py create mode 100644 apps/users/serializers.py create mode 100644 apps/users/urls.py create mode 100644 apps/users/views.py create mode 100644 liemo/__init__.py create mode 100644 liemo/settings/__init__.py create mode 100644 liemo/settings/base.py create mode 100644 liemo/settings/local.py create mode 100644 liemo/settings/production.py create mode 100644 liemo/urls.py create mode 100644 liemo/wsgi.py create mode 100644 manage.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8370b86 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Django +DEBUG=True +SECRET_KEY=your-secret-key-here +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Database +DB_NAME=liemo_db +DB_USER=your_mac_username +DB_PASSWORD= +DB_HOST=localhost +DB_PORT=5432 + +# JWT +JWT_ACCESS_TOKEN_LIFETIME_MINUTES=60 +JWT_REFRESH_TOKEN_LIFETIME_DAYS=7 diff --git a/.gitignore b/.gitignore index b7faf40..27a3b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,207 +1,32 @@ -# Byte-compiled / optimized / DLL files +# Python __pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging +*.py[cod] +*.pyo +*.pyd .Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ +*.pyc -# Translations -*.mo -*.pot +# Virtual Environment +venv/ +env/ +.venv/ -# Django stuff: +# Django *.log local_settings.py db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ +media/ +staticfiles/ -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments +# Environment .env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ -# PyPI configuration file -.pypirc +# macOS +.DS_Store -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore +# VS Code +.vscode/ -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ +# IDE +*.swp +*.swo diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/categories/__init__.py b/apps/categories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/categories/admin.py b/apps/categories/admin.py new file mode 100644 index 0000000..f28fda0 --- /dev/null +++ b/apps/categories/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from .models import Category + + +@admin.register(Category) +class CategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'slug', 'parent', 'is_active'] + list_filter = ['is_active'] + search_fields = ['name', 'slug'] + prepopulated_fields = {'slug': ('name',)} diff --git a/apps/categories/models.py b/apps/categories/models.py new file mode 100644 index 0000000..256ccb6 --- /dev/null +++ b/apps/categories/models.py @@ -0,0 +1,22 @@ +from django.db import models + + +class Category(models.Model): + name = models.CharField(max_length=100) + slug = models.SlugField(unique=True) + icon_url = models.URLField(blank=True) + parent = models.ForeignKey( + 'self', null=True, blank=True, + on_delete=models.SET_NULL, + related_name='children' + ) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'categories' + verbose_name_plural = 'Categories' + ordering = ['name'] + + def __str__(self): + return self.name diff --git a/apps/categories/serializers.py b/apps/categories/serializers.py new file mode 100644 index 0000000..57f034f --- /dev/null +++ b/apps/categories/serializers.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from .models import Category + + +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ['id', 'name', 'slug', 'icon_url', 'parent', 'is_active'] diff --git a/apps/categories/urls.py b/apps/categories/urls.py new file mode 100644 index 0000000..6638e5f --- /dev/null +++ b/apps/categories/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import CategoryViewSet + +router = DefaultRouter() +router.register(r'', CategoryViewSet, basename='category') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/categories/views.py b/apps/categories/views.py new file mode 100644 index 0000000..00ee647 --- /dev/null +++ b/apps/categories/views.py @@ -0,0 +1,12 @@ +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticatedOrReadOnly +from drf_spectacular.utils import extend_schema +from .models import Category +from .serializers import CategorySerializer + + +@extend_schema(tags=['Categories']) +class CategoryViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Category.objects.filter(is_active=True) + serializer_class = CategorySerializer + permission_classes = [IsAuthenticatedOrReadOnly] diff --git a/apps/stores/__init__.py b/apps/stores/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/stores/admin.py b/apps/stores/admin.py new file mode 100644 index 0000000..a9de557 --- /dev/null +++ b/apps/stores/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from .models import Store, StoreLink + + +class StoreLinkInline(admin.TabularInline): + model = StoreLink + extra = 1 + + +@admin.register(Store) +class StoreAdmin(admin.ModelAdmin): + list_display = ['name', 'admin', 'status', 'is_live', 'is_verified', 'avg_rating'] + list_filter = ['status', 'is_live', 'is_verified'] + search_fields = ['name', 'admin__email'] + inlines = [StoreLinkInline] diff --git a/apps/stores/models.py b/apps/stores/models.py new file mode 100644 index 0000000..e99fe07 --- /dev/null +++ b/apps/stores/models.py @@ -0,0 +1,62 @@ +from django.db import models +from django.utils.text import slugify +from apps.users.models import User +from apps.categories.models import Category + + +class Store(models.Model): + class Status(models.TextChoices): + ACTIVE = 'ACTIVE', 'Active' + PENDING = 'PENDING', 'Pending' + SUSPENDED = 'SUSPENDED', 'Suspended' + + admin = models.OneToOneField(User, on_delete=models.CASCADE, related_name='store') + name = models.CharField(max_length=200) + slug = models.SlugField(unique=True, blank=True) + bio = models.TextField(blank=True) + logo_url = models.URLField(blank=True) + is_verified = models.BooleanField(default=False) + is_live = models.BooleanField(default=False) + avg_rating = models.DecimalField(max_digits=3, decimal_places=2, default=0.00) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + categories = models.ManyToManyField(Category, blank=True, related_name='stores') + follower_count = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'stores' + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + + def __str__(self): + return self.name + + +class StoreLink(models.Model): + class Platform(models.TextChoices): + TIKTOK = 'TIKTOK', 'TikTok' + FACEBOOK = 'FACEBOOK', 'Facebook' + INSTAGRAM = 'INSTAGRAM', 'Instagram' + SHOPEE = 'SHOPEE', 'Shopee' + YOUTUBE = 'YOUTUBE', 'YouTube' + TWITTER = 'TWITTER', 'Twitter/X' + WEBSITE = 'WEBSITE', 'Website' + + store = models.ForeignKey(Store, on_delete=models.CASCADE, related_name='links') + platform = models.CharField(max_length=20, choices=Platform.choices) + url = models.URLField() + label = models.CharField(max_length=100, blank=True) + click_count = models.PositiveIntegerField(default=0) + display_order = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'store_links' + ordering = ['display_order'] + + def __str__(self): + return f'{self.store.name} - {self.platform}' diff --git a/apps/stores/serializers.py b/apps/stores/serializers.py new file mode 100644 index 0000000..a650e0c --- /dev/null +++ b/apps/stores/serializers.py @@ -0,0 +1,50 @@ +from rest_framework import serializers +from .models import Store, StoreLink +from apps.categories.serializers import CategorySerializer + + +class StoreLinkSerializer(serializers.ModelSerializer): + class Meta: + model = StoreLink + fields = ['id', 'platform', 'url', 'label', 'click_count', 'display_order'] + read_only_fields = ['id', 'click_count'] + + +class StoreSerializer(serializers.ModelSerializer): + links = StoreLinkSerializer(many=True, read_only=True) + categories = CategorySerializer(many=True, read_only=True) + category_ids = serializers.PrimaryKeyRelatedField( + many=True, write_only=True, + queryset=__import__('apps.categories.models', fromlist=['Category']).Category.objects.all(), + source='categories' + ) + + class Meta: + model = Store + fields = [ + 'id', 'name', 'slug', 'bio', 'logo_url', + 'is_verified', 'is_live', 'avg_rating', 'status', + 'categories', 'category_ids', 'links', + 'follower_count', 'created_at' + ] + read_only_fields = ['id', 'slug', 'is_verified', 'avg_rating', 'follower_count', 'created_at'] + + +class StoreCreateSerializer(serializers.ModelSerializer): + category_ids = serializers.PrimaryKeyRelatedField( + many=True, write_only=True, + queryset=__import__('apps.categories.models', fromlist=['Category']).Category.objects.all(), + source='categories', + required=False + ) + + class Meta: + model = Store + fields = ['name', 'bio', 'logo_url', 'category_ids'] + + def create(self, validated_data): + categories = validated_data.pop('categories', []) + store = Store.objects.create(**validated_data) + if categories: + store.categories.set(categories) + return store diff --git a/apps/stores/urls.py b/apps/stores/urls.py new file mode 100644 index 0000000..ffd3ded --- /dev/null +++ b/apps/stores/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import StoreViewSet, StoreLinkViewSet + +router = DefaultRouter() +router.register(r'', StoreViewSet, basename='store') +router.register(r'links', StoreLinkViewSet, basename='store-link') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/apps/stores/views.py b/apps/stores/views.py new file mode 100644 index 0000000..faed641 --- /dev/null +++ b/apps/stores/views.py @@ -0,0 +1,45 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly +from drf_spectacular.utils import extend_schema +from .models import Store, StoreLink +from .serializers import StoreSerializer, StoreCreateSerializer, StoreLinkSerializer + + +@extend_schema(tags=['Stores']) +class StoreViewSet(viewsets.ModelViewSet): + queryset = Store.objects.filter(status='ACTIVE').prefetch_related('links', 'categories') + permission_classes = [IsAuthenticatedOrReadOnly] + + def get_serializer_class(self): + if self.action == 'create': + return StoreCreateSerializer + return StoreSerializer + + def perform_create(self, serializer): + serializer.save(admin=self.request.user, status='ACTIVE') + + @extend_schema(tags=['Stores']) + @action(detail=True, methods=['patch'], url_path='live-status') + def live_status(self, request, pk=None): + store = self.get_object() + if store.admin != request.user: + return Response({'error': 'Not authorized'}, status=status.HTTP_403_FORBIDDEN) + is_live = request.data.get('is_live', False) + store.is_live = is_live + store.save(update_fields=['is_live']) + return Response({'is_live': store.is_live}) + + +@extend_schema(tags=['Store Links']) +class StoreLinkViewSet(viewsets.ModelViewSet): + serializer_class = StoreLinkSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return StoreLink.objects.filter(store__admin=self.request.user) + + def perform_create(self, serializer): + store = Store.objects.get(admin=self.request.user) + serializer.save(store=store) diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..5c79610 --- /dev/null +++ b/apps/users/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from .models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + list_display = ['email', 'role', 'is_active', 'is_verified', 'created_at'] + list_filter = ['role', 'is_active', 'is_verified'] + search_fields = ['email', 'display_name'] + ordering = ['-created_at'] + fieldsets = ( + (None, {'fields': ('email', 'password')}), + ('Profile', {'fields': ('display_name', 'avatar_url', 'preferred_language', 'country')}), + ('Permissions', {'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'is_verified')}), + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2', 'role'), + }), + ) diff --git a/apps/users/models.py b/apps/users/models.py new file mode 100644 index 0000000..ca9d836 --- /dev/null +++ b/apps/users/models.py @@ -0,0 +1,61 @@ +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin +from django.db import models + + +class UserManager(BaseUserManager): + def create_user(self, email, password=None, **extra_fields): + if not email: + raise ValueError('Email is required') + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password=None, **extra_fields): + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault('role', User.Role.SUPERADMIN) + return self.create_user(email, password, **extra_fields) + + +class User(AbstractBaseUser, PermissionsMixin): + class Role(models.TextChoices): + USER = 'USER', 'End User' + ADMIN = 'ADMIN', 'Store Admin' + SUPERADMIN = 'SUPERADMIN', 'Super Admin' + + email = models.EmailField(unique=True) + role = models.CharField(max_length=20, choices=Role.choices, default=Role.USER) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + is_verified = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # Profile fields + display_name = models.CharField(max_length=100, blank=True) + avatar_url = models.URLField(blank=True) + preferred_language = models.CharField(max_length=10, default='en') + country = models.CharField(max_length=10, blank=True) + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = [] + + objects = UserManager() + + class Meta: + db_table = 'users' + verbose_name = 'User' + verbose_name_plural = 'Users' + + def __str__(self): + return self.email + + @property + def is_store_admin(self): + return self.role == self.Role.ADMIN + + @property + def is_super_admin(self): + return self.role == self.Role.SUPERADMIN diff --git a/apps/users/serializers.py b/apps/users/serializers.py new file mode 100644 index 0000000..1b4637e --- /dev/null +++ b/apps/users/serializers.py @@ -0,0 +1,52 @@ +from rest_framework import serializers +from rest_framework_simplejwt.tokens import RefreshToken +from django.contrib.auth import authenticate +from .models import User + + +class RegisterSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, min_length=8) + password2 = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ['email', 'password', 'password2', 'display_name', 'role'] + extra_kwargs = {'role': {'required': False}} + + def validate(self, attrs): + if attrs['password'] != attrs['password2']: + raise serializers.ValidationError({'password': 'Passwords do not match'}) + return attrs + + def create(self, validated_data): + validated_data.pop('password2') + user = User.objects.create_user(**validated_data) + return user + + +class LoginSerializer(serializers.Serializer): + email = serializers.EmailField() + password = serializers.CharField(write_only=True) + + def validate(self, attrs): + user = authenticate(username=attrs['email'], password=attrs['password']) + if not user: + raise serializers.ValidationError('Invalid email or password') + if not user.is_active: + raise serializers.ValidationError('Account is disabled') + attrs['user'] = user + return attrs + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['id', 'email', 'role', 'display_name', 'avatar_url', + 'preferred_language', 'country', 'is_verified', 'created_at'] + read_only_fields = ['id', 'role', 'is_verified', 'created_at'] + + +class TokenSerializer(serializers.Serializer): + access = serializers.CharField() + refresh = serializers.CharField() + user = UserSerializer() diff --git a/apps/users/urls.py b/apps/users/urls.py new file mode 100644 index 0000000..46a2284 --- /dev/null +++ b/apps/users/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView +from .views import RegisterView, LoginView, ProfileView + +urlpatterns = [ + path('register/', RegisterView.as_view(), name='register'), + path('login/', LoginView.as_view(), name='login'), + path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'), + path('profile/', ProfileView.as_view(), name='profile'), +] diff --git a/apps/users/views.py b/apps/users/views.py new file mode 100644 index 0000000..9835d62 --- /dev/null +++ b/apps/users/views.py @@ -0,0 +1,52 @@ +from rest_framework import status, generics +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.tokens import RefreshToken +from drf_spectacular.utils import extend_schema +from .models import User +from .serializers import RegisterSerializer, LoginSerializer, UserSerializer + + +class RegisterView(APIView): + permission_classes = [AllowAny] + + @extend_schema(request=RegisterSerializer, tags=['Auth']) + def post(self, request): + serializer = RegisterSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.save() + refresh = RefreshToken.for_user(user) + return Response({ + 'message': 'Registration successful', + 'user': UserSerializer(user).data, + 'access': str(refresh.access_token), + 'refresh': str(refresh), + }, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LoginView(APIView): + permission_classes = [AllowAny] + + @extend_schema(request=LoginSerializer, tags=['Auth']) + def post(self, request): + serializer = LoginSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.validated_data['user'] + refresh = RefreshToken.for_user(user) + return Response({ + 'user': UserSerializer(user).data, + 'access': str(refresh.access_token), + 'refresh': str(refresh), + }) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProfileView(generics.RetrieveUpdateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = UserSerializer + + @extend_schema(tags=['Auth']) + def get_object(self): + return self.request.user diff --git a/liemo/__init__.py b/liemo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/liemo/settings/__init__.py b/liemo/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/liemo/settings/base.py b/liemo/settings/base.py new file mode 100644 index 0000000..e264a74 --- /dev/null +++ b/liemo/settings/base.py @@ -0,0 +1,130 @@ +import os +from pathlib import Path +from datetime import timedelta +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = os.getenv('SECRET_KEY', 'django-insecure-change-this-in-production') + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # Third party + 'rest_framework', + 'rest_framework_simplejwt', + 'corsheaders', + 'drf_spectacular', + + # Liemo apps + 'apps.users', + 'apps.stores', + 'apps.categories', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'liemo.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'liemo.wsgi.application' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.getenv('DB_NAME', 'liemo_db'), + 'USER': os.getenv('DB_USER', ''), + 'PASSWORD': os.getenv('DB_PASSWORD', ''), + 'HOST': os.getenv('DB_HOST', 'localhost'), + 'PORT': os.getenv('DB_PORT', '5432'), + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +AUTH_USER_MODEL = 'users.User' + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# DRF +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, +} + +# JWT +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=int(os.getenv('JWT_ACCESS_TOKEN_LIFETIME_MINUTES', 60))), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=int(os.getenv('JWT_REFRESH_TOKEN_LIFETIME_DAYS', 7))), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': False, + 'AUTH_HEADER_TYPES': ('Bearer',), +} + +# Swagger +SPECTACULAR_SETTINGS = { + 'TITLE': 'Liemo API', + 'DESCRIPTION': 'Live Store Link Aggregator Platform API', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} + +# CORS +CORS_ALLOWED_ORIGINS = [ + 'http://localhost:3000', + 'http://localhost:5173', +] diff --git a/liemo/settings/local.py b/liemo/settings/local.py new file mode 100644 index 0000000..c117d5b --- /dev/null +++ b/liemo/settings/local.py @@ -0,0 +1,7 @@ +from .base import * + +DEBUG = True + +ALLOWED_HOSTS = ['localhost', '127.0.0.1'] + +CORS_ALLOW_ALL_ORIGINS = True diff --git a/liemo/settings/production.py b/liemo/settings/production.py new file mode 100644 index 0000000..d7ed678 --- /dev/null +++ b/liemo/settings/production.py @@ -0,0 +1,9 @@ +from .base import * + +DEBUG = False + +ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', '').split(',') + +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +X_FRAME_OPTIONS = 'DENY' diff --git a/liemo/urls.py b/liemo/urls.py new file mode 100644 index 0000000..b83fbe5 --- /dev/null +++ b/liemo/urls.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + +urlpatterns = [ + path('admin/', admin.site.urls), + + # API + path('api/auth/', include('apps.users.urls')), + path('api/stores/', include('apps.stores.urls')), + path('api/categories/', include('apps.categories.urls')), + + # Swagger Docs + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/liemo/wsgi.py b/liemo/wsgi.py new file mode 100644 index 0000000..9bdbebd --- /dev/null +++ b/liemo/wsgi.py @@ -0,0 +1,5 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'liemo.settings.local') +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..4233288 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'liemo.settings.local') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2cce991 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +# Django Core +Django==5.0.6 +djangorestframework==3.15.2 +djangorestframework-simplejwt==5.3.1 +django-cors-headers==4.4.0 +drf-spectacular==0.27.2 + +# Database +psycopg2-binary==2.9.9 + +# Auth +social-auth-app-django==5.4.1 + +# Environment +python-dotenv==1.0.1 + +# Utilities +Pillow==10.4.0 +gunicorn==22.0.0