From 65263e709247809c692cdb6b5e163c5f8f9f458b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Tue, 31 Dec 2024 23:17:19 +0100 Subject: [PATCH 1/7] Update requirements --- .../requirements/requirements.txt | 57 +++++++------------ 1 file changed, 19 insertions(+), 38 deletions(-) diff --git a/{{ cookiecutter.project_slug }}/requirements/requirements.txt b/{{ cookiecutter.project_slug }}/requirements/requirements.txt index 50b01db..34d0594 100644 --- a/{{ cookiecutter.project_slug }}/requirements/requirements.txt +++ b/{{ cookiecutter.project_slug }}/requirements/requirements.txt @@ -1,49 +1,30 @@ -django==4.1.4 +boto3==1.35.71 +celery[redis]==5.4.0 +celery==5.4.0 +click==8.1.3 +django==5.0.6 djangorestframework==3.14.0 -django-allauth==0.52.0 +djangorestframework-api-key==3.0.0 +django-celery-beat==2.6.0 django-cors-headers==3.13.0 django-countries==7.5 -dj-database-url==0.5.0 +django-jsonform==2.22.0 +django-opensearch-dsl==0.6.2 django-phonenumber-field==7.0.0 django-storages==1.13.1 django-debug-toolbar -factory-boy==3.2.1 django-browser-reload==1.6.0 django-compressor==4.1 -django-tailwind==3.4.0 -django-money==3.0.0 - -# Dates -pendulum==2.1.2 - -# CLI -click==8.1.3 - -# Postgres -psycopg2==2.9.3 - -# Tasks -celery[redis]==5.2.7 -celery==5.2.7 -django-celery-beat==2.4.0 - -# Environment variables -python-dotenv==0.15.0 - -# WSGI +django-hijack==3.5.3 +django-json-widget==2.0.1 +dj-database-url==0.5.0 +factory-boy==3.2.1 +google-auth==2.16.0 gunicorn==20.0.4 - -# Static files -whitenoise==6.2.0 - -# Phone numbers phonenumbers==8.13.0 - -# Short UUID +psycopg2==2.9.3 +python-dotenv==0.15.0 +sentry-sdk==2.18.0 +serpy==0.3.1 shortuuid==1.0.11 - -# Google -google-auth==2.16.0 - -# Billing -stripe==4.2.0 \ No newline at end of file +whitenoise==6.2.0 \ No newline at end of file From 1112a26e649e63108eb76682cde9428373ccd74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Tue, 31 Dec 2024 23:19:24 +0100 Subject: [PATCH 2/7] Add `Serializer` --- .../serializer.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 {{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/serializer.py diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/serializer.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/serializer.py new file mode 100644 index 0000000..2d224a6 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/serializer.py @@ -0,0 +1,23 @@ +import serpy + + +class Serializer(serpy.Serializer): + def __init__(self, *args, **kwargs): + fields = kwargs.pop("fields", None) + super(Serializer, self).__init__(*args, **kwargs) + + # If fields is passed, includes only passed fields + # in the .to_value() step, allowing to skip query/serialize + # only the necessary fields. + if fields is not None: + allowed = set(fields) + existing = set(self._field_map) + for field_name in existing - allowed: + del self._field_map[field_name] + self._compiled_fields = list( + filter(lambda x: x[0] in allowed, self._compiled_fields) + ) + + context = kwargs.pop("context", None) + if context is not None: + self.context = context From e24b7d4466af97e962cdadb987c5b69946c95a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Tue, 31 Dec 2024 23:25:13 +0100 Subject: [PATCH 3/7] Add rest framework and django-hijack --- .../settings.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/settings.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/settings.py index 1bc5eb6..861eaea 100644 --- a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/settings.py +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/settings.py @@ -51,8 +51,13 @@ "django_celery_beat", "django_countries", "djmoney", + "django_opensearch_dsl", + "hijack", + "hijack.contrib.admin", "phonenumber_field", "rest_framework", + "rest_framework.authtoken", + "rest_framework_api_key", "storages", "tailwind", "tailwind_theme", @@ -75,6 +80,7 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "hijack.middleware.HijackUserMiddleware", ] if DEBUG is True: @@ -110,6 +116,28 @@ CORS_ORIGIN_WHITELIST = json.loads(os.environ.get("CORS_ORIGIN_WHITELIST")) +# Django Rest Framework +# https://www.django-rest-framework.org/ + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.TokenAuthentication", + "rest_framework.authentication.SessionAuthentication", + ), + "DEFAULT_PERMISSION_CLASSES": ( + "{{cookiecutter.project_slug}}.permissions.IsConsumerAuthenticated", + "rest_framework.permissions.IsAuthenticated", + ), + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 100, + "DEFAULT_RENDERER_CLASSES": ( + "rest_framework.renderers.JSONRenderer", + ), +} + +API_KEY_CUSTOM_HEADER = "HTTP_X_API_KEY" + + # Database # https://docs.djangoproject.com/en/4.1/ref/settings/#databases From b1db7ec013267ccd2ce6baa89cf2b0065d02d720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Wed, 1 Jan 2025 14:24:54 +0100 Subject: [PATCH 4/7] Add AWS and OpenSearch settings --- .../settings.py | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/settings.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/settings.py index 861eaea..fc7a45b 100644 --- a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/settings.py +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/settings.py @@ -2,8 +2,12 @@ import os from pathlib import Path +import boto3 import dj_database_url +import sentry_sdk +from django.utils.translation import gettext_lazy as _ from dotenv import load_dotenv +from opensearchpy import AWSV4SignerAuth, RequestsHttpConnection # Load environment variables from .env file load_dotenv(verbose=True) @@ -151,6 +155,7 @@ "django.contrib.auth.backends.ModelBackend", ] +LOGIN_REDIRECT_URL = "" LOGIN_URL = "/login/" LOGOUT = "" @@ -197,6 +202,11 @@ USE_TZ = True +LANGUAGES = [ + ("en", _("English")), + ("es", _("Spanish")), +] + # Celery Configuration Options # https://docs.celeryproject.org @@ -268,6 +278,52 @@ DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL") +# Amazon Web Services configurations +# https://aws.amazon.com + +AWS_ACCOUNT_ID = os.environ.get("AWS_ACCOUNT_ID") +AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") +AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") +AWS_STORAGE_STATIC_BUCKET_NAME = os.environ.get("AWS_STORAGE_STATIC_BUCKET_NAME") +AWS_STORAGE_MEDIA_INPUT_BUCKET_NAME = os.environ.get( + "AWS_STORAGE_MEDIA_INPUT_BUCKET_NAME" +) +AWS_STORAGE_MEDIA_INPUT_BUCKET_REGION_NAME = os.environ.get( + "AWS_STORAGE_MEDIA_INPUT_BUCKET_REGION_NAME" +) +AWS_STORAGE_MEDIA_OUTPUT_BUCKET_NAME = os.environ.get( + "AWS_STORAGE_MEDIA_OUTPUT_BUCKET_NAME" +) +AWS_STORAGE_STATIC_HOST = os.environ.get("AWS_STORAGE_STATIC_HOST", "s3.amazonaws.com") +AWS_STORAGE_MEDIA_INPUT_HOST = os.environ.get( + "AWS_STORAGE_MEDIA_INPUT_HOST", "s3.amazonaws.com" +) +AWS_STORAGE_MEDIA_OUTPUT_HOST = os.environ.get( + "AWS_STORAGE_MEDIA_OUTPUT_HOST", "s3.amazonaws.com" +) +AWS_STORAGE_STATIC_DOMAIN = "%s.%s" % ( + AWS_STORAGE_STATIC_BUCKET_NAME, + AWS_STORAGE_STATIC_HOST, +) +AWS_STORAGE_MEDIA_INPUT_DOMAIN = "%s.%s" % ( + AWS_STORAGE_MEDIA_INPUT_BUCKET_NAME, + AWS_STORAGE_MEDIA_INPUT_HOST, +) +AWS_STORAGE_MEDIA_OUTPUT_DOMAIN = "%s.%s" % ( + AWS_STORAGE_MEDIA_OUTPUT_BUCKET_NAME, + AWS_STORAGE_MEDIA_OUTPUT_HOST, +) +AWS_CLOUD_FRONT_DOMAIN_NAME = os.environ.get("AWS_CLOUD_FRONT_DOMAIN_NAME") +AWS_CLOUD_FRONT_PRIVATE_KEY = os.environ.get("AWS_CLOUD_FRONT_PRIVATE_KEY") +AWS_CLOUD_FRONT_KEY_PAIR_ID = os.environ.get("AWS_CLOUD_FRONT_KEY_PAIR_ID") +AWS_OPEN_SEARCH_HOST = os.environ.get("AWS_OPEN_SEARCH_HOST") +AWS_OPEN_SEARCH_REGION_NAME = os.environ.get("AWS_OPEN_SEARCH_REGION_NAME") +AWS_SES_SMTP_USER = os.environ.get("AWS_SES_SMTP_USER") +AWS_SES_SMTP_PASSWORD = os.environ.get("AWS_SES_SMTP_PASSWORD") +AWS_SES_REGION_NAME = os.environ.get("AWS_SES_REGION_NAME") +AWS_SES_REGION_ENDPOINT = os.environ.get("AWS_SES_REGION_ENDPOINT") + + # Google GOOGLE_OAUTH_CLIENT_ID = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", None) @@ -282,6 +338,46 @@ STRIPE_PRICE_ID = os.environ.get("STRIPE_PRICE_ID", None) +# Mixpanel + +MIXPANEL_API_TOKEN = os.environ.get("MIXPANEL_API_TOKEN", None) + + +# Django Opensearch DSL +# https://django-opensearch-dsl.readthedocs.io/en/latest/ + +OPENSEARCH_DSL = { + "default": { + "hosts": AWS_OPEN_SEARCH_HOST, + "http_auth": AWSV4SignerAuth( + boto3.Session( + aws_access_key_id=AWS_ACCESS_KEY_ID, + aws_secret_access_key=AWS_SECRET_ACCESS_KEY, + ).get_credentials(), + AWS_OPEN_SEARCH_REGION_NAME, + "es", + ), + "use_ssl": True, + "verify_certs": True, + "connection_class": RequestsHttpConnection, + "pool_maxsize": 20, + }, +} + + +# Sentry + +SENTRY_DSN = os.environ.get("SENTRY_DSN", None) + +sentry_sdk.init( + dsn=SENTRY_DSN, + traces_sample_rate=1.0, + _experiments={ + "continuous_profiling_auto_start": False, + }, +) + + # {{ cookiecutter.project_name }} config SUBSCRIPTION_TRIAL_PERIOD_DAYS = 14 From 605834db2c0fe3f9a65427ea5b6a10e8255b22f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Wed, 1 Jan 2025 14:27:21 +0100 Subject: [PATCH 5/7] Add env vars --- {{ cookiecutter.project_slug }}/.env.example | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/{{ cookiecutter.project_slug }}/.env.example b/{{ cookiecutter.project_slug }}/.env.example index 7136db1..f0cf67f 100644 --- a/{{ cookiecutter.project_slug }}/.env.example +++ b/{{ cookiecutter.project_slug }}/.env.example @@ -15,9 +15,16 @@ POSTGRES_DB= CELERY_BROKER_URL= CELERY_ACCEPT_CONTENT= +# AWS +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_OPEN_SEARCH_REGION_NAME= +AWS_OPEN_SEARCH_HOST= + # Google GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= +GOOGLE_TAG_ID= # Heroku PORT= @@ -26,4 +33,7 @@ PORT= STRIPE_PUBLISHABLE_KEY= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= -STRIPE_PRICE_ID= \ No newline at end of file +STRIPE_PRICE_ID= + +# Mixpanel +MIXPANEL_API_TOKEN= \ No newline at end of file From e9cf5c17954df4561d6435c0ad1a8e5aada6e9ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Wed, 1 Jan 2025 14:27:47 +0100 Subject: [PATCH 6/7] Add search app --- .../permissions.py | 23 ++++++++++++++++ .../search/__init__.py | 0 .../search/api_urls.py | 13 +++++++++ .../search/api_views.py | 27 +++++++++++++++++++ .../search/apps.py | 7 +++++ .../search/serializers.py | 26 ++++++++++++++++++ .../search/utils.py | 20 ++++++++++++++ .../{{ cookiecutter.project_slug }}/urls.py | 8 ++++++ 8 files changed, 124 insertions(+) create mode 100644 {{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/permissions.py create mode 100644 {{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/__init__.py create mode 100644 {{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/api_urls.py create mode 100644 {{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/api_views.py create mode 100644 {{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/apps.py create mode 100644 {{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/serializers.py create mode 100644 {{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/utils.py diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/permissions.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/permissions.py new file mode 100644 index 0000000..1e8f852 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/permissions.py @@ -0,0 +1,23 @@ +from django.conf import settings +from rest_framework.permissions import SAFE_METHODS, BasePermission +from rest_framework_api_key.models import APIKey +from rest_framework_api_key.permissions import BaseHasAPIKey + + +class AllowAnyInDebug(BasePermission): + def has_permission(self, request, view): + if settings.DEBUG: + return True + return False + + +class IsAdminUserAndReadOnly(BasePermission): + def has_permission(self, request, view): + if request.method not in SAFE_METHODS: + return False + + return request.user and request.user.is_staff + + +class IsConsumerAuthenticated(BaseHasAPIKey): + model = APIKey diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/__init__.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/api_urls.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/api_urls.py new file mode 100644 index 0000000..d5a090e --- /dev/null +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/api_urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from {{cookiecutter.project_slug}}.search import api_views + +app_name = "{{ cookiecutter.project_slug }}-search" + +urlpatterns = [ + path( + "search/", + api_views.search_view, + name="search", + ), +] diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/api_views.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/api_views.py new file mode 100644 index 0000000..dea359c --- /dev/null +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/api_views.py @@ -0,0 +1,27 @@ +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK + +from {{cookiecutter.project_slug}}.permissions import IsConsumerAuthenticated +from {{cookiecutter.project_slug}}.search.serializers import SearchHitSerializer +from {{cookiecutter.project_slug}}.search.utils import search + + +@api_view(["GET"]) +@permission_classes([IsConsumerAuthenticated, IsAuthenticated]) +def search_view(request): + q = request.GET.get("q", None) + + course = request.GET.get("course", None) + + s = search(q, course_uuid=course) + hits_serializer = SearchHitSerializer(s.hits, many=True) + + return Response( + { + "total": len(hits_serializer.data), + "results": hits_serializer.data, + }, + status=HTTP_200_OK, + ) diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/apps.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/apps.py new file mode 100644 index 0000000..3c9c59e --- /dev/null +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class SearchConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "{{ cookiecutter.project_slug }}.search" + label = "{{ cookiecutter.project_slug }}_search" diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/serializers.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/serializers.py new file mode 100644 index 0000000..1a7bbbd --- /dev/null +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/serializers.py @@ -0,0 +1,26 @@ +import serpy + +from {{cookiecutter.project_slug}}.serializers import Serializer + + +class SearchHitSerializer(Serializer): + id = serpy.Field() + type = serpy.Field() + + def _serialize(self, instance, fields=None): + # Dynamically select the appropriate serializer based on the 'type' field + type_to_serializer = { + # NOTE: include a map from instance.type to respective serializer class + } + + serializer_class = type_to_serializer.get(instance["type"]) + if not serializer_class: + raise ValueError(f"Unknown type: {instance['type']}") + + serialized_data = { + "id": instance["uuid"], + "type": instance["type"], + **serializer_class(instance).data + } + + return serialized_data diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/utils.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/utils.py new file mode 100644 index 0000000..730f281 --- /dev/null +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/search/utils.py @@ -0,0 +1,20 @@ +from django_opensearch_dsl.search import Search + + +def search(query, course_uuid=None): + search = Search(index="exercises") + search = search.query( + "multi_match", + query=query, + fields=[ + "content_name^3", + "content_messages_text^2", + "content_messages_text_translation", + ], + fuzziness="AUTO", + ) + + if course_uuid: + search = search.filter("term", course_uuid=course_uuid) + + return search.execute() diff --git a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/urls.py b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/urls.py index 92a0c05..39a59ce 100644 --- a/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/urls.py +++ b/{{ cookiecutter.project_slug }}/src/{{ cookiecutter.project_slug }}/urls.py @@ -4,8 +4,16 @@ from django.urls import include, path from {{cookiecutter.project_slug}} import views +api_urlpatterns = ( + [ + path("", include("{{ cookiecutter.project_slug }}.search.api_urls")), + ], + "api", +) + urlpatterns = [ path("admin/", admin.site.urls), + path("api/", include(api_urlpatterns)), path("", views.index_view, name="index"), path("", include("{{ cookiecutter.project_slug }}.auth.urls")), path("", include("{{ cookiecutter.project_slug }}.billing.urls")), From 264fbb16e1d7e168d3a5594f45b6473c4e913b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernesto=20F=2E=20Gonz=C3=A1lez?= Date: Wed, 1 Jan 2025 14:31:04 +0100 Subject: [PATCH 7/7] Update docs --- docs/changelog.rst | 9 +++++++++ docs/conf.py | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 89650ab..e311aba 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,15 @@ Changelog ========= +.. _v_0_5_0: + +0.5.0 (2025-01-01) +------------------ + +* Added search with OpenSearch +* Added settings for Mixpanel +* Added error logging with Sentry + .. _v_0_4_3: 0.4.3 (2023-08-11) diff --git a/docs/conf.py b/docs/conf.py index 783d240..d9a5651 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,9 +7,9 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'Django Rocket' -copyright = '2023, Ernesto F. González' +copyright = '2023-2025, Ernesto F. González' author = 'Ernesto F. González' -release = '0.4.3' +release = '0.5.0' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration