├── 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 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
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 |
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 |
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 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/.idea/runConfigurations/Run_IndaBOM.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
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 |
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 |
14 |
Welcome Back
15 |
Log in to continue managing your BOMs.
16 |
17 |
18 |
19 |
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 | VIDEO
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 |
17 |
Create Your Account
18 |
Start simplifying your BOM management today.
19 |
20 |
21 |
22 |
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 | {#
#}
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 |
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 |
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 |
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 |
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 |
116 |
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 |
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 |
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 |
83 |
84 |
85 |
IndaBOM
87 |
IndaBOM
90 |
menu
91 |
92 | {% block menu %}{% endblock %}
93 | {% include 'indabom/base-menu.html' with user=user pagename=pagename title=title %}
94 |
95 |
96 |
97 |
98 |
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 |
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 |
143 |
144 |
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 |
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 |
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 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
Level Up Your BOM
110 |
111 |
112 |
113 |
114 |
115 |
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 |
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 |
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 |
--------------------------------------------------------------------------------