├── indabom ├── __init__.py ├── tests │ ├── __init__.py │ ├── test_account_deletion.py │ └── test_views.py ├── migrations │ ├── __init__.py │ ├── 0003_checkoutsessionrecord.py │ ├── 0004_alter_checkoutsessionrecord_checkout_session_id_and_more.py │ ├── 0001_createsuperuser.py │ └── 0002_initial.py ├── templates │ ├── indabom │ │ ├── password-reset-subject.txt │ │ ├── robots.txt │ │ ├── base-bom.html │ │ ├── 500.html │ │ ├── 404.html │ │ ├── password-reset-email.html │ │ ├── password-reset-complete.html │ │ ├── account-deleted.html │ │ ├── password-reset-done.html │ │ ├── password-reset-confirm.html │ │ ├── password-reset.html │ │ ├── delete-account.html │ │ ├── checkout-cancelled.html │ │ ├── base-menu.html │ │ ├── login.html │ │ ├── install.html │ │ ├── signup.html │ │ ├── checkout-success.html │ │ ├── bom │ │ │ └── bom-modal-add-users.html │ │ ├── checkout.html │ │ ├── about.html │ │ ├── index.html │ │ ├── base.html │ │ ├── pricing.html │ │ └── product.html │ ├── admin │ │ ├── index.html │ │ └── base_site.html │ ├── explorer │ │ ├── play.html │ │ ├── query.html │ │ ├── query_list.html │ │ └── querylog_list.html │ └── bom │ │ └── subscription_panel.html ├── static │ └── indabom │ │ ├── img │ │ ├── favicon.ico │ │ ├── indabom.png │ │ ├── part-info.png │ │ ├── part-list.png │ │ └── part-info-short.png │ │ ├── fonts │ │ └── roboto │ │ │ ├── Roboto-Bold.eot │ │ │ ├── Roboto-Bold.ttf │ │ │ ├── Roboto-Thin.eot │ │ │ ├── Roboto-Thin.ttf │ │ │ ├── Roboto-Bold.woff │ │ │ ├── Roboto-Bold.woff2 │ │ │ ├── Roboto-Light.eot │ │ │ ├── Roboto-Light.ttf │ │ │ ├── Roboto-Light.woff │ │ │ ├── Roboto-Light.woff2 │ │ │ ├── Roboto-Medium.eot │ │ │ ├── Roboto-Medium.ttf │ │ │ ├── Roboto-Medium.woff │ │ │ ├── Roboto-Regular.eot │ │ │ ├── Roboto-Regular.ttf │ │ │ ├── Roboto-Thin.woff │ │ │ ├── Roboto-Thin.woff2 │ │ │ ├── Roboto-Medium.woff2 │ │ │ ├── Roboto-Regular.woff │ │ │ └── Roboto-Regular.woff2 │ │ └── css │ │ ├── indabom-colors.css │ │ └── indabom.css ├── sitemaps.py ├── wsgi.py ├── admin.py ├── models.py ├── forms.py ├── urls.py ├── views.py ├── settings.py └── stripe.py ├── .idea ├── vcs.xml ├── inspectionProfiles │ └── profiles_settings.xml ├── modules.xml ├── misc.xml ├── dataSources.xml ├── indabom.iml └── runConfigurations │ └── Run_IndaBOM.xml ├── .dockerignore ├── setup.cfg ├── Dockerfile ├── AGENTS.md ├── manage.py ├── .env_example ├── LICENSE.txt ├── Pipfile ├── README.md ├── cloudmigrate.yaml └── .gitignore /indabom/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /indabom/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /indabom/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /indabom/templates/indabom/password-reset-subject.txt: -------------------------------------------------------------------------------- 1 | IndaBOM Password Reset -------------------------------------------------------------------------------- /indabom/static/indabom/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/img/favicon.ico -------------------------------------------------------------------------------- /indabom/static/indabom/img/indabom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/img/indabom.png -------------------------------------------------------------------------------- /indabom/templates/indabom/robots.txt: -------------------------------------------------------------------------------- 1 | Sitemap: https://{{ request.get_host }}{% url 'sitemap' %} 2 | User-Agent: * 3 | Disallow: -------------------------------------------------------------------------------- /indabom/static/indabom/img/part-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/img/part-info.png -------------------------------------------------------------------------------- /indabom/static/indabom/img/part-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/img/part-list.png -------------------------------------------------------------------------------- /indabom/templates/indabom/base-bom.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | {% block html-class %}supports-dark-mode{% endblock %} -------------------------------------------------------------------------------- /indabom/static/indabom/img/part-info-short.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/img/part-info-short.png -------------------------------------------------------------------------------- /indabom/templates/indabom/500.html: -------------------------------------------------------------------------------- 1 |

Bummer! We had an error!

2 |

A message has been sent to IndaBOM and we hope to fix this soon.

-------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Bold.eot -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Bold.ttf -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Thin.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Thin.eot -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Thin.ttf -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Bold.woff -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Bold.woff2 -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Light.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Light.eot -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Light.ttf -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Light.woff -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Light.woff2 -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Medium.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Medium.eot -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Medium.ttf -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Medium.woff -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Regular.eot -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Regular.ttf -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Thin.woff -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Thin.woff2 -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Medium.woff2 -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Regular.woff -------------------------------------------------------------------------------- /indabom/static/indabom/fonts/roboto/Roboto-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpkasp/indabom/HEAD/indabom/static/indabom/fonts/roboto/Roboto-Regular.woff2 -------------------------------------------------------------------------------- /indabom/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/index.html" %} 2 | 3 | {% block userlinks %} 4 | {{ block.super }} / 5 | SQL Explorer 6 | {% endblock %} -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | ** 3 | 4 | # ... except for dependencies and source 5 | !Pipfile 6 | !Pipfile.lock 7 | !indabom/ 8 | !scripts/ 9 | !manage.py 10 | # for running tests 11 | !.env_example -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /indabom/templates/explorer/play.html: -------------------------------------------------------------------------------- 1 | {% extends "explorer/play.html" %} 2 | 3 | {% load explorer_tags i18n %} 4 | 5 | {% block sql_explorer_navlinks %} 6 | {{ block.super }} 7 | {% if can_change %} 8 |
  • {% trans "IndaBOM Admin" %}
  • 9 | {% endif %} 10 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/explorer/query.html: -------------------------------------------------------------------------------- 1 | {% extends "explorer/query.html" %} 2 | 3 | {% load explorer_tags i18n %} 4 | 5 | {% block sql_explorer_navlinks %} 6 | {{ block.super }} 7 | {% if can_change %} 8 |
  • {% trans "IndaBOM Admin" %}
  • 9 | {% endif %} 10 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/explorer/query_list.html: -------------------------------------------------------------------------------- 1 | {% extends "explorer/query_list.html" %} 2 | 3 | {% load explorer_tags i18n %} 4 | 5 | {% block sql_explorer_navlinks %} 6 | {{ block.super }} 7 | {% if can_change %} 8 |
  • {% trans "IndaBOM Admin" %}
  • 9 | {% endif %} 10 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {#{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}#} 4 | 5 | {% block branding %} 6 |

    IndaBOM Administration

    7 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/explorer/querylog_list.html: -------------------------------------------------------------------------------- 1 | {% extends "explorer/querylog_list.html" %} 2 | 3 | {% load explorer_tags i18n %} 4 | 5 | {% block sql_explorer_navlinks %} 6 | {{ block.super }} 7 | {% if can_change %} 8 |
  • {% trans "IndaBOM Admin" %}
  • 9 | {% endif %} 10 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/indabom/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% block content %} 4 |
    5 |

    Bummer! We couldn't find that page.

    6 |

    The requested URL was not found on this server.

    7 |

    Return to homepage

    8 |
    9 | {% endblock %} -------------------------------------------------------------------------------- /indabom/sitemaps.py: -------------------------------------------------------------------------------- 1 | from django.contrib import sitemaps 2 | from django.urls import reverse 3 | 4 | class StaticViewSitemap(sitemaps.Sitemap): 5 | priority = 0.8 6 | changefreq = 'weekly' 7 | 8 | def items(self): 9 | return ['index', 'about', 'install', 'learn-more', ] 10 | 11 | def location(self, item): 12 | return reverse(item) -------------------------------------------------------------------------------- /indabom/templates/indabom/password-reset-email.html: -------------------------------------------------------------------------------- 1 | {% autoescape off %} 2 | To initiate the password reset process for your {{ user.get_username }} IndaBOM Account, 3 | click the link below: 4 | 5 | {{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} 6 | 7 | If clicking the link above doesn't work, please copy and paste the URL in a new browser 8 | window instead. 9 | 10 | Sincerely, 11 | 12 | The IndaBOM Team 13 | {% endautoescape %} -------------------------------------------------------------------------------- /indabom/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for indabom 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/1.10/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", "indabom.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /.idea/dataSources.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | sqlite.xerial 6 | true 7 | org.sqlite.JDBC 8 | jdbc:sqlite:$PROJECT_DIR$/db.sqlite3 9 | 10 | 11 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length = 120 3 | combine_as_imports = true 4 | known_django = django 5 | known_first_party = bom, indabom 6 | known_third_party = pytest,setuptools 7 | sections=FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 8 | include_trailing_comma = true 9 | multi_line_output = 3 10 | lines_after_imports = 2 11 | 12 | [flake8] 13 | ignore = E226,E302,E41,W503 14 | max-line-length = 120 15 | max-complexity = 10 16 | exclude = migrations 17 | 18 | [pep8] 19 | ignore = E226,E302,E41,E402 20 | max-line-length = 120 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12.7 as BASE 2 | 3 | ENV APP_HOME /app 4 | WORKDIR $APP_HOME 5 | 6 | ARG SETTINGS_NAME 7 | 8 | ENV SETTINGS_NAME=${SETTINGS_NAME} 9 | ENV LANG C.UTF-8 10 | ENV LC_ALL C.UTF-8 11 | ENV PYTHONUNBUFFERED 1 12 | ENV PYTHONDONTWRITEBYTECODE=1 13 | 14 | RUN echo "settings name: $SETTINGS_NAME" 15 | 16 | RUN python3 -m pip install --upgrade pip 17 | RUN python3 -m pip install pipenv 18 | ADD . ${APP_HOME}/ 19 | RUN pipenv install --system --deploy 20 | 21 | CMD exec gunicorn --bind 0.0.0.0:$PORT --workers 1 --threads 8 --timeout 0 indabom.wsgi:application -------------------------------------------------------------------------------- /indabom/templates/indabom/password-reset-complete.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% load materializecss %} 4 | {% block head-title %}Password reset complete{% endblock %} 5 | {% block title %}Password reset complete{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 |
    10 |
    11 |

    Your new password has been set. You can log in now on the log in page.

    12 |
    13 |
    14 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/indabom/account-deleted.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% block title %}Account Deleted{% endblock %} 4 | 5 | {% block content %} 6 |
    7 |

    Account Deleted

    8 |

    Your account{% if username %} ({{ username }}){% endif %} has been deleted successfully.

    9 |

    We're sorry to see you go. If this was a mistake, you can create a new account at any time.

    10 | Return to Home 11 |
    12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | ## Architecture 2 | 3 | - This is a Django project built on Python 3.12. 4 | - The front end is mostly standard Django views and templates. 5 | - The front end uses MaterializeCSS, vanilla JavaScript, CSS, and JQuery. 6 | - The main database is Postgres, but SQLite is also supported for local development. 7 | - Prefer KISS and Django native implementations over custom code. 8 | - This project, IndaBOM, is mainly used as an hosted web implementation of the open source reusable app django-bom. 9 | - IndaBOM extends django-bom by adding subscription management, database management, email, terms and conditions, 10 | privacy policy, and other things specific to being the hosted managed version of django-bom. -------------------------------------------------------------------------------- /indabom/templates/indabom/password-reset-done.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% load materializecss %} 4 | {% block head-title %}Password reset sent{% endblock %} 5 | {% block title %}Password reset sent{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 |
    10 |
    11 |

    We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly.

    12 |

    If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder.

    13 |
    14 |
    15 | {% endblock %} -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "indabom.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /indabom/templates/indabom/password-reset-confirm.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% load materializecss %} 4 | {% block head-title %}Enter new password{% endblock %} 5 | {% block title %}Enter new password{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 |
    10 |
    11 |

    Please enter your new password twice so we can verify you typed it in correctly.

    12 |
    13 | {% csrf_token %} 14 |
    15 |
    16 | {{ form|materializecss }} 17 | 18 |
    19 |
    20 |
    21 |
    22 |
    23 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/indabom/password-reset.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% load materializecss %} 4 | {% block head-title %}Forgot Your Password?{% endblock %} 5 | {% block title %}Forgot Your Password?{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 |
    10 |
    11 |
    Forgot Your Password?
    12 |

    Enter your email address below, and we'll email instructions for setting a new one.

    13 | 14 |
    15 | {% csrf_token %} 16 |
    17 |
    18 | {{ form|materializecss }} 19 | 20 |
    21 |
    22 |
    23 |
    24 |
    25 | {% endblock %} -------------------------------------------------------------------------------- /.env_example: -------------------------------------------------------------------------------- 1 | # Disable Debug in production 2 | DEBUG=True 3 | 4 | SECRET_KEY=supersecretkey 5 | SENTRY_DSN=supersecretdsn 6 | DOMAIN=yoururl.com 7 | PASSWORD_NAME=supersecretadminpassword 8 | DB_HOST=supersecrethost 9 | DB_USER=secretuser 10 | DB_PASSWORD=supersecretkey 11 | DB_NAME=secretname 12 | DB_READONLY_USER=readonly 13 | DB_READONLY_PASSWORD=supersecretkey 14 | GS_DEFAULT_ACL=publicRead 15 | GS_BUCKET_NAME=secretbucketname 16 | ALLOWED_HOSTS=yoururl.com,www.yoururl.com 17 | CSRF_TRUSTED_ORIGINS=yoururl.com,www.yoururl.com 18 | 19 | OCTOPART_API_KEY=supersecretkey 20 | MOUSER_API_KEY=supersecretkey 21 | SOCIAL_AUTH_GOOGLE_OAUTH2_KEY=supersecretkey 22 | SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET=supersecretkey 23 | MAILGUN_API_KEY=supersecretkey 24 | RECAPTCHA_PUBLIC_KEY=supersecretkey 25 | RECAPTCHA_PRIVATE_KEY=supersecretkey 26 | FIXER_ACCESS_KEY=supersecretkey 27 | 28 | # Stripe test keys 29 | INDABOM_STRIPE_PRICE_ID=supersecretid 30 | STRIPE_PUBLIC_KEY=supersecretkey 31 | STRIPE_SECRET_KEY=sk_test_supersecretkey 32 | STRIPE_LIVE_MODE=0 33 | STRIPE_WEBHOOK_SECRET=whsec_supersecretkey 34 | 35 | # For Google Cloud Build 36 | DATABASE_URL=supersecretkey -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mike Kasparian 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 | -------------------------------------------------------------------------------- /.idea/indabom.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 29 | 30 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run_IndaBOM.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | django = "==5.2.8" 8 | asgiref = ">=3.8.1,<4" 9 | sqlparse = ">=0.4.4,<0.6" 10 | mysqlclient = ">=2.2,<3" 11 | psycopg2-binary = ">=2.9" 12 | pymemcache = ">=4.0" 13 | social-auth-app-django = ">=5,<6" 14 | social-auth-core = ">=4,<6" 15 | python3-openid = ">=3.2" 16 | django-anymail = {extras = ["mailgun"], version = ">=11"} 17 | django-materializecss-form = "*" 18 | django-recaptcha = ">=3,<5" 19 | django-money = ">=3,<5" 20 | py-moneyed = ">=3,<4" 21 | django-sql-explorer = ">=4,<5" 22 | google-api-python-client = ">=2.0" 23 | google-auth = ">=2.0" 24 | google-auth-httplib2 = ">=0.2" 25 | google-cloud-secret-manager = ">=2.0" 26 | google-cloud-storage = ">=2.0" 27 | httplib2 = ">=0.22" 28 | requests = ">=2.31" 29 | urllib3 = ">=2,<3" 30 | cryptography = ">=42" 31 | oauthlib = ">=3.2" 32 | requests-oauthlib = ">=1.3" 33 | sentry-sdk = ">=2.0" 34 | bleach = ">=6.0" 35 | babel = ">=2.12" 36 | packaging = ">=23.0" 37 | pyjwt = ">=2.7" 38 | pytz = ">=2024.1" 39 | gunicorn = ">=21" 40 | django-environ = "*" 41 | django-storages = {extras = ["google"], version = ">=1.14.0"} 42 | invoke = ">=1.7" 43 | jsonfield = ">=3.1.0" 44 | paramiko = ">=2.8" 45 | protobuf = ">=4.0" 46 | stripe = "*" 47 | django-bom = "*" 48 | 49 | [dev-packages] 50 | 51 | [requires] 52 | python_version = "3.12" 53 | -------------------------------------------------------------------------------- /indabom/migrations/0003_checkoutsessionrecord.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.8 on 2025-12-12 20:16 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 | 10 | dependencies = [ 11 | ('indabom', '0002_initial'), 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='CheckoutSessionRecord', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('renewal_consent', models.BooleanField(default=False)), 21 | ('renewal_consent_timestamp', models.DateTimeField(null=True)), 22 | ('renewal_consent_text', models.TextField(blank=True, null=True)), 23 | ('checkout_session_id', models.CharField(max_length=50)), 24 | ('stripe_subscription_id', models.CharField(max_length=50)), 25 | ('organization_subscription', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='indabom.organizationsubscription')), 26 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /indabom/migrations/0004_alter_checkoutsessionrecord_checkout_session_id_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.8 on 2025-12-16 18:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('indabom', '0003_checkoutsessionrecord'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='checkoutsessionrecord', 15 | name='checkout_session_id', 16 | field=models.CharField(max_length=256), 17 | ), 18 | migrations.AlterField( 19 | model_name='checkoutsessionrecord', 20 | name='stripe_subscription_id', 21 | field=models.CharField(max_length=256), 22 | ), 23 | migrations.AlterField( 24 | model_name='organizationmeta', 25 | name='stripe_customer_id', 26 | field=models.CharField(blank=True, max_length=256, null=True, unique=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='organizationsubscription', 30 | name='stripe_price_id', 31 | field=models.CharField(max_length=256), 32 | ), 33 | migrations.AlterField( 34 | model_name='organizationsubscription', 35 | name='stripe_subscription_id', 36 | field=models.CharField(db_index=True, max_length=256, unique=True), 37 | ), 38 | ] 39 | -------------------------------------------------------------------------------- /indabom/templates/indabom/delete-account.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% block title %}Delete Account{% endblock %} 4 | 5 | {% block content %} 6 |
    7 |

    Delete Your Account

    8 | {% if is_owner and organization %} 9 |
    10 | Warning: You are the owner of the organization "{{ organization.name }}". 11 | Deleting your account will also permanently delete this organization and all of its data. 12 |
    13 | {% endif %} 14 | 15 | {% if is_owner and has_active_sub %} 16 |
    17 | You currently have an active subscription. You must cancel it before you can delete your account. 18 |
    19 | Manage Subscription 20 |
    21 | {% else %} 22 |

    This action is permanent and cannot be undone. Please confirm your password to proceed.

    23 |
    24 | {% csrf_token %} 25 | {{ form.non_field_errors }} 26 |
    27 | {{ form.password.label_tag }} 28 | {{ form.password }} 29 | {% for error in form.password.errors %} 30 |
    {{ error }}
    31 | {% endfor %} 32 |
    33 | 34 | Cancel 35 |
    36 | {% endif %} 37 |
    38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /indabom/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import get_user_model 3 | from django.contrib.auth.admin import UserAdmin 4 | 5 | from .models import ( 6 | OrganizationMeta, 7 | OrganizationSubscription, 8 | CheckoutSessionRecord 9 | ) 10 | 11 | User = get_user_model() 12 | 13 | 14 | class OrganizationMetaAdmin(admin.ModelAdmin): 15 | list_display = ('organization', 'stripe_customer_id',) 16 | raw_id_fields = ('organization',) 17 | ordering = ('organization__name',) 18 | 19 | 20 | class CheckoutSessionRecordInline(admin.TabularInline): 21 | model = CheckoutSessionRecord 22 | fk_name = 'organization_subscription' 23 | raw_id_fields = ('organization_subscription',) 24 | 25 | 26 | class OrganizationSubscriptionAdmin(admin.ModelAdmin): 27 | list_display = ('organization_meta', 'quantity', 'started_by', 'status') 28 | inlines = [CheckoutSessionRecordInline] 29 | raw_id_fields = ('organization_meta', 'started_by',) 30 | 31 | 32 | class CheckoutSessionRecordAdmin(admin.ModelAdmin): 33 | list_display = ('user', 'organization_subscription', 'renewal_consent_timestamp') 34 | raw_id_fields = ('user', 'organization_subscription',) 35 | ordering = ('-renewal_consent_timestamp',) 36 | 37 | # Try to unregister User model 38 | try: 39 | admin.site.unregister(User) 40 | except admin.sites.NotRegistered: 41 | pass 42 | 43 | admin.site.register(User, UserAdmin) 44 | admin.site.register(OrganizationMeta, OrganizationMetaAdmin) 45 | admin.site.register(OrganizationSubscription, OrganizationSubscriptionAdmin) 46 | admin.site.register(CheckoutSessionRecord, CheckoutSessionRecordAdmin) 47 | -------------------------------------------------------------------------------- /indabom/migrations/0001_createsuperuser.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.contrib.auth.models import User 4 | from django.db import migrations 5 | from django.db.backends.postgresql.schema import DatabaseSchemaEditor 6 | from django.db.migrations.state import StateApps 7 | 8 | import google.auth 9 | from google.cloud import secretmanager 10 | 11 | 12 | def createsuperuser(apps: StateApps, schema_editor: DatabaseSchemaEditor) -> None: 13 | """ 14 | Dynamically create an admin user as part of a migration 15 | Password is pulled from Secret Manger (previously created as part of tutorial) 16 | """ 17 | try: 18 | client = secretmanager.SecretManagerServiceClient() 19 | 20 | # Get project value for identifying current context 21 | _, project = google.auth.default() 22 | 23 | # Retrieve the previously stored admin password 24 | PASSWORD_NAME = os.environ.get("PASSWORD_NAME", "superuser_password") 25 | name = f"projects/{project}/secrets/{PASSWORD_NAME}/versions/latest" 26 | admin_password = client.access_secret_version(name=name).payload.data.decode( 27 | "UTF-8" 28 | ) 29 | except: 30 | admin_password = 'test' 31 | 32 | # Create a new user using acquired password, stripping any accidentally stored newline characters 33 | try: 34 | User.objects.create_superuser("admin", password=admin_password.strip()) 35 | except: 36 | print('[createsuperuser] admin superuser exists already') 37 | 38 | 39 | class Migration(migrations.Migration): 40 | 41 | initial = True 42 | dependencies = [] 43 | operations = [migrations.RunPython(createsuperuser)] 44 | -------------------------------------------------------------------------------- /indabom/templates/indabom/checkout-cancelled.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% load materializecss %} 4 | {% block head-title %}Checkout cancel{% endblock %} 5 | {% block title %}Checkout cancel{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 |
    10 |
    11 |

    Checkout cancelled

    12 |
    13 | 14 |
    15 |
    16 | 17 |

    18 | You have successfully stopped the payment process. 19 | No charge has been made to your account. 20 |

    21 | 22 | 23 | home Return to Home 24 | 25 |

    26 |
    27 | 28 |
    29 |
    Need Assistance?
    30 |

    If you cancelled due to a technical issue or a question, we are here to help!

    31 | 32 | 33 | email Email Support 34 | 35 | 36 |
    37 |
    38 |
    39 |
    40 | {% endblock %} 41 | 42 | {% block script %} 43 | 44 | {% endblock script %} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # indabom 2 | A simple bill of materials web app using [django-bom](https://github.com/mpkasp/django-bom). 3 | 4 | - Master parts list with indented bill of materials 5 | - Octopart price matching 6 | - BOM Cost reporting, with sourcing recommendations 7 | 8 | ## Local Dev 9 | 10 | Install django-bom locally as a sibling directory to indabom then in this directory run: 11 | 12 | ```shell 13 | pipenv sync 14 | # Install django-bom in editable mode for local dev 15 | pipenv run pip install -e ../django-bom 16 | pipenv shell 17 | python manage.py migrate 18 | python manage.py runserver 19 | ``` 20 | 21 | ## Stripe 22 | - To sync models, call `python manage.py djstripe_sync_models` 23 | - Set up stripe cli to forward events to local webhook endpoint here using: 24 | `stripe listen --forward-to localhost:8000/stripe/webhook/` then updating DJSTRIPE_WEBHOOK_SECRET in `.env` 25 | - Test stripe using [this card info](https://stripe.com/docs/testing). 26 | 27 | ## MacOS Install 28 | If issues installing mysqlclient on Apple Silicon MacOS [try](https://github.com/Homebrew/homebrew-core/issues/130258): 29 | 30 | ```console 31 | $ export MYSQLCLIENT_LDFLAGS=$(pkg-config --libs mysqlclient) 32 | $ export MYSQLCLIENT_CFLAGS=$(pkg-config --cflags mysqlclient) 33 | $ pip install mysqlclient 34 | ``` 35 | 36 | ## Deploying 37 | 38 | To deploy simply: 39 | 40 | ``` 41 | git checkout prod 42 | git merge --ff-only master 43 | git push 44 | ``` 45 | 46 | Secrets are managed through GCP [Secret Manager](https://cloud.google.com/sdk/gcloud/reference/secrets). To update secrets for a respective environment, change to the correct project (using the gcloud PROJECT_ID) then run: 47 | 48 | ```console 49 | gcloud secrets versions add django_settings_dev --data-file=.env.dev 50 | gcloud secrets versions add django_settings --data-file=.env.prod 51 | ``` 52 | 53 | Build and deploy is run automagically using GCP [Cloud Build](https://cloud.google.com/build/docs/overview). (We tried github actions, but had trouble finding a way to run management commands thru cloud run on github actions.) -------------------------------------------------------------------------------- /indabom/templates/indabom/base-menu.html: -------------------------------------------------------------------------------- 1 | {% if user.is_superuser and user.is_authenticated %} 2 |
  • Admin
  • 3 | {% endif %} 4 | 5 | {% if user.is_authenticated %} 6 |
  • Feedback
  • 7 | {% if user.bom_profile.is_organization_owner %} 8 | {% if user.bom_profile.organization.subscription != 'F' %} 9 | {#
  • Billing
  • #} 10 | {% else %} 11 | {#
  • Billing
  • #} 12 | {% endif %} 13 | {% endif %} 14 | {% endif %} 15 | {% if user.is_authenticated %} 16 |
  • 17 | 20 | 21 | Logout 22 | 23 |
  • 24 | {% endif %} 25 | 26 | {% if not user.is_authenticated %} 27 |
  • Pricing 28 |
  • 29 |
  • Product 30 |
  • 31 |
  • About
  • 32 |
  • Sign Up
  • 33 |
  • Login
  • 34 | {% endif %} 35 | 36 | -------------------------------------------------------------------------------- /indabom/templates/indabom/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% load materializecss %} 4 | {% block head-title %}Log In{% endblock %} 5 | {% block title %}Log In to IndaBOM{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 | 10 |
    11 |
    12 |
    13 | IndaBOM Logo 14 |

    Welcome Back

    15 |
    Log in to continue managing your BOMs.
    16 |
    17 |
    18 | 19 |
    20 |
    21 |
    22 |
    23 | {% csrf_token %} 24 |
    25 |
    26 | {{ form|materializecss }} 27 |
    28 |
    29 | 30 |
    31 | 35 |
    36 |
    37 | 38 | 43 |
    44 |
    45 |
    46 | 47 |
    48 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/indabom/install.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% block title %}Install{% endblock %} 4 | {% block head-title %}Install{% endblock %} 5 | 6 | {% block content %} 7 | {% load static %} 8 |
    9 | 10 |
    11 |
    12 |

    13 | Self-Host IndaBOM 14 |

    15 |
    16 |
    17 | 18 |

    19 | Don't want to use our hosted version? No problem, 20 | IndaBOM is open source. 21 | Developers, hackers, and makers can also install the underlying bill of materials product, called django-bom, locally onto your machine, or host it on your own web server. 22 |

    23 |

    24 | Installation instructions are available on github, and to make things even easier, one of our awesome contributors, Tathagata, put together some videos to help you get started. 25 |

    26 |

    27 | 31 |

    32 |

    33 | Can't get enough? Click here for an introduction, local usage, and more! 34 |

    35 |

    36 | P.S. If you start tweaking things, we'd love to know! It's possible the whole IndaBOM community could benefit from your ideas! Shoot us an e-mail or just submit a pull request! 37 |

    38 |
    39 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/indabom/signup.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% load materializecss %} 4 | 5 | {% block title %}Sign Up For IndaBOM{% endblock %} 6 | {% block head-title %}Sign Up{% endblock %} 7 | 8 | {% block content %} 9 | {% load static %} 10 | 11 | 12 |
    13 |
    14 |
    15 | IndaBOM Logo 17 |

    Create Your Account

    18 |
    Start simplifying your BOM management today.
    19 |
    20 |
    21 | 22 |
    23 |
    24 |
    25 |
    26 | {% csrf_token %} 27 |
    28 |
    29 | {{ form|materializecss }} 30 |
    31 |
    32 | 33 |
    34 | 38 |
    39 |
    40 | 44 |
    45 |
    46 | Already have an account? Log in 47 | here 48 |
    49 |
    50 |
    51 | 52 | 53 |
    54 | 55 | {% endblock %} -------------------------------------------------------------------------------- /indabom/models.py: -------------------------------------------------------------------------------- 1 | from bom.models import Organization 2 | from django.conf import settings 3 | from django.contrib.auth.models import AbstractUser, User 4 | from django.db import models 5 | 6 | 7 | class OrganizationMeta(models.Model): 8 | organization = models.OneToOneField(Organization, db_index=True, on_delete=models.CASCADE) 9 | stripe_customer_id = models.CharField(max_length=256, blank=True, null=True, unique=True) 10 | 11 | def _organization_meta(self): 12 | return OrganizationMeta.objects.get_or_create(organization=self)[0] 13 | 14 | Organization.add_to_class('meta', _organization_meta) 15 | 16 | @property 17 | def active_subscription(self): 18 | try: 19 | return self.organizationsubscription_set.get(status='active') 20 | except OrganizationSubscription.DoesNotExist: 21 | return None 22 | except OrganizationSubscription.MultipleObjectsReturned: 23 | print("WARNING: Multiple active subscriptions found!") 24 | return self.organizationsubscription_set.filter(status='active').first() 25 | 26 | def active_user_count(self): 27 | subscription = self.active_subscription 28 | if not subscription: 29 | return 1 30 | return subscription.quantity 31 | 32 | 33 | class OrganizationSubscription(models.Model): 34 | organization_meta = models.ForeignKey(OrganizationMeta, on_delete=models.CASCADE) 35 | stripe_subscription_id = models.CharField(max_length=256, unique=True, db_index=True) 36 | stripe_price_id = models.CharField(max_length=256) 37 | status = models.CharField(max_length=20, default='incomplete') # e.g., 'active', 'canceled', 'incomplete' 38 | quantity = models.IntegerField(default=1) # The number of allowed users/seats 39 | current_period_start = models.DateTimeField() 40 | current_period_end = models.DateTimeField() 41 | started_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) 42 | 43 | def __str__(self): 44 | return f"{self.organization_meta.organization.name} - {self.status}" 45 | 46 | 47 | class CheckoutSessionRecord(models.Model): 48 | user = models.ForeignKey(User, on_delete=models.CASCADE) 49 | renewal_consent = models.BooleanField(default=False) 50 | renewal_consent_timestamp = models.DateTimeField(null=True) 51 | renewal_consent_text = models.TextField(null=True, blank=True) 52 | checkout_session_id = models.CharField(max_length=256) 53 | stripe_subscription_id = models.CharField(max_length=256) 54 | organization_subscription = models.ForeignKey(OrganizationSubscription, null=True, default=None, 55 | on_delete=models.CASCADE) 56 | -------------------------------------------------------------------------------- /indabom/migrations/0002_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.2.8 on 2025-11-13 20:52 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 | ("bom", "0049_alter_assembly_id_alter_assemblysubparts_id_and_more"), 13 | ("indabom", "0001_createsuperuser"), 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="OrganizationMeta", 20 | fields=[ 21 | ( 22 | "id", 23 | models.BigAutoField( 24 | auto_created=True, 25 | primary_key=True, 26 | serialize=False, 27 | verbose_name="ID", 28 | ), 29 | ), 30 | ( 31 | "stripe_customer_id", 32 | models.CharField(blank=True, max_length=50, null=True, unique=True), 33 | ), 34 | ( 35 | "organization", 36 | models.OneToOneField( 37 | on_delete=django.db.models.deletion.CASCADE, 38 | to="bom.organization", 39 | ), 40 | ), 41 | ], 42 | ), 43 | migrations.CreateModel( 44 | name="OrganizationSubscription", 45 | fields=[ 46 | ( 47 | "id", 48 | models.BigAutoField( 49 | auto_created=True, 50 | primary_key=True, 51 | serialize=False, 52 | verbose_name="ID", 53 | ), 54 | ), 55 | ( 56 | "stripe_subscription_id", 57 | models.CharField(db_index=True, max_length=50, unique=True), 58 | ), 59 | ("stripe_price_id", models.CharField(max_length=50)), 60 | ("status", models.CharField(default="incomplete", max_length=20)), 61 | ("quantity", models.IntegerField(default=1)), 62 | ("current_period_start", models.DateTimeField()), 63 | ("current_period_end", models.DateTimeField()), 64 | ( 65 | "organization_meta", 66 | models.ForeignKey( 67 | on_delete=django.db.models.deletion.CASCADE, 68 | to="indabom.organizationmeta", 69 | ), 70 | ), 71 | ( 72 | "started_by", 73 | models.ForeignKey( 74 | null=True, 75 | on_delete=django.db.models.deletion.SET_NULL, 76 | to=settings.AUTH_USER_MODEL, 77 | ), 78 | ), 79 | ], 80 | ), 81 | ] 82 | -------------------------------------------------------------------------------- /indabom/forms.py: -------------------------------------------------------------------------------- 1 | from bom.models import Organization 2 | from django import forms 3 | from django.contrib.auth.forms import UserCreationForm 4 | from django.contrib.auth.models import User 5 | from django.core.exceptions import ValidationError 6 | from django_recaptcha.fields import ReCaptchaField 7 | 8 | from indabom.settings import DEBUG 9 | 10 | 11 | class UserForm(UserCreationForm): 12 | first_name = forms.CharField(required=True) 13 | last_name = forms.CharField(required=True) 14 | email = forms.EmailField(required=True) 15 | captcha = ReCaptchaField(label='') 16 | 17 | def __init__(self, *args, **kwargs): 18 | super(UserForm, self).__init__(*args, **kwargs) 19 | self.fields['captcha'].required = not DEBUG 20 | if DEBUG: 21 | del self.fields['captcha'] 22 | 23 | def clean_email(self): 24 | email = self.cleaned_data['email'] 25 | exists = User.objects.filter(email__iexact=email).count() > 0 26 | if exists: 27 | raise ValidationError('An account with this email address already exists.') 28 | return email 29 | 30 | def save(self, commit=True): 31 | user = super(UserForm, self).save(commit=commit) 32 | user.email = self.cleaned_data['email'] 33 | user.first_name = self.cleaned_data['first_name'] 34 | user.last_name = self.cleaned_data['last_name'] 35 | user.save() 36 | return user 37 | 38 | 39 | class SubscriptionForm(forms.Form): 40 | price_id = forms.CharField(widget=forms.HiddenInput(), max_length=255) 41 | organization = forms.ModelChoiceField(queryset=Organization.objects.none(), widget=forms.HiddenInput()) 42 | unit = forms.IntegerField(min_value=1) 43 | renewal_consent_text = "I understand and agree that my subscription will automatically renew each month at the current rate unless canceled." 44 | renewal_consent = forms.BooleanField(required=True, label=renewal_consent_text) 45 | 46 | def __init__(self, *args, **kwargs): 47 | self.owner = kwargs.pop('owner') 48 | super(SubscriptionForm, self).__init__(*args, **kwargs) 49 | queryset = Organization.objects.filter(owner=self.owner) 50 | self.fields['organization'].queryset = queryset 51 | if queryset.count() > 0: 52 | self.fields['organization'].initial = queryset[0] 53 | 54 | 55 | class OrganizationForm(forms.Form): 56 | organization = forms.ModelChoiceField(queryset=Organization.objects.none()) 57 | 58 | def __init__(self, *args, **kwargs): 59 | self.owner = kwargs.pop('owner') 60 | super(OrganizationForm, self).__init__(*args, **kwargs) 61 | self.fields['organization'].queryset = Organization.objects.filter(owner=self.owner) 62 | 63 | 64 | class PasswordConfirmForm(forms.Form): 65 | password = forms.CharField(widget=forms.PasswordInput, label='Confirm your password') 66 | 67 | def __init__(self, *args, **kwargs): 68 | self.user = kwargs.pop('user') 69 | super().__init__(*args, **kwargs) 70 | 71 | def clean_password(self): 72 | pwd = self.cleaned_data.get('password') 73 | if not self.user.check_password(pwd): 74 | raise ValidationError('Incorrect password.') 75 | return pwd -------------------------------------------------------------------------------- /indabom/templates/indabom/checkout-success.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% load materializecss %} 4 | {% block head-title %}Checkout success{% endblock %} 5 | {% block title %}Checkout success{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 |
    10 |
    11 |
    12 | 13 | {# Icon and Primary Header #} 14 | check_circle 15 |

    Success! Welcome to IndaBOM Professional.

    16 | 17 |

    18 | Your subscription is now active. 19 | Thank you for joining the IndaBOM community! 20 |

    21 | 22 |
    23 | 24 | {# Primary CTA - Go to the App #} 25 |
    Your workspace is ready. You can now add your first 26 | user from the settings page!
    27 | 28 | 30 | send Add your first user 31 | 32 | 33 | {#
    #} 34 | {#
    #} 35 | {# Secondary Options - Key Next Steps #} 36 | {#
    Next Steps:
    #} 37 | {#

    #} 38 | {# #} 40 | {# credit_card Manage Billing & Invoices#} 41 | {# #} 42 | {#

    #} 43 | {#
    #} 44 | {#
    #} 45 | 46 |
    47 | 48 | {# Support Section #} 49 |
    50 |
    Need Help Getting Started?
    51 |

    52 | We want to ensure your setup is smooth. Don't hesitate to reach out if you have any questions. 53 |

    54 | 55 | 67 | 68 |
    69 | 70 |
    71 |
    72 |
    73 | {% endblock %} 74 | 75 | {% block script %} 76 | 77 | {% endblock script %} -------------------------------------------------------------------------------- /indabom/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls.static import static 3 | from django.contrib import admin 4 | from django.contrib.auth import views as auth_views 5 | from django.contrib.auth.decorators import login_required 6 | from django.contrib.sitemaps.views import sitemap 7 | from django.urls import path, include 8 | from django.views.generic import TemplateView 9 | 10 | from . import views 11 | from .sitemaps import StaticViewSitemap 12 | 13 | # Dictionary containing your sitemap classes 14 | sitemaps = { 15 | 'static': StaticViewSitemap(), 16 | } 17 | 18 | def trigger_error(request): 19 | division_by_zero = 1 / 0 20 | 21 | urlpatterns = [ 22 | path('', views.index, name='index'), 23 | path('bom/', include('bom.urls')), 24 | path('signup/', views.signup, name='signup'), 25 | 26 | path('admin/', admin.site.urls, name='admin'), 27 | path('login/', auth_views.LoginView.as_view( 28 | template_name='indabom/login.html', 29 | redirect_authenticated_user=True 30 | ), name='login'), 31 | path('logout/', auth_views.LogoutView.as_view(), name='logout'), 32 | 33 | path('password-reset/', auth_views.PasswordResetView.as_view(template_name='indabom/password-reset.html', 34 | from_email='no-reply@indabom.com', 35 | subject_template_name='indabom/password-reset-subject.txt', 36 | email_template_name='indabom/password-reset-email.html'), 37 | name='password_reset'), 38 | path('password-reset/done/', auth_views.PasswordResetDoneView.as_view( 39 | template_name='indabom/password-reset-done.html'), 40 | name='password_reset_done'), 41 | path('password-reset/confirm///', auth_views.PasswordResetConfirmView.as_view( 42 | template_name='indabom/password-reset-confirm.html'), 43 | name='password_reset_confirm'), 44 | path('password-reset/complete/', auth_views.PasswordResetCompleteView.as_view( 45 | template_name='indabom/password-reset-complete.html'), name='password_reset_complete'), 46 | 47 | path('about/', views.About.as_view(), name=views.About.name), 48 | path('product/', views.Product.as_view(), name=views.Product.name), 49 | path('privacy-policy/', views.PrivacyPolicy.as_view(), name=views.PrivacyPolicy.name), 50 | path('terms-and-conditions/', views.TermsAndConditions.as_view(), name=views.TermsAndConditions.name), 51 | path('install/', views.Install.as_view(), name=views.Install.name), 52 | path('pricing/', views.Pricing.as_view(), name=views.Pricing.name), 53 | path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='sitemap'), 54 | path('robots.txt', TemplateView.as_view(template_name='robots.txt', content_type="text/plain"), name="robots-file"), 55 | 56 | path('checkout/', login_required(views.Checkout.as_view()), name=views.Checkout.name), 57 | path('checkout-success/', views.CheckoutSuccess.as_view(), name=views.CheckoutSuccess.name), 58 | path('checkout-cancelled/', views.CheckoutCancelled.as_view(), name=views.CheckoutCancelled.name), 59 | path('stripe-manage/', views.stripe_manage, name='stripe-manage'), 60 | path('webhooks/stripe/', views.stripe_webhook, name='stripe-webhook'), 61 | path('account/delete/', views.delete_account, name='account-delete'), 62 | 63 | path('explorer/', include('explorer.urls')), 64 | path('sentry-debug/', trigger_error) 65 | ] 66 | 67 | handler404 = 'indabom.views.handler404' 68 | handler500 = 'indabom.views.handler500' 69 | 70 | if settings.DEBUG: 71 | media_url_prefix = settings.MEDIA_URL if settings.MEDIA_URL else '/media/' 72 | urlpatterns += static(media_url_prefix, document_root=settings.MEDIA_ROOT) 73 | -------------------------------------------------------------------------------- /indabom/templates/indabom/bom/bom-modal-add-users.html: -------------------------------------------------------------------------------- 1 | {% load materializecss %} 2 | {% load static %} 3 | 4 | {{ modal_title }} 6 | 7 | 62 | 63 | {% block script %} 64 | 69 | {% endblock %} -------------------------------------------------------------------------------- /indabom/tests/test_account_deletion.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock 2 | 3 | from bom.models import Organization, UserMeta 4 | from django.contrib.auth import get_user_model 5 | from django.test import TestCase 6 | from django.urls import reverse 7 | 8 | User = get_user_model() 9 | 10 | 11 | class AccountDeletionTests(TestCase): 12 | def setUp(self): 13 | # Create a base organization owner for some tests 14 | self.owner = User.objects.create_user(username='owner', password='ownerpass', email='owner@example.com') 15 | self.owner_meta = UserMeta.objects.create(user=self.owner, organization=None, role='A') 16 | # Owner's organization 17 | self.org = Organization.objects.create(name='Org Inc', subscription='F', owner=self.owner) 18 | self.owner_meta.organization = self.org 19 | self.owner_meta.save() 20 | 21 | def login(self, user, password): 22 | logged_in = self.client.login(username=user.username, password=password) 23 | self.assertTrue(logged_in, 'Precondition failed: could not log test user in') 24 | 25 | def test_non_owner_can_delete_account(self): 26 | # Create a regular user in the owner's org 27 | user = User.objects.create_user(username='member', password='memberpass', email='m@example.com') 28 | UserMeta.objects.create(user=user, organization=self.org, role='M') 29 | 30 | self.login(user, 'memberpass') 31 | 32 | # GET confirm page 33 | url = reverse('account-delete') 34 | resp = self.client.get(url) 35 | self.assertEqual(resp.status_code, 200) 36 | 37 | # POST with correct password 38 | resp = self.client.post(url, data={'password': 'memberpass'}) 39 | self.assertEqual(resp.status_code, 200) 40 | self.assertTemplateUsed(resp, 'indabom/account-deleted.html') 41 | self.assertFalse(User.objects.filter(id=user.id).exists()) 42 | 43 | @patch('indabom.views.stripe.get_active_subscription') 44 | def test_owner_with_active_subscription_blocked_and_redirected(self, mock_active_sub): 45 | mock_active_sub.return_value = Mock() 46 | 47 | self.login(self.owner, 'ownerpass') 48 | url = reverse('account-delete') 49 | resp = self.client.get(url) 50 | # Should redirect to stripe-manage 51 | self.assertEqual(resp.status_code, 302) 52 | self.assertEqual(resp.url, reverse('stripe-manage')) 53 | # Ensure owner and org still exist 54 | self.assertTrue(User.objects.filter(id=self.owner.id).exists()) 55 | self.assertTrue(Organization.objects.filter(id=self.org.id).exists()) 56 | 57 | @patch('indabom.views.stripe.get_active_subscription') 58 | def test_owner_without_active_subscription_deletes_org_and_user(self, mock_active_sub): 59 | mock_active_sub.return_value = None 60 | 61 | self.login(self.owner, 'ownerpass') 62 | url = reverse('account-delete') 63 | resp = self.client.get(url) 64 | self.assertEqual(resp.status_code, 200) 65 | 66 | resp = self.client.post(url, data={'password': 'ownerpass'}) 67 | self.assertEqual(resp.status_code, 200) 68 | self.assertTemplateUsed(resp, 'indabom/account-deleted.html') 69 | self.assertFalse(User.objects.filter(id=self.owner.id).exists()) 70 | self.assertFalse(Organization.objects.filter(id=self.org.id).exists()) 71 | 72 | def test_incorrect_password_does_not_delete(self): 73 | # Non-owner scenario 74 | user = User.objects.create_user(username='member2', password='memberpass2', email='m2@example.com') 75 | UserMeta.objects.create(user=user, organization=self.org, role='M') 76 | 77 | self.login(user, 'memberpass2') 78 | url = reverse('account-delete') 79 | resp = self.client.post(url, data={'password': 'wrongpass'}) 80 | self.assertEqual(resp.status_code, 200) 81 | # Should render the same confirm template due to error 82 | self.assertTemplateUsed(resp, 'indabom/delete-account.html') 83 | self.assertTrue(User.objects.filter(id=user.id).exists()) 84 | -------------------------------------------------------------------------------- /indabom/templates/bom/subscription_panel.html: -------------------------------------------------------------------------------- 1 | {# IndaBOM override for the BOM Subscription & Billing panel (flat, no cards). #} 2 | 3 |
    4 |

    5 | credit_card 6 | Subscription & Billing 7 |

    8 | 9 |
    10 |
    11 |
      12 |
    • 13 | Plan
      14 | {% if is_pro %}Pro{% else %}Free{% endif %} 15 |
    • 16 |
    • 17 | Seats
      18 | {% if is_pro %} 19 | {% with used=users_in_organization_count total=organization.subscription_quantity %} 20 | {{ used }}/{{ total }} 21 | {% if has_member_capacity %} 22 | {{ seats_available }} 23 | {% else %} 24 | 0 25 | {% endif %} 26 | {% endwith %} 27 | {% else %} 28 | 1 29 | {% endif %} 30 |
    • 31 |
    32 |
    33 |
    34 | {% with sub=organization.meta.active_subscription %} 35 | {% if sub %} 36 |
      37 |
    • 38 | Status
      39 | {{ sub.status|title }} 40 |
    • 41 |
    • 42 | Current Period
      43 | {{ sub.current_period_start|date:"M j, Y" }} – {{ sub.current_period_end|date:"M j, Y" }} 44 |
    • 45 |
    46 | {% else %} 47 |

    No active subscription found.

    48 | {% endif %} 49 | {% endwith %} 50 |
    51 |
    52 | 53 |
    54 |

    55 | Subscriptions renew automatically each billing period. You can update payment method, change seats, or cancel anytime in the billing portal. 56 | See our Terms and Privacy Policy. 57 |

    58 | 59 |
    60 | {% if is_pro %} 61 | 63 | settingsManage Billing 64 | 65 | {# #} 67 | {# group_addChange Seats#} 68 | {# #} 69 | {% else %} 70 | 72 | upgradeUpgrade to Pro 73 | 74 | {% endif %} 75 |
    76 |
    77 | -------------------------------------------------------------------------------- /cloudmigrate.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2020 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # [START cloudrun_django_cloudmigrate] 16 | steps: 17 | - id: "build image" 18 | name: "gcr.io/cloud-builders/docker" 19 | args: ["build", "-t", "${_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}", "--build-arg", "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", "."] 20 | 21 | - id: "run unittests" 22 | name: "gcr.io/cloud-builders/docker" 23 | args: 24 | [ 25 | "run", 26 | "--env", "CI=true", 27 | "--env", "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", 28 | "${_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}", 29 | "/bin/sh", 30 | "-c", 31 | "cp .env_example .env && python manage.py test" 32 | ] 33 | 34 | - id: "push image" 35 | name: "gcr.io/cloud-builders/docker" 36 | args: ["push", "${_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}"] 37 | 38 | - id: "collect static" 39 | name: "gcr.io/google-appengine/exec-wrapper" 40 | args: 41 | [ 42 | "-i", 43 | "${_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}", 44 | "-s", 45 | "${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME}", 46 | "-e", 47 | "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", 48 | "-e", 49 | "GS_BUCKET_NAME_INCLUDE_PROJECT=False", 50 | "--", 51 | "python", 52 | "manage.py", 53 | "collectstatic", 54 | "--verbosity", 55 | "2", 56 | "--no-input", 57 | ] 58 | 59 | - id: "apply migrations" 60 | name: "gcr.io/google-appengine/exec-wrapper" 61 | args: 62 | [ 63 | "-i", 64 | "${_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}", 65 | "-s", 66 | "${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME}", 67 | "-e", 68 | "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", 69 | "-e", 70 | "DB_HOST=${_DB_HOST}", 71 | "--", 72 | "python", 73 | "manage.py", 74 | "migrate", 75 | ] 76 | 77 | - id: "create cache table" 78 | name: "gcr.io/google-appengine/exec-wrapper" 79 | waitFor: [ "apply migrations" ] 80 | args: 81 | [ 82 | "-i", 83 | "${_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}", 84 | "-s", 85 | "${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME}", 86 | "-e", 87 | "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", 88 | "-e", 89 | "DB_HOST=${_DB_HOST}", 90 | "--", 91 | "python", 92 | "manage.py", 93 | "createcachetable", 94 | "indabom_cache", 95 | ] 96 | 97 | - id: "deploy image" 98 | name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' 99 | entrypoint: gcloud 100 | waitFor: [ "apply migrations", "collect static", "create cache table", "push image", "run unittests" ] 101 | args: [ 'run', 'deploy', '${_SERVICE_NAME}', '--image', '${_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}', '--region', '${_LOCATION}', '--update-env-vars', 'GITHUB_SHORT_SHA=$SHORT_SHA' ] 102 | 103 | - id: "update exchange rates via fixer" 104 | name: "gcr.io/google-appengine/exec-wrapper" 105 | args: 106 | [ 107 | "-i", 108 | "${_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}", 109 | "-s", 110 | "${PROJECT_ID}:${_REGION}:${_INSTANCE_NAME}", 111 | "-e", 112 | "SETTINGS_NAME=${_SECRET_SETTINGS_NAME}", 113 | "-e", 114 | "DB_HOST=${_DB_HOST}", 115 | "--", 116 | "python", 117 | "manage.py", 118 | "update_rates", 119 | ] 120 | 121 | substitutions: 122 | _INSTANCE_NAME: prod-02 123 | _DB_HOST: /cloudsql/my-project-1472838847531:us-central1:prod-02 124 | _REGION: us-central1 125 | # Below are defined in the cloud build triggers https://console.cloud.google.com/cloud-build/triggers 126 | # _SERVICE_NAME: indabom 127 | # _SECRET_SETTINGS_NAME: django_settings 128 | 129 | images: 130 | - "${_LOCATION}-docker.pkg.dev/$PROJECT_ID/${_REPOSITORY}/${_IMAGE}" 131 | # [END cloudrun_django_cloudmigrate] -------------------------------------------------------------------------------- /indabom/templates/indabom/checkout.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% load materializecss %} 4 | {% block head-title %}Select a plan{% endblock %} 5 | {% block title %}Select a plan{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 | 19 | 20 |
    21 | 22 |
    23 |

    Unlock IndaBOM {{ product.name }}

    24 |

    Streamline your workflow with these exclusive features:

    25 | 26 |
    27 | 28 |
    29 | group 30 |
    Multi-User Access
    31 |

    Enable secure, organization-wide BOM collaboration.

    32 |
    33 | 34 |
    35 | star_rate 36 |
    Prioritized Support
    37 |

    Get dedicated, rapid assistance from our expert team.

    38 |
    39 | 40 |
    41 | cloud_done 42 |
    Managed Software
    43 |

    Enjoy a fully managed, always up-to-date BOM environment.

    44 |
    45 | 46 |
    47 |
    48 | 49 |
    50 | 51 |
    52 |
    53 |

    54 | Upgrading Organization: {{ organization }} 55 |

    56 |
    57 | {% csrf_token %} 58 | 59 |
    60 |
    61 |
    Plan Details
    62 |
    63 |
    64 |
    65 | 66 | 67 |
    68 |
    69 | 70 |
    71 |

    72 | Monthly Cost:
    73 | ${{ human_readable_price|floatformat:"2" }} 75 |

    76 |
    77 |
    78 | 79 |
    80 |

    81 | lock Secure Checkout. 83 | Billed monthly. Cancel anytime. 84 |

    85 |
    86 |
    87 | 88 |
    89 |
    90 | {{ form|materializecss }} 91 |
    92 |
    93 | 94 |
    95 |
    96 | 101 | 102 |

    You will be redirected to our secure payment 103 | portal.

    104 |
    105 |
    106 |
    107 |
    108 |
    109 |
    110 | {% endblock %} 111 | 112 | {% block script %} 113 | 114 | 136 | {% endblock script %} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/macos,python,django,pycharm 3 | # Edit at https://www.gitignore.io/?templates=macos,python,django,pycharm 4 | 5 | ### Django ### 6 | *.log 7 | *.pot 8 | *.pyc 9 | __pycache__/ 10 | local_settings.py 11 | db.sqlite3 12 | db.sqlite3-journal 13 | media 14 | 15 | # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ 16 | # in your Git repository. Update and uncomment the following line accordingly. 17 | static/ 18 | 19 | ### Django.Python Stack ### 20 | # Byte-compiled / optimized / DLL files 21 | *.py[cod] 22 | *$py.class 23 | 24 | # C extensions 25 | *.so 26 | 27 | # Distribution / packaging 28 | .Python 29 | build/ 30 | develop-eggs/ 31 | dist/ 32 | downloads/ 33 | eggs/ 34 | .eggs/ 35 | lib/ 36 | lib64/ 37 | parts/ 38 | sdist/ 39 | var/ 40 | wheels/ 41 | pip-wheel-metadata/ 42 | share/python-wheels/ 43 | *.egg-info/ 44 | .installed.cfg 45 | *.egg 46 | MANIFEST 47 | 48 | # PyInstaller 49 | # Usually these files are written by a python script from a template 50 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 51 | *.manifest 52 | *.spec 53 | 54 | # Installer logs 55 | pip-log.txt 56 | pip-delete-this-directory.txt 57 | 58 | # Unit test / coverage reports 59 | htmlcov/ 60 | .tox/ 61 | .nox/ 62 | .coverage 63 | .coverage.* 64 | .cache 65 | nosetests.xml 66 | coverage.xml 67 | *.cover 68 | .hypothesis/ 69 | .pytest_cache/ 70 | 71 | # Translations 72 | *.mo 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # Mr Developer 107 | .mr.developer.cfg 108 | .project 109 | .pydevproject 110 | 111 | # mkdocs documentation 112 | /site 113 | 114 | # mypy 115 | .mypy_cache/ 116 | .dmypy.json 117 | dmypy.json 118 | 119 | # Pyre type checker 120 | .pyre/ 121 | 122 | ### macOS ### 123 | # General 124 | .DS_Store 125 | .AppleDouble 126 | .LSOverride 127 | 128 | # Icon must end with two \r 129 | Icon 130 | 131 | # Thumbnails 132 | ._* 133 | 134 | # Files that might appear in the root of a volume 135 | .DocumentRevisions-V100 136 | .fseventsd 137 | .Spotlight-V100 138 | .TemporaryItems 139 | .Trashes 140 | .VolumeIcon.icns 141 | .com.apple.timemachine.donotpresent 142 | 143 | # Directories potentially created on remote AFP share 144 | .AppleDB 145 | .AppleDesktop 146 | Network Trash Folder 147 | Temporary Items 148 | .apdisk 149 | 150 | ### PyCharm ### 151 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 152 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 153 | 154 | # User-specific stuff 155 | .idea/workspace.xml 156 | .idea/**/workspace.xml 157 | .idea/**/tasks.xml 158 | .idea/**/usage.statistics.xml 159 | .idea/**/dictionaries 160 | .idea/**/shelf 161 | 162 | # Generated files 163 | .idea/**/contentModel.xml 164 | 165 | # Sensitive or high-churn files 166 | .idea/**/dataSources/ 167 | .idea/**/dataSources.ids 168 | .idea/**/dataSources.local.xml 169 | .idea/**/sqlDataSources.xml 170 | .idea/**/dynamic.xml 171 | .idea/**/uiDesigner.xml 172 | .idea/**/dbnavigator.xml 173 | 174 | # Gradle 175 | .idea/**/gradle.xml 176 | .idea/**/libraries 177 | 178 | # Gradle and Maven with auto-import 179 | # When using Gradle or Maven with auto-import, you should exclude module files, 180 | # since they will be recreated, and may cause churn. Uncomment if using 181 | # auto-import. 182 | # .idea/modules.xml 183 | # .idea/*.iml 184 | # .idea/modules 185 | # *.iml 186 | # *.ipr 187 | 188 | # CMake 189 | cmake-build-*/ 190 | 191 | # Mongo Explorer plugin 192 | .idea/**/mongoSettings.xml 193 | 194 | # File-based project format 195 | *.iws 196 | 197 | # IntelliJ 198 | out/ 199 | 200 | # mpeltonen/sbt-idea plugin 201 | .idea_modules/ 202 | 203 | # JIRA plugin 204 | atlassian-ide-plugin.xml 205 | 206 | # Cursive Clojure plugin 207 | .idea/replstate.xml 208 | 209 | # Crashlytics plugin (for Android Studio and IntelliJ) 210 | com_crashlytics_export_strings.xml 211 | crashlytics.properties 212 | crashlytics-build.properties 213 | fabric.properties 214 | 215 | # Editor-based Rest Client 216 | .idea/httpRequests 217 | 218 | # Android studio 3.1+ serialized cache file 219 | .idea/caches/build_file_checksums.ser 220 | 221 | ### PyCharm Patch ### 222 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 223 | 224 | # *.iml 225 | # modules.xml 226 | # .idea/misc.xml 227 | # *.ipr 228 | 229 | # Sonarlint plugin 230 | .idea/**/sonarlint/ 231 | 232 | # SonarQube Plugin 233 | .idea/**/sonarIssues.xml 234 | 235 | # Markdown Navigator plugin 236 | .idea/**/markdown-navigator.xml 237 | .idea/**/markdown-navigator/ 238 | 239 | ### Python ### 240 | # Byte-compiled / optimized / DLL files 241 | 242 | # C extensions 243 | 244 | # Distribution / packaging 245 | 246 | # PyInstaller 247 | # Usually these files are written by a python script from a template 248 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 249 | 250 | # Installer logs 251 | 252 | # Unit test / coverage reports 253 | 254 | # Translations 255 | 256 | # Scrapy stuff: 257 | 258 | # Sphinx documentation 259 | 260 | # PyBuilder 261 | 262 | # pyenv 263 | venv 264 | # pipenv 265 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 266 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 267 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 268 | # install all needed dependencies. 269 | 270 | # celery beat schedule file 271 | 272 | # SageMath parsed files 273 | 274 | # Spyder project settings 275 | 276 | # Rope project settings 277 | 278 | # Mr Developer 279 | 280 | # mkdocs documentation 281 | 282 | # mypy 283 | 284 | # Pyre type checker 285 | 286 | # End of https://www.gitignore.io/api/macos,python,django,pycharm 287 | 288 | .env 289 | .env.* 290 | *.bak 291 | deploy.sh 292 | -------------------------------------------------------------------------------- /indabom/templates/indabom/about.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% block head-title %}About{% endblock %} 4 | 5 | {% block title %}About{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 |
    10 |
    11 |

    12 | Built by Engineers, for Engineers 13 |

    14 |

    15 | Solving the real-world frustration of overly complex PLM tools. 16 |

    17 |
    18 |
    19 | 20 |
    21 | 22 |
    23 |

    The Origin Story

    24 |
    25 |
    26 |

    27 | Hi! I'm Mike. I started IndaBOM as a side project to 29 | support the 30 | hardware development going on at my last startup, Atlas Wearables (Austin, TX, USA). 31 |

    32 |

    33 | When we first started Atlas, like many, we were just using spreadsheets. We quickly outgrew this and 34 | moved on to using a bulky Product Lifecycle Management (PLM) tool. Paying large amounts per 35 | seat, 36 | per month for a tool that we only utilized a fraction of the features got old fast. Not to 37 | mention that the added complexity required an additional part-time consultant to assist with! After 38 | our first 39 | project, we cancelled that subscription and we were back to square one. 40 |

    41 | 42 |

    43 | engineering - Mike Kasparian 44 |

    45 |
    46 |
    47 |
    48 | 49 |
    50 |
    51 |
    52 |
    53 |

    Why IndaBOM?

    54 |
    55 |
    56 | 57 |
    58 |
    59 |
    60 | rocket_launch 61 |
    More Powerful Than a Spreadsheet
    62 |

    63 | Manage complex BOM revisions, track sourcing costs, and connect supply chain data without 64 | the 65 | hassle of manual data management. Get structured data without the structure overhead. 66 |

    67 |
    68 |
    69 | 70 |
    71 |
    72 | handshake 73 |
    Simpler Than Traditional PLM
    74 |

    75 | We focus on the biggest pain points first, putting lots of thought into keeping the tool 76 | intuitive, fast, and affordable for modern hardware teams. Minimal setup, maximum utility. 77 |

    78 |
    79 |
    80 |
    81 |
    82 | 83 | 84 |
    85 | 86 |
    87 |

    How You Can Help

    88 |

    89 | After using it internally to track our parts and manage quotes, I decided to share IndaBOM with the 90 | world. If you share similar frustrations, we'd love your help! 91 |

    92 | 93 |
    94 |
    95 | 111 |
    112 |
    113 | 114 |

    115 | Or reach out to Mike directly via e-mail at mike at indabom dot com. 116 |

    117 | 118 |
    119 | 120 |
    121 |

    122 | For Privacy Policy and Terms & Conditions, see the links at the bottom of this page. 123 |

    124 | 125 | {% endblock %} -------------------------------------------------------------------------------- /indabom/templates/indabom/index.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% block head-title %}Free, Simple, Open Source BOM Management and Sourcing Tool{% endblock %} 4 | 5 | {% block head %} 6 | 44 | {% endblock head %} 45 | 46 | {% block title %}IndaBOM{% endblock %} 47 | 48 | {% block content %} 49 | {% load static %} 50 |
    51 | IndaBOM Logo 52 |

    IndaBOM

    53 |

    54 | Free, simple, open source BOM management and sourcing tool. 55 |

    56 | 57 |
    58 | 59 |
    60 |
    61 |
    62 | {% if not user.is_authenticated %} 63 | 69 | {% endif %} 70 | {% if user.is_authenticated %} 71 | 77 | {% endif %} 78 | 84 |
    85 |
    86 |
    87 |
    88 | 89 |
    90 | 91 |
    92 |
    93 |
    94 |

    95 | Built by hardware engineers to do what Excel can't 96 |

    97 |
    98 |
    99 | 100 |
    101 |
    102 |
    103 | thumb_up 104 |
    Simple
    105 |

    106 | Most PLM tools are overloaded and expensive. IndaBOM stays focused on your bill of materials, 107 | sourcing, and revision control — nothing you don't need. 108 |

    109 |
    110 |
    111 |
    112 |
    113 |
    114 | Mouser logo 116 | Google Drive logo 118 |
    119 |
    Integrated
    120 |

    121 | Connect with modern tools like Google Drive for easy file sharing and Mouser to pull the latest 122 | sourcing data and optimize cost. 123 |

    124 |
    125 |
    126 |
    127 |
    128 | settings 129 |
    Open Source
    130 |

    131 | Built on Django and Python. Transparent, extensible, and community-driven so you can adapt it to 132 | your workflow. 133 |

    134 |
    135 |
    136 |
    137 | 138 |
    139 |
    140 | IndaBOM Part List 142 |
    143 |
    144 | 145 |
    146 |
    147 |

    148 | Organize all your parts in one place. Auto-generate internal part numbers with a scalable numbering 149 | scheme. Track multiple manufacturer and seller options, quotes, and prices per part. 150 |

    151 |
    152 |
    153 | 154 |
    155 |
    156 | {% if not user.is_authenticated %} 157 | 159 | Start For Free 160 | 161 | {% else %} 162 | 164 | Enter 165 | 166 | {% endif %} 167 |
    168 |
    169 |
    170 | {% endblock %} -------------------------------------------------------------------------------- /indabom/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, MagicMock 2 | 3 | from bom.models import Organization 4 | from django.contrib.auth import get_user_model 5 | from django.http import HttpResponseRedirect 6 | from django.test import TestCase, Client 7 | from django.urls import reverse 8 | 9 | User = get_user_model() 10 | 11 | 12 | class IndabomViewTests(TestCase): 13 | def setUp(self): 14 | self.client = Client() 15 | self.user = User.objects.create_user(username="kasper", email="kasper@ghost.com", password="pw12345") 16 | self.org = Organization.objects.create(name="Org1", owner=self.user) 17 | profile = self.user.bom_profile() 18 | profile.organization = self.org 19 | profile.save() 20 | self.other_user = User.objects.create_user(username="bob", email="bob@example.com", password="pw12345") 21 | other_profile = self.other_user.bom_profile() 22 | other_profile.organization = self.org 23 | other_profile.save() 24 | 25 | def _set_owner_to_other_user(self): 26 | self.org.owner = self.other_user 27 | self.org.save() 28 | 29 | # --- index --- 30 | def test_index_anonymous_ok(self): 31 | resp = self.client.get(reverse("index")) 32 | self.assertEqual(resp.status_code, 200) 33 | 34 | def test_index_authenticated_redirects_home(self): 35 | self.client.force_login(self.user) 36 | resp = self.client.get(reverse("index")) 37 | self.assertEqual(resp.status_code, 302) 38 | self.assertEqual(resp.url, reverse("bom:home")) 39 | 40 | # --- signup --- 41 | def test_signup_get(self): 42 | resp = self.client.get(reverse("signup")) 43 | self.assertEqual(resp.status_code, 200) 44 | 45 | def test_signup_post_creates_and_logs_in(self): 46 | data = { 47 | "username": "charlie", 48 | "password1": "secretpassword", 49 | "password2": "secretpassword", 50 | "email": "charlie@example.com", 51 | "first_name": "Char", 52 | "last_name": "Lie", 53 | } 54 | resp = self.client.post(reverse("signup"), data) 55 | self.assertEqual(resp.status_code, 302) 56 | self.assertEqual(resp.url, reverse("bom:home")) 57 | 58 | # --- static content pages --- 59 | def test_static_pages_ok(self): 60 | pages = [ 61 | "about", 62 | "product", 63 | "privacy-policy", 64 | "terms-and-conditions", 65 | "install", 66 | "pricing", 67 | "checkout-success", 68 | "checkout-cancelled", 69 | ] 70 | for name in pages: 71 | resp = self.client.get(reverse(name)) 72 | self.assertEqual((name, resp.status_code), (name, 200)) 73 | 74 | # --- checkout (GET) branches --- 75 | def test_checkout_get_non_owner_redirects(self): 76 | self.client.force_login(self.user) 77 | self._set_owner_to_other_user() 78 | 79 | resp = self.client.get(reverse("checkout"), HTTP_REFERER=reverse("bom:settings")) 80 | self.assertEqual(resp.status_code, 302) 81 | self.assertEqual(resp.url, reverse("bom:settings")) 82 | 83 | @patch("indabom.views.stripe.get_active_subscription", return_value=MagicMock()) 84 | def test_checkout_get_already_subscribed_redirects_manage(self, _mock_active): 85 | self.client.force_login(self.user) 86 | 87 | resp = self.client.get(reverse("checkout")) 88 | self.assertEqual(resp.status_code, 302) 89 | self.assertEqual(resp.url, reverse("stripe-manage")) 90 | 91 | @patch("indabom.views.stripe.get_product") 92 | @patch("indabom.views.stripe.get_price") 93 | @patch("indabom.views.stripe.get_active_subscription", return_value=None) 94 | def test_checkout_get_renders_when_ok(self, _mock_active, mock_price, mock_product): 95 | self.client.force_login(self.user) 96 | 97 | mock_price.return_value = MagicMock(unit_amount=500, product="prod_1") 98 | mock_product.return_value = MagicMock() 99 | 100 | resp = self.client.get(reverse("checkout")) 101 | self.assertEqual(resp.status_code, 200) 102 | self.assertIn(b"checkout", resp.content.lower()) 103 | 104 | # --- checkout (POST) --- 105 | @patch("indabom.views.stripe.subscribe", return_value=MagicMock(id="sess_1", url="/ok")) 106 | def test_checkout_post_valid_calls_subscribe(self, mock_subscribe): 107 | self.client.force_login(self.user) 108 | 109 | data = { 110 | "price_id": "price_123", 111 | "organization": str(self.org.pk), 112 | "unit": 2, 113 | "renewal_consent": True 114 | } 115 | resp = self.client.post(reverse("checkout"), data) 116 | self.assertEqual(resp.status_code, 303) 117 | self.assertEqual(resp.url, "/ok") 118 | mock_subscribe.assert_called_once() 119 | 120 | data = { 121 | "price_id": "price_123", 122 | "organization": str(self.org.pk), 123 | "unit": 2, 124 | "renewal_consent": False 125 | } 126 | resp = self.client.post(reverse("checkout"), data) 127 | self.assertEqual(resp.status_code, 200) 128 | mock_subscribe.assert_called_once() 129 | 130 | # --- stripe_manage --- 131 | @patch("indabom.views.stripe.manage_subscription", return_value=HttpResponseRedirect("/portal")) 132 | def test_stripe_manage_owner_delegates(self, mock_manage): 133 | self.client.force_login(self.user) 134 | 135 | resp = self.client.get(reverse("stripe-manage")) 136 | self.assertEqual(resp.status_code, 302) 137 | self.assertEqual(resp.url, "/portal") 138 | mock_manage.assert_called_once() 139 | 140 | def test_stripe_manage_non_owner_redirects(self): 141 | self.client.force_login(self.user) 142 | self._set_owner_to_other_user() 143 | 144 | resp = self.client.get(reverse("stripe-manage")) 145 | self.assertEqual(resp.status_code, 302) 146 | self.assertEqual(resp.url, reverse("bom:settings") + "#organization") 147 | 148 | resp = self.client.get(reverse("stripe-manage"), HTTP_REFERER=reverse("bom:settings")) 149 | self.assertEqual(resp.status_code, 302) 150 | self.assertEqual(resp.url, reverse("bom:settings")) 151 | 152 | # --- delete_account --- 153 | @patch("indabom.views.stripe.get_active_subscription", return_value=MagicMock()) 154 | def test_delete_account_owner_with_active_subscription_redirects(self, _mock_active): 155 | self.client.force_login(self.user) 156 | 157 | resp = self.client.get(reverse("account-delete")) 158 | # GET shows page, but the branch for active subscription is on POST/flow? In code it redirects immediately. 159 | # View checks and redirects before rendering. 160 | self.assertEqual(resp.status_code, 302) 161 | self.assertEqual(resp.url, reverse("stripe-manage")) 162 | 163 | def test_delete_account_post_deletes_user_when_allowed(self): 164 | # Make user NOT owner to allow deletion 165 | self._set_owner_to_other_user() 166 | username = self.user.username 167 | self.client.force_login(self.user) 168 | 169 | resp = self.client.post(reverse("account-delete"), {"password": "pw12345"}) 170 | self.assertEqual(resp.status_code, 200) 171 | # Template shows username in content 172 | self.assertIn(username.encode('utf-8'), resp.content) 173 | 174 | # --- stripe_webhook wrapper --- 175 | def test_stripe_webhook_get_not_allowed(self): 176 | resp = self.client.get(reverse("stripe-webhook")) 177 | self.assertEqual(resp.status_code, 405) 178 | 179 | @patch("indabom.views.stripe.stripe_webhook", side_effect=Exception("boom")) 180 | def test_stripe_webhook_failure_returns_500(self, _mock_delegate): 181 | resp = self.client.post(reverse("stripe-webhook")) 182 | self.assertEqual(resp.status_code, 500) 183 | -------------------------------------------------------------------------------- /indabom/static/indabom/css/indabom-colors.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Base Background/Foreground Colors */ 3 | --color-background: #fff; 4 | --color-background-surface: #eaeaea; 5 | --color-background-surface-hover: #dfdfdf; 6 | --color-text: #121212; 7 | --color-text-grey: #757575; 8 | 9 | /* Brand Colors (Teal Palette) - does not change in dark mode */ 10 | --color-brand: #00695C; 11 | --color-brand-surface: #B2DFDB; 12 | --color-brand-contrast: #004D40; 13 | 14 | /* Accent Colors (Green Palette) - does not change in dark mode */ 15 | --color-accent: #66BB6A; 16 | --color-accent-surface: #C8E6C9; 17 | --color-accent-contrast: #4CAF50; 18 | 19 | /* Text on Brand Colors */ 20 | --color-text-on-brand: #fff; 21 | --color-text-on-brand-grey: #BDBDBD; 22 | --color-link-on-brand: #EEE; 23 | 24 | /* Primary Colors (Teal Palette) */ 25 | --color-primary: #00695C; 26 | --color-primary-surface: #B2DFDB; 27 | --color-primary-contrast: #004D40; 28 | 29 | /* Secondary Colors (Green Palette) */ 30 | --color-secondary: #66BB6A; 31 | --color-secondary-surface: #C8E6C9; 32 | --color-secondary-contrast: #4CAF50; 33 | 34 | /* State Colors */ 35 | --color-error: #F44336; 36 | --color-error-surface: #FFEBEE; 37 | --color-error-contrast: #D32F2F; 38 | 39 | --color-warning: #FFEB3B; 40 | --color-warning-surface: #FFF9C4; 41 | --color-warning-contrast: #FFC107; 42 | 43 | --color-info: #dce775; 44 | --color-info-surface: #f0f4c3; 45 | --color-info-contrast: #aed581; 46 | 47 | /* Other one-offs */ 48 | --color-table-stripe: #f5f5f5; 49 | } 50 | 51 | @media (prefers-color-scheme: dark) { 52 | .supports-dark-mode { 53 | /* Swap the base colors for high contrast */ 54 | --color-background: #121212; 55 | --color-background-surface: #424242; 56 | --color-background-surface-hover: #303030; 57 | --color-text: #fff; 58 | --color-text-grey: #BDBDBD; 59 | 60 | /* Primary Colors - Adjusting values for dark background visibility */ 61 | --color-primary: #81C784; 62 | --color-primary-surface: #004D40; 63 | --color-primary-contrast: #B2DFDB; 64 | 65 | /* Secondary Colors */ 66 | --color-secondary: #00695C; 67 | --color-secondary-surface: #004D40; 68 | --color-secondary-contrast: #C8E6C9; 69 | 70 | /* State Colors */ 71 | --color-error: #F44336; 72 | --color-error-surface: #D32F2F; 73 | --color-error-contrast: #FFEBEE; 74 | 75 | --color-warning: #FFEB3B; 76 | --color-warning-surface: #FFC107; 77 | --color-warning-contrast: #FFF9C4; 78 | 79 | --color-info: #aed581; 80 | --color-info-surface: #dce775; 81 | --color-info-contrast: #f0f4c3; 82 | 83 | /* Other one-offs */ 84 | --color-table-stripe: #1c1c1c; 85 | } 86 | } 87 | 88 | body { 89 | background-color: var(--color-background); 90 | color: var(--color-text); 91 | } 92 | 93 | /* Foreground Color Utilities */ 94 | .text-brand {color: var(--color-brand);} 95 | .text-accent {color: var(--color-accent);} 96 | .text-primary {color: var(--color-primary);} 97 | .text-secondary {color: var(--color-secondary);} 98 | .text-grey {color: var(--color-text-grey);} 99 | .text-warning {color: var(--color-warning);} 100 | .text-error {color: var(--color-error);} 101 | .text-on-background {color: var(--color-background);} 102 | .text-on-brand {color: var(--color-text-on-brand);} 103 | .text-on-brand-grey {color: var(--color-text-on-brand-grey);} 104 | .link-on-brand {color: var(--color-link-on-brand);} 105 | 106 | 107 | /* Background Color Utilities */ 108 | .bg-brand {background-color: var(--color-brand);} 109 | .bg-accent {background-color: var(--color-accent);} 110 | .bg-primary {background-color: var(--color-primary);} 111 | .bg-secondary {background-color: var(--color-secondary);} 112 | .bg-surface-brand {background-color: var(--color-brand-surface);} 113 | .bg-surface-accent {background-color: var(--color-accent-surface);} 114 | .bg-warning {background-color: var(--color-warning);} 115 | .bg-error {background-color: var(--color-error);} 116 | 117 | /* Materialize Overrides */ 118 | body, .tabs, input, .btn-flat { 119 | color: var(--color-text); 120 | } 121 | 122 | body, .tabs { 123 | background: var(--color-background); 124 | } 125 | 126 | .btn-flat, .btn-flat:focus { 127 | background-color: var(--color-background-surface); 128 | } 129 | 130 | .btn-primary, .btn-primary:focus { 131 | background-color: var(--color-primary); 132 | } 133 | 134 | .btn-primary:hover { 135 | background-color: var(--color-primary); 136 | } 137 | 138 | .modal, .modal .modal-footer { 139 | background-color: var(--color-background-surface); 140 | } 141 | 142 | .input-field input[type=search]:focus:not(.browser-default) { 143 | background: transparent; 144 | color: var(--color-background-surface); 145 | } 146 | 147 | .dropdown-content { 148 | background: var( --color-background); 149 | border: 1px solid var(--color-background-surface); 150 | color: var(--color-text); 151 | } 152 | 153 | .select-dropdown li.disabled, .select-dropdown li.disabled > span, .select-dropdown li.optgroup { 154 | color: var(--color-text-grey); 155 | } 156 | 157 | .dropdown-content li:hover, .dropdown-content li.active { 158 | background-color: var(--color-background-surface-hover) !important; 159 | } 160 | 161 | .dropdown-content li > a, .dropdown-content li > span { 162 | color: var(--color-primary); 163 | } 164 | 165 | table.striped > tbody > tr:nth-child(odd) { 166 | background-color: var(--color-table-stripe); 167 | } 168 | 169 | .collection.with-header .collection-header, .collection .collection-item { 170 | background-color: var(--color-background); 171 | border-bottom: 1px solid var(--color-background-surface); 172 | } 173 | 174 | .collapsible-header { 175 | background-color: var(--color-background); 176 | } 177 | 178 | .tablesorter-header { 179 | background-color: var(--color-background-surface) !important; 180 | } 181 | 182 | table.highlight > tbody > tr:hover { 183 | background-color: var(--color-background-surface-hover); 184 | } 185 | 186 | table.treetable tr.branch { 187 | background-color: var(--color-background-surface-hover) !important; 188 | } 189 | 190 | .card { 191 | background-color: var(--color-background); 192 | border: 1px solid var(--color-background-surface); 193 | } 194 | 195 | .card-panel { 196 | background-color: var(--color-background); 197 | border: 1px solid var(--color-background-surface); 198 | } 199 | 200 | input:focus { 201 | border-bottom: 1px solid var(--color-primary) !important; 202 | box-shadow: 0 1px 0 0 var(--color-primary) !important; 203 | } 204 | 205 | label.active { 206 | color: var(--color-primary) !important; 207 | } 208 | 209 | .input-field .prefix.active { 210 | color: var(--color-primary); 211 | } 212 | 213 | .tabs .tab a { 214 | color: var(--color-secondary); 215 | } 216 | 217 | .tabs .tab a:hover, .tabs .tab a.active { 218 | background-color: transparent; 219 | color: var(--color-primary); 220 | } 221 | 222 | .tabs .indicator { 223 | background-color: var(--color-primary); 224 | } 225 | 226 | .tabs .tab.disabled a, .tabs .tab.disabled a:hover { 227 | color: var(--color-text-grey); 228 | } 229 | 230 | .alert { 231 | padding: 5px; 232 | background-color: var(--color-info-surface); /* light green lighten-4 */ 233 | margin: 5px; 234 | border: solid var(--color-info) 1px; /* light green lighten-2 */ 235 | color: var(--color-text); 236 | } 237 | 238 | .alert-error { 239 | color: var(--color-text); 240 | background-color: var(--color-error-surface); /* red */ 241 | border: solid var(--color-error) 1px; /* red lighten-2 */ 242 | } 243 | 244 | .alert-warning { 245 | color: var(--color-text); 246 | background-color: var(--color-warning-surface); /* lime lighten-4 */ 247 | border: solid var(--color-warning) 1px; /* lime lighten-2 */ 248 | } 249 | -------------------------------------------------------------------------------- /indabom/static/indabom/css/indabom.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Roboto'); 2 | @import url('indabom-colors.css'); 3 | 4 | html { 5 | line-height: normal; 6 | } 7 | 8 | main { 9 | min-height: 100vh; 10 | } 11 | 12 | p { 13 | font-size: 1.2em; 14 | font-weight: 300; 15 | } 16 | 17 | li { 18 | font-size: 1.2em; 19 | font-weight: 300; 20 | } 21 | 22 | h1 { 23 | font-size: 3.5rem; 24 | font-weight: 300; 25 | } 26 | 27 | h2 { 28 | font-size: 3.0rem; 29 | font-weight: 300; 30 | } 31 | 32 | h3 { 33 | font-size: 1.6rem; 34 | font-weight: 300; 35 | } 36 | 37 | h4 { 38 | font-size: 1.3rem; 39 | font-weight: 300; 40 | } 41 | 42 | h5 { 43 | font-size: 1.1rem; 44 | } 45 | 46 | .btn { 47 | border-radius: 20px; 48 | } 49 | 50 | .brand-logo { 51 | padding-left: 15px !important; 52 | } 53 | 54 | .tabs .tab a:focus, .tabs .tab a:focus.active { 55 | background-color: transparent; 56 | } 57 | 58 | .page-footer { 59 | margin-top: 50px; 60 | } 61 | 62 | .promo-caption { 63 | font-size: 1.7rem; 64 | font-weight: 500; 65 | margin-top: 5px; 66 | margin-bottom: 0; 67 | } 68 | 69 | input[type=text]:focus:not([readonly]) { 70 | box-shadow: 0 0px 0 0; 71 | } 72 | 73 | /* Cards */ 74 | .card { 75 | border-radius: 12px; 76 | } 77 | 78 | .card-panel { 79 | border-radius: 12px; 80 | } 81 | 82 | .card-panel .white { 83 | background-color: transparent !important; 84 | } 85 | 86 | .feature-card { 87 | transition: all 0.2s ease; 88 | display: flex; 89 | flex-direction: column; 90 | overflow: hidden; 91 | } 92 | 93 | .feature-card:hover { 94 | transform: translateY(-5px); 95 | box-shadow: 0 8px 17px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); 96 | } 97 | 98 | .highlighted-card { 99 | border: 3px solid #66BB6A; 100 | box-shadow: 0 6px 15px rgba(102, 187, 106, 0.6) !important; 101 | } 102 | 103 | .row-equal-height-cards { 104 | display: flex; 105 | flex-wrap: wrap; 106 | } 107 | 108 | .row-equal-height-cards > .col { 109 | display: flex; /* Make the column flexible */ 110 | } 111 | 112 | .row-equal-height-cards .card-panel { 113 | flex-grow: 1; /* Tell the card to fill all available space */ 114 | } 115 | 116 | .row-equal-height-cards .card { 117 | flex-grow: 1; /* Tell the card to fill all available space */ 118 | } 119 | 120 | /* Large padding inside focused card content (e.g., forms) */ 121 | .card-pad-lg { 122 | padding: 30px; 123 | } 124 | 125 | /* Style for the footer area inside a card (e.g., "Already have an account?" link) */ 126 | .card-footer-separator { 127 | margin-top: 20px; 128 | padding-top: 15px; 129 | border-top: 1px solid #eee; 130 | } 131 | 132 | /* Screen size media queries */ 133 | @media only screen and (min-width: 600px) { 134 | .inline-flex-medium { 135 | display: inline-flex; 136 | } 137 | } 138 | 139 | /* Override materialize */ 140 | input.invalid:not([type]), input.invalid:not([type]):focus, input.invalid[type=text]:not(.browser-default), input.invalid[type=text]:not(.browser-default):focus, input.invalid[type=password]:not(.browser-default), input.invalid[type=password]:not(.browser-default):focus, input.invalid[type=email]:not(.browser-default), input.invalid[type=email]:not(.browser-default):focus, input.invalid[type=url]:not(.browser-default), input.invalid[type=url]:not(.browser-default):focus, input.invalid[type=time]:not(.browser-default), input.invalid[type=time]:not(.browser-default):focus, input.invalid[type=date]:not(.browser-default), input.invalid[type=date]:not(.browser-default):focus, input.invalid[type=datetime]:not(.browser-default), input.invalid[type=datetime]:not(.browser-default):focus, input.invalid[type=datetime-local]:not(.browser-default), input.invalid[type=datetime-local]:not(.browser-default):focus, input.invalid[type=tel]:not(.browser-default), input.invalid[type=tel]:not(.browser-default):focus, input.invalid[type=number]:not(.browser-default), input.invalid[type=number]:not(.browser-default):focus, input.invalid[type=search]:not(.browser-default), input.invalid[type=search]:not(.browser-default):focus, textarea.materialize-textarea.invalid, textarea.materialize-textarea.invalid:focus, .select-wrapper.invalid > input.select-dropdown, .select-wrapper.invalid > input.select-dropdown:focus { 141 | border-bottom: 1px solid #a5d6a7; 142 | -webkit-box-shadow: none; 143 | box-shadow: none; 144 | } 145 | 146 | #toast-container .toast { 147 | cursor: pointer; 148 | } 149 | 150 | #toast-container .toast::after { 151 | font-family: 'Material Icons'; 152 | content: "close"; 153 | -webkit-font-feature-settings: 'liga'; 154 | /*color: #dd2c00;*/ 155 | color: white; 156 | font-size: 1.5rem; 157 | font-weight: 300; 158 | float: right; 159 | padding-left: 3rem; 160 | } 161 | 162 | /* Printer friendly */ 163 | .printer-show { 164 | display: none; 165 | } 166 | 167 | @media print { 168 | .printer-show { 169 | display: block; 170 | } 171 | } 172 | 173 | /* 4. Feature List Styling (Custom list inside a card) */ 174 | /* Standardizes the look of custom lists used in feature, benefit, or pricing blocks. */ 175 | .feature-list { 176 | flex-grow: 1; /* Key for pushing card-action to the bottom on feature cards */ 177 | padding: 0; 178 | margin: 0; 179 | border: none; 180 | } 181 | 182 | .feature-list li { 183 | padding: 10px 0; 184 | border-bottom: 1px solid #eee; 185 | } 186 | 187 | .feature-list li.collection-item { 188 | /* Override Materialize default horizontal padding for centering */ 189 | padding-left: 0 !important; 190 | padding-right: 0 !important; 191 | } 192 | 193 | /* 5. Centering Content in List Items */ 194 | /* General utility to center text/icons within a flex container. */ 195 | .feature-item-content { 196 | display: flex; 197 | align-items: center; 198 | justify-content: center; 199 | } 200 | 201 | /* --- Vertical Spacing Utilities --- */ 202 | 203 | /* Standard section padding for page wrapper (e.g., around the main container) */ 204 | .page-v-pad-lg { 205 | padding-top: 50px; 206 | padding-bottom: 20px; 207 | } 208 | 209 | /* Margin Top Utilities */ 210 | .mt-0 { 211 | margin-top: 0; 212 | } 213 | 214 | .mt-sm { /* Small margin top (e.g., between logo and heading) */ 215 | margin-top: 15px; 216 | } 217 | 218 | .mt-md { /* Medium margin top (e.g., after the main heading block) */ 219 | margin-top: 25px; 220 | } 221 | 222 | .mt-lg { /* Large margin top (e.g., starting a major new row/component block) */ 223 | margin-top: 40px; 224 | } 225 | 226 | .mt-xl { /* Extra-large margin top (e.g., before the main CTA button) */ 227 | margin-top: 30px; 228 | } 229 | 230 | /* Margin Bottom Utilities */ 231 | .mb-0 { /* Remove default Materialize bottom margin on rows */ 232 | margin-bottom: 0; 233 | } 234 | 235 | .mb-sm { 236 | margin-bottom: 15px; /* Used below the logo */ 237 | } 238 | 239 | /* --- Component Specific Styles --- */ 240 | 241 | /* Consistent style for separators directly under main headings */ 242 | .header-divider { 243 | max-width: 300px; 244 | } 245 | 246 | /* Responsive CTA Button Sizing (used for primary sign-up/login buttons) */ 247 | .btn-cta-responsive { 248 | width: 100%; 249 | max-width: 300px; 250 | } 251 | 252 | /* --- Forms --- */ 253 | form p { 254 | font-size: .8rem; 255 | } 256 | 257 | form li { 258 | font-size: .8rem; 259 | } 260 | 261 | /* --- Settings & Admin Layout Refinements (flat, Material-inspired) --- */ 262 | /* These rules lightly standardize the look of sectioned settings pages 263 | (e.g., Settings tabs) without impacting card-based marketing pages. */ 264 | 265 | /* Section spacing */ 266 | .container-app .section { 267 | padding-top: 16px; 268 | padding-bottom: 16px; 269 | } 270 | 271 | /* Consistent section headers with optional leading/trailing icons */ 272 | .container-app .section-title { 273 | display: flex; 274 | align-items: center; 275 | gap: 8px; 276 | margin: 0 0 8px 0; /* no extra space above headings inside sections */ 277 | letter-spacing: 0.2px; 278 | } 279 | 280 | .container-app .section-title .material-icons { 281 | line-height: 1; 282 | } 283 | 284 | /* Trailing service logos (e.g., Google Drive) line up nicely with text */ 285 | .container-app .section-title img { 286 | height: 1.8rem; 287 | margin-left: 6px; 288 | vertical-align: middle; 289 | } 290 | 291 | /* When a service logo/image is used as a leading icon, adjust spacing */ 292 | .container-app .section-title img:first-child { 293 | margin-left: 0; 294 | margin-right: 6px; 295 | } 296 | 297 | /* Dividers provide comfortable breathing room between sections */ 298 | .container-app .divider { 299 | margin: 16px 0; 300 | } 301 | 302 | /* Paragraph sizing inside settings sections (denser than marketing pages) */ 303 | .container-app .section p { 304 | margin: 0 0 12px 0; 305 | } 306 | 307 | /* Tabs sit a bit away from content below (e.g., in Settings) */ 308 | .container-app .tabs { 309 | margin-bottom: 12px; 310 | } 311 | 312 | /* Flat collections (used for quick key-value lists) */ 313 | .container-app .collection.z-depth-0 { 314 | border: none; 315 | } 316 | 317 | .container-app .collection.z-depth-0 .collection-item { 318 | border: none; 319 | padding-left: 0; /* align with surrounding text blocks */ 320 | } 321 | 322 | /* Button group spacing in right-aligned action rows */ 323 | .container-app .right-align .btn, 324 | .container-app .right-align .btn-flat { 325 | margin-left: 4px; 326 | } -------------------------------------------------------------------------------- /indabom/templates/indabom/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% load static %} 4 | 5 | 6 | 7 | 8 | 19 | 20 | 21 | 22 | 23 | 25 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 39 | 40 | 41 | 42 | {% block no-head-title %}{% block head-title %}{% endblock head-title %} - {% endblock no-head-title %}IndaBOM 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 70 | 71 | 72 | 73 | 74 | 75 | {% block head %} 76 | {% endblock head %} 77 | 78 | 79 | 80 | 81 | 82 | 99 | 100 | 101 | 105 | 106 |

    IndaBOM | www.indabom.com{% if organization %} | {{ organization }}{% endif %}{% if title %} | {{ title|safe }}{% endif %}

    107 | 108 |
    109 | {% block main %} 110 | {% endblock main %} 111 | {% if title %} 112 |
    113 |

    {{ title|safe }}

    114 |
    115 | {% endif %} 116 | {% block content %}{% endblock %} 117 | {% block action-btn %}{% endblock %} 118 |
    119 | 120 |
    121 |
    122 |
    123 |
    124 |

    Help IndaBOM Grow

    125 |

    We hope you are as excited about IndaBOM as we are! Please reach out if 126 | you'd like to learn more, or help take down expensive PLM at info@indabom.com

    128 |
    129 |
    130 |

    Links

    131 | 142 |
    143 |
    144 |
    145 | 146 | Star 147 |
    148 |
    149 | 154 |
    155 | 156 | 171 | 172 | 181 | 182 | {% block script %}{% endblock script %} 183 | 184 | -------------------------------------------------------------------------------- /indabom/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Optional 4 | from urllib.error import URLError 5 | 6 | from bom.models import Organization, UserMeta 7 | from django.contrib import messages 8 | from django.contrib.auth import login 9 | from django.contrib.auth import logout 10 | from django.contrib.auth.decorators import login_required 11 | from django.http import ( 12 | HttpResponseNotFound, 13 | HttpResponseRedirect, 14 | HttpResponseServerError, 15 | HttpResponse, 16 | ) 17 | from django.shortcuts import render, redirect 18 | from django.template.response import TemplateResponse 19 | from django.urls import reverse 20 | from django.views.decorators.csrf import csrf_exempt 21 | from django.views.generic.base import TemplateView 22 | 23 | from indabom import stripe 24 | from indabom.forms import SubscriptionForm, UserForm, PasswordConfirmForm 25 | from indabom.models import CheckoutSessionRecord 26 | from indabom.settings import DEBUG, INDABOM_STRIPE_PRICE_ID 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | def index(request): 31 | if request.user.is_authenticated: 32 | return HttpResponseRedirect(reverse('bom:home')) 33 | else: 34 | return TemplateResponse(request, 'indabom/index.html', locals()) 35 | 36 | 37 | def handler404(request, exception=None, *args, **kwargs): 38 | return HttpResponseNotFound(render(request, 'indabom/404.html', status=404, context=locals())) 39 | 40 | 41 | def handler500(request): 42 | return HttpResponseServerError(render(request, 'indabom/500.html', status=500)) 43 | 44 | 45 | def signup(request): 46 | name = 'signup' 47 | 48 | if request.method == 'POST': 49 | form = UserForm(request.POST) 50 | try: 51 | if form.is_valid(): 52 | new_user = form.save() 53 | login(request, new_user, backend='django.contrib.auth.backends.ModelBackend') 54 | return HttpResponseRedirect(reverse('bom:home')) 55 | except URLError: 56 | if DEBUG and len(form.errors.keys()) == 1 and 'captcha' in form.errors.keys(): 57 | new_user = form.save() 58 | login(request, new_user, backend='django.contrib.auth.backends.ModelBackend') 59 | return HttpResponseRedirect(reverse('bom:home')) 60 | else: 61 | form = UserForm() 62 | 63 | return TemplateResponse(request, 'indabom/signup.html', locals()) 64 | 65 | 66 | class IndabomTemplateView(TemplateView): 67 | name = None 68 | 69 | def __init__(self, *args, **kwargs): 70 | super().__init__(**kwargs) 71 | self.template_name = f'indabom/{self.name}.html' 72 | 73 | def get_context_data(self, *args, **kwargs): 74 | context = super(IndabomTemplateView, self).get_context_data(**kwargs) 75 | context['name'] = self.name 76 | return context 77 | 78 | 79 | class About(IndabomTemplateView): 80 | name = 'about' 81 | 82 | 83 | class Product(IndabomTemplateView): 84 | name = 'product' 85 | 86 | 87 | class PrivacyPolicy(IndabomTemplateView): 88 | name = 'privacy-policy' 89 | 90 | 91 | class TermsAndConditions(IndabomTemplateView): 92 | name = 'terms-and-conditions' 93 | 94 | 95 | class Install(IndabomTemplateView): 96 | name = 'install' 97 | 98 | 99 | class Pricing(IndabomTemplateView): 100 | name = 'pricing' 101 | 102 | 103 | class Checkout(IndabomTemplateView): 104 | name = 'checkout' 105 | initial = {} 106 | form_class = SubscriptionForm 107 | user_profile: Optional[UserMeta] = None 108 | organization: Optional[Organization] = None 109 | 110 | def setup(self, request, *args, **kwargs): 111 | super().setup(request, *args, **kwargs) 112 | self.user_profile = request.user.bom_profile() 113 | self.organization = self.user_profile.organization 114 | 115 | def get_context_data(self, *args, **kwargs): 116 | context = super(Checkout, self).get_context_data(**kwargs) 117 | form = self.form_class(owner=self.request.user) 118 | del form.fields["unit"] 119 | 120 | stripe_price = stripe.get_price(INDABOM_STRIPE_PRICE_ID, self.request) 121 | 122 | context.update({ 123 | 'organization': self.organization, 124 | 'user_profile': self.user_profile, 125 | 'price': stripe_price, 126 | 'form': form, 127 | 'product': None, 128 | }) 129 | 130 | if stripe_price is None: 131 | return context 132 | 133 | context.update({'human_readable_price': stripe_price.unit_amount / 100}) 134 | 135 | stripe_product = stripe.get_product(stripe_price.product, self.request) 136 | if stripe_product is None: 137 | return context 138 | 139 | context.update({'product': stripe_product}) 140 | form.initial = {'price_id': INDABOM_STRIPE_PRICE_ID} 141 | 142 | return context 143 | 144 | def get(self, request, *args, **kwargs): 145 | user_profile = self.user_profile 146 | organization: Optional[Organization] = self.organization 147 | 148 | if not user_profile.is_organization_owner(): 149 | if organization is not None and organization.owner is not None: 150 | messages.error(request, 151 | f'Only your organization owner {organization.owner.email} can upgrade the organization.') 152 | else: 153 | messages.error(request, f'You must be an organization owner to upgrade your organization.') 154 | return HttpResponseRedirect(reverse('bom:settings')) 155 | 156 | try: 157 | if stripe.get_active_subscription(organization) is not None: 158 | messages.info(request, "You already have an active subscription.") 159 | return HttpResponseRedirect(reverse('stripe-manage')) 160 | except Exception: # Catch any exceptions from database lookup 161 | messages.error(request, 162 | f'There was an error getting your organization. Please contact info@indabom.com with this error message.') 163 | return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('bom:settings') + '#organization')) 164 | 165 | return render(request, self.template_name, self.get_context_data()) 166 | 167 | def post(self, request, *args, **kwargs): 168 | form = self.form_class(request.POST, owner=request.user) 169 | 170 | if form.is_valid(): 171 | organization = form.cleaned_data['organization'] 172 | price_id = form.cleaned_data['price_id'] 173 | quantity = form.cleaned_data['unit'] 174 | checkout_session_record = CheckoutSessionRecord.objects.create( 175 | user=request.user, 176 | renewal_consent=form.cleaned_data['renewal_consent'], 177 | renewal_consent_text=form.renewal_consent_text, 178 | renewal_consent_timestamp=datetime.now(), 179 | ) 180 | 181 | checkout_session = stripe.subscribe(request, price_id, organization, quantity, checkout_session_record) 182 | if checkout_session is None: 183 | return HttpResponseRedirect(reverse('bom:settings')) 184 | checkout_session_record.checkout_session_id = checkout_session.id 185 | checkout_session_record.save() 186 | response = redirect(checkout_session.url) 187 | response.status_code = 303 188 | return response 189 | 190 | if "unit" in form.fields: 191 | del form.fields["unit"] 192 | 193 | context = self.get_context_data() 194 | context['form'] = form 195 | return render(request, self.template_name, context) 196 | 197 | 198 | class CheckoutSuccess(IndabomTemplateView): 199 | name = 'checkout-success' 200 | 201 | 202 | class CheckoutCancelled(IndabomTemplateView): 203 | name = 'checkout-cancelled' 204 | 205 | 206 | @login_required 207 | def stripe_manage(request): 208 | user_profile = request.user.bom_profile() 209 | organization = user_profile.organization 210 | 211 | if user_profile.is_organization_owner() and organization is not None: 212 | return stripe.manage_subscription(request, organization) 213 | 214 | messages.warning(request, "Can't manage a subscription for an organization you don't own.") 215 | return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('bom:settings') + '#organization')) 216 | 217 | 218 | @login_required 219 | def delete_account(request): 220 | user = request.user 221 | user_profile = user.bom_profile() 222 | organization = user_profile.organization 223 | 224 | # Determine owner and subscription status 225 | is_owner = user_profile.is_organization_owner() 226 | has_active_sub = False 227 | if is_owner and organization is not None: 228 | try: 229 | has_active_sub = stripe.get_active_subscription(organization) is not None 230 | except Exception: 231 | messages.error(request, 232 | 'There was an error checking your subscription. Please contact support at info@indabom.com.') 233 | return HttpResponseRedirect(reverse('bom:settings') + '#organization') 234 | 235 | # Block owners with active subscription 236 | if is_owner and has_active_sub: 237 | messages.error(request, 'You have an active subscription. Please cancel it first. Redirecting you to manage your subscription.') 238 | return HttpResponseRedirect(reverse('stripe-manage')) 239 | 240 | if request.method == 'POST': 241 | form = PasswordConfirmForm(request.POST, user=user) 242 | if form.is_valid(): 243 | # If owner (and not actively subscribed), delete their organization first 244 | if is_owner and organization is not None: 245 | org_name = organization.name 246 | organization.delete() 247 | messages.info(request, f'Organization "{org_name}" was deleted as part of account deletion.') 248 | 249 | # Delete the user and logout 250 | username = user.username 251 | user.delete() 252 | logout(request) 253 | return TemplateResponse(request, 'indabom/account-deleted.html', {'username': username}) 254 | else: 255 | messages.error(request, 'Incorrect password, please try again.') 256 | else: 257 | form = PasswordConfirmForm(user=user) 258 | 259 | context = { 260 | 'form': form, 261 | 'is_owner': is_owner, 262 | 'organization': organization, 263 | 'has_active_sub': has_active_sub, 264 | } 265 | return TemplateResponse(request, 'indabom/delete-account.html', context) 266 | 267 | 268 | @csrf_exempt 269 | def stripe_webhook(request): 270 | # Check if the request method is POST, which is required for webhooks 271 | if request.method != 'POST': 272 | return HttpResponse('Method not allowed', status=405) 273 | 274 | try: 275 | return stripe.stripe_webhook(request) 276 | except Exception as e: 277 | logger.error(f"Failed to process Stripe webhook: {e}", exc_info=True) 278 | return HttpResponse('Webhook failed to process.', status=500) 279 | -------------------------------------------------------------------------------- /indabom/templates/indabom/pricing.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% block title %}Pricing{% endblock %} 4 | {% block head-title %}Pricing{% endblock %} 5 | 6 | {% block content %} 7 | {% load static %} 8 | 9 |
    10 |

    11 | Get Started with IndaBOM 12 |

    13 |

    14 | The essential BOM lifecycle and supply chain collaboration platform. 15 |

    16 |
    17 | 18 | 19 |
    20 |
    21 | 22 |
    23 |
    Free
    24 |
    25 |

    $0

    26 |

    One User / Always Free

    27 |
    28 | 29 |
    31 |

    32 | Perfect for solo professionals and evaluation. 33 |

    34 |
    35 | 36 |
      37 |
    • 38 |
      39 | check Unlimited BOMs 41 |
      42 |
    • 43 |
    • 44 |
      45 | check Fully managed 47 |
      48 |
    • 49 |
    • 50 |
      51 | remove Single User Access 53 |
      54 |
    • 55 |
    • 56 |
      57 | close Multi-User Organizations 59 |
      60 |
    • 61 |
    • 62 |
      63 | close Priority Support 65 |
      66 |
    • 67 |
    68 | 69 | 75 |
    76 |
    77 | 78 | 79 |
    80 |
    81 |
    82 |
    Professional
    83 |
    84 |

    $35

    85 |

    Per User / Month

    86 |
    87 | 88 |
    90 |

    91 | Full enterprise control and customization. 92 |

    93 |
    94 | 95 |
      96 |
    • 97 |
      98 | check Unlimited BOMs 100 |
      101 |
    • 102 |
    • 103 |
      104 | check Fully managed 106 |
      107 |
    • 108 |
    • 109 |
      110 | check Multi User Access 112 |
      113 |
    • 114 |
    • 115 |
      116 | check Multi-User Organizations 118 |
      119 |
    • 120 |
    • 121 |
      122 | check Priority Support 124 |
      125 |
    • 126 |
    127 | 128 | 134 |
    135 |
    136 | 137 | 138 |
    139 | 140 |
    141 | 142 |
    143 |
    Self Hosted
    144 |
    145 |

    $0

    146 |

    Per User / Always Free

    147 |
    148 | 149 |
    151 |

    152 | For the techies. 153 |

    154 |
    155 | 156 | 157 |
      158 |
    • 159 |
      160 | check Unlimited BOMs 162 |
      163 |
    • 164 |
    • 165 |
      166 | close 168 | Fully managed 169 |
      170 |
    • 171 |
    • 172 |
      173 | check Multi User Access 175 |
      176 |
    • 177 |
    • 178 |
      179 | check Multi-User Organizations 181 |
      182 |
    • 183 |
    • 184 |
      185 | close 187 | Priority Support 188 |
      189 |
    • 190 |
    191 | 192 | 199 |
    200 |
    201 | 202 |
    203 | 204 |
    205 |
    Need a custom solution for 50+ users?
    206 | 207 | Contact us at info@indabom.com 208 | 209 |
    210 | 211 |
    212 | {% endblock %} -------------------------------------------------------------------------------- /indabom/settings.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | import os 4 | import subprocess 5 | from pathlib import Path 6 | from urllib.parse import urlparse 7 | 8 | import environ 9 | import google.auth 10 | import google.auth.exceptions 11 | import sentry_sdk 12 | from google.cloud import secretmanager 13 | from sentry_sdk.integrations.django import DjangoIntegration 14 | 15 | # --- Basic Setup and Environment Loading --- 16 | ## Basic Setup and Environment Loading 17 | 18 | logger = logging.getLogger(__name__) 19 | BASE_DIR = Path(__file__).resolve().parent.parent 20 | 21 | # Setup django-environ 22 | env = environ.Env() 23 | env_file = BASE_DIR / '.env' 24 | 25 | # Determine project ID from Google Cloud credentials 26 | project_id = None 27 | try: 28 | # This call is often used to establish the project context 29 | _, os.environ['GOOGLE_CLOUD_PROJECT'] = google.auth.default() 30 | project_id = os.environ.get("GOOGLE_CLOUD_PROJECT") 31 | logger.info(f'Project ID: {project_id}') 32 | except (google.auth.exceptions.DefaultCredentialsError, 33 | google.auth.exceptions.RefreshError, 34 | google.auth.exceptions.TransportError, 35 | TypeError) as e: 36 | logger.warning(f'Could not determine Google Cloud Project ID: {e}') 37 | 38 | # Load environment variables 39 | if os.path.isfile(env_file): 40 | logger.info(f'Found local .env file: {env_file}') 41 | env.read_env(env_file) 42 | elif project_id: 43 | # Load secrets from Google Secret Manager 44 | client = secretmanager.SecretManagerServiceClient() 45 | settings_name = os.environ.get("SETTINGS_NAME", "django_settings") 46 | logger.info(f'Fetching secrets from {settings_name} in project {project_id}') 47 | try: 48 | name = f"projects/{project_id}/secrets/{settings_name}/versions/latest" 49 | payload = client.access_secret_version(name=name).payload.data.decode("UTF-8") 50 | env.read_env(io.StringIO(payload)) 51 | except Exception as e: 52 | logger.error(f"Error accessing secret manager: {e}") 53 | raise 54 | else: 55 | # Only raise if essential for running, otherwise default to minimal settings 56 | logger.warning("No local .env or GOOGLE_CLOUD_PROJECT detected. Running with minimal environment.") 57 | 58 | 59 | # --- Core Django Settings and Secret Variables --- 60 | ## Core Django Settings and Secret Variables 61 | 62 | # Variables loaded via env.str/env.bool 63 | DEBUG = env.bool("DEBUG", False) 64 | ENVIRONMENT = env.str("ENVIRONMENT", "unset") 65 | GITHUB_SHA = env.str("GITHUB_SHA", env.str("GITHUB_SHORT_SHA", "unknown")) 66 | LOCALHOST = env.bool("LOCALHOST", False) 67 | SECRET_KEY = env.str("SECRET_KEY") 68 | 69 | # Domain and Host Configuration 70 | DOMAIN = env.str("DOMAIN", 'localhost:8000') 71 | ROOT_DOMAIN = f'https://{DOMAIN}' if not LOCALHOST else f'http://{DOMAIN}' 72 | 73 | CLOUDRUN_SERVICE_URL = env("CLOUDRUN_SERVICE_URL", default=None) 74 | 75 | # List settings 76 | ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) 77 | CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[]) 78 | 79 | # Append Cloud Run specific settings 80 | if CLOUDRUN_SERVICE_URL: 81 | logger.info(f'Cloud Run Service URL detected: {CLOUDRUN_SERVICE_URL}') 82 | parsed_url = urlparse(CLOUDRUN_SERVICE_URL) 83 | ALLOWED_HOSTS.append(parsed_url.netloc) 84 | CSRF_TRUSTED_ORIGINS.append(CLOUDRUN_SERVICE_URL) 85 | SECURE_SSL_REDIRECT = True 86 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 87 | 88 | # Normalize CSRF_TRUSTED_ORIGINS to include a scheme (required since Django 4+) 89 | _SCHEMES = ("http://", "https://") 90 | _normalized = [] 91 | for origin in CSRF_TRUSTED_ORIGINS: 92 | if not origin: 93 | continue 94 | o = origin.strip() 95 | if not o.startswith(_SCHEMES): 96 | o = f"https://{o}" # Default to https 97 | _normalized.append(o) 98 | CSRF_TRUSTED_ORIGINS = list(dict.fromkeys(_normalized)) 99 | 100 | if DEBUG or LOCALHOST: 101 | if "http://localhost:8000" not in CSRF_TRUSTED_ORIGINS: 102 | CSRF_TRUSTED_ORIGINS.append("http://localhost:8000") 103 | 104 | # --- Application Configuration --- 105 | ## Application Configuration 106 | 107 | INSTALLED_APPS = [ 108 | 'indabom', 109 | 'bom.apps.BomConfig', 110 | 111 | # Django contrib apps 112 | 'django.contrib.admin', 113 | 'django.contrib.auth', 114 | 'django.contrib.contenttypes', 115 | 'django.contrib.sessions', 116 | 'django.contrib.messages', 117 | 'django.contrib.staticfiles', 118 | 'django.contrib.sitemaps', 119 | 120 | # Third-party apps 121 | 'storages', 122 | 'social_django', 123 | 'materializecssform', 124 | 'djmoney', 125 | 'djmoney.contrib.exchange', 126 | 'django_recaptcha', 127 | 'anymail', 128 | 'explorer', 129 | ] 130 | 131 | MIDDLEWARE = [ 132 | 'django.middleware.security.SecurityMiddleware', 133 | 'django.contrib.sessions.middleware.SessionMiddleware', 134 | 'django.middleware.common.CommonMiddleware', 135 | 'django.middleware.csrf.CsrfViewMiddleware', 136 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 137 | 'django.contrib.messages.middleware.MessageMiddleware', 138 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 139 | 'social_django.middleware.SocialAuthExceptionMiddleware', 140 | ] 141 | 142 | ROOT_URLCONF = 'indabom.urls' 143 | 144 | # --- Templates and WSGI --- 145 | ## Templates and WSGI 146 | 147 | TEMPLATES = [ 148 | { 149 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 150 | 'DIRS': [BASE_DIR / 'indabom' / 'templates' / 'indabom'], 151 | 'APP_DIRS': True, 152 | 'OPTIONS': { 153 | 'context_processors': [ 154 | 'django.template.context_processors.debug', 155 | 'django.template.context_processors.request', 156 | 'django.contrib.auth.context_processors.auth', 157 | 'django.contrib.messages.context_processors.messages', 158 | 'django.template.context_processors.media', 159 | 'bom.context_processors.bom_config', 160 | ], 161 | }, 162 | }, 163 | ] 164 | 165 | WSGI_APPLICATION = 'indabom.wsgi.application' 166 | 167 | # --- Authentication and Authorization --- 168 | ## Authentication and Authorization 169 | 170 | AUTHENTICATION_BACKENDS = ( 171 | 'social_core.backends.google.GoogleOAuth2', 172 | 'bom.auth_backends.OrganizationPermissionBackend', 173 | 'django.contrib.auth.backends.ModelBackend', 174 | ) 175 | 176 | AUTH_PASSWORD_VALIDATORS = [ 177 | {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, 178 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, 179 | {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, 180 | {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, 181 | ] 182 | 183 | LOGIN_URL = '/login/' 184 | LOGOUT_URL = '/logout/' 185 | LOGIN_REDIRECT_URL = '/bom/' 186 | LOGOUT_REDIRECT_URL = '/' 187 | 188 | # Social Auth 189 | SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = env.str("SOCIAL_AUTH_GOOGLE_OAUTH2_KEY") 190 | SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = env.str("SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET") 191 | SOCIAL_AUTH_GOOGLE_OAUTH2_SCOPE = ['email', 'profile', 'https://www.googleapis.com/auth/drive'] 192 | SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS = { 193 | 'access_type': 'offline', 194 | 'approval_prompt': 'force' 195 | } 196 | SOCIAL_AUTH_REDIRECT_IS_HTTPS = not DEBUG 197 | SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/bom/settings?tab_anchor=file' 198 | SOCIAL_AUTH_DISCONNECT_REDIRECT_URL = '/bom/settings?tab_anchor=file' 199 | SOCIAL_AUTH_LOGIN_ERROR_URL = '/' 200 | 201 | # --- Database and Cache --- 202 | ## Database and Cache 203 | 204 | if os.environ.get("GOOGLE_CLOUD_PROJECT") and not LOCALHOST and not env.bool("CI", False): 205 | logger.info(f"Using Cloud-based database configuration.") 206 | 207 | DATABASES = { 208 | 'default': { 209 | 'ENGINE': 'django.db.backends.mysql', 210 | 'HOST': env.str("DB_HOST"), 211 | 'NAME': env.str("DB_NAME"), 212 | 'USER': env.str("DB_USER"), 213 | 'PASSWORD': env.str("DB_PASSWORD"), 214 | }, 215 | 'readonly': { 216 | 'ENGINE': 'django.db.backends.mysql', 217 | 'HOST': env.str("DB_HOST"), 218 | 'NAME': env.str("DB_NAME"), 219 | 'USER': env.str("DB_READONLY_USER"), 220 | 'PASSWORD': env.str("DB_READONLY_PASSWORD"), 221 | } 222 | } 223 | else: 224 | logger.info("Using Localhost SQLite database.") 225 | DATABASES = { 226 | 'default': { 227 | 'ENGINE': 'django.db.backends.sqlite3', 228 | 'NAME': BASE_DIR / 'db.sqlite3', 229 | }, 230 | 'readonly': { 231 | 'ENGINE': 'django.db.backends.sqlite3', 232 | 'NAME': BASE_DIR / 'db.sqlite3', 233 | } 234 | } 235 | 236 | if os.environ.get("GOOGLE_CLOUD_PROJECT") and not LOCALHOST: 237 | CACHES = { 238 | 'default': { 239 | 'BACKEND': 'django.core.cache.backends.db.DatabaseCache', 240 | 'LOCATION': 'indabom_cache', 241 | 'OPTIONS': { 242 | 'MAX_ENTRIES': 5000 243 | } 244 | } 245 | } 246 | else: 247 | CACHES = { 248 | 'default': { 249 | 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', 250 | } 251 | } 252 | 253 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 254 | 255 | # --- Static and Media Files (Storage) --- 256 | ## Static and Media Files (Storage) 257 | 258 | STATIC_ROOT = BASE_DIR / "static" 259 | MEDIA_ROOT = BASE_DIR / "media" 260 | STATIC_URL = "/static/" 261 | MEDIA_URL = "/media/" 262 | GS_BUCKET_NAME = env.str("GS_BUCKET_NAME", None) 263 | GS_DEFAULT_ACL = env.str("GS_DEFAULT_ACL", 'publicRead') 264 | 265 | STORAGES = { 266 | "default": { 267 | "BACKEND": "django.core.files.storage.FileSystemStorage", 268 | }, 269 | "staticfiles": { 270 | "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", 271 | }, 272 | } 273 | 274 | if GS_BUCKET_NAME and not env.bool("CI", False): 275 | logger.info(f"Using Google Cloud Storage bucket: {GS_BUCKET_NAME}") 276 | 277 | GCS_STORAGE_BACKEND = "storages.backends.gcloud.GoogleCloudStorage" 278 | 279 | STORAGES["default"] = { 280 | "BACKEND": GCS_STORAGE_BACKEND, 281 | "OPTIONS": {"bucket_name": GS_BUCKET_NAME}, 282 | } 283 | STORAGES["staticfiles"] = { 284 | "BACKEND": GCS_STORAGE_BACKEND, 285 | "OPTIONS": {"bucket_name": GS_BUCKET_NAME}, 286 | } 287 | # When using GCS for media, you typically don't set MEDIA_URL 288 | MEDIA_URL = None 289 | 290 | 291 | # --- Email and Internationalization --- 292 | ## Email and Internationalization 293 | 294 | # Anymail/Mailgun Configuration 295 | MAILGUN_API_KEY = env.str("MAILGUN_API_KEY") 296 | MAILGUN_SENDER_DOMAIN = os.environ.get('MAILGUN_SENDER_DOMAIN', f'mg.{DOMAIN}') 297 | 298 | ANYMAIL = { 299 | "MAILGUN_API_KEY": MAILGUN_API_KEY, 300 | "MAILGUN_SENDER_DOMAIN": MAILGUN_SENDER_DOMAIN, 301 | } 302 | EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" 303 | DEFAULT_FROM_EMAIL = "info@indabom.com" 304 | SERVER_EMAIL = "info@indabom.com" 305 | 306 | # Internationalization 307 | LANGUAGE_CODE = 'en-us' 308 | TIME_ZONE = 'UTC' 309 | USE_I18N = True 310 | USE_TZ = True 311 | 312 | # --- Logging --- 313 | ## Logging Configuration 314 | 315 | LOGGING = { 316 | 'version': 1, 317 | 'disable_existing_loggers': False, 318 | 'formatters': { 319 | 'timestamp': { 320 | 'format': "[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s" 321 | }, 322 | }, 323 | 'handlers': { 324 | 'mail_admins': { 325 | 'class': 'django.utils.log.AdminEmailHandler', 326 | 'level': 'ERROR', 327 | 'include_html': True, 328 | 'formatter': 'timestamp', 329 | }, 330 | 'console': { 331 | 'class': 'logging.StreamHandler', 332 | 'formatter': 'timestamp', 333 | }, 334 | }, 335 | 'loggers': { 336 | # Root logger 337 | '': { 338 | 'level': 'INFO', 339 | 'handlers': ['console'], 340 | }, 341 | # Your app loggers 342 | 'indabom': { 343 | 'handlers': ['console'], 344 | 'level': 'INFO', 345 | 'propagate': False 346 | }, 347 | 'bom': { 348 | 'handlers': ['console'], 349 | 'level': 'INFO', 350 | 'propagate': False 351 | }, 352 | }, 353 | } 354 | 355 | # --- Third-party Specific Settings --- 356 | ## Third-party Specific Settings 357 | 358 | # Sentry.io config 359 | SENTRY_DSN = env.str("SENTRY_DSN") 360 | if not LOCALHOST and SENTRY_DSN and SENTRY_DSN != 'supersecretdsn': 361 | sentry_sdk.init( 362 | dsn=SENTRY_DSN, 363 | integrations=[DjangoIntegration()], 364 | release=GITHUB_SHA, 365 | environment=ENVIRONMENT, 366 | traces_sample_rate=1.0 if DEBUG else 0.5, 367 | debug=DEBUG, 368 | ) 369 | 370 | # SQL Explorer 371 | EXPLORER_CONNECTIONS = {'Default': 'readonly'} 372 | EXPLORER_DEFAULT_CONNECTION = 'readonly' 373 | 374 | # Django Money / Fixer 375 | CURRENCY_DECIMAL_PLACES = 4 376 | EXCHANGE_BACKEND = 'djmoney.contrib.exchange.backends.FixerBackend' 377 | FIXER_ACCESS_KEY = env.str("FIXER_ACCESS_KEY") 378 | 379 | # Stripe 380 | INDABOM_STRIPE_PRICE_ID = env.str("INDABOM_STRIPE_PRICE_ID") 381 | STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", False) 382 | STRIPE_PUBLIC_KEY = env.str("STRIPE_PUBLIC_KEY") 383 | STRIPE_SECRET_KEY = env.str("STRIPE_SECRET_KEY") 384 | STRIPE_TEST_PUBLIC_KEY = env.str("STRIPE_TEST_PUBLIC_KEY", STRIPE_PUBLIC_KEY) # Fallback to live if test not provided 385 | STRIPE_TEST_SECRET_KEY = env.str("STRIPE_TEST_SECRET_KEY", STRIPE_SECRET_KEY) # Fallback to live if test not provided 386 | STRIPE_WEBHOOK_SECRET = env.str("STRIPE_WEBHOOK_SECRET") 387 | 388 | # reCAPTCHA 389 | RECAPTCHA_PRIVATE_KEY = env.str("RECAPTCHA_PRIVATE_KEY") 390 | RECAPTCHA_PUBLIC_KEY = env.str("RECAPTCHA_PUBLIC_KEY") 391 | 392 | # Other API Keys 393 | OCTOPART_API_KEY = env.str("OCTOPART_API_KEY") 394 | MOUSER_API_KEY = env.str("MOUSER_API_KEY") 395 | 396 | # BOM Config 397 | BOM_CONFIG = { 398 | 'base_template': 'base-bom.html', 399 | 'octopart_api_key': env.str("OCTOPART_API_KEY"), 400 | 'mouser_api_key': env.str("MOUSER_API_KEY"), 401 | 'standalone_mode': False, 402 | 'admin_dashboard': { 403 | 'enable_autocomplete': False, 404 | 'page_size': 50, 405 | } 406 | } -------------------------------------------------------------------------------- /indabom/stripe.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timezone 3 | from typing import Optional 4 | 5 | import stripe 6 | from bom.constants import SUBSCRIPTION_TYPE_FREE, SUBSCRIPTION_TYPE_PRO 7 | from bom.models import Organization 8 | from django.contrib import messages 9 | from django.core.mail import send_mail 10 | from django.db import transaction 11 | from django.http import HttpRequest, HttpResponse, HttpResponseRedirect 12 | from django.shortcuts import redirect 13 | from django.urls import reverse 14 | 15 | from indabom.models import CheckoutSessionRecord 16 | from indabom.settings import ROOT_DOMAIN, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET 17 | from .models import OrganizationMeta, OrganizationSubscription 18 | 19 | logger = logging.getLogger(__name__) 20 | stripe.api_key = STRIPE_SECRET_KEY 21 | 22 | 23 | def _to_dt(ts): 24 | # Stripe sends seconds since epoch; adjust if already a datetime 25 | return ts if isinstance(ts, datetime) else datetime.fromtimestamp(ts, tz=timezone.utc) 26 | 27 | 28 | def get_organization_meta_or_404(organization: Organization) -> OrganizationMeta: 29 | try: 30 | return organization.meta() 31 | except OrganizationMeta.DoesNotExist: 32 | return OrganizationMeta.objects.get_or_create(organization=organization)[0] 33 | 34 | 35 | def get_active_subscription(organization: Organization) -> Optional[OrganizationSubscription]: 36 | """Retrieves the active local subscription object for the organization.""" 37 | try: 38 | return OrganizationSubscription.objects.get( 39 | organization_meta__organization=organization, 40 | status='active' 41 | ) 42 | except OrganizationSubscription.DoesNotExist: 43 | return None 44 | except OrganizationSubscription.MultipleObjectsReturned as e: 45 | logger.error( 46 | f"Multiple active subscriptions found for organization {organization.name} ({organization.id}). Please contact support@indabom.com.") 47 | raise e 48 | 49 | 50 | # --- Stripe helpers 51 | def get_price(price_id: str, request: HttpRequest) -> Optional[stripe.Price]: 52 | try: 53 | price = stripe.Price.retrieve(price_id) 54 | except stripe.StripeError as e: 55 | messages.error(request, f"Error fetching subscription details: {str(e)}. Please contact administrator.") 56 | return None 57 | except Exception: 58 | messages.error(request, "A critical error occurred while connecting to the payment service.") 59 | return None 60 | return price 61 | 62 | 63 | def get_product(product_id: str, request: HttpRequest) -> Optional[stripe.Product]: 64 | try: 65 | product = stripe.Product.retrieve(product_id) 66 | except stripe.StripeError as e: 67 | messages.error(request, f"Error fetching subscription details: {str(e)}. Please contact administrator.") 68 | return None 69 | except Exception: 70 | messages.error(request, "A critical error occurred while connecting to the payment service.") 71 | return None 72 | return product 73 | 74 | 75 | # --- Core Subscription Functions --- 76 | 77 | def create_org_customer_if_needed(organization: Organization) -> str: 78 | """Ensures the organization has a Stripe Customer ID and returns it.""" 79 | org_meta = get_organization_meta_or_404(organization) 80 | 81 | if org_meta.stripe_customer_id: 82 | try: 83 | stripe.Customer.retrieve(org_meta.stripe_customer_id) 84 | return org_meta.stripe_customer_id 85 | except stripe.InvalidRequestError as e: 86 | if e.code == 'resource_missing': 87 | logger.warning( 88 | f"Stale Stripe customer ID '{org_meta.stripe_customer_id}' found for Org {organization.id}. Re-creating.") 89 | org_meta.stripe_customer_id = None 90 | else: 91 | raise e 92 | 93 | # Assuming organization.admin_user gives us the user to use for billing contact email 94 | billing_user = organization.owner 95 | 96 | customer = stripe.Customer.create( 97 | email=billing_user.email, 98 | name=organization.name, 99 | metadata={'organization_id': organization.id} 100 | ) 101 | 102 | org_meta.stripe_customer_id = customer.id 103 | org_meta.save() 104 | return customer.id 105 | 106 | 107 | def subscribe(request: HttpRequest, price_id: str, organization: Organization, quantity: int, 108 | pending_subscription: CheckoutSessionRecord) -> Optional[ 109 | stripe.checkout.Session]: 110 | if get_active_subscription(organization) is not None: 111 | messages.error(request, f"The organization ({organization.name}) is already subscribed. " 112 | f"Manage subscriptions in Settings > Organization.") 113 | return None 114 | 115 | try: 116 | customer_id = create_org_customer_if_needed(organization) 117 | checkout_session = stripe.checkout.Session.create( 118 | customer=customer_id, # Use the Organization's Stripe Customer ID 119 | success_url=ROOT_DOMAIN + '/checkout-success?session_id={CHECKOUT_SESSION_ID}', 120 | cancel_url=ROOT_DOMAIN + '/checkout-cancelled', 121 | # payment_method_types=['card'], 122 | automatic_tax={ 123 | "enabled": True, 124 | }, 125 | customer_update={ 126 | 'address': 'auto' 127 | }, 128 | mode='subscription', 129 | line_items=[{ 130 | 'price': price_id, 131 | 'quantity': quantity 132 | }], 133 | metadata={'pending_subscription_id': pending_subscription.id}, 134 | ) 135 | return checkout_session 136 | except Exception as e: 137 | messages.error(request, str(e)) 138 | logger.error(f"Stripe Checkout Error: {e}", exc_info=True) 139 | return None 140 | 141 | 142 | def manage_subscription(request: HttpRequest, organization: Organization) -> HttpResponse: 143 | if get_active_subscription(organization) is None: 144 | messages.error(request, f"The organization ({organization.name}) is not yet subscribed.") 145 | return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('bom:settings'))) 146 | 147 | try: 148 | org_meta = get_organization_meta_or_404(organization) 149 | customer_id = org_meta.stripe_customer_id 150 | session = stripe.billing_portal.Session.create( 151 | customer=customer_id, # Use the Organization's Stripe Customer ID 152 | return_url=ROOT_DOMAIN + reverse('bom:settings'), 153 | ) 154 | return redirect(session.url) 155 | except Exception as e: 156 | logger.error(f"Error creating Stripe Billing Portal session: {e}", exc_info=True) 157 | messages.error(request, "Error creating stripe session, please try again or contact support.") 158 | return HttpResponseRedirect(reverse('bom:settings')) 159 | 160 | 161 | # --- Webhook Handlers --- 162 | 163 | # Note: This requires the @csrf_exempt decorator in the URL configuration, 164 | # but it is omitted here as it belongs in views.py or urls.py. 165 | def subscription_completed_handler(event: stripe.Event): 166 | checkout_session = event.get('data', {}).get('object') 167 | 168 | pending_sub_pk = checkout_session.metadata.get('pending_subscription_id') 169 | stripe_subscription_id = checkout_session.get('subscription') 170 | customer_id = checkout_session.get('customer') 171 | 172 | logger.info(f"Checkout completed for Subscription ID: {stripe_subscription_id}. Pending PK: {pending_sub_pk}") 173 | 174 | if not pending_sub_pk or not stripe_subscription_id: 175 | logger.error( 176 | f"Missing IDs in completed session. Sub ID: {stripe_subscription_id}, Pending PK: {pending_sub_pk}") 177 | return 178 | 179 | try: 180 | pending_record = CheckoutSessionRecord.objects.get(pk=pending_sub_pk) 181 | except CheckoutSessionRecord.DoesNotExist: 182 | checkout_session_id = checkout_session.get('id') 183 | logger.error( 184 | f"PendingSubscription PK {pending_sub_pk} not found for linking. Trying to use Checkout Session ID instead: {checkout_session_id}") 185 | try: 186 | pending_record = CheckoutSessionRecord.objects.get(checkout_session_id=checkout_session_id) 187 | except CheckoutSessionRecord.DoesNotExist: 188 | logger.error(f"PendingSubscription not found for Checkout Session ID either. Giving up.") 189 | return 190 | 191 | pending_record.stripe_subscription_id = stripe_subscription_id 192 | pending_record.save() 193 | 194 | try: 195 | org_meta = OrganizationMeta.objects.get(stripe_customer_id=customer_id) 196 | stripe_sub = stripe.Subscription.retrieve(stripe_subscription_id) 197 | status = stripe_sub.get('status') 198 | quantity = stripe_sub.get('quantity', 1) 199 | price_id = stripe_sub.items.data[0].price.id if stripe_sub.items.data else None 200 | 201 | if not price_id: 202 | logger.error(f"Subscription {stripe_subscription_id} has no price data.") 203 | return 204 | 205 | sub_obj, created = OrganizationSubscription.objects.update_or_create( 206 | stripe_subscription_id=stripe_subscription_id, 207 | defaults={ 208 | "organization_meta": org_meta, 209 | "stripe_price_id": price_id, 210 | "status": status, 211 | "quantity": quantity, 212 | "started_by": org_meta.organization.owner, 213 | "current_period_start": _to_dt(stripe_sub.get("current_period_start")), 214 | "current_period_end": _to_dt(stripe_sub.get("current_period_end")), 215 | }, 216 | ) 217 | 218 | pending_record.subscription = sub_obj 219 | pending_record.save() 220 | 221 | organization = org_meta.organization 222 | organization.subscription = SUBSCRIPTION_TYPE_PRO 223 | organization.subscription_quantity = quantity 224 | organization.save() 225 | 226 | logger.info( 227 | f"Subscription {stripe_subscription_id} created, active status applied to organization " 228 | f"{organization.name}, and auto-renewal consent successfully linked." 229 | ) 230 | 231 | except OrganizationMeta.DoesNotExist: 232 | logger.error(f"OrganizationMeta not found for customer ID: {customer_id}.") 233 | except Exception as e: 234 | logger.error(f"Critical error in completion handler: {e}", exc_info=True) 235 | 236 | 237 | def subscription_changed_handler(event: stripe.Event): 238 | data = event.get('data', {}).get('object') 239 | 240 | try: 241 | org_meta = OrganizationMeta.objects.get(stripe_customer_id=data.get('customer')) 242 | organization = org_meta.organization 243 | except OrganizationMeta.DoesNotExist: 244 | logger.warning(f"Webhook received for unknown customer ID: {data.get('customer')}") 245 | return 246 | 247 | subscription_id = data.get('id') 248 | status = data.get('status') 249 | 250 | # Pull from root first; fall back to first subscription item when Stripe sends fields there 251 | items = (data.get('items') or {}).get('data', []) 252 | first_item = items[0] if items else {} 253 | 254 | quantity = data.get('quantity') or first_item.get('quantity') or 1 255 | 256 | # Price ID may be under item.price.id (new) or item.plan.id (legacy) 257 | price_id = ( 258 | (first_item.get('price') or {}).get('id') 259 | or (first_item.get('plan') or {}).get('id') 260 | ) 261 | if not price_id: 262 | logger.error( 263 | f"Subscription changed event for {organization.name} ({organization.id}) is missing price information.") 264 | return 265 | 266 | # Current period times may be on root or on the subscription item in some events 267 | current_period_start_timestamp = ( 268 | first_item.get('current_period_start') 269 | or data.get('current_period_start') 270 | ) 271 | current_period_end_timestamp = ( 272 | first_item.get('current_period_end') 273 | or data.get('current_period_end') 274 | ) 275 | # Ensure we don't store nulls in non-nullable DateTimeFields 276 | if not current_period_start_timestamp: 277 | current_period_start_datetime = datetime.now(timezone.utc) 278 | else: 279 | current_period_start_datetime = _to_dt(current_period_start_timestamp) 280 | if not current_period_end_timestamp: 281 | current_period_end_datetime = current_period_start_datetime 282 | else: 283 | current_period_end_datetime = _to_dt(current_period_end_timestamp) 284 | 285 | sub_obj, created = OrganizationSubscription.objects.update_or_create( 286 | stripe_subscription_id=subscription_id, 287 | defaults={ 288 | "organization_meta": org_meta, 289 | "stripe_price_id": price_id, 290 | "status": status, 291 | "quantity": quantity, 292 | "current_period_start": current_period_start_datetime, 293 | "current_period_end": current_period_end_datetime, 294 | }, 295 | ) 296 | if created and not sub_obj.started_by: 297 | sub_obj.started_by = org_meta.organization.owner 298 | sub_obj.save(update_fields=["started_by"]) 299 | 300 | if status == 'active': 301 | organization.subscription = SUBSCRIPTION_TYPE_PRO 302 | organization.subscription_quantity = quantity 303 | logger.info(f"Updated subscription for organization {organization.name} to PRO ({quantity} users)") 304 | else: 305 | organization.subscription = SUBSCRIPTION_TYPE_FREE 306 | organization.subscription_quantity = 1 307 | logger.info(f"Subscription status for {organization.name} changed to {status}. Set to FREE.") 308 | 309 | organization.save() 310 | 311 | 312 | def subscription_issue_handler(event: stripe.Event): 313 | data = event.get('data', {}).get('object') 314 | 315 | try: 316 | org_meta = OrganizationMeta.objects.get(stripe_customer_id=data.get('customer')) 317 | organization = org_meta.organization 318 | 319 | email = organization.owner.email # Assuming the organization model has a primary contact email 320 | send_mail( 321 | 'IndaBOM Payment Failed', 322 | 'Just writing to give you a heads up that your payment has failed and your subscription has been marked to be suspended. Please visit IndaBOM and update your payment settings.', 323 | 'no-reply@indabom.com', 324 | [email, ], 325 | fail_silently=False, 326 | ) 327 | except OrganizationMeta.DoesNotExist: 328 | logger.warning(f"Invoice failed for unknown customer ID: {data.get('customer')}") 329 | except Exception as err: 330 | logger.error(f'Error sending subscription issue email for event ({event.id}): {str(err)}', exc_info=True) 331 | 332 | 333 | def stripe_webhook(request: HttpRequest) -> HttpResponse: 334 | payload = request.body 335 | sig_header = request.META.get('HTTP_STRIPE_SIGNATURE') 336 | 337 | try: 338 | event = stripe.Webhook.construct_event( 339 | payload, sig_header, STRIPE_WEBHOOK_SECRET 340 | ) 341 | except ValueError: 342 | return HttpResponse(status=400) 343 | except stripe.error.SignatureVerificationError: 344 | return HttpResponse(status=400) 345 | 346 | if event['type'] == 'checkout.session.completed': 347 | transaction.on_commit(lambda: subscription_completed_handler(event)) 348 | 349 | elif (event['type'] == 'customer.subscription.updated' or event['type'] == 'customer.subscription.created' or 350 | event['type'] == 'customer.subscription.deleted'): 351 | transaction.on_commit(lambda: subscription_changed_handler(event)) 352 | 353 | elif event['type'] == 'invoice.payment_failed': 354 | transaction.on_commit(lambda: subscription_issue_handler(event)) 355 | 356 | elif event['type'] == 'invoice.paid': 357 | # Often handled by subscription.updated, but you can add custom logic here if needed 358 | pass 359 | 360 | return HttpResponse(status=200) 361 | -------------------------------------------------------------------------------- /indabom/templates/indabom/product.html: -------------------------------------------------------------------------------- 1 | {% extends 'indabom/base.html' %} 2 | 3 | {% block head-title %}Product{% endblock %} 4 | 5 | {% block title %}Why IndaBOM?{% endblock %} 6 | 7 | {% block content %} 8 | {% load static %} 9 | 10 | 11 |
    12 | IndaBOM Logo 13 |

    Why IndaBOM?

    14 |

    15 | Streamlined BOM management and sourcing — professional tools without the bloat. 16 |

    17 | 18 |
    19 |
    20 |
    21 | {% if not user.is_authenticated %} 22 | 28 | {% endif %} 29 | {% if user.is_authenticated %} 30 | 36 | {% endif %} 37 | 43 |
    44 |
    45 |
    46 |
    47 | 48 |
    49 | 50 | 51 |
    52 |
    53 |
    54 |

    Jump right in

    55 |
    56 |
    57 | 58 |
    59 |
    60 |
    61 | format_list_numbered 62 |
    Flexible Numbering
    63 |

    64 | Choose the integrated semi‑intelligent scheme or roll your own with intelligent numbering. IndaBOM ensures uniqueness. 65 |

    66 |
    67 |
    68 |
    69 |
    70 | file_upload 71 |
    Bulk Upload
    72 |

    73 | Grab your existing CSV or Excel list and batch upload parts and assemblies to get started fast. 74 |

    75 |
    76 |
    77 |
    78 |
    79 | inventory_2 80 |
    Manage Revisions
    81 |

    82 | Revision management from development to production and the whole product lifecycle to track electrical, mechanical, hardware and more. 83 |

    84 |
    85 |
    86 |
    87 | 88 |
    89 |
    90 | {% if not user.is_authenticated %} 91 | 93 | Start For Free 94 | 95 | {% else %} 96 | 98 | Enter 99 | 100 | {% endif %} 101 |
    102 |
    103 |
    104 | 105 |
    106 |
    107 |
    108 |
    109 |

    Level Up Your BOM

    110 |
    111 |
    112 | 113 |
    114 |
    115 | IndaBOM Part Info 116 |
    117 |
    118 |

    Indented BOM

    119 |

    120 | Know your full assembly. Using an Indented BOM provides a clear, tree-like structure that shows exactly how sub-assemblies and individual components build up to the final product 121 |

    122 |

    Integrated Sourcing

    123 |

    124 | IndaBOM integrates with Mouser to bring pricing and datasheets directly into your indented BOM 125 | and part information views. 126 |

    127 |

    Simple Cost Estimates

    128 |

    129 | Estimate BOM costs at different volumes. Manage alternate manufacturers and sellers to keep builds on track when 130 | parts go out of stock. 131 |

    132 |
    133 |
    134 | 135 |
    136 |
    137 |

    138 | Want more? Unlike expensive PLM/PDM systems, IndaBOM is free and open source so you can customize it 139 | to your needs. We're always open to ideas — message us at 140 | info@indabom.com or submit a pull request. 141 |

    142 |
    143 |
    144 | 145 |
    146 |
    147 | {% if not user.is_authenticated %} 148 | 150 | Get Started 151 | 152 | {% else %} 153 | 155 | Enter 156 | 157 | {% endif %} 158 |
    159 |
    160 |
    161 |
    162 |
    163 |
    164 |
    165 |

    Flexible Part Numbering

    166 |

    167 | IndaBOM helps you stay organized with an opinionated industry standard semi-intelligent 168 | part numbering scheme. Already have a scheme? Want something different? Use any scheme with intelligent 169 | numbering. 170 |

    171 |
    172 |
    173 |
    174 |
    175 |
    176 |
    Semi‑Intelligent
    177 |
      178 |
    • Three part numbering: Class-Number-Variation
    • 179 |
    • Organized and easy to search
    • 180 |
    • Fast to assign and communicate
    • 181 |
    182 |
    183 |
    184 |
    185 |
    186 |
    Intelligent
    187 |
      188 |
    • Human‑readable codes (e.g., size, dielectric)
    • 189 |
    • Familiar for some teams and workflows
    • 190 |
    • Less structure, and less flexible when requirements change
    • 191 |
    192 |
    193 |
    194 |
    195 |
    196 | 197 | 198 |
    199 | 200 |
    201 |
    202 |
    203 |

    An Integrated BOM Tool

    204 |

    205 | Always know where to go. Stay organized, and always up to date with the latest pricing and datasheets, right at your fingertips. 206 |

    207 |
    208 |
    209 |
    210 |
    211 |
    212 | integration_instructions 213 |
    Google Drive
    214 |

    Integrate with Google Drive, and IndaBOM automatically generates a dedicated folder for each part number in your BOM, housing all associated files in one precise location.

    215 |

    Keep your data all in one place, and use the storage system you're familiar with.

    216 |
    217 |
    218 |
    219 |
    220 | integration_instructions 221 |
    Mouser
    222 |

    The Mouser API provides up to date pricing. Enable pricing for your part class, and pricing information appears throughout your BOM including:

    223 |
      224 |
    • Bill of Materials
    • 225 |
    • Part Sourcing
    • 226 |
    • Cost Estimation for Assembly totals
    • 227 |
    228 |
    229 |
    230 |
    231 |
    232 | 233 | 234 |
    235 |
    236 |
    237 |
    238 |

    Who is this for?

    239 |
    240 |
    241 |
    242 |
    243 |
    244 | memory 245 |
    Engineers
    246 |

    Consistent part numbers, revisions, alternates, and specs.

    247 |
    248 |
    249 |
    250 |
    251 | local_shipping 252 |
    Operations
    253 |

    Share BOMs, align sourcing, and simplify vendor comms.

    254 |
    255 |
    256 |
    257 |
    258 | rocket_launch 259 |
    Founders
    260 |

    See cost roll‑ups quickly and keep builds on track.

    261 |
    262 |
    263 |
    264 |
    265 |
    266 | View Pricing 267 |
    268 |
    269 |
    270 |
    271 | 272 |
    273 |
    274 |
    275 |

    Quick Start

    276 |

    From zero to useful BOMs in minutes.

    277 |
    278 |
    279 |
    280 |
    281 |
    282 | tune 283 |
    1. Configure
    284 |

    Create your organization, choose your numbering scheme, and define part classes.

    285 |
    286 |
    287 |
    288 |
    289 | playlist_add 290 |
    2. Add Parts
    291 |

    Create parts via form or upload a CSV. IndaBOM keeps numbers unique and consistent.

    292 |
    293 |
    294 |
    295 |
    296 | schema 297 |
    3. Build BOMs
    298 |

    Upload or create indented BOMs and begin to see rolled‑up costs.

    299 |
    300 |
    301 |
    302 |
    303 |
    304 | {% if not user.is_authenticated %} 305 | Start For Free 306 | {% else %} 307 | Enter 308 | {% endif %} 309 |
    310 |
    311 |
    312 | 313 | {% endblock %} 314 | --------------------------------------------------------------------------------