├── src ├── auth │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── admin.py │ ├── tests.py │ ├── apps.py │ └── views.py ├── cfehome │ ├── __init__.py │ ├── asgi.py │ ├── wsgi.py │ ├── hosts.py │ ├── installed.py │ ├── urls.py │ ├── views.py │ └── settings.py ├── landing │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ ├── admin.py │ ├── apps.py │ └── views.py ├── tenants │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_alter_tenant_subdomain.py │ │ └── 0001_initial.py │ ├── tests.py │ ├── apps.py │ ├── urls.py │ ├── admin.py │ ├── utils.py │ ├── models.py │ ├── views.py │ └── tasks.py ├── visits │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_pagevisit_user.py │ │ └── 0001_initial.py │ ├── tests.py │ ├── admin.py │ ├── apps.py │ ├── views.py │ └── models.py ├── checkouts │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── admin.py │ ├── tests.py │ ├── apps.py │ └── views.py ├── commando │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── hello_world.py │ │ │ ├── drop_schema.py │ │ │ ├── init_schema.py │ │ │ ├── migrate_schema.py │ │ │ ├── migrate_dynamic_db.py │ │ │ ├── db_branch.py │ │ │ ├── vendor_pull.py │ │ │ └── migrate_schema_basic.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── admin.py │ ├── views.py │ ├── apps.py │ └── tests.py ├── customers │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_customer_init_email_customer_init_email_confirmed.py │ │ └── 0001_initial.py │ ├── tests.py │ ├── views.py │ ├── admin.py │ ├── apps.py │ └── models.py ├── dashboard │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── admin.py │ ├── tests.py │ ├── apps.py │ └── views.py ├── enterprises │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ └── views.py ├── profiles │ ├── __init__.py │ ├── migrations │ │ └── __init__.py │ ├── models.py │ ├── tests.py │ ├── admin.py │ ├── apps.py │ ├── urls.py │ └── views.py ├── helpers │ ├── db │ │ ├── engine │ │ │ ├── __init__.py │ │ │ └── base.py │ │ ├── __init__.py │ │ ├── context │ │ │ ├── __init__.py │ │ │ └── managers.py │ │ ├── statements.py │ │ ├── validators.py │ │ └── schemas.py │ ├── security │ │ ├── __init__.py │ │ └── blocked_lists.py │ ├── middleware │ │ ├── __init__.py │ │ └── schemas.py │ ├── __init__.py │ ├── neonctl │ │ ├── __init__.py │ │ └── clients.py │ ├── date_utils.py │ ├── downloader.py │ ├── numbers.py │ └── billing.py ├── subscriptions │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── sync_permissions.py │ │ │ └── sync_user_subs.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0006_subscription_active.py │ │ ├── 0012_alter_subscriptionprice_options.py │ │ ├── 0016_subscription_subtitle.py │ │ ├── 0008_subscription_stripe_id.py │ │ ├── 0018_usersubscription_user_cancelled.py │ │ ├── 0017_usersubscription_stripe_id.py │ │ ├── 0021_usersubscription_cancel_at_period_end.py │ │ ├── 0003_subscription_groups.py │ │ ├── 0004_subscription_permissions.py │ │ ├── 0014_alter_subscriptionprice_options.py │ │ ├── 0015_subscription_features.py │ │ ├── 0002_alter_subscription_options.py │ │ ├── 0010_subscriptionprice_featured_subscriptionprice_order.py │ │ ├── 0005_alter_subscription_permissions.py │ │ ├── 0022_usersubscription_timestamp_usersubscription_updated.py │ │ ├── 0019_usersubscription_current_period_end_and_more.py │ │ ├── 0001_initial.py │ │ ├── 0020_usersubscription_status.py │ │ ├── 0011_subscriptionprice_timestamp_and_more.py │ │ ├── 0007_usersubscription.py │ │ ├── 0009_subscriptionprice.py │ │ └── 0013_alter_subscription_options_subscription_featured_and_more.py │ ├── tests.py │ ├── apps.py │ ├── admin.py │ ├── timing.md │ ├── utils.py │ ├── views.py │ └── models.py ├── requirements │ ├── dev.in │ └── prod.in ├── templates │ ├── base │ │ ├── js.html │ │ ├── css.html │ │ └── messages.html │ ├── snippets │ │ └── welcome-user-msg.html │ ├── protected │ │ ├── view.html │ │ ├── user-only.html │ │ └── entry.html │ ├── landing │ │ ├── main.html │ │ ├── proof.html │ │ ├── hero.html │ │ └── features.html │ ├── tenants │ │ ├── detail.html │ │ ├── new-user.html │ │ └── list.html │ ├── auth │ │ ├── login.html │ │ └── register.html │ ├── base.html │ ├── checkout │ │ └── success.html │ ├── profiles │ │ ├── detail.html │ │ └── list.html │ ├── dashboard │ │ ├── base.html │ │ ├── main.html │ │ └── sidebar.html │ ├── subscriptions │ │ ├── user_cancel_view.html │ │ ├── user_detail_view.html │ │ ├── snippets │ │ │ └── pricing-card.html │ │ └── pricing.html │ ├── allauth │ │ └── layouts │ │ │ └── base.html │ └── nav │ │ └── navbar.html ├── staticfiles │ ├── base_tailwind │ │ └── tailwind-input.css │ └── images │ │ └── flowbite-phone-mockup.png └── manage.py ├── saas.code-workspace ├── railway.toml ├── package.json ├── rav.yaml ├── requirements.dev.txt ├── LICENSE ├── tailwind.config.js ├── requirements.txt ├── Dockerfile ├── README.md └── .gitignore /src/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/cfehome/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/landing/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tenants/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/visits/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/checkouts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/commando/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/customers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/enterprises/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/profiles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/auth/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/helpers/db/engine/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/helpers/security/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/subscriptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/visits/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/checkouts/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/commando/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/commando/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/customers/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/dashboard/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/enterprises/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/helpers/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/landing/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/profiles/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/tenants/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/requirements/dev.in: -------------------------------------------------------------------------------- 1 | pip-tools 2 | rav -------------------------------------------------------------------------------- /src/subscriptions/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/commando/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/subscriptions/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/auth/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /src/auth/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /src/auth/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/checkouts/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /src/commando/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /src/dashboard/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /src/landing/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /src/landing/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/profiles/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /src/profiles/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/tenants/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/visits/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/checkouts/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /src/checkouts/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/commando/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /src/commando/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /src/customers/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/customers/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | -------------------------------------------------------------------------------- /src/dashboard/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /src/dashboard/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/enterprises/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /src/enterprises/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/landing/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /src/profiles/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /src/subscriptions/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /src/visits/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /src/enterprises/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | -------------------------------------------------------------------------------- /saas.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /src/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .downloader import download_to_local 2 | 3 | __all__ = ['download_to_local'] -------------------------------------------------------------------------------- /src/templates/base/js.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | -------------------------------------------------------------------------------- /src/staticfiles/base_tailwind/tailwind-input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /src/helpers/neonctl/__init__.py: -------------------------------------------------------------------------------- 1 | from .clients import NeonBranchClient 2 | 3 | __all__ = [ 4 | "NeonBranchClient" 5 | ] -------------------------------------------------------------------------------- /src/templates/base/css.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | -------------------------------------------------------------------------------- /src/helpers/security/blocked_lists.py: -------------------------------------------------------------------------------- 1 | BLOCKED_LIST = [ 2 | "apple", 3 | "admin", 4 | "www", 5 | "blocked" 6 | ] -------------------------------------------------------------------------------- /src/helpers/db/__init__.py: -------------------------------------------------------------------------------- 1 | from .context import use_dynamic_database_url 2 | 3 | __all__ = [ 4 | 'use_dynamic_database_url' 5 | ] -------------------------------------------------------------------------------- /src/helpers/db/context/__init__.py: -------------------------------------------------------------------------------- 1 | from .managers import use_dynamic_database_url 2 | 3 | __all__ = [ 4 | 'use_dynamic_database_url' 5 | ] -------------------------------------------------------------------------------- /src/helpers/db/engine/base.py: -------------------------------------------------------------------------------- 1 | from django.db.backends.postgresql import base 2 | 3 | class DatabaseWrapper(base.DatabaseWrapper): 4 | schema_name = None -------------------------------------------------------------------------------- /src/helpers/date_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | def timestamp_as_datetime(timestamp): 4 | return datetime.datetime.fromtimestamp(timestamp, tz=datetime.UTC) -------------------------------------------------------------------------------- /src/customers/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import Customer 5 | 6 | admin.site.register(Customer) -------------------------------------------------------------------------------- /src/auth/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AuthConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "auth" 7 | -------------------------------------------------------------------------------- /src/staticfiles/images/flowbite-phone-mockup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codingforentrepreneurs/SaaS-for-Enterprise-with-Django/HEAD/src/staticfiles/images/flowbite-phone-mockup.png -------------------------------------------------------------------------------- /src/visits/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class VisitsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "visits" 7 | -------------------------------------------------------------------------------- /src/commando/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CommandoConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "commando" 7 | -------------------------------------------------------------------------------- /src/landing/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class LandingConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "landing" 7 | -------------------------------------------------------------------------------- /src/profiles/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ProfilesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "profiles" 7 | -------------------------------------------------------------------------------- /src/tenants/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TenantsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "tenants" 7 | -------------------------------------------------------------------------------- /src/checkouts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CheckoutsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "checkouts" 7 | -------------------------------------------------------------------------------- /src/customers/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CustomersConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "customers" 7 | -------------------------------------------------------------------------------- /src/dashboard/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DashboardConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "dashboard" 7 | -------------------------------------------------------------------------------- /src/enterprises/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class EnterprisesConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "enterprises" 7 | -------------------------------------------------------------------------------- /src/templates/snippets/welcome-user-msg.html: -------------------------------------------------------------------------------- 1 |
2 | {% if request.user.is_authenticated %} 3 | You are a user 4 | {% else %} 5 | You are not a user 6 | {% endif %} 7 |
-------------------------------------------------------------------------------- /src/subscriptions/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SubscriptionsConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "subscriptions" 7 | -------------------------------------------------------------------------------- /railway.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | builder = "DOCKERFILE" 3 | dockerfilePath = "./Dockerfile" 4 | watchPatterns = [ 5 | "requirements.txt", 6 | "src/**", 7 | "railway.toml", 8 | "Dockerfile", 9 | ] 10 | -------------------------------------------------------------------------------- /src/profiles/urls.py: -------------------------------------------------------------------------------- 1 | 2 | from django.urls import path 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path("", views.profile_list_view), 8 | path("/", views.profile_detail_view), 9 | ] 10 | -------------------------------------------------------------------------------- /src/visits/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | # Create your views here. 4 | 5 | # def page_visit_view(request): 6 | # PageVisit.objects.create(path='/', subdomain='www') 7 | # return HttpResponse("Hello") -------------------------------------------------------------------------------- /src/helpers/db/statements.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | ACTIVATE_SCHEMA_SQL = 'SET search_path TO "{schema_name}";' 4 | 5 | CREATE_SCHEMA_SQL = 'CREATE SCHEMA IF NOT EXISTS "{schema_name}";' 6 | 7 | 8 | DROP_SCHEMA_SQL = 'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE;' -------------------------------------------------------------------------------- /src/commando/management/commands/hello_world.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from django.core.management.base import BaseCommand 3 | 4 | 5 | class Command(BaseCommand): 6 | 7 | def handle(self, *args: Any, **options: Any): 8 | print("hello world") -------------------------------------------------------------------------------- /src/templates/protected/view.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}{{ page_title }} - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

You are in!

9 | {% endblock content %} 10 | 11 | -------------------------------------------------------------------------------- /src/enterprises/urls.py: -------------------------------------------------------------------------------- 1 | 2 | from django.urls import path, include 3 | 4 | from . import views 5 | 6 | # enterprises url conf 7 | urlpatterns = [ 8 | path("", views.home_view, name='home'), 9 | path('accounts/', include('allauth.urls')), 10 | ] 11 | -------------------------------------------------------------------------------- /src/templates/protected/user-only.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}{{ page_title }} - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

You are in dear user!

9 | {% endblock content %} 10 | 11 | -------------------------------------------------------------------------------- /src/enterprises/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | # Create your views here. 3 | def home_view(request, *args, **kwargs): 4 | if request.user.is_authenticated: 5 | print(request.user.first_name) 6 | return HttpResponse(f"your subdomain is {request.tenant_active}") -------------------------------------------------------------------------------- /src/commando/tests.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.test import TestCase 3 | 4 | # Create your tests here. 5 | class NeonDBTestCase(TestCase): 6 | 7 | def test_db_url(self): 8 | DATABASE_URL = settings.DATABASE_URL 9 | self.assertIn("neon.tech", DATABASE_URL) -------------------------------------------------------------------------------- /src/requirements/prod.in: -------------------------------------------------------------------------------- 1 | Django>=5.1,<5.2 2 | gunicorn 3 | python-decouple 4 | psycopg[binary] 5 | dj-database-url 6 | requests 7 | whitenoise 8 | django-allauth[socialaccount] 9 | django-allauth-ui 10 | django-widget-tweaks 11 | stripe 12 | slippers 13 | redis 14 | django-redis 15 | django-hosts -------------------------------------------------------------------------------- /src/subscriptions/management/commands/sync_permissions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from django.core.management.base import BaseCommand 3 | 4 | from subscriptions import utils as subs_utils 5 | 6 | class Command(BaseCommand): 7 | 8 | def handle(self, *args: Any, **options: Any): 9 | subs_utils.sync_subs_group_permissions() -------------------------------------------------------------------------------- /src/templates/base/messages.html: -------------------------------------------------------------------------------- 1 | {% if messages %} 2 |
3 | {% for message in messages %} 4 | 8 | {% endfor %} 9 |
10 | {% endif %} -------------------------------------------------------------------------------- /src/templates/landing/main.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}Buld your SaaS Now - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 | 9 | {% include 'landing/hero.html' %} 10 | {% include 'landing/features.html' %} 11 | {% include 'landing/proof.html' %} 12 | 13 | {% endblock content %} 14 | 15 | -------------------------------------------------------------------------------- /src/tenants/urls.py: -------------------------------------------------------------------------------- 1 | 2 | from django.urls import path 3 | 4 | from . import views 5 | 6 | urlpatterns = [ 7 | path("", views.tenant_list_view), 8 | path("/", views.tenant_detail_view), 9 | path("/new-user/", views.tenant_create_user_view), 10 | path("/users//", views.tenant_user_detail_view), 11 | ] 12 | -------------------------------------------------------------------------------- /src/tenants/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import Tenant 5 | 6 | class TenantAdmin(admin.ModelAdmin): 7 | readonly_fields = ['schema_name', 'active_at', 'inactive_at', 'timestamp', 'updated' ] 8 | list_display = ['subdomain', 'owner', 'schema_name'] 9 | 10 | admin.site.register(Tenant, TenantAdmin) -------------------------------------------------------------------------------- /src/templates/tenants/detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}Tenant - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

{{ instance.subdomain }}

