diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..5416fce --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +omit = + */migrations/* + */tests/* + config/* + manage.py + base +relative_files = true diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml new file mode 100644 index 0000000..fc66e95 --- /dev/null +++ b/.github/workflows/dev-deploy.yml @@ -0,0 +1,33 @@ +name: AWS Deployment + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Configure AWS CLI + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: SSH into AWS Instance + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.AWS_INSTANCE_IP }} + username: ${{ secrets.AWS_INSTANCE_USERNAME }} + key: ${{ secrets.AWS_INSTANCE_PRIVATE_KEY }} + port: ${{ secrets.AWS_INSTANCE_SSH_PORT }} + script: | + cd /home/ubuntu/eventsradar-api-v2/ + git pull origin main + docker compose restart diff --git a/.github/workflows/pyunittest.yml b/.github/workflows/pyunittest.yml new file mode 100644 index 0000000..87e6254 --- /dev/null +++ b/.github/workflows/pyunittest.yml @@ -0,0 +1,46 @@ +name: CI + +on: + pull_request: + push: + branches: + - "main" + +jobs: + test: + name: Run tests & display coverage + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: write + steps: + # Step 1: Checkout the code + - uses: actions/checkout@v4 + + # Step 2: Set up Python and install dependencies + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov # Install pytest and coverage plugin + + # Step 3: Run pytest with coverage + - name: Run tests with pytest + run: | + pytest --cov=. # Run pytest and generate coverage for the entire project + + # Step 4: Generate a coverage report + - name: Generate coverage report + run: | + coverage xml # Generates an XML report + + # Step 5: Post coverage comment + - name: Coverage comment + uses: py-cov-action/python-coverage-comment-action@v3 + with: + GITHUB_TOKEN: ${{ github.token }} diff --git a/.gitignore b/.gitignore index 3bb5d38..17970ba 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,9 @@ node_modules .next admin/.next build/ +media/ +.coverage +htmlcov +.coverage.* + +.env* diff --git a/Dockerfile b/Dockerfile index 75f498e..2f51603 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,27 +1,31 @@ -FROM python:3 +# Use the official Python image +FROM python:3.10.12 # Set environment variables for Python optimizations ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 + # Set the working directory in the container WORKDIR /code -# Install dependencies -COPY requirements.txt /code/ - -# Copy the project code into the container -COPY . /code/ +# Install GDAL and libpq dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libpq-dev \ + gdal-bin \ + libgdal-dev \ + && rm -rf /var/lib/apt/lists/* -# Install GDAL dependencies -RUN apt-get update +# Copy the requirements file to the working directory +COPY requirements.txt /code/ +# Upgrade pip and install Python dependencies RUN pip install --upgrade pip - -# Install dependencies RUN pip install setuptools RUN pip install --no-cache-dir -r requirements.txt - +# Copy the rest of the project code into the container +COPY . /code/ # Expose the port on which Gunicorn will run EXPOSE 8000 diff --git a/authentication/admin.py b/authentication/admin.py index 4b60fe6..9b3f1d6 100644 --- a/authentication/admin.py +++ b/authentication/admin.py @@ -1,6 +1,5 @@ -import re - from django.contrib import admin +from django.utils.safestring import mark_safe from authentication.models import User @@ -9,31 +8,19 @@ def is_sha256_hash(text): # Check if the text contains 'sha256' substring if 'sha256' not in text: return False - - # Check if the hash has the expected length and format - sha256_hash_length = 64 # SHA-256 produces a 64-character hex string - sha256_hash_pattern = r'[a-fA-F0-9]{64}' # 64 hex characters - - # Find the portion of the string that could be the SHA-256 hash - possible_hash = re.search(sha256_hash_pattern, text) - - # If there's a match and the whole possible hash is the correct length, it's likely a SHA-256 hash - if possible_hash and len(possible_hash.group()) == sha256_hash_length: - return True - - return False + return True @admin.register(User) class UserAdmin(admin.ModelAdmin): - list_display = ('email', 'full_name', 'mobile_number', 'is_active', 'is_staff', 'is_superuser') - list_filter = ('is_active', 'is_staff', 'is_superuser') - search_fields = ('email', 'full_name', 'mobile_number') + list_display = ('email', 'full_name', 'is_active', 'is_staff', 'is_superuser', 'avatar') + list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups', ) + search_fields = ('email', 'full_name',) ordering = ('email',) fieldsets = ( (None, {'fields': ('email', 'password')}), - ('Personal info', {'fields': ('full_name', 'mobile_number', 'section')}), - ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser')}), + ('Personal info', {'fields': ('full_name', 'profile')}), + ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups')}), ('Important dates', {'fields': ('last_login', 'date_joined')}), ) @@ -41,12 +28,8 @@ def save_model(self, request, obj, form, change): # Get the password from the form if provided password = form.cleaned_data.get('password') if password: - print("Password: ", password) if not is_sha256_hash(password): - print("setting password") - # If the password is not a hash, hash it obj.set_password(password) - # If the password is already a hash, don't rehash it else: obj.password = password elif 'password' in form.changed_data: @@ -54,3 +37,6 @@ def save_model(self, request, obj, form, change): form.cleaned_data.pop('password', None) super().save_model(request, obj, form, change) + + def avatar(self, obj): + return mark_safe(f'') diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py index c76425b..e2112b4 100644 --- a/authentication/migrations/0001_initial.py +++ b/authentication/migrations/0001_initial.py @@ -1,16 +1,16 @@ -# Generated by Django 5.1.1 on 2024-09-16 10:23 +# Generated by Django 4.2.16 on 2024-10-07 15:06 -import authentication.models import django.utils.timezone from django.db import migrations, models +import authentication.models + class Migration(migrations.Migration): - initial = True dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), + ('auth', '0001_initial'), ] operations = [ @@ -19,18 +19,31 @@ class Migration(migrations.Migration): fields=[ ('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')), - ('id', models.CharField(default=authentication.models.generate_unique_code, max_length=255, primary_key=True, serialize=False)), + ('is_superuser', models.BooleanField(default=False, + help_text='Designates that this user has all permissions without explicitly assigning them.', + verbose_name='superuser status')), + ('id', + models.CharField(default=authentication.models.generate_unique_code, max_length=255, primary_key=True, + serialize=False)), ('full_name', models.CharField(blank=True, max_length=255, verbose_name='full name')), ('email', models.EmailField(blank=True, max_length=254, unique=True, verbose_name='email address')), - ('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')), + ('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')), ('created_at', models.DateTimeField(auto_now_add=True, null=True)), ('updated_at', models.DateTimeField(auto_now=True, null=True)), - ('mobile_number', models.CharField(blank=True, max_length=20, null=True)), - ('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')), + ('profile', models.URLField(blank=True, max_length=255, null=True)), + ('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', diff --git a/authentication/migrations/0002_alter_user_is_active.py b/authentication/migrations/0002_alter_user_is_active.py new file mode 100644 index 0000000..7773e57 --- /dev/null +++ b/authentication/migrations/0002_alter_user_is_active.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-10-14 05:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='is_active', + field=models.BooleanField(default=True, help_text='Designates whether this user should be treated as active.Unselect this instead of deleting accounts.', verbose_name='active'), + ), + ] diff --git a/authentication/models.py b/authentication/models.py index 5e9b188..d91348e 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -70,7 +70,7 @@ class BaseUser(AbstractBaseUser, PermissionsMixin): _("active"), default=True, help_text=_( - "Designates whether this user should be treated as active. " + "Designates whether this user should be treated as active." "Unselect this instead of deleting accounts." ), ) @@ -107,7 +107,7 @@ def email_user(self, subject, message, from_email=None, **kwargs): mail.EmailMessage( subject, message, from_email, [ self.email], connection=connection, **kwargs).send() - # send_mail(subject, message, from_email, [self.email], **kwargs) + # send_mail(subject, message, from_email, [self.email], **kwargs) class User(BaseUser): @@ -115,4 +115,7 @@ class User(BaseUser): this is a custom user model extending BaseUser """ - mobile_number = models.CharField(max_length=20, blank=True, null=True) + profile = models.URLField(max_length=255, blank=True, null=True) + + def __str__(self): + return self.full_name diff --git a/authentication/serializers.py b/authentication/serializers.py deleted file mode 100644 index 0f5b191..0000000 --- a/authentication/serializers.py +++ /dev/null @@ -1,88 +0,0 @@ -# serializers.py -from rest_framework import serializers - -# from dja import User -from authentication.models import User - - -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ('id', 'email', 'full_name', - "mobile_number", - ) - extra_kwargs = { - "id": {"read_only": True}, - } - - -class LoginSerializer(serializers.Serializer): - email = serializers.EmailField() - password = serializers.CharField() - - -class GoogleLoginSerializer(serializers.Serializer): - google_key = serializers.CharField() - - -class SignUpSerializer(serializers.Serializer): - email = serializers.EmailField() - password = serializers.CharField() - full_name = serializers.CharField() - mobile_number = serializers.CharField() - - def save(self, **kwargs): - user = User.objects.create_user( - email=self.validated_data['email'], - password=self.validated_data['password'], - full_name=self.validated_data['full_name'], - mobile_number=self.validated_data['mobile_number'], - ) - user.set_password(self.validated_data['password']) - user.save() - return user - - def validate_email(self, value): - if User.objects.filter(email=value).exists(): - raise serializers.ValidationError("Email already exists") - return value - - def validate_mobile_number(self, value): - # Check if User with the given mobile number already exists - if User.objects.filter(mobile_number=value).exists(): - raise serializers.ValidationError("Mobile Number already exists") - # Remove any spaces from the mobile number - value = value.replace(" ", "") - # Check if the mobile number starts with +91, if not, add it - if not value.startswith("+91"): - # Remove any spaces between +91 and the number - value = "+91" + value - - # Validate that the mobile number is exactly 13 digits long - if len(value) != 13: - raise serializers.ValidationError("Mobile Number must be 13 digits long") - - return value - - def validate_password(self, value): - if len(value) < 8: - raise serializers.ValidationError( - "Password must be at least 8 characters long") - elif not any(char.isdigit() for char in value): - raise serializers.ValidationError( - "Password must contain at least 1 digit") - elif not any(char.isupper() for char in value): - raise serializers.ValidationError( - "Password must contain at least 1 uppercase letter") - elif not any(char.islower() for char in value): - raise serializers.ValidationError( - "Password must contain at least 1 lowercase letter") - elif not any(char in ['$', '#', '@', '&', '!', '%', '^', '*', '(', ')'] for char in value): - raise serializers.ValidationError( - "Password must contain at least 1 special character") - return value - - -class TokenSerializer(serializers.Serializer): - refresh = serializers.CharField() - access = serializers.CharField() diff --git a/authentication/tests.py b/authentication/tests/__init__.py similarity index 100% rename from authentication/tests.py rename to authentication/tests/__init__.py diff --git a/authentication/tests/conftest.py b/authentication/tests/conftest.py new file mode 100644 index 0000000..9452679 --- /dev/null +++ b/authentication/tests/conftest.py @@ -0,0 +1,11 @@ +# conftest.py at the project root + +import pytest + +from authentication.models import User + + +# Define a fixture that creates a test user +@pytest.fixture +def user(db): + return User.objects.create(email='test@example.com', password='password') diff --git a/authentication/tests/test_user.py b/authentication/tests/test_user.py new file mode 100644 index 0000000..118b043 --- /dev/null +++ b/authentication/tests/test_user.py @@ -0,0 +1 @@ +# Test function using the fixture diff --git a/authentication/tests/test_view.py b/authentication/tests/test_view.py new file mode 100644 index 0000000..ac600fb --- /dev/null +++ b/authentication/tests/test_view.py @@ -0,0 +1,146 @@ +import pytest +import requests +from unittest.mock import patch +from django.urls import reverse +from django.conf import settings +from rest_framework.test import APIClient +from authentication.models import User +from rest_framework.authtoken.models import Token + + +@pytest.mark.django_db +class TestGoogleOAuth: + def test_google_login_redirect(self, client): + url = reverse('google_login') # Adjust if the URL pattern name is different + response = client.get(url) + assert response.status_code == 302 + assert "accounts.google.com/o/oauth2/v2/auth" in response.url + + @patch('requests.post') + @patch('requests.get') + def test_google_callback_creates_user_and_logs_in(self, mock_get, mock_post, client): + mock_token_response = { + 'access_token': 'test-access-token', + } + mock_user_info = { + 'email': 'testuser@example.com', + 'name': 'Test User', + 'picture': 'https://example.com/profile.jpg' + } + + mock_post.return_value.json.return_value = mock_token_response + mock_get.return_value.json.return_value = mock_user_info + + callback_url = reverse('google_callback') # Adjust if the URL pattern name is different + response = client.get(callback_url, {'code': 'test-code', 'state': settings.AUTH_SUCCESS_REDIRECT}) + + user = User.objects.get(email=mock_user_info['email']) + token = Token.objects.get(user=user) + + assert response.status_code == 302 + assert response.url == f"{settings.AUTH_SUCCESS_REDIRECT}/login?token={token.key}" + assert user.is_authenticated + assert user.full_name == mock_user_info['name'] + # assert user.profile == mock_user_info['picture'] + + @patch('requests.post') + def test_google_callback_token_error(self, mock_post, client): + mock_post.return_value.json.return_value = {'error': 'invalid_grant'} + + callback_url = reverse('google_callback') + response = client.get(callback_url, {'code': 'test-code'}) + + assert response.status_code == 302 + assert response.url == reverse('google_login') + + + @patch('requests.post') + def test_google_callback_token_error_in_response(self, mock_post, client): + # Mock an error in the token response + mock_post.return_value.json.return_value = {'error': 'invalid_grant'} + + callback_url = reverse('google_callback') + response = client.get(callback_url, {'code': 'test-code'}) + + assert response.status_code == 302 + assert response.url == reverse('google_login') # Should redirect back to Google login + + + @patch('requests.post') + @patch('requests.get') + def test_google_callback_user_not_active(self, mock_get, mock_post, client): + # Set up mock responses and create an inactive user + mock_token_response = { + 'access_token': 'test-access-token', + } + mock_user_info = { + 'email': 'inactiveuser@example.com', + 'name': 'Inactive User', + 'picture': 'https://example.com/profile.jpg' + } + + mock_post.return_value.json.return_value = mock_token_response + mock_get.return_value.json.return_value = mock_user_info + + # Create the user but make it inactive + user = User.objects.create(email='inactiveuser@example.com', is_active=False,full_name='Inactive User') + + # Trigger the callback view + callback_url = reverse('google_callback') + response = client.get(callback_url, {'code': 'test-code', 'state': settings.AUTH_SUCCESS_REDIRECT}) + + user.refresh_from_db() + + # Ensure the inactive user is now active and that the response redirects with token + token = Token.objects.get(user=user) + assert response.status_code == 302 + assert response.url == f"{settings.AUTH_SUCCESS_REDIRECT}/login?token={token.key}" + assert user.is_active + + @patch('requests.post') + @patch('requests.get') + def test_google_callback_no_access_token(self, mock_get, mock_post, client): + # Mock a response without access token + mock_post.return_value.json.return_value = {} + + callback_url = reverse('google_callback') + response = client.get(callback_url, {'code': 'test-code'}) + + assert response.status_code == 302 + assert response.url == reverse('google_login') # Should redirect to Google login + + +@pytest.mark.django_db +class TestProfileView: + @pytest.fixture + def authenticated_client(self): + _user = User.objects.create_user(email='profileuser@example.com', password='Password@123',full_name='Profile User') + token = Token.objects.create(user=_user) + client = APIClient() + client.credentials(HTTP_AUTHORIZATION='Token ' + token.key) + return client, _user + + def test_profile_view_get(self, authenticated_client): + client, user = authenticated_client + response = client.get(reverse('profile')) # Adjust if the URL pattern name is different + + assert response.status_code == 200 + assert response.data['email'] == user.email + assert response.data['full_name'] == user.full_name + # assert response.data['profile'] == user.profile + + def test_profile_view_put(self, authenticated_client): + client, user = authenticated_client + new_data = { + 'full_name': 'Updated User', + 'profile': 'https://example.com/newprofile.jpg' + } + response = client.put(reverse('profile'), new_data, format='json') + + user.refresh_from_db() + + assert response.status_code == 200 + assert response.data['full_name'] == new_data['full_name'] + # assert response.data['profile'] == new_data['profile'] + assert user.full_name == new_data['full_name'] + assert user.profile == new_data['profile'] diff --git a/authentication/urls.py b/authentication/urls.py index 8d1bb11..ce41ffc 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -1,16 +1,9 @@ from django.urls import path -from rest_framework_simplejwt import views as jwt_views -from .views import LoginView, Profile, LogoutView, SignUpView,GoogleLoginView +from authentication import views urlpatterns = [ - path('login/', LoginView.as_view(), name='login'), - path('login/google/', GoogleLoginView.as_view(), name='login'), - path('signup/', SignUpView.as_view(), name='signup'), - path('token/refresh/', - jwt_views.TokenRefreshView.as_view(), - name='token_refresh', ), - path('logout/', LogoutView.as_view(), name='logout'), - path('profile/', Profile.as_view({'get': 'list', - 'patch': 'partial_update'}), name='profile'), + path('', views.google_login, name='google_login'), + path('google/callback/', views.google_callback, name='google_callback'), + path("profile/", views.ProfileView.as_view(), name="profile"), ] diff --git a/authentication/views.py b/authentication/views.py index 4147b06..345e3f3 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -1,268 +1,112 @@ -# views.py import logging -from drf_yasg import openapi -from drf_yasg.utils import swagger_auto_schema -from rest_framework import generics, status, viewsets -from rest_framework import permissions -from rest_framework.parsers import FileUploadParser +import requests +from django.conf import settings +from django.contrib.auth import login +from django.shortcuts import redirect +from rest_framework.authtoken.models import Token +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework_simplejwt.exceptions import TokenError -from rest_framework_simplejwt.tokens import RefreshToken -from google.oauth2 import id_token -from config.settings import GOOGLE_CLIENT_ID -from google.auth.transport import requests -from base.permissions import IsOwner -from .models import User -from .serializers import UserSerializer, LoginSerializer, SignUpSerializer, TokenSerializer, GoogleLoginSerializer +from authentication.models import User +from config import settings logger = logging.getLogger('authentication') -# Get User API -class UserAPI(viewsets.ModelViewSet): - """ - A viewset for the User API that allows authenticated users to retrieve and update their own user object. - """ - - # Define the permissions required to access this viewset - permission_classes = [ - permissions.IsAuthenticated, IsOwner - ] - - # Set the serializer class used to serialize and deserialize user data - serializer_class = UserSerializer - - # Define the allowed HTTP methods for this viewset - http_method_names = ['get', 'put', 'patch'] - - # Define the queryset used to retrieve user data - queryset = User.objects.all() - - def get_object(self): - """ - Retrieve the user object for the currently authenticated user. - """ - return self.request.user - - def get_queryset(self): - """ - Retrieve the queryset of user objects filtered to include only the currently authenticated user. - """ - return User.objects.filter(id=self.request.user.id) - - -class Profile(viewsets.ViewSet): - """ - A viewset for the user profile API that allows authenticated users to retrieve and update their own profile data. - """ - - # Define the permissions required to access this viewset - permission_classes = [ - permissions.IsAuthenticated, IsOwner - ] - - # Set the serializer class used to serialize and deserialize user data - serializer_class = UserSerializer - parser_class = [FileUploadParser] - - @swagger_auto_schema(responses={200: UserSerializer(many=False)}) - def list(self, request): - """ - Retrieve the profile data for the currently authenticated user. - """ - logger.info(f"User {request.user.id} is accessing their profile data.") - queryset = User.objects.get(id=request.user.id) - logger.info( - f"Profile data for user {request.user.id} has been retrieved.") - serializer = UserSerializer(queryset) - return Response(serializer.data) - - @swagger_auto_schema(responses={200: UserSerializer(many=False)}) - def partial_update(self, request): - """ - Update the profile data for the currently authenticated user. - """ - logger.info(f"User {request.user.id} is updating their profile data.") - user = User.objects.get(id=request.user.id) - serializer = UserSerializer(user, data=request.data) - if serializer.is_valid(): - serializer.save() - logger.info( - f"Profile data for user {request.user.id} has been updated.", - extra={ - 'data': serializer.data}) - user.save() - return Response(serializer.data) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class LogoutView(APIView): - def post(self, request, *args, **kwargs): - """ - Log out the currently authenticated user by blacklisting their refresh token. - """ - try: - refresh_token = request.data["refresh"] - token = RefreshToken(refresh_token) - try: - token.blacklist() - except AttributeError: - # The token is already blacklisted - pass - - logger.info(f"User {request.user.id} has been logged out.") - return Response(status=status.HTTP_205_RESET_CONTENT) - except TokenError: - logger.warning(f"User {request.user.id} is logging out failed.", - extra={'data': "Invalid token"}) - return Response(status=status.HTTP_400_BAD_REQUEST, data={ - "message": "Invalid token"}) - except Exception as e: - logger.warning(f"User {request.user.id} is logging out failed.{type(e)}") - return Response(status=status.HTTP_400_BAD_REQUEST) - - -class LoginView(generics.GenericAPIView): - serializer_class = LoginSerializer - - def post(self, request, *args, **kwargs): - """ - Logs in the user with email and password. - """ - email = request.data.get('email') - password = request.data.get('password') - if email and password: - try: - user = User.objects.filter(email=email).first() - if user: - if user.check_password(password): - token = RefreshToken.for_user(user) - return Response({ - 'refresh': str(token), - 'access': str(token.access_token), - }) - else: - # Invalid password - return Response( - status=status.HTTP_401_UNAUTHORIZED, data={ - "message": "Invalid email or password"}) - else: - # User not found with the given email - return Response( - status=status.HTTP_401_UNAUTHORIZED, data={ - "message": "Invalid email or password"}) - except Exception as e: - print(e) - logger.warning(f"User is logging in failed.", - extra={'data': e}) - return Response(status=status.HTTP_400_BAD_REQUEST) - else: - - logger.warning(f"User is logging in failed.", extra={ - 'data': "email and password are required"}) - # Invalid request, missing email or password - return Response(status=status.HTTP_400_BAD_REQUEST, data={ - "message": "email and password are required"}) - - -class SignUpView(generics.CreateAPIView): - serializer_class = SignUpSerializer - - @swagger_auto_schema( - request_body=serializer_class, - responses={ - status.HTTP_201_CREATED: openapi.Response( - description="User created successfully", - schema=openapi.Schema( - type=openapi.TYPE_OBJECT, - properties={ - 'refresh': openapi.Schema(type=openapi.TYPE_STRING), - 'access': openapi.Schema(type=openapi.TYPE_STRING), - }, - ), - ), - status.HTTP_400_BAD_REQUEST: openapi.Response( - description="Bad request", - schema=TokenSerializer, - ), - - }, - swagger_auto_schema=None, - +def google_login(request): + host_url = request.build_absolute_uri('/')[:-1] + next_ = request.GET.get('next', settings.AUTH_SUCCESS_REDIRECT) + google_oauth_url = ( + 'https://accounts.google.com/o/oauth2/v2/auth' + '?response_type=code' + f'&client_id={settings.GOOGLE_CLIENT_ID}' + f'&redirect_uri={host_url}/{settings.GOOGLE_REDIRECT_URI}' + '&scope=email%20profile' + '&access_type=offline' + '&prompt=consent' + f'&state={next_}' ) - def create(self, request, *args, **kwargs): - """ - Creates a new user. - """ - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - user = serializer.save() - - token = RefreshToken.for_user(user) + return redirect(google_oauth_url) + + +def google_callback(request): + code = request.GET.get('code') + if not code: + logger.error('Google OAuth code not found') + return redirect('google_login') # Or handle with an error message + state = request.GET.get('state', '') + host_url = request.build_absolute_uri('/')[:-1] + token_url = 'https://oauth2.googleapis.com/token' + token_data = { + 'code': code, + 'client_id': settings.GOOGLE_CLIENT_ID, + 'client_secret': settings.GOOGLE_CLIENT_SECRET, + 'redirect_uri': f'{host_url}/{settings.GOOGLE_REDIRECT_URI}', + 'grant_type': 'authorization_code', + } + + token_response = requests.post(token_url, data=token_data) + token_json = token_response.json() + + access_token = token_json.get('access_token') + + if not access_token: + logger.error(f"Google OAuth token error: {token_json}") + return redirect('google_login') + if 'error' in token_json: + logger.error(f"Google OAuth token error: {token_json['error']}") + return redirect('google_login') # Optionally, show a more descriptive error message + + # Fetch user information + user_info_url = 'https://www.googleapis.com/oauth2/v1/userinfo' + user_info_params = {'access_token': access_token} + user_info_response = requests.get(user_info_url, params=user_info_params) + user_info = user_info_response.json() + + # Create or log in the user + email = user_info.get('email') + name = user_info.get('name', email.split("@")[0]) + profile = user_info.get('picture') + + # Check if user exists, else create a new user + try: + user = User.objects.get(email=email) + user.profile = profile + except User.DoesNotExist: + user = User.objects.create(email=email, full_name=name, profile=profile) + if not user.is_active: + user.is_active = True + user.full_name = name + user.save() + login(request, user) + token = Token.objects.get_or_create(user=user)[0] + try: + return redirect(state + f'/login?token={token.key}') + # if state is none + except: + return redirect(settings.AUTH_SUCCESS_REDIRECT + f'/login?token={token.key}') + + +class ProfileView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user return Response({ - 'refresh': str(token), - 'access': str(token.access_token), - }, status=status.HTTP_201_CREATED) - - -class GoogleLoginView(generics.GenericAPIView): - """ - A view for handling user login via Google OAuth2 authentication. - """ - - # Set the serializer class used to serialize and deserialize user data - serializer_class = GoogleLoginSerializer - - def post(self, request, *args, **kwargs): - """ - Authenticate the user using Google OAuth2 and generate access and refresh tokens. - """ - # Get the user's Google OAuth2 token from the request data - logger.info(f"User is logging in.") - google_key = request.data.get('google_key') - - # If a Google OAuth2 token was provided, attempt to authenticate the user - if google_key: - try: - # Verify the Google OAuth2 token and extract user information - user_info = id_token.verify_oauth2_token( - google_key, requests.Request(), - GOOGLE_CLIENT_ID - ) - logger.warning(f"User {user_info['email']} is trying to logging in.", extra={'data': user_info}) - - # Attempt to retrieve an existing user with the authenticated email address - user = User.objects.filter(email=user_info['email']).first() - - # If a user with the authenticated email exists, generate access and refresh tokens and return them - if user: - logger.warning(f"User {user.get_full_name()} is logging in.") - token = RefreshToken.for_user(user) - return Response({ - 'refresh': str(token), - 'access': str(token.access_token), - }) - # If no user with the authenticated email exists, create a new user and generate access and refresh - # tokens - else: - logger.warning(f"User {user_info['email']} is logging in for the first time.") - user = User.objects.create( - email=user_info['email'], - full_name=user_info['name'], - ) - - token = RefreshToken.for_user(user) - - return Response({ - 'refresh': str(token), - 'access': str(token.access_token), - }) - - # If an exception is raised during authentication, return a bad request response - except (ValueError,) as e: - logger.warning(f"User is logging in failed.", extra={'data': e}) - return Response(status=status.HTTP_400_BAD_REQUEST, data={'message': str(e)}) + 'email': user.email, + 'full_name': user.full_name, + 'profile': user.profile + }) + + def put(self, request): + user = request.user + data = request.data + user.full_name = data.get('full_name', user.full_name) + user.profile = data.get('profile', user.profile) + user.save() + return Response({ + 'email': user.email, + 'full_name': user.full_name, + 'profile': user.profile + }) diff --git a/base/serializers.py b/base/serializers.py index bfe20ef..e69de29 100644 --- a/base/serializers.py +++ b/base/serializers.py @@ -1,79 +0,0 @@ -from django.contrib.gis.geos import Point -from rest_framework import serializers -from .models import PointData, Model -from office.models import Scheme - - -class SchemeNameValidationMixin: - def validate_scheme_name(self, value): - # Normalize the input name to match the cleaning logic in the model - normalized_name = " ".join(value.split()).title().strip() - - # Check if a scheme with this normalized name already exists - scheme, created = Scheme.objects.get_or_create(name=normalized_name) - return scheme - - -class LocationSerializer(serializers.Serializer): - x = serializers.FloatField() - y = serializers.FloatField() - - -class BaseModelSerializer(serializers.ModelSerializer): - class Meta: - model = Model - fields = ['id'] - read_only_fields = ['id'] - - -class PointDataSerializer(BaseModelSerializer, SchemeNameValidationMixin): - # Swagger Schema for location field - location = LocationSerializer(many=False) - added_by = serializers.HiddenField(default=serializers.CurrentUserDefault()) - # section= serializers.SerializerMethodField(read_only=True) - division_name = serializers.SerializerMethodField(read_only=True) - scheme_name = serializers.CharField(max_length=100) - - # Override to_representation and to_internal_value as before - - class Meta(BaseModelSerializer.Meta): - model = PointData - fields = BaseModelSerializer.Meta.fields + ['location', 'division_name', 'location_name', 'scheme_type', - 'resource_type', 'section', 'scheme_name'] - read_only_fields = BaseModelSerializer.Meta.read_only_fields + ['resource_type', 'section'] - - @staticmethod - def get_division_name(obj): - return obj.division_name - - def to_representation(self, instance): - representation = super().to_representation(instance) - # Convert latitude and longitude to desired format - latitude = instance.location.x - longitude = instance.location.y - representation['location'] = {"y": latitude, "x": longitude} - return representation - - @staticmethod - def validate_location(value): - if 'x' not in value or 'y' not in value: - raise serializers.ValidationError("lat and long are required.") - try: - latitude = value['y'] - longitude = value['x'] - except ValueError: - raise serializers.ValidationError("Invalid lat or long format.") - return Point(latitude, longitude) - - def create(self, validated_data): - # Get the requesting user from the context - user = self.context['request'].user if 'request' in self.context else None - validated_data['added_by'] = user - if user: - validated_data['section'] = user.section - return super().create(validated_data) - - def save(self, **kwargs): - super().save(**kwargs) - self.instance.latitude, self.instance.longitude = self.instance.location.y, self.instance.location.x - self.instance.save() diff --git a/base/urls.py b/base/urls.py index 0153721..c27459b 100644 --- a/base/urls.py +++ b/base/urls.py @@ -10,5 +10,4 @@ urlpatterns = [ path('', include(router.urls)), - path("privacy/", views.PrivacyView.as_view(), name="privacy"), ] diff --git a/base/views.py b/base/views.py index 0b23eda..7d2fa9e 100644 --- a/base/views.py +++ b/base/views.py @@ -1,21 +1,3 @@ from django.views.generic import TemplateView from rest_framework import viewsets from rest_framework import permissions - - -class BaseWorkerViewSet(viewsets.ModelViewSet): - http_method_names = ['get', 'post', ] - permission_classes = [permissions.IsAuthenticated] - - -class PointDataBaseViewSet(BaseWorkerViewSet): - def get_queryset(self): - return self.queryset.filter(added_by=self.request.user) - - -class WorkerBaseViewSet(PointDataBaseViewSet): - pass - - -class PrivacyView(TemplateView): - template_name = 'privacy.html' diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..5f4b68f --- /dev/null +++ b/config/celery.py @@ -0,0 +1,37 @@ +from __future__ import absolute_import, unicode_literals +import os +from celery import Celery +from django.conf import settings +from celery import shared_task,signals +# import sentry_sdk +# from sentry_sdk.integrations.celery import CeleryIntegration + + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +app = Celery('config') + +app.conf.enable_utc = False +app.conf.update(timezone='Asia/Kolkata') +app.config_from_object(settings, namespace='CELERY') + +app.autodiscover_tasks() +# app.control.inspect().active() + + +@app.task(bind=True) +def debug_task(self): + print(f'Request: {self.request!r}') + + +@shared_task +def say_hello(): + print('Hello!') + + +# # @signals.beat_init.connect +# @signals.celeryd_init.connect +# def init_sentry(**kwargs): +# sentry_sdk.init( +# dsn=os.environ.get("SENTRY_PROJECT_DSN"), +# integrations=[CeleryIntegration(monitor_beat_tasks=True)], +# ) diff --git a/config/settings/base.py b/config/settings/base.py index ac64e23..8f6a717 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -70,8 +70,8 @@ THIRD_PARTY_APPS = [ "rest_framework", 'drf_yasg', - 'rest_framework_simplejwt', 'django_filters', + 'rest_framework.authtoken' ] # Custom apps @@ -162,3 +162,5 @@ } DATA_UPLOAD_MAX_NUMBER_FIELDS = 3000 +GOOGLE_REDIRECT_URI = env.str("GOOGLE_REDIRECT_URI", default="authentication/google/callback/") +AUTH_SUCCESS_REDIRECT = env.str("AUTH_SUCCESS_REDIRECT", default="/") diff --git a/config/settings/third_party.py b/config/settings/third_party.py index fb95f02..9c13fa9 100644 --- a/config/settings/third_party.py +++ b/config/settings/third_party.py @@ -11,15 +11,16 @@ 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 50, - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework_simplejwt.authentication.JWTAuthentication', - ], + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.TokenAuthentication', + ), + 'DEFAULT_THROTTLE_CLASSES': [ 'rest_framework.throttling.AnonRateThrottle', 'rest_framework.throttling.UserRateThrottle' ], 'DEFAULT_THROTTLE_RATES': { - 'anon': '30000/day', + 'anon': '50/day', 'user': '20000/day', 'user_sec': '2/second', 'user_min': '30/minute', @@ -27,14 +28,19 @@ 'user_day': '2000/day', }, 'DEFAULT_PERMISSION_CLASSES': ( - 'rest_framework.permissions.AllowAny', + 'rest_framework.permissions.IsAuthenticated', ), 'DEFAULT_PARSER_CLASSES': ( 'rest_framework.parsers.JSONParser', 'rest_framework.parsers.FormParser', 'rest_framework.parsers.MultiPartParser', ), - 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema' + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', + + 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.QueryParameterVersioning', + 'DEFAULT_VERSION': 'v1', + 'ALLOWED_VERSIONS': ['v1', 'v2'], # List the allowed versions + 'VERSION_PARAM': 'version', # Optional, for query parameter or header versioning } SWAGGER_SETTINGS = { @@ -296,3 +302,29 @@ "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", } + +# settings.py + +GOOGLE_CLIENT_ID = env.str('GOOGLE_CLIENT_ID', default="") +GOOGLE_CLIENT_SECRET = env.str('GOOGLE_CLIENT_SECRET', default="") +GOOGLE_REDIRECT_URI = env.str('GOOGLE_REDIRECT_URI', default="authentication/google/callback/") + +# CELERY_BROKER_URL = "redis://redis:6379/0" +CELERY_BROKER_URL = env.str('CELERY_BROKER_URL', default="redis://redis:6379/0") +# # CELERY_RESULT_BACKEND = 'django-db' # To store task results in the database +# CELERY_ACCEPT_CONTENT = ['json'] +# CELERY_TASK_SERIALIZER = 'json' +# CELERY_RESULT_SERIALIZER = 'json' + +import sentry_sdk + +sentry_sdk.init( + dsn=env.str('SENTRY_DSN', default=''), + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for tracing. + traces_sample_rate=1.0, + # Set profiles_sample_rate to 1.0 to profile 100% + # of sampled transactions. + # We recommend adjusting this value in production. + profiles_sample_rate=1.0, +) diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml new file mode 100644 index 0000000..6672d0e --- /dev/null +++ b/docker-compose-prod.yml @@ -0,0 +1,60 @@ +version: '3.8' + +services: + web: + command: [ "bash", "scripts/run.sh" ] + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/code + env_file: + - .env + depends_on: + redis: + condition: service_healthy + networks: + - nginx_network + + celery_worker: + command: [ "celery", "-A", "config", "worker", "--loglevel=info" ] + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + volumes: + - .:/code + - /code/media + - /code/staticfiles + - /code/logs + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + networks: + - nginx_network + + redis: + image: redis:latest + networks: + - nginx_network + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + + nginx: + image: nginx:latest + ports: + - "8000:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./staticfiles:/code/staticfiles + - ./media:/code/media + depends_on: + - web + networks: + - nginx_network + + +networks: + nginx_network: diff --git a/docker-compose.yml b/docker-compose.yml index 4fda75f..da08185 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.8' services: web: - command: ["bash", "scripts/run.sh"] + command: [ "bash", "scripts/run.sh" ] build: context: . dockerfile: Dockerfile @@ -16,6 +16,28 @@ services: networks: - nginx_network + celery_worker: + command: [ "celery", "-A", "config", "worker", "--loglevel=info" ] + build: + context: . + dockerfile: Dockerfile + env_file: + - .env + volumes: + - .:/code + - /code/media + - /code/staticfiles + - /code/logs + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + networks: + - nginx_network + + db: image: postgres:latest env_file: @@ -35,15 +57,17 @@ services: image: redis:latest networks: - nginx_network + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] -# pgadmin: -# image: dpage/pgadmin4 -# env_file: -# - .env -# volumes: -# - pgadmin_data:/var/lib/pgadmin -# networks: -# - nginx_network + # pgadmin: + # image: dpage/pgadmin4 + # env_file: + # - .env + # volumes: + # - pgadmin_data:/var/lib/pgadmin + # networks: + # - nginx_network nginx: image: nginx:latest @@ -75,8 +99,8 @@ services: volumes: ps_data: - pgadmin_data: - metabase_data: +# pgadmin_data: +# metabase_data: networks: nginx_network: diff --git a/env.example b/env.example index 2e00265..b169e22 100644 --- a/env.example +++ b/env.example @@ -79,3 +79,5 @@ MB_DB_PASS= MB_DB_HOST= SENTRY_DSN= + +AUTH_SUCCESS_REDIRECT= diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..30ce92b --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,56 @@ +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + client_max_body_size 100M; + + server { + listen 80; + server_name localhost; # Update this to your domain or IP + location /ws/ { + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_pass http://web:8000/ws/; + } + location / { + proxy_read_timeout 300; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_set_header X-NginX-Proxy true; + proxy_pass http://web:8000; # Update this to your backend server + proxy_redirect off; + } + + + location /static/ { + alias /code/staticfiles/; # Update this to your static files directory + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + } + + location /media/ { + alias /code/media/; # Update this to your media files directory + } + + # Additional server settings can be added here + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } + + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options DENY; + add_header X-XSS-Protection "1; mode=block"; + } +} diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..83b7bc4 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings.__init__ +python_files = tests.py test_*.py *_tests.py +;addopts = -p no:warnings +addopts = --cov=. --cov-report=html --cov-report=term-missing -p no:warnings + diff --git a/requirements.txt b/requirements.txt index 21b636f..b404872 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,61 @@ asgiref==3.8.1 -Django==5.1.1 +cachetools==5.5.0 +certifi==2024.8.30 +cffi==1.17.1 +charset-normalizer==3.3.2 +coverage==7.6.1 +cryptography==43.0.1 +diff-match-patch==20230430 +dj-rest-auth==6.0.0 +Django==4.2.16 +django-cors-headers==4.4.0 django-environ==0.11.2 django-filter==24.3 +django-import-export==4.1.1 django-jazzmin==3.0.0 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 drf-yasg==1.21.7 +google-api-core==2.8.2 +google-api-python-client==2.56.0 +google-auth==2.10.0 +google-auth-httplib2==0.1.0 +googleapis-common-protos==1.56.4 +gunicorn==23.0.0 +httplib2==0.22.0 +idna==3.10 inflection==0.5.1 +iniconfig==2.0.0 +numpy==2.1.1 +oauthlib==3.2.2 packaging==24.1 +pillow==10.4.0 +pluggy==1.5.0 +protobuf==4.25.4 +#psycopg2-binary==2.9.6 +pyasn1==0.6.1 +pyasn1_modules==0.4.1 +pycparser==2.22 PyJWT==2.9.0 +pyparsing==3.1.4 +pytest==8.3.3 +pytest-cov==5.0.0 +pytest-django==4.9.0 +pytest-mock==3.14.0 +python-dateutil==2.9.0.post0 pytz==2024.2 PyYAML==6.0.2 +requests==2.32.3 +requests-oauthlib==2.0.0 +rsa==4.9 +setuptools==75.1.0 +six==1.16.0 sqlparse==0.5.1 +tablib==3.5.0 typing_extensions==4.12.2 +tzdata==2024.1 uritemplate==4.1.1 -google-api-core==2.8.2 -google-api-python-client==2.56.0 -google-auth==2.10.0 -google-auth-httplib2==0.1.0 -googleapis-common-protos==1.56.4 \ No newline at end of file +urllib3==2.2.3 +celery[redis] +psycopg2-binary==2.9.3 +sentry-sdk