diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..c54c6dcc --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +venv +*.pyc +local_settings.py +**/migrations/** +!**/migrations +!**/migrations/__init__.py \ No newline at end of file diff --git a/selling_tickets/booking/__init__.py b/selling_tickets/booking/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/selling_tickets/booking/admin.py b/selling_tickets/booking/admin.py new file mode 100644 index 00000000..cdc9bfe1 --- /dev/null +++ b/selling_tickets/booking/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from booking.models import Stadium, StadiumPlace, PlaceSeats, Match, Team +# Register your models here. +admin.site.register(Stadium) +admin.site.register(StadiumPlace) +admin.site.register(PlaceSeats) +admin.site.register(Match) +admin.site.register(Team) +admin.site.register(Ticket) +admin.site.register(Invoice) \ No newline at end of file diff --git a/selling_tickets/booking/apps.py b/selling_tickets/booking/apps.py new file mode 100644 index 00000000..47728e88 --- /dev/null +++ b/selling_tickets/booking/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BookingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'booking' diff --git a/selling_tickets/booking/migrations/__init__.py b/selling_tickets/booking/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/selling_tickets/booking/models.py b/selling_tickets/booking/models.py new file mode 100644 index 00000000..173962c9 --- /dev/null +++ b/selling_tickets/booking/models.py @@ -0,0 +1,143 @@ +from django.db import models, transaction +import uuid + +# each stadium is consist of defining statdium and its places and the nuumber of row and seats +# in each place, so in adding stadium action we should define all these models (Stadium, StadiumPlace, PlaceSeats) +class Stadium(models.Model): + name = models.CharField(max_length=50, verbose_name='Stadium Name') + city = models.CharField(max_length=50, blank=True, verbose_name='Stadium City') + address = models.TextField(verbose_name='Stadium Address') + + def __str__(self) -> str: + return self.name + +class StadiumPlace(models.Model): + """ + each stadium has multiple places such as vip, north , south and ... + in this model we define these palces and number of rows in each place + """ + stadium = models.ForeignKey('Stadium', on_delete=models.CASCADE, related_name='places') + name = models.CharField(max_length=50, verbose_name='Place Name', unique=True) + number_of_rows = models.PositiveSmallIntegerField() + + def __str__(self) -> str: + return ("stadium: %s place: %s") % (self.stadium, self.name) + + +class PlaceSeats(models.Model): + """ + each place in stadium has muultiple rows and each row has different number of saets + so we define this model to define this feature + """ + place = models.ForeignKey('StadiumPlace', on_delete=models.CASCADE, related_name='place_seats') + row_number = models.IntegerField() + number_of_seats_per_row = models.PositiveSmallIntegerField() + + def __str__(self) -> str: + return ("%s row_number: %s") % (self.place, self.row_number) + + class Meta: + unique_together = ['place', 'row_number'] + +# in definattion of matches, we need to add teams too, so for adding a new match we should hav att least two teams + +class Team(models.Model): + name = models.CharField(max_length=50, verbose_name='Team Name', unique=True) + + def __str__(self) -> str: + return self.name + + +class Match(models.Model): + stadium = models.ForeignKey('Stadium', on_delete=models.CASCADE, related_name='matches') + host_team = models.ForeignKey('Team', on_delete=models.PROTECT, related_name='host_team') + guest_team = models.ForeignKey('Team', on_delete=models.PROTECT, related_name='guest_team') + start_time = models.DateTimeField() + + def __str__(self) -> str: + return "%s VS %s, Date: %s" % (self.host_team, self.guest_team, self.start_time) + + class Meta: + unique_together = ['stadium', 'start_time'] + + +# by selecting first ticket , we create the invoice for that user +# invoice has many to many relation with ticket model +# so each invoice has multiple tickets and each ticket can assign to many invoice +# before calling the payment method we change the state of tickets to reserved +# at this moment nobody can buy those tickets anymore until payment is done +# reservation of selected tickets only occured that user going to pay the invoice amount + +class Invoice(models.Model): + START = 0 # creation of invoice + IN_PROGGRESS = 1 # user is in payment section + SUCCESS = 2 # payment is successfull + FAILED = 3 # payment is faieled or canceled + INVOICE_STATUSES = [ + (START, 'START'), + (IN_PROGGRESS, 'IN_PROGGRESS'), + (SUCCESS, 'SUCCESS'), + (FAILED, 'FAILED'), + ] + status = models.IntegerField(default=START, choices=INVOICE_STATUSES) + user = models.ForeignKey('user.CustomUser', on_delete=models.CASCADE, related_name='reservations') + creation_date = models.DateTimeField(auto_now_add=True) + payment_date_time = models.DateTimeField(blank=True, null=True) + payment_description = models.TextField(blank=True) + payment_number = models.CharField(max_length=50, blank=True) + + def __str__(self) -> str: + return ("user: %s created_date: %s") % (self.user, self.creation_date) + + def set_in_progress(self): + self.status = Invoice.IN_PROGGRESS + self.save() + + def set_payment_number(self): + self.payment_number = uuid.uuid4().hex[:10].upper() + self.save() + +# after we create Stadium and its places, we can add tickets for each seat in stadium accordiing to each match policy +# sometimes we can not define tickets for all of seats in stadium + +class Ticket(models.Model): + EMPTY = 'EMPTY' #first state of Tticket + RESERVED = 'RESERVED' # user is in payment section and the tickets are reserved for that user + SOLD = 'SOLD' # user pay the invoice amount successfully and get the reservation number + STATUS_CHOICES = [ + (EMPTY, 'EMPTY'), + (RESERVED, 'RESERVED'), + (SOLD, 'SOLD'), + ] + match = models.ForeignKey('Match', on_delete=models.RESTRICT, related_name='tickets') + stadium = models.ForeignKey('Stadium', on_delete=models.RESTRICT, related_name='tickets') + status = models.CharField(default=EMPTY, choices=STATUS_CHOICES, max_length=8) + last_update_time = models.DateTimeField(auto_now=True) + place = models.ForeignKey('StadiumPlace', on_delete=models.CASCADE, related_name='tickets') + row_number = models.PositiveSmallIntegerField() + seat_number = models.PositiveSmallIntegerField() + price= models.DecimalField(max_digits=10, decimal_places=2) + invoices = models.ManyToManyField(Invoice, blank=True, related_name='tickets') + + def __str__(self) -> str: + return "%s, %s row: %s seat: %s" % (self.match, self.place, self.row_number, self.seat_number) + + class AlreadyReserved(Exception): + pass + + def get_queryset(self): + return self.__class__.objects.filter(id=self.id) + + def reserve_ticket(self): + if self.status == Ticket.RESERVED: # if status of ticket is already reserved we raise an error + raise Ticket.AlreadyReserved + with transaction.atomic(): # the action of reservation of ticket is doing by transaction that nobody can get the rows concurrency + ticket = self.get_queryset().select_for_update().get() + if ticket.status == Ticket.RESERVED: + raise Ticket.AlreadyReserved + self._reserve_ticket() + + def _reserve_ticket(self): + self.status = Ticket.RESERVED + self.save() + diff --git a/selling_tickets/booking/serializers.py b/selling_tickets/booking/serializers.py new file mode 100644 index 00000000..f658667e --- /dev/null +++ b/selling_tickets/booking/serializers.py @@ -0,0 +1,120 @@ +from django.db.models import Q +from rest_framework import serializers +from booking.models import Stadium, StadiumPlace, PlaceSeats, Team, Match, Ticket, Invoice + + +class MatchSerializer(serializers.ModelSerializer): + start_time = serializers.DateTimeField(format='%Y-%m-%d %H:%M') + tickets = serializers.HyperlinkedRelatedField(many=True, read_only=True, view_name='ticket_detail') + class Meta: + model = Match + fields = '__all__' + + +class PlaceSeatsSerializer(serializers.ModelSerializer): + class Meta: + model = PlaceSeats + fields = ('id', 'number_of_seats_per_row', 'row_number', 'place',) + + def create(self, validated_data): + place = validated_data.get('place') + place_number_of_rows_limitation = place.number_of_rows + if validated_data.get('row_number') > place_number_of_rows_limitation: # validate that selected row is existed in range of rows limiation + raise serializers.ValidationError("seledted row is not existed for this place") + return super().create(validated_data) + + +class StadiumPlaceSerializer(serializers.ModelSerializer): + place_seats = PlaceSeatsSerializer(many=True, read_only=True) + + class Meta: + model = StadiumPlace + fields = ('id', 'name', 'number_of_rows', 'stadium', 'place_seats',) + + +class StadiumSerializer(serializers.ModelSerializer): + places = StadiumPlaceSerializer(many=True, read_only=True) + + class Meta: + model = Stadium + fields = ('id', 'name', 'city', 'address', 'places', 'matches',) + + +class TeamSerializer(serializers.ModelSerializer): + class Meta: + model = Team + fields = ('id', 'name',) + + +class TicketSerializer(serializers.ModelSerializer): + status = serializers.CharField(read_only=True) + last_update_time = serializers.DateTimeField(format='%Y-%m-%d %H:%M:%S', read_only=True) + + class Meta: + model = Ticket + fields = ('id', 'stadium', 'match', 'status', 'last_update_time', 'row_number', 'seat_number', 'place', 'price') + + def create(self, validated_data): + selected_row_number = validated_data.get('row_number') + place_seats = PlaceSeats.objects.get(row_number=selected_row_number) + number_of_row_seats_limitation = place_seats.number_of_seats_per_row + if validated_data.get('seat_number') > number_of_row_seats_limitation: # validate that the seat number is existed in range of number of seats limitation + raise serializers.ValidationError("seledted seats is not existed for this row") + return super().create(validated_data) + + +class InvoiceSerilizer(serializers.ModelSerializer): + tickets = serializers.PrimaryKeyRelatedField(many=True, queryset=Ticket.objects.filter(~Q(status=Ticket.RESERVED))) + + class Meta: + model = Invoice + fields = ('id', 'tickets') + + def create(self, validated_data): + tickets = validated_data.get('tickets') + for t in tickets: + if t.status == Ticket.RESERVED: + raise serializers.ValidationError("selected ticket %s is reserved" % (t.id)) + return super().create(validated_data) + + + def update(self, instance, validated_data): + tickets = validated_data.get('tickets') + for t in tickets: + if t.status == Ticket.RESERVED: + raise serializers.ValidationError("selected ticket %s is reserved" % (t.id)) + return super().update(instance, validated_data) + + +class RetrieveInvoiceSerilizer(serializers.ModelSerializer): + tickets = TicketSerializer(many=True) + tickets_total_price = serializers.SerializerMethodField() + + class Meta: + model = Invoice + fields = ('id', 'tickets', 'tickets_total_price') + + def get_tickets_total_price(self, obj): + return sum([t.price for t in obj.tickets.all()]) + + +class BuyInvoiceSerilizer(serializers.ModelSerializer): + class Meta: + model = Invoice + fields = ('id',) + + def update(self, instance, validated_data): + # meaning do the payment but before that + # get invoice instance , get invoice tickets, check status of tickets + # if they are not reserved we will change their status to reservered , + # change status of invoice to in progress, generate payment_id and payment_date + # and then call payment method + # payment gateway should call my callback method for alerting the result #TODO: callback method is not implemented + # TODO: we should handle reserverd tickets after 15 min (default duration for payment) in another job (such as celery job) + tickets = instance.tickets.all() + for t in tickets: + t.reserve_ticket() + instance.set_in_progress() + instance.set_payment_number() + + return super().update(instance, validated_data) \ No newline at end of file diff --git a/selling_tickets/booking/tests.py b/selling_tickets/booking/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/selling_tickets/booking/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/selling_tickets/booking/urls.py b/selling_tickets/booking/urls.py new file mode 100644 index 00000000..bdebaffc --- /dev/null +++ b/selling_tickets/booking/urls.py @@ -0,0 +1,24 @@ +from django.urls import path, include +from booking.views import StadiumListCreateAPI, StadiumRetrieveUpdateDestroyAPI, StadiumPlaceListCreateAPI,\ + StadiumPlaceRetrieveUpdateDestroyAPI, PlaceSeatsListCreateAPI, PlaceSeatsRetrieveUpdateDestroyAPI,\ + TeamListCreateAPI, TeamRetrieveUpdateDestroyAPI, MatchListCreateAPI, MatchRetrieveUpdateDestroyAPI,\ + TicketListCreateAPIView, TicketRetrieveUpdateDestroyAPI, CreateInvoiceAPI, RetrieveUpdateInvoiceAPI,\ + BuyInvoiceAPI + +urlpatterns = [ + path('stadium', StadiumListCreateAPI.as_view()), + path('stadium/', StadiumRetrieveUpdateDestroyAPI.as_view(), name='stadium_detail'), + path('stadium-place', StadiumPlaceListCreateAPI.as_view()), + path('stadium-place/', StadiumPlaceRetrieveUpdateDestroyAPI.as_view(), name='stadium_place_detail'), + path('place-seats', PlaceSeatsListCreateAPI.as_view()), + path('place-seats/', PlaceSeatsRetrieveUpdateDestroyAPI.as_view(), name='place_seats_detail'), + path('team', TeamListCreateAPI.as_view()), + path('team/', TeamRetrieveUpdateDestroyAPI.as_view(), name='team_detail'), + path('match', MatchListCreateAPI.as_view()), + path('match/', MatchRetrieveUpdateDestroyAPI.as_view(), name='match_detail'), + path('ticket', TicketListCreateAPIView.as_view()), + path('ticket/', TicketRetrieveUpdateDestroyAPI.as_view(), name='ticket_detail'), + path('invoice', CreateInvoiceAPI.as_view()), + path('invoice/', RetrieveUpdateInvoiceAPI.as_view()), + path('invoice//buy', BuyInvoiceAPI.as_view()), +] diff --git a/selling_tickets/booking/views.py b/selling_tickets/booking/views.py new file mode 100644 index 00000000..c3b54530 --- /dev/null +++ b/selling_tickets/booking/views.py @@ -0,0 +1,118 @@ +from cgitb import reset +from rest_framework import generics +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from booking.models import Stadium, StadiumPlace, PlaceSeats, Match, Team, Ticket, Invoice +from booking.serializers import StadiumSerializer, StadiumPlaceSerializer,\ + PlaceSeatsSerializer, TeamSerializer, MatchSerializer, TicketSerializer, InvoiceSerilizer,\ + RetrieveInvoiceSerilizer, BuyInvoiceSerilizer + + +class StadiumListCreateAPI(generics.ListCreateAPIView): + queryset = Stadium.objects.all() + serializer_class = StadiumSerializer + permission_classes = [IsAuthenticated] + + +class StadiumRetrieveUpdateDestroyAPI(generics.RetrieveUpdateDestroyAPIView): + queryset = Stadium.objects.all() + serializer_class = StadiumSerializer + permission_classes = [IsAuthenticated] + + +class StadiumPlaceListCreateAPI(generics.ListCreateAPIView): + queryset = StadiumPlace.objects.all() + serializer_class = StadiumPlaceSerializer + + +class StadiumPlaceRetrieveUpdateDestroyAPI(generics.RetrieveUpdateDestroyAPIView): + queryset = StadiumPlace.objects.all() + serializer_class = StadiumPlaceSerializer + permission_classes = [IsAuthenticated] + + +class PlaceSeatsListCreateAPI(generics.ListCreateAPIView): + queryset = PlaceSeats.objects.all() + serializer_class = PlaceSeatsSerializer + permission_classes = [IsAuthenticated] + + +class PlaceSeatsRetrieveUpdateDestroyAPI(generics.RetrieveUpdateDestroyAPIView): + queryset = PlaceSeats.objects.all() + serializer_class = PlaceSeatsSerializer + permission_classes = [IsAuthenticated] + + +class TeamListCreateAPI(generics.ListCreateAPIView): + queryset = Team.objects.all() + serializer_class = TeamSerializer + permission_classes = [IsAuthenticated] + + +class TeamRetrieveUpdateDestroyAPI(generics.RetrieveUpdateDestroyAPIView): + queryset = Team.objects.all() + serializer_class = TeamSerializer + permission_classes = [IsAuthenticated] + + +class MatchListCreateAPI(generics.ListCreateAPIView): + queryset = Match.objects.all() + serializer_class = MatchSerializer + permission_classes = [IsAuthenticated] + + +class MatchRetrieveUpdateDestroyAPI(generics.RetrieveUpdateDestroyAPIView): + queryset = Match.objects.all() + serializer_class = MatchSerializer + permission_classes = [IsAuthenticated] + + +class TicketListCreateAPIView(generics.ListCreateAPIView): + queryset = Ticket.objects.all() + serializer_class = TicketSerializer + permission_classes = [IsAuthenticated] + + +class TicketRetrieveUpdateDestroyAPI(generics.RetrieveUpdateDestroyAPIView): + queryset = Ticket.objects.all() + serializer_class = TicketSerializer + permission_classes = [IsAuthenticated] + + +class CreateInvoiceAPI(generics.CreateAPIView): + queryset = Invoice.objects.all() + serializer_class = InvoiceSerilizer + permission_classes = [IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def create(self, request, *args, **kwargs): + print(request.data) + return super().create(request, *args, **kwargs) + + +class RetrieveUpdateInvoiceAPI(generics.RetrieveUpdateAPIView): + queryset = Invoice.objects.all() + serializer_class = InvoiceSerilizer + permission_classes = [IsAuthenticated] + serializer_classes = { + 'GET': RetrieveInvoiceSerilizer, + 'PUT': InvoiceSerilizer + } + def get_serializer_class(self): + if self.request.method == 'GET': + return self.serializer_classes.get(self.request.method) + return self.serializer_classes.get(self.request.method) + + +class BuyInvoiceAPI(generics.UpdateAPIView): + """ + + """ + queryset = Invoice.objects.all() + serializer_class = BuyInvoiceSerilizer + permission_classes = [IsAuthenticated] + + def perform_update(self, serializer): + serializer.save(user=self.request.user) diff --git a/selling_tickets/manage.py b/selling_tickets/manage.py new file mode 100755 index 00000000..ad982162 --- /dev/null +++ b/selling_tickets/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', 'selling_tickets.settings') + 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/selling_tickets/selling_tickets/__init__.py b/selling_tickets/selling_tickets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/selling_tickets/selling_tickets/asgi.py b/selling_tickets/selling_tickets/asgi.py new file mode 100644 index 00000000..776c730d --- /dev/null +++ b/selling_tickets/selling_tickets/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for selling_tickets project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'selling_tickets.settings') + +application = get_asgi_application() diff --git a/selling_tickets/selling_tickets/settings.py b/selling_tickets/selling_tickets/settings.py new file mode 100644 index 00000000..4ee6d8d2 --- /dev/null +++ b/selling_tickets/selling_tickets/settings.py @@ -0,0 +1,137 @@ +""" +Django settings for selling_tickets project. + +Generated by 'django-admin startproject' using Django 4.1.3. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-#n2x4&2%pm33%j8l#a-#jnrm-c149psn(n6w#%bgm)z&1_my)m' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'knox', + 'user', + 'booking' +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'selling_tickets.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 = 'selling_tickets.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'selling_tickets', + 'USER': 'admin', + 'PASSWORD': 'admin', + 'HOST': '127.0.0.1', + 'PORT': '5432', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +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', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ('knox.auth.TokenAuthentication',), + } + +AUTH_USER_MODEL = 'user.CustomUser' \ No newline at end of file diff --git a/selling_tickets/selling_tickets/urls.py b/selling_tickets/selling_tickets/urls.py new file mode 100644 index 00000000..716cb2bc --- /dev/null +++ b/selling_tickets/selling_tickets/urls.py @@ -0,0 +1,23 @@ +"""selling_tickets URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include + +urlpatterns = [ + path('admin/', admin.site.urls), + path('user/', include('user.urls')), + path('', include('booking.urls')), +] diff --git a/selling_tickets/selling_tickets/wsgi.py b/selling_tickets/selling_tickets/wsgi.py new file mode 100644 index 00000000..12e2a921 --- /dev/null +++ b/selling_tickets/selling_tickets/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for selling_tickets project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'selling_tickets.settings') + +application = get_wsgi_application() diff --git a/selling_tickets/user/__init__.py b/selling_tickets/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/selling_tickets/user/admin.py b/selling_tickets/user/admin.py new file mode 100644 index 00000000..4f3091e6 --- /dev/null +++ b/selling_tickets/user/admin.py @@ -0,0 +1,29 @@ + +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin + +from user.forms import CustomUserCreationForm, CustomUserChangeForm +from user.models import CustomUser + + +class CustomUserAdmin(UserAdmin): + add_form = CustomUserCreationForm + form = CustomUserChangeForm + model = CustomUser + list_display = ('mobile_number', 'is_staff', 'is_active', 'national_code') + list_filter = ('mobile_number', 'is_staff', 'is_active', 'national_code') + fieldsets = ( + (None, {'fields': ('mobile_number', 'password', 'national_code')}), + ('Permissions', {'fields': ('is_staff', 'is_active')}), + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('mobile_number', 'password1', 'password2', 'national_code', 'is_staff', 'is_active')} + ), + ) + search_fields = ('mobile_number', 'national_code') + ordering = ('mobile_number',) + + +admin.site.register(CustomUser, CustomUserAdmin) diff --git a/selling_tickets/user/apps.py b/selling_tickets/user/apps.py new file mode 100644 index 00000000..36cce4c8 --- /dev/null +++ b/selling_tickets/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'user' diff --git a/selling_tickets/user/base_user.py b/selling_tickets/user/base_user.py new file mode 100644 index 00000000..b40cd365 --- /dev/null +++ b/selling_tickets/user/base_user.py @@ -0,0 +1,36 @@ +from django.contrib.auth.base_user import BaseUserManager + + +class MyUserManager(BaseUserManager): + """ + Custom user model manager where mobile_number is the unique identifiers + for authentication instead of usernames. + """ + use_in_migrations = True + def __create_user(self, mobile_number, password, **extra_fields): + if not mobile_number: + raise ValueError('The mobile_number must be set') + mobile_number = self.normalize_mobile_number(mobile_number) + user = self.model(mobile_number=mobile_number, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + def create_user(self, mobile_number, password=None, **extra_fields): + extra_fields.setdefault('is_staff', False) + extra_fields.setdefault('is_superuser', False) + return self.__create_user(mobile_number, password, **extra_fields) + + def create_superuser(self, mobile_number, password, **extra_fields): + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault('is_active', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError('Superuser must have is_staff=True.') + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser must have is_superuser=True.') + + return self.__create_user(mobile_number, password, **extra_fields) + + def normalize_mobile_number(self, mobile_number): + return mobile_number \ No newline at end of file diff --git a/selling_tickets/user/forms.py b/selling_tickets/user/forms.py new file mode 100644 index 00000000..b4a958a8 --- /dev/null +++ b/selling_tickets/user/forms.py @@ -0,0 +1,17 @@ +from django.contrib.auth.forms import UserCreationForm, UserChangeForm + +from user.models import CustomUser + + +class CustomUserCreationForm(UserCreationForm): + + class Meta: + model = CustomUser + fields = ('mobile_number',) + + +class CustomUserChangeForm(UserChangeForm): + + class Meta: + model = CustomUser + fields = ('mobile_number',) diff --git a/selling_tickets/user/migrations/__init__.py b/selling_tickets/user/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/selling_tickets/user/models.py b/selling_tickets/user/models.py new file mode 100644 index 00000000..b3ad219e --- /dev/null +++ b/selling_tickets/user/models.py @@ -0,0 +1,26 @@ +from django.db import models +from user.base_user import MyUserManager +from django.contrib.auth.models import AbstractUser +from user.validators import UnicodeMobileNumberValidator + + +class CustomUser(AbstractUser): + """ + customize user model to authenticate user by mobile_number + both user and superUser username_field is mobile_number + """ + username = None + mobile_number = models.CharField(max_length=50, + unique=True, + verbose_name='Mobile Number', + validators=[UnicodeMobileNumberValidator], + error_messages={ + 'unique': "A user with that mobile number already exists.", + }, + ) + national_code = models.CharField(max_length=10, verbose_name='National Code', blank=True) + USERNAME_FIELD = 'mobile_number' + REQUIRED_FIELDS = [] + objects = MyUserManager() + + diff --git a/selling_tickets/user/serializers.py b/selling_tickets/user/serializers.py new file mode 100644 index 00000000..f1554cd4 --- /dev/null +++ b/selling_tickets/user/serializers.py @@ -0,0 +1,35 @@ +from rest_framework import serializers +from user.models import CustomUser +from django.contrib.auth import authenticate + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ('id', 'mobile_number', 'national_code', 'first_name', 'last_name') + + +class SignUpSerializer(serializers.ModelSerializer): + class Meta: + model = CustomUser + fields = ('id', 'mobile_number', 'password', 'national_code', 'first_name', 'last_name') + extra_kwargs = {'password': {'write_only': True}} + + def create(self, validated_data): + print(validated_data) + user = CustomUser.objects.create_user(mobile_number=validated_data['mobile_number'], + password=validated_data['password'], + national_code=validated_data['national_code'], + first_name=validated_data['first_name'], + last_name=validated_data['last_name']) + return user + +class LoginSerializer(serializers.Serializer): + mobile_number = serializers.CharField() + password = serializers.CharField() + + def validate(self, attrs): + user = authenticate(**attrs) + if user and user.is_active: + return user + raise serializers.ValidationError('Incorrect Credentials') \ No newline at end of file diff --git a/selling_tickets/user/tests.py b/selling_tickets/user/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/selling_tickets/user/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/selling_tickets/user/urls.py b/selling_tickets/user/urls.py new file mode 100644 index 00000000..85e0f371 --- /dev/null +++ b/selling_tickets/user/urls.py @@ -0,0 +1,10 @@ +from django.urls import path, include +from user.views import SignUpAPI, LoginAPI +from knox import views as knox_views + +urlpatterns = [ + path('', include('knox.urls')), + path('register', SignUpAPI.as_view()), + path('login', LoginAPI.as_view()), + path('logout',knox_views.LogoutView.as_view(), name="knox-logout"), +] \ No newline at end of file diff --git a/selling_tickets/user/validators.py b/selling_tickets/user/validators.py new file mode 100644 index 00000000..8abca1b2 --- /dev/null +++ b/selling_tickets/user/validators.py @@ -0,0 +1,11 @@ +from django.core import validators +from django.utils.deconstruct import deconstructible + + +@deconstructible +class UnicodeMobileNumberValidator(validators.RegexValidator): + """ + validate mmobile_number + """ + regex = r'09(\d{9})$' + message = 'Enter a valid mobile number' \ No newline at end of file diff --git a/selling_tickets/user/views.py b/selling_tickets/user/views.py new file mode 100644 index 00000000..99dbf881 --- /dev/null +++ b/selling_tickets/user/views.py @@ -0,0 +1,32 @@ +from multiprocessing import context +from django.shortcuts import render +from rest_framework import generics, permissions, serializers +from user.serializers import SignUpSerializer, UserSerializer, LoginSerializer +from knox.models import AuthToken +from rest_framework.response import Response + + +class SignUpAPI(generics.GenericAPIView): + serializer_class = SignUpSerializer + + def post(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.save() + token = AuthToken.objects.create(user=user) + return Response({ + "user": UserSerializer(user, context=self.get_serializer_context()).data, + "token": token[1] + }) + +class LoginAPI(generics.GenericAPIView): + serializer_class = LoginSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data + return Response({ + "user": UserSerializer(user, context=self.get_serializer_context()).data, + "token": AuthToken.objects.create(user)[1] + }) \ No newline at end of file