From 6bb49337c321843d7a6657cd228498ca787e56b8 Mon Sep 17 00:00:00 2001 From: Ameya Shenoy Date: Sun, 19 Mar 2023 19:12:47 +0530 Subject: [PATCH] feat: backend with models Signed-off-by: Ameya Shenoy --- backend/.gitignore | 174 ++++++++++++++ backend/Dockerfile.dev | 15 ++ backend/backend/__init__.py | 0 backend/backend/asgi.py | 16 ++ backend/backend/settings.py | 249 ++++++++++++++++++++ backend/backend/urls.py | 29 +++ backend/backend/wsgi.py | 16 ++ backend/foldbank/__init__.py | 0 backend/foldbank/admin.py | 96 ++++++++ backend/foldbank/apps.py | 6 + backend/foldbank/migrations/0001_initial.py | 125 ++++++++++ backend/foldbank/migrations/__init__.py | 0 backend/foldbank/models.py | 175 ++++++++++++++ backend/foldbank/tests.py | 3 + backend/foldbank/utils.py | 19 ++ backend/foldbank/views.py | 3 + backend/manage.py | 22 ++ backend/requirements.txt | 16 ++ dev.env | 24 ++ docker-compose.yml | 47 +++- 20 files changed, 1028 insertions(+), 7 deletions(-) create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile.dev create mode 100644 backend/backend/__init__.py create mode 100644 backend/backend/asgi.py create mode 100644 backend/backend/settings.py create mode 100644 backend/backend/urls.py create mode 100644 backend/backend/wsgi.py create mode 100644 backend/foldbank/__init__.py create mode 100644 backend/foldbank/admin.py create mode 100644 backend/foldbank/apps.py create mode 100644 backend/foldbank/migrations/0001_initial.py create mode 100644 backend/foldbank/migrations/__init__.py create mode 100644 backend/foldbank/models.py create mode 100644 backend/foldbank/tests.py create mode 100644 backend/foldbank/utils.py create mode 100644 backend/foldbank/views.py create mode 100755 backend/manage.py create mode 100644 backend/requirements.txt create mode 100644 dev.env diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..26124aa --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,174 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# End of https://www.toptal.com/developers/gitignore/api/python + +# App Specific +media/ +media_cdn/ +static/ +static_cdn/ + diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..3d39503 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM python:3.10.9-slim-bullseye + +LABEL maintainer "Ameya Shenoy " + +ENV PYTHONUNBUFFERED=1 +ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 + +WORKDIR /code + +COPY requirements.txt / + +RUN set -ex \ + && pip install -r /requirements.txt \ + && rm -rf requirements.txt + diff --git a/backend/backend/__init__.py b/backend/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/backend/asgi.py b/backend/backend/asgi.py new file mode 100644 index 0000000..f4518a1 --- /dev/null +++ b/backend/backend/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for backend project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') + +application = get_asgi_application() diff --git a/backend/backend/settings.py b/backend/backend/settings.py new file mode 100644 index 0000000..a126898 --- /dev/null +++ b/backend/backend/settings.py @@ -0,0 +1,249 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 4.1.7. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.1/ref/settings/ +""" + +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get("SECRET_KEY", "sample-for-collectstatic") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ.get("DEBUG", "0").lower() in ["1", "true", "True", "yes"] + +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",") + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "whitenoise.runserver_nostatic", + "django.contrib.staticfiles", + "drf_yasg", + "django_rq", + "rest_framework", + "django_extensions", + "foldbank", + "corsheaders", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.middleware.common.CommonMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "backend.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "backend.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.1/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB"), + "USER": os.environ.get("POSTGRES_USER"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD"), + "HOST": os.environ.get("POSTGRES_SERVER"), + "PORT": os.environ.get("POSTGRES_PORT"), + "CONN_MAX_AGE": int(os.environ.get("POSTGRES_CONN_MAX_AGE", 60)), + "ATOMIC_REQUESTS": True, + }, +} + + +# Password validation +# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.1/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.1/howto/static-files/ + +STATIC_URL = "static/" +MEDIA_URL = "media/" +STATIC_ROOT = os.path.join(BASE_DIR, "static_cdn") +MEDIA_ROOT = os.path.join(BASE_DIR, "media_cdn") + +STATICFILES_DIRS = [ + BASE_DIR / "static", + BASE_DIR / "media", +] + +# Default primary key field type +# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +DJANGO_LOG_LEVEL = os.environ.get("DJANGO_LOG_LEVEL", "INFO") +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "level": DJANGO_LOG_LEVEL, + "class": "logging.StreamHandler", + "formatter": "console", + }, + "rq_console": { + "level": DJANGO_LOG_LEVEL, + "class": "rq.utils.ColorizingStreamHandler", + "formatter": "rq_console", + "exclude": ["%(asctime)s"], + }, + }, + "formatters": { + "rq_console": { + "format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + }, + "console": { + "format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + }, + }, + "loggers": { + "rq.worker": { + "handlers": ["rq_console"], + "level": DJANGO_LOG_LEVEL, + }, + }, + "root": { + "handlers": ["console"], + "level": DJANGO_LOG_LEVEL, + }, +} + + +RQ_SHOW_ADMIN_LINK = True + +REDIS_HOST = os.environ.get("REDIS_HOST", "redis") +REDIS_PORT = os.environ.get("REDIS_PORT", 6379) +REDIS_QUEUE_DB = os.environ.get("REDIS_QUEUE_DB", 0) +REDIS_DEFAULT_TIMEOUT = os.environ.get("REDIS_RQ_DEFAULT_TIMEOUT", 360) +REDIS_CACHE_DB = os.environ.get("REDIS_CACHE_DB", 1) + +RQ_QUEUES = { + "default": { + "HOST": REDIS_HOST, + "PORT": REDIS_PORT, + "DB": REDIS_QUEUE_DB, + "DEFAULT_TIMEOUT": REDIS_DEFAULT_TIMEOUT, + }, +} + +AUTH_USER_MODEL = "foldbank.User" + +CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",") +CORS_ALLOWED_ORIGINS = os.environ.get( + "CORS_ALLOWED_ORIGINS", "https://foldbank.coingcoffee.me" +).split(",") + +STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" + +DEFAULT_RENDERER_CLASSES = ( + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", +) + +REST_FRAMEWORK = { + "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), + "DEFAULT_PARSER_CLASSES": ("rest_framework.parsers.JSONParser",), + "SEARCH_PARAM": "q", + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": {"anon": "100/day", "user": "1000/day"}, + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 5, + "DEFAULT_RENDERER_CLASSES": DEFAULT_RENDERER_CLASSES, +} + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_CACHE_DB}", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} + +SWAGGER_SETTINGS = { + "SECURITY_DEFINITIONS": { + "Bearer": {"type": "apiKey", "name": "Authorization", "in": "header"} + } +} diff --git a/backend/backend/urls.py b/backend/backend/urls.py new file mode 100644 index 0000000..bb275f7 --- /dev/null +++ b/backend/backend/urls.py @@ -0,0 +1,29 @@ +"""backend URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] + + +if settings.DEBUG: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + diff --git a/backend/backend/wsgi.py b/backend/backend/wsgi.py new file mode 100644 index 0000000..c4aa324 --- /dev/null +++ b/backend/backend/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for backend project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') + +application = get_wsgi_application() diff --git a/backend/foldbank/__init__.py b/backend/foldbank/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/foldbank/admin.py b/backend/foldbank/admin.py new file mode 100644 index 0000000..c5cf9f5 --- /dev/null +++ b/backend/foldbank/admin.py @@ -0,0 +1,96 @@ +from django.contrib import admin +from django.contrib.auth.forms import ReadOnlyPasswordHashField +from django import forms +from django.contrib.auth.admin import UserAdmin +from foldbank.models import ( + Account, + Bank, + RecurringPayment, + RecurringPaymentTransactionLink, + Tag, + Transaction, + User, +) + + +class UserForm(forms.ModelForm): + password = ReadOnlyPasswordHashField( + help_text=( + "Raw passwords are not stored, " + "so there is no way to see this user’s password, " + "but you can change the password using " + "this form." + ) + ) + + class Meta: + fields = "__all__" + model = User + + +class UserAdminConfig(UserAdmin): + form = UserForm + + search_fields = ("username", "id",) + readonly_fields = ( + "id", + "date_joined", + "last_login", + "last_updated", + ) + list_filter = ("is_staff", "is_superuser", "is_active") + ordering = ("-date_joined",) + list_display = ("username", "name", "last_login", "is_active", "is_superuser") + fieldsets = ( + ( + "Identifiers", + {"fields": ("id", "name", "username", "password",)}, + ), + ( + "Personal", + { + "fields": ( + "last_login", + "last_updated", + "date_joined", + ) + }, + ), + ( + "Permissions", + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ) + }, + ), + ) + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ( + "username", + "password1", + "password2", + ), + }, + ), + ) + + + +# Register your models here. +admin.site.register(User, UserAdminConfig) +admin.site.register(Tag) +admin.site.register(Bank) +admin.site.register(Account) +admin.site.register(RecurringPayment) +admin.site.register(Transaction) +admin.site.register(RecurringPaymentTransactionLink) + diff --git a/backend/foldbank/apps.py b/backend/foldbank/apps.py new file mode 100644 index 0000000..08e6bd2 --- /dev/null +++ b/backend/foldbank/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FoldbankConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'foldbank' diff --git a/backend/foldbank/migrations/0001_initial.py b/backend/foldbank/migrations/0001_initial.py new file mode 100644 index 0000000..531b1bf --- /dev/null +++ b/backend/foldbank/migrations/0001_initial.py @@ -0,0 +1,125 @@ +# Generated by Django 4.1.7 on 2023-03-19 13:39 + +import datetime +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import foldbank.utils +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('username', models.CharField(blank=True, help_text='only allows letters, numbers and underscores', max_length=32, null=True, unique=True, validators=[django.core.validators.RegexValidator('^[a-zA-Z0-9_]*$', message='only allows letters, numbers and underscores')])), + ('name', models.CharField(blank=True, max_length=128, null=True)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('is_superuser', models.BooleanField(default=False)), + ('date_joined', models.DateTimeField(auto_now_add=True)), + ('last_login', models.DateTimeField(default=django.utils.timezone.now)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Account', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('account_number', models.CharField(blank=True, max_length=64, null=True)), + ('account_type', models.CharField(choices=[('SA', 'Savings Account'), ('CA', 'Current Account')], default='SA', max_length=2)), + ('ifsc_code', models.CharField(blank=True, max_length=64, null=True)), + ('swift_bic', models.CharField(blank=True, max_length=64, null=True)), + ('holders_name', models.CharField(blank=True, max_length=64, null=True)), + ('account_owner_type', models.CharField(choices=[('UA', 'User Account'), ('OA', 'Organization Account')], default='UA', max_length=2)), + ('balance', models.FloatField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='Bank', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(blank=True, max_length=64, null=True)), + ('logo', models.FileField(blank=True, default=None, null=True, upload_to=foldbank.utils.get_bank_logo_file_path)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='RecurringPayment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('frequency', models.DurationField(default=datetime.timedelta(days=30))), + ('amount', models.FloatField(blank=True, null=True)), + ('due_on', models.DateTimeField(auto_now_add=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('from_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recurring_payment_from', to='foldbank.account')), + ], + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('title', models.CharField(blank=True, max_length=64, null=True)), + ('sub_category', models.CharField(blank=True, max_length=64, null=True)), + ('icon_type', models.CharField(blank=True, max_length=64, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Transaction', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('amount', models.FloatField(blank=True, null=True)), + ('from_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions_from', to='foldbank.account')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='foldbank.tag')), + ('to_account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transactions_to', to='foldbank.account')), + ], + ), + migrations.CreateModel( + name='RecurringPaymentTransactionLink', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('recurring_payment', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='foldbank.recurringpayment')), + ('transaction', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='foldbank.transaction')), + ], + ), + migrations.AddField( + model_name='recurringpayment', + name='tag', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='foldbank.tag'), + ), + migrations.AddField( + model_name='recurringpayment', + name='to_account', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recurring_payment_to', to='foldbank.account'), + ), + migrations.AddField( + model_name='account', + name='bank', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='foldbank.bank'), + ), + migrations.AddField( + model_name='account', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/foldbank/migrations/__init__.py b/backend/foldbank/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/foldbank/models.py b/backend/foldbank/models.py new file mode 100644 index 0000000..3ec0229 --- /dev/null +++ b/backend/foldbank/models.py @@ -0,0 +1,175 @@ +import uuid +from datetime import datetime, timedelta + +from django.contrib.auth.models import ( + AbstractBaseUser, + BaseUserManager, + PermissionsMixin, +) +from django.core.validators import RegexValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext as _ +from foldbank.utils import get_bank_logo_file_path + + +# object managers +class CustomAccountManager(BaseUserManager): + def create_user(self, username, password): + if not username: + raise ValueError("Users must have a username.") + user = self.model(username=username) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, username, password): + user = self.create_user(username, password) + user.is_staff = True + user.is_superuser = True + user.save(using=self._db) + return user + + + +# Create your models here. +class User(AbstractBaseUser, PermissionsMixin): + # TODO: make username case insensitive yet retain case sensitivity + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + username = models.CharField( + max_length=32, + unique=True, + null=True, + blank=True, + validators=[ + RegexValidator( + r"^[a-zA-Z0-9_]*$", + message="only allows letters, numbers and underscores", + ) + ], + help_text=_("only allows letters, numbers and underscores"), + ) + name = models.CharField(null=True, blank=True, max_length=128) + + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + is_superuser = models.BooleanField(default=False) + + date_joined = models.DateTimeField(auto_now_add=True) + last_login = models.DateTimeField(default=timezone.now) + last_updated = models.DateTimeField(auto_now=True) + + objects = CustomAccountManager() + + USERNAME_FIELD = "username" + + def __str__(self): + if not self.username: + return "USERNAME-NOT-SET" + return self.username + + +class Tag(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + title = models.CharField(blank=True, null=True, max_length=64) + sub_category = models.CharField(blank=True, null=True, max_length=64) + icon_type = models.CharField(blank=True, null=True, max_length=64) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"{self.title} - {self.sub_category}" + + + +class Bank(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(blank=True, null=True, max_length=64) + logo = models.FileField( + upload_to=get_bank_logo_file_path, + null=True, + blank=True, + default=None, + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + +class Account(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + user = models.ForeignKey("User", on_delete=models.PROTECT) + bank = models.ForeignKey("Bank", on_delete=models.PROTECT) + account_number = models.CharField(blank=True, null=True, max_length=64) + SAVINGS_ACCOUNT = "SA" + CURRENT_ACCOUNT = "CA" + ACCOUNT_TYPES = [ + (SAVINGS_ACCOUNT, "Savings Account"), + (CURRENT_ACCOUNT, "Current Account"), + ] + account_type = models.CharField( + max_length=2, + choices=ACCOUNT_TYPES, + default=SAVINGS_ACCOUNT, + ) + ifsc_code = models.CharField(blank=True, null=True, max_length=64) + swift_bic = models.CharField(blank=True, null=True, max_length=64) + holders_name = models.CharField(blank=True, null=True, max_length=64) + USER_ACCOUNT = "UA" + ORG_ACCOUNT = "OA" + ACCOUNT_OWNER_TYPES = [ + (USER_ACCOUNT, "User Account"), + (ORG_ACCOUNT, "Organization Account"), + ] + account_owner_type = models.CharField( + max_length=2, + choices=ACCOUNT_OWNER_TYPES, + default=USER_ACCOUNT, + ) + balance = models.FloatField(null=True, blank=True) + + def __str__(self): + return f"{self.account_owner_type} - {self.bank} - {self.account_number} - {self.holders_name}" + + +class Transaction(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + created_at = models.DateTimeField(auto_now_add=True) + amount = models.FloatField(null=True, blank=True) + tag = models.ForeignKey("Tag", on_delete=models.PROTECT) + from_account = models.ForeignKey( + "Account", on_delete=models.PROTECT, related_name="transactions_from" + ) + to_account = models.ForeignKey( + "Account", on_delete=models.PROTECT, related_name="transactions_to" + ) + + def __str__(self): + return f"{self.from_account.holders_name} - {self.to_account.holders_name}" + + + +class RecurringPayment(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + from_account = models.ForeignKey( + "Account", on_delete=models.PROTECT, related_name="recurring_payment_from" + ) + to_account = models.ForeignKey( + "Account", on_delete=models.PROTECT, related_name="recurring_payment_to" + ) + frequency = models.DurationField(default=timedelta(days=30)) + amount = models.FloatField(null=True, blank=True) + due_on = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True) + tag = models.ForeignKey("Tag", on_delete=models.PROTECT) + + def __str__(self): + return f"{self.from_account.holders_name} - {self.to_account.holders_name}" + + + +class RecurringPaymentTransactionLink(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + recurring_payment = models.ForeignKey("RecurringPayment", on_delete=models.PROTECT) + transaction = models.ForeignKey("Transaction", on_delete=models.PROTECT) diff --git a/backend/foldbank/tests.py b/backend/foldbank/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/foldbank/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/foldbank/utils.py b/backend/foldbank/utils.py new file mode 100644 index 0000000..9b2a211 --- /dev/null +++ b/backend/foldbank/utils.py @@ -0,0 +1,19 @@ + + +import pathlib +import datetime + +def get_file_extension(file): + return pathlib.Path(file.name).suffix + + +def generate_filename_for_storage(file): + ext = get_file_extension(file) + filename = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + return f"{filename}{ext}" + + +def get_bank_logo_file_path(self, _): + filename = generate_filename_for_storage(self.logo) + return f"images/bank/{self.pk}/logo/{filename}" + diff --git a/backend/foldbank/views.py b/backend/foldbank/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/foldbank/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 0000000..eb6431e --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..f42d89b --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,16 @@ +django==4.1.7 +psycopg2-binary==2.9.3 # to support PostgreSQL as a DB backend +djangorestframework==3.14.0 # DJango REST Framework +markdown==3.4.1 # Markdown support for the browsable API for DJango REST Framework +django-filter==22.1 # Filtering support for DJango REST Framework +django-rq==2.7.0 # for django rq for background jobs +django-extensions==3.2.1 # db model generation, shellplus +Werkzeug==2.2.3 # for runserver plus by django-extensions +Pillow==9.4.0 # dealing with images +whitenoise[brotli]==6.4.0 # whitenoise for serving staticfiles and using brolti for compression +django-redis==5.2.0 # full featured redis cache backend for Django +drf-yasg==1.21.5 # swagger for docs +django-health-check==3.17.0 # healthcheck +rich==13.3.2 # for visual feedback on scripts +django-cors-headers==3.14.0 # for cors + diff --git a/dev.env b/dev.env new file mode 100644 index 0000000..eaefd4f --- /dev/null +++ b/dev.env @@ -0,0 +1,24 @@ +# Django +SECRET_KEY=sample +DEBUG=true +ALLOWED_HOSTS=0.0.0.0,localhost,127.0.0.1,* +CSRF_TRUSTED_ORIGINS=https://foldbank.codingcoffee.me +DJANGO_LOG_LEVEL=INFO +# Django - Postgres +POSTGRES_SERVER=postgres-db +POSTGRES_PORT=5432 +POSTGRES_CONN_MAX_AGE=60 +POSTGRES_CONN_MAX_AGE_READONLY=60 +# RQ +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_QUEUE_DB=0 +REDIS_RQ_DEFAULT_TIMEOUT=360 + + +# Postgres, Django +POSTGRES_USER=foldbank +POSTGRES_PASSWORD=password +POSTGRES_DB=foldbank +PGDATA=/var/lib/postgresql/data/pgdata + diff --git a/docker-compose.yml b/docker-compose.yml index e9b77d8..bace45a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,13 +3,46 @@ version: '3.7' services: - web: - image: node:16.19.1-alpine3.17 + # web: + # image: node:16.19.1-alpine3.17 + # restart: unless-stopped + # volumes: + # - ./web:/app + # working_dir: /app + # entrypoint: npm run dev + # ports: + # - 3000:3000 + + backend: + image: foldbank-backend-dev + build: + context: ./backend + dockerfile: Dockerfile.dev + restart: unless-stopped + env_file: + - dev.env + volumes: + - ./backend:/code + working_dir: /code + entrypoint: python manage.py runserver_plus --keep-meta-shutdown 0.0.0.0:8000 + depends_on: + - redis + - postgres-db + ports: + - 8000:8000 + + redis: + image: redis:7.0.0-alpine3.16 + restart: unless-stopped + + postgres-db: + image: postgres:14.3-alpine3.16 restart: unless-stopped volumes: - - ./web:/app - working_dir: /app - entrypoint: npm run dev - ports: - - 3000:3000 + - postgres-data:/var/lib/postgresql/data/pgdata + env_file: + - dev.env + +volumes: + postgres-data: