diff --git a/.gitignore b/.gitignore index 6973ddf..ee11ee2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ BASIC_COMMANDS.md /DJANGO_GUIDE phase1-api-starter.md +/pak_wheels/users/__pycache__ +/pak_wheels/users/migrations/__pycache__ +/media/listings +/pak_wheels/__pycache__ +PHASE1_REVIEW_GUIDE.md +PHASE1_REVIEW_GUIDE.md +*.pyc +*.pyc +/pak_wheels/__pycache__ +pak_wheels/__pycache__/urls.cpython-314.pyc +deep_dive_api_dry_run.md diff --git a/db.sqlite3 b/db.sqlite3 index a309275..500c104 100644 Binary files a/db.sqlite3 and b/db.sqlite3 differ diff --git a/pak_wheels/.DS_Store b/pak_wheels/.DS_Store index 99d5f22..4de642a 100644 Binary files a/pak_wheels/.DS_Store and b/pak_wheels/.DS_Store differ diff --git a/pak_wheels/__pycache__/settings.cpython-314.pyc b/pak_wheels/__pycache__/settings.cpython-314.pyc index 0b6a522..f811c4c 100644 Binary files a/pak_wheels/__pycache__/settings.cpython-314.pyc and b/pak_wheels/__pycache__/settings.cpython-314.pyc differ diff --git a/pak_wheels/listings/__init__.py b/pak_wheels/listings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pak_wheels/listings/admin.py b/pak_wheels/listings/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/pak_wheels/listings/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/pak_wheels/listings/apps.py b/pak_wheels/listings/apps.py new file mode 100644 index 0000000..68b52e6 --- /dev/null +++ b/pak_wheels/listings/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ListingsConfig(AppConfig): + name = 'pak_wheels.listings' diff --git a/pak_wheels/listings/migrations/0001_initial.py b/pak_wheels/listings/migrations/0001_initial.py new file mode 100644 index 0000000..ee1fb4b --- /dev/null +++ b/pak_wheels/listings/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 6.0.4 on 2026-05-07 13:01 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CarListing', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('brand', models.CharField(max_length=100)), + ('model', models.CharField(max_length=100)), + ('year', models.PositiveIntegerField()), + ('price', models.DecimalField(decimal_places=2, max_digits=12)), + ('city', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('seller', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='listings', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Listing', + 'verbose_name_plural': 'Listings', + 'ordering': ['-created'], + }, + ), + migrations.CreateModel( + name='CarImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('image', models.ImageField(upload_to='listings/')), + ('is_primary', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('listing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='listings.carlisting')), + ], + ), + ] diff --git a/pak_wheels/listings/migrations/__init__.py b/pak_wheels/listings/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pak_wheels/listings/models.py b/pak_wheels/listings/models.py new file mode 100644 index 0000000..f33f442 --- /dev/null +++ b/pak_wheels/listings/models.py @@ -0,0 +1,46 @@ +from django.db import models +from django.conf import settings + +# Create your models here. +class CarListing(models.Model): + seller = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='listings') + title = models.CharField(max_length=255) + brand = models.CharField(max_length=100) + model = models.CharField(max_length=100) + year = models.PositiveIntegerField() + price = models.DecimalField(max_digits=12, decimal_places=2) + city = models.CharField(max_length=100) + description = models.TextField(blank=True) + + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Listing' + verbose_name_plural = 'Listings' + ordering = ['-created'] + + def __str__(self): + return f"{self.year} {self.brand} {self.model}" + + + +class CarImage(models.Model): + """Images attached to a listing.""" + listing = models.ForeignKey( + CarListing, + on_delete=models.CASCADE, + related_name='images' + ) + image = models.ImageField(upload_to='listings/') + is_primary = models.BooleanField(default=False) + created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Image for {self.listing.title}" + + def save(self, *args, **kwargs): + if self.is_primary: + # Set all other images for this listing to NOT primary + CarImage.objects.filter(listing=self.listing).update(is_primary=False) + super().save(*args, **kwargs) \ No newline at end of file diff --git a/pak_wheels/listings/serializers.py b/pak_wheels/listings/serializers.py new file mode 100644 index 0000000..47e0e16 --- /dev/null +++ b/pak_wheels/listings/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers +from .models import CarListing, CarImage + + +class CarImageSerializer(serializers.ModelSerializer): + class Meta: + model = CarImage + fields = ('id', 'image', 'is_primary', 'created') + read_only_fields = ('id', 'created') + +class CarListingSerializer(serializers.ModelSerializer): + """For listing details — includes nested images and seller info.""" + images = CarImageSerializer(many=True, read_only=True) + seller_name = serializers.CharField(source='seller.full_name', read_only=True) + seller_phone = serializers.CharField(source='seller.phone', read_only=True) + class Meta: + model = CarListing + fields = ( + 'id', 'seller', 'seller_name', 'seller_phone', + 'title', 'brand', 'model', 'year', 'price', 'city', + 'description', 'images', 'created', 'modified' + ) + read_only_fields = ('id', 'seller', 'created', 'modified') + +class CarListingCreateSerializer(serializers.ModelSerializer): + """For creating/updating a listing — simpler payload.""" + class Meta: + model = CarListing + fields = ('id', 'title', 'brand', 'model', 'year', 'price', 'city', 'description') \ No newline at end of file diff --git a/pak_wheels/listings/tests.py b/pak_wheels/listings/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/pak_wheels/listings/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/pak_wheels/listings/urls.py b/pak_wheels/listings/urls.py new file mode 100644 index 0000000..d951076 --- /dev/null +++ b/pak_wheels/listings/urls.py @@ -0,0 +1,23 @@ +from django.urls import path +from .views import ( + CarListCreateView, + CarListingDetailView, + MyListingsView, + CarImageUploadView, + CarImageDeleteView, +) + +urlpatterns = [ + # List and Create Car Ads + path('', CarListCreateView.as_view(), name='car-list-create'), + + # User's own listings + path('me/', MyListingsView.as_view(), name='my-listings'), + + # Detail, Update, Delete specific listing + path('/', CarListingDetailView.as_view(), name='car-detail'), + + # Images + path('/images/', CarImageUploadView.as_view(), name='image-upload'), + path('images//', CarImageDeleteView.as_view(), name='image-delete'), +] diff --git a/pak_wheels/listings/views.py b/pak_wheels/listings/views.py new file mode 100644 index 0000000..c35c5ac --- /dev/null +++ b/pak_wheels/listings/views.py @@ -0,0 +1,71 @@ +from rest_framework import generics, status, permissions +from rest_framework.parsers import MultiPartParser, FormParser +from django.shortcuts import get_object_or_404 +from rest_framework.exceptions import PermissionDenied +from rest_framework.views import APIView +from .models import CarListing, CarImage +from .serializers import ( + CarListingSerializer, + CarListingCreateSerializer, + CarImageSerializer, +) + + +class IsOwnerOrReadOnly(permissions.BasePermission): + """Only the seller can edit/delete their own listing.""" + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + return obj.seller == request.user + + + +class CarListCreateView(generics.ListCreateAPIView): + queryset = CarListing.objects.all() + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + + def get_serializer_class(self): + if self.request.method == "POST": + return CarListingCreateSerializer + return CarListingSerializer + + def perform_create(self, serializer): + serializer.save(seller=self.request.user) + +class CarListingDetailView(generics.RetrieveUpdateDestroyAPIView): + queryset = CarListing.objects.all() + serializer_class = CarListingSerializer + permission_classes = [IsOwnerOrReadOnly] + + +class MyListingsView(generics.ListAPIView): + serializer_class = CarListingSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return CarListing.objects.filter(seller=self.request.user) + +class CarImageUploadView(generics.CreateAPIView): + serializer_class = CarImageSerializer + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def perform_create(self, serializer): + listing_id = self.kwargs["listing_id"] + + listing = get_object_or_404(CarListing, id=listing_id) + + if listing.seller != self.request.user: + raise PermissionDenied("You don't own this listing") + + serializer.save(listing=listing) + +class CarImageDeleteView(generics.DestroyAPIView): + queryset = CarImage.objects.all() + serializer_class = CarImageSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_destroy(self, instance): + if instance.listing.seller != self.request.user: + raise PermissionDenied("You don't own this listing") + instance.delete() \ No newline at end of file diff --git a/pak_wheels/settings.py b/pak_wheels/settings.py index ce60467..93f3fbf 100644 --- a/pak_wheels/settings.py +++ b/pak_wheels/settings.py @@ -11,6 +11,7 @@ """ from pathlib import Path +from datetime import timedelta # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -47,12 +48,14 @@ ] CUSTOM_APPS = [ - + 'pak_wheels.users', + 'pak_wheels.listings', ] INSTALLED_APPS = DEFAULT_APPS + THIRD_PARTY_APPS + CUSTOM_APPS MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -62,6 +65,7 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +AUTH_USER_MODEL = 'users.User' ROOT_URLCONF = 'pak_wheels.urls' @@ -80,6 +84,16 @@ }, ] +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, +} WSGI_APPLICATION = 'pak_wheels.wsgi.application' @@ -95,6 +109,11 @@ } +# JWT settings +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(days=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), +} MEDIA_URL = '/media/' MEDIA_ROOT = BASE_DIR / 'media' diff --git a/pak_wheels/urls.py b/pak_wheels/urls.py index 1f078be..c2c0b7b 100644 --- a/pak_wheels/urls.py +++ b/pak_wheels/urls.py @@ -16,8 +16,16 @@ """ from django.contrib import admin from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static urlpatterns = [ path('admin/', admin.site.urls), - path('', include('pak_wheels.users.urls')), + path('api/auth/', include('pak_wheels.users.urls')), + path('api/listings/', include('pak_wheels.listings.urls')), ] + + +# Serve uploaded images during development +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) \ No newline at end of file diff --git a/pak_wheels/users/.DS_Store b/pak_wheels/users/.DS_Store new file mode 100644 index 0000000..c35d988 Binary files /dev/null and b/pak_wheels/users/.DS_Store differ diff --git a/pak_wheels/users/__init__.py b/pak_wheels/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pak_wheels/users/admin.py b/pak_wheels/users/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/pak_wheels/users/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/pak_wheels/users/apps.py b/pak_wheels/users/apps.py new file mode 100644 index 0000000..46574ae --- /dev/null +++ b/pak_wheels/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = 'pak_wheels.users' diff --git a/pak_wheels/users/migrations/0001_initial.py b/pak_wheels/users/migrations/0001_initial.py new file mode 100644 index 0000000..9c262cf --- /dev/null +++ b/pak_wheels/users/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 6.0.4 on 2026-05-06 03:48 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('email', models.EmailField(max_length=254, unique=True)), + ('full_name', models.CharField(max_length=255)), + ('phone', models.CharField(max_length=20)), + ('city', models.CharField(blank=True, max_length=100)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/pak_wheels/users/migrations/__init__.py b/pak_wheels/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pak_wheels/users/models.py b/pak_wheels/users/models.py new file mode 100644 index 0000000..d76b3bc --- /dev/null +++ b/pak_wheels/users/models.py @@ -0,0 +1,14 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + +class User(AbstractUser): + email = models.EmailField(unique=True) + full_name = models.CharField(max_length=255) + phone = models.CharField(max_length=20) + city = models.CharField(max_length=100, blank=True) + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['username', 'full_name'] + + def __str__(self): + return self.email \ No newline at end of file diff --git a/pak_wheels/users/serializers.py b/pak_wheels/users/serializers.py new file mode 100644 index 0000000..6d64cf8 --- /dev/null +++ b/pak_wheels/users/serializers.py @@ -0,0 +1,41 @@ +from rest_framework import serializers +from django.contrib.auth.password_validation import validate_password +from .models import User +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer + +class UserTokenObtainPairSerializer(TokenObtainPairSerializer): + def validate(self, attrs): + data = super().validate(attrs) + serializer = UserSerializer(self.user).data + data['user'] = serializer + return data + +class UserRegisterationSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, validators=[validate_password]) + + class Meta: + model = User + fields = ['email', 'username', 'password', 'full_name', 'phone', 'city'] + + def validate(self, attrs): + email = attrs.get('email') + if User.objects.filter(email=email).exists(): + raise serializers.ValidationError("User with this email already exists") + return attrs + + def create(self, validated_data): + user = User.objects.create_user( + username=validated_data['username'], + email=validated_data['email'], + password=validated_data['password'], + full_name=validated_data['full_name'], + phone=validated_data['phone'], + city=validated_data.get('city', '') + ) + return user + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('id', 'email', 'username', 'full_name', 'phone', 'city') + read_only_fields = ('id', 'email') \ No newline at end of file diff --git a/pak_wheels/users/tests.py b/pak_wheels/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/pak_wheels/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/pak_wheels/users/urls.py b/pak_wheels/users/urls.py new file mode 100644 index 0000000..21abf0d --- /dev/null +++ b/pak_wheels/users/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from rest_framework_simplejwt.views import ( + TokenRefreshView, +) +from .views import UserRegistrationView, ProfileView, UserTokenObtainPairView + +urlpatterns = [ + path('register/', UserRegistrationView.as_view(), name='register'), + path('login/', UserTokenObtainPairView.as_view(), name='login'), + path('refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('profile/', ProfileView.as_view(), name='profile'), +] \ No newline at end of file diff --git a/pak_wheels/users/views.py b/pak_wheels/users/views.py new file mode 100644 index 0000000..5fe3496 --- /dev/null +++ b/pak_wheels/users/views.py @@ -0,0 +1,20 @@ +from rest_framework import generics +from rest_framework.permissions import AllowAny, IsAuthenticated +from .models import User +from .serializers import UserRegisterationSerializer, UserSerializer, UserTokenObtainPairSerializer +from rest_framework_simplejwt.views import TokenObtainPairView + +class UserRegistrationView(generics.CreateAPIView): + queryset = User.objects.all() + permission_classes = [AllowAny] + serializer_class = UserRegisterationSerializer + +class UserTokenObtainPairView(TokenObtainPairView): + serializer_class = UserTokenObtainPairSerializer + +class ProfileView(generics.RetrieveUpdateAPIView): + permission_classes = [IsAuthenticated] + serializer_class = UserSerializer + + def get_object(self): + return self.request.user \ No newline at end of file