diff --git a/Makefile b/Makefile index 9551633..50287a0 100644 --- a/Makefile +++ b/Makefile @@ -9,3 +9,9 @@ stop: log: docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env logs -f + +migrate: + docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py migrate" + +makemigrations: + docker-compose -f docker/docker-compose.prod.yml --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py makemigrations" \ No newline at end of file diff --git a/app/local_settings.py b/app/local_settings.py index 2129eec..f023be9 100644 --- a/app/local_settings.py +++ b/app/local_settings.py @@ -27,6 +27,9 @@ SECRET_KEY = getenv("SECRET_KEY", "") STRIPE_ADMIN_COUPON = getenv("STRIPE_ADMIN_COUPON", "") +# Hook URL, do not include leading or trailing slash +HOOK_PATH = "hook" + DEBUG = getenv("DEBUG", "false").lower() in trues PROFILER = getenv("PROFILER", "false").lower() in trues @@ -39,4 +42,4 @@ if DEBUG: "10.0.2.2", ] -SETTINGS_EXPORT = ["STRIPE_ENABLED"] +SETTINGS_EXPORT = ["STRIPE_ENABLED", "URL", "HOOK_PATH"] diff --git a/app/settings.py b/app/settings.py index a6aac00..eea2653 100644 --- a/app/settings.py +++ b/app/settings.py @@ -30,6 +30,7 @@ ALLOWED_HOSTS = [] INSTALLED_APPS = [ "core", "django.contrib.admin", + # 'core.apps.LibraryAdminConfig', # our custom OTP'ed admin "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", @@ -42,6 +43,10 @@ INSTALLED_APPS = [ "crispy_bulma", # "django_tables2", # "django_tables2_bulma_template", + "django_otp", + "django_otp.plugins.otp_totp", + # 'django_otp.plugins.otp_hotp', + # 'django_otp.plugins.otp_static', ] CRISPY_TEMPLATE_PACK = "bulma" CRISPY_ALLOWED_TEMPLATE_PACKS = ( @@ -57,6 +62,7 @@ MIDDLEWARE = [ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "django_otp.middleware.OTPMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "django_htmx.middleware.HtmxMiddleware", diff --git a/app/urls.py b/app/urls.py index 8545679..fcca426 100644 --- a/app/urls.py +++ b/app/urls.py @@ -16,11 +16,13 @@ Including another URLconf from django.conf import settings from django.conf.urls.static import static from django.contrib import admin +from django.contrib.auth.views import LoginView from django.urls import include, path from django.views.generic import TemplateView +from django_otp.forms import OTPAuthenticationForm -from core.views import base, hooks -from core.views.callbacks import Callback +from core.views import base, callbacks, hooks +from core.views.stripe_callbacks import Callback urlpatterns = [ path("__debug__/", include("debug_toolbar.urls")), @@ -38,7 +40,10 @@ urlpatterns = [ ), path("cancel/", TemplateView.as_view(template_name="cancel.html"), name="cancel"), path("portal", base.Portal.as_view(), name="portal"), - path("admin/", admin.site.urls), + path("sapp/", admin.site.urls), + path( + "accounts/login/", LoginView.as_view(authentication_form=OTPAuthenticationForm) + ), path("accounts/", include("django.contrib.auth.urls")), path("accounts/signup/", base.Signup.as_view(), name="signup"), path("hooks//", hooks.Hooks.as_view(), name="hooks"), @@ -48,6 +53,17 @@ urlpatterns = [ "hooks/page/del//", hooks.HookAction.as_view(), name="hook_action" ), path( - "hooks/page/edit//", hooks.HookAction.as_view(), name="hook_action" + "hooks/modal/edit//", + hooks.HookAction.as_view(), + name="hook_action", ), + path( + f"{settings.HOOK_PATH}//", hooks.HookAPI.as_view(), name="hook" + ), + path( + "callbacks///", + callbacks.Callbacks.as_view(), + name="callbacks", + ), + path("callbacks//", callbacks.Callbacks.as_view(), name="callbacks"), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/core/admin.py b/core/admin.py index 0fdd381..52b3b2d 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,9 +1,16 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin +from django_otp.admin import OTPAdminSite from .forms import CustomUserCreationForm from .models import Plan, Session, User +admin.site.__class__ = OTPAdminSite + +# otp_admin_site = OTPAdminSite(OTPAdminSite.name) +# for model_cls, model_admin in admin.site._registry.items(): +# otp_admin_site.register(model_cls, model_admin.__class__) + # Register your models here. class CustomUserAdmin(UserAdmin): diff --git a/core/migrations/0004_callback.py b/core/migrations/0004_callback.py new file mode 100644 index 0000000..68dcd94 --- /dev/null +++ b/core/migrations/0004_callback.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.2 on 2022-10-15 18:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_hook'), + ] + + operations = [ + migrations.CreateModel( + name='Callback', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', models.JSONField()), + ('hook', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.hook')), + ], + ), + ] diff --git a/core/models.py b/core/models.py index f8dba39..4670954 100644 --- a/core/models.py +++ b/core/models.py @@ -81,6 +81,11 @@ class Hook(models.Model): received = models.IntegerField(default=0) +class Callback(models.Model): + hook = models.ForeignKey(Hook, on_delete=models.CASCADE) + data = models.JSONField() + + # class Perms(models.Model): # class Meta: # permissions = ( diff --git a/core/templates/base.html b/core/templates/base.html index 65b441c..8a9e106 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -206,6 +206,11 @@ Hooks {% endif %} + {% if user.is_authenticated %} + + Callbacks + + {% endif %} {% if settings.STRIPE_ENABLED %} {% if user.is_authenticated %} diff --git a/core/templates/index.html b/core/templates/index.html index 1319b0a..be7701f 100644 --- a/core/templates/index.html +++ b/core/templates/index.html @@ -4,7 +4,7 @@ {% block outer_content %}
-
+
diff --git a/core/templates/window-content/callbacks.html b/core/templates/window-content/callbacks.html new file mode 100644 index 0000000..68c4fed --- /dev/null +++ b/core/templates/window-content/callbacks.html @@ -0,0 +1,32 @@ +{% include 'partials/notify.html' %} + + + + + + + + + + {% for item in items %} + + + + + + + + {% endfor %} + +
idhook idhook namedataactions
{{ item.id }}{{ item.hook.id }} + {{ item.hook.name }} + +
{{ item.data }}
+
+ +
+
diff --git a/core/templates/window-content/main.html b/core/templates/window-content/main.html index ca44c02..0189a8a 100644 --- a/core/templates/window-content/main.html +++ b/core/templates/window-content/main.html @@ -1,44 +1,95 @@ -

This is a demo panel

+

Management panel

-
- - - -
\ No newline at end of file + + + + + +
+
+ + + + + + + +
nameactions
Hooks + + + + +
Callbacks + + + + +
diff --git a/core/views/base.py b/core/views/base.py index 777ca03..1ad875a 100644 --- a/core/views/base.py +++ b/core/views/base.py @@ -19,10 +19,10 @@ logger = logging.getLogger(__name__) # Create your views here -class Home(View): +class Home(LoginRequiredMixin, View): template_name = "index.html" - async def get(self, request): + def get(self, request): return render(request, self.template_name) diff --git a/core/views/callbacks.py b/core/views/callbacks.py index f193561..859d862 100644 --- a/core/views/callbacks.py +++ b/core/views/callbacks.py @@ -1,104 +1,51 @@ -import logging -from datetime import datetime +import uuid -import stripe -from django.conf import settings -from django.http import HttpResponse, JsonResponse -from django.views.decorators.csrf import csrf_exempt -from rest_framework.parsers import JSONParser -from rest_framework.views import APIView +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseBadRequest +from django.shortcuts import render +from django.views import View -from core.models import Plan, Session, User - -logger = logging.getLogger(__name__) +from core.models import Callback, Hook -class Callback(APIView): - parser_classes = [JSONParser] +def get_callbacks(hook=None, user=None): + if user: + callbacks = Callback.objects.filter(hook__user=user) + elif hook: + callbacks = Callback.objects.filter(hook=hook) + print("CALLBACKS", callbacks) + return callbacks - # TODO: make async - @csrf_exempt - def post(self, request): - payload = request.body - sig_header = request.META["HTTP_STRIPE_SIGNATURE"] - try: - stripe.Webhook.construct_event( - payload, sig_header, settings.STRIPE_ENDPOINT_SECRET - ) - except ValueError: - # Invalid payload - logger.error("Invalid payload") - return HttpResponse(status=400) - except stripe.error.SignatureVerificationError: - # Invalid signature - logger.error("Invalid signature") - return HttpResponse(status=400) - if request.data is None: - return JsonResponse({"success": False}, status=500) - if "type" in request.data.keys(): - rtype = request.data["type"] - if rtype == "checkout.session.completed": - session = request.data["data"]["object"]["id"] - subscription_id = request.data["data"]["object"]["subscription"] - session_map = Session.objects.get(session=session) - if not session_map: - return JsonResponse({"success": False}, status=500) - user = session_map.user - session_map.subscription_id = subscription_id - session_map.save() +class Callbacks(LoginRequiredMixin, View): + allowed_types = ["modal", "widget", "window", "page"] + window_content = "window-content/callbacks.html" - if rtype == "customer.subscription.updated": - stripe_id = request.data["data"]["object"]["customer"] - if not stripe_id: - logging.error("No stripe id") - return JsonResponse({"success": False}, status=500) - user = User.objects.get(stripe_id=stripe_id) - # ssubscription_active - subscription_id = request.data["data"]["object"]["id"] - sessions = Session.objects.filter(user=user) - session = None - for session_iter in sessions: - if session_iter.subscription_id == subscription_id: - session = session_iter - if not session: - logging.error( - f"No session found for subscription id {subscription_id}" - ) - return JsonResponse({"success": False}, status=500) - # query Session objects - # iterate and check against product_id - session.request = request.data["request"]["id"] - product_id = request.data["data"]["object"]["plan"]["id"] - plan = Plan.objects.get(product_id=product_id) - if not plan: - logging.error(f"Plan not found: {product_id}") - return JsonResponse({"success": False}, status=500) - session.plan = plan - session.save() + async def get(self, request, type, hook_id=None): + if type not in self.allowed_types: + return HttpResponseBadRequest + template_name = f"wm/{type}.html" + unique = str(uuid.uuid4())[:8] - elif rtype == "payment_intent.succeeded": - customer = request.data["data"]["object"]["customer"] - user = User.objects.get(stripe_id=customer) - if not user: - logging.error(f"No user found for customer: {customer}") - return JsonResponse({"success": False}, status=500) - session = Session.objects.get(request=request.data["request"]["id"]) - - user.plans.add(session.plan) - user.last_payment = datetime.utcnow() - user.save() - - elif rtype == "customer.subscription.deleted": - customer = request.data["data"]["object"]["customer"] - user = User.objects.get(stripe_id=customer) - if not user: - logging.error(f"No user found for customer {customer}") - return JsonResponse({"success": False}, status=500) - product_id = request.data["data"]["object"]["plan"]["id"] - plan = Plan.objects.get(product_id=product_id) - user.plans.remove(plan) - user.save() + if hook_id: + try: + hook = Hook.objects.get(id=hook_id, user=request.user) + except Hook.DoesNotExist: + message = "Hook does not exist." + message_class = "danger" + context = { + "message": message, + "class": message_class, + } + return render(request, "wm/modal.html", context) + callbacks = get_callbacks(hook) else: - return JsonResponse({"success": False}, status=500) - return JsonResponse({"success": True}) + callbacks = get_callbacks(user=request.user) + + context = { + "title": f"Callbacks ({type})", + "unique": unique, + "window_content": self.window_content, + "items": callbacks, + } + return render(request, template_name, context) diff --git a/core/views/hooks.py b/core/views/hooks.py index 4c6f663..ffd2e4a 100644 --- a/core/views/hooks.py +++ b/core/views/hooks.py @@ -1,14 +1,15 @@ import uuid +import orjson from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponseBadRequest +from django.http import HttpResponse, HttpResponseBadRequest from django.shortcuts import render from django.views import View -from rest_framework.parsers import FormParser +from rest_framework.parsers import FormParser, JSONParser from rest_framework.views import APIView from core.forms import HookForm -from core.models import Hook +from core.models import Callback, Hook def get_hooks(user): @@ -16,6 +17,28 @@ def get_hooks(user): return hooks +class HookAPI(APIView): + parser_classes = [JSONParser] + + def post(self, request, hook_name): + hook = Hook.objects.get(name=hook_name) + print("DATA FREOM POST", request.data) + callback = Callback.objects.create( + hook=hook, + data=request.data, + ) + callback.save() + print("SAVED") + + return HttpResponse("OK") + + def get(self, request, hook_name): + hook = Hook.objects.get(name=hook_name) + + return_data = {"name": hook.name, "hook": hook.hook, "hook_id": hook.id} + return HttpResponse(orjson.dumps(return_data), content_type="application/json") + + class Hooks(LoginRequiredMixin, View): allowed_types = ["modal", "widget", "window", "page"] window_content = "window-content/hooks.html" @@ -27,10 +50,10 @@ class Hooks(LoginRequiredMixin, View): unique = str(uuid.uuid4())[:8] hooks = get_hooks(request.user) context = { - "title": f"{type} Demo", + "title": f"Hooks ({type})", "unique": unique, "window_content": self.window_content, - "hooks": hooks, + "items": hooks, } return render(request, template_name, context) @@ -98,7 +121,7 @@ class HookAction(LoginRequiredMixin, APIView): hooks = get_hooks(request.user) context = { - "hooks": hooks, + "items": hooks, } if message: context["message"] = message @@ -124,7 +147,7 @@ class HookAction(LoginRequiredMixin, APIView): hooks = get_hooks(request.user) context = { - "hooks": hooks, + "items": hooks, } if message: context["message"] = message diff --git a/core/views/stripe_callbacks.py b/core/views/stripe_callbacks.py new file mode 100644 index 0000000..f193561 --- /dev/null +++ b/core/views/stripe_callbacks.py @@ -0,0 +1,104 @@ +import logging +from datetime import datetime + +import stripe +from django.conf import settings +from django.http import HttpResponse, JsonResponse +from django.views.decorators.csrf import csrf_exempt +from rest_framework.parsers import JSONParser +from rest_framework.views import APIView + +from core.models import Plan, Session, User + +logger = logging.getLogger(__name__) + + +class Callback(APIView): + parser_classes = [JSONParser] + + # TODO: make async + @csrf_exempt + def post(self, request): + payload = request.body + sig_header = request.META["HTTP_STRIPE_SIGNATURE"] + try: + stripe.Webhook.construct_event( + payload, sig_header, settings.STRIPE_ENDPOINT_SECRET + ) + except ValueError: + # Invalid payload + logger.error("Invalid payload") + return HttpResponse(status=400) + except stripe.error.SignatureVerificationError: + # Invalid signature + logger.error("Invalid signature") + return HttpResponse(status=400) + + if request.data is None: + return JsonResponse({"success": False}, status=500) + if "type" in request.data.keys(): + rtype = request.data["type"] + if rtype == "checkout.session.completed": + session = request.data["data"]["object"]["id"] + subscription_id = request.data["data"]["object"]["subscription"] + session_map = Session.objects.get(session=session) + if not session_map: + return JsonResponse({"success": False}, status=500) + user = session_map.user + session_map.subscription_id = subscription_id + session_map.save() + + if rtype == "customer.subscription.updated": + stripe_id = request.data["data"]["object"]["customer"] + if not stripe_id: + logging.error("No stripe id") + return JsonResponse({"success": False}, status=500) + user = User.objects.get(stripe_id=stripe_id) + # ssubscription_active + subscription_id = request.data["data"]["object"]["id"] + sessions = Session.objects.filter(user=user) + session = None + for session_iter in sessions: + if session_iter.subscription_id == subscription_id: + session = session_iter + if not session: + logging.error( + f"No session found for subscription id {subscription_id}" + ) + return JsonResponse({"success": False}, status=500) + # query Session objects + # iterate and check against product_id + session.request = request.data["request"]["id"] + product_id = request.data["data"]["object"]["plan"]["id"] + plan = Plan.objects.get(product_id=product_id) + if not plan: + logging.error(f"Plan not found: {product_id}") + return JsonResponse({"success": False}, status=500) + session.plan = plan + session.save() + + elif rtype == "payment_intent.succeeded": + customer = request.data["data"]["object"]["customer"] + user = User.objects.get(stripe_id=customer) + if not user: + logging.error(f"No user found for customer: {customer}") + return JsonResponse({"success": False}, status=500) + session = Session.objects.get(request=request.data["request"]["id"]) + + user.plans.add(session.plan) + user.last_payment = datetime.utcnow() + user.save() + + elif rtype == "customer.subscription.deleted": + customer = request.data["data"]["object"]["customer"] + user = User.objects.get(stripe_id=customer) + if not user: + logging.error(f"No user found for customer {customer}") + return JsonResponse({"success": False}, status=500) + product_id = request.data["data"]["object"]["plan"]["id"] + plan = Plan.objects.get(product_id=product_id) + user.plans.remove(plan) + user.save() + else: + return JsonResponse({"success": False}, status=500) + return JsonResponse({"success": True}) diff --git a/docker/prod/requirements.prod.txt b/docker/prod/requirements.prod.txt index 3a72239..0bcdfce 100644 --- a/docker/prod/requirements.prod.txt +++ b/docker/prod/requirements.prod.txt @@ -13,3 +13,5 @@ cryptography django-debug-toolbar django-debug-toolbar-template-profiler orjson +django-otp +qrcode