diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e2be777..29bf1df 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,33 +1,59 @@ name: Lint -on: pull_request + +on: + pull_request: + jobs: - run-linters: - name: Run linters - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./frontend - - steps: - - name: Check out Git repository - uses: actions/checkout@v5 - - - name: Enable corepack - run: corepack enable - - - name: Set up Node.js - uses: actions/setup-node@v5 - with: - node-version: 22 - - # ESLint and Prettier must be in `package.json` - - name: Install Node.js dependencies - run: yarn install - - - name: Run linters - uses: wearerequired/lint-action@v2.3.0 - with: - eslint: true - eslint_dir: ./frontend - prettier: true - prettier_dir: ./frontend + run-linters: + name: ESLint + Prettier (frontend) + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + + - name: Enable Corepack + run: corepack enable + + - name: Install Node.js dependencies + run: yarn install --immutable + + - name: Run ESLint + run: yarn lint + + - name: Run Prettier format check + run: yarn format + + ruff: + name: Ruff (backend) + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./backend + + steps: + - name: Check out Git repository + uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install + + - name: Install backend dependencies + run: uv sync --group dev + + - name: Run Ruff lint check + run: uv run ruff check + + - name: Run Ruff format check + run: uv run ruff format --check \ No newline at end of file diff --git a/backend/config/asgi.py b/backend/config/asgi.py index 5d1e7d5..55da7af 100644 --- a/backend/config/asgi.py +++ b/backend/config/asgi.py @@ -1,5 +1,4 @@ -""" -ASGI config for hoagiehelp project. +"""ASGI config for hoagiehelp project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -11,6 +10,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") application = get_asgi_application() diff --git a/backend/config/settings.py b/backend/config/settings.py index 62e6af3..18676ff 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1,5 +1,4 @@ -""" -Django settings for hoagiehelp project. +"""Django settings for hoagiehelp project. Generated by 'django-admin startproject' using Django 5.2.8. @@ -37,41 +36,41 @@ # Application definition INSTALLED_APPS = [ - "django.contrib.admin", - "django.contrib.auth", - "django.contrib.contenttypes", - "django.contrib.sessions", - "django.contrib.messages", - "django.contrib.staticfiles", - "rest_framework", - "hoagiehelp", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "hoagiehelp", ] MIDDLEWARE = [ - "django.middleware.security.SecurityMiddleware", - "django.contrib.sessions.middleware.SessionMiddleware", - "django.middleware.common.CommonMiddleware", - "django.middleware.csrf.CsrfViewMiddleware", - "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", - "django.middleware.clickjacking.XFrameOptionsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "config.urls" TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ], - }, - }, + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, ] WSGI_APPLICATION = "config.wsgi.application" @@ -90,79 +89,79 @@ AUTH0_ALGORITHMS = ["RS256"] REST_FRAMEWORK = { - "DEFAULT_AUTHENTICATION_CLASSES": [ - "hoagiehelp.auth.auth.Auth0JWTAuthentication", - ], - "DEFAULT_PERMISSION_CLASSES": [ - "rest_framework.permissions.IsAuthenticated", - ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "hoagiehelp.auth.auth.Auth0JWTAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], } # Logging LOGS = os.getenv("LOGS", "False").lower() == "true" LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "standard": { - "format": "{asctime} [{levelname}] {name}: {message}", - "style": "{", - }, - }, - "handlers": { - "console": { - "class": "logging.StreamHandler", - "formatter": "standard", - }, - }, - "root": { - "handlers": ["console"], - "level": "WARNING", - }, - "loggers": { - # Your application - "hoagiehelp": { - "level": "DEBUG" if LOGS else "INFO", - "handlers": ["console"], - "propagate": False, - }, - # Django internals - "django": { - "level": "INFO", - "handlers": ["console"], - "propagate": False, - }, - # SQL queries - "django.db.backends": { - "level": "DEBUG" if LOGS else "WARNING", - "handlers": ["console"], - "propagate": False, - }, - # HTTP requests - "django.request": { - "level": "INFO", - "handlers": ["console"], - "propagate": False, - }, - }, + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "standard": { + "format": "{asctime} [{levelname}] {name}: {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "standard", + }, + }, + "root": { + "handlers": ["console"], + "level": "WARNING", + }, + "loggers": { + # Your application + "hoagiehelp": { + "level": "DEBUG" if LOGS else "INFO", + "handlers": ["console"], + "propagate": False, + }, + # Django internals + "django": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, + # SQL queries + "django.db.backends": { + "level": "DEBUG" if LOGS else "WARNING", + "handlers": ["console"], + "propagate": False, + }, + # HTTP requests + "django.request": { + "level": "INFO", + "handlers": ["console"], + "propagate": False, + }, + }, } # Password validation # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, ] diff --git a/backend/config/urls.py b/backend/config/urls.py index 02c21b8..4626daa 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -1,5 +1,4 @@ -""" -URL configuration for hoagiehelp project. +"""URL configuration for hoagiehelp project. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/5.2/topics/http/urls/ @@ -21,64 +20,62 @@ from hoagiehelp.api.comment_views import CommentDetailView, CommentListView from hoagiehelp.api.heart_views import heart_answer, heart_comment, heart_question from hoagiehelp.api.notification_views import ( - NotificationView, - get_notifications, + NotificationView, + get_notifications, ) from hoagiehelp.api.question_views import QuestionDetailView, QuestionListView from hoagiehelp.api.study_group_views import StudyGroupDetailView, StudyGroupListView from hoagiehelp.api.user_views import ( - UserView, - user_answers, - user_comments, - user_questions, + UserView, + user_answers, + user_comments, + user_questions, ) urlpatterns = [ - path("admin/", admin.site.urls), - # Questions - path("questions/", QuestionListView.as_view(), name="question-list"), - path( - "questions//", - QuestionDetailView.as_view(), - name="question-detail", - ), - # Answers - path( - "questions//answers/", - AnswerListView.as_view(), - name="answer-list", - ), - path("answers//", AnswerDetailView.as_view(), name="answer-detail"), - # Comments - path( - "answers//comments/", - CommentListView.as_view(), - name="comment-list", - ), - path( - "comments//", CommentDetailView.as_view(), name="comment-detail" - ), - # Study Groups - path("study-groups/", StudyGroupListView.as_view(), name="studygroup-list"), - path( - "study-groups//", - StudyGroupDetailView.as_view(), - name="studygroup-detail", - ), - # Users - path("users//", UserView.as_view(), name="user-detail"), - path("users//questions/", user_questions, name="user-questions"), - path("users//answers/", user_answers, name="user-answers"), - path("users//comments/", user_comments, name="user-comments"), - # Notifications - path("notifications/", get_notifications, name="get-notifications"), - path( - "notifications//", - NotificationView.as_view(), - name="notification-detail", - ), - # Hearts - path("questions//heart", heart_question, name="heart_question"), - path("answers//heart", heart_answer, name="heart_answer"), - path("comments//heart", heart_comment, name="heart_comment"), + path("admin/", admin.site.urls), + # Questions + path("questions/", QuestionListView.as_view(), name="question-list"), + path( + "questions//", + QuestionDetailView.as_view(), + name="question-detail", + ), + # Answers + path( + "questions//answers/", + AnswerListView.as_view(), + name="answer-list", + ), + path("answers//", AnswerDetailView.as_view(), name="answer-detail"), + # Comments + path( + "answers//comments/", + CommentListView.as_view(), + name="comment-list", + ), + path("comments//", CommentDetailView.as_view(), name="comment-detail"), + # Study Groups + path("study-groups/", StudyGroupListView.as_view(), name="studygroup-list"), + path( + "study-groups//", + StudyGroupDetailView.as_view(), + name="studygroup-detail", + ), + # Users + path("users//", UserView.as_view(), name="user-detail"), + path("users//questions/", user_questions, name="user-questions"), + path("users//answers/", user_answers, name="user-answers"), + path("users//comments/", user_comments, name="user-comments"), + # Notifications + path("notifications/", get_notifications, name="get-notifications"), + path( + "notifications//", + NotificationView.as_view(), + name="notification-detail", + ), + # Hearts + path("questions//heart", heart_question, name="heart_question"), + path("answers//heart", heart_answer, name="heart_answer"), + path("comments//heart", heart_comment, name="heart_comment"), ] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py index 4c27760..37a3a0f 100644 --- a/backend/config/wsgi.py +++ b/backend/config/wsgi.py @@ -1,5 +1,4 @@ -""" -WSGI config for hoagiehelp project. +"""WSGI config for hoagiehelp project. It exposes the WSGI callable as a module-level variable named ``application``. @@ -11,6 +10,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") application = get_wsgi_application() diff --git a/backend/hoagiehelp/admin.py b/backend/hoagiehelp/admin.py index 8c38f3f..846f6b4 100644 --- a/backend/hoagiehelp/admin.py +++ b/backend/hoagiehelp/admin.py @@ -1,3 +1 @@ -from django.contrib import admin - # Register your models here. diff --git a/backend/hoagiehelp/api/answer_views.py b/backend/hoagiehelp/api/answer_views.py index a119710..72f0156 100644 --- a/backend/hoagiehelp/api/answer_views.py +++ b/backend/hoagiehelp/api/answer_views.py @@ -8,84 +8,77 @@ class AnswerSerializer(serializers.ModelSerializer): - class Meta: - model = Answer - fields = ( - "id", - "question", - "user", - "text", - "hearts", - "created_at", - "updated_at", - "is_anonymous", - ) - read_only_fields = ("question", "user") + class Meta: + model = Answer + fields = ( + "id", + "question", + "user", + "text", + "hearts", + "created_at", + "updated_at", + "is_anonymous", + ) + read_only_fields = ("question", "user") class AnswerListView(APIView): - """Handle collection operations for answers under a question.""" + """Handle collection operations for answers under a question.""" - def get(self, request, question_id: str) -> Response: - """List all answers for a given question.""" - question = get_object_or_404(Question, pk=question_id) + def get(self, request, question_id: str) -> Response: + """List all answers for a given question.""" + question = get_object_or_404(Question, pk=question_id) - queryset = Answer.objects.filter(question=question).order_by("-created_at") + queryset = Answer.objects.filter(question=question).order_by("-created_at") - serializer = AnswerSerializer(queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + serializer = AnswerSerializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) - def post(self, request, question_id: str) -> Response: - """Create a new answer associated with a given question.""" + def post(self, request, question_id: str) -> Response: + """Create a new answer associated with a given question.""" + question = get_object_or_404(Question, pk=question_id) - question = get_object_or_404(Question, pk=question_id) + data = request.data.copy() + data["question"] = question.id - data = request.data.copy() - data["question"] = question.id + serializer = AnswerSerializer(data=data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) - serializer = AnswerSerializer(data=data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class AnswerDetailView(APIView): - """Handle individual answer operations.""" - - def get(self, request, answer_id: str) -> Response: - """Get all details associated with a given answer.""" - try: - answer = Answer.objects.get(id=answer_id) - except Answer.DoesNotExist: - return Response( - {"detail": "Answer not found"}, status=status.HTTP_404_NOT_FOUND - ) - serializer = AnswerSerializer(answer) - return Response(serializer.data) - - def put(self, request, answer_id: str) -> Response: - """Update an existing answer.""" - try: - answer = Answer.objects.get(id=answer_id) - except Answer.DoesNotExist: - return Response( - {"detail": "Answer not found"}, status=status.HTTP_404_NOT_FOUND - ) - serializer = AnswerSerializer(answer, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def delete(self, request, answer_id: str) -> Response: - """Delete an existing answer.""" - try: - answer = Answer.objects.get(id=answer_id) - except Answer.DoesNotExist: - return Response( - {"detail": "Answer not found"}, status=status.HTTP_404_NOT_FOUND - ) - answer.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + """Handle individual answer operations.""" + + def get(self, request, answer_id: str) -> Response: + """Get all details associated with a given answer.""" + try: + answer = Answer.objects.get(id=answer_id) + except Answer.DoesNotExist: + return Response({"detail": "Answer not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = AnswerSerializer(answer) + return Response(serializer.data) + + def put(self, request, answer_id: str) -> Response: + """Update an existing answer.""" + try: + answer = Answer.objects.get(id=answer_id) + except Answer.DoesNotExist: + return Response({"detail": "Answer not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = AnswerSerializer(answer, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, answer_id: str) -> Response: + """Delete an existing answer.""" + try: + answer = Answer.objects.get(id=answer_id) + except Answer.DoesNotExist: + return Response({"detail": "Answer not found"}, status=status.HTTP_404_NOT_FOUND) + answer.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/hoagiehelp/api/comment_views.py b/backend/hoagiehelp/api/comment_views.py index 10b86d6..b1fef08 100644 --- a/backend/hoagiehelp/api/comment_views.py +++ b/backend/hoagiehelp/api/comment_views.py @@ -6,72 +6,72 @@ from hoagiehelp.models.comment import Comment from hoagiehelp.models.answer import Answer + # Comment Serializer class CommentSerializer(serializers.ModelSerializer): - class Meta: - model = Comment - fields = ( - "id", - "answer", - "user", - "text", - "hearts", - "is_anonymous", - "created_at", - "updated_at", - ) + class Meta: + model = Comment + fields = ( + "id", + "answer", + "user", + "text", + "hearts", + "is_anonymous", + "created_at", + "updated_at", + ) class CommentListView(APIView): - """Handle collection operations for comments under an answer.""" - - def get(self, request, answer_id: str) -> Response: - """List all comments for a given answer.""" - answer = get_object_or_404(Answer, pk=answer_id) + """Handle collection operations for comments under an answer.""" - queryset = Comment.objects.filter(answer=answer).order_by("-created_at") + def get(self, request, answer_id: str) -> Response: + """List all comments for a given answer.""" + answer = get_object_or_404(Answer, pk=answer_id) - serializer = CommentSerializer(queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + queryset = Comment.objects.filter(answer=answer).order_by("-created_at") + serializer = CommentSerializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) - def post(self, request, answer_id: str) -> Response: - """Create a new comment associated with a given answer.""" - answer = get_object_or_404(Answer, pk=answer_id) + def post(self, request, answer_id: str) -> Response: + """Create a new comment associated with a given answer.""" + answer = get_object_or_404(Answer, pk=answer_id) - serializer = CommentSerializer(data=request.data) + serializer = CommentSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(answer=answer) - return Response(serializer.data, status=status.HTTP_201_CREATED) + if serializer.is_valid(): + serializer.save(answer=answer) + return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class CommentDetailView(APIView): - """Handle individual comment operations.""" + """Handle individual comment operations.""" - def get(self, request, comment_id: str) -> Response: - """Get all details associated with a given comment.""" - comment = get_object_or_404(Comment, id=comment_id) - serializer = CommentSerializer(comment) + def get(self, request, comment_id: str) -> Response: + """Get all details associated with a given comment.""" + comment = get_object_or_404(Comment, id=comment_id) + serializer = CommentSerializer(comment) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.data, status=status.HTTP_200_OK) - def put(self, request, comment_id: str) -> Response: - """Update an existing comment.""" - comment = get_object_or_404(Comment, id=comment_id) - serializer = CommentSerializer(comment, data=request.data) + def put(self, request, comment_id: str) -> Response: + """Update an existing comment.""" + comment = get_object_or_404(Comment, id=comment_id) + serializer = CommentSerializer(comment, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, comment_id: str) -> Response: - """Delete an existing comment.""" - comment = get_object_or_404(Comment, id=comment_id) - comment.delete() + def delete(self, request, comment_id: str) -> Response: + """Delete an existing comment.""" + comment = get_object_or_404(Comment, id=comment_id) + comment.delete() - return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/hoagiehelp/api/heart_views.py b/backend/hoagiehelp/api/heart_views.py index b11675e..83f49c2 100644 --- a/backend/hoagiehelp/api/heart_views.py +++ b/backend/hoagiehelp/api/heart_views.py @@ -12,46 +12,44 @@ def _toggle_heart(request, model_class, obj_id: int, heart_field: str) -> Response: - # Lock the post to prevent race conditions - obj = get_object_or_404(model_class.objects.select_for_update(), id=obj_id) - - # Find the heart associated with the post and user, and delete it if it exists - deleted, _ = Heart.objects.filter( - **{heart_field: obj, "user": request.user} - ).delete() - - # Decrement heart count - if deleted: - model_class.objects.filter(id=obj_id).update(hearts=F("hearts") - 1) - obj.refresh_from_db(fields=["hearts"]) - return Response( - {"hearts": obj.hearts, "is_hearted": False}, - status=status.HTTP_200_OK, - ) - - # Otherwise, create a new heart and increment heart count - Heart.objects.create(**{heart_field: obj, "user": request.user}) - model_class.objects.filter(id=obj_id).update(hearts=F("hearts") + 1) - obj.refresh_from_db(fields=["hearts"]) - return Response( - {"hearts": obj.hearts, "is_hearted": True}, - status=status.HTTP_200_OK, - ) + # Lock the post to prevent race conditions + obj = get_object_or_404(model_class.objects.select_for_update(), id=obj_id) + + # Find the heart associated with the post and user, and delete it if it exists + deleted, _ = Heart.objects.filter(**{heart_field: obj, "user": request.user}).delete() + + # Decrement heart count + if deleted: + model_class.objects.filter(id=obj_id).update(hearts=F("hearts") - 1) + obj.refresh_from_db(fields=["hearts"]) + return Response( + {"hearts": obj.hearts, "is_hearted": False}, + status=status.HTTP_200_OK, + ) + + # Otherwise, create a new heart and increment heart count + Heart.objects.create(**{heart_field: obj, "user": request.user}) + model_class.objects.filter(id=obj_id).update(hearts=F("hearts") + 1) + obj.refresh_from_db(fields=["hearts"]) + return Response( + {"hearts": obj.hearts, "is_hearted": True}, + status=status.HTTP_200_OK, + ) @api_view(["POST"]) @transaction.atomic def heart_question(request, question_id: int): - return _toggle_heart(request, Question, question_id, "question") + return _toggle_heart(request, Question, question_id, "question") @api_view(["POST"]) @transaction.atomic def heart_answer(request, answer_id: int): - return _toggle_heart(request, Answer, answer_id, "answer") + return _toggle_heart(request, Answer, answer_id, "answer") @api_view(["POST"]) @transaction.atomic def heart_comment(request, comment_id: int): - return _toggle_heart(request, Comment, comment_id, "comment") + return _toggle_heart(request, Comment, comment_id, "comment") diff --git a/backend/hoagiehelp/api/notification_views.py b/backend/hoagiehelp/api/notification_views.py index 360c7bc..f8d1d03 100644 --- a/backend/hoagiehelp/api/notification_views.py +++ b/backend/hoagiehelp/api/notification_views.py @@ -7,58 +7,50 @@ class NotificationSerializer(serializers.ModelSerializer): - class Meta: - model = Notification - fields = [ - "id", - "user", - "question", - "answer", - "comment", - "is_read", - "created_at", - ] - read_only_fields = ["id", "user", "question", "answer", "comment", "created_at"] + class Meta: + model = Notification + fields = [ + "id", + "user", + "question", + "answer", + "comment", + "is_read", + "created_at", + ] + read_only_fields = ["id", "user", "question", "answer", "comment", "created_at"] class NotificationView(APIView): - """Handle collection operations for notifications.""" + """Handle collection operations for notifications.""" - def get(self, request, notification_id: str) -> Response: - """Retrieve specific notification for a given user.""" - notification = get_object_or_404( - Notification, id=notification_id, user=request.user - ) - serializer = NotificationSerializer(notification) - return Response(serializer.data, status=status.HTTP_200_OK) + def get(self, request, notification_id: str) -> Response: + """Retrieve specific notification for a given user.""" + notification = get_object_or_404(Notification, id=notification_id, user=request.user) + serializer = NotificationSerializer(notification) + return Response(serializer.data, status=status.HTTP_200_OK) - def delete(self, request, notification_id: str) -> Response: - """Delete a specific notification for a given user.""" - notification = get_object_or_404( - Notification, id=notification_id, user=request.user - ) - notification.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + def delete(self, request, notification_id: str) -> Response: + """Delete a specific notification for a given user.""" + notification = get_object_or_404(Notification, id=notification_id, user=request.user) + notification.delete() + return Response(status=status.HTTP_204_NO_CONTENT) - def post(self, request, notification_id: str) -> Response: - """Update an existing notification.""" - notification = get_object_or_404( - Notification, id=notification_id, user=request.user - ) - serializer = NotificationSerializer( - notification, data=request.data, partial=True - ) + def post(self, request, notification_id: str) -> Response: + """Update an existing notification.""" + notification = get_object_or_404(Notification, id=notification_id, user=request.user) + serializer = NotificationSerializer(notification, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def get_notifications(request) -> Response: - """Get all notifications for a given user.""" - user_id = request.user.id - notifications = Notification.objects.filter(user=user_id).order_by("-created_at") - serializer = NotificationSerializer(notifications, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + """Get all notifications for a given user.""" + user_id = request.user.id + notifications = Notification.objects.filter(user=user_id).order_by("-created_at") + serializer = NotificationSerializer(notifications, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/hoagiehelp/api/question_views.py b/backend/hoagiehelp/api/question_views.py index 04479aa..5071d23 100644 --- a/backend/hoagiehelp/api/question_views.py +++ b/backend/hoagiehelp/api/question_views.py @@ -7,85 +7,83 @@ class QuestionSerializer(serializers.ModelSerializer): - class Meta: - model = Question - fields = [ - "id", - "user", - "title", - "tags", - "course", - "details", - "create_time", - "last_updated_time", - "hearts", - "view", - "user_is_anonymous", - ] + class Meta: + model = Question + fields = [ + "id", + "user", + "title", + "tags", + "course", + "details", + "create_time", + "last_updated_time", + "hearts", + "view", + "user_is_anonymous", + ] class QuestionListView(APIView): - """Handle collection operations for questions.""" - - def get(self, request) -> Response: - """List all questions, filtered by title and tags.""" - queryset = Question.objects.all() - - # Filter queries by following parameters - title_string = request.query_params.get('title') - tags_list = request.query_params.getlist('tags') - - if title_string: - queryset = queryset.filter(title__icontains=title_string) - if tags_list: - queryset = queryset.filter(tags__name__in=tags_list).distinct() - - serializer = QuestionSerializer(queryset, many=True) - return Response(serializer.data) - - def post(self, request) -> Response: - """Create a new question.""" - serializer = QuestionSerializer(data=request.data) - - # field checks and validation - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + """Handle collection operations for questions.""" + + def get(self, request) -> Response: + """List all questions, filtered by title and tags.""" + queryset = Question.objects.all() + + # Filter queries by following parameters + title_string = request.query_params.get("title") + tags_list = request.query_params.getlist("tags") + + if title_string: + queryset = queryset.filter(title__icontains=title_string) + if tags_list: + queryset = queryset.filter(tags__name__in=tags_list).distinct() + + serializer = QuestionSerializer(queryset, many=True) + return Response(serializer.data) + + def post(self, request) -> Response: + """Create a new question.""" + serializer = QuestionSerializer(data=request.data) + + # field checks and validation + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class QuestionDetailView(APIView): - """Handle individual question operations.""" - - def get(self, request, question_id: str) -> Response: - """Get all details associated with a given question.""" - try: - question = Question.objects.get(id=question_id) - except Question.DoesNotExist: - return Response({"detail": "Question not found"}, status=status.HTTP_404_NOT_FOUND) - serializer = QuestionSerializer(question) - return Response(serializer.data) - - def put(self, request, question_id: str) -> Response: - """Update an existing question.""" - try: - question = Question.objects.get(id=question_id) - except Question.DoesNotExist: - return Response({"detail": "Question not found"}, status=status.HTTP_404_NOT_FOUND) - serializer = QuestionSerializer(question, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - - - def delete(self, request, question_id: str) -> Response: - """Delete an existing question.""" - try: - question = Question.objects.get(id=question_id) - except Question.DoesNotExist: - return Response({"detail":"Question not found"}, status=status.HTTP_404_NOT_FOUND) - question.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + """Handle individual question operations.""" + + def get(self, request, question_id: str) -> Response: + """Get all details associated with a given question.""" + try: + question = Question.objects.get(id=question_id) + except Question.DoesNotExist: + return Response({"detail": "Question not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = QuestionSerializer(question) + return Response(serializer.data) + + def put(self, request, question_id: str) -> Response: + """Update an existing question.""" + try: + question = Question.objects.get(id=question_id) + except Question.DoesNotExist: + return Response({"detail": "Question not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = QuestionSerializer(question, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def delete(self, request, question_id: str) -> Response: + """Delete an existing question.""" + try: + question = Question.objects.get(id=question_id) + except Question.DoesNotExist: + return Response({"detail": "Question not found"}, status=status.HTTP_404_NOT_FOUND) + question.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/hoagiehelp/api/study_group_views.py b/backend/hoagiehelp/api/study_group_views.py index 04a201c..1c34d49 100644 --- a/backend/hoagiehelp/api/study_group_views.py +++ b/backend/hoagiehelp/api/study_group_views.py @@ -7,75 +7,69 @@ # Study Group Serializer class StudyGroupSerializer(serializers.ModelSerializer): - class Meta: - model = StudyGroup - fields = ( - "id", - "title", - "description", - "leader", - "meeting_datetime", - "max_spots", - "members", - "created_at", - "updated_at", - ) - read_only_fields = ("id", "leader", "created_at", "updated_at") + class Meta: + model = StudyGroup + fields = ( + "id", + "title", + "description", + "leader", + "meeting_datetime", + "max_spots", + "members", + "created_at", + "updated_at", + ) + read_only_fields = ("id", "leader", "created_at", "updated_at") class StudyGroupListView(APIView): - """Handle collection operations for study groups.""" + """Handle collection operations for study groups.""" - def get(self, request) -> Response: - """List all study groups.""" - queryset = StudyGroup.objects.all() - serializer = StudyGroupSerializer(queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + def get(self, request) -> Response: + """List all study groups.""" + queryset = StudyGroup.objects.all() + serializer = StudyGroupSerializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) - def post(self, request) -> Response: - """Create a new study group.""" - serializer = StudyGroupSerializer(data=request.data) - if serializer.is_valid(): - serializer.save(leader=request.user) - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def post(self, request) -> Response: + """Create a new study group.""" + serializer = StudyGroupSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(leader=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class StudyGroupDetailView(APIView): - """Handle individual study group operations.""" + """Handle individual study group operations.""" - def get(self, request, studygroup_id: str) -> Response: - """Get all details associated with a given study group.""" - try: - study_group = StudyGroup.objects.get(id=studygroup_id) - except StudyGroup.DoesNotExist: - return Response( - {"detail": "Study group not found"}, status=status.HTTP_404_NOT_FOUND - ) - serializer = StudyGroupSerializer(study_group) - return Response(serializer.data) + def get(self, request, studygroup_id: str) -> Response: + """Get all details associated with a given study group.""" + try: + study_group = StudyGroup.objects.get(id=studygroup_id) + except StudyGroup.DoesNotExist: + return Response({"detail": "Study group not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = StudyGroupSerializer(study_group) + return Response(serializer.data) - def put(self, request, studygroup_id: str) -> Response: - """Update an existing study group.""" - try: - study_group = StudyGroup.objects.get(id=studygroup_id) - except StudyGroup.DoesNotExist: - return Response( - {"detail": "Study group not found"}, status=status.HTTP_404_NOT_FOUND - ) - serializer = StudyGroupSerializer(study_group, data=request.data) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def put(self, request, studygroup_id: str) -> Response: + """Update an existing study group.""" + try: + study_group = StudyGroup.objects.get(id=studygroup_id) + except StudyGroup.DoesNotExist: + return Response({"detail": "Study group not found"}, status=status.HTTP_404_NOT_FOUND) + serializer = StudyGroupSerializer(study_group, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - def delete(self, request, studygroup_id: str) -> Response: - """Delete an existing study group.""" - try: - study_group = StudyGroup.objects.get(id=studygroup_id) - except StudyGroup.DoesNotExist: - return Response( - {"detail": "Study group not found"}, status=status.HTTP_404_NOT_FOUND - ) - study_group.delete() - return Response(status=status.HTTP_204_NO_CONTENT) + def delete(self, request, studygroup_id: str) -> Response: + """Delete an existing study group.""" + try: + study_group = StudyGroup.objects.get(id=studygroup_id) + except StudyGroup.DoesNotExist: + return Response({"detail": "Study group not found"}, status=status.HTTP_404_NOT_FOUND) + study_group.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/hoagiehelp/api/user_views.py b/backend/hoagiehelp/api/user_views.py index 2abb794..f0f1abe 100644 --- a/backend/hoagiehelp/api/user_views.py +++ b/backend/hoagiehelp/api/user_views.py @@ -12,59 +12,59 @@ # User Serializer class UserSerializer(serializers.ModelSerializer): - class Meta: - model = CustomUser - fields = ( - "net_id", - "class_year", - "first_name", - "last_name", - "username", - "email", - ) - read_only_fields = ("net_id", "email") + class Meta: + model = CustomUser + fields = ( + "net_id", + "class_year", + "first_name", + "last_name", + "username", + "email", + ) + read_only_fields = ("net_id", "email") class UserView(APIView): - def get(self, request) -> Response: - """Return the authenticated user's data.""" - serializer = UserSerializer(request.user) - return Response(serializer.data, status=status.HTTP_200_OK) + def get(self, request) -> Response: + """Return the authenticated user's data.""" + serializer = UserSerializer(request.user) + return Response(serializer.data, status=status.HTTP_200_OK) - def post(self, request) -> Response: - """Update first_name, last_name, and username for the authenticated user.""" - serializer = UserSerializer(request.user, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def post(self, request) -> Response: + """Update first_name, last_name, and username for the authenticated user.""" + serializer = UserSerializer(request.user, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def get_questions_for_user(request, user_id: str): - target_user = get_object_or_404(CustomUser, id=user_id) - questions_set = Question.objects.filter(user=target_user) + target_user = get_object_or_404(CustomUser, id=user_id) + questions_set = Question.objects.filter(user=target_user) - if request.user.net_id != target_user.net_id: - questions_set = questions_set.filter(user_is_anonymous=False) + if request.user.net_id != target_user.net_id: + questions_set = questions_set.filter(user_is_anonymous=False) - questions_set = questions_set.order_by("-created_at") - serializer = QuestionSerializer(questions_set, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + questions_set = questions_set.order_by("-created_at") + serializer = QuestionSerializer(questions_set, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) def user_answers(request, user_id: str): - pass + pass def get_comments_for_user(request, user_id: str) -> Response: - # retrieve all comments a user has posted - target_user = get_object_or_404(CustomUser, id=user_id) + # retrieve all comments a user has posted + target_user = get_object_or_404(CustomUser, id=user_id) - queryset = Comment.objects.filter(user=target_user).order_by("-created_at") + queryset = Comment.objects.filter(user=target_user).order_by("-created_at") - if request.user.net_id != target_user.net_id: - # only show public comments for other users - queryset = queryset.filter(is_anonymous=False) + if request.user.net_id != target_user.net_id: + # only show public comments for other users + queryset = queryset.filter(is_anonymous=False) - serializer = CommentSerializer(queryset, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + serializer = CommentSerializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/hoagiehelp/apps.py b/backend/hoagiehelp/apps.py index ac905aa..0488920 100644 --- a/backend/hoagiehelp/apps.py +++ b/backend/hoagiehelp/apps.py @@ -2,5 +2,5 @@ class HoagiehelpConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'hoagiehelp' + default_auto_field = "django.db.models.BigAutoField" + name = "hoagiehelp" diff --git a/backend/hoagiehelp/auth/auth.py b/backend/hoagiehelp/auth/auth.py index 251f933..a556f8c 100644 --- a/backend/hoagiehelp/auth/auth.py +++ b/backend/hoagiehelp/auth/auth.py @@ -15,89 +15,89 @@ class Auth0JWTAuthentication(authentication.BaseAuthentication): - def authenticate(self, request: HttpRequest): - auth_header = request.headers.get("Authorization") - - if not auth_header: - return None - - if not auth_header.startswith("Bearer "): - raise exceptions.AuthenticationFailed("Invalid token header") - - token = auth_header.split(" ")[1] - try: - # Verify and decode the token - payload = self.verify_token(token) - - # Get or create user based on Auth0 subject - auth0_id = payload["sub"] - net_id = auth0_id.split("|")[2].split("@")[0] - name = payload.get("https://hoagie.io/name", "") - email = payload.get("https://hoagie.io/email", "") - - first_name = name.split(" ")[0] - # Handle missing last name - last_name = name.split(" ")[-1] if " " in name else "" - - user, _ = CustomUser.objects.get_or_create( - net_id=net_id, - defaults={ - "email": email, - "first_name": first_name, - "last_name": last_name, - "net_id": net_id, - "username": net_id, - "class_year": datetime.now().year + 1, - }, - ) - - return (user, payload) - - except jwt.ExpiredSignatureError as e: - raise exceptions.AuthenticationFailed("Token has expired") from e - except jwt.InvalidTokenError as e: - raise exceptions.AuthenticationFailed("Invalid token") from e - except Exception as e: - logger.error(f"Authentication error: {str(e)}") - raise exceptions.AuthenticationFailed("Authentication failed") from e - - def _fetch_jwks(self): - jwks_url = f"https://{settings.AUTH0_DOMAIN}/.well-known/jwks.json" - jwks = cache.get("auth0_jwks") - if jwks is None: - jwks = requests.get(jwks_url, timeout=5).json() - cache.set("auth0_jwks", jwks, timeout=3600) - return jwks - - def _find_rsa_key(self, jwks, kid): - for key in jwks["keys"]: - if key["kid"] == kid: - return RSAAlgorithm.from_jwk(key) - return None - - def verify_token(self, token: str): - unverified_header = jwt.get_unverified_header(token) - kid = unverified_header["kid"] - - jwks = self._fetch_jwks() - rsa_key = self._find_rsa_key(jwks, kid) - - # Key not found so refetch in case Auth0 rotated keys - if rsa_key is None: - cache.delete("auth0_jwks") - jwks = self._fetch_jwks() - rsa_key = self._find_rsa_key(jwks, kid) - - if rsa_key is None: - raise exceptions.AuthenticationFailed("Unable to find appropriate key") - - # Verify and decode token - payload = jwt.decode( - token, - rsa_key, - algorithms=settings.AUTH0_ALGORITHMS, - audience=settings.AUTH0_AUDIENCE, - issuer=f"https://{settings.AUTH0_DOMAIN}/", - ) - - return payload + def authenticate(self, request: HttpRequest): + auth_header = request.headers.get("Authorization") + + if not auth_header: + return None + + if not auth_header.startswith("Bearer "): + raise exceptions.AuthenticationFailed("Invalid token header") + + token = auth_header.split(" ")[1] + try: + # Verify and decode the token + payload = self.verify_token(token) + + # Get or create user based on Auth0 subject + auth0_id = payload["sub"] + net_id = auth0_id.split("|")[2].split("@")[0] + name = payload.get("https://hoagie.io/name", "") + email = payload.get("https://hoagie.io/email", "") + + first_name = name.split(" ")[0] + # Handle missing last name + last_name = name.split(" ")[-1] if " " in name else "" + + user, _ = CustomUser.objects.get_or_create( + net_id=net_id, + defaults={ + "email": email, + "first_name": first_name, + "last_name": last_name, + "net_id": net_id, + "username": net_id, + "class_year": datetime.now().year + 1, + }, + ) + + return (user, payload) + + except jwt.ExpiredSignatureError as e: + raise exceptions.AuthenticationFailed("Token has expired") from e + except jwt.InvalidTokenError as e: + raise exceptions.AuthenticationFailed("Invalid token") from e + except Exception as e: + logger.error(f"Authentication error: {str(e)}") + raise exceptions.AuthenticationFailed("Authentication failed") from e + + def _fetch_jwks(self): + jwks_url = f"https://{settings.AUTH0_DOMAIN}/.well-known/jwks.json" + jwks = cache.get("auth0_jwks") + if jwks is None: + jwks = requests.get(jwks_url, timeout=5).json() + cache.set("auth0_jwks", jwks, timeout=3600) + return jwks + + def _find_rsa_key(self, jwks, kid): + for key in jwks["keys"]: + if key["kid"] == kid: + return RSAAlgorithm.from_jwk(key) + return None + + def verify_token(self, token: str): + unverified_header = jwt.get_unverified_header(token) + kid = unverified_header["kid"] + + jwks = self._fetch_jwks() + rsa_key = self._find_rsa_key(jwks, kid) + + # Key not found so refetch in case Auth0 rotated keys + if rsa_key is None: + cache.delete("auth0_jwks") + jwks = self._fetch_jwks() + rsa_key = self._find_rsa_key(jwks, kid) + + if rsa_key is None: + raise exceptions.AuthenticationFailed("Unable to find appropriate key") + + # Verify and decode token + payload = jwt.decode( + token, + rsa_key, + algorithms=settings.AUTH0_ALGORITHMS, + audience=settings.AUTH0_AUDIENCE, + issuer=f"https://{settings.AUTH0_DOMAIN}/", + ) + + return payload diff --git a/backend/hoagiehelp/models/__init__.py b/backend/hoagiehelp/models/__init__.py index 6f7b4db..8820411 100644 --- a/backend/hoagiehelp/models/__init__.py +++ b/backend/hoagiehelp/models/__init__.py @@ -7,11 +7,11 @@ from .user import CustomUser __all__ = [ - "CustomUser", - "Question", - "Answer", - "Comment", - "Tag", - "StudyGroup", - "AnonymousName", + "CustomUser", + "Question", + "Answer", + "Comment", + "Tag", + "StudyGroup", + "AnonymousName", ] diff --git a/backend/hoagiehelp/models/anonymous_name.py b/backend/hoagiehelp/models/anonymous_name.py index 5bed31d..7725d55 100644 --- a/backend/hoagiehelp/models/anonymous_name.py +++ b/backend/hoagiehelp/models/anonymous_name.py @@ -5,24 +5,24 @@ class AnonymousName(models.Model): - """Model to store anonymous names associated with users and questions.""" + """Model to store anonymous names associated with users and questions.""" - id = models.AutoField(primary_key=True) - anonymous_name = models.CharField(max_length=255) - user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) - question = models.ForeignKey(Question, on_delete=models.CASCADE) + id = models.AutoField(primary_key=True) + anonymous_name = models.CharField(max_length=255) + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + question = models.ForeignKey(Question, on_delete=models.CASCADE) - class Meta: - constraints = [ - models.UniqueConstraint( - fields=["question", "user"], - name="unique_question_user", - ), - models.UniqueConstraint( - fields=["question", "anonymous_name"], - name="unique_question_anonymous_name", - ), - ] + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["question", "user"], + name="unique_question_user", + ), + models.UniqueConstraint( + fields=["question", "anonymous_name"], + name="unique_question_anonymous_name", + ), + ] - def __str__(self): - return f"Anonymous_Name(id={self.id}, anonymous_name={self.anonymous_name})" + def __str__(self): + return f"Anonymous_Name(id={self.id}, anonymous_name={self.anonymous_name})" diff --git a/backend/hoagiehelp/models/answer.py b/backend/hoagiehelp/models/answer.py index 3a5ab08..6e78127 100644 --- a/backend/hoagiehelp/models/answer.py +++ b/backend/hoagiehelp/models/answer.py @@ -1,19 +1,20 @@ from django.db import models + class Answer(models.Model): - """Represents an answer to a question""" - - id = models.AutoField(primary_key=True) - question = models.ForeignKey('Question', on_delete=models.CASCADE) - user = models.ForeignKey('CustomUser', on_delete=models.CASCADE) - text = models.TextField(blank=False) - hearts = models.PositiveIntegerField(default=0) - is_anonymous = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + """Represents an answer to a question""" + + id = models.AutoField(primary_key=True) + question = models.ForeignKey("Question", on_delete=models.CASCADE) + user = models.ForeignKey("CustomUser", on_delete=models.CASCADE) + text = models.TextField(blank=False) + hearts = models.PositiveIntegerField(default=0) + is_anonymous = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) - class Meta: - db_table = 'Answer' + class Meta: + db_table = "Answer" - def __str__(self): - return f'Answer to question {self.question} by {self.user.net_id}' \ No newline at end of file + def __str__(self): + return f"Answer to question {self.question} by {self.user.net_id}" diff --git a/backend/hoagiehelp/models/comment.py b/backend/hoagiehelp/models/comment.py index b5e9675..32fc458 100644 --- a/backend/hoagiehelp/models/comment.py +++ b/backend/hoagiehelp/models/comment.py @@ -1,19 +1,20 @@ from django.db import models + class Comment(models.Model): - '''Represents comments on an answer of a question''' - - id = models.AutoField(primary_key=True) - answer = models.ForeignKey('Answer', on_delete=models.CASCADE) - user = models.ForeignKey('CustomUser', on_delete=models.CASCADE) - text = models.TextField(blank=False) - hearts = models.PositiveIntegerField(default=0) - is_anonymous = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + """Represents comments on an answer of a question""" + + id = models.AutoField(primary_key=True) + answer = models.ForeignKey("Answer", on_delete=models.CASCADE) + user = models.ForeignKey("CustomUser", on_delete=models.CASCADE) + text = models.TextField(blank=False) + hearts = models.PositiveIntegerField(default=0) + is_anonymous = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) - class Meta: - db_table = 'Comment' + class Meta: + db_table = "Comment" - def __str__(self): - return f'Comment on {self.answer_id} by {self.user_id}' \ No newline at end of file + def __str__(self): + return f"Comment on {self.answer_id} by {self.user_id}" diff --git a/backend/hoagiehelp/models/heart.py b/backend/hoagiehelp/models/heart.py index 66c6001..ca49220 100644 --- a/backend/hoagiehelp/models/heart.py +++ b/backend/hoagiehelp/models/heart.py @@ -2,60 +2,54 @@ class Heart(models.Model): - """Represents a like on a question, answer, or comment""" + """Represents a like on a question, answer, or comment""" - id = models.AutoField(primary_key=True) - user = models.ForeignKey("CustomUser", on_delete=models.CASCADE) - question = models.ForeignKey( - "Question", on_delete=models.CASCADE, null=True, blank=True - ) - answer = models.ForeignKey( - "Answer", on_delete=models.CASCADE, null=True, blank=True - ) - comment = models.ForeignKey( - "Comment", on_delete=models.CASCADE, null=True, blank=True - ) + id = models.AutoField(primary_key=True) + user = models.ForeignKey("CustomUser", on_delete=models.CASCADE) + question = models.ForeignKey("Question", on_delete=models.CASCADE, null=True, blank=True) + answer = models.ForeignKey("Answer", on_delete=models.CASCADE, null=True, blank=True) + comment = models.ForeignKey("Comment", on_delete=models.CASCADE, null=True, blank=True) - class Meta: - db_table = "Heart" - constraints = [ - models.UniqueConstraint( - fields=["user", "question"], - condition=models.Q(question__isnull=False), - name="unique_heart_question", - ), - models.UniqueConstraint( - fields=["user", "answer"], - condition=models.Q(answer__isnull=False), - name="unique_heart_answer", - ), - models.UniqueConstraint( - fields=["user", "comment"], - condition=models.Q(comment__isnull=False), - name="unique_heart_comment", - ), - models.CheckConstraint( - condition=( - models.Q( - question__isnull=False, - answer__isnull=True, - comment__isnull=True, - ) - | models.Q( - question__isnull=True, - answer__isnull=False, - comment__isnull=True, - ) - | models.Q( - question__isnull=True, - answer__isnull=True, - comment__isnull=False, - ) - ), - name="heart_exactly_one_target", - ), - ] + class Meta: + db_table = "Heart" + constraints = [ + models.UniqueConstraint( + fields=["user", "question"], + condition=models.Q(question__isnull=False), + name="unique_heart_question", + ), + models.UniqueConstraint( + fields=["user", "answer"], + condition=models.Q(answer__isnull=False), + name="unique_heart_answer", + ), + models.UniqueConstraint( + fields=["user", "comment"], + condition=models.Q(comment__isnull=False), + name="unique_heart_comment", + ), + models.CheckConstraint( + condition=( + models.Q( + question__isnull=False, + answer__isnull=True, + comment__isnull=True, + ) + | models.Q( + question__isnull=True, + answer__isnull=False, + comment__isnull=True, + ) + | models.Q( + question__isnull=True, + answer__isnull=True, + comment__isnull=False, + ) + ), + name="heart_exactly_one_target", + ), + ] - def __str__(self): - # Point heart to question, answer, or comment to which it pertains - return f"Heart on {self.question_id or self.answer_id or self.comment_id} by {self.user_id}" + def __str__(self): + # Point heart to question, answer, or comment to which it pertains + return f"Heart on {self.question_id or self.answer_id or self.comment_id} by {self.user_id}" diff --git a/backend/hoagiehelp/models/notification.py b/backend/hoagiehelp/models/notification.py index 66af3a2..31dfe05 100644 --- a/backend/hoagiehelp/models/notification.py +++ b/backend/hoagiehelp/models/notification.py @@ -2,18 +2,18 @@ class Notification(models.Model): - """Represents notifications to a user when an answer is given""" + """Represents notifications to a user when an answer is given""" - id = models.AutoField(primary_key=True) - user = models.ForeignKey("CustomUser", on_delete=models.CASCADE) - question = models.ForeignKey("Question", on_delete=models.CASCADE) - answer = models.ForeignKey("Answer", on_delete=models.CASCADE) - comment = models.ForeignKey("Comment", on_delete=models.CASCADE, null=True) - is_read = models.BooleanField(default=False) - created_at = models.DateTimeField(auto_now_add=True) + id = models.AutoField(primary_key=True) + user = models.ForeignKey("CustomUser", on_delete=models.CASCADE) + question = models.ForeignKey("Question", on_delete=models.CASCADE) + answer = models.ForeignKey("Answer", on_delete=models.CASCADE) + comment = models.ForeignKey("Comment", on_delete=models.CASCADE, null=True) + is_read = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) - class Meta: - db_table = "Notification" + class Meta: + db_table = "Notification" - def __str__(self): - return f"Notification on {self.question_id} by {self.user_id}" + def __str__(self): + return f"Notification on {self.question_id} by {self.user_id}" diff --git a/backend/hoagiehelp/models/question.py b/backend/hoagiehelp/models/question.py index e22ea23..98a1b37 100644 --- a/backend/hoagiehelp/models/question.py +++ b/backend/hoagiehelp/models/question.py @@ -6,19 +6,19 @@ class Question(models.Model): - """Represents a question posted by a user.""" + """Represents a question posted by a user.""" - id = models.AutoField(primary_key=True) - user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) - title = models.CharField(max_length=255) - tags = models.ManyToManyField(Tag) - course = models.CharField(max_length=100, blank=True, null=True, db_index=True) - details = models.TextField() - create_time = models.DateTimeField(auto_now_add=True) - last_updated_time = models.DateTimeField(auto_now=True) - hearts = models.IntegerField(default=0, validators=[MinValueValidator(0)]) - view = models.IntegerField(default=0, validators=[MinValueValidator(0)]) - user_is_anonymous = models.BooleanField(default=False) + id = models.AutoField(primary_key=True) + user = models.ForeignKey(CustomUser, on_delete=models.CASCADE) + title = models.CharField(max_length=255) + tags = models.ManyToManyField(Tag) + course = models.CharField(max_length=100, blank=True, null=True, db_index=True) + details = models.TextField() + create_time = models.DateTimeField(auto_now_add=True) + last_updated_time = models.DateTimeField(auto_now=True) + hearts = models.IntegerField(default=0, validators=[MinValueValidator(0)]) + view = models.IntegerField(default=0, validators=[MinValueValidator(0)]) + user_is_anonymous = models.BooleanField(default=False) - def __str__(self): - return f"Question(id={self.id}, title={self.title})" + def __str__(self): + return f"Question(id={self.id}, title={self.title})" diff --git a/backend/hoagiehelp/models/studygroup.py b/backend/hoagiehelp/models/studygroup.py index d89a340..e1e277f 100644 --- a/backend/hoagiehelp/models/studygroup.py +++ b/backend/hoagiehelp/models/studygroup.py @@ -7,53 +7,53 @@ class StudyGroup(models.Model): - """Represents a study group.""" - - id = models.AutoField(primary_key=True) - title = models.CharField(max_length=MAX_TITLE_LENGTH) - description = models.TextField(blank=True) - - leader = models.ForeignKey( - User, - on_delete=models.CASCADE, - related_name="study_groups_led", - ) - - meeting_datetime = models.DateTimeField(db_index=True) - max_spots = models.PositiveIntegerField(validators=[MinValueValidator(1)]) - - members = models.ManyToManyField( - User, - related_name="study_groups_joined", - blank=True, - ) - - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - - class Meta: - ordering = ["-created_at"] - constraints = [ - models.UniqueConstraint( - fields=["leader", "meeting_datetime", "title"], - name="unique_study_group_per_leader", - ) - ] - - @property - def spots_taken(self): - """Returns number of members""" - return self.members.count() - - @property - def spots_remaining(self): - """Returns remaining spots""" - return max(self.max_spots - self.spots_taken, 0) - - @property - def is_full(self): - """Check if the study group is full""" - return self.spots_taken >= self.max_spots - - def __str__(self): - return self.title + """Represents a study group.""" + + id = models.AutoField(primary_key=True) + title = models.CharField(max_length=MAX_TITLE_LENGTH) + description = models.TextField(blank=True) + + leader = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="study_groups_led", + ) + + meeting_datetime = models.DateTimeField(db_index=True) + max_spots = models.PositiveIntegerField(validators=[MinValueValidator(1)]) + + members = models.ManyToManyField( + User, + related_name="study_groups_joined", + blank=True, + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["leader", "meeting_datetime", "title"], + name="unique_study_group_per_leader", + ) + ] + + @property + def spots_taken(self): + """Returns number of members""" + return self.members.count() + + @property + def spots_remaining(self): + """Returns remaining spots""" + return max(self.max_spots - self.spots_taken, 0) + + @property + def is_full(self): + """Check if the study group is full""" + return self.spots_taken >= self.max_spots + + def __str__(self): + return self.title diff --git a/backend/hoagiehelp/models/tag.py b/backend/hoagiehelp/models/tag.py index 23a780d..e6c7189 100644 --- a/backend/hoagiehelp/models/tag.py +++ b/backend/hoagiehelp/models/tag.py @@ -2,26 +2,26 @@ # Choices for question_tag QUESTION_TAG_CHOICES = ( - ("exam prep", "exam prep"), - ("problem set", "problem set"), - ("study tips", "study tips"), - ("concepts", "concepts"), - ("course selection", "course selection"), - ("degree planning", "degree planning"), - ("citation", "citation"), - ("course advice", "course advice"), - ("career", "career"), + ("exam prep", "exam prep"), + ("problem set", "problem set"), + ("study tips", "study tips"), + ("concepts", "concepts"), + ("course selection", "course selection"), + ("degree planning", "degree planning"), + ("citation", "citation"), + ("course advice", "course advice"), + ("career", "career"), ) class Tag(models.Model): - """Represents a tag on a question""" + """Represents a tag on a question""" - id = models.AutoField(primary_key=True) - name = models.CharField(max_length=30, choices=QUESTION_TAG_CHOICES, unique=True) + id = models.AutoField(primary_key=True) + name = models.CharField(max_length=30, choices=QUESTION_TAG_CHOICES, unique=True) - class Meta: - db_table = "Tag" + class Meta: + db_table = "Tag" - def __str__(self): - return f"Tag: {self.name}" + def __str__(self): + return f"Tag: {self.name}" diff --git a/backend/hoagiehelp/models/user.py b/backend/hoagiehelp/models/user.py index d7c0a22..38abcc1 100644 --- a/backend/hoagiehelp/models/user.py +++ b/backend/hoagiehelp/models/user.py @@ -2,22 +2,18 @@ from django.contrib.auth.models import AbstractUser from django.core.validators import MinValueValidator, MaxValueValidator -class CustomUser(AbstractUser): - '''HoagieHelp user.''' - net_id = models.CharField( - max_length=20, unique=True, db_index=True - ) - class_year = models.PositiveSmallIntegerField( - validators=[MinValueValidator(1900), MaxValueValidator(2100)] - ) - hearts = models.PositiveIntegerField(default=0) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) +class CustomUser(AbstractUser): + """HoagieHelp user.""" - class Meta: - db_table = "User" + net_id = models.CharField(max_length=20, unique=True, db_index=True) + class_year = models.PositiveSmallIntegerField(validators=[MinValueValidator(1900), MaxValueValidator(2100)]) + hearts = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) - def __str__(self): - return f"{self.get_full_name()} ({self.net_id})" + class Meta: + db_table = "User" + def __str__(self): + return f"{self.get_full_name()} ({self.net_id})" diff --git a/backend/hoagiehelp/tests.py b/backend/hoagiehelp/tests.py index 7ce503c..a39b155 100644 --- a/backend/hoagiehelp/tests.py +++ b/backend/hoagiehelp/tests.py @@ -1,3 +1 @@ -from django.test import TestCase - # Create your tests here. diff --git a/backend/hoagiehelp/views.py b/backend/hoagiehelp/views.py index 91ea44a..60f00ef 100644 --- a/backend/hoagiehelp/views.py +++ b/backend/hoagiehelp/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - # Create your views here. diff --git a/backend/manage.py b/backend/manage.py index 8e7ac79..8a42707 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -1,22 +1,23 @@ #!/usr/bin/env python """Django's command-line utility for administrative tasks.""" + import os import sys def main(): - """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) -if __name__ == '__main__': - main() +if __name__ == "__main__": + main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 048506b..b54cfde 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -34,3 +34,81 @@ dependencies = [ [project.urls] Homepage = "https://help.hoagie.io/" Repository = "https://github.com/hoagieclub/help/" + +# =============================== +# Ruff Configuration +# =============================== + +# Adopting Google-style docstrings for consistency across the project. + +[tool.ruff] +# Exclude commonly ignored directories from linting +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "hoagieplan/migrations", +] + +# General Ruff settings +line-length = 119 # Slight deviation from Google style since we are using Django. +indent-width = 4 +target-version = "py312" + +# ------------------------------- +# Ruff Linting Rules +# ------------------------------- +[tool.ruff.lint] + +# Enable pydocstyle rules for Google-style docstrings +select = ["E4", "E7", "E9", "F", "B", "Q", "D2"] # Flake8 rules, Bugbear, Quotes, etc. + +ignore = [ + "D203", # Ignore conflicts for one-liner docstring style + "D206", # Ignore docstring being indented with spaces, not tabs + "D213", # Allow Google-style multi-line docstrings with different line breaks + "E402", # Allow arbitrary import order to support django.setup() flow +] + +# Define fixable and unfixable rules +fixable = ["ALL"] +unfixable = [] + +# ------------------------------- +# Ruff Flake8-Quotes Settings +# ------------------------------- +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" # Enforce double quotes for docstrings +inline-quotes = "double" # Enforce double quotes for inline strings + +# ------------------------------- +# Ruff Formatting Settings +# ------------------------------- +[tool.ruff.format] +quote-style = "double" # Use double quotes for auto-formatting +indent-style = "tab" # Indent with tabs instead of spaces +skip-magic-trailing-comma = false # Respect magic trailing commas +line-ending = "auto" # Automatically detect line endings + +[dependency-groups] +dev = [ + "ruff>=0.15.4", +] diff --git a/backend/uv.lock b/backend/uv.lock index b14a1d5..478a685 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -239,6 +239,11 @@ dependencies = [ { name = "requests" }, ] +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "dj-database-url", specifier = ">=3.0.1" }, @@ -250,6 +255,9 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5" }, ] +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = ">=0.15.4" }] + [[package]] name = "idna" version = "3.11" @@ -372,6 +380,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "ruff" +version = "0.15.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, + { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, + { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, + { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, + { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, + { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, + { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, + { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +] + [[package]] name = "sqlparse" version = "0.5.3"