9 | 10 | 11 | {% for user in enterprise_users %} 12 |
  • {{ user.id }} - {{ user.username }}
  • 13 | {% endfor %} 14 | 15 | {% endblock content %} 16 | 17 | -------------------------------------------------------------------------------- /src/dashboard/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.shortcuts import render 3 | from visits.models import PageVisit 4 | 5 | @login_required 6 | def dashboard_view(request): 7 | qs = PageVisit.objects.all() #.filter(user=request.user) 8 | visit_count = qs.count() 9 | return render(request, "dashboard/main.html", { 10 | "visit_count": visit_count, 11 | }) -------------------------------------------------------------------------------- /src/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | 4 | 5 | {% block content %} 6 | 7 |
    8 | {% csrf_token %} 9 | 10 | 11 | 12 | 13 |
    14 | 15 | 16 | 17 | {% endblock content %} -------------------------------------------------------------------------------- /src/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head_title %}SaaS{% endblock head_title %} 5 | {% include 'base/css.html' %} 6 | 7 | 8 | {% include 'nav/navbar.html' %} 9 | {% include 'base/messages.html' with messages=messages %} 10 | {% block content %} 11 | {% endblock content %} 12 | {% include 'base/js.html' %} 13 | 14 | -------------------------------------------------------------------------------- /src/templates/checkout/success.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}{{ page_title }} - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

    Success welcome!

    9 | 10 |
    11 | {{ subscription }} 12 |
    13 | 14 | 15 |
    16 | {{ checkout }} 17 |
    18 | 19 | 20 | {% endblock content %} 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "watch": "tailwindcss -i src/staticfiles/base_tailwind/tailwind-input.css -o src/staticfiles/vendors/tailwind-out.css --watch", 4 | "build": "tailwindcss -i src/staticfiles/base_tailwind/tailwind-input.css -o src/staticfiles/theme/saas-theme.min.css --build --minify" 5 | }, 6 | "devDependencies": { 7 | "flowbite": "^2.3.0", 8 | "tailwindcss": "^3.4.4" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/templates/protected/entry.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}{{ page_title }} - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

    Enter the password

    9 | 10 |
    {% csrf_token %} 11 | 12 | 13 |
    14 | {% endblock content %} 15 | 16 | -------------------------------------------------------------------------------- /src/templates/tenants/new-user.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}Tenant - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

    {{ instance.subdomain }}

    9 | 10 | 11 |
    {% csrf_token %} 12 | 13 | {{ form.as_p }} 14 | 15 |
    16 | 17 | {% endblock content %} 18 | 19 | -------------------------------------------------------------------------------- /rav.yaml: -------------------------------------------------------------------------------- 1 | 2 | scripts: 3 | install: 4 | - venv/bin/pip-compile src/requirements/prod.in -o requirements.txt 5 | - venv/bin/python -m pip install -r requirements.txt 6 | install_dev: 7 | - venv/bin/pip-compile src/requirements/prod.in -o requirements.txt 8 | - venv/bin/python -m pip install -r requirements.txt 9 | - venv/bin/pip-compile src/requirements/dev.in -o requirements.dev.txt 10 | - venv/bin/python -m pip install -r requirements.dev.txt -------------------------------------------------------------------------------- /src/cfehome/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for cfehome project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cfehome.settings") 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /src/cfehome/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for cfehome project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cfehome.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /src/templates/tenants/list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}Tenants - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

    Tenants

    9 | 10 | 11 | 12 |
    13 | {% for instance in object_list %} 14 | 15 |
  • {{ instance.subdomain }}
  • 16 | 17 | {% endfor %} 18 |
    19 | {% endblock content %} 20 | 21 | -------------------------------------------------------------------------------- /src/cfehome/hosts.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django_hosts import patterns, host 3 | 4 | host_patterns = patterns('', 5 | host(r'www', settings.ROOT_URLCONF, name='www'), 6 | host(r'localhost', settings.ROOT_URLCONF, name='localhost'), 7 | host(r'^desalsa', settings.ROOT_URLCONF, name='custom_domain'), 8 | host(r'^invalid', settings.ROOT_URLCONF, name='invalid'), 9 | host(r'(?P[\w.@+-]+)', settings.ENTERPRISES_URLCONF, name='enterprises'), 10 | ) -------------------------------------------------------------------------------- /src/templates/auth/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 |
    6 | {% csrf_token %} 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 | 15 | {% endblock content %} -------------------------------------------------------------------------------- /src/tenants/utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | 3 | 4 | def generate_unique_schema_name(tenant_id: str, max_length:int = 60): 5 | base_name = f"tenant_{tenant_id}" 6 | base_name = base_name.replace("-", "") 7 | hash_suffix = hashlib.sha256(base_name.encode('utf-8')).hexdigest()[:16] 8 | unique_name = base_name[:40] 9 | schema_name = f"{unique_name}_{hash_suffix}" 10 | schema_name = schema_name.replace("-", "") 11 | final_name = schema_name[:max_length] 12 | return final_name -------------------------------------------------------------------------------- /src/commando/management/commands/drop_schema.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from django.db import connection 3 | from django.core.management.base import BaseCommand 4 | 5 | from helpers.db import statements as db_statements 6 | 7 | class Command(BaseCommand): 8 | 9 | def handle(self, *args: Any, **options: Any): 10 | schema_name = 'example' 11 | with connection.cursor() as cursor: 12 | cursor.execute( 13 | db_statements.DROP_SCHEMA_SQL.format(schema_name=schema_name) 14 | ) -------------------------------------------------------------------------------- /src/commando/management/commands/init_schema.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from django.db import connection 3 | from django.core.management.base import BaseCommand 4 | 5 | from helpers.db import statements as db_statements 6 | 7 | class Command(BaseCommand): 8 | 9 | def handle(self, *args: Any, **options: Any): 10 | schema_name = 'example' 11 | with connection.cursor() as cursor: 12 | cursor.execute( 13 | db_statements.CREATE_SCHEMA_SQL.format(schema_name=schema_name) 14 | ) -------------------------------------------------------------------------------- /src/subscriptions/migrations/0006_subscription_active.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-01 22:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0005_alter_subscription_permissions"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="subscription", 14 | name="active", 15 | field=models.BooleanField(default=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0012_alter_subscriptionprice_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-04 16:44 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0011_subscriptionprice_timestamp_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="subscriptionprice", 14 | options={"ordering": ["order", "featured", "-updated"]}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0016_subscription_subtitle.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-04 17:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0015_subscription_features"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="subscription", 14 | name="subtitle", 15 | field=models.TextField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0008_subscription_stripe_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-04 16:00 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0007_usersubscription"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="subscription", 14 | name="stripe_id", 15 | field=models.CharField(blank=True, max_length=120, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0018_usersubscription_user_cancelled.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-05 17:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0017_usersubscription_stripe_id"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersubscription", 14 | name="user_cancelled", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0017_usersubscription_stripe_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-05 16:57 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0016_subscription_subtitle"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersubscription", 14 | name="stripe_id", 15 | field=models.CharField(blank=True, max_length=120, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0021_usersubscription_cancel_at_period_end.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-05 18:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0020_usersubscription_status"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersubscription", 14 | name="cancel_at_period_end", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /src/visits/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | 4 | User = settings.AUTH_USER_MODEL 5 | # Create your models here. 6 | class PageVisit(models.Model): 7 | # db -> table 8 | # id -> hidden -> primary key -> autofield -> 1, 2, 3, 4, 5 9 | path = models.TextField(blank=True, null=True) # col 10 | timestamp = models.DateTimeField(auto_now_add=True) 11 | # subdomain = models.CharField(max_length=50, blank=True, null=True) 12 | user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) -------------------------------------------------------------------------------- /src/subscriptions/migrations/0003_subscription_groups.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-01 21:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("auth", "0012_alter_user_first_name_max_length"), 9 | ("subscriptions", "0002_alter_subscription_options"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="subscription", 15 | name="groups", 16 | field=models.ManyToManyField(to="auth.group"), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0004_subscription_permissions.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-01 21:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("auth", "0012_alter_user_first_name_max_length"), 9 | ("subscriptions", "0003_subscription_groups"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="subscription", 15 | name="permissions", 16 | field=models.ManyToManyField(to="auth.permission"), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /src/templates/profiles/detail.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}Users - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

    {{ instance.username }}

    9 | 10 | 11 | {% if perms.auth or owner %} 12 | {% if perms.auth.view_user or owner %} 13 |
    14 | {{ instance.first_name }} - {{ instance.last_name }} 15 | {{ instance.last_login }} 16 |
    17 | {% else %} 18 |
    You are not allowed to see this
    19 | 20 | {% endif %} 21 | {% endif %} 22 | 23 | {% endblock content %} 24 | 25 | -------------------------------------------------------------------------------- /src/subscriptions/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | # Register your models here. 4 | from .models import Subscription, SubscriptionPrice, UserSubscription 5 | 6 | class SubscriptionPrice(admin.StackedInline): 7 | model = SubscriptionPrice 8 | readonly_fields = ['stripe_id'] 9 | can_delete = False 10 | extra = 0 11 | 12 | class SubscriptionAdmin(admin.ModelAdmin): 13 | inlines = [SubscriptionPrice] 14 | list_display = ['name', 'active'] 15 | readonly_fields = ['stripe_id'] 16 | 17 | 18 | admin.site.register(Subscription, SubscriptionAdmin) 19 | 20 | 21 | admin.site.register(UserSubscription) -------------------------------------------------------------------------------- /src/templates/dashboard/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {% block head_title %}SaaS{% endblock head_title %} 5 | {% include 'base/css.html' %} 6 | 7 | 8 |
    9 | {% include 'dashboard/nav.html' %} 10 | {% include 'dashboard/sidebar.html' %} 11 | 12 |
    13 | {% include 'base/messages.html' with messages=messages %} 14 | {% block content %} 15 | {% endblock content %} 16 |
    17 | 18 |
    19 | {% include 'base/js.html' %} 20 | 21 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0014_alter_subscriptionprice_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-04 16:46 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ( 9 | "subscriptions", 10 | "0013_alter_subscription_options_subscription_featured_and_more", 11 | ), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name="subscriptionprice", 17 | options={ 18 | "ordering": ["subscription__order", "order", "featured", "-updated"] 19 | }, 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /src/templates/profiles/list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}Users - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

    Users

    9 | 10 | 11 | {% if perms.auth %} 12 | {% if perms.auth.view_user %} 13 |
    14 | {% for instance in object_list %} 15 | 16 |
  • {{ instance.username }}
  • 17 | 18 | {% endfor %} 19 |
    20 | {% else %} 21 |
    You are not allowed to see this
    22 | 23 | {% endif %} 24 | {% endif %} 25 | 26 | {% endblock content %} 27 | 28 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0015_subscription_features.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-04 17:05 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0014_alter_subscriptionprice_options"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="subscription", 14 | name="features", 15 | field=models.TextField( 16 | blank=True, 17 | help_text="Features for pricing, seperated by new line", 18 | null=True, 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /src/templates/subscriptions/user_cancel_view.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}Your Subscription - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 | 9 | {% if subscription.is_active_status %} 10 |

    Are you sure you want to cancel {{ subscription.plan_name }}?

    11 | 12 | 13 |
    {% csrf_token %} 14 | 15 |
    16 | No, go back 17 | 18 | {% else %} 19 | 20 |

    Your membership is not longer active

    21 | {% endif %} 22 | {% endblock content %} 23 | 24 | -------------------------------------------------------------------------------- /src/helpers/downloader.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from pathlib import Path 3 | 4 | def download_to_local(url:str, out_path:Path, parent_mkdir:bool=True): 5 | if not isinstance(out_path, Path): 6 | raise ValueError(f"{out_path} must be a valid pathlib.Path object") 7 | if parent_mkdir: 8 | out_path.parent.mkdir(parents=True, exist_ok=True) 9 | try: 10 | response = requests.get(url) 11 | response.raise_for_status() 12 | # Write the file out in binary mode to prevent any newline conversions 13 | out_path.write_bytes(response.content) 14 | return True 15 | except requests.RequestException as e: 16 | print(f'Failed to download {url}: {e}') 17 | return False -------------------------------------------------------------------------------- /src/subscriptions/migrations/0002_alter_subscription_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-01 21:05 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="subscription", 14 | options={ 15 | "permissions": [ 16 | ("advanced", "Advanced Perm"), 17 | ("pro", "Pro Perm"), 18 | ("basic", "Basic Perm"), 19 | ("basic_ai", "Basic AI Perm"), 20 | ] 21 | }, 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0010_subscriptionprice_featured_subscriptionprice_order.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-04 16:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0009_subscriptionprice"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="subscriptionprice", 14 | name="featured", 15 | field=models.BooleanField(default=True), 16 | ), 17 | migrations.AddField( 18 | model_name="subscriptionprice", 19 | name="order", 20 | field=models.IntegerField(default=-1), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /src/customers/migrations/0002_customer_init_email_customer_init_email_confirmed.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-03 22:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("customers", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="customer", 14 | name="init_email", 15 | field=models.EmailField(blank=True, max_length=254, null=True), 16 | ), 17 | migrations.AddField( 18 | model_name="customer", 19 | name="init_email_confirmed", 20 | field=models.BooleanField(default=False), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /src/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | """Run administrative tasks.""" 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cfehome.settings") 10 | try: 11 | from django.core.management import execute_from_command_line 12 | except ImportError as exc: 13 | raise ImportError( 14 | "Couldn't import Django. Are you sure it's installed and " 15 | "available on your PYTHONPATH environment variable? Did you " 16 | "forget to activate a virtual environment?" 17 | ) from exc 18 | execute_from_command_line(sys.argv) 19 | 20 | 21 | if __name__ == "__main__": 22 | main() 23 | -------------------------------------------------------------------------------- /src/commando/management/commands/migrate_schema.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.apps import apps 4 | from django.core.management import BaseCommand, call_command 5 | from django.db import connection 6 | from django.conf import settings 7 | from django.db.migrations.executor import MigrationExecutor 8 | 9 | from helpers.db import statements as db_statements 10 | from helpers.db.schemas import ( 11 | use_public_schema, 12 | use_tenant_schema 13 | ) 14 | 15 | from tenants import tasks 16 | 17 | class Command(BaseCommand): 18 | 19 | def handle(self, *args: Any, **options: Any): 20 | self.stdout.write("Starting migrations") 21 | tasks.migrate_tenant_schemas_task() 22 | self.stdout.write(self.style.SUCCESS("All migrations for CUSTOMER_APPS are completed.")) -------------------------------------------------------------------------------- /src/visits/migrations/0002_pagevisit_user.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.4 on 2024-12-20 00:04 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("visits", "0001_initial"), 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="pagevisit", 17 | name="user", 18 | field=models.ForeignKey( 19 | blank=True, 20 | null=True, 21 | on_delete=django.db.models.deletion.SET_NULL, 22 | to=settings.AUTH_USER_MODEL, 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /src/subscriptions/timing.md: -------------------------------------------------------------------------------- 1 | Timing with Python 2 | 3 | ```python 4 | import datetime 5 | now = datetime.datetime.now() 6 | ``` 7 | 8 | ```python 9 | next_week = now + datetime.timedelta(days=7) 10 | in_six_days = now + datetime.timedelta(days=6) 11 | ``` 12 | 13 | 14 | ```python 15 | sub_ends = now + datetime.timedelta(days=6, hours=20) 16 | ends_this_week = sub_ends < next_week # True 17 | ``` 18 | 19 | ```python 20 | ends_later_than_next_week = sub_ends > next_week # False 21 | ends_in_about_seven_days = in_six_days < sub_ends and sub_ends < next_week 22 | ``` 23 | 24 | 25 | ```python 26 | sub_ends_2 = now + datetime.timedelta(days=7, hours=2) 27 | next_week_min = next_week.replace(hour=0, minute=0, second=0, microsecond=0) 28 | next_week_max = next_week.replace(hour=23, minute=59, second=59, microsecond=59) 29 | ``` -------------------------------------------------------------------------------- /src/tenants/migrations/0002_alter_tenant_subdomain.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.4 on 2024-12-20 00:04 2 | 3 | import helpers.db.validators 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("tenants", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="tenant", 15 | name="subdomain", 16 | field=models.CharField( 17 | db_index=True, 18 | max_length=60, 19 | unique=True, 20 | validators=[ 21 | helpers.db.validators.validate_subdomain, 22 | helpers.db.validators.validate_blocked_subdomains, 23 | ], 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /src/templates/subscriptions/user_detail_view.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/base.html' %} 2 | 3 | 4 | {% block head_title %}Your Subscription - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 |

    Your Subscription

    9 |
    {% csrf_token %} 10 | 11 |
    12 | 13 | Cancel Membership 14 | 15 |

    Plan name: {{ subscription.plan_name }}

    16 | 17 |

    Status: {{ subscription.status|title }}

    18 | 19 |

    Membership age: {{ subscription.original_period_start|timesince }}

    20 |

    Start: {{ subscription.current_period_start }}

    21 | 22 |

    End: {{ subscription.current_period_end|timeuntil }} ({{ subscription.current_period_end }})

    23 | 24 | {% endblock content %} 25 | 26 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0005_alter_subscription_permissions.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-01 21:51 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("auth", "0012_alter_user_first_name_max_length"), 9 | ("subscriptions", "0004_subscription_permissions"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="subscription", 15 | name="permissions", 16 | field=models.ManyToManyField( 17 | limit_choices_to={ 18 | "codename__in": ["advanced", "pro", "basic", "basic_ai"], 19 | "content_type__app_label": "subscriptions", 20 | }, 21 | to="auth.permission", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /src/templates/landing/proof.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 |
    {{ page_view_count }}+
    6 |
    page views
    7 |
    8 |
    9 |
    {{ social_views_count }}+
    10 |
    social views
    11 |
    12 |
    13 |
    14 |
    -------------------------------------------------------------------------------- /src/visits/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-05-20 22:39 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="PageVisit", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("path", models.TextField(blank=True, null=True)), 25 | ("timestamp", models.DateTimeField(auto_now_add=True)), 26 | ], 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0022_usersubscription_timestamp_usersubscription_updated.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-07 19:23 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("subscriptions", "0021_usersubscription_cancel_at_period_end"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="usersubscription", 15 | name="timestamp", 16 | field=models.DateTimeField( 17 | auto_now_add=True, default=django.utils.timezone.now 18 | ), 19 | preserve_default=False, 20 | ), 21 | migrations.AddField( 22 | model_name="usersubscription", 23 | name="updated", 24 | field=models.DateTimeField(auto_now=True), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements.dev.txt src/requirements/dev.in 6 | # 7 | build==1.2.2.post1 8 | # via pip-tools 9 | click==8.1.7 10 | # via pip-tools 11 | fire==0.7.0 12 | # via rav 13 | markdown-it-py==3.0.0 14 | # via rich 15 | mdurl==0.1.2 16 | # via markdown-it-py 17 | packaging==24.2 18 | # via build 19 | pip-tools==7.4.1 20 | # via -r src/requirements/dev.in 21 | pygments==2.18.0 22 | # via rich 23 | pyproject-hooks==1.2.0 24 | # via 25 | # build 26 | # pip-tools 27 | pyyaml==6.0.2 28 | # via rav 29 | rav==0.0.9 30 | # via -r src/requirements/dev.in 31 | rich==13.9.4 32 | # via rav 33 | termcolor==2.5.0 34 | # via fire 35 | wheel==0.45.1 36 | # via pip-tools 37 | 38 | # The following packages are considered to be unsafe in a requirements file: 39 | # pip 40 | # setuptools 41 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0019_usersubscription_current_period_end_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-05 17:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0018_usersubscription_user_cancelled"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersubscription", 14 | name="current_period_end", 15 | field=models.DateTimeField(blank=True, null=True), 16 | ), 17 | migrations.AddField( 18 | model_name="usersubscription", 19 | name="current_period_start", 20 | field=models.DateTimeField(blank=True, null=True), 21 | ), 22 | migrations.AddField( 23 | model_name="usersubscription", 24 | name="original_period_start", 25 | field=models.DateTimeField(blank=True, null=True), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /src/cfehome/installed.py: -------------------------------------------------------------------------------- 1 | DEFAULT_APPS = [ 2 | # django-apps 3 | "django.contrib.admin", 4 | "django.contrib.auth", 5 | "django.contrib.contenttypes", 6 | "django.contrib.sessions", 7 | "django.contrib.messages", 8 | "django.contrib.staticfiles", 9 | # third-party-apps 10 | "allauth_ui", 11 | 'allauth', 12 | 'allauth.account', 13 | 'allauth.socialaccount', 14 | 'allauth.socialaccount.providers.github', 15 | 'django_hosts', 16 | 'slippers', 17 | "widget_tweaks", 18 | ] 19 | 20 | # tenant/enterpise apps 21 | _CUSTOMER_INSTALLED_APPS = DEFAULT_APPS + [ 22 | # my-apps 23 | "commando", 24 | "profiles", 25 | "visits", 26 | ] 27 | # reverse("tenants:list") 28 | 29 | # public schema default installed apps 30 | _INSTALLED_APPS = _CUSTOMER_INSTALLED_APPS + [ 31 | # my-apps 32 | "commando", 33 | "customers", 34 | "profiles", 35 | "subscriptions", 36 | "tenants", 37 | "visits", 38 | ] 39 | 40 | _INSTALLED_APPS = list(set(_INSTALLED_APPS)) -------------------------------------------------------------------------------- /src/subscriptions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-01 21:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | initial = True 8 | 9 | dependencies = [] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name="Subscription", 14 | fields=[ 15 | ( 16 | "id", 17 | models.BigAutoField( 18 | auto_created=True, 19 | primary_key=True, 20 | serialize=False, 21 | verbose_name="ID", 22 | ), 23 | ), 24 | ("name", models.CharField(max_length=120)), 25 | ], 26 | options={ 27 | "permissions": [ 28 | ("advanced", "Advanced Perm"), 29 | ("pro", "Pro Perm"), 30 | ("basic", "Basic Perm"), 31 | ], 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0020_usersubscription_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-05 18:02 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("subscriptions", "0019_usersubscription_current_period_end_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="usersubscription", 14 | name="status", 15 | field=models.CharField( 16 | blank=True, 17 | choices=[ 18 | ("active", "Active"), 19 | ("trialing", "Trialing"), 20 | ("incomplete", "Incomplete"), 21 | ("incomplete_expired", "Incomplete Expired"), 22 | ("past_due", "Past Due"), 23 | ("canceled", "Canceled"), 24 | ("unpaid", "Unpaid"), 25 | ("paused", "Paused"), 26 | ], 27 | max_length=20, 28 | null=True, 29 | ), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /src/templates/allauth/layouts/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {% block head_title %}{% endblock %} - SaaS 10 | 11 | 12 | {% block extra_head %}{% endblock %} 13 | {% include 'base/css.html' %} 14 | 15 | 16 | {% include 'nav/navbar.html' %} 17 | {% include 'base/messages.html' with messages=messages %} 18 | {% block content %} 19 |
    20 |
    21 | {% block whitebox %}{% endblock %} 22 |
    23 |
    24 | {% endblock %} 25 | 26 | {% include 'base/js.html' %} 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Coding For Entrepreneurs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/helpers/db/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | from django.core.exceptions import ValidationError 3 | 4 | from helpers.security.blocked_lists import ( 5 | BLOCKED_LIST 6 | ) 7 | 8 | def validate_blocked_subdomains(value): 9 | lowered_blocked_list = [x.lower() for x in BLOCKED_LIST] 10 | if value in BLOCKED_LIST: 11 | raise ValidationError( 12 | f"'{value}' is not a valid subdomain.", 13 | params={'value': value}, 14 | ) 15 | if value.lower() in lowered_blocked_list: 16 | raise ValidationError( 17 | f"'{value}' is not a valid subdomain.", 18 | params={'value': value}, 19 | ) 20 | 21 | def validate_subdomain(value): 22 | """Validator for subdomain fields in Django models.""" 23 | subdomain_regex = r'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$' 24 | 25 | if not re.match(subdomain_regex, value): 26 | raise ValidationError( 27 | f"'{value}' is not a valid subdomain. Subdomains must start and end with an alphanumeric character and can only contain alphanumeric characters and hyphens in between.", 28 | params={'value': value}, 29 | ) -------------------------------------------------------------------------------- /src/helpers/numbers.py: -------------------------------------------------------------------------------- 1 | def shorten_number(value): 2 | """ 3 | Converts large numbers into a shortened format. 4 | Example: 8200000 -> 8.2M, 1500000000 -> 1.5B, 9000000 -> 9M, 100000000000001 -> 100T 5 | """ 6 | try: 7 | value = int(value) 8 | if value >= 1_000_000_000_000: 9 | formatted_value = value / 1_000_000_000_000 10 | suffix = 'T' 11 | elif value >= 1_000_000_000: 12 | formatted_value = value / 1_000_000_000 13 | suffix = 'B' 14 | elif value >= 1_000_000: 15 | formatted_value = value / 1_000_000 16 | suffix = 'M' 17 | elif value >= 1_000: 18 | formatted_value = value / 1_000 19 | suffix = 'K' 20 | else: 21 | return str(value) 22 | 23 | # Round the formatted value 24 | formatted_value = round(formatted_value, 1) 25 | 26 | # Check if the formatted value is a whole number 27 | if formatted_value.is_integer(): 28 | return '{:.0f}{}'.format(formatted_value, suffix) 29 | else: 30 | return '{:.1f}{}'.format(formatted_value, suffix) 31 | 32 | except (ValueError, TypeError): 33 | return str(value) -------------------------------------------------------------------------------- /src/customers/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-03 21:01 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Customer", 18 | fields=[ 19 | ( 20 | "id", 21 | models.BigAutoField( 22 | auto_created=True, 23 | primary_key=True, 24 | serialize=False, 25 | verbose_name="ID", 26 | ), 27 | ), 28 | ("stripe_id", models.CharField(blank=True, max_length=120, null=True)), 29 | ( 30 | "user", 31 | models.OneToOneField( 32 | on_delete=django.db.models.deletion.CASCADE, 33 | to=settings.AUTH_USER_MODEL, 34 | ), 35 | ), 36 | ], 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /src/profiles/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.shortcuts import render, get_object_or_404 3 | from django.http import HttpResponse 4 | 5 | from django.contrib.auth import get_user_model 6 | User = get_user_model() 7 | 8 | @login_required 9 | def profile_list_view(request): 10 | context = { 11 | "object_list": User.objects.filter(is_active=True) 12 | } 13 | return render(request, "profiles/list.html", context) 14 | 15 | 16 | @login_required 17 | def profile_detail_view(request, username=None, *args, **kwargs): 18 | user = request.user 19 | print( 20 | user.has_perm("subscriptions.basic"), 21 | user.has_perm("subscriptions.basic_ai"), 22 | user.has_perm("subscriptions.pro"), 23 | user.has_perm("subscriptions.advanced"), 24 | ) 25 | # user_groups = user.groups.all() 26 | # print("user_groups", user_groups) 27 | # if user_groups.filter(name__icontains='basic').exists(): 28 | # return HttpResponse("Congrats") 29 | profile_user_obj = get_object_or_404(User, username=username) 30 | is_me = profile_user_obj == user 31 | context = { 32 | "object": profile_user_obj, 33 | "instance": profile_user_obj, 34 | "owner": is_me, 35 | } 36 | return render(request, "profiles/detail.html", context) 37 | -------------------------------------------------------------------------------- /src/subscriptions/migrations/0011_subscriptionprice_timestamp_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-04 16:41 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("subscriptions", "0010_subscriptionprice_featured_subscriptionprice_order"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="subscriptionprice", 15 | name="timestamp", 16 | field=models.DateTimeField( 17 | auto_now_add=True, default=django.utils.timezone.now 18 | ), 19 | preserve_default=False, 20 | ), 21 | migrations.AddField( 22 | model_name="subscriptionprice", 23 | name="updated", 24 | field=models.DateTimeField(auto_now=True), 25 | ), 26 | migrations.AlterField( 27 | model_name="subscriptionprice", 28 | name="featured", 29 | field=models.BooleanField( 30 | default=True, help_text="Featured on Django pricing page" 31 | ), 32 | ), 33 | migrations.AlterField( 34 | model_name="subscriptionprice", 35 | name="order", 36 | field=models.IntegerField( 37 | default=-1, help_text="Ordering on Django pricing page" 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /src/commando/management/commands/migrate_dynamic_db.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.apps import apps 4 | from django.core.management import BaseCommand, call_command 5 | from django.db import connection 6 | from django.conf import settings 7 | from django.db.migrations.executor import MigrationExecutor 8 | 9 | from helpers.db import statements as db_statements 10 | from helpers.db.schemas import ( 11 | use_public_schema, 12 | use_tenant_schema 13 | ) 14 | 15 | from tenants import tasks 16 | 17 | from decouple import config 18 | import dj_database_url 19 | 20 | class Command(BaseCommand): 21 | 22 | def handle(self, *args: Any, **options: Any): 23 | db_url = config("DB_URL_2", default=None) 24 | if db_url is None: 25 | return 26 | alias = "db-2" 27 | # default -> public -> database 28 | # qs = Tenant.objects.all() 29 | # obj = qs.first() 30 | # db_url = obj.db_url 31 | db_parsed_config = dj_database_url.parse( 32 | db_url, 33 | conn_health_checks=True, 34 | engine='helpers.db.engine' 35 | ) 36 | db_parsed_config.setdefault('TIME_ZONE', getattr(settings, 'TIME_ZONE', 'UTC')) 37 | db_parsed_config.setdefault('AUTOCOMMIT', True) 38 | db_parsed_config.setdefault('ATOMIC_REQUESTS', False) 39 | settings.DATABASES[alias] = db_parsed_config 40 | call_command('migrate', database=alias) -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const colors = require('tailwindcss/colors') 3 | 4 | module.exports = { 5 | content: [ 6 | "./src/templates/**/*.{html,js}", 7 | "./node_modules/flowbite/**/*.js" 8 | ], 9 | theme: { 10 | extend: { 11 | colors: { 12 | primary: {"50":"#eff6ff","100":"#dbeafe","200":"#bfdbfe","300":"#93c5fd","400":"#60a5fa","500":"#3b82f6","600":"#2563eb","700":"#1d4ed8","800":"#1e40af","900":"#1e3a8a","950":"#172554"} 13 | } 14 | }, 15 | fontFamily: { 16 | 'body': [ 17 | 'Inter', 18 | 'ui-sans-serif', 19 | 'system-ui', 20 | '-apple-system', 21 | 'system-ui', 22 | 'Segoe UI', 23 | 'Roboto', 24 | 'Helvetica Neue', 25 | 'Arial', 26 | 'Noto Sans', 27 | 'sans-serif', 28 | 'Apple Color Emoji', 29 | 'Segoe UI Emoji', 30 | 'Segoe UI Symbol', 31 | 'Noto Color Emoji' 32 | ], 33 | 'sans': [ 34 | 'Inter', 35 | 'ui-sans-serif', 36 | 'system-ui', 37 | '-apple-system', 38 | 'system-ui', 39 | 'Segoe UI', 40 | 'Roboto', 41 | 'Helvetica Neue', 42 | 'Arial', 43 | 'Noto Sans', 44 | 'sans-serif', 45 | 'Apple Color Emoji', 46 | 'Segoe UI Emoji', 47 | 'Segoe UI Symbol', 48 | 'Noto Color Emoji' 49 | ] 50 | } 51 | }, 52 | plugins: [ 53 | require('flowbite/plugin') 54 | ], 55 | } -------------------------------------------------------------------------------- /src/auth/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import authenticate, login 2 | from django.shortcuts import render, redirect 3 | 4 | # from django.contrib.auth.models import User 5 | from django.contrib.auth import get_user_model 6 | 7 | User = get_user_model() 8 | 9 | # Create your views here. 10 | def login_view(request): 11 | if request.method == "POST": 12 | username = request.POST.get("username") or None 13 | password = request.POST.get("password") or None 14 | # eval("print('hello')") 15 | if all([username, password]): 16 | user = authenticate(request, username=username, password=password) 17 | if user is not None: 18 | login(request, user) 19 | print("Login here!") 20 | return redirect("/") 21 | return render(request, "auth/login.html", {}) 22 | 23 | 24 | def register_view(request): 25 | if request.method == "POST": 26 | # print(request.POST) 27 | username = request.POST.get("username") or None 28 | email = request.POST.get("email") or None 29 | password = request.POST.get("password") or None 30 | # Django Forms 31 | # username_exists = User.objects.filter(username__iexact=username).exists() 32 | # email_exists = User.objects.filter(email__iexact=email).exists() 33 | try: 34 | User.objects.create_user(username, email=email, password=password) 35 | except: 36 | pass 37 | return render(request, "auth/register.html", {}) -------------------------------------------------------------------------------- /src/landing/views.py: -------------------------------------------------------------------------------- 1 | import helpers.numbers 2 | from django.http import HttpResponse 3 | from django.shortcuts import render 4 | from django.db import connection 5 | 6 | # Create your views here. 7 | from dashboard.views import dashboard_view 8 | 9 | from visits.models import PageVisit 10 | 11 | from decouple import config 12 | from helpers.db import use_dynamic_database_url 13 | 14 | def landing_dashboard_page_view(request): 15 | qs = PageVisit.objects.all() 16 | print("this is working...") 17 | print(f"og visits, {qs.count()}") 18 | DB_URL_2 = config('DB_URL_2', default=None) 19 | if DB_URL_2 is not None: 20 | alias = 'db_url_2' 21 | with use_dynamic_database_url(DB_URL_2, alias=alias): 22 | qs = PageVisit.objects.using(alias).all() 23 | PageVisit.objects.using(alias).create(path=request.path, user=None) 24 | print('page visis', qs.count()) 25 | # if not request.tenant_active: 26 | # return HttpResponse("invalid subdomain") 27 | user = None 28 | if request.user.is_authenticated: 29 | user = request.user 30 | PageVisit.objects.create(path=request.path, user=user) 31 | qs = PageVisit.objects.all() 32 | print('other qs visits', qs.count()) 33 | if user is not None: 34 | return dashboard_view(request) 35 | 36 | page_views_formatted = helpers.numbers.shorten_number(qs.count() * 100_000) 37 | social_views_formatted = helpers.numbers.shorten_number(qs.count() * 23_000) 38 | return render(request, "landing/main.html", {"page_view_count": page_views_formatted, "social_views_count": social_views_formatted}) -------------------------------------------------------------------------------- /src/subscriptions/management/commands/sync_user_subs.py: -------------------------------------------------------------------------------- 1 | import helpers.billing 2 | from typing import Any 3 | from django.core.management.base import BaseCommand 4 | 5 | from subscriptions import utils as subs_utils 6 | 7 | class Command(BaseCommand): 8 | 9 | def add_arguments(self, parser): 10 | parser.add_argument("--day-start", default=0, type=int) 11 | parser.add_argument("--day-end", default=0, type=int) 12 | parser.add_argument("--days-left", default=0, type=int) 13 | parser.add_argument("--days-ago", default=0, type=int) 14 | parser.add_argument("--clear-dangling", action="store_true", default=False) 15 | 16 | def handle(self, *args: Any, **options: Any): 17 | # python manage.py sync_user_subs --clear-dangling 18 | # print(options) 19 | days_left = options.get("days_left") 20 | days_ago = options.get("days_ago") 21 | day_start = options.get("day_start") 22 | day_end = options.get("day_end") 23 | clear_dangling = options.get("clear_dangling") 24 | if clear_dangling: 25 | print("Clearing dangling not in use active subs in stripe") 26 | subs_utils.clear_dangling_subs() 27 | else: 28 | print("Sync active subs") 29 | done = subs_utils.refresh_active_users_subscriptions( 30 | active_only=True, 31 | days_left=days_left, 32 | days_ago=days_ago, 33 | day_start=day_start, 34 | day_end=day_end, 35 | verbose=True 36 | ) 37 | if done: 38 | print("Done") -------------------------------------------------------------------------------- /src/subscriptions/migrations/0007_usersubscription.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-01 22:12 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("subscriptions", "0006_subscription_active"), 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="UserSubscription", 17 | fields=[ 18 | ( 19 | "id", 20 | models.BigAutoField( 21 | auto_created=True, 22 | primary_key=True, 23 | serialize=False, 24 | verbose_name="ID", 25 | ), 26 | ), 27 | ("active", models.BooleanField(default=True)), 28 | ( 29 | "subscription", 30 | models.ForeignKey( 31 | blank=True, 32 | null=True, 33 | on_delete=django.db.models.deletion.SET_NULL, 34 | to="subscriptions.subscription", 35 | ), 36 | ), 37 | ( 38 | "user", 39 | models.OneToOneField( 40 | on_delete=django.db.models.deletion.CASCADE, 41 | to=settings.AUTH_USER_MODEL, 42 | ), 43 | ), 44 | ], 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /src/templates/subscriptions/snippets/pricing-card.html: -------------------------------------------------------------------------------- 1 | 2 |
    3 |

    {{ object.display_sub_name }}

    4 | {% if object.display_sub_subtitle %} 5 |

    {{ object.display_sub_subtitle }}

    6 | {% endif %} 7 |
    8 | ${{ object.price }} 9 | /{{ object.interval }} 10 |
    11 | 12 |
      13 | {% for feature in object.display_features_list %} 14 |
    • 15 | 16 | 17 | {{ feature }} 18 |
    • 19 | {% endfor %} 20 |
    21 | Select Plan 22 |
    -------------------------------------------------------------------------------- /src/commando/management/commands/db_branch.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Any 3 | 4 | from django.conf import settings 5 | 6 | from django.core.management.base import BaseCommand 7 | 8 | from helpers.neonctl.clients import NeonBranchClient 9 | 10 | NEON_MIGRATION_BRANCH_BASE = getattr(settings, "NEON_MIGRATION_BRANCH_BASE", "saas_migration") 11 | 12 | class Command(BaseCommand): 13 | def add_arguments(self, parser): 14 | parser.add_argument( 15 | "--clear-migrations", 16 | action="store_true", 17 | help="Clear migrations before branching", 18 | ) 19 | 20 | def handle(self, *args: Any, **options: Any): 21 | neon = NeonBranchClient() 22 | clear_migrations = options["clear_migrations"] 23 | if clear_migrations: 24 | print("Clearing neon migration branches") 25 | branches = neon.list_branches() 26 | deleted_branches = [] 27 | for branch in branches: 28 | if branch["name"].startswith(NEON_MIGRATION_BRANCH_BASE): 29 | neon.delete_branch(branch["id"]) 30 | deleted_branches.append(branch["name"]) 31 | import time 32 | 33 | time.sleep(2) 34 | print(f"Deleted branches: {deleted_branches}") 35 | return 36 | print("Branching Neon") 37 | new_branch_name = f"{NEON_MIGRATION_BRANCH_BASE}_{uuid.uuid1()}" 38 | try: 39 | neon.create_branch(name=new_branch_name) 40 | print(f"Created branch: {new_branch_name}") 41 | except Exception as e: 42 | print(f"Failed creating branch: {e}") 43 | return -------------------------------------------------------------------------------- /src/commando/management/commands/vendor_pull.py: -------------------------------------------------------------------------------- 1 | import helpers 2 | 3 | from typing import Any 4 | from django.conf import settings 5 | from django.core.management.base import BaseCommand 6 | 7 | 8 | STATICFILES_VENDOR_DIR = getattr(settings,'STATICFILES_VENDOR_DIR') 9 | 10 | VENDOR_STATICFILES = { 11 | "saas-theme.min.css": "https://raw.githubusercontent.com/codingforentrepreneurs/SaaS-Foundations/main/src/staticfiles/theme/saas-theme.min.css", 12 | "flowbite.min.css": "https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.3.0/flowbite.min.css", 13 | "flowbite.min.js": "https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.3.0/flowbite.min.js", 14 | "flowbite.min.js.map": "https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.3.0/flowbite.min.js.map" 15 | } 16 | 17 | class Command(BaseCommand): 18 | 19 | def handle(self, *args: Any, **options: Any): 20 | self.stdout.write("Downloading vendor static files") 21 | completed_urls = [] 22 | for name, url in VENDOR_STATICFILES.items(): 23 | out_path = STATICFILES_VENDOR_DIR / name 24 | dl_success = helpers.download_to_local(url, out_path) 25 | if dl_success: 26 | completed_urls.append(url) 27 | else: 28 | self.stdout.write( 29 | self.style.ERROR(f'Failed to download {url}') 30 | ) 31 | if set(completed_urls) == set(VENDOR_STATICFILES.values()): 32 | self.stdout.write( 33 | self.style.SUCCESS('Successfully updated all vendor static files.') 34 | ) 35 | else: 36 | self.stdout.write( 37 | self.style.WARNING('Some files were not updated.') 38 | ) -------------------------------------------------------------------------------- /src/subscriptions/migrations/0009_subscriptionprice.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-04 16:17 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("subscriptions", "0008_subscription_stripe_id"), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="SubscriptionPrice", 15 | fields=[ 16 | ( 17 | "id", 18 | models.BigAutoField( 19 | auto_created=True, 20 | primary_key=True, 21 | serialize=False, 22 | verbose_name="ID", 23 | ), 24 | ), 25 | ("stripe_id", models.CharField(blank=True, max_length=120, null=True)), 26 | ( 27 | "interval", 28 | models.CharField( 29 | choices=[("month", "Monthly"), ("year", "Yearly")], 30 | default="month", 31 | max_length=120, 32 | ), 33 | ), 34 | ( 35 | "price", 36 | models.DecimalField(decimal_places=2, default=99.99, max_digits=10), 37 | ), 38 | ( 39 | "subscription", 40 | models.ForeignKey( 41 | null=True, 42 | on_delete=django.db.models.deletion.SET_NULL, 43 | to="subscriptions.subscription", 44 | ), 45 | ), 46 | ], 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /src/templates/landing/hero.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 |
    3 |
    4 |
    5 |

    Payments tool for software companies

    6 |

    From checkout to global sales tax compliance, companies around the world use Flowbite to simplify their payment stack.

    7 | 8 | Get started 9 | 10 | 11 | 12 | Speak to Sales 13 | 14 |
    15 | 18 |
    19 |
    -------------------------------------------------------------------------------- /src/subscriptions/migrations/0013_alter_subscription_options_subscription_featured_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.6 on 2024-06-04 16:45 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("subscriptions", "0012_alter_subscriptionprice_options"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="subscription", 15 | options={ 16 | "ordering": ["order", "featured", "-updated"], 17 | "permissions": [ 18 | ("advanced", "Advanced Perm"), 19 | ("pro", "Pro Perm"), 20 | ("basic", "Basic Perm"), 21 | ("basic_ai", "Basic AI Perm"), 22 | ], 23 | }, 24 | ), 25 | migrations.AddField( 26 | model_name="subscription", 27 | name="featured", 28 | field=models.BooleanField( 29 | default=True, help_text="Featured on Django pricing page" 30 | ), 31 | ), 32 | migrations.AddField( 33 | model_name="subscription", 34 | name="order", 35 | field=models.IntegerField( 36 | default=-1, help_text="Ordering on Django pricing page" 37 | ), 38 | ), 39 | migrations.AddField( 40 | model_name="subscription", 41 | name="timestamp", 42 | field=models.DateTimeField( 43 | auto_now_add=True, default=django.utils.timezone.now 44 | ), 45 | preserve_default=False, 46 | ), 47 | migrations.AddField( 48 | model_name="subscription", 49 | name="updated", 50 | field=models.DateTimeField(auto_now=True), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /src/tenants/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from django.conf import settings 4 | from django.utils import timezone 5 | from django.core.management import call_command 6 | 7 | from helpers.db.validators import ( 8 | validate_subdomain, 9 | validate_blocked_subdomains 10 | 11 | ) 12 | from . import tasks, utils 13 | 14 | User = settings.AUTH_USER_MODEL # auth.User 15 | 16 | # Create your models here. 17 | class Tenant(models.Model): 18 | # 19 | id = models.UUIDField(default=uuid.uuid4, primary_key=True, db_index=True, editable=False) 20 | owner = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) 21 | subdomain = models.CharField( 22 | max_length=60, 23 | unique=True, 24 | db_index=True, 25 | validators=[ 26 | validate_subdomain, 27 | validate_blocked_subdomains 28 | ] 29 | ) 30 | schema_name = models.CharField(max_length=60, unique=True, blank=True, null=True, db_index=True) 31 | active = models.BooleanField(default=True) 32 | active_at = models.DateTimeField(null=True, blank=True) 33 | inactive_at = models.DateTimeField(null=True, blank=True) 34 | timestamp = models.DateTimeField(auto_now_add=True) 35 | updated = models.DateTimeField(auto_now=True) 36 | 37 | def save(self, *args, **kwargs): 38 | created = False 39 | if not self.pk: 40 | created = True 41 | now = timezone.now() 42 | if self.active and not self.active_at: 43 | self.active_at = now 44 | self.inactive_at = None 45 | elif not self.active and not self.inactive_at: 46 | self.active_at = None 47 | self.inactive_at = now 48 | if not self.schema_name: 49 | self.schema_name = utils.generate_unique_schema_name(self.id) 50 | super().save(*args, **kwargs) 51 | # call_command("migrate_schema") 52 | tasks.migrate_tenant_task(self.id, branch=created) -------------------------------------------------------------------------------- /src/commando/management/commands/migrate_schema_basic.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.apps import apps 4 | from django.conf import settings 5 | from django.db import connection 6 | from django.core.management import BaseCommand, call_command 7 | 8 | from helpers.db import statements as db_statements 9 | 10 | CUSTOMER_INSTALLED_APPS = getattr(settings, 'CUSTOMER_INSTALLED_APPS', []) 11 | 12 | class Command(BaseCommand): 13 | 14 | def handle(self, *args: Any, **options: Any): 15 | schemas = ['example'] 16 | with connection.cursor() as cursor: 17 | cursor.execute( 18 | db_statements.CREATE_SCHEMA_SQL.format(schema_name="public") 19 | ) 20 | cursor.execute( 21 | db_statements.ACTIVATE_SCHEMA_SQL.format(schema_name="public") 22 | ) 23 | call_command("migrate", interactive=False) 24 | 25 | for schema_name in schemas: 26 | with connection.cursor() as cursor: 27 | cursor.execute( 28 | db_statements.CREATE_SCHEMA_SQL.format(schema_name=schema_name) 29 | ) 30 | cursor.execute( 31 | db_statements.ACTIVATE_SCHEMA_SQL.format(schema_name=schema_name) 32 | ) 33 | for app in apps.get_app_configs(): 34 | # print(app) 35 | app_name = app.name 36 | if app_name not in CUSTOMER_INSTALLED_APPS: 37 | continue 38 | print("customer app", app) 39 | try: 40 | call_command("migrate", app.label, interactive=False) 41 | except: 42 | continue 43 | # python manage.py migrate --no-input 44 | # 45 | # User = get_user_model() 46 | # user_a = User.objects.create_superuser( 47 | # username='example', 48 | # password='example1233' 49 | # ) 50 | -------------------------------------------------------------------------------- /src/tenants/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.4 on 2024-12-19 17:57 2 | 3 | import django.db.models.deletion 4 | import uuid 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name="Tenant", 19 | fields=[ 20 | ( 21 | "id", 22 | models.UUIDField( 23 | db_index=True, 24 | default=uuid.uuid4, 25 | editable=False, 26 | primary_key=True, 27 | serialize=False, 28 | ), 29 | ), 30 | ( 31 | "subdomain", 32 | models.CharField(db_index=True, max_length=60, unique=True), 33 | ), 34 | ( 35 | "schema_name", 36 | models.CharField( 37 | blank=True, db_index=True, max_length=60, null=True, unique=True 38 | ), 39 | ), 40 | ("active", models.BooleanField(default=True)), 41 | ("active_at", models.DateTimeField(blank=True, null=True)), 42 | ("inactive_at", models.DateTimeField(blank=True, null=True)), 43 | ("timestamp", models.DateTimeField(auto_now_add=True)), 44 | ("updated", models.DateTimeField(auto_now=True)), 45 | ( 46 | "owner", 47 | models.ForeignKey( 48 | null=True, 49 | on_delete=django.db.models.deletion.SET_NULL, 50 | to=settings.AUTH_USER_MODEL, 51 | ), 52 | ), 53 | ], 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /src/templates/subscriptions/pricing.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | 4 | {% block head_title %}Pricing - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 | 9 |
    10 |
    11 |
    12 |

    Designed for business teams like yours

    13 |

    Here at SaaS we focus on markets where technology, innovation, and capital can unlock long-term value and drive economic growth.

    14 | 15 |
    16 | 24 |
    25 |
    26 |
    27 | 28 | 29 | {% for price_obj in object_list %} 30 | {% include 'subscriptions/snippets/pricing-card.html' with object=price_obj %} 31 | {% endfor %} 32 | 33 | 34 |
    35 |
    36 |
    37 | 38 | {% endblock content %} 39 | 40 | -------------------------------------------------------------------------------- /src/templates/dashboard/main.html: -------------------------------------------------------------------------------- 1 | {% extends 'dashboard/base.html' %} 2 | 3 | 4 | {% block head_title %}Buld your SaaS Now - {{ block.super }}{% endblock head_title %} 5 | 6 | 7 | {% block content %} 8 | 9 |

    My Visits: {{ visit_count }}

    10 | 11 |
    12 |
    15 |
    18 |
    21 |
    24 |
    25 |
    28 |
    29 |
    32 |
    35 |
    38 |
    41 |
    42 |
    45 |
    46 |
    49 |
    52 |
    55 |
    58 |
    59 | 60 | {% endblock content %} -------------------------------------------------------------------------------- /src/helpers/middleware/schemas.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.db import connection 3 | from django.core.cache import cache 4 | from django.http import HttpResponse 5 | 6 | from helpers.db.schemas import ( 7 | use_public_schema, 8 | activate_tenent_schema 9 | ) 10 | from helpers.db import statements as db_statements 11 | 12 | class SchemaTenantMiddleware: 13 | def __init__(self, get_response): 14 | self.get_response = get_response 15 | # One-time configuration and initialization. 16 | 17 | def __call__(self, request): 18 | host = request.get_host() 19 | host_portless = host.split(":")[0] 20 | host_split = host_portless.split('.') 21 | subdomain = None 22 | if len(host_split) > 1: 23 | subdomain = host_split[0] 24 | schema_name, tenant_active = self.get_schema_name(subdomain=subdomain) 25 | activate_tenent_schema(schema_name) 26 | request.tenant_active = tenant_active 27 | return self.get_response(request) 28 | 29 | def get_schema_name(self, subdomain=None): 30 | if subdomain in [None, "localhost", 'desalsa']: 31 | return "public", True 32 | schema_name = "public" 33 | cache_subdomain_key = f"subdomain_schema:{subdomain}" 34 | cache_subdomain_val = cache.get(cache_subdomain_key) 35 | cache_subdomain_valid_key = f"subdomain_valid_schema:{subdomain}" 36 | cache_subdomain_valid_val = cache.get(cache_subdomain_valid_key) 37 | if cache_subdomain_val and cache_subdomain_valid_val: 38 | return cache_subdomain_val, cache_subdomain_valid_val 39 | tenant_active = False 40 | with use_public_schema(): 41 | Tenant = apps.get_model('tenants', 'Tenant') 42 | try: 43 | obj = Tenant.objects.get(subdomain=subdomain) 44 | schema_name = obj.schema_name 45 | tenant_active = True 46 | except Tenant.DoesNotExist: 47 | print(f"{subdomain} does not exist as Tenant") 48 | except Exception as e: 49 | print(f"{subdomain} does not exist as Tenant.\n {e}") 50 | cache_ttl = 60 # seconds 51 | cache.set(cache_subdomain_key, str(schema_name), cache_ttl) 52 | cache.set(cache_subdomain_valid_key, tenant_active, cache_ttl) 53 | return schema_name, tenant_active -------------------------------------------------------------------------------- /src/helpers/db/schemas.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.db import connection 3 | from contextlib import contextmanager 4 | 5 | from helpers.db import statements as db_statements 6 | 7 | DEFAULT_SCHEMA = "public" 8 | 9 | 10 | def check_if_schema_exists(schema_name, require_check=False): 11 | if schema_name == DEFAULT_SCHEMA and not require_check: 12 | return True 13 | exists = False 14 | with connection.cursor() as cursor: 15 | cursor.execute(""" 16 | SELECT schema_name 17 | FROM information_schema.schemata 18 | WHERE schema_name = %s 19 | """, [schema_name]) 20 | exists = cursor.fetchone() is not None 21 | return exists 22 | 23 | 24 | def activate_tenent_schema(schema_name): 25 | is_check_exists_required = schema_name != DEFAULT_SCHEMA 26 | schema_to_use = DEFAULT_SCHEMA 27 | if is_check_exists_required and check_if_schema_exists(schema_name): 28 | schema_to_use = schema_name 29 | with connection.cursor() as cursor: 30 | sql = f'SET search_path TO "{schema_to_use}";' 31 | cursor.execute(sql) 32 | connection.schema_name = schema_to_use 33 | 34 | 35 | 36 | @contextmanager 37 | def use_tenant_schema(schema_name, create_if_missing=True, revert_public=True): 38 | """ 39 | with use_tenant_schema(schema_name): 40 | Visit.object.all() 41 | """ 42 | try: 43 | with connection.cursor() as cursor: 44 | if create_if_missing and not check_if_schema_exists(schema_name): 45 | cursor.execute( 46 | f'CREATE SCHEMA IF NOT EXISTS "{schema_name}";' 47 | ) 48 | sql = f'SET search_path TO "{schema_name}";' 49 | cursor.execute(sql) 50 | yield 51 | finally: 52 | if revert_public: 53 | activate_tenent_schema(DEFAULT_SCHEMA) 54 | 55 | 56 | @contextmanager 57 | def use_public_schema(revert_schema_name=None, revert_schema=False): 58 | """ 59 | with use_public_schema(): 60 | Tenant.object.all() 61 | """ 62 | try: 63 | schema_to_use = DEFAULT_SCHEMA 64 | with connection.cursor() as cursor: 65 | sql = f'SET search_path TO "{schema_to_use}";' 66 | cursor.execute(sql) 67 | yield 68 | finally: 69 | if revert_schema: 70 | activate_tenent_schema(revert_schema_name) 71 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.13 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements.txt src/requirements/prod.in 6 | # 7 | asgiref==3.8.1 8 | # via 9 | # django 10 | # django-allauth 11 | certifi==2024.12.14 12 | # via requests 13 | cffi==1.17.1 14 | # via cryptography 15 | charset-normalizer==3.4.0 16 | # via requests 17 | cryptography==44.0.0 18 | # via pyjwt 19 | dj-database-url==2.3.0 20 | # via -r src/requirements/prod.in 21 | django==5.1.4 22 | # via 23 | # -r src/requirements/prod.in 24 | # dj-database-url 25 | # django-allauth 26 | # django-redis 27 | # slippers 28 | django-allauth[socialaccount]==65.3.0 29 | # via -r src/requirements/prod.in 30 | django-allauth-ui==1.5.3 31 | # via -r src/requirements/prod.in 32 | django-hosts==6.0 33 | # via -r src/requirements/prod.in 34 | django-redis==5.4.0 35 | # via -r src/requirements/prod.in 36 | django-widget-tweaks==1.5.0 37 | # via 38 | # -r src/requirements/prod.in 39 | # django-allauth-ui 40 | gunicorn==23.0.0 41 | # via -r src/requirements/prod.in 42 | idna==3.10 43 | # via requests 44 | oauthlib==3.2.2 45 | # via requests-oauthlib 46 | packaging==24.2 47 | # via gunicorn 48 | psycopg[binary]==3.2.3 49 | # via -r src/requirements/prod.in 50 | psycopg-binary==3.2.3 51 | # via psycopg 52 | pycparser==2.22 53 | # via cffi 54 | pyjwt[crypto]==2.10.1 55 | # via django-allauth 56 | python-decouple==3.8 57 | # via -r src/requirements/prod.in 58 | pyyaml==6.0.2 59 | # via slippers 60 | redis==5.2.1 61 | # via 62 | # -r src/requirements/prod.in 63 | # django-redis 64 | requests==2.32.3 65 | # via 66 | # -r src/requirements/prod.in 67 | # django-allauth 68 | # requests-oauthlib 69 | # stripe 70 | requests-oauthlib==2.0.0 71 | # via django-allauth 72 | slippers==0.6.2 73 | # via 74 | # -r src/requirements/prod.in 75 | # django-allauth-ui 76 | sqlparse==0.5.3 77 | # via django 78 | stripe==11.3.0 79 | # via -r src/requirements/prod.in 80 | typeguard==2.13.3 81 | # via slippers 82 | typing-extensions==4.12.2 83 | # via 84 | # dj-database-url 85 | # slippers 86 | # stripe 87 | urllib3==2.2.3 88 | # via requests 89 | whitenoise==6.8.2 90 | # via -r src/requirements/prod.in 91 | -------------------------------------------------------------------------------- /src/subscriptions/utils.py: -------------------------------------------------------------------------------- 1 | import helpers.billing 2 | 3 | from django.db.models import Q 4 | from customers.models import Customer 5 | from subscriptions.models import Subscription, UserSubscription, SubscriptionStatus 6 | 7 | 8 | def refresh_active_users_subscriptions( 9 | user_ids=None, 10 | active_only=True, 11 | days_left=-1, 12 | days_ago=-1, 13 | day_start=-1, 14 | day_end=-1, 15 | verbose=False): 16 | qs = UserSubscription.objects.all() 17 | if active_only: 18 | qs = qs.by_active_trialing() 19 | if user_ids is not None: 20 | qs = qs.by_user_ids(user_ids=user_ids) 21 | if days_ago > -1: 22 | qs = qs.by_days_ago(days_ago=days_ago) 23 | if days_left > -1: 24 | qs = qs.by_days_left(days_left=days_left) 25 | if day_start > -1 and day_end > -1: 26 | qs = qs.by_range(days_start=day_start, days_end=day_end, verbose=verbose) 27 | complete_count = 0 28 | qs_count = qs.count() 29 | for obj in qs: 30 | if verbose: 31 | print("Updating user", obj.user, obj.subscription, obj.current_period_end) 32 | if obj.stripe_id: 33 | sub_data = helpers.billing.get_subscription(obj.stripe_id, raw=False) 34 | for k,v in sub_data.items(): 35 | setattr(obj, k, v) 36 | obj.save() 37 | complete_count += 1 38 | return complete_count == qs_count 39 | 40 | def clear_dangling_subs(): 41 | qs = Customer.objects.filter(stripe_id__isnull=False) 42 | for customer_obj in qs: 43 | user = customer_obj.user 44 | customer_stripe_id = customer_obj.stripe_id 45 | print(f"Sync {user} - {customer_stripe_id} subs and remove old ones") 46 | subs = helpers.billing.get_customer_active_subscriptions(customer_stripe_id) 47 | for sub in subs: 48 | existing_user_subs_qs = UserSubscription.objects.filter(stripe_id__iexact=f"{sub.id}".strip()) 49 | if existing_user_subs_qs.exists(): 50 | continue 51 | helpers.billing.cancel_subscription(sub.id, reason="Dangling active subscription", cancel_at_period_end=False) 52 | # print(sub.id, existing_user_subs_qs.exists()) 53 | 54 | def sync_subs_group_permissions(): 55 | qs = Subscription.objects.filter(active=True) 56 | for obj in qs: 57 | sub_perms = obj.permissions.all() 58 | for group in obj.groups.all(): 59 | group.permissions.set(sub_perms) -------------------------------------------------------------------------------- /src/customers/models.py: -------------------------------------------------------------------------------- 1 | import helpers.billing 2 | from django.conf import settings 3 | from django.db import models, connection 4 | 5 | # database signals 6 | from allauth.account.signals import ( 7 | user_signed_up as allauth_user_signed_up, 8 | email_confirmed as allauth_email_confirmed 9 | ) 10 | 11 | User = settings.AUTH_USER_MODEL # "auth.user" 12 | 13 | # public schema -> customer accounts 14 | # enterprise schema no customer account 15 | class Customer(models.Model): 16 | user = models.OneToOneField(User, on_delete=models.CASCADE) 17 | stripe_id = models.CharField(max_length=120, null=True, blank=True) 18 | init_email = models.EmailField(blank=True, null=True) 19 | init_email_confirmed = models.BooleanField(default=False) 20 | 21 | def __str__(self): 22 | return f"{self.user.username}" 23 | 24 | def save(self, *args, **kwargs): 25 | if not self.stripe_id: 26 | if self.init_email_confirmed and self.init_email: 27 | email = self.init_email 28 | if email != "" and email is not None: 29 | stripe_id = helpers.billing.create_customer(email=email,metadata={ 30 | "user_id": self.user.id, 31 | "username": self.user.username 32 | }, raw=False) 33 | self.stripe_id = stripe_id 34 | super().save(*args, **kwargs) 35 | # post -svae will not update 36 | # self.stripe_id = "something else" 37 | # self.save() 38 | 39 | 40 | def allauth_user_signed_up_handler(request, user, *args, **kwargs): 41 | if connection.schema_name == "public": 42 | email = user.email 43 | Customer.objects.create( 44 | user=user, 45 | init_email=email, 46 | init_email_confirmed=False, 47 | ) 48 | 49 | allauth_user_signed_up.connect(allauth_user_signed_up_handler) 50 | 51 | 52 | def allauth_email_confirmed_handler(request, email_address, *args, **kwargs): 53 | if connection.schema_name == "public": 54 | qs = Customer.objects.filter( 55 | init_email=email_address, 56 | init_email_confirmed=False, 57 | ) 58 | # does not send the save method or create the 59 | # stripe customer 60 | # qs.update(init_email_confirmed=True) 61 | for obj in qs: 62 | obj.init_email_confirmed=True 63 | # send the signal 64 | obj.save() 65 | 66 | 67 | allauth_email_confirmed.connect(allauth_email_confirmed_handler) 68 | -------------------------------------------------------------------------------- /src/helpers/db/context/managers.py: -------------------------------------------------------------------------------- 1 | 2 | from django.conf import settings 3 | from django.db import connections, OperationalError 4 | 5 | from contextlib import contextmanager 6 | import dj_database_url 7 | 8 | @contextmanager 9 | def use_dynamic_database_url(db_url, alias='dynamic_db', schema='public'): 10 | """ 11 | A context manager to temporarily add/use a dynamic database from a URL. 12 | 13 | Args: 14 | db_url (str): The database URL (e.g. "postgres://user:pass@host:5432/dbname"). 15 | alias (str): The alias for the dynamic database (default: 'dynamic_db'). 16 | schema (str): The schema to set in search_path (default: 'public'). 17 | 18 | Yields: 19 | str: The alias of the database being used. 20 | """ 21 | # Parse the URL into a proper Django database config dict 22 | database_config = dj_database_url.parse(db_url, engine='django.db.backends.postgresql') 23 | 24 | # Provide fallback or extra config as needed 25 | database_config.setdefault('TIME_ZONE', getattr(settings, 'TIME_ZONE', 'UTC')) 26 | database_config.setdefault('AUTOCOMMIT', True) 27 | database_config.setdefault('ATOMIC_REQUESTS', False) 28 | 29 | # If you want a specific schema for Postgres, set search_path 30 | if schema: 31 | database_config.setdefault('OPTIONS', {}) 32 | # Combine existing options with search_path 33 | existing_options = database_config['OPTIONS'].get('options', '') 34 | new_options = f"-c search_path={schema}" 35 | if existing_options: 36 | new_options = existing_options + " " + new_options 37 | database_config['OPTIONS']['options'] = new_options 38 | 39 | # Save any existing config for this alias so we can restore it later 40 | original_config = connections.databases.get(alias) 41 | 42 | try: 43 | # Override the alias with our new dynamic config 44 | connections.databases[alias] = database_config 45 | 46 | # Force a connection to ensure we can connect 47 | connection = connections[alias] 48 | connection.ensure_connection() 49 | 50 | # Hand over control 51 | yield alias 52 | 53 | except OperationalError as e: 54 | raise RuntimeError(f"Could not connect to the database '{alias}' using url: {db_url}. Error: {e}") 55 | 56 | finally: 57 | # Restore the original config or remove the alias 58 | if original_config is not None: 59 | connections.databases[alias] = original_config 60 | else: 61 | del connections.databases[alias] -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Set the python version as a build-time argument 2 | # with Python 3.12 as the default 3 | ARG PYTHON_VERSION=3.12-slim-bullseye 4 | FROM python:${PYTHON_VERSION} 5 | 6 | # Create a virtual environment 7 | RUN python -m venv /opt/venv 8 | 9 | # Set the virtual environment as the current location 10 | ENV PATH=/opt/venv/bin:$PATH 11 | 12 | # Upgrade pip 13 | RUN pip install --upgrade pip 14 | 15 | # Set Python-related environment variables 16 | ENV PYTHONDONTWRITEBYTECODE 1 17 | ENV PYTHONUNBUFFERED 1 18 | 19 | # Install os dependencies for our mini vm 20 | RUN apt-get update && apt-get install -y \ 21 | # for postgres 22 | libpq-dev \ 23 | # for Pillow 24 | libjpeg-dev \ 25 | # for CairoSVG 26 | libcairo2 \ 27 | # other 28 | gcc \ 29 | && rm -rf /var/lib/apt/lists/* 30 | 31 | # Create the mini vm's code directory 32 | RUN mkdir -p /code 33 | 34 | # Set the working directory to that same code directory 35 | WORKDIR /code 36 | 37 | # Copy the requirements file into the container 38 | COPY requirements.txt /tmp/requirements.txt 39 | 40 | # copy the project code into the container's working directory 41 | COPY ./src /code 42 | 43 | # Install the Python project requirements 44 | RUN pip install -r /tmp/requirements.txt 45 | 46 | ARG DJANGO_SECRET_KEY 47 | ENV DJANGO_SECRET_KEY=${DJANGO_SECRET_KEY} 48 | 49 | ARG DJANGO_DEBUG=0 50 | ENV DJANGO_DEBUG=${DJANGO_DEBUG} 51 | 52 | # database isn't available during build 53 | # run any other commands that do not need the database 54 | # such as: 55 | RUN python manage.py vendor_pull 56 | RUN python manage.py collectstatic --noinput 57 | # whitenoise -> s3 58 | 59 | # set the Django default project name 60 | ARG PROJ_NAME="cfehome" 61 | 62 | # create a bash script to run the Django project 63 | # this script will execute at runtime when 64 | # the container starts and the database is available 65 | RUN printf "#!/bin/bash\n" > ./paracord_runner.sh && \ 66 | printf "RUN_PORT=\"\${PORT:-8000}\"\n\n" >> ./paracord_runner.sh && \ 67 | printf "python manage.py migrate --no-input\n" >> ./paracord_runner.sh && \ 68 | printf "gunicorn ${PROJ_NAME}.wsgi:application --bind \"0.0.0.0:\$RUN_PORT\"\n" >> ./paracord_runner.sh 69 | 70 | # make the bash script executable 71 | RUN chmod +x paracord_runner.sh 72 | 73 | # Clean up apt cache to reduce image size 74 | RUN apt-get remove --purge -y \ 75 | && apt-get autoremove -y \ 76 | && apt-get clean \ 77 | && rm -rf /var/lib/apt/lists/* 78 | 79 | # Run the Django project via the runtime script 80 | # when the container starts 81 | CMD ./paracord_runner.sh 82 | -------------------------------------------------------------------------------- /src/cfehome/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | URL configuration for cfehome project. 3 | 4 | The `urlpatterns` list routes URLs to views. For more information please see: 5 | https://docs.djangoproject.com/en/5.0/topics/http/urls/ 6 | Examples: 7 | Function views 8 | 1. Add an import: from my_app import views 9 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 10 | Class-based views 11 | 1. Add an import: from other_app.views import Home 12 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 13 | Including another URLconf 14 | 1. Import the include() function: from django.urls import include, path 15 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 16 | """ 17 | from django.contrib import admin 18 | from django.urls import path, include 19 | from auth import views as auth_views 20 | from checkouts import views as checkout_views 21 | from landing import views as landing_views 22 | from subscriptions import views as subscriptions_views 23 | from .views import ( 24 | home_view, 25 | about_view, 26 | pw_protected_view, 27 | user_only_view, 28 | staff_only_view 29 | ) 30 | 31 | urlpatterns = [ 32 | path("", landing_views.landing_dashboard_page_view, name='home'), 33 | path("checkout/sub-price//", 34 | checkout_views.product_price_redirect_view, 35 | name='sub-price-checkout' 36 | ), 37 | path("checkout/start/", 38 | checkout_views.checkout_redirect_view, 39 | name='stripe-checkout-start' 40 | ), 41 | path("checkout/success/", 42 | checkout_views.checkout_finalize_view, 43 | name='stripe-checkout-end' 44 | ), 45 | path("pricing/", subscriptions_views.subscription_price_view, name='pricing'), 46 | path("pricing//", subscriptions_views.subscription_price_view, name='pricing_interval'), 47 | path("about/", about_view), 48 | path("hello-world/", home_view), 49 | path("hello-world.html", home_view), 50 | path('accounts/billing/', subscriptions_views.user_subscription_view, name='user_subscription'), 51 | path('accounts/billing/cancel', subscriptions_views.user_subscription_cancel_view, name='user_subscription_cancel'), 52 | path('accounts/', include('allauth.urls')), 53 | path('protected/user-only/', user_only_view), 54 | path('protected/staff-only/', staff_only_view), 55 | path('protected/', pw_protected_view), 56 | path('profiles/', include('profiles.urls')), 57 | path('tenants/', include('tenants.urls')), 58 | path("admin/", admin.site.urls), 59 | ] 60 | -------------------------------------------------------------------------------- /src/tenants/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.http import HttpResponse 3 | from django.shortcuts import render, get_object_or_404, redirect 4 | 5 | from django.contrib.auth import get_user_model 6 | 7 | from allauth.account.forms import SignupForm 8 | 9 | from helpers.db.schemas import ( 10 | use_tenant_schema 11 | ) 12 | 13 | from .models import Tenant 14 | 15 | User = get_user_model() 16 | 17 | @login_required 18 | def tenant_list_view(request): 19 | owner = request.user 20 | context = { 21 | "object_list": Tenant.objects.filter(owner=owner) 22 | } 23 | return render(request, "tenants/list.html", context) 24 | 25 | 26 | @login_required 27 | def tenant_detail_view(request, pk): 28 | # public schema -> update urls.py -> django-hosts 29 | owner = request.user 30 | instance = get_object_or_404(Tenant, owner=owner, pk=pk) 31 | enterprise_users = [] 32 | with use_tenant_schema(instance.schema_name, create_if_missing=True, revert_public=True): 33 | # cache?? -> per tenant caching system setup too 34 | enterprise_users = list(User.objects.all()) 35 | context = { 36 | "object": instance, 37 | "instance": instance, 38 | "enterprise_users": enterprise_users 39 | } 40 | return render(request, "tenants/detail.html", context) 41 | 42 | 43 | @login_required 44 | def tenant_create_user_view(request, pk): 45 | # public schema -> update urls.py -> django-hosts 46 | owner = request.user 47 | instance = get_object_or_404(Tenant, owner=owner, pk=pk) 48 | form = SignupForm() 49 | with use_tenant_schema(instance.schema_name, create_if_missing=True, revert_public=True): 50 | form = SignupForm(request.POST or None) 51 | if form.is_valid(): 52 | form.save(request) 53 | pk = instance.pk 54 | return redirect(f'/tenants/{pk}') 55 | context = { 56 | "object": instance, 57 | "instance": instance, 58 | "form": form, 59 | } 60 | return render(request, "tenants/new-user.html", context) 61 | 62 | 63 | @login_required 64 | def tenant_user_detail_view(request, tenant_pk, user_pk): 65 | # public schema -> update urls.py -> django-hosts 66 | owner = request.user 67 | instance = get_object_or_404(Tenant, owner=owner, pk=tenant_pk) 68 | user_instance = None 69 | with use_tenant_schema(instance.schema_name, create_if_missing=True, revert_public=True): 70 | user_instance = User.objects.get(pk=user_pk) 71 | return HttpResponse(f"{user_instance.id}-{user_instance.username}") 72 | return HttpResponse("") -------------------------------------------------------------------------------- /src/cfehome/views.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from django.shortcuts import render 3 | from django.contrib.auth.decorators import login_required 4 | from django.contrib.admin.views.decorators import staff_member_required 5 | from django.conf import settings 6 | 7 | from django.http import HttpResponse 8 | 9 | from visits.models import PageVisit 10 | 11 | LOGIN_URL = settings.LOGIN_URL 12 | 13 | this_dir = pathlib.Path(__file__).resolve().parent 14 | 15 | def home_view(request, *args, **kwargs): 16 | if request.user.is_authenticated: 17 | print(request.user.first_name) 18 | return about_view(request, *args, **kwargs) 19 | 20 | 21 | def about_view(request, *args, **kwargs): 22 | qs = PageVisit.objects.all() 23 | page_qs = PageVisit.objects.filter(path=request.path) 24 | try: 25 | percent = (page_qs.count() * 100.0) / qs.count() 26 | except: 27 | percent = 0 28 | my_title = "My Page" 29 | html_template = "landing/main.html" 30 | my_context = { 31 | "page_title": my_title, 32 | "page_visit_count": page_qs.count(), 33 | "percent": percent, 34 | "total_visit_count": qs.count(), 35 | } 36 | PageVisit.objects.create(path=request.path) 37 | return render(request, html_template, my_context) 38 | 39 | 40 | def my_old_home_page_view(request, *args, **kwargs): 41 | my_title = "My Page" 42 | my_context = { 43 | "page_title": my_title 44 | } 45 | html_ = """ 46 | 47 | 48 | 49 | 50 |

    {page_title} anything?

    51 | 52 | 53 | """.format(**my_context) # page_title=my_title 54 | # html_file_path = this_dir / "home.html" 55 | # html_ = html_file_path.read_text() 56 | return HttpResponse(html_) 57 | 58 | VALID_CODE = "abc123" 59 | 60 | def pw_protected_view(request, *args, **kwargs): 61 | is_allowed = request.session.get('protected_page_allowed') or 0 62 | # print(request.session.get('protected_page_allowed'), type(request.session.get('protected_page_allowed'))) 63 | if request.method == "POST": 64 | user_pw_sent = request.POST.get("code") or None 65 | if user_pw_sent == VALID_CODE: 66 | is_allowed = 1 67 | request.session['protected_page_allowed'] = is_allowed 68 | if is_allowed: 69 | return render(request, "protected/view.html", {}) 70 | return render(request, "protected/entry.html", {}) 71 | 72 | 73 | @login_required 74 | def user_only_view(request, *args, **kwargs): 75 | # print(request.user.is_staff) 76 | return render(request, "protected/user-only.html", {}) 77 | 78 | 79 | @staff_member_required(login_url=LOGIN_URL) 80 | def staff_only_view(request, *args, **kwargs): 81 | return render(request, "protected/user-only.html", {}) -------------------------------------------------------------------------------- /src/subscriptions/views.py: -------------------------------------------------------------------------------- 1 | import helpers.billing 2 | from django.contrib import messages 3 | from django.contrib.auth.decorators import login_required 4 | from django.shortcuts import render, redirect 5 | from django.urls import reverse 6 | 7 | from subscriptions.models import SubscriptionPrice, UserSubscription 8 | from subscriptions import utils as subs_utils 9 | 10 | @login_required 11 | def user_subscription_view(request,): 12 | user_sub_obj, created = UserSubscription.objects.get_or_create(user=request.user) 13 | if request.method == "POST": 14 | print("refresh sub") 15 | finished = subs_utils.refresh_active_users_subscriptions(user_ids=[request.user.id], active_only=False) 16 | if finished: 17 | messages.success(request, "Your plan details have been refreshed.") 18 | else: 19 | messages.error(request, "Your plan details have not been refreshed, please try again.") 20 | return redirect(user_sub_obj.get_absolute_url()) 21 | return render(request, 'subscriptions/user_detail_view.html', {"subscription": user_sub_obj}) 22 | 23 | 24 | @login_required 25 | def user_subscription_cancel_view(request,): 26 | user_sub_obj, created = UserSubscription.objects.get_or_create(user=request.user) 27 | if request.method == "POST": 28 | if user_sub_obj.stripe_id and user_sub_obj.is_active_status: 29 | sub_data = helpers.billing.cancel_subscription( 30 | user_sub_obj.stripe_id, 31 | reason="User wanted to end", 32 | feedback="other", 33 | cancel_at_period_end=True, 34 | raw=False) 35 | for k,v in sub_data.items(): 36 | setattr(user_sub_obj, k, v) 37 | user_sub_obj.save() 38 | messages.success(request, "Your plan has been cancelled.") 39 | return redirect(user_sub_obj.get_absolute_url()) 40 | return render(request, 'subscriptions/user_cancel_view.html', {"subscription": user_sub_obj}) 41 | # Create your views here. 42 | def subscription_price_view(request, interval="month"): 43 | qs = SubscriptionPrice.objects.filter(featured=True) 44 | inv_mo = SubscriptionPrice.IntervalChoices.MONTHLY 45 | inv_yr = SubscriptionPrice.IntervalChoices.YEARLY 46 | object_list = qs.filter(interval=inv_mo) 47 | url_path_name = "pricing_interval" 48 | mo_url = reverse(url_path_name, kwargs={"interval": inv_mo}) 49 | yr_url = reverse(url_path_name, kwargs={"interval": inv_yr}) 50 | active = inv_mo 51 | if interval == inv_yr: 52 | active = inv_yr 53 | object_list = qs.filter(interval=inv_yr) 54 | return render(request, "subscriptions/pricing.html", { 55 | "object_list": object_list, 56 | "mo_url": mo_url, 57 | "yr_url": yr_url, 58 | "active": active, 59 | }) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SaaS for Enterprise with Django 2 | Learn how to build an Enterprise SaaS project using Django, Neon Postgres, TailwindCSS, SaaS Starter Code, and more. Enterprise SaaS means we need a a way to isolate customer data by implementing multi-tenancy, subdomain-based auth, and much more. We start with an existing Django application and build on top of that. 3 | 4 | ## Need help? 5 | Use [the discussions tab](https://saasgorillas.com/discussions). 6 | 7 | ## Before you start 8 | 9 | I recommend you know some of the following: 10 | - __Python__ 11 | - Such as _classes_, _functions_, _variables_, _math operations_, _installing_, _setting up virtual environments_ 12 | - If you're new to Python, watch up to day 15 of [30 Days of Python](https://saasgorillas.com/python1)(free) 13 | - __Django basics__ 14 | - Such as _views_, _URL routing_, _models_ and _migrations_, _users_ and auth _login_ 15 | - If new to Django, watch [Your First Django Project](https://saasgorillas.com/django1)(paid) or [Try Django 3.2](https://saasgorillas.com/django2)(free) 16 | - __SaaS fundamentals with Django__ (Optional). 17 | - Such as Stripe integration, groups, user permissions, 18 | - Consider watching SaaS Foundations course on [YouTube](https://www.youtube.com/watch?v=WbNNESIxJnY) or on [CFE](https://saasgorillas.com/pre) 19 | - Review the SaaS starter code on [GitHub](https://github.com/codingforentrepreneurs/SaaS-Foundations)(open source) or [cfe.run](https://get.cfe.run)(managed) 20 | 21 | ## Links 22 | - [Code](https://saasgorillas.com/code) 23 | - SaaS Starter Code on [GitHub](https://github.com/codingforentrepreneurs/SaaS-Foundations)(open source) or [cfe.run](https://get.cfe.run)(managed) 24 | - Course (coming soon) 25 | - [Neon Postgres](https://saasgorillas.com/db) (our course sponsor) 26 | 27 | ## Topics 28 | 29 | - What, when, and why of multi-tenant apps (e.g. SaaS apps that need to keep enterprise data isolated) 30 | - Levels of helping enterprise customers (e.g. when not to use multi-tenant) 31 | - Implementing a multi-tenant in Django 32 | - Per-tenant (per enterprise customer) login 33 | - User data isoloation (via Postgres Schemas and Neon Databases) 34 | - Custom migrations for Django models; for standard Django and Enterprise customers 35 | - Django-hosts for subdomain routing and handling 36 | 37 | 38 | ## Tech stack 39 | 40 | - Django 5+ 41 | - Python 3+ 42 | - [django-hosts](https://django-hosts.readthedocs.io/en/latest/) - Subdomain handling in Django 43 | - [Neon Postgres](https://kirr.co/ffogxb) - Serverless postgres + near instance database loading 44 | - Django SaaS Starter code via [GitHub](https://github.com/codingforentrepreneurs/SaaS-Foundations)(open source) or [cfe.run](https://get.cfe.run)(managed) 45 | - `psycopyg[binary]` and `dj-database-url` - Loading postgres 46 | - and more 47 | 48 | > Note: [django-tenants](https://github.com/django-tenants/django-tenants) is not used in this course. While Django Tenants is a _very good tool_ it requires you to start with Django tenants for your SaaS projects. This tutorial does not. django-tenants was instrumental in designing this course. Once you finish the course, I encourage you to play around with django-tenants! 49 | 50 | 51 | ## Definitions 52 | 53 | Read the course-specific definitions [on the GitHub discussions](https://github.com/codingforentrepreneurs/SaaS-for-Enterprise-with-Django/discussions/1). This course has a number of definitions that are specific to multi-tenant, enterprise-ready, Django SaaS projects _and_ specific to this course. 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/checkouts/views.py: -------------------------------------------------------------------------------- 1 | import helpers.billing 2 | from django.shortcuts import render, redirect 3 | from django.urls import reverse 4 | from django.contrib import messages 5 | from django.contrib.auth.decorators import login_required 6 | from django.contrib.auth import get_user_model 7 | from django.conf import settings 8 | from django.http import HttpResponseBadRequest 9 | 10 | from subscriptions.models import SubscriptionPrice, Subscription, UserSubscription 11 | 12 | User = get_user_model() 13 | 14 | BASE_URL = settings.BASE_URL 15 | # Create your views here. 16 | def product_price_redirect_view(request, price_id=None, *args, **kwargs): 17 | request.session['checkout_subscription_price_id'] = price_id 18 | return redirect("stripe-checkout-start") 19 | 20 | 21 | @login_required 22 | def checkout_redirect_view(request): 23 | checkout_subscription_price_id = request.session.get("checkout_subscription_price_id") 24 | try: 25 | obj = SubscriptionPrice.objects.get(id=checkout_subscription_price_id) 26 | except: 27 | obj = None 28 | if checkout_subscription_price_id is None or obj is None: 29 | return redirect("pricing") 30 | customer_stripe_id = request.user.customer.stripe_id 31 | success_url_path = reverse("stripe-checkout-end") 32 | pricing_url_path = reverse("pricing") 33 | success_url = f"{BASE_URL}{success_url_path}" 34 | cancel_url= f"{BASE_URL}{pricing_url_path}" 35 | price_stripe_id = obj.stripe_id 36 | url = helpers.billing.start_checkout_session( 37 | customer_stripe_id, 38 | success_url=success_url, 39 | cancel_url=cancel_url, 40 | price_stripe_id=price_stripe_id, 41 | raw=False 42 | 43 | ) 44 | return redirect(url) 45 | 46 | 47 | def checkout_finalize_view(request): 48 | session_id = request.GET.get('session_id') 49 | checkout_data = helpers.billing.get_checkout_customer_plan(session_id) 50 | plan_id = checkout_data.pop('plan_id') 51 | customer_id = checkout_data.pop('customer_id') 52 | sub_stripe_id = checkout_data.pop("sub_stripe_id") 53 | subscription_data = {**checkout_data} 54 | try: 55 | sub_obj = Subscription.objects.get(subscriptionprice__stripe_id=plan_id) 56 | except: 57 | sub_obj = None 58 | try: 59 | user_obj = User.objects.get(customer__stripe_id=customer_id) 60 | except: 61 | user_obj = None 62 | 63 | _user_sub_exists = False 64 | updated_sub_options = { 65 | "subscription": sub_obj, 66 | "stripe_id": sub_stripe_id, 67 | "user_cancelled": False, 68 | **subscription_data, 69 | } 70 | try: 71 | _user_sub_obj = UserSubscription.objects.get(user=user_obj) 72 | _user_sub_exists = True 73 | except UserSubscription.DoesNotExist: 74 | _user_sub_obj = UserSubscription.objects.create( 75 | user=user_obj, 76 | **updated_sub_options 77 | ) 78 | except: 79 | _user_sub_obj = None 80 | if None in [sub_obj, user_obj, _user_sub_obj]: 81 | return HttpResponseBadRequest("There was an error with your account, please contact us.") 82 | if _user_sub_exists: 83 | # cancel old sub 84 | old_stripe_id = _user_sub_obj.stripe_id 85 | same_stripe_id = sub_stripe_id == old_stripe_id 86 | if old_stripe_id is not None and not same_stripe_id: 87 | try: 88 | helpers.billing.cancel_subscription(old_stripe_id, reason="Auto ended, new membership", feedback="other") 89 | except: 90 | pass 91 | # assign new sub 92 | for k, v in updated_sub_options.items(): 93 | setattr(_user_sub_obj, k, v) 94 | _user_sub_obj.save() 95 | messages.success(request, "Success! Thank you for joining.") 96 | return redirect(_user_sub_obj.get_absolute_url()) 97 | context = {} 98 | return render(request, "checkout/success.html", context) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | -------------------------------------------------------------------------------- /src/tenants/tasks.py: -------------------------------------------------------------------------------- 1 | # from celery import shared_task 2 | from typing import Any 3 | 4 | from django.apps import apps 5 | from django.core.management import call_command 6 | from django.db import connection 7 | from django.conf import settings 8 | from django.db.migrations.executor import MigrationExecutor 9 | from helpers.db.schemas import ( 10 | use_public_schema, 11 | use_tenant_schema 12 | ) 13 | 14 | 15 | # @shared_task 16 | def migrate_public_schema_task(): 17 | with use_public_schema(): 18 | call_command("migrate", interactive=False) 19 | 20 | 21 | # @shared_task 22 | def migrate_tenant_task(tenant_id:str, branch=True): 23 | if branch: 24 | call_command("db_branch") 25 | Tenant = apps.get_model("tenants", "Tenant") 26 | try: 27 | instance = Tenant.objects.get(id=tenant_id) 28 | except Exception as e: 29 | print(f'Tenant {tenant_id} failed: {e}') 30 | return 31 | schema_name = instance.schema_name 32 | with use_tenant_schema(schema_name, create_if_missing=True, revert_public=True): 33 | # Initialize the executor after setting the search path 34 | executor = MigrationExecutor(connection) 35 | loader = executor.loader 36 | loader.build_graph() # Ensure the graph is up-to-date 37 | 38 | customer_apps = getattr(settings, 'CUSTOMER_INSTALLED_APPS', []) 39 | customer_app_configs = [ 40 | app_config for app_config in apps.get_app_configs() 41 | if app_config.name in customer_apps 42 | ] 43 | 44 | # For each customer app, determine what migrations need to be run 45 | for app_config in customer_app_configs: 46 | app_label = app_config.label 47 | 48 | # Get all leaf nodes for this app 49 | leaf_nodes = [ 50 | node for node in loader.graph.leaf_nodes() 51 | if node[0] == app_label 52 | ] 53 | 54 | if not leaf_nodes: 55 | # App has no migrations at all, do nothing silently 56 | continue 57 | 58 | # For each leaf node, figure out the plan to get there 59 | # If the plan is empty, it means no new migrations are needed. 60 | full_plan = [] 61 | for leaf in leaf_nodes: 62 | plan = executor.migration_plan([leaf]) 63 | for migration, backwards in plan: 64 | if not backwards: # only include forward migrations 65 | full_plan.append(migration) 66 | 67 | # Remove duplicates while preserving order 68 | seen = set() 69 | ordered_migrations = [] 70 | for m in full_plan: 71 | if m not in seen: 72 | seen.add(m) 73 | ordered_migrations.append(m) 74 | 75 | if not ordered_migrations: 76 | # No forward migrations needed for this app 77 | continue 78 | 79 | # Print out which migrations are going to be applied 80 | print(f"Applying migrations for '{app_label}':") 81 | for migration in ordered_migrations: 82 | print(f" - {migration.app_label}.{migration.name}") 83 | 84 | # Apply the migrations 85 | # The plan to migrate is the leaf_nodes for this app 86 | executor.migrate(leaf_nodes) 87 | # Rebuild the graph after applying migrations 88 | executor.loader.build_graph() 89 | 90 | 91 | # @shared_task 92 | def migrate_tenant_schemas_task(): 93 | Tenant = apps.get_model("tenants", "Tenant") 94 | qs = Tenant.objects.none() 95 | with use_public_schema(): 96 | qs = Tenant.objects.all().values_list('id', flat=True) 97 | call_command("migrate", interactive=False) 98 | call_command("db_branch") 99 | for tenant_id in qs: 100 | migrate_tenant_task(tenant_id, branch=False) -------------------------------------------------------------------------------- /src/templates/nav/navbar.html: -------------------------------------------------------------------------------- 1 | {% load hosts %} 2 | 3 | 52 | -------------------------------------------------------------------------------- /src/helpers/neonctl/clients.py: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/codingforentrepreneurs/ffb3fb23a2710361a9489e7e0ee73cb8 2 | 3 | from typing import Dict, List, Optional 4 | 5 | import requests 6 | from django.conf import settings 7 | 8 | NEON_API_KEY = getattr(settings, "NEON_API_KEY", None) 9 | NEON_PROJECT_ID = getattr(settings, "NEON_PROJECT_ID", None) 10 | NEON_API_BASE_URL = "https://console.neon.tech/api/v2" 11 | 12 | 13 | class NeonBranchClient: 14 | def __init__(self, api_key: str = NEON_API_KEY, project_id: str = NEON_PROJECT_ID): 15 | self.api_key = api_key 16 | self.project_id = project_id 17 | self.base_url = NEON_API_BASE_URL 18 | self.headers = { 19 | "Accept": "application/json", 20 | "Authorization": f"Bearer {self.api_key}", 21 | "Content-Type": "application/json", 22 | } 23 | 24 | def list_branches(self, names_only: bool = False) -> Dict: 25 | """List all branches in the project""" 26 | url = f"{self.base_url}/projects/{self.project_id}/branches" 27 | response = requests.get(url, headers=self.headers) 28 | response.raise_for_status() 29 | branches = response.json()["branches"] 30 | if names_only: 31 | return [branch["name"] for branch in branches] 32 | return branches 33 | 34 | def protect_branch(self, branch_id: str) -> Dict: 35 | """Protect a branch""" 36 | url = f"{self.base_url}/projects/{self.project_id}/branches/{branch_id}" 37 | payload = {"branch": {"protected": True}} 38 | headers = { 39 | "accept": "application/json", 40 | "content-type": "application/json", 41 | **self.headers, 42 | } 43 | try: 44 | response = requests.patch(url, headers=headers, json=payload) 45 | response.raise_for_status() 46 | except requests.exceptions.HTTPError as http_err: 47 | return {"error": str(http_err)} 48 | except Exception as err: 49 | return {"error": str(err)} 50 | return response.json() 51 | 52 | def set_as_primary(self, branch_id: str) -> Dict: 53 | """Set a branch as the primary branch""" 54 | url = f"{self.base_url}/projects/{self.project_id}/primary_branch" 55 | payload = {"branch_id": branch_id} 56 | response = requests.put(url, headers=self.headers, json=payload) 57 | response.raise_for_status() 58 | return response.json() 59 | 60 | def get_primary_branch( 61 | self, fields: List[str] = ["id", "name", "protected"] 62 | ) -> Dict: 63 | """Get the primary branch""" 64 | branches = self.list_branches() 65 | for branch in branches: 66 | if branch["primary"]: 67 | if len(fields) == 0: 68 | return branch 69 | elif len(fields) == 1: 70 | if fields[0] == "*": 71 | return branch 72 | return {field: branch[field] for field in fields} 73 | return {} 74 | 75 | def create_branch( 76 | self, 77 | parent_id: Optional[str] = None, 78 | name: Optional[str] = None, 79 | with_compute: bool = True, 80 | ) -> Dict: 81 | """Create a new branch""" 82 | url = f"{self.base_url}/projects/{self.project_id}/branches" 83 | 84 | payload = {} 85 | if parent_id: 86 | payload["branch"] = {"parent_id": parent_id} 87 | if name: 88 | payload["branch"] = payload.get("branch", {}) 89 | payload["branch"]["name"] = name 90 | if with_compute: 91 | payload["endpoints"] = [{"type": "read_write"}] 92 | 93 | response = requests.post(url, headers=self.headers, json=payload) 94 | response.raise_for_status() 95 | return response.json() 96 | 97 | def get_branch_by_name(self, starts_with: str) -> Dict: 98 | """Get a branch by name""" 99 | branches = self.list_branches() 100 | for branch in branches: 101 | if branch["name"].startswith(starts_with): 102 | return branch 103 | return {} 104 | 105 | def delete_branch(self, branch_id: str) -> Dict: 106 | """Delete a specific branch""" 107 | url = f"{self.base_url}/projects/{self.project_id}/branches/{branch_id}" 108 | response = requests.delete(url, headers=self.headers) 109 | response.raise_for_status() 110 | return response.json() 111 | 112 | def get_branch(self, branch_id: str) -> Dict: 113 | """Get details of a specific branch""" 114 | url = f"{self.base_url}/projects/{self.project_id}/branches/{branch_id}" 115 | response = requests.get(url, headers=self.headers) 116 | response.raise_for_status() 117 | return response.json() -------------------------------------------------------------------------------- /src/helpers/billing.py: -------------------------------------------------------------------------------- 1 | import stripe 2 | from decouple import config 3 | 4 | from . import date_utils 5 | 6 | DJANGO_DEBUG=config("DJANGO_DEBUG", default=False, cast=bool) 7 | STRIPE_SECRET_KEY=config("STRIPE_SECRET_KEY", default="", cast=str) 8 | STRIPE_TEST_OVERRIDE = config("STRIPE_TEST_OVERRIDE", default=False, cast=bool) 9 | 10 | if "sk_test" in STRIPE_SECRET_KEY and not DJANGO_DEBUG and not STRIPE_TEST_OVERRIDE: 11 | raise ValueError("Invalid stripe key for prod") 12 | 13 | stripe.api_key = STRIPE_SECRET_KEY 14 | 15 | 16 | def serialize_subscription_data(subscription_response): 17 | status = subscription_response.status 18 | current_period_start = date_utils.timestamp_as_datetime(subscription_response.current_period_start) 19 | current_period_end = date_utils.timestamp_as_datetime(subscription_response.current_period_end) 20 | cancel_at_period_end = subscription_response.cancel_at_period_end 21 | return { 22 | "current_period_start": current_period_start, 23 | "current_period_end": current_period_end, 24 | "status": status, 25 | "cancel_at_period_end": cancel_at_period_end, 26 | } 27 | 28 | def create_customer( 29 | name="", 30 | email="", 31 | metadata={}, 32 | raw=False): 33 | response = stripe.Customer.create( 34 | name=name, 35 | email=email, 36 | metadata=metadata, 37 | ) 38 | if raw: 39 | return response 40 | stripe_id = response.id 41 | return stripe_id 42 | 43 | 44 | def create_product(name="", 45 | metadata={}, 46 | raw=False): 47 | response = stripe.Product.create( 48 | name=name, 49 | metadata=metadata, 50 | ) 51 | if raw: 52 | return response 53 | stripe_id = response.id 54 | return stripe_id 55 | 56 | def create_price(currency="usd", 57 | unit_amount="9999", 58 | interval="month", 59 | product=None, 60 | metadata={}, 61 | raw=False): 62 | if product is None: 63 | return None 64 | response = stripe.Price.create( 65 | currency=currency, 66 | unit_amount=unit_amount, 67 | recurring={"interval": interval}, 68 | product=product, 69 | metadata=metadata 70 | ) 71 | if raw: 72 | return response 73 | stripe_id = response.id 74 | return stripe_id 75 | 76 | 77 | def start_checkout_session(customer_id, 78 | success_url="", 79 | cancel_url="", 80 | price_stripe_id="", 81 | raw=True): 82 | if not success_url.endswith("?session_id={CHECKOUT_SESSION_ID}"): 83 | success_url = f"{success_url}" + "?session_id={CHECKOUT_SESSION_ID}" 84 | response= stripe.checkout.Session.create( 85 | customer=customer_id, 86 | success_url=success_url, 87 | cancel_url=cancel_url, 88 | line_items=[{"price": price_stripe_id, "quantity": 1}], 89 | mode="subscription", 90 | ) 91 | if raw: 92 | return response 93 | return response.url 94 | 95 | def get_checkout_session(stripe_id, raw=True): 96 | response = stripe.checkout.Session.retrieve( 97 | stripe_id 98 | ) 99 | if raw: 100 | return response 101 | return response.url 102 | 103 | def get_subscription(stripe_id, raw=True): 104 | response = stripe.Subscription.retrieve( 105 | stripe_id 106 | ) 107 | if raw: 108 | return response 109 | return serialize_subscription_data(response) 110 | 111 | 112 | def get_customer_active_subscriptions(customer_stripe_id): 113 | response = stripe.Subscription.list( 114 | customer=customer_stripe_id, 115 | status="active" 116 | ) 117 | return response 118 | 119 | 120 | def cancel_subscription(stripe_id, reason="", feedback="other", cancel_at_period_end=False, raw=True): 121 | if cancel_at_period_end: 122 | response = stripe.Subscription.modify( 123 | stripe_id, 124 | cancel_at_period_end=cancel_at_period_end, 125 | cancellation_details={ 126 | "comment": reason, 127 | "feedback": feedback 128 | } 129 | ) 130 | else: 131 | response = stripe.Subscription.cancel( 132 | stripe_id, 133 | cancellation_details={ 134 | "comment": reason, 135 | "feedback": feedback 136 | } 137 | ) 138 | if raw: 139 | return response 140 | return serialize_subscription_data(response) 141 | 142 | 143 | def get_checkout_customer_plan(session_id): 144 | checkout_r = get_checkout_session(session_id, raw=True) 145 | customer_id = checkout_r.customer 146 | sub_stripe_id = checkout_r.subscription 147 | sub_r = get_subscription(sub_stripe_id, raw=True) 148 | # current_period_start 149 | # current_period_end 150 | sub_plan = sub_r.plan 151 | subscription_data = serialize_subscription_data(sub_r) 152 | data = { 153 | "customer_id": customer_id, 154 | "plan_id": sub_plan.id, 155 | "sub_stripe_id": sub_stripe_id, 156 | **subscription_data, 157 | } 158 | return data -------------------------------------------------------------------------------- /src/templates/landing/features.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    Designed for business teams like yours

    5 |

    Here at Flowbite we focus on markets where technology, innovation, and capital can unlock long-term value and drive economic growth.

    6 |
    7 |
    8 |
    9 |
    10 | 11 |
    12 |

    Marketing

    13 |

    Plan it, create it, launch it. Collaborate seamlessly with all the organization and hit your marketing goals every month with our marketing plan.

    14 |
    15 |
    16 |
    17 | 18 |
    19 |

    Legal

    20 |

    Protect your organization, devices and stay compliant with our structured workflows and custom permissions made for you.

    21 |
    22 |
    23 |
    24 | 25 |
    26 |

    Business Automation

    27 |

    Auto-assign tasks, send Slack messages, and much more. Now power up with hundreds of new templates to help you get started.

    28 |
    29 |
    30 |
    31 | 32 |
    33 |

    Finance

    34 |

    Audit-proof software built for critical financial operations like month-end close and quarterly budgeting.

    35 |
    36 |
    37 |
    38 | 39 |
    40 |

    Enterprise Design

    41 |

    Craft beautiful, delightful experiences for both marketing and product with real cross-company collaboration.

    42 |
    43 |
    44 |
    45 | 46 |
    47 |

    Operations

    48 |

    Keep your company’s lights on with customizable, iterative, and structured workflows built for all efficient teams and individual.

    49 |
    50 |
    51 |
    52 |
    -------------------------------------------------------------------------------- /src/cfehome/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for cfehome project. 3 | 4 | Generated by 'django-admin startproject' using Django 5.0.6. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/5.0/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/5.0/ref/settings/ 11 | """ 12 | from decouple import config 13 | from pathlib import Path 14 | from .installed import ( 15 | _CUSTOMER_INSTALLED_APPS, 16 | _INSTALLED_APPS 17 | ) 18 | 19 | # Build paths inside the project like this: BASE_DIR / 'subdir'. 20 | BASE_DIR = Path(__file__).resolve().parent.parent 21 | 22 | # Configure Gmail for Django Emails: https://www.codingforentrepreneurs.com/blog/sending-email-in-django-from-gmail/ 23 | 24 | # Email config 25 | EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" 26 | EMAIL_HOST = config("EMAIL_HOST", cast=str, default="smtp.gmail.com") 27 | EMAIL_PORT = config("EMAIL_PORT", cast=str, default="587") # Recommended 28 | EMAIL_HOST_USER = config("EMAIL_HOST_USER", cast=str, default=None) 29 | EMAIL_HOST_PASSWORD = config("EMAIL_HOST_PASSWORD", cast=str, default=None) 30 | EMAIL_USE_TLS = config("EMAIL_USE_TLS", cast=bool, default=True) # Use EMAIL_PORT 587 for TLS 31 | EMAIL_USE_SSL = config("EMAIL_USE_SSL", cast=bool, default=False) # Use MAIL_PORT 465 for SSL 32 | ADMIN_USER_NAME=config("ADMIN_USER_NAME", default="Admin user") 33 | ADMIN_USER_EMAIL=config("ADMIN_USER_EMAIL", default=None) 34 | 35 | MANAGERS=[] 36 | ADMINS=[] 37 | if all([ADMIN_USER_NAME, ADMIN_USER_EMAIL]): 38 | # 500 errors are emailed to these users 39 | ADMINS +=[ 40 | (f'{ADMIN_USER_NAME}', f'{ADMIN_USER_EMAIL}') 41 | ] 42 | MANAGERS=ADMINS 43 | 44 | # Quick-start development settings - unsuitable for production 45 | # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ 46 | 47 | # SECURITY WARNING: keep the secret key used in production secret! 48 | SECRET_KEY = config("DJANGO_SECRET_KEY") 49 | 50 | # SECURITY WARNING: don't run with debug turned on in production! 51 | # DEBUG = str(os.environ.get("DJANGO_DEBUG")).lower() == "true" 52 | DEBUG = config("DJANGO_DEBUG", cast=bool) 53 | BASE_URL = config("BASE_URL", default=None) 54 | ALLOWED_HOSTS = [ 55 | ".railway.app" # https://saas.prod.railway.app 56 | ] 57 | if DEBUG: 58 | ALLOWED_HOSTS += [ 59 | "127.0.0.1", 60 | ".localhost", 61 | '.desalsa.io' 62 | ] 63 | 64 | 65 | # Application definition 66 | 67 | INSTALLED_APPS = _INSTALLED_APPS 68 | CUSTOMER_INSTALLED_APPS = _CUSTOMER_INSTALLED_APPS 69 | 70 | MIDDLEWARE = [ 71 | "django_hosts.middleware.HostsRequestMiddleware", 72 | "django.middleware.security.SecurityMiddleware", 73 | "whitenoise.middleware.WhiteNoiseMiddleware", 74 | "helpers.middleware.schemas.SchemaTenantMiddleware", 75 | "django.contrib.sessions.middleware.SessionMiddleware", 76 | "django.middleware.common.CommonMiddleware", 77 | "django.middleware.csrf.CsrfViewMiddleware", 78 | "django.contrib.auth.middleware.AuthenticationMiddleware", 79 | "django.contrib.messages.middleware.MessageMiddleware", 80 | "allauth.account.middleware.AccountMiddleware", 81 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 82 | "django_hosts.middleware.HostsResponseMiddleware", 83 | ] 84 | 85 | DEFAULT_HOST = "www" 86 | PARENT_HOST = "localhost:8000" 87 | ROOT_HOSTCONF = "cfehome.hosts" 88 | ROOT_URLCONF = "cfehome.urls" 89 | ENTERPRISES_URLCONF = "enterprises.urls" 90 | # ROOT_URLCONF = ENTERPRISES_URLCONF 91 | 92 | TEMPLATES = [ 93 | { 94 | "BACKEND": "django.template.backends.django.DjangoTemplates", 95 | "DIRS": [BASE_DIR / "templates"], 96 | "APP_DIRS": True, 97 | "OPTIONS": { 98 | "context_processors": [ 99 | "django.template.context_processors.debug", 100 | "django.template.context_processors.request", 101 | "django.contrib.auth.context_processors.auth", 102 | "django.contrib.messages.context_processors.messages", 103 | ], 104 | }, 105 | }, 106 | ] 107 | 108 | WSGI_APPLICATION = "cfehome.wsgi.application" 109 | 110 | 111 | # Database 112 | # https://docs.djangoproject.com/en/5.0/ref/settings/#databases 113 | 114 | DATABASES = { 115 | "default": { 116 | "ENGINE": "django.db.backends.sqlite3", 117 | "NAME": BASE_DIR / "db.sqlite3", 118 | } 119 | } 120 | 121 | CONN_MAX_AGE = config("CONN_MAX_AGE", cast=int, default=300) 122 | DATABASE_URL = config("DATABASE_URL", default=None) 123 | NEON_API_KEY=config("NEON_API_KEY", default=None) 124 | NEON_PROJECT_ID=config("NEON_PROJECT_ID", default=None) 125 | 126 | if DATABASE_URL is not None: 127 | import dj_database_url 128 | DATABASES = { 129 | "default": dj_database_url.config( 130 | default=DATABASE_URL, 131 | conn_max_age=CONN_MAX_AGE, 132 | conn_health_checks=True, 133 | engine='helpers.db.engine' 134 | ), 135 | } 136 | 137 | 138 | ## REDIS CACHING 139 | 140 | REDIS_CACHE_URL = config("REDIS_CACHE_URL", default=None) 141 | 142 | if REDIS_CACHE_URL is not None: 143 | CACHES = { 144 | "default": { 145 | "BACKEND": "django_redis.cache.RedisCache", 146 | "LOCATION": REDIS_CACHE_URL, 147 | "OPTIONS": { 148 | "CLIENT_CLASS": "django_redis.client.DefaultClient", 149 | } 150 | } 151 | } 152 | 153 | # Add these at the top of your settings.py 154 | # from os import getenv 155 | # from dotenv import load_dotenv 156 | 157 | # Replace the DATABASES section of your settings.py with this 158 | # DATABASES = { 159 | # 'default': { 160 | # 'ENGINE': 'django.db.backends.postgresql', 161 | # 'NAME': config('PGDATABASE'), 162 | # 'USER': config('PGUSER'), 163 | # 'PASSWORD': config('PGPASSWORD'), 164 | # 'HOST': config('PGHOST'), 165 | # 'PORT': config('PGPORT', 5432), 166 | # 'OPTIONS': { 167 | # 'sslmode': 'require', 168 | # }, 169 | # } 170 | # } 171 | 172 | # Password validation 173 | # https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators 174 | 175 | AUTH_PASSWORD_VALIDATORS = [ 176 | { 177 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 178 | }, 179 | { 180 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 181 | }, 182 | { 183 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 184 | }, 185 | { 186 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 187 | }, 188 | ] 189 | 190 | # Django Allauth Config 191 | LOGIN_REDIRECT_URL = "/" 192 | ACCOUNT_AUTHENTICATION_METHOD = "username" 193 | ACCOUNT_EMAIL_VERIFICATION="optional" 194 | ACCOUNT_EMAIL_SUBJECT_PREFIX="[CFE] " 195 | ACCOUNT_EMAIL_REQUIRED=False 196 | 197 | AUTHENTICATION_BACKENDS = [ 198 | # ... 199 | # Needed to login by username in Django admin, regardless of `allauth` 200 | 'django.contrib.auth.backends.ModelBackend', 201 | 202 | # `allauth` specific authentication methods, such as login by email 203 | 'allauth.account.auth_backends.AuthenticationBackend', 204 | # ... 205 | ] 206 | 207 | SOCIALACCOUNT_PROVIDERS = { 208 | "github": { 209 | "VERIFIED_EMAIL": True 210 | } 211 | } 212 | 213 | 214 | # Internationalization 215 | # https://docs.djangoproject.com/en/5.0/topics/i18n/ 216 | 217 | LANGUAGE_CODE = "en-us" 218 | 219 | TIME_ZONE = "UTC" 220 | 221 | USE_I18N = True 222 | 223 | USE_TZ = True 224 | 225 | 226 | # Static files (CSS, JavaScript, Images) 227 | # https://docs.djangoproject.com/en/5.0/howto/static-files/ 228 | 229 | STATIC_URL = "static/" 230 | STATICFILES_BASE_DIR = BASE_DIR / "staticfiles" 231 | STATICFILES_BASE_DIR.mkdir(exist_ok=True, parents=True) 232 | STATICFILES_VENDOR_DIR = STATICFILES_BASE_DIR / "vendors" 233 | 234 | # source(s) for python manage.py collectstatic 235 | STATICFILES_DIRS = [ 236 | STATICFILES_BASE_DIR 237 | ] 238 | 239 | # output for python manage.py collectstatic 240 | # local cdn 241 | STATIC_ROOT = BASE_DIR / "local-cdn" 242 | 243 | # < Django 4.2 244 | # STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 245 | 246 | STORAGES = { 247 | "staticfiles": { 248 | "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", 249 | }, 250 | } 251 | 252 | # Default primary key field type 253 | # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field 254 | 255 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 256 | -------------------------------------------------------------------------------- /src/subscriptions/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import helpers.billing 3 | from django.db import models 4 | from django.db.models import Q 5 | from django.contrib.auth.models import Group, Permission 6 | from django.db.models.signals import post_save 7 | from django.conf import settings 8 | from django.urls import reverse 9 | from django.utils import timezone 10 | 11 | User = settings.AUTH_USER_MODEL # "auth.User" 12 | 13 | ALLOW_CUSTOM_GROUPS = True 14 | SUBSCRIPTION_PERMISSIONS = [ 15 | ("advanced", "Advanced Perm"), # subscriptions.advanced 16 | ("pro", "Pro Perm"), # subscriptions.pro 17 | ("basic", "Basic Perm"), # subscriptions.basic, 18 | ("basic_ai", "Basic AI Perm") 19 | ] 20 | 21 | 22 | # Create your models here. 23 | class Subscription(models.Model): 24 | """ 25 | Subscription Plan = Stripe Product 26 | """ 27 | name = models.CharField(max_length=120) 28 | subtitle = models.TextField(blank=True, null=True) 29 | active = models.BooleanField(default=True) 30 | groups = models.ManyToManyField(Group) # one-to-one 31 | permissions = models.ManyToManyField(Permission, limit_choices_to={ 32 | "content_type__app_label": "subscriptions", "codename__in": [x[0]for x in SUBSCRIPTION_PERMISSIONS] 33 | } 34 | ) 35 | stripe_id = models.CharField(max_length=120, null=True, blank=True) 36 | 37 | order = models.IntegerField(default=-1, help_text='Ordering on Django pricing page') 38 | featured = models.BooleanField(default=True, help_text='Featured on Django pricing page') 39 | updated = models.DateTimeField(auto_now=True) 40 | timestamp = models.DateTimeField(auto_now_add=True) 41 | features = models.TextField(help_text="Features for pricing, seperated by new line", blank=True, null=True) 42 | 43 | def __str__(self): 44 | return f"{self.name}" 45 | 46 | class Meta: 47 | ordering = ['order', 'featured', '-updated'] 48 | permissions = SUBSCRIPTION_PERMISSIONS 49 | 50 | def get_features_as_list(self): 51 | if not self.features: 52 | return [] 53 | return [x.strip() for x in self.features.split("\n")] 54 | 55 | def save(self, *args, **kwargs): 56 | if not self.stripe_id: 57 | stripe_id = helpers.billing.create_product( 58 | name=self.name, 59 | metadata={ 60 | "subscription_plan_id": self.id 61 | }, 62 | raw=False 63 | ) 64 | self.stripe_id = stripe_id 65 | super().save(*args, **kwargs) 66 | 67 | 68 | # Create your models here. 69 | class SubscriptionPrice(models.Model): 70 | """ 71 | Subscription Price = Stripe Price 72 | """ 73 | class IntervalChoices(models.TextChoices): 74 | MONTHLY = "month", "Monthly" 75 | YEARLY = "year", "Yearly" 76 | 77 | subscription = models.ForeignKey(Subscription, on_delete=models.SET_NULL, null=True) 78 | stripe_id = models.CharField(max_length=120, null=True, blank=True) 79 | interval = models.CharField(max_length=120, 80 | default=IntervalChoices.MONTHLY, 81 | choices=IntervalChoices.choices 82 | ) 83 | price = models.DecimalField(max_digits=10, decimal_places=2, default=99.99) 84 | order = models.IntegerField(default=-1, help_text='Ordering on Django pricing page') 85 | featured = models.BooleanField(default=True, help_text='Featured on Django pricing page') 86 | updated = models.DateTimeField(auto_now=True) 87 | timestamp = models.DateTimeField(auto_now_add=True) 88 | 89 | class Meta: 90 | ordering = ['subscription__order', 'order', 'featured', '-updated'] 91 | 92 | def get_checkout_url(self): 93 | return reverse("sub-price-checkout", 94 | kwargs = {"price_id": self.id} 95 | ) 96 | 97 | @property 98 | def display_features_list(self): 99 | if not self.subscription: 100 | return [] 101 | return self.subscription.get_features_as_list() 102 | 103 | @property 104 | def display_sub_name(self): 105 | if not self.subscription: 106 | return "Plan" 107 | return self.subscription.name 108 | 109 | @property 110 | def display_sub_subtitle(self): 111 | if not self.subscription: 112 | return "Plan" 113 | return self.subscription.subtitle 114 | 115 | @property 116 | def stripe_currency(self): 117 | return "usd" 118 | 119 | @property 120 | def stripe_price(self): 121 | """ 122 | remove decimal places 123 | """ 124 | return int(self.price * 100) 125 | 126 | @property 127 | def product_stripe_id(self): 128 | if not self.subscription: 129 | return None 130 | return self.subscription.stripe_id 131 | 132 | def save(self, *args, **kwargs): 133 | if (not self.stripe_id and 134 | self.product_stripe_id is not None): 135 | stripe_id = helpers.billing.create_price( 136 | currency=self.stripe_currency, 137 | unit_amount=self.stripe_price, 138 | interval=self.interval, 139 | product=self.product_stripe_id, 140 | metadata={ 141 | "subscription_plan_price_id": self.id 142 | }, 143 | raw=False 144 | ) 145 | self.stripe_id = stripe_id 146 | super().save(*args, **kwargs) 147 | if self.featured and self.subscription: 148 | qs = SubscriptionPrice.objects.filter( 149 | subscription=self.subscription, 150 | interval=self.interval 151 | ).exclude(id=self.id) 152 | qs.update(featured=False) 153 | 154 | class SubscriptionStatus(models.TextChoices): 155 | ACTIVE = 'active', 'Active' 156 | TRIALING = 'trialing', 'Trialing' 157 | INCOMPLETE = 'incomplete', 'Incomplete' 158 | INCOMPLETE_EXPIRED = 'incomplete_expired', 'Incomplete Expired' 159 | PAST_DUE = 'past_due', 'Past Due' 160 | CANCELED = 'canceled', 'Canceled' 161 | UNPAID = 'unpaid', 'Unpaid' 162 | PAUSED = 'paused', 'Paused' 163 | 164 | class UserSubscriptionQuerySet(models.QuerySet): 165 | def by_range(self, days_start=7, days_end=120, verbose=True): 166 | now = timezone.now() 167 | days_start_from_now = now + datetime.timedelta(days=days_start) 168 | days_end_from_now = now + datetime.timedelta(days=days_end) 169 | range_start = days_start_from_now.replace(hour=0, minute=0, second=0, microsecond=0) 170 | range_end = days_end_from_now.replace(hour=23, minute=59, second=59, microsecond=59) 171 | if verbose: 172 | print(f"Range is {range_start} to {range_end}") 173 | return self.filter( 174 | current_period_end__gte=range_start, 175 | current_period_end__lte=range_end 176 | ) 177 | 178 | def by_days_left(self, days_left=7): 179 | now = timezone.now() 180 | in_n_days = now + datetime.timedelta(days=days_left) 181 | day_start = in_n_days.replace(hour=0, minute=0, second=0, microsecond=0) 182 | day_end = in_n_days.replace(hour=23, minute=59, second=59, microsecond=59) 183 | return self.filter( 184 | current_period_end__gte=day_start, 185 | current_period_end__lte=day_end 186 | ) 187 | 188 | def by_days_ago(self, days_ago=3): 189 | now = timezone.now() 190 | in_n_days = now - datetime.timedelta(days=days_ago) 191 | day_start = in_n_days.replace(hour=0, minute=0, second=0, microsecond=0) 192 | day_end = in_n_days.replace(hour=23, minute=59, second=59, microsecond=59) 193 | return self.filter( 194 | current_period_end__gte=day_start, 195 | current_period_end__lte=day_end 196 | ) 197 | 198 | def by_active_trialing(self): 199 | active_qs_lookup = ( 200 | Q(status = SubscriptionStatus.ACTIVE) | 201 | Q(status = SubscriptionStatus.TRIALING) 202 | ) 203 | return self.filter(active_qs_lookup) 204 | 205 | def by_user_ids(self, user_ids=None): 206 | qs = self 207 | if isinstance(user_ids, list): 208 | qs = self.filter(user_id__in=user_ids) 209 | elif isinstance(user_ids, int): 210 | qs = self.filter(user_id__in=[user_ids]) 211 | elif isinstance(user_ids, str): 212 | qs = self.filter(user_id__in=[user_ids]) 213 | return qs 214 | 215 | 216 | class UserSubscriptionManager(models.Manager): 217 | def get_queryset(self): 218 | return UserSubscriptionQuerySet(self.model, using=self._db) 219 | 220 | # def by_user_ids(self, user_ids=None): 221 | # return self.get_queryset().by_user_ids(user_ids=user_ids) 222 | 223 | 224 | class UserSubscription(models.Model): 225 | user = models.OneToOneField(User, on_delete=models.CASCADE) 226 | subscription = models.ForeignKey(Subscription, on_delete=models.SET_NULL, null=True, blank=True) 227 | stripe_id = models.CharField(max_length=120, null=True, blank=True) 228 | active = models.BooleanField(default=True) 229 | user_cancelled = models.BooleanField(default=False) 230 | original_period_start = models.DateTimeField(auto_now=False, auto_now_add=False, blank=True, null=True) 231 | current_period_start = models.DateTimeField(auto_now=False, auto_now_add=False, blank=True, null=True) 232 | current_period_end = models.DateTimeField(auto_now=False, auto_now_add=False, blank=True, null=True) 233 | cancel_at_period_end = models.BooleanField(default=False) 234 | status = models.CharField(max_length=20, choices=SubscriptionStatus.choices, null=True, blank=True) 235 | timestamp = models.DateTimeField(auto_now_add=True) 236 | updated = models.DateTimeField(auto_now=True) 237 | 238 | objects = UserSubscriptionManager() 239 | 240 | def get_absolute_url(self): 241 | return reverse("user_subscription") 242 | 243 | def get_cancel_url(self): 244 | return reverse("user_subscription_cancel") 245 | 246 | @property 247 | def is_active_status(self): 248 | return self.status in [ 249 | SubscriptionStatus.ACTIVE, 250 | SubscriptionStatus.TRIALING 251 | ] 252 | 253 | @property 254 | def plan_name(self): 255 | if not self.subscription: 256 | return None 257 | return self.subscription.name 258 | 259 | def serialize(self): 260 | return { 261 | "plan_name": self.plan_name, 262 | "status": self.status, 263 | "current_period_start": self.current_period_start, 264 | "current_period_end": self.current_period_end, 265 | } 266 | 267 | @property 268 | def billing_cycle_anchor(self): 269 | """ 270 | https://docs.stripe.com/payments/checkout/billing-cycle 271 | Optional delay to start new subscription in 272 | Stripe checkout 273 | """ 274 | if not self.current_period_end: 275 | return None 276 | return int(self.current_period_end.timestamp()) 277 | 278 | def save(self, *args, **kwargs): 279 | if (self.original_period_start is None and 280 | self.current_period_start is not None 281 | ): 282 | self.original_period_start = self.current_period_start 283 | super().save(*args, **kwargs) 284 | 285 | 286 | 287 | def user_sub_post_save(sender, instance, *args, **kwargs): 288 | user_sub_instance = instance 289 | user = user_sub_instance.user 290 | subscription_obj = user_sub_instance.subscription 291 | groups_ids = [] 292 | if subscription_obj is not None: 293 | groups = subscription_obj.groups.all() 294 | groups_ids = groups.values_list('id', flat=True) 295 | if not ALLOW_CUSTOM_GROUPS: 296 | user.groups.set(groups_ids) 297 | else: 298 | subs_qs = Subscription.objects.filter(active=True) 299 | if subscription_obj is not None: 300 | subs_qs = subs_qs.exclude(id=subscription_obj.id) 301 | subs_groups = subs_qs.values_list("groups__id", flat=True) 302 | subs_groups_set = set(subs_groups) 303 | # groups_ids = groups.values_list('id', flat=True) # [1, 2, 3] 304 | current_groups = user.groups.all().values_list('id', flat=True) 305 | groups_ids_set = set(groups_ids) 306 | current_groups_set = set(current_groups) - subs_groups_set 307 | final_group_ids = list(groups_ids_set | current_groups_set) 308 | user.groups.set(final_group_ids) 309 | 310 | 311 | post_save.connect(user_sub_post_save, sender=UserSubscription) -------------------------------------------------------------------------------- /src/templates/dashboard/sidebar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | --------------------------------------------------------------------------------