diff --git a/.github/workflows/CD.yaml b/.github/workflows/CD.yaml new file mode 100644 index 0000000..285b1f1 --- /dev/null +++ b/.github/workflows/CD.yaml @@ -0,0 +1,74 @@ +name: CD + +on: + push: + branches: + - fix/sprint_issues + pull_request: + branches: + - develop + types: + - closed + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Write .env file + run: echo "${{ secrets.ENV_FILE }}" > .env + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ secrets.USERNAME }} + password: ${{ secrets.TOKEN }} + + - name: Install Docker Compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/download/v2.23.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + + - name: Build and Push Docker Image + run: | + docker-compose build + docker-compose push + + - name: Copy files to server via SSH + uses: appleboy/scp-action@v0.1.0 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + source: "docker-compose.yml, prometheus.yml, alert_rules.yml" + target: "/home/fd/" + + - name: Copy files and deploy + uses: appleboy/ssh-action@v0.1.8 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + script: | + + cd /home/fd + + echo "${{ secrets.ENV_FILE }}" > .env + echo "${{ secrets.ALERT_MANAGER}}" > alertmanager.yml + + chmod 644 .env + + docker compose pull web + + docker compose down + + docker compose up -d + diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml new file mode 100644 index 0000000..2ca0673 --- /dev/null +++ b/.github/workflows/CI.yaml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + branches: + - develop + types: + - opened + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Write .env file + run: echo "${{ secrets.ENV_FILE }}" > .env + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Tests + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + python manage.py test --keepdb + \ No newline at end of file diff --git a/.gitignore b/.gitignore index 02fa3dc..c65f128 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ Makefile venv/ .env **/__pycache__/ -Dockerfile -docker-compose.yml \ No newline at end of file +.Dockerignore +.env.docker +alertmanager.yml +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bce96fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-alpine + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apk add --no-cache gcc musl-dev mariadb-connector-c-dev mariadb-dev pkgconfig + +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/ + +EXPOSE 8000 + +CMD ["daphne", "FD.asgi:application", "--bind", "0.0.0.0", "--port", "8000"] diff --git a/FD/__init__.py b/FD/__init__.py index e69de29..9e0d95f 100644 --- a/FD/__init__.py +++ b/FD/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/FD/celery.py b/FD/celery.py new file mode 100644 index 0000000..0d10b31 --- /dev/null +++ b/FD/celery.py @@ -0,0 +1,23 @@ +import os +from celery import Celery +from celery.schedules import crontab + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'FD.settings') + +app = Celery('FD') + +app.config_from_object('django.conf:settings', namespace='CELERY') +app.conf.broker_connection_retry_on_startup = True + +app.autodiscover_tasks() + +app.conf.beat_schedule = { + 'cache-matching-groups': { + 'task': 'home.tasks.cache_matching_groups', + 'schedule': crontab(hour=2), + }, + 'test-celery': { + 'task': 'home.tasks.test_celery', + 'schedule': crontab(minute=1), + }, +} \ No newline at end of file diff --git a/FD/settings.py b/FD/settings.py index 23525eb..b36571f 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -1,6 +1,8 @@ from datetime import timedelta from pathlib import Path from dotenv import load_dotenv + + import os load_dotenv() @@ -31,6 +33,8 @@ 'storages', 'django_filters', 'channels', + 'django_prometheus', + 'authentication', 'groups', @@ -42,7 +46,9 @@ ] MIDDLEWARE = [ + 'django_prometheus.middleware.PrometheusBeforeMiddleware', 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -51,6 +57,8 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + 'django_prometheus.middleware.PrometheusAfterMiddleware', ] CORS_ALLOW_ALL_ORIGINS = True @@ -74,7 +82,7 @@ }, ] -# WSGI_APPLICATION = 'FD.wsgi.application' + ASGI_APPLICATION = "FD.asgi.application" @@ -131,7 +139,7 @@ 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 1 + 'PAGE_SIZE': 3 } SPECTACULAR_SETTINGS = { @@ -169,18 +177,35 @@ -import urllib.parse -import redis -redis_url = os.environ.get('REDIS_URL') -parsed_url = urllib.parse.urlparse(redis_url) CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "hosts": [f"rediss://{parsed_url.netloc}"], - # "hosts": [("127.0.0.1", 6379)], + "hosts": [(os.getenv("REDIS_URL"))], }, }, -} \ No newline at end of file +} + + + +REDIS_URL = os.getenv("REDIS_URL") + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} + +CELERY_BROKER_URL = os.getenv("REDIS_URL") +CELERY_RESULT_BACKEND = os.getenv("REDIS_URL") +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'Asia/Tehran' + diff --git a/FD/urls.py b/FD/urls.py index 44e5363..a17a2ac 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -1,8 +1,40 @@ from django.contrib import admin from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from django_prometheus import exports +from rest_framework.response import Response +from rest_framework import status + +def trigger_error(request): + division_by_zero = 1 / 0 + +from rest_framework.decorators import api_view + +@api_view(['GET']) +def trigger_400(request): + return Response("Bad Request", status=status.HTTP_400_BAD_REQUEST) + +@api_view(['GET']) +def trigger_401(request): + return Response("Unauthorized", status=status.HTTP_401_UNAUTHORIZED) + +@api_view(['GET']) +def trigger_403(request): + return Response("Forbidden", status=status.HTTP_403_FORBIDDEN) + +@api_view(['GET']) +def trigger_404(request): + return Response("Not Found", status=status.HTTP_404_NOT_FOUND) + +@api_view(['GET']) +def trigger_500(request): + return Response("Internal Server Error", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + urlpatterns = [ + path('trigger_error/', trigger_error), + path('', include('django_prometheus.urls')), + path('admin/', admin.site.urls), path('authentication/', include('authentication.urls')), @@ -12,4 +44,13 @@ path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + + path('notifications/', include('notifications.urls')), + + path('trigger_400/', trigger_400), + path('trigger_401/', trigger_401), + path('trigger_403/', trigger_403), + path('trigger_404/', trigger_404), + path('trigger_500/', trigger_500), + ] diff --git a/alert_rules.yml b/alert_rules.yml new file mode 100644 index 0000000..ee6d9d1 --- /dev/null +++ b/alert_rules.yml @@ -0,0 +1,29 @@ +groups: +- name: website + rules: + - alert: WebsiteDown + expr: up{job="website"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Website is down" + description: "The website has been down for more than 1 minute" + + - alert: HighResponseTime + expr: prometheus_http_request_duration_seconds{job="website"} > 2 + for: 5m + labels: + severity: warning + annotations: + summary: "High response time" + description: "Website response time is above 2 seconds for 5 minutes" + + - alert: HighErrorRate + expr: rate(django_http_responses_total_by_status_total{status=~"5.."}[5m]) > 0 + for: 0s + labels: + severity: warning + annotations: + summary: "High error rate" + description: "Website is returning too many 5xx errors" diff --git a/authentication/migrations/0002_user_profile_image.py b/authentication/migrations/0002_user_profile_image.py deleted file mode 100644 index eff884f..0000000 --- a/authentication/migrations/0002_user_profile_image.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-18 22:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='profile_image', - field=models.ImageField(blank=True, null=True, upload_to='profile_images/'), - ), - ] diff --git a/authentication/migrations/0004_otp.py b/authentication/migrations/0004_otp.py new file mode 100644 index 0000000..c73803a --- /dev/null +++ b/authentication/migrations/0004_otp.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2024-12-27 16:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_alter_tempuser_otp'), + ] + + operations = [ + migrations.CreateModel( + name='OTP', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('otp', models.CharField(max_length=6)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='forget_password_otp', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/authentication/models.py b/authentication/models.py index 16c3d89..2b2672b 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -44,3 +44,11 @@ class TempUser(models.Model): def __str__(self): return self.username + +class OTP(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='forget_password_otp') + otp = models.CharField(max_length=6) + date_created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.otp diff --git a/authentication/serializers.py b/authentication/serializers.py index 03d5d25..38f2123 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -16,6 +16,12 @@ class VerifyEmailSerializer(serializers.Serializer): class Meta: model = TempUser fields = ['otp'] + +class ResendVerificationEmailSerializer(serializers.Serializer): + email = serializers.EmailField() + class Meta: + model = TempUser + fields = ['email'] class UserLoginSerializer(serializers.ModelSerializer): @@ -30,18 +36,16 @@ class Meta: class UserLogoutSerializer(serializers.Serializer): refresh_token = serializers.CharField() + class PasswordResetRequestSerializer(serializers.ModelSerializer): email = serializers.EmailField() class Meta: model = User fields = ['email'] - -class PasswordResetConfirmSerializer(serializers.ModelSerializer): - password = serializers.CharField(min_length=8, max_length=64, write_only=True) - class Meta: - model = User - fields = ['password'] + +class PasswordResetConfirmSerializer(serializers.Serializer): + otp = serializers.CharField(max_length=6) class UserRetriveSerializer(serializers.ModelSerializer): @@ -58,8 +62,7 @@ class Meta: class ChangePasswordSerializer(serializers.Serializer): - old_password = serializers.CharField(min_length=8, max_length=64, write_only=True) new_password = serializers.CharField(min_length=8, max_length=64, write_only=True) class Meta: model = User - fields = ['old_password', 'new_password'] + fields = ['new_password'] diff --git a/authentication/services.py b/authentication/services.py index 41ed5c1..69307d1 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -1,21 +1,37 @@ from datetime import timedelta +import random from django.utils import timezone from django.utils.encoding import smart_str, smart_bytes from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.mail import EmailMessage - from rest_framework.exceptions import ValidationError from rest_framework import status +import boto3 + +from notifications.models import Notification from .models import * from .utils import send_otp_email from FD import settings +from notifications.consumers import NotificationConsumer + class UserService: VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'] + + @staticmethod + def delete_s3_object(file_path): + s3 = boto3.client('s3', + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + ) + s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_path) + + @staticmethod @@ -33,11 +49,11 @@ def check_temp_user(cls, username, email): temp_user_by_username = TempUser.objects.filter(username=username).first() temp_user_by_email = TempUser.objects.filter(email=email).first() - if temp_user_by_username and (timezone.now() - temp_user_by_username.date_joined) > timedelta(minutes=0): + if temp_user_by_username and (timezone.now() - temp_user_by_username.date_joined) > timedelta(minutes=5): temp_user_by_username.delete() return - if temp_user_by_email and (timezone.now() - temp_user_by_email.date_joined) > timedelta(minutes=0): + if temp_user_by_email and (timezone.now() - temp_user_by_email.date_joined) > timedelta(minutes=5): temp_user_by_email.delete() return @@ -63,10 +79,29 @@ def verify_email(cls, otp): raise ValidationError({'detail': 'invalid one time password', 'status': status.HTTP_400_BAD_REQUEST}) temp_user = TempUser.objects.get(otp=otp) + + if User.objects.filter(username=temp_user.username).exists(): + raise ValidationError({'detail': 'user already verified', 'status': status.HTTP_400_BAD_REQUEST}) + user = User.objects.create(username=temp_user.username, email=temp_user.email, password=temp_user.password) user.set_password(temp_user.password) user.save() temp_user.delete() + + @staticmethod + def resend_verification_email(email): + if not TempUser.objects.filter(email=email).exists(): + raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + + temp_user = TempUser.objects.get(email=email) + + if (timezone.now() - temp_user.date_joined) < timedelta(minutes=0): + raise ValidationError({'detail': 'Please wait for 2 minutes before requesting another OTP', 'status': status.HTTP_400_BAD_REQUEST}) + + send_otp_email(temp_user.email) + temp_user.refresh_from_db() + temp_user.date_joined = timezone.now() + temp_user.save() @classmethod def check_level(cls, level): @@ -84,23 +119,27 @@ def update_user(cls, user, data): cls.check_level(data.get('level')) user.level = data.get('level') + if not user.profile_image and data.get('profile_image'): + path = data.get('profile_image').name + f'_{user.id}' + user.profile_image.save(path, data.get('profile_image')) + if user.profile_image and data.get('profile_image') == '': + cls.delete_s3_object(user.profile_image.name) + user.profile_image = None - + if user.profile_image and data.get('profile_image') and user.profile_image != data.get('profile_image'): + cls.delete_s3_object(user.profile_image.name) + path = data.get('profile_image').name + f'_{user.id}' + user.profile_image.save(path, data.get('profile_image')) + user.city = data.get('city', user.city) user.neighborhood = data.get('neighborhood', user.neighborhood) - user.profile_image = data.get('profile_image', user.profile_image) + user.save() return user @staticmethod - def change_password(user, old_password, new_password): - if not user.check_password(old_password): - raise ValidationError({'detail': 'Invalid password', 'status': status.HTTP_400_BAD_REQUEST}) - - if old_password == new_password: - raise ValidationError({'detail': 'New password cannot be the same as the old password', 'status': status.HTTP_400_BAD_REQUEST}) - + def change_password(user, new_password): user.set_password(new_password) user.save() return user @@ -132,33 +171,54 @@ def request_reset_password(email): user = User.objects.get(email=email) - uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) - token = PasswordResetTokenGenerator().make_token(user) + if (OTP.objects.filter(user=user).exists()) and (timezone.now() - OTP.objects.get(user=user).date_created) < timedelta(minutes=2): + raise ValidationError({'detail': 'Please wait for 2 minutes before requesting another OTP', 'status': status.HTTP_400_BAD_REQUEST}) - absolute_url = f'http://localhost:5173/reset-password/{uidb64}/{token}' - - email_subject = 'Reset your password' - email_body = f'Click the link below to reset your password\n{absolute_url}' + if OTP.objects.filter(user=user).exists(): + old_otp = OTP.objects.get(user=user) + old_otp.delete() + otp = random.randint(100000, 999999) + OTP.objects.create(user=user, otp=otp) + email_subject = 'Forgot Password' + email_body = f'Your OTP is {otp}' + email = EmailMessage(subject=email_subject, body=email_body, from_email=settings.EMAIL_HOST_USER, to=[email]) email.send() @staticmethod - def confirm_reset_password(uidb64, token, new_password): - id = smart_str(urlsafe_base64_decode(uidb64)) - - if not User.objects.filter(id=id).exists(): - raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) - - user = User.objects.get(id=id) - - is_valid = PasswordResetTokenGenerator().check_token(user, token) - if not is_valid: - raise ValidationError({'detail': 'The reset link is invalid', 'status': status.HTTP_400_BAD_REQUEST}) + def confirm_reset_password(otp): + print(otp) + print(OTP.objects.all()) + if not OTP.objects.filter(otp=otp).exists(): + raise ValidationError({'detail': 'Invalid OTP', 'status': status.HTTP_400_BAD_REQUEST}) + if (timezone.now() - OTP.objects.get(otp=otp).date_created) > timedelta(minutes=2): + raise ValidationError({'detail': 'OTP has expired', 'status': status.HTTP_400_BAD_REQUEST}) + + otp = OTP.objects.get(otp=otp) + user = otp.user + tokens = user.tokens() + otp.delete() + return { + 'email': user.email, + 'username': user.username, + 'access_token': str(tokens['access']), + 'refresh_token': str(tokens['refresh']), + } - user.set_password(new_password) - user.save() + + @classmethod + def delete_account(cls, user): + for group in user.owned_groups.all(): + for member in group.members.all(): + NotificationConsumer.send_notification(member, f"{group.title} has been deleted") + Notification.objects.create(recipient=member, message=f"{group.title} has been deleted") + group.delete() + + if user.profile_image: + cls.delete_s3_object(user.profile_image.name) - return user + user.delete() + \ No newline at end of file diff --git a/authentication/tests.py b/authentication/tests.py index 7ce503c..ba4b5d8 100644 --- a/authentication/tests.py +++ b/authentication/tests.py @@ -1,3 +1,163 @@ -from django.test import TestCase +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient +from .models import User, TempUser +import boto3 +from moto import mock_aws +from django.core import mail +from django.conf import settings +import os -# Create your tests here. +@pytest.fixture +def api_client(): + return APIClient() + +@pytest.fixture +def test_user(): + user = User.objects.create_user( + email='test@example.com', + username='testuser', + password='testpass123' + ) + return user + +@pytest.fixture +def test_temp_user(): + temp_user = TempUser.objects.create( + email='temp@example.com', + username='tempuser', + password='temppass123', + otp='123456' + ) + return temp_user + +@pytest.fixtureh +def auth_client(api_client, test_user): + tokens = test_user.tokens() + api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {tokens["access"]}') + return api_client + +@pytest.fixture +def aws_credentials(): + boto3.setup_default_session( + aws_access_key_id="testing", + aws_secret_access_key="testing", + aws_session_token="testing" + ) + +@pytest.fixture +def s3_bucket(aws_credentials): + with mock_aws(): + s3 = boto3.client('s3') + s3.create_bucket(Bucket=settings.AWS_STORAGE_BUCKET_NAME) + yield s3 + +@pytest.mark.django_db +class TestAuthentication: + def test_register_user_success(self, api_client): + url = reverse('register') + data = { + 'email': 'new@example.com', + 'username': 'newuser', + 'password': 'newpass123' + } + response = api_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED + assert TempUser.objects.filter(email=data['email']).exists() + + def test_verify_email_success(self, api_client, test_temp_user): + url = reverse('verify-email') + data = {'otp': test_temp_user.otp} + response = api_client.post(url, data) + assert response.status_code == status.HTTP_200_OK + assert User.objects.filter(email=test_temp_user.email).exists() + assert not TempUser.objects.filter(email=test_temp_user.email).exists() + + def test_login_success(self, api_client, test_user): + url = reverse('login') + data = { + 'username': test_user.username, + 'password': 'testpass123' + } + response = api_client.post(url, data) + assert response.status_code == status.HTTP_200_OK + assert 'access_token' in response.data + assert 'refresh_token' in response.data + + + def test_password_reset_request(self, api_client, test_user): + url = reverse('password-reset') + data = {'email': test_user.email} + response = api_client.post(url, data) + assert response.status_code == status.HTTP_200_OK + assert len(mail.outbox) == 1 + assert mail.outbox[0].to[0] == test_user.email + + otp = mail.outbox[0].body.split()[-1] + confirmation_url = reverse('password-reset-confirm') + data = {'otp': otp} + response = api_client.post(confirmation_url, data) + assert response.status_code == status.HTTP_200_OK + assert 'access_token' in response.data + assert 'refresh_token' in response.data + + + + + def test_change_password(self, auth_client, test_user): + url = reverse('change-password') + data = { + 'old_password': 'testpass123', + 'new_password': 'newpass123' + } + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + test_user.refresh_from_db() + assert test_user.check_password('newpass123') + + @mock_aws + def test_update_user_profile(self, auth_client, test_user, s3_bucket): + url = reverse('user-update') + with open('test_image.jpg', 'wb') as f: + f.write(b'fake image content') + + with open('test_image.jpg', 'rb') as image: + data = { + 'username': 'updateduser', + 'level': 'B2', + 'city': 'Test City', + 'profile_image': image + } + response = auth_client.patch(url, data, format='multipart') + + assert response.status_code == status.HTTP_200_OK + test_user.refresh_from_db() + assert test_user.username == 'updateduser' + assert test_user.level == 'B2' + assert test_user.city == 'Test City' + assert test_user.profile_image is not None + + os.remove('test_image.jpg') + + def test_delete_account(self, auth_client, test_user): + url = reverse('delete-account') + response = auth_client.delete(url) + assert response.status_code == status.HTTP_200_OK + assert not User.objects.filter(id=test_user.id).exists() + + def test_register_existing_user(self, api_client, test_user): + url = reverse('register') + data = { + 'email': test_user.email, + 'username': test_user.username, + 'password': 'newpass123' + } + response = api_client.post(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_login_failure_wrong_credentials(self, api_client, test_user): + url = reverse('login') + data = {'username': test_user.username, 'password': 'invalidpass'} + response = api_client.post(url, data) + assert response.status_code == status.HTTP_401_UNAUTHORIZED \ No newline at end of file diff --git a/authentication/urls.py b/authentication/urls.py index 4ed3806..0acdf69 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -6,20 +6,23 @@ ) urlpatterns = [ - path('register/', RegisterUserView.as_view()), - path('verify-email/', VerifyEmailView.as_view()), + path('register/', RegisterUserView.as_view(), name='register'), + path('verify-email/', VerifyEmailView.as_view(), name='verify-email'), + path('resend-verification-email/', ResendVerificationEmailView.as_view()), - path('login/', LoginUserView.as_view()), + path('login/', LoginUserView.as_view(), name='login'), + + + path('password-reset/', PasswordResetRequestView.as_view(), name='password-reset'), + path('password-reset-confirm/', PasswordResetConfirmView.as_view(), name='password-reset-confirm'), + path('change-password/', ChangePasswordView.as_view(), name='change-password'), + - path('password-reset/', PasswordResetRequestView.as_view()), - path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), - path('change-password/', ChangePasswordView.as_view()), - - path('users/retrieve/', UserRetriveView.as_view()), - path('users/delete/', UserDeleteView.as_view()), - path('users/update/', UserUpdateView.as_view()), + path('users/retrieve/', UserRetriveView.as_view(), name='user-retrieve'), + path('users/delete/', DeleteAccountView.as_view(), name='delete-account'), + path('users/update/', UserUpdateView.as_view(), name='user-update'), path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), diff --git a/authentication/utils.py b/authentication/utils.py index c659cd1..11c447b 100644 --- a/authentication/utils.py +++ b/authentication/utils.py @@ -13,7 +13,6 @@ def send_otp_email(email): otp = random.randint(100000, 999999) while TempUser.objects.filter(otp=otp).exists(): otp = random.randint(100000, 999999) - temp_user = TempUser.objects.get(email=email) temp_user.otp = otp temp_user.save() diff --git a/authentication/views.py b/authentication/views.py index ab6cd6d..e9fa925 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -4,9 +4,10 @@ from rest_framework.response import Response from rest_framework import status from rest_framework.parsers import MultiPartParser, FormParser, JSONParser - from rest_framework.permissions import IsAuthenticated + + from .serializers import * from .models import * from .services import * @@ -22,7 +23,9 @@ def post(self, request): return Response("user created successfully, please verify your email", status=status.HTTP_201_CREATED) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class VerifyEmailView(APIView): serializer_class = VerifyEmailSerializer @@ -36,8 +39,19 @@ def post(self, request): return Response("email verified successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) - +class ResendVerificationEmailView(APIView): + serializer_class = ResendVerificationEmailSerializer + def post(self, request): + try: + UserService.resend_verification_email(request.data['email']) + return Response("verification email sent successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class LoginUserView(APIView): serializer_class = UserLoginSerializer @@ -50,6 +64,9 @@ def post(self, request): return Response(tokens, status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class PasswordResetRequestView(APIView): @@ -64,21 +81,21 @@ def post(self, request): return Response("password reset link sent successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class PasswordResetConfirmView(APIView): serializer_class = PasswordResetConfirmSerializer - def post(self, request, *args, **kwargs): - serializer = PasswordResetConfirmSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - uidb64 = kwargs.get('uidb64') - token = kwargs.get('token') + def post(self, request): try: - UserService.confirm_reset_password(uidb64, token, serializer.validated_data['password']) - return Response("password reset successfully", status=status.HTTP_200_OK) + tokens = UserService.confirm_reset_password(request.data['otp']) + return Response(tokens, status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class UserRetriveView(APIView): @@ -86,30 +103,13 @@ class UserRetriveView(APIView): serializer_class= UserRetriveSerializer def get(self, request): - user = request.user - - serializer = self.serializer_class(user) - - return Response(serializer.data, status=status.HTTP_200_OK) - + try: + user = request.user + serializer = self.serializer_class(user) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) -class UserDeleteView(APIView): - permission_classes = [IsAuthenticated] - serializer_class= UserDeleteSerializer - - def delete(self, request): - - user = request.user - serializer = self.serializer_class(data = request.data) - - serializer.is_valid(raise_exception=True) - - if not user.check_password(serializer.validated_data.get('password')): - return Response("invalid password", status=status.HTTP_400_BAD_REQUEST) - - user.delete() - return Response("user deleted successfully", status=status.HTTP_200_OK) - class UserUpdateView(APIView): permission_classes = [IsAuthenticated] @@ -122,9 +122,13 @@ def patch(self, request): return Response("user updated successfully", status=status.HTTP_200_OK) except User.DoesNotExist: return Response("user not found", status=status.HTTP_404_NOT_FOUND) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class ChangePasswordView(APIView): + permission_classes = [IsAuthenticated] + serializer_class= ChangePasswordSerializer def patch(self, request): @@ -135,15 +139,25 @@ def patch(self, request): username = request.user.username user = User.objects.get(username=username) - old_password = serializer.validated_data.get('old_password') new_password = serializer.validated_data.get('new_password') - UserService.change_password(user, old_password, new_password) - - except User.DoesNotExist: - return Response("user not found", status=status.HTTP_404_NOT_FOUND) + UserService.change_password(user, new_password) + return Response("password changed successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) - return Response("password changed successfully", status=status.HTTP_200_OK) + + +class DeleteAccountView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request): + try: + UserService.delete_account(request.user) + return Response("account deleted successfully", status=status.HTTP_200_OK) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + diff --git a/chat/consumers.py b/chat/consumers.py index 3f691c8..1e025ec 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -17,44 +17,28 @@ from rest_framework import status from rest_framework.response import Response +from django.db.models import F + class ChatConsumer(WebsocketConsumer): - connected_users = set() + connected_users = {} def connect(self): self.room_name = self.scope["url_route"]["kwargs"]["room_name"] - self.room_group_name = f"chat_{self.room_name}" try: self.chat = Chat.objects.get(group__title=self.room_name) except Chat.DoesNotExist: self.close() - headers = dict(self.scope['headers']) - auth_header = headers.get(b'authorization', b'').decode() + token = self.scope['url_route']['kwargs']['token'] + user = self.get_user_from_token(token) - if auth_header.startswith('Bearer '): - token = auth_header.split(' ')[1] - user = self.get_user_from_token(token) - if user: - self.scope['user'] = user - self.connected_users.add(user.id) - else: - self.accept() - self.send(text_data=json.dumps({ - 'type': 'error', - 'message': 'You are not authenticated' - })) - self.close() - return - else: - self.accept() - self.send(text_data=json.dumps({ - 'type': 'error', - 'message': 'You are not authenticated' - })) + if not user: self.close() - return + self.scope['user'] = user + self.connected_users[user.id] = self.connected_users.get(user.id, 0) + 1 + if user not in self.chat.group.members.all(): self.accept() self.send(text_data=json.dumps({ @@ -63,23 +47,28 @@ def connect(self): })) self.close() + # Only join the room group async_to_sync(self.channel_layer.group_add)( - self.room_group_name, self.channel_name + self.room_name, + self.channel_name ) self.accept() - messages = self.get_chat_messages() + messages = self.get_chat_messages() self.send(text_data=json.dumps({ 'type': 'chat_history', 'messages': messages }, cls=DjangoJSONEncoder)) - def get_chat_messages(self): return list(self.chat.messages.all().order_by('-timestamp').values( 'content', 'sender__username', 'timestamp' - )) + ).annotate( + message=F('content'), + username=F('sender__username'), + ).values('message', 'username', 'timestamp')) + def get_user_from_token(self, token): try: @@ -90,11 +79,15 @@ def get_user_from_token(self, token): return None def disconnect(self, close_code): - # Leave room group + # Only leave the room group async_to_sync(self.channel_layer.group_discard)( - self.room_group_name, self.channel_name + self.room_name, + self.channel_name ) - self.connected_users.remove(self.scope["user"].id) + + self.connected_users[self.scope['user'].id] = self.connected_users.get(self.scope['user'].id, 1) - 1 + if self.connected_users[self.scope['user'].id] <= 0: + del self.connected_users[self.scope['user'].id] def receive(self, text_data): text_data_json = json.loads(text_data) @@ -107,27 +100,26 @@ def receive(self, text_data): sender=user ) + # Broadcast to room async_to_sync(self.channel_layer.group_send)( - self.room_group_name, + self.room_name, { - "type": "chat_message", - "message": db_message.content, + "type": "chat_message", + "message": db_message.content, "username": db_message.sender.username, "timestamp": db_message.timestamp.isoformat(), } ) - group_members = Group.objects.get(title=self.room_name).members.exclude(id__in=self.connected_users) - print(self.connected_users) + # Send notifications to offline members + group_members = self.chat.group.members.exclude(id__in=self.connected_users.keys()) for member in group_members: - print(member.username) NotificationConsumer.send_notification(member, f"New message in {self.room_name} from {user.username}") - # Receive message from room group def chat_message(self, event): - # Send message to WebSocket + self.send(text_data=json.dumps({ 'message': event['message'], 'username': event['username'], 'timestamp': event['timestamp'] - })) \ No newline at end of file + })) diff --git a/chat/routing.py b/chat/routing.py index 35a1b39..033e5c7 100644 --- a/chat/routing.py +++ b/chat/routing.py @@ -3,5 +3,6 @@ from . import consumers chat_websocket_urlpatterns = [ - re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), + re_path(r"ws/chat/(?P\w+)/(?P[-\w.]+)/$", consumers.ChatConsumer.as_asgi()), + ] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..859c377 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,97 @@ +services: + web: + image: ghcr.io/kianyari/web:latest + container_name: web + env_file: + - ./.env + ports: + - 8000:8000 + networks: + - fd + depends_on: + - redis + + prometheus: + image: prom/prometheus + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - ./alert_rules.yml:/etc/prometheus/alert_rules.yml + - prometheus_data:/prometheus + ports: + - 9090:9090 + networks: + - fd + + grafana: + image: grafana/grafana + container_name: grafana + volumes: + - grafana_data:/var/lib/grafana + ports: + - 3000:3000 + networks: + - fd + + redis: + image: redis:latest + container_name: redis + ports: + - 6379:6379 + networks: + - fd + + alertmanager: + image: prom/alertmanager + container_name: alertmanager + volumes: + - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml + ports: + - 9093:9093 + networks: + - fd + + celery-worker: + image: ghcr.io/kianyari/web:latest + container_name: celery-worker + command: celery -A FD worker -l INFO + env_file: + - ./.env + networks: + - fd + depends_on: + - web + + celery-beat: + image: ghcr.io/kianyari/web:latest + container_name: celery-beat + command: celery -A FD beat -l INFO + env_file: + - ./.env + networks: + - fd + depends_on: + - web + + elasticsearch: + image: elasticsearch:7.14.1 + container_name: elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + ports: + - 9200:9200 + volumes: + - esdata01:/usr/share/elasticsearch/data + networks: + - fd + +volumes: + esdata01: + prometheus_data: + grafana_data: + + +networks: + fd: + driver: bridge \ No newline at end of file diff --git a/groups/permissions.py b/groups/permissions.py deleted file mode 100644 index 4ee7820..0000000 --- a/groups/permissions.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework.permissions import BasePermission - -class IsGroupOwner(BasePermission): - def has_object_permission(self, request, view, obj): - return obj.owner == request.user - -class IsGroupMember(BasePermission): - def has_object_permission(self, request, view, obj): - return request.user in obj.members.all() \ No newline at end of file diff --git a/groups/serializers.py b/groups/serializers.py index 31b4530..814861f 100644 --- a/groups/serializers.py +++ b/groups/serializers.py @@ -54,7 +54,4 @@ class GroupKickSerializer(serializers.Serializer): class GroupMemberListSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['username', 'profile_image', 'level'] - - - \ No newline at end of file + fields = ['username', 'profile_image', 'level'] \ No newline at end of file diff --git a/groups/services.py b/groups/services.py index ac406d7..a619700 100644 --- a/groups/services.py +++ b/groups/services.py @@ -7,6 +7,11 @@ from notifications.consumers import NotificationConsumer from notifications.models import Notification +import boto3 + +from FD import settings + + from channels.layers import get_channel_layer from asgiref.sync import async_to_sync @@ -14,16 +19,27 @@ class GroupService: VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + @staticmethod + def delete_s3_object(file_path): + s3 = boto3.client('s3', + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + ) + s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_path) + + def check_title(title): if Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group title already exists', 'status': status.HTTP_400_BAD_REQUEST}) def check_level(level): if level not in GroupService.VALID_LEVELS: - raise ValidationError({'detail': f'Invalid level. Please select one of the following: {", ".join(Group.VALID_LEVELS)}', 'status': status.HTTP_400_BAD_REQUEST}) + raise ValidationError({'detail': f'Invalid level. Please select one of the following: {", ".join(GroupService.VALID_LEVELS)}', 'status': status.HTTP_400_BAD_REQUEST}) @classmethod def create_group(cls, user, data): + cls.check_level(data.get('level')) cls.check_title(data.get('title')) group = Group.objects.create(owner=user, **data) Chat.objects.create(group=group) @@ -31,38 +47,60 @@ def create_group(cls, user, data): return group @staticmethod - def retrieve_group(title): + def retrieve_group(title, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if not Group.objects.get(title=title).members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_403_FORBIDDEN}) group = Group.objects.get(title=title) return group @staticmethod - def update_group(title, data): + def update_group(title, data, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) - group = Group.objects.get(title=title) + group = Group.objects.get(title=title) + + if group.owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) if 'title' in data and data.get('title') != group.title: GroupService.check_title(data.get('title')) for member in group.members.all(): NotificationConsumer.send_notification(member, f"{group.title} has been renamed to {data.get('title')}") + Notification.objects.create(recipient=member, message=f"{group.title} has been renamed to {data.get('title')}") group.title = data.get('title') if 'description' in data and data.get('description') != group.description: for member in group.members.all(): - NotificationConsumer.send_notification(member, f"{member.username} updated the description of {group.title}") + NotificationConsumer.send_notification(member, f"{member.username} updated the noticeboard of {group.title}") + Notification.objects.create(recipient=member, message=f"{member.username} updated the noticeboard of {group.title}") group.description = data.get('description') if 'level' in data and data.get('level') != group.level: GroupService.check_level(data.get('level')) for member in group.members.all(): NotificationConsumer.send_notification(member, f"{group.title} level has been updated to {data.get('level')}") + Notification.objects.create(recipient=member, message=f"{group.title} level has been updated to {data.get('level')}") group.level = data.get('level') - group.image = data.get("image", group.image) + if not group.image and data.get('image'): + path = data.get('image').name + f'_{group.id}' + group.image.save(path, data.get('image')) + + if group.image and data.get('image') == '': + GroupService.delete_s3_object(group.image.name) + group.image = None + + if group.image and data.get('image') and group.image != data.get('image'): + GroupService.delete_s3_object(group.image.name) + path = data.get('image').name + f'_{group.id}' + group.image.save(path, data.get('image')) + + group.private = data.get("private", group.private) group.meeting_url = data.get("meeting_url", group.meeting_url) group.neighborhood = data.get("neighborhood", group.neighborhood) @@ -70,11 +108,21 @@ def update_group(title, data): group.save() - @staticmethod - def delete_group(title): + @classmethod + def delete_group(cls, title, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + + if group.owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + + for member in group.members.all(): + NotificationConsumer.send_notification(member, f"{group.title} has been deleted") + Notification.objects.create(recipient=member, message=f"{group.title} has been deleted") + if group.image: + cls.delete_s3_object(group.image.name) + group.delete() @staticmethod @@ -101,6 +149,7 @@ def join_request(title, user): message = f"{user.username} joined {group.title}" NotificationConsumer.send_notification(group.owner, message) + Notification.objects.create(recipient=group.owner, message=message) return group_status @@ -112,34 +161,63 @@ def cancel_join_request(title, user): group.pending_members.remove(user) @staticmethod - def pending_requests(title): + def pending_requests(title, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + + if group.owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + return group.pending_members.all() @staticmethod - def accept_request(title, username): + def accept_request(title, username, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + if not User.objects.filter(username=username).exists(): raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + if User.objects.get(username=username) not in Group.objects.get(title=title).pending_members.all(): + raise ValidationError({'detail': 'This user has not sent a request to join this group', 'status': status.HTTP_400_BAD_REQUEST}) + + if User.objects.get(username=username) in Group.objects.get(title=title).members.all(): + raise ValidationError({'detail': 'This user is already a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) + user = User.objects.get(username=username) group = Group.objects.get(title=title) + + group.accept_pending_member(user) NotificationConsumer.send_notification(user, f"your request to join {group.title} has been accepted") + Notification.objects.create(recipient=user, message=f"your request to join {group.title} has been accepted") @staticmethod - def decline_request(title, username): + def decline_request(title, username, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + if not User.objects.filter(username=username).exists(): raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + if User.objects.get(username=username) not in Group.objects.get(title=title).pending_members.all(): + raise ValidationError({'detail': 'This user has not sent a request to join this group', 'status': status.HTTP_400_BAD_REQUEST}) + + if User.objects.get(username=username) in Group.objects.get(title=title).members.all(): + raise ValidationError({'detail': 'This user is already a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) + user = User.objects.get(username=username) group = Group.objects.get(title=title) + + group.decline_pending_member(user) @staticmethod @@ -148,19 +226,27 @@ def leave_group(title, user): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + if not group.members.filter(username=user.username).exists(): raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) - group.remove_member(user) if group.owner == user: - group.delete() + raise ValidationError({'detail': 'You cannot leave your own group', 'status': status.HTTP_400_BAD_REQUEST}) + + + group.remove_member(user) + + group.save() @staticmethod - def kick_member(title, username): + def kick_member(title, username, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + if not User.objects.filter(username=username).exists(): raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) @@ -170,16 +256,21 @@ def kick_member(title, username): user = User.objects.get(username=username) group = Group.objects.get(title=title) + if not group.members.filter(username=user.username).exists(): raise ValidationError({'detail': 'this user is not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) group.remove_member(user) group.save() @staticmethod - def member_list(title): + def member_list(title, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + + if not group.members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_403_FORBIDDEN}) + return group.members.all() diff --git a/groups/test.py b/groups/test.py new file mode 100644 index 0000000..7db0e72 --- /dev/null +++ b/groups/test.py @@ -0,0 +1,213 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status + +from groups.models import Group +from groups.services import GroupService +from authentication.models import User + +class TestGroupAPI(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user( + username="testuser", + password="testpassword", + email="test@gmail.com" + ) + + cls.group_data = { + "title": "Test Group", + "description": "This is a test group", + "level": "A1", + "city": "Test City", + "neighborhood": "Test Neighborhood", + "meeting_url": "https://example.com", + "private": False + } + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_create_group_api(self): + url = reverse('create-group') + response = self.client.post(url, self.group_data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Group.objects.count(), 1) + self.assertEqual(Group.objects.get().title, 'Test Group') + + def test_retrieve_group_api(self): + group = GroupService.create_group(self.user, self.group_data) + url = reverse('retrieve-group', kwargs={'title': group.title}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['title'], group.title) + group.delete() + + def test_update_group_api(self): + group = GroupService.create_group(self.user, self.group_data) + url = reverse('update-group', kwargs={'title': group.title}) + updated_data = { + "description": "Updated description", + "level": "B1" + } + response = self.client.patch(url, updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + group.refresh_from_db() + self.assertEqual(group.description, "Updated description") + self.assertEqual(group.level, "B1") + group.delete() + + def test_delete_group_api(self): + group = GroupService.create_group(self.user, self.group_data) + url = reverse('delete-group', kwargs={'title': group.title}) + response = self.client.delete(url) + self.assertEqual(self.user.groups.count(), 0) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Group.objects.count(), 0) + + def test_join_request_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + self.client.force_authenticate(user=other_user) + + url = reverse('join-group', kwargs={'title': group.title}) + + + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(group.members.filter(username="other_user").exists()) + group.members.remove(other_user) + + group.private = True + group.save() + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(group.pending_members.filter(username="other_user").exists()) + other_user.delete() + group.delete() + + def test_cancel_request_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_pending_member(other_user) + self.client.force_authenticate(user=other_user) + + url = reverse('cancel-join-request', kwargs={'title': group.title}) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(group.pending_members.filter(username="other_user").exists()) + other_user.delete() + group.delete() + + def test_leave_group_api(self): + group = GroupService.create_group(self.user, self.group_data) + + url = reverse('leave-group', kwargs={'title': group.title}) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(group.members.filter(username=self.user.username).exists()) + group.delete() + + def test_member_list_api(self): + group = GroupService.create_group(self.user, self.group_data) + url = reverse('retrieve-members', kwargs={'title': group.title}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], group.members.count()) + group.delete() + + def test_pending_member_list_api(self): + group = GroupService.create_group(self.user, self.group_data) + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_pending_member(other_user) + url = reverse('retrieve-pending-members', kwargs={'title': group.title}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], group.pending_members.count()) + other_user.delete() + group.delete() + + def test_accept_request_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_pending_member(other_user) + self.client.force_authenticate(user=other_user) + + data = { + "username": other_user.username + } + + url = reverse('accept-request', kwargs={'title': group.title}) + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(group.members.filter(username="other_user").exists()) + self.assertFalse(group.pending_members.filter(username="other_user").exists()) + other_user.delete() + group.delete() + + def test_decline_request_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_pending_member(other_user) + self.client.force_authenticate(user=other_user) + + data = { + "username": other_user.username + } + url = reverse('decline-request', kwargs={'title': group.title}) + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(group.pending_members.filter(username="other_user").exists()) + other_user.delete() + group.delete() + + def test_kick_member_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_member(other_user) + self.client.force_authenticate(user=self.user) + + data = { + "username": other_user.username + } + + url = reverse('kick-member', kwargs={'title': group.title}) + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(group.members.filter(username="other_user").exists()) + other_user.delete() + group.delete() + + def cleanup(self): + self.user.delete() \ No newline at end of file diff --git a/groups/tests.py b/groups/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/groups/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/groups/urls.py b/groups/urls.py index c5d8c94..af8edab 100644 --- a/groups/urls.py +++ b/groups/urls.py @@ -2,20 +2,21 @@ from .views import * urlpatterns = [ - path('create/', GroupCreateView.as_view()), - path('/', GroupRetrieveView.as_view()), #retrieve - path('/delete/', GroupDeleteView.as_view()), - path('/update/', GroupUpdateView.as_view()), + path('create/', GroupCreateView.as_view(), name='create-group'), + path('/', GroupRetrieveView.as_view(), name='retrieve-group'), + path('/update/', GroupUpdateView.as_view(), name='update-group'), + path('/delete/', GroupDeleteView.as_view(), name='delete-group'), - path('/members/', GroupMemberListView.as_view()), - path('/join/', GroupJoinRequestView.as_view()), - path('/cancel/', GroupCancelRequestView.as_view()), + path('/join/', GroupJoinRequestView.as_view(), name='join-group'), + path('/cancel/', GroupCancelRequestView.as_view(), name='cancel-join-request'), + path('/leave/', GroupLeaveView.as_view(), name='leave-group'), - path('/leave/', GroupLeaveView.as_view()), - path('/pending/', GroupPendingRequestView.as_view()), - path('/accept/', GroupAcceptRequestView.as_view()), - path('/decline/', GroupDeclineRequestView.as_view()), - path('/kick/', GroupKickView.as_view()), + path('/members/', GroupMemberListView.as_view(), name='retrieve-members'), + path('/pending/', GroupPendingRequestView.as_view(), name='retrieve-pending-members'), + path('/accept/', GroupAcceptRequestView.as_view(), name='accept-request'), + path('/decline/', GroupDeclineRequestView.as_view(), name='decline-request'), + path('/kick/', GroupKickView.as_view(), name='kick-member'), + ] \ No newline at end of file diff --git a/groups/views.py b/groups/views.py index 0da1889..f785d9c 100644 --- a/groups/views.py +++ b/groups/views.py @@ -9,7 +9,6 @@ from authentication.models import User from .serializers import * from .models import * -from .permissions import * from .services import * @@ -27,43 +26,56 @@ def post(self, request): return Response("group created successfully", status=status.HTTP_201_CREATED) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupRetrieveView(APIView): + permission_classes = [IsAuthenticated] serializer_class = GroupRetrieveSerializer def get(self, request, *args, **kwargs): try: - group = GroupService.retrieve_group(kwargs.get('title')) + group = GroupService.retrieve_group(kwargs.get('title'), request.user) serializer = self.serializer_class(group) return Response(serializer.data, status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupUpdateView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser, FormParser, JSONParser] def patch(self, request, *args, **kwargs): try: - GroupService.update_group(kwargs.get('title'), request.data) + GroupService.update_group(kwargs.get('title'), request.data, request.user) return Response("group updated successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupDeleteView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] + def delete(self, request, *args, **kwargs): try: - GroupService.delete_group(kwargs.get('title')) + GroupService.delete_group(kwargs.get('title'), request.user) return Response("group deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupJoinRequestView(APIView): permission_classes = [IsAuthenticated] @@ -77,65 +89,79 @@ def post(self, request, *args, **kwargs): except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupCancelRequestView(APIView): permission_classes = [IsAuthenticated] + def post(self, request, *args, **kwargs): try: GroupService.cancel_join_request(kwargs.get('title'), request.user) return Response("request cancelled successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupPendingRequestView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] serializer_class = GroupPendingRequestSerializer pagination_class = PageNumberPagination def get(self, request, *args, **kwargs): try: - group_requests = GroupService.pending_requests(kwargs.get('title')) + group_requests = GroupService.pending_requests(kwargs.get('title'), request.user) paginator = self.pagination_class() paginated_data = paginator.paginate_queryset(group_requests, request) serializer = self.serializer_class(paginated_data, many=True) return paginator.get_paginated_response(serializer.data) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupAcceptRequestView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] serializer_class = GroupAcceptRequestSerializer def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - GroupService.accept_request(kwargs.get('title'), serializer.validated_data['username']) + GroupService.accept_request(kwargs.get('title'), serializer.validated_data['username'], request.user) return Response("request accepted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + class GroupDeclineRequestView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] serializer_class = GroupDeclineRequestSerializer def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - GroupService.decline_request(kwargs.get('title'), serializer.validated_data['username']) - return Response("request accepted successfully", status=status.HTTP_200_OK) + GroupService.decline_request(kwargs.get('title'), serializer.validated_data['username'], request.user) + return Response("request declined successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + - class GroupLeaveView(APIView): - permission_classes = [IsAuthenticated, IsGroupMember] + permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): try: @@ -143,36 +169,47 @@ def post(self, request, *args, **kwargs): return Response("group left successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupKickView(APIView): serializer_class = GroupKickSerializer - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] + def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - GroupService.kick_member(kwargs.get('title'), serializer.validated_data['username']) + GroupService.kick_member(kwargs.get('title'), serializer.validated_data['username'], request.user) return Response("user kicked successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupMemberListView(APIView): permission_classes = [IsAuthenticated] serializer_class = GroupMemberListSerializer pagination_class = PageNumberPagination - + def get(self, request, *args, **kwargs): try: - members = GroupService.member_list(kwargs.get('title')) + print(request.user) + members = GroupService.member_list(kwargs.get('title'), request.user) paginator = self.pagination_class() + paginator.page_size = 4 + paginated_data = paginator.paginate_queryset(members, request) serializer = self.serializer_class(paginated_data, many=True) return paginator.get_paginated_response(serializer.data) except ValidationError as e: - return Response(e.detail.get('detail'), status=e.detail.get('status')) \ No newline at end of file + return Response(e.detail.get('detail'), status=e.detail.get('status')) + # except Exception as e: + # return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + diff --git a/home/migrations/0002_usersuggestions.py b/home/migrations/0002_usersuggestions.py new file mode 100644 index 0000000..abcc0d9 --- /dev/null +++ b/home/migrations/0002_usersuggestions.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.2 on 2024-12-19 21:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserSuggestions', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField()), + ('group_ids', models.TextField()), + ], + ), + ] diff --git a/home/models.py b/home/models.py index 94d3f33..8a2d75b 100644 --- a/home/models.py +++ b/home/models.py @@ -20,4 +20,8 @@ class GroupTimeSlot(models.Model): group = models.ForeignKey('groups.Group', models.CASCADE, related_name='time_slots') def __str__(self): - return f"{self.day_of_week} {self.start_time} - {self.end_time}" \ No newline at end of file + return f"{self.day_of_week} {self.start_time} - {self.end_time}" + +class UserSuggestions(models.Model): + user_id = models.IntegerField() + group_ids = models.TextField() \ No newline at end of file diff --git a/home/permissions.py b/home/permissions.py deleted file mode 100644 index 4ee7820..0000000 --- a/home/permissions.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework.permissions import BasePermission - -class IsGroupOwner(BasePermission): - def has_object_permission(self, request, view, obj): - return obj.owner == request.user - -class IsGroupMember(BasePermission): - def has_object_permission(self, request, view, obj): - return request.user in obj.members.all() \ No newline at end of file diff --git a/home/serializers.py b/home/serializers.py index 93f9723..ad3f6ae 100644 --- a/home/serializers.py +++ b/home/serializers.py @@ -23,19 +23,40 @@ class Meta: fields = ['id', 'day_of_week', 'start_time', 'end_time'] class SuggestionSerializer(serializers.ModelSerializer): + requested = serializers.SerializerMethodField() class Meta: model = Group - fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private'] + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'requested'] + def get_requested(self, obj): + request = self.context['request'] + return request.user in obj.pending_members.all() + class GroupSerializer(serializers.ModelSerializer): member_count = serializers.SerializerMethodField() + requested = serializers.SerializerMethodField() class Meta: model = Group - fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'member_count'] + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'member_count', 'requestd'] + def get_member_count(self, obj): return obj.members.count() + +class AllGroupsSerializer(serializers.ModelSerializer): + member_count = serializers.SerializerMethodField() + requested = serializers.SerializerMethodField() + + class Meta: + model = Group + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'member_count', 'requested'] + def get_member_count(self, obj): + return obj.members.count() + + def get_requested(self, obj): + request = self.context['request'] + return request.user in obj.pending_members.all() \ No newline at end of file diff --git a/home/services.py b/home/services.py index a35701b..cd7242d 100644 --- a/home/services.py +++ b/home/services.py @@ -1,4 +1,16 @@ -from django.db.models import QuerySet, Q +import redis +from dotenv import load_dotenv +import os +from groups.models import Group + +from django.db.models import Case, When, Value, FloatField, F, ExpressionWrapper, Sum +from django.db.models.functions import Coalesce +import random + +load_dotenv() + + +from django.db.models import QuerySet from rest_framework.exceptions import ValidationError from rest_framework import status @@ -7,7 +19,7 @@ from .models import * from groups.models import * -from .filters import GroupFilter + class UserTimeSlotService: @staticmethod @@ -25,8 +37,8 @@ def validate_overlap(user, day_of_week: str, start_time: float, end_time: float) overlapping = UserTimeSlot.objects.filter( user=user, day_of_week=day_of_week.lower(), - start_time__lt=end_time, - end_time__gt=start_time + start_time__lte=end_time, + end_time__gte=start_time ) if overlapping.exists(): @@ -81,8 +93,8 @@ def validate_overlap(group, day_of_week: str, start_time: float, end_time: float overlapping = GroupTimeSlot.objects.filter( group=group, day_of_week=day_of_week.lower(), - start_time__lt=end_time, - end_time__gt=start_time + start_time__lte=end_time, + end_time__gte=start_time ) if overlapping.exists(): @@ -90,8 +102,16 @@ def validate_overlap(group, day_of_week: str, start_time: float, end_time: float @classmethod - def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_time: float) -> GroupTimeSlot: + def create_group_time_slot(cls, title, user, day_of_week: str, start_time: float, end_time: float) -> GroupTimeSlot: + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + group = Group.objects.get(title=title) + print(group.owner) + print(user) cls.validate_time_range(start_time, end_time) cls.validate_overlap(group, day_of_week, start_time, end_time) @@ -104,20 +124,29 @@ def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_ return time_slot - def get_group_time_slots(group) -> QuerySet[GroupTimeSlot]: - return GroupTimeSlot.objects.filter(group=group) + def get_group_time_slots(title, user) -> QuerySet[GroupTimeSlot]: + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if not Group.objects.get(title=title).members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_403_FORBIDDEN}) + + group = Group.objects.get(title=title) + return group.time_slots.all() @staticmethod - def delete_time_slot(user, time_slot_id: int) -> None: - time_slot = GroupTimeSlot.objects.filter( - id=time_slot_id, - user=user - ).first() + def delete_time_slot(title, time_slot_id: int, user) -> None: + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) - if not time_slot: + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + + group = Group.objects.get(title=title) + if not group.time_slots.filter(id=time_slot_id).exists(): raise ValidationError({'detail': 'Time slot not found', 'status': status.HTTP_404_NOT_FOUND}) + time_slot = group.time_slots.filter(id=time_slot_id).first() - time_slot.delete() @@ -136,30 +165,68 @@ def get_joined_groups(user, request) -> list[Group]: class SuggestionService: def get_suggestions(user) -> list[Group]: + + if UserSuggestions.objects.filter(user_id=user.id).exists(): + group_ids = UserSuggestions.objects.get(user_id=user.id).group_ids + group_ids = group_ids.split(',') + group_ids = [int(group_id) for group_id in group_ids] + groups = Group.objects.filter(id__in=group_ids) + return groups + + VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + user_level = user.level.upper() + level_index = VALID_LEVELS.index(user_level) + levels_to_consider = [user_level] + if level_index > 0: + levels_to_consider.append(VALID_LEVELS[level_index - 1]) + if level_index < len(VALID_LEVELS) - 1: + levels_to_consider.append(VALID_LEVELS[level_index + 1]) + user_time_slots = UserTimeSlotService.get_user_time_slots(user) - groups = Group.objects.exclude(members=user) - group_matches = [] - - for group in groups: - group_time_slots = GroupTimeSlotService.get_group_time_slots(group) - total_overlap = 0 - - for user_slot in user_time_slots: - for group_slot in group_time_slots: - if user_slot.day_of_week == group_slot.day_of_week: - overlap = min(user_slot.end_time, group_slot.end_time) - max(user_slot.start_time, group_slot.start_time) - if overlap > 0: - total_overlap += overlap - - # if total_overlap > 0: - group_matches.append((group, total_overlap)) - - group_matches.sort(key=lambda x: x[1], reverse=True) - group_matches = [group[0] for group in group_matches] - return group_matches - + user_group_ids = list(user.joined_groups.values_list('id', flat=True)) + + + groups = Group.objects.filter(level__in=levels_to_consider)\ + .exclude(id__in=user_group_ids)\ + .annotate( + total_overlap=Coalesce( + Sum( + ExpressionWrapper( + Case( + *[ + When( + time_slots__day_of_week=user_slot.day_of_week, + then=F('time_slots__end_time') - F('time_slots__start_time') + ) + for user_slot in user_time_slots + ], + output_field=FloatField() + ), + output_field=FloatField() + ) + ), + Value(0.0), + ) + ) + + if user.neighborhood: + groups = groups.annotate( + total_overlap=ExpressionWrapper( + F('total_overlap') * Case( + When(neighborhood=user.neighborhood, then=1.3), + default=1, + output_field=FloatField() + ), + output_field=FloatField() + ) + ) + + best_fit_groups = groups.order_by('-total_overlap')[:25] + UserSuggestions.objects.create(user_id=user.id, group_ids=','.join([str(group.id) for group in best_fit_groups])) + return best_fit_groups class AllGroupsService: @staticmethod - def get_all_groups() -> QuerySet[Group]: - return Group.objects.all() + def get_all_groups(user) -> QuerySet[Group]: + return Group.objects.exclude(members=user) + diff --git a/home/tasks.py b/home/tasks.py new file mode 100644 index 0000000..fd9415c --- /dev/null +++ b/home/tasks.py @@ -0,0 +1,80 @@ +from django.db.models import F, ExpressionWrapper, FloatField, Case, When +from celery import shared_task +from django.contrib.auth import get_user_model +import redis +from dotenv import load_dotenv +import os + +from groups.models import Group +from home.services import UserTimeSlotService, GroupTimeSlotService +from home.models import GroupTimeSlot + +from django.db.models import Case, When, Value, FloatField, F, ExpressionWrapper, Sum +from django.db.models.functions import Coalesce + +from home.models import UserSuggestions + +load_dotenv() + + +@shared_task +def find_matching_groups(user): + VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + user_level = user.level.upper() + level_index = VALID_LEVELS.index(user_level) + levels_to_consider = [user_level] + if level_index > 0: + levels_to_consider.append(VALID_LEVELS[level_index - 1]) + if level_index < len(VALID_LEVELS) - 1: + levels_to_consider.append(VALID_LEVELS[level_index + 1]) + + user_time_slots = UserTimeSlotService.get_user_time_slots(user) + + groups = Group.objects.filter(level__in=levels_to_consider)\ + .exclude(id__in=[group.id for group in user.groups.all()])\ + .order_by('?')[:1000].annotate( + total_overlap=Coalesce( + Sum( + ExpressionWrapper( + Case( + *[ + When( + time_slots__day_of_week=user_slot.day_of_week, + then=F('time_slots__end_time') - F('time_slots__start_time') + ) + for user_slot in user_time_slots + ], + output_field=FloatField() + ), + output_field=FloatField() + ) + ), + Value(0.0), + output_field=FloatField() + ) + ) + + if user.neighborhood: + groups = groups.annotate( + total_overlap=ExpressionWrapper( + F('total_overlap') * Case( + When(neighborhood=user.neighborhood, then=1.3), + default=1, + output_field=FloatField() + ), + output_field=FloatField() + ) + ) + + best_fit_groups = groups.order_by('-total_overlap')[:25] + return best_fit_groups + +@shared_task +def cache_matching_groups(): + User = get_user_model() + users = User.objects.all() + + for user in users: + matching_groups = find_matching_groups(user) + matching_group_ids = [group.id for group in matching_groups] + UserSuggestions.objects.update_or_create(user_id=user.id, defaults={'group_ids': ','.join(map(str, matching_group_ids))}) diff --git a/home/test.py b/home/test.py new file mode 100644 index 0000000..9c7bba2 --- /dev/null +++ b/home/test.py @@ -0,0 +1,75 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from authentication.models import User +from groups.models import Group +from home.models import UserTimeSlot, GroupTimeSlot + +class TestHomeAPI(TestCase): + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(username='testuser', email='user@test.com', password='testpassword') + cls.group = Group.objects.create(title='testgroup', owner=cls.user) + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_home_view(self): + url = reverse('home') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_suggestions_view(self): + url = reverse('suggestions') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_all_groups_view(self): + url = reverse('all-groups') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_user_time_slot(self): + url = reverse('user-time-slot-create') + data = { + 'day_of_week': 'Monday', + 'start_time': 9.00, + 'end_time': 17.00 + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_list_user_time_slots(self): + url = reverse('user-time-slot-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_delete_user_time_slot(self): + time_slot = UserTimeSlot.objects.create(user=self.user, day_of_week='Monday', start_time=9.00, end_time=17.00) + url = reverse('user-time-slot-delete', kwargs={'id': time_slot.id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_group_time_slot(self): + url = reverse('group-time-slot-create', kwargs={'title': self.group.title}) + data = { + 'day_of_week': 'Monday', + 'start_time': 9.00, + 'end_time': 17.00 + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_list_group_time_slots(self): + url = reverse('group-time-slot-list', kwargs={'title': self.group.title}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_delete_group_time_slot(self): + time_slot = GroupTimeSlot.objects.create(group=self.group, day_of_week='Monday', start_time=9.00, end_time=17.00) + url = reverse('group-time-slot-delete', kwargs={'title': self.group.title, 'id': time_slot.id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) \ No newline at end of file diff --git a/home/tests.py b/home/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/home/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/home/urls.py b/home/urls.py index 31a5ff6..7edcc6c 100644 --- a/home/urls.py +++ b/home/urls.py @@ -2,18 +2,19 @@ from .views import * urlpatterns = [ - path('', HomeView.as_view()), - path('suggestions/', SuggestionsView.as_view()), - path('all-groups/', AllGroupsView.as_view()), + path('', HomeView.as_view(), name='home'), + path('suggestions/', SuggestionsView.as_view(), name='suggestions'), + path('all-groups/', AllGroupsView.as_view(), name='all-groups'), + - path('user-time-slots/create/', UserTimeSlotCreateView.as_view()), - path('user-time-slots/', UserTimeSlotListView.as_view()), - path('user-time-slots//delete/', UserTimeSlotDeleteView.as_view()), + path('user-time-slots/create/', UserTimeSlotCreateView.as_view(), name='user-time-slot-create'), + path('user-time-slots/', UserTimeSlotListView.as_view(), name='user-time-slot-list'), + path('user-time-slots//delete/', UserTimeSlotDeleteView.as_view(), name='user-time-slot-delete'), - path('group-time-slots//create/', GroupTimeSlotCreateView.as_view()), - path('group-time-slots//', GroupTimeSlotListView.as_view()), - path('group-time-slots///delete/', GroupTimeSlotDeleteView.as_view()), + path('group-time-slots//create/', GroupTimeSlotCreateView.as_view(), name='group-time-slot-create'), + path('group-time-slots//', GroupTimeSlotListView.as_view(), name='group-time-slot-list'), + path('group-time-slots///delete/', GroupTimeSlotDeleteView.as_view(), name='group-time-slot-delete'), path('neighborhoods/', get_table_data, name='table_data'), ] diff --git a/home/views.py b/home/views.py index 8c68b22..a325185 100644 --- a/home/views.py +++ b/home/views.py @@ -14,25 +14,30 @@ from .serializers import * from .services import * from groups.models import* -from .permissions import * from .filters import * class HomeView(APIView): permission_classes = [IsAuthenticated] serializer_class = HomeSerializer pagination_class = PageNumberPagination - filter_backends = [SearchFilter] + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = GroupFilter search_fields = ['title'] + ordering_fields = ['title', 'level', 'member_count'] def get(self, request): - joined_groups = HomeService.get_joined_groups(request.user, request) - filtered_groups = self.filter_queryset(joined_groups) - paginator = self.pagination_class() - paginated_data = paginator.paginate_queryset(filtered_groups, request) - serializer = self.serializer_class(paginated_data, many=True) - - return paginator.get_paginated_response(serializer.data) + try: + joined_groups = HomeService.get_joined_groups(request.user, request) + filtered_groups = self.filter_queryset(joined_groups) + paginator = self.pagination_class() + paginator.page_size = 4 + paginated_data = paginator.paginate_queryset(filtered_groups, request) + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) + except Exception: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + def filter_queryset(self, queryset): for backend in list(self.filter_backends): queryset = backend().filter_queryset(self.request, queryset, self) @@ -43,32 +48,48 @@ class SuggestionsView(APIView): permission_classes = [IsAuthenticated] serializer_class = SuggestionSerializer pagination_class = PageNumberPagination + filter_backends = [OrderingFilter] + ordering_fields = ['title'] def get(self, request): - suggestions = SuggestionService.get_suggestions(request.user) - paginator = self.pagination_class() - paginated_data = paginator.paginate_queryset(suggestions, request) - serializer = self.serializer_class(paginated_data, many=True) - return paginator.get_paginated_response(serializer.data) - + try: + suggestions = SuggestionService.get_suggestions(request.user) + filtered_suggestions = self.filter_queryset(suggestions) + paginator = self.pagination_class() + paginator.page_size = 4 + paginated_data = paginator.paginate_queryset(filtered_suggestions, request) + serializer = self.serializer_class(paginated_data, many=True, context={'request': request}) + return paginator.get_paginated_response(serializer.data) + except Exception: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset class AllGroupsView(APIView): + permission_classes = [IsAuthenticated] - serializer_class = GroupSerializer + serializer_class = AllGroupsSerializer pagination_class = PageNumberPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = GroupFilter search_fields = ['title'] - ordering_fields = ['level', 'member_count'] + ordering_fields = ['title', 'level', 'member_count'] def get(self, request): - groups = AllGroupsService.get_all_groups() - groups = groups.annotate(member_count=Count('members')) - filtered_groups = self.filter_queryset(groups) - paginator = self.pagination_class() - paginated_data = paginator.paginate_queryset(filtered_groups, request) - serializer = self.serializer_class(paginated_data, many=True) - return paginator.get_paginated_response(serializer.data) + try: + groups = AllGroupsService.get_all_groups(request.user) + groups = groups.annotate(member_count=Count('members')) + filtered_groups = self.filter_queryset(groups) + paginator = self.pagination_class() + paginator.page_size = 4 + paginated_data = paginator.paginate_queryset(filtered_groups, request) + serializer = self.serializer_class(paginated_data, many=True, context={'request': request}) + return paginator.get_paginated_response(serializer.data) + except Exception: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) def filter_queryset(self, queryset): for backend in list(self.filter_backends): @@ -78,8 +99,7 @@ def filter_queryset(self, queryset): class UserTimeSlotCreateView(APIView): - permission_classes = [IsAuthenticated] - + permission_classes = [IsAuthenticated] serializer_class = UserTimeSlotSerializer def post(self, request): @@ -93,17 +113,22 @@ def post(self, request): return Response(self.serializer_class(time_slot).data, status=status.HTTP_201_CREATED) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class UserTimeSlotListView(APIView): permission_classes = [IsAuthenticated] - serializer_class = UserTimeSlotSerializer def get(self, request): - time_slots = UserTimeSlotService.get_user_time_slots(request.user) - serializer = self.serializer_class(time_slots, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + try: + time_slots = UserTimeSlotService.get_user_time_slots(request.user) + serializer = self.serializer_class(time_slots, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class UserTimeSlotDeleteView(APIView): @@ -115,13 +140,13 @@ def delete(self, request, *args, **kwargs): return Response("time slot deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupTimeSlotCreateView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] - + permission_classes = [IsAuthenticated] serializer_class = GroupTimeSlotSerializer def post(self, request, *args, **kwargs): @@ -129,36 +154,45 @@ def post(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) try: time_slot = GroupTimeSlotService.create_group_time_slot( - group=Group.objects.get(title=kwargs.get('title')), + kwargs.get('title'), + request.user, **serializer.validated_data ) return Response(self.serializer_class(time_slot).data, status=status.HTTP_201_CREATED) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupTimeSlotListView(APIView): permission_classes = [IsAuthenticated] - serializer_class = GroupTimeSlotSerializer - def get(self, request): - time_slots = GroupTimeSlotService.get_group_time_slots(request.user) - serializer = self.serializer_class(time_slots, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - + def get(self, request, *args, **kwargs): + try: + time_slots = GroupTimeSlotService.get_group_time_slots(kwargs.get('title'), request.user) + serializer = self.serializer_class(time_slots, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupTimeSlotDeleteView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] def delete(self, request, *args, **kwargs): try: - GroupTimeSlotService.delete_group_time_slot(request.user, kwargs.get('id')) + GroupTimeSlotService.delete_time_slot(kwargs.get('title'), kwargs.get('id'), request.user) return Response("time slot deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + from django.db import connection diff --git a/notifications/consumers.py b/notifications/consumers.py index 3e678b4..7048852 100644 --- a/notifications/consumers.py +++ b/notifications/consumers.py @@ -13,20 +13,14 @@ class NotificationConsumer(WebsocketConsumer): def connect(self): - headers = dict(self.scope['headers']) - auth_header = headers.get(b'authorization', b'').decode() + token = self.scope['url_route']['kwargs']['token'] + user = self.get_user_from_token(token) - if auth_header.startswith('Bearer '): - token = auth_header.split(' ')[1] - user = self.get_user_from_token(token) - if user: - self.scope['user'] = user - else: - self.close() - return - else: - self.close() - return + if not user: + self.close(403) + + self.scope['user'] = user + async_to_sync(self.channel_layer.group_add)( f"user_{user.id}", diff --git a/notifications/routing.py b/notifications/routing.py index c40e637..91ab979 100644 --- a/notifications/routing.py +++ b/notifications/routing.py @@ -3,5 +3,7 @@ from .consumers import NotificationConsumer notifications_websocket_urlpatterns = [ - re_path(r"ws/notifications/", NotificationConsumer.as_asgi()), -] \ No newline at end of file + re_path(r"ws/notifications/(?P[-\w.]+)/$", NotificationConsumer.as_asgi()), + +] + diff --git a/notifications/serializers.py b/notifications/serializers.py index 85a7d97..53d8dd0 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -7,6 +7,6 @@ class NotificationSerializer(serializers.ModelSerializer): class Meta: model = Notification fields = '__all__' - read_only_fields = '__all__' + diff --git a/notifications/services.py b/notifications/services.py new file mode 100644 index 0000000..6fd8df0 --- /dev/null +++ b/notifications/services.py @@ -0,0 +1,18 @@ +from .models import Notification +from rest_framework.exceptions import ValidationError + +class NotificationService: + @staticmethod + def get_notifications(user): + result = Notification.objects.filter(recipient=user) + return result[::-1] + + def delete_notification(user, notification_id): + try: + notification = Notification.objects.get(recipient=user, id=notification_id) + notification.delete() + except Notification.DoesNotExist: + raise ValidationError({'detail': 'Notification not found', 'status': 404}) + + def delete_all_notifications(user): + Notification.objects.filter(recipient=user).delete() \ No newline at end of file diff --git a/notifications/urls.py b/notifications/urls.py index e69de29..40dacc4 100644 --- a/notifications/urls.py +++ b/notifications/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from .views import * + +urlpatterns = [ + path('all-notifications/', AllNotificationsView.as_view(), name='all-notifications'), + path('delete-notification/', DeleteNotificationView.as_view(), name='delete-notification'), + path('delete-all-notifications/', DeleteAllNotificationsView.as_view(), name='delete-all-notifications'), +] \ No newline at end of file diff --git a/notifications/views.py b/notifications/views.py index 91ea44a..8303a94 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -1,3 +1,55 @@ -from django.shortcuts import render +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.pagination import PageNumberPagination + +from .serializers import * +from .services import * + +class AllNotificationsView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = NotificationSerializer + pagination_class = PageNumberPagination + def get(self, request): + try: + result = NotificationService.get_notifications(request.user) + paginator = self.pagination_class() + paginator.page_size = 10 + paginated_data = paginator.paginate_queryset(result, request) + + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) + + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +class DeleteNotificationView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request): + try: + response = NotificationService.delete_notification(request.user, request.data.get('notification_id')) + return Response("Notification deleted successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class DeleteAllNotificationsView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request): + try: + response = NotificationService.delete_all_notifications(request.user) + return Response("All notifications deleted successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + -# Create your views here. diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..c9dc6bc --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,20 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +alerting: + alertmanagers: + - static_configs: + - targets: + - '185.252.86.8:9093' + scheme: http + +rule_files: + - 'alert_rules.yml' + +scrape_configs: + - job_name: 'website' + metrics_path: '/metrics' + static_configs: + - targets: ['freediscussion.liara.run'] + scheme: https diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..29e505d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = authentication +python_files = test_*.py +addopts = --maxfail=1 --disable-warnings -q \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c97b717..c9fd64b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,43 +1,85 @@ +amqp==5.3.1 asgiref==3.8.1 attrs==24.2.0 autobahn==24.4.2 Automat==24.8.1 +autopep8==2.3.1 +billiard==4.2.1 boto3==1.35.64 botocore==1.35.64 +celery==5.4.0 +certifi==2024.8.30 cffi==1.17.1 channels==4.2.0 channels_redis==4.2.1 +charset-normalizer==3.4.1 +click==8.1.7 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +colorama==0.4.6 constantly==23.10.4 +coverage==7.6.9 cryptography==43.0.3 daphne==4.1.2 Django==5.1.2 django-cors-headers==4.6.0 +django-elasticsearch-dsl==8.0 django-filter==24.3 +django-prometheus==2.3.1 +django-redis==5.4.0 django-storages==1.14.4 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.2 +elastic-transport==8.15.1 +elasticsearch==8.17.0 +elasticsearch-dsl==8.17.0 +execnet==2.1.1 +factory_boy==3.3.1 +Faker==33.1.0 +flake8==7.1.1 +gprof2dot==2024.6.6 +hiredis==3.0.0 hyperlink==21.0.0 idna==3.10 incremental==24.7.2 inflection==0.5.1 +iniconfig==2.0.0 +Jinja2==3.1.5 jmespath==1.0.1 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 +kombu==5.4.2 +MarkupSafe==3.0.2 +mccabe==0.7.0 +moto==5.0.25 msgpack==1.1.0 mysql-connector-python==9.1.0 mysqlclient==2.2.5 +packaging==24.2 pillow==11.0.0 +pip-check-reqs==2.5.3 +pluggy==1.5.0 +prometheus_client==0.21.1 +prompt_toolkit==3.0.48 pyasn1==0.6.1 pyasn1_modules==0.4.1 +pycodestyle==2.12.1 pycparser==2.22 +pyflakes==3.2.0 PyJWT==2.9.0 pyOpenSSL==24.2.1 +pytest==8.3.4 +pytest-django==4.9.0 +pytest-xdist==3.6.1 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 PyYAML==6.0.2 redis==5.2.0 referencing==0.35.1 +requests==2.32.3 +responses==0.25.3 rpds-py==0.20.1 s3transfer==0.10.3 service-identity==24.2.0 @@ -50,4 +92,8 @@ typing_extensions==4.12.2 tzdata==2024.2 uritemplate==4.1.1 urllib3==2.2.3 -zope.interface==7.1.1 \ No newline at end of file +vine==5.1.0 +wcwidth==0.2.13 +Werkzeug==3.1.3 +xmltodict==0.14.2 +zope.interface==7.1.1 diff --git a/supervisor.conf b/supervisor.conf new file mode 100644 index 0000000..1ba3c70 --- /dev/null +++ b/supervisor.conf @@ -0,0 +1,12 @@ +[program:celery-worker] +process_name=%(program_name)s_%(process_num)02d +command=celery -A proj worker -l INFO +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +numprocs=1 +startsecs=10 +stopwaitsecs=600 +redirect_stderr=true +stdout_logfile=/tmp/worker.log \ No newline at end of file