mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-01-11 20:00:26 +01:00
initial commit
This commit is contained in:
43
.env.example
Normal file
43
.env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
COMPOSE_FILE=
|
||||
SERVER_NAME=
|
||||
DB_NAME=
|
||||
REDIS_NAME=
|
||||
CELERYWORKER_NAME=
|
||||
CELERYBEAT_NAME=
|
||||
FLOWER_NAME=
|
||||
|
||||
DEBUG=true
|
||||
URL = https://mais.alcanceconsulting.com.br
|
||||
HTTPS_ENABLED=true
|
||||
SECRET_KEY=foo
|
||||
DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1]
|
||||
OUTBOUND_PORT=9005
|
||||
|
||||
SQL_ENGINE=django.db.backends.postgresql
|
||||
SQL_DATABASE=alcance_mais
|
||||
SQL_USER=
|
||||
SQL_PASSWORD=
|
||||
SQL_HOST=alcance_mais_pg
|
||||
SQL_PORT=5432
|
||||
|
||||
BREVO_API_KEY=
|
||||
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_ENDPOINT=
|
||||
MINIO_SECRET_KEY=
|
||||
MINIO_CONTAS_AVATARS_BUCKET=
|
||||
MINIO_ENTREGAVEIS_BUCKET=
|
||||
MINIO_CLIENTES_LOGOS_BUCKET=
|
||||
|
||||
# Recaptcha
|
||||
RECAPTCHA_PRIVATE_KEY=
|
||||
RECAPTCHA_PUBLIC_KEY=
|
||||
RECAPTCHA_REQUIRED_SCORE=0.6
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL=redis://${REDIS_NAME}
|
||||
CELERY_FLOWER_USER=
|
||||
CELERY_FLOWER_PASSWORD=
|
||||
|
||||
# Gunicorn
|
||||
WEB_CONCURRENCY=4
|
||||
8
.idea/.gitignore
generated
vendored
8
.idea/.gitignore
generated
vendored
@@ -1,8 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
3
app/WYGIWYH/__init__.py
Normal file
3
app/WYGIWYH/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
16
app/WYGIWYH/asgi.py
Normal file
16
app/WYGIWYH/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for WYGIWYH 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/5.1/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "WYGIWYH.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
7
app/WYGIWYH/celery.py
Normal file
7
app/WYGIWYH/celery.py
Normal file
@@ -0,0 +1,7 @@
|
||||
import os
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "WYGIWYH.settings")
|
||||
app = Celery("WYGIWYH")
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
app.autodiscover_tasks()
|
||||
213
app/WYGIWYH/settings.py
Normal file
213
app/WYGIWYH/settings.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Django settings for WYGIWYH project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.1.1.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.1/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.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
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-##6^&g49xwn7s67xc&33vf&=*4ibqfzn#xa*p-1sy8ag+zjjb9"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
|
||||
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost 127.0.0.1").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",
|
||||
"webpack_boilerplate",
|
||||
"django.contrib.humanize",
|
||||
"django.contrib.postgres",
|
||||
"django_browser_reload",
|
||||
"django.forms",
|
||||
"debug_toolbar",
|
||||
"crispy_forms",
|
||||
"crispy_bootstrap5",
|
||||
"hijack",
|
||||
"hijack.contrib.admin",
|
||||
"django_tables2",
|
||||
"django_filters",
|
||||
"apps.users.apps.UsersConfig",
|
||||
"django_celery_beat",
|
||||
"apps.transactions.apps.TransactionsConfig",
|
||||
"apps.currencies.apps.CurrenciesConfig",
|
||||
"apps.accounts.apps.AccountsConfig",
|
||||
"apps.common.apps.CommonConfig",
|
||||
"tz_detect",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||
"hijack.middleware.HijackUserMiddleware",
|
||||
"tz_detect.middleware.TimezoneMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "WYGIWYH.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [BASE_DIR / "templates"],
|
||||
"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",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
WHITENOISE_MANIFEST_STRICT = False
|
||||
|
||||
WSGI_APPLICATION = "WYGIWYH.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"),
|
||||
"NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"),
|
||||
"USER": os.environ.get("SQL_USER", "user"),
|
||||
"PASSWORD": os.environ.get("SQL_PASSWORD", "password"),
|
||||
"HOST": os.environ.get("SQL_HOST", "localhost"),
|
||||
"PORT": "5432",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.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",
|
||||
},
|
||||
]
|
||||
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.1/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "pt-BR"
|
||||
|
||||
TIME_ZONE = "America/Sao_Paulo"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.1/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "static_files"
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
ROOT_DIR / "frontend/build",
|
||||
BASE_DIR / "static",
|
||||
]
|
||||
|
||||
STATICFILES_FINDERS = [
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
]
|
||||
|
||||
WEBPACK_LOADER = {
|
||||
"MANIFEST_FILE": ROOT_DIR / "frontend/build/manifest.json",
|
||||
}
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
LOGIN_URL = "/login/"
|
||||
|
||||
# CRISPY FORMS
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = ["bootstrap5", "crispy_forms/pure_text"]
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||
|
||||
# Celery settings
|
||||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://redis")
|
||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_BROKER_URL", "redis://redis")
|
||||
REDIS_URL = os.getenv("CELERY_BROKER_URL", "redis://redis")
|
||||
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
|
||||
|
||||
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE = False
|
||||
SESSION_COOKIE_AGE = 86400 # 24 horas
|
||||
SESSION_COOKIE_SECURE = os.getenv("HTTPS_ENABLED", "false").lower() == "true"
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"}
|
||||
INTERNAL_IPS = [
|
||||
"127.0.0.1",
|
||||
]
|
||||
|
||||
if DEBUG:
|
||||
import socket
|
||||
|
||||
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
|
||||
INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips]
|
||||
try:
|
||||
_, _, ips = socket.gethostbyname_ex("node")
|
||||
INTERNAL_IPS.extend(ips)
|
||||
except socket.gaierror:
|
||||
# The node container isn't started (yet?)
|
||||
pass
|
||||
29
app/WYGIWYH/urls.py
Normal file
29
app/WYGIWYH/urls.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
URL configuration for WYGIWYH project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.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.contrib import admin
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("hijack/", include("hijack.urls")),
|
||||
path("__debug__/", include("debug_toolbar.urls")),
|
||||
path("__reload__/", include("django_browser_reload.urls")),
|
||||
path("tz_detect/", include("tz_detect.urls")),
|
||||
path("", include("apps.transactions.urls")),
|
||||
path("", include("apps.common.urls")),
|
||||
]
|
||||
16
app/WYGIWYH/wsgi.py
Normal file
16
app/WYGIWYH/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for WYGIWYH 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/5.1/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "WYGIWYH.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/apps/__init__.py
Normal file
0
app/apps/__init__.py
Normal file
0
app/apps/accounts/__init__.py
Normal file
0
app/apps/accounts/__init__.py
Normal file
6
app/apps/accounts/admin.py
Normal file
6
app/apps/accounts/admin.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.accounts.models import Account
|
||||
|
||||
|
||||
admin.site.register(Account)
|
||||
6
app/apps/accounts/apps.py
Normal file
6
app/apps/accounts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
29
app/apps/accounts/migrations/0001_initial.py
Normal file
29
app/apps/accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-19 13:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Account',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=40, verbose_name='Name')),
|
||||
('is_asset', models.BooleanField(default=False, help_text='Asset accounts count towards your Net Worth, but not towards your month.', verbose_name='Is an asset account?')),
|
||||
('currency', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accounts', to='currencies.currency', verbose_name='Currency')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Account',
|
||||
'verbose_name_plural': 'Accounts',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
app/apps/accounts/migrations/__init__.py
Normal file
0
app/apps/accounts/migrations/__init__.py
Normal file
27
app/apps/accounts/models.py
Normal file
27
app/apps/accounts/models.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class Account(models.Model):
|
||||
name = models.CharField(max_length=40, verbose_name=_("Name"))
|
||||
currency = models.ForeignKey(
|
||||
"currencies.Currency",
|
||||
verbose_name=_("Currency"),
|
||||
on_delete=models.PROTECT,
|
||||
related_name="accounts",
|
||||
)
|
||||
is_asset = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name=_("Is an asset account?"),
|
||||
help_text=_(
|
||||
"Asset accounts count towards your Net Worth, but not towards your month."
|
||||
),
|
||||
)
|
||||
a = models.BigIntegerField
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Account")
|
||||
verbose_name_plural = _("Accounts")
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
3
app/apps/accounts/tests.py
Normal file
3
app/apps/accounts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
app/apps/accounts/views.py
Normal file
3
app/apps/accounts/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
0
app/apps/common/__init__.py
Normal file
0
app/apps/common/__init__.py
Normal file
6
app/apps/common/apps.py
Normal file
6
app/apps/common/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.common"
|
||||
0
app/apps/common/functions/__init__.py
Normal file
0
app/apps/common/functions/__init__.py
Normal file
13
app/apps/common/functions/decimals.py
Normal file
13
app/apps/common/functions/decimals.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
|
||||
|
||||
def truncate_decimal(value, decimal_places):
|
||||
"""
|
||||
Truncate a Decimal value to n decimal places without rounding.
|
||||
|
||||
:param value: The Decimal value to truncate
|
||||
:param decimal_places: The number of decimal places to keep
|
||||
:return: Truncated Decimal value
|
||||
"""
|
||||
multiplier = Decimal(10**decimal_places)
|
||||
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
||||
0
app/apps/common/migrations/__init__.py
Normal file
0
app/apps/common/migrations/__init__.py
Normal file
0
app/apps/common/templatetags/__init__.py
Normal file
0
app/apps/common/templatetags/__init__.py
Normal file
9
app/apps/common/templatetags/month_name.py
Normal file
9
app/apps/common/templatetags/month_name.py
Normal file
@@ -0,0 +1,9 @@
|
||||
import calendar
|
||||
|
||||
from django.template.loader_tags import register
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@register.filter
|
||||
def month_name(month_number):
|
||||
return _(calendar.month_name[month_number])
|
||||
40
app/apps/common/templatetags/toast_bg.py
Normal file
40
app/apps/common/templatetags/toast_bg.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from django import template
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def toast_bg(tags):
|
||||
if "success" in tags:
|
||||
return "success"
|
||||
elif "warning" in tags:
|
||||
return "warning"
|
||||
elif "error" in tags:
|
||||
return "danger"
|
||||
elif "info" in tags:
|
||||
return "info"
|
||||
|
||||
|
||||
@register.filter
|
||||
def toast_icon(tags):
|
||||
if "success" in tags:
|
||||
return "fa-solid fa-circle-check"
|
||||
elif "warning" in tags:
|
||||
return "fa-solid fa-circle-exclamation"
|
||||
elif "error" in tags:
|
||||
return "fa-solid fa-circle-xmark"
|
||||
elif "info" in tags:
|
||||
return "fa-solid fa-circle-info"
|
||||
|
||||
|
||||
@register.filter
|
||||
def toast_title(tags):
|
||||
if "success" in tags:
|
||||
return _("Success")
|
||||
elif "warning" in tags:
|
||||
return _("Warning")
|
||||
elif "error" in tags:
|
||||
return _("Error")
|
||||
elif "info" in tags:
|
||||
return _("Info")
|
||||
11
app/apps/common/urls.py
Normal file
11
app/apps/common/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"toasts/",
|
||||
views.toasts,
|
||||
name="toasts",
|
||||
),
|
||||
]
|
||||
5
app/apps/common/views.py
Normal file
5
app/apps/common/views.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
|
||||
def toasts(request):
|
||||
return render(request, "common/toasts.html")
|
||||
0
app/apps/common/widgets/__init__.py
Normal file
0
app/apps/common/widgets/__init__.py
Normal file
0
app/apps/currencies/__init__.py
Normal file
0
app/apps/currencies/__init__.py
Normal file
11
app/apps/currencies/admin.py
Normal file
11
app/apps/currencies/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
@admin.register(Currency)
|
||||
class CurrencyAdmin(admin.ModelAdmin):
|
||||
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
||||
if db_field.name == "suffix" or db_field.name == "prefix":
|
||||
kwargs["strip"] = False
|
||||
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
||||
6
app/apps/currencies/apps.py
Normal file
6
app/apps/currencies/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CurrenciesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.currencies"
|
||||
27
app/apps/currencies/migrations/0001_initial.py
Normal file
27
app/apps/currencies/migrations/0001_initial.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-19 13:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Currency',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=10, unique=True, verbose_name='Currency Code')),
|
||||
('name', models.CharField(max_length=50, verbose_name='Currency Name')),
|
||||
('decimal_places', models.PositiveIntegerField(default=2, verbose_name='Decimal Places')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Currency',
|
||||
'verbose_name_plural': 'Currencies',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-21 03:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='currency',
|
||||
name='prefix',
|
||||
field=models.CharField(blank=True, max_length=10, verbose_name='Prefix'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='currency',
|
||||
name='suffix',
|
||||
field=models.CharField(blank=True, max_length=10, verbose_name='Suffix'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-23 04:05
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0002_currency_prefix_currency_suffix'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='currency',
|
||||
name='decimal_places',
|
||||
field=models.PositiveIntegerField(default=2, validators=[django.core.validators.MaxValueValidator(30), django.core.validators.MinValueValidator(0)], verbose_name='Decimal Places'),
|
||||
),
|
||||
]
|
||||
0
app/apps/currencies/migrations/__init__.py
Normal file
0
app/apps/currencies/migrations/__init__.py
Normal file
22
app/apps/currencies/models.py
Normal file
22
app/apps/currencies/models.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class Currency(models.Model):
|
||||
code = models.CharField(max_length=10, unique=True, verbose_name=_("Currency Code"))
|
||||
name = models.CharField(max_length=50, verbose_name=_("Currency Name"))
|
||||
decimal_places = models.PositiveIntegerField(
|
||||
default=2,
|
||||
validators=[MaxValueValidator(30), MinValueValidator(0)],
|
||||
verbose_name=_("Decimal Places"),
|
||||
)
|
||||
prefix = models.CharField(max_length=10, verbose_name=_("Prefix"), blank=True)
|
||||
suffix = models.CharField(max_length=10, verbose_name=_("Suffix"), blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Currency")
|
||||
verbose_name_plural = _("Currencies")
|
||||
3
app/apps/currencies/tests.py
Normal file
3
app/apps/currencies/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
app/apps/currencies/views.py
Normal file
3
app/apps/currencies/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
0
app/apps/transactions/__init__.py
Normal file
0
app/apps/transactions/__init__.py
Normal file
20
app/apps/transactions/admin.py
Normal file
20
app/apps/transactions/admin.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
|
||||
|
||||
|
||||
@admin.register(Transaction)
|
||||
class TransactionModelAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"description",
|
||||
"type",
|
||||
"account__name",
|
||||
"amount",
|
||||
"account__currency__code",
|
||||
"date",
|
||||
"reference_date",
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(TransactionCategory)
|
||||
admin.site.register(TransactionTag)
|
||||
6
app/apps/transactions/apps.py
Normal file
6
app/apps/transactions/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TransactionsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.transactions"
|
||||
46
app/apps/transactions/fields.py
Normal file
46
app/apps/transactions/fields.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import datetime
|
||||
|
||||
from django import forms
|
||||
from django.db import models
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.transactions.widgets import MonthYearWidget
|
||||
|
||||
|
||||
class MonthYearField(models.DateField):
|
||||
def to_python(self, value):
|
||||
if value is None or isinstance(value, datetime.date):
|
||||
return value
|
||||
|
||||
try:
|
||||
# Parse the input as year-month
|
||||
date = datetime.datetime.strptime(value, "%Y-%m")
|
||||
# Set the day to 1
|
||||
return date.replace(day=1).date()
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid date format. Use YYYY-MM.")
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
kwargs["widget"] = MonthYearWidget
|
||||
kwargs["form_class"] = MonthYearFormField
|
||||
return super().formfield(**kwargs)
|
||||
|
||||
|
||||
class MonthYearFormField(forms.DateField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.input_formats = ["%Y-%m"]
|
||||
|
||||
def to_python(self, value):
|
||||
if value in self.empty_values:
|
||||
return None
|
||||
try:
|
||||
date = datetime.datetime.strptime(value, "%Y-%m")
|
||||
return date.replace(day=1).date()
|
||||
except ValueError:
|
||||
raise ValidationError("Invalid date format. Use YYYY-MM.")
|
||||
|
||||
def prepare_value(self, value):
|
||||
if isinstance(value, datetime.date):
|
||||
return value.strftime("%Y-%m")
|
||||
return value
|
||||
75
app/apps/transactions/forms.py
Normal file
75
app/apps/transactions/forms.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit, Row, Column, Div, Field, Hidden
|
||||
|
||||
from .models import Transaction
|
||||
from apps.transactions.widgets import ArbitraryDecimalDisplayNumberInput
|
||||
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = [
|
||||
"account",
|
||||
"type",
|
||||
"is_paid",
|
||||
"date",
|
||||
"reference_date",
|
||||
"amount",
|
||||
"description",
|
||||
"notes",
|
||||
"category",
|
||||
"tags",
|
||||
]
|
||||
widgets = {
|
||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
labels = {
|
||||
"tags": mark_safe('<i class="fa-solid fa-hashtag me-1"></i>' + _("Tags")),
|
||||
"category": mark_safe(
|
||||
'<i class="fa-solid fa-icons me-1"></i>' + _("Category")
|
||||
),
|
||||
"notes": mark_safe(
|
||||
'<i class="fa-solid fa-align-justify me-1"></i>' + _("Notes")
|
||||
),
|
||||
"amount": mark_safe('<i class="fa-solid fa-coins me-1"></i>' + _("Amount")),
|
||||
"description": mark_safe(
|
||||
'<i class="fa-solid fa-quote-left me-1"></i>' + _("Name")
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
Field(
|
||||
"type",
|
||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||
),
|
||||
Switch("is_paid"),
|
||||
"account",
|
||||
Row(
|
||||
Column("date", css_class="form-group col-md-6 mb-0"),
|
||||
Column("reference_date", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"description",
|
||||
"amount",
|
||||
Field("category", css_class="select"),
|
||||
Field("tags", css_class="multiselect", size=1),
|
||||
"notes",
|
||||
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||
Submit("submit", "Save", css_class="btn btn-warning"),
|
||||
)
|
||||
if self.instance and self.instance.pk:
|
||||
decimal_places = self.instance.account.currency.decimal_places
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=decimal_places
|
||||
)
|
||||
26
app/apps/transactions/migrations/0001_initial.py
Normal file
26
app/apps/transactions/migrations/0001_initial.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-19 02:11
|
||||
|
||||
import apps.transactions.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Transaction',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('is_paid', models.BooleanField(default=True)),
|
||||
('date', models.DateField()),
|
||||
('reference_date', apps.transactions.fields.MonthYearField(help_text='Please enter a month and year in the format MM/YYYY.')),
|
||||
('description', models.CharField(max_length=500)),
|
||||
('notes', models.TextField(blank=True)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-19 13:35
|
||||
|
||||
import apps.transactions.fields
|
||||
import apps.transactions.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
('transactions', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='account',
|
||||
field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.PROTECT, to='accounts.account', verbose_name='Account'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='amount',
|
||||
field=models.DecimalField(decimal_places=18, default=0, max_digits=30, validators=[apps.transactions.validators.validate_non_negative, apps.transactions.validators.validate_decimal_places], verbose_name='Amount'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='date',
|
||||
field=models.DateField(verbose_name='Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='description',
|
||||
field=models.CharField(max_length=500, verbose_name='Description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='is_paid',
|
||||
field=models.BooleanField(default=True, verbose_name='Paid'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='notes',
|
||||
field=models.TextField(blank=True, verbose_name='Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='reference_date',
|
||||
field=apps.transactions.fields.MonthYearField(verbose_name='Reference Date'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-20 03:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
('transactions', '0002_transaction_account_transaction_amount_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('IN', 'Income'), ('EX', 'Expense')], default='EX', max_length=2, verbose_name='Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='account',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='accounts.account', verbose_name='Account'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-23 04:05
|
||||
|
||||
import apps.transactions.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0003_transaction_type_alter_transaction_account'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='amount',
|
||||
field=models.DecimalField(decimal_places=30, max_digits=42, validators=[apps.transactions.validators.validate_non_negative, apps.transactions.validators.validate_decimal_places], verbose_name='Amount'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-24 17:08
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0004_alter_transaction_amount'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TransactionCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='Name')),
|
||||
('mute', models.BooleanField(default=False, verbose_name='Mute')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Transaction Category',
|
||||
'verbose_name_plural': 'Transaction Categories',
|
||||
'db_table': 'transaction_category',
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='transaction',
|
||||
options={'verbose_name': 'Transaction', 'verbose_name_plural': 'Transactions'},
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='transaction',
|
||||
table='transactions',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-24 17:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0005_transactioncategory_alter_transaction_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='category',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='transactions.transactioncategory', verbose_name='Category'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-25 03:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0006_transaction_category'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TransactionTags',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=255, verbose_name='Name')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Transaction Tags',
|
||||
'verbose_name_plural': 'Transaction Tags',
|
||||
'db_table': 'tags',
|
||||
},
|
||||
),
|
||||
migrations.AlterModelTable(
|
||||
name='transactioncategory',
|
||||
table='t_categories',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transaction',
|
||||
name='tags',
|
||||
field=models.ManyToManyField(to='transactions.transactiontags', verbose_name='Tags'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-25 03:11
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0007_transactiontags_alter_transactioncategory_table_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='TransactionTags',
|
||||
new_name='TransactionTag',
|
||||
),
|
||||
]
|
||||
0
app/apps/transactions/migrations/__init__.py
Normal file
0
app/apps/transactions/migrations/__init__.py
Normal file
81
app/apps/transactions/models.py
Normal file
81
app/apps/transactions/models.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.functions.decimals import truncate_decimal
|
||||
from apps.transactions.fields import MonthYearField
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
|
||||
|
||||
class TransactionCategory(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction Category")
|
||||
verbose_name_plural = _("Transaction Categories")
|
||||
db_table = "t_categories"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class TransactionTag(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction Tags")
|
||||
verbose_name_plural = _("Transaction Tags")
|
||||
db_table = "tags"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
class Type(models.TextChoices):
|
||||
INCOME = "IN", _("Income")
|
||||
EXPENSE = "EX", _("Expense")
|
||||
|
||||
account = models.ForeignKey(
|
||||
"accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account")
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=2,
|
||||
choices=Type,
|
||||
default=Type.EXPENSE,
|
||||
verbose_name=_("Type"),
|
||||
)
|
||||
is_paid = models.BooleanField(default=True, verbose_name=_("Paid"))
|
||||
date = models.DateField(verbose_name=_("Date"))
|
||||
reference_date = MonthYearField(verbose_name=_("Reference Date"))
|
||||
|
||||
amount = models.DecimalField(
|
||||
max_digits=42,
|
||||
decimal_places=30,
|
||||
verbose_name=_("Amount"),
|
||||
validators=[validate_non_negative, validate_decimal_places],
|
||||
)
|
||||
|
||||
description = models.CharField(max_length=500, verbose_name=_("Description"))
|
||||
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||
category = models.ForeignKey(
|
||||
TransactionCategory,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name=_("Category"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction")
|
||||
verbose_name_plural = _("Transactions")
|
||||
db_table = "transactions"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.amount = truncate_decimal(
|
||||
value=self.amount, decimal_places=self.account.currency.decimal_places
|
||||
)
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
0
app/apps/transactions/templatetags/__init__.py
Normal file
0
app/apps/transactions/templatetags/__init__.py
Normal file
31
app/apps/transactions/templatetags/currency_display.py
Normal file
31
app/apps/transactions/templatetags/currency_display.py
Normal file
@@ -0,0 +1,31 @@
|
||||
import datetime
|
||||
from django import template
|
||||
from django.template.defaultfilters import floatformat
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def _format_string(prefix, amount, decimal_places, suffix):
|
||||
return f"{prefix}{floatformat(amount, decimal_places)}{suffix}"
|
||||
|
||||
|
||||
@register.simple_tag(name="transaction_amount")
|
||||
def transaction_currency(transaction: Transaction):
|
||||
prefix = transaction.account.currency.prefix
|
||||
amount = transaction.amount
|
||||
decimal_places = transaction.account.currency.decimal_places
|
||||
suffix = transaction.account.currency.suffix
|
||||
|
||||
return _format_string(prefix, amount, decimal_places, suffix)
|
||||
|
||||
|
||||
@register.simple_tag(name="entry_amount")
|
||||
def entry_currency(entry):
|
||||
prefix = entry["prefix"]
|
||||
amount = entry["total_amount"]
|
||||
decimal_places = entry["decimal_places"]
|
||||
suffix = entry["suffix"]
|
||||
|
||||
return _format_string(prefix, amount, decimal_places, suffix)
|
||||
3
app/apps/transactions/tests.py
Normal file
3
app/apps/transactions/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
47
app/apps/transactions/urls.py
Normal file
47
app/apps/transactions/urls.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path(
|
||||
"transactions/<int:month>/<int:year>/",
|
||||
views.transactions_overview,
|
||||
name="transactions_overview",
|
||||
),
|
||||
path(
|
||||
"transactions/<int:month>/<int:year>/list/",
|
||||
views.transactions_list,
|
||||
name="transactions_list",
|
||||
),
|
||||
path(
|
||||
"transactions/<int:month>/<int:year>/summary/",
|
||||
views.monthly_summary,
|
||||
name="monthly_summary",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/pay",
|
||||
views.transaction_pay,
|
||||
name="transaction_pay",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/delete",
|
||||
views.transaction_delete,
|
||||
name="transaction_delete",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/edit",
|
||||
views.transaction_edit,
|
||||
name="transaction_edit",
|
||||
),
|
||||
path(
|
||||
"transaction/add",
|
||||
views.transaction_add,
|
||||
name="transaction_add",
|
||||
),
|
||||
path(
|
||||
"available_dates/",
|
||||
views.month_year_picker,
|
||||
name="available_dates",
|
||||
),
|
||||
]
|
||||
18
app/apps/transactions/validators.py
Normal file
18
app/apps/transactions/validators.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def validate_decimal_places(value):
|
||||
if abs(value.as_tuple().exponent) > 18:
|
||||
raise ValidationError(
|
||||
_("%(value)s has too many decimal places. Maximum is 18."),
|
||||
params={"value": value},
|
||||
)
|
||||
|
||||
|
||||
def validate_non_negative(value):
|
||||
if value < 0:
|
||||
raise ValidationError(
|
||||
_("%(value)s is not a non-negative number"),
|
||||
params={"value": value},
|
||||
)
|
||||
387
app/apps/transactions/views.py
Normal file
387
app/apps/transactions/views.py
Normal file
@@ -0,0 +1,387 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
from operator import itemgetter
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Sum, F, Case, When, DecimalField, Value, Q, CharField
|
||||
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from unicodedata import category
|
||||
|
||||
from apps.transactions.forms import TransactionForm
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
now = timezone.now()
|
||||
|
||||
return redirect(to="transactions_overview", month=now.month, year=now.year)
|
||||
|
||||
|
||||
@login_required
|
||||
def transactions_overview(request, month: int, year: int):
|
||||
if month < 1 or month > 12:
|
||||
from django.http import Http404
|
||||
|
||||
raise Http404("Month is out of range")
|
||||
|
||||
next_month = 1 if month == 12 else month + 1
|
||||
next_year = year + 1 if next_month == 1 and month == 12 else year
|
||||
|
||||
previous_month = 12 if month == 1 else month - 1
|
||||
previous_year = year - 1 if previous_month == 12 and month == 1 else year
|
||||
|
||||
print(
|
||||
Transaction.objects.annotate(
|
||||
month=ExtractMonth("reference_date"), year=ExtractYear("reference_date")
|
||||
)
|
||||
.values("month", "year")
|
||||
.distinct()
|
||||
.order_by("year", "month")
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/overview.html",
|
||||
context={
|
||||
"month": month,
|
||||
"year": year,
|
||||
"next_month": next_month,
|
||||
"next_year": next_year,
|
||||
"previous_month": previous_month,
|
||||
"previous_year": previous_year,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transactions_list(request, month: int, year: int):
|
||||
from django.db.models.functions import ExtractMonth, ExtractYear
|
||||
|
||||
queryset = (
|
||||
Transaction.objects.annotate(
|
||||
month=ExtractMonth("reference_date"), year=ExtractYear("reference_date")
|
||||
)
|
||||
.values("month", "year")
|
||||
.distinct()
|
||||
.order_by("year", "month")
|
||||
)
|
||||
# print(queryset)
|
||||
|
||||
transactions = (
|
||||
Transaction.objects.all()
|
||||
.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.order_by("date", "id")
|
||||
.select_related()
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/list.html",
|
||||
context={"transactions": transactions},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_add(request, **kwargs):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully!"))
|
||||
|
||||
# redirect to a new URL:
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"reference_date": expected_date,
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_edit(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, instance=transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction updated successfully!"))
|
||||
|
||||
# redirect to a new URL:
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(instance=transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/edit.html",
|
||||
{"form": form, "transaction": transaction},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_delete(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
transaction.delete()
|
||||
|
||||
messages.success(request, _("Transaction deleted successfully!"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, toast"},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_pay(request, transaction_id):
|
||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
new_is_paid = False if transaction.is_paid else True
|
||||
transaction.is_paid = new_is_paid
|
||||
transaction.save()
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"transactions/fragments/item.html",
|
||||
context={"transaction": transaction},
|
||||
)
|
||||
response.headers["HX-Trigger"] = (
|
||||
f'{"paid" if new_is_paid else "unpaid"}, transaction_updated'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def monthly_summary(request, month: int, year: int):
|
||||
queryset = (
|
||||
Transaction.objects.filter(
|
||||
Q(category__mute=False) | Q(category__isnull=True),
|
||||
account__is_asset=False,
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.annotate(
|
||||
transaction_type=Value("expense", output_field=CharField()),
|
||||
is_paid_status=Value("paid", output_field=CharField()),
|
||||
)
|
||||
.filter(type=Transaction.Type.EXPENSE, is_paid=True)
|
||||
.values(
|
||||
"account__currency__name",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__decimal_places",
|
||||
"transaction_type",
|
||||
"is_paid_status",
|
||||
)
|
||||
.annotate(
|
||||
total_amount=Coalesce(
|
||||
Sum("amount"),
|
||||
0,
|
||||
output_field=DecimalField(max_digits=30, decimal_places=18),
|
||||
)
|
||||
)
|
||||
.union(
|
||||
Transaction.objects.filter(
|
||||
Q(category__mute=False) | Q(category__isnull=True),
|
||||
account__is_asset=False,
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.annotate(
|
||||
transaction_type=Value("expense", output_field=CharField()),
|
||||
is_paid_status=Value("projected", output_field=CharField()),
|
||||
)
|
||||
.filter(type=Transaction.Type.EXPENSE, is_paid=False)
|
||||
.values(
|
||||
"account__currency__name",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__decimal_places",
|
||||
"transaction_type",
|
||||
"is_paid_status",
|
||||
)
|
||||
.annotate(
|
||||
total_amount=Coalesce(
|
||||
Sum("amount"),
|
||||
0,
|
||||
output_field=DecimalField(max_digits=30, decimal_places=18),
|
||||
)
|
||||
)
|
||||
)
|
||||
.union(
|
||||
Transaction.objects.filter(
|
||||
Q(category__mute=False) | Q(category__isnull=True),
|
||||
account__is_asset=False,
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.annotate(
|
||||
transaction_type=Value("income", output_field=CharField()),
|
||||
is_paid_status=Value("paid", output_field=CharField()),
|
||||
)
|
||||
.filter(type=Transaction.Type.INCOME, is_paid=True)
|
||||
.values(
|
||||
"account__currency__name",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__decimal_places",
|
||||
"transaction_type",
|
||||
"is_paid_status",
|
||||
)
|
||||
.annotate(
|
||||
total_amount=Coalesce(
|
||||
Sum("amount"),
|
||||
0,
|
||||
output_field=DecimalField(max_digits=30, decimal_places=18),
|
||||
)
|
||||
)
|
||||
)
|
||||
.union(
|
||||
Transaction.objects.filter(
|
||||
Q(category__mute=False) | Q(category__isnull=True),
|
||||
account__is_asset=False,
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.annotate(
|
||||
transaction_type=Value("income", output_field=CharField()),
|
||||
is_paid_status=Value("projected", output_field=CharField()),
|
||||
)
|
||||
.filter(type=Transaction.Type.INCOME, is_paid=False)
|
||||
.values(
|
||||
"account__currency__name",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__decimal_places",
|
||||
"transaction_type",
|
||||
"is_paid_status",
|
||||
)
|
||||
.annotate(
|
||||
total_amount=Coalesce(
|
||||
Sum("amount"),
|
||||
0,
|
||||
output_field=DecimalField(max_digits=30, decimal_places=18),
|
||||
)
|
||||
)
|
||||
)
|
||||
.order_by("account__currency__name", "transaction_type", "is_paid_status")
|
||||
)
|
||||
|
||||
result = {}
|
||||
for (transaction_type, is_paid_status), group in groupby(
|
||||
queryset, key=itemgetter("transaction_type", "is_paid_status")
|
||||
):
|
||||
key = f"{is_paid_status}_{transaction_type}"
|
||||
result[key] = [
|
||||
{
|
||||
"name": item["account__currency__name"],
|
||||
"prefix": item["account__currency__prefix"],
|
||||
"suffix": item["account__currency__suffix"],
|
||||
"decimal_places": item["account__currency__decimal_places"],
|
||||
"total_amount": item["total_amount"],
|
||||
}
|
||||
for item in group
|
||||
]
|
||||
|
||||
# result["total_balance"] =
|
||||
# result["projected_balance"] = calculate_total(
|
||||
# "projected_income", "projected_expenses"
|
||||
# )
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/monthly_summary.html",
|
||||
context={"totals": result},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def month_year_picker(request):
|
||||
available_dates = (
|
||||
Transaction.objects.annotate(
|
||||
month=ExtractMonth("reference_date"), year=ExtractYear("reference_date")
|
||||
)
|
||||
.values("month", "year")
|
||||
.distinct()
|
||||
.order_by("year", "month")
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/month_year_picker.html",
|
||||
{"available_dates": available_dates},
|
||||
)
|
||||
|
||||
|
||||
# @login_required
|
||||
# def monthly_income(request, month: int, year: int):
|
||||
# situation = request.GET.get("s", "c")
|
||||
#
|
||||
# income_sum_by_currency = (
|
||||
# Transaction.objects.filter(
|
||||
# type=Transaction.Type.INCOME,
|
||||
# is_paid=True if situation == "c" else False,
|
||||
# account__is_asset=False,
|
||||
# reference_date__year=year,
|
||||
# reference_date__month=month,
|
||||
# )
|
||||
# .values(
|
||||
# "account__currency__name",
|
||||
# "account__currency__prefix",
|
||||
# "account__currency__suffix",
|
||||
# "account__currency__decimal_places",
|
||||
# )
|
||||
# .annotate(
|
||||
# total_amount=Coalesce(
|
||||
# Sum("amount"),
|
||||
# 0,
|
||||
# output_field=DecimalField(max_digits=30, decimal_places=18),
|
||||
# )
|
||||
# )
|
||||
# .order_by("account__currency__name")
|
||||
# )
|
||||
#
|
||||
# print(income_sum_by_currency)
|
||||
#
|
||||
# return render(
|
||||
# request,
|
||||
# "transactions/fragments/income.html",
|
||||
# context={"income": income_sum_by_currency},
|
||||
# )
|
||||
34
app/apps/transactions/widgets.py
Normal file
34
app/apps/transactions/widgets.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from datetime import datetime, date
|
||||
|
||||
from django import forms
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class MonthYearWidget(forms.DateInput):
|
||||
"""
|
||||
Custom widget to display a month-year picker.
|
||||
"""
|
||||
|
||||
input_type = "month" # Set the input type to 'month'
|
||||
|
||||
def format_value(self, value):
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.strftime("%Y-%m")
|
||||
return value
|
||||
|
||||
|
||||
class ArbitraryDecimalDisplayNumberInput(forms.NumberInput):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.decimal_places = kwargs.pop("decimal_places", 2)
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs.update({"step": f".{'0' * (self.decimal_places - 1)}1"})
|
||||
|
||||
def format_value(self, value):
|
||||
if value is not None and isinstance(value, Decimal):
|
||||
# Strip trailing 0s, leaving a minimum of 2 decimal places
|
||||
while (
|
||||
abs(value.as_tuple().exponent) > self.decimal_places
|
||||
and value.as_tuple().digits[-1] == 0
|
||||
):
|
||||
value = Decimal(str(value)[:-1])
|
||||
return value
|
||||
0
app/apps/users/__init__.py
Normal file
0
app/apps/users/__init__.py
Normal file
69
app/apps/users/admin.py
Normal file
69
app/apps/users/admin.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from django.contrib.admin import ModelAdmin
|
||||
from django.contrib.auth.forms import (
|
||||
UserChangeForm,
|
||||
UserCreationForm,
|
||||
AdminPasswordChangeForm,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from apps.users.models import User
|
||||
|
||||
admin.site.unregister(Group)
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin, ModelAdmin):
|
||||
ordering = ("email",)
|
||||
exclude = ("username",)
|
||||
list_display = ("email", "is_staff")
|
||||
search_fields = ("first_name", "last_name", "email")
|
||||
|
||||
form = UserChangeForm
|
||||
add_form = UserCreationForm
|
||||
change_password_form = AdminPasswordChangeForm
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"email",
|
||||
"password",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("first_name", "last_name")}),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
),
|
||||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": ("email", "password1", "password2"),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Group)
|
||||
class GroupAdmin(BaseGroupAdmin, ModelAdmin):
|
||||
pass
|
||||
6
app/apps/users/apps.py
Normal file
6
app/apps/users/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.users"
|
||||
44
app/apps/users/forms.py
Normal file
44
app/apps/users/forms.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from crispy_forms.bootstrap import (
|
||||
FormActions,
|
||||
PrependedText,
|
||||
Alert,
|
||||
)
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit, Div, HTML
|
||||
from django import forms
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.forms import (
|
||||
# AuthenticationForm,
|
||||
UsernameField,
|
||||
PasswordResetForm,
|
||||
SetPasswordForm,
|
||||
PasswordChangeForm,
|
||||
UserCreationForm,
|
||||
)
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_recaptcha.fields import ReCaptchaField
|
||||
from django_recaptcha.widgets import ReCaptchaV3
|
||||
from unfold.forms import AuthenticationForm
|
||||
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
username = UsernameField(
|
||||
label="Seu e-mail",
|
||||
widget=forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "E-mail"}
|
||||
),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label="Sua senha",
|
||||
strip=False,
|
||||
widget=forms.PasswordInput(
|
||||
attrs={"class": "form-control", "placeholder": "Senha"}
|
||||
),
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
"invalid_login": _("E-mail ou senha inválidos."),
|
||||
"inactive": _("Esta conta esta desativada."),
|
||||
}
|
||||
30
app/apps/users/managers.py
Normal file
30
app/apps/users/managers.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.contrib.auth.base_user import BaseUserManager
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
use_in_migrations = True
|
||||
|
||||
def _create_user(self, email, password, **extra_fields):
|
||||
if not email:
|
||||
raise ValueError("Users require an email field")
|
||||
email = self.normalize_email(email)
|
||||
user = self.model(email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", False)
|
||||
extra_fields.setdefault("is_superuser", False)
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, email, password, **extra_fields):
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
raise ValueError("Superuser must have is_staff=True.")
|
||||
if extra_fields.get("is_superuser") is not True:
|
||||
raise ValueError("Superuser must have is_superuser=True.")
|
||||
|
||||
return self._create_user(email, password, **extra_fields)
|
||||
42
app/apps/users/migrations/0001_initial.py
Normal file
42
app/apps/users/migrations/0001_initial.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-19 01:32
|
||||
|
||||
import apps.users.managers
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='E-mail')),
|
||||
('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={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
managers=[
|
||||
('objects', apps.users.managers.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
0
app/apps/users/migrations/__init__.py
Normal file
0
app/apps/users/migrations/__init__.py
Normal file
19
app/apps/users/models.py
Normal file
19
app/apps/users/models.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.users.managers import UserManager
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
username = None
|
||||
email = models.EmailField(_("E-mail"), unique=True)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.first_name} {self.last_name} ({self.email})"
|
||||
9
app/apps/users/urls.py
Normal file
9
app/apps/users/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("login/", views.UserLoginView.as_view(), name="login"),
|
||||
# path("login/fallback/", views.UserLoginView.as_view(), name="fallback_login"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
]
|
||||
23
app/apps/users/views.py
Normal file
23
app/apps/users/views.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.views import (
|
||||
LoginView,
|
||||
)
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
from apps.users.forms import (
|
||||
LoginForm,
|
||||
EmailLoginForm,
|
||||
)
|
||||
|
||||
|
||||
def logout_view(request):
|
||||
logout(request)
|
||||
return redirect(reverse("inicio"))
|
||||
|
||||
|
||||
class UserLoginView(LoginView):
|
||||
form_class = LoginForm
|
||||
# template_name = "users/login.html"
|
||||
redirect_authenticated_user = True
|
||||
1
app/fixtures/a.json
Normal file
1
app/fixtures/a.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
22
app/manage.py
Normal file
22
app/manage.py
Normal file
@@ -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", "WYGIWYH.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()
|
||||
7
app/static/img/loading/ripple.svg
Normal file
7
app/static/img/loading/ripple.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid" width="200" height="200" style="shape-rendering: auto; display: block;" xmlns:xlink="http://www.w3.org/1999/xlink"><g><circle stroke-width="2" stroke="#e90c59" fill="none" r="0" cy="50" cx="50">
|
||||
<animate begin="0s" calcMode="spline" keySplines="0 0.2 0.8 1" keyTimes="0;1" values="0;40" dur="1s" repeatCount="indefinite" attributeName="r"></animate>
|
||||
<animate begin="0s" calcMode="spline" keySplines="0.2 0 0.8 1" keyTimes="0;1" values="1;0" dur="1s" repeatCount="indefinite" attributeName="opacity"></animate>
|
||||
</circle><circle stroke-width="2" stroke="#46dff0" fill="none" r="0" cy="50" cx="50">
|
||||
<animate begin="-0.5s" calcMode="spline" keySplines="0 0.2 0.8 1" keyTimes="0;1" values="0;40" dur="1s" repeatCount="indefinite" attributeName="r"></animate>
|
||||
<animate begin="-0.5s" calcMode="spline" keySplines="0.2 0 0.8 1" keyTimes="0;1" values="1;0" dur="1s" repeatCount="indefinite" attributeName="opacity"></animate>
|
||||
</circle><g></g></g><!-- [ldio] generated by https://loading.io --></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
app/static/sounds/pop.mp3
Normal file
BIN
app/static/sounds/pop.mp3
Normal file
Binary file not shown.
BIN
app/static/sounds/success.mp3
Normal file
BIN
app/static/sounds/success.mp3
Normal file
Binary file not shown.
21
app/templates/common/toasts.html
Normal file
21
app/templates/common/toasts.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% load toast_bg %}
|
||||
{% if messages %}
|
||||
<div class="toast-container position-fixed top-0 start-50 translate-middle-x p-3">
|
||||
{% for message in messages %}
|
||||
<div class="toast align-items-center text-bg-{{ message.tags | toast_bg }} border-0"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<i class="{{ message.tags | toast_icon }} fa-fw me-1"></i>
|
||||
<strong class="me-auto">{{ message.tags | toast_title }}</strong>
|
||||
<button type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="toast"
|
||||
aria-label="Fechar"></button>
|
||||
</div>
|
||||
<div class="toast-body">{{ message }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
9
app/templates/extends/offcanvas.html
Normal file
9
app/templates/extends/offcanvas.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% load webpack_loader %}
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title">{% block title %}{% endblock %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div id="generic-offcanvas-body" class="offcanvas-body">
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
{% javascript_pack 'select' %}
|
||||
5
app/templates/includes/offcanvas.html
Normal file
5
app/templates/includes/offcanvas.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<div id="generic-offcanvas" class="offcanvas offcanvas-end offcanvas-size-xl"
|
||||
_="on htmx:afterSettle call bootstrap.Offcanvas.getOrCreateInstance(me).show() end
|
||||
on hide_offcanvas call bootstrap.Offcanvas.getOrCreateInstance(me).hide() end
|
||||
on hidden.bs.offcanvas set my innerHTML to '' end">
|
||||
</div>
|
||||
7
app/templates/includes/scripts.html
Normal file
7
app/templates/includes/scripts.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% load webpack_loader %}
|
||||
|
||||
{% javascript_pack 'bootstrap' %}
|
||||
{% javascript_pack 'sweetalert2' %}
|
||||
{% javascript_pack 'htmx' %}
|
||||
{% javascript_pack 'jquery' %}
|
||||
{#{% javascript_pack 'select' %}#}
|
||||
4
app/templates/includes/styles.html
Normal file
4
app/templates/includes/styles.html
Normal file
@@ -0,0 +1,4 @@
|
||||
{% load webpack_loader %}
|
||||
|
||||
{% stylesheet_pack 'style' %}
|
||||
{#{% stylesheet_pack 'select' %}#}
|
||||
3
app/templates/includes/toasts.html
Normal file
3
app/templates/includes/toasts.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div id="toasts" hx-get="{% url 'toasts' %}"
|
||||
hx-trigger="load, toast from:window">
|
||||
</div>
|
||||
26
app/templates/layouts/base.html
Normal file
26
app/templates/layouts/base.html
Normal file
@@ -0,0 +1,26 @@
|
||||
{% load webpack_loader %}
|
||||
{% load tz_detect %}
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}WYGIWYH{% endblock %}</title>
|
||||
|
||||
{% include 'includes/styles.html' %}
|
||||
{% block extra_styles %}{% endblock %}
|
||||
|
||||
{% tz_detect %}
|
||||
</head>
|
||||
<body>
|
||||
<div id="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
{% include 'includes/offcanvas.html' %}
|
||||
{% include 'includes/toasts.html' %}
|
||||
|
||||
{% include 'includes/scripts.html' %}
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
12
app/templates/transactions/fragments/add.html
Normal file
12
app/templates/transactions/fragments/add.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% translate 'Adding transaction' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form hx-post="{% url 'transaction_add' %}" hx-target="#generic-offcanvas" novalidate>
|
||||
{% csrf_token %}
|
||||
{% crispy form %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
12
app/templates/transactions/fragments/edit.html
Normal file
12
app/templates/transactions/fragments/edit.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
{% block title %}{% translate 'Editing transaction' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form hx-post="{% url 'transaction_edit' transaction_id=transaction.id %}" hx-target="#generic-offcanvas" novalidate>
|
||||
{% csrf_token %}
|
||||
{% crispy form %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -1,38 +1,123 @@
|
||||
<div class="row border-bottom border-top p-1 hover:tw-bg-zinc-900 font-monospace tw-text-sm transaction
|
||||
{% if transaction.account.is_asset %}tw-opacity-70{% endif %}">
|
||||
<div class="col-lg-auto col-6">
|
||||
<input class="form-check-input" type="checkbox" id="checkboxNoLabel" value="" aria-label="...">
|
||||
{% load i18n %}
|
||||
{% load currency_display %}
|
||||
<div class="tw-border-s-2 tw-border-e-0 tw-border-t-0 tw-border-b-0 border-bottom
|
||||
hover:tw-bg-zinc-900 p-2 {% if transaction.account.is_asset %}tw-border-dashed{% else %}tw-border-solid{% endif %}
|
||||
{% if transaction.type == "EX" %}tw-border-red-500{% else %}tw-border-green-500{% endif %} transaction tw-relative"
|
||||
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
|
||||
on mouseout add .tw-invisible to the first .transaction-actions in me end">
|
||||
<div class="row font-monospace tw-text-sm align-items-center">
|
||||
<div class="col-lg-1 col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center">
|
||||
<a class="text-decoration-none my-2 w-100"
|
||||
role="button"
|
||||
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
|
||||
hx-target="closest .transaction"
|
||||
hx-swap="outerHTML">
|
||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check tw-text-green-400"></i>{% else %}<i
|
||||
class="fa-regular fa-circle tw-text-red-400"></i>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-8 col-12">
|
||||
<div class="mb-1 text-white tw-text-base">{{ transaction.description }}</div>
|
||||
<div class="text-white-50 mb-2 d-flex align-items-center">
|
||||
<i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i>
|
||||
{{ transaction.date|date:"SHORT_DATE_FORMAT" }}</div>
|
||||
<div class="text-white-50 mb-1 tw-text-xs">
|
||||
{% for tag in transaction.tags.all %}
|
||||
<span><i class="fa-solid fa-hashtag tw-text-gray-400"></i>{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="tw-text-sm mb-1 mb-lg-0 tw-text-gray-400 font-normal">
|
||||
<i class="fa-solid fa-note-sticky me-1"></i>{{ transaction.notes | linebreaksbr }}
|
||||
</div>
|
||||
<div class="tw-text-sm mb-1 mb-lg-0">
|
||||
{% if transaction.category %}
|
||||
<i class="fa-solid fa-icons text-primary"></i>
|
||||
<span class="badge rounded-0
|
||||
{% if transaction.category.mute %}text-bg-secondary{% else %}text-bg-light{% endif %}">
|
||||
{{ transaction.category.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<a class="text-decoration-none"
|
||||
role="button"
|
||||
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
|
||||
hx-target="closest .transaction"
|
||||
hx-swap="outerHTML">
|
||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check tw-text-green-400"></i>{% else %}<i
|
||||
class="fa-regular fa-circle tw-text-red-400"></i>{% endif %}
|
||||
</a>
|
||||
<a class="text-decoration-none text-secondary"
|
||||
role="button"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas"
|
||||
data-bs-toggle="offcanvas"
|
||||
data-bs-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-lg-auto col-md-2 col-12 text-truncate">
|
||||
{{ transaction.date|date:"SHORT_DATE_FORMAT" }}
|
||||
</div>
|
||||
<div class="col-lg-5 col-12 text-truncate" title="{{ transaction.description }}">
|
||||
{{ transaction.description }}
|
||||
</div>
|
||||
<div class="col-lg-2 col-12 text-truncate">
|
||||
{{ transaction.account.name }}
|
||||
</div>
|
||||
<div class="col-lg-auto col-md-2 col-12 {% if transaction.type == "EX" %}tw-text-red-400{% else %}tw-text-green-400{% endif %}
|
||||
text-truncate">
|
||||
{{ transaction.account.currency.prefix }}
|
||||
{{ transaction.amount | floatformat:transaction.account.currency.decimal_places }}
|
||||
{{ transaction.account.currency.suffix }}
|
||||
</div>
|
||||
<div class="col-lg-3 col-12 text-lg-end align-self-end">
|
||||
<div class="{% if transaction.type == "EX" %}tw-text-red-400{% else %}tw-text-green-400{% endif %}">
|
||||
{% transaction_amount transaction %}
|
||||
</div>
|
||||
<div>{{ transaction.account.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="transaction-actions !tw-absolute tw-top-0 tw-right-0 tw-invisible tw-text-base d-none
|
||||
d-xl-flex">
|
||||
<a class="text-danger text-decoration-none p-2 transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-get="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
_="on click send transaction_action_clicked to .transaction-action in the closest parent .transaction end
|
||||
on transaction_action_clicked call bootstrap.Tooltip.getOrCreateInstance(me).dispose() end
|
||||
on click
|
||||
call Swal.fire({title: '{% translate 'Are you sure?' %}',
|
||||
text: '{% blocktranslate %}You won\'t be able to revert this!{% endblocktranslate %}',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#d33',
|
||||
cancelButtonText: '{% blocktranslate %}Cancel{% endblocktranslate %}',
|
||||
confirmButtonText: '{% blocktranslate %}Yes, delete it!{% endblocktranslate %}',
|
||||
customClass: {
|
||||
confirmButton: 'btn btn-primary me-3',
|
||||
cancelButton: 'btn btn-danger'
|
||||
},
|
||||
buttonsStyling: false})
|
||||
if result.isConfirmed trigger confirmed"><i class="fa-solid fa-trash fa-fw"></i></a>
|
||||
<a class="text-decoration-none tw-text-gray-400 p-2 transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas"
|
||||
_="on click send transaction_action_clicked to .transaction-action in the closest parent .transaction end
|
||||
on transaction_action_clicked call bootstrap.Tooltip.getOrCreateInstance(me).dispose() end">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
</div>
|
||||
<div class="dropdown !tw-absolute tw-top-0 tw-right-0 xl:tw-invisible">
|
||||
<a class="btn tw-text-2xl lg:tw-text-sm lg:tw-border-none text-light" type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
<i class="fa-solid fa-ellipsis fa-fw"></i>
|
||||
</a>
|
||||
<ul class="dropdown-menu tw-text-base">
|
||||
<li><a class="dropdown-item"
|
||||
role="button"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil me-3"></i>{% translate "Edit" %}
|
||||
</a></li>
|
||||
<li><a class="dropdown-item"
|
||||
role="button"
|
||||
hx-get="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
_="on click
|
||||
call Swal.fire({title: '{% translate 'Are you sure?' %}',
|
||||
text: '{% blocktranslate %}You won\'t be able to revert this!{% endblocktranslate %}',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#3085d6',
|
||||
cancelButtonColor: '#d33',
|
||||
cancelButtonText: '{% blocktranslate %}Cancel{% endblocktranslate %}',
|
||||
confirmButtonText: '{% blocktranslate %}Yes, delete it!{% endblocktranslate %}',
|
||||
customClass: {
|
||||
confirmButton: 'btn btn-primary me-3',
|
||||
cancelButton: 'btn btn-danger'
|
||||
},
|
||||
buttonsStyling: false})
|
||||
if result.isConfirmed trigger confirmed">
|
||||
<i class="fa-solid fa-trash me-3"></i>{% translate "Delete" %}
|
||||
</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
{% for trans in transactions %}
|
||||
{% include 'transactions/fragments/item.html' with transaction=trans %}
|
||||
{% endfor %}
|
||||
<div class="">
|
||||
{% for trans in transactions %}
|
||||
{% include 'transactions/fragments/item.html' with transaction=trans %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
27
app/templates/transactions/fragments/month_year_picker.html
Normal file
27
app/templates/transactions/fragments/month_year_picker.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends 'extends/offcanvas.html' %}
|
||||
{% load month_name %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% translate 'Pick one' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="form-floating mb-3">
|
||||
<input type="search" class="form-control" id="floatingInput" placeholder="{% translate 'Search' %}"
|
||||
_="on change or input
|
||||
if the event's key is 'Escape'
|
||||
set my value to ''
|
||||
trigger keyup
|
||||
else
|
||||
show <li/> in #month-year-list when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||
<label for="floatingInput">{% translate 'Search' %}</label>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" id="month-year-list">
|
||||
{% for date in available_dates %}
|
||||
<li class="list-group-item hover:tw-bg-zinc-900">
|
||||
<a class="text-decoration-none stretched-link" href="{% url "transactions_overview" month=date.month year=date.year %}">
|
||||
{{ date.month|month_name }} / {{ date.year }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
123
app/templates/transactions/fragments/monthly_summary.html
Normal file
123
app/templates/transactions/fragments/monthly_summary.html
Normal file
@@ -0,0 +1,123 @@
|
||||
{% load i18n %}
|
||||
{% load currency_display %}
|
||||
<div class="text-bg-secondary p-2 rounded-2 shadow tw-text-sm">
|
||||
<p class="font-monospace text-light text-uppercase text-center fw-bold m-0 tw-text-base">{% translate "Summary" %}</p>
|
||||
<hr class="my-1">
|
||||
<div>
|
||||
<p class="font-monospace text-uppercase text-center fw-bold m-0">{% translate "Presente" %}</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="font-monospace text-primary text-start align-self-end fw-bold m-0">{% translate "Earned Income" %}</div>
|
||||
</div>
|
||||
<div class="col-6 text-end font-monospace">
|
||||
{% for entry in totals.paid_income %}
|
||||
<div>{% entry_amount entry %}</div>
|
||||
{% empty %}
|
||||
<div>-</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-3"></div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="font-monospace text-primary text-start align-self-end fw-bold m-0">{% translate "Projected Income" %}</div>
|
||||
</div>
|
||||
<div class="col-6 text-end font-monospace">
|
||||
{% for entry in totals.projected_income %}
|
||||
<div>{% entry_amount entry %}</div>
|
||||
{% empty %}
|
||||
<div>-</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-1">
|
||||
<div>
|
||||
<p class="font-monospace text-uppercase text-center fw-bold m-0">{% translate "Projected" %}</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="font-monospace text-primary text-start align-self-end fw-bold m-0">{% translate "Total Expenses" %}</div>
|
||||
</div>
|
||||
<div class="col-6 text-end font-monospace">
|
||||
{% for entry in totals.paid_expense %}
|
||||
<div>{% entry_amount entry %}</div>
|
||||
{% empty %}
|
||||
<div>-</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-3"></div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="font-monospace text-primary text-start align-self-end fw-bold m-0">{% translate "Projected Expenses" %}</div>
|
||||
</div>
|
||||
<div class="col-6 text-end font-monospace">
|
||||
{% for entry in totals.projected_expense %}
|
||||
<div>{% entry_amount entry %}</div>
|
||||
{% empty %}
|
||||
<div>-</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-1">
|
||||
<div>
|
||||
<p class="font-monospace text-uppercase text-center fw-bold m-0">{% translate "Total" %}</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="font-monospace text-primary text-start align-self-end fw-bold m-0">{% translate "Total Expenses" %}</div>
|
||||
</div>
|
||||
<div class="col-6 text-end font-monospace">
|
||||
{% for entry in totals.paid_expense %}
|
||||
<div>{% entry_amount entry %}</div>
|
||||
{% empty %}
|
||||
<div>-</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-3"></div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="font-monospace text-primary text-start align-self-end fw-bold m-0">{% translate "Projected Expenses" %}</div>
|
||||
</div>
|
||||
<div class="col-6 text-end font-monospace">
|
||||
{% for entry in totals.projected_expense %}
|
||||
<div>{% entry_amount entry %}</div>
|
||||
{% empty %}
|
||||
<div>-</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-1">
|
||||
<div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="font-monospace text-primary text-start align-self-end fw-bold m-0">{% translate "Total Expenses" %}</div>
|
||||
</div>
|
||||
<div class="col-6 text-end font-monospace">
|
||||
{% for entry in totals.paid_expense %}
|
||||
<div>{% entry_amount entry %}</div>
|
||||
{% empty %}
|
||||
<div>-</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-3"></div>
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="font-monospace text-primary text-start align-self-end fw-bold m-0">{% translate "Daily Spending Allowance" %}</div>
|
||||
</div>
|
||||
<div class="col-6 text-end font-monospace">
|
||||
{% for entry in totals.projected_expense %}
|
||||
<div>{% entry_amount entry %}</div>
|
||||
{% empty %}
|
||||
<div>-</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
0
app/templates/transactions/fragments/overview.html
Normal file
0
app/templates/transactions/fragments/overview.html
Normal file
63
app/templates/transactions/overview.html
Normal file
63
app/templates/transactions/overview.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load month_name %}
|
||||
{% load static %}
|
||||
{% load webpack_loader %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-md-3 py-5 column-gap-5">
|
||||
<div class="row">
|
||||
<div class="col flex-row align-items-center d-flex">
|
||||
<div class="tw-text-base mx-2 h-100 align-items-center d-flex">
|
||||
<a role="button" href="
|
||||
{% url 'transactions_overview' month=previous_month year=previous_year %}"><i
|
||||
class="fa-solid fa-chevron-left"></i></a>
|
||||
</div>
|
||||
<div class="tw-text-3xl fw-bold" hx-get="{% url 'available_dates' %}" hx-target="#generic-offcanvas">
|
||||
{{ month|month_name }} {{ year }}
|
||||
</div>
|
||||
<div class="tw-text-base mx-2 h-100 align-items-center d-flex"><a role="button" href="
|
||||
{% url 'transactions_overview' month=next_month year=next_year %}"><i
|
||||
class="fa-solid
|
||||
fa-chevron-right"></i></a></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="row gx-xl-4 gy-3">
|
||||
<div class="col-12 col-xl-3 order-1 order-xl-0">
|
||||
<div class="row">
|
||||
<div class="col-6 p-1">
|
||||
<button class="btn btn-sm btn-outline-success w-100"
|
||||
hx-get="{% url 'transaction_add' %}"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-vals='{"year": {{ year }}, "month": {{ month }}, "type": "IN"}'>
|
||||
<i class="fa-solid fa-circle-plus me-3"></i>{% translate "Income" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 p-1">
|
||||
<button class="btn btn-sm btn-outline-danger w-100"
|
||||
hx-get="{% url 'transaction_add' %}"
|
||||
hx-target="#generic-offcanvas"
|
||||
hx-vals='{"year": {{ year }}, "month": {{ month }}, "type": "EX"}'>
|
||||
<i class="fa-solid fa-circle-plus me-3"></i>{% translate "Expense" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-6 order-2 order-xl-1"
|
||||
id="transactions"
|
||||
hx-get="{% url 'transactions_list' month=month year=year %}"
|
||||
hx-trigger="load, transaction_updated from:window">
|
||||
</div>
|
||||
<div class="col-12 col-xl-3 order-0 order-xl-2">
|
||||
<div id="total-expenses" hx-get="{% url 'monthly_summary' month=month year=year %}"
|
||||
hx-trigger="load, transaction_updated from:window" class="sticky-sidebar">
|
||||
</div>
|
||||
|
||||
{# <div id="total-spent" hx-get="{% url 'monthly_expenses' month=month year=year %}"#}
|
||||
{# hx-trigger="load, transaction_updated from:window" hx-vals='{"s": "p"}'>#}
|
||||
{# </div>#}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% load crispy_forms_field %}
|
||||
|
||||
<div class="btn-group w-100 mb-3" role="group" aria-label="{{ field.label }}">
|
||||
{% for choice in field.field.choices %}
|
||||
<input type="radio"
|
||||
class="btn-check"
|
||||
name="{{ field.html_name }}"
|
||||
id="{{ field.html_name }}_{{ forloop.counter }}"
|
||||
value="{{ choice.0 }}"
|
||||
{% if choice.0 == field.value %}checked{% endif %}>
|
||||
<label class="btn {% if forloop.first %}btn-outline-success{% elif forloop.last %}btn-outline-danger{% else %}btn-outline-primary{% endif %}"
|
||||
for="{{ field.html_name }}_{{ forloop.counter }}">
|
||||
{{ choice.1 }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
93
docker-compose.dev.yml
Normal file
93
docker-compose.dev.yml
Normal file
@@ -0,0 +1,93 @@
|
||||
version: '3.8'
|
||||
|
||||
volumes:
|
||||
wygiwyh_dev_postgres_data: {}
|
||||
temp:
|
||||
|
||||
services:
|
||||
web: &django
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/dev/django/Dockerfile
|
||||
image: wygiwyh_dev_server
|
||||
container_name: wygiwyh_dev_server
|
||||
command: /start
|
||||
volumes:
|
||||
- ./app/:/usr/src/app/:z
|
||||
- ./frontend/:/usr/src/frontend:z
|
||||
- temp:/temp
|
||||
ports:
|
||||
- "${OUTBOUND_PORT}:8000"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- db
|
||||
- webpack
|
||||
restart: unless-stopped
|
||||
|
||||
webpack:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/dev/webpack/Dockerfile
|
||||
image: wygiwyh_dev_node
|
||||
container_name: wygiwyh_dev_node
|
||||
volumes:
|
||||
- ./frontend/:/usr/src/frontend
|
||||
- ./app/:/usr/src/app/
|
||||
# http://jdlm.info/articles/2016/03/06/lessons-building-node-app-docker.html
|
||||
- /usr/src/frontend/node_modules
|
||||
command: npm run watch
|
||||
ports:
|
||||
- '${WEBPACK_OUTBOUND_PORT}:9091'
|
||||
environment:
|
||||
- WATCHPACK_POLLING=true
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
container_name: ${SQL_HOST}
|
||||
volumes:
|
||||
- wygiwyh_dev_postgres_data:/var/lib/postgresql/data/
|
||||
environment:
|
||||
- POSTGRES_USER=${SQL_USER}
|
||||
- POSTGRES_PASSWORD=${SQL_PASSWORD}
|
||||
- POSTGRES_DB=${SQL_DATABASE}
|
||||
ports:
|
||||
- '${SQL_PORT}:5432'
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: docker.io/redis:6
|
||||
restart: unless-stopped
|
||||
container_name: wygiwyh_dev_redis
|
||||
|
||||
celeryworker:
|
||||
<<: *django
|
||||
image: wygiwyh_dev_celeryworker
|
||||
container_name: wygiwyh_dev_celeryworker
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
ports: [ ]
|
||||
command: /start-celeryworker
|
||||
restart: unless-stopped
|
||||
|
||||
celerybeat:
|
||||
<<: *django
|
||||
image: wygiwyh_dev_celerybeat
|
||||
container_name: wygiwyh_dev_celerybeat
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
ports: [ ]
|
||||
command: /start-celerybeat
|
||||
restart: unless-stopped
|
||||
|
||||
flower:
|
||||
<<: *django
|
||||
image: wygiwyh_dev_flower
|
||||
container_name: wygiwyh_dev_flower
|
||||
ports:
|
||||
- '${FLOWER_OUTBOUND_PORT}:5555'
|
||||
command: /start-flower
|
||||
restart: unless-stopped
|
||||
100
docker-compose.prod.yml
Normal file
100
docker-compose.prod.yml
Normal file
@@ -0,0 +1,100 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
web: &django
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./docker/prod/django/Dockerfile
|
||||
image: ${SERVER_NAME}
|
||||
container_name: ${SERVER_NAME}
|
||||
command: /start
|
||||
volumes:
|
||||
- temp:/temp
|
||||
ports:
|
||||
- "${OUTBOUND_PORT}:8000"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: curl --fail http://localhost:8000/ht/health || exit 1
|
||||
interval: 60s
|
||||
timeout: 30s
|
||||
retries: 3
|
||||
start_period: 360s
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
|
||||
db:
|
||||
image: postgres:15
|
||||
container_name: ${DB_NAME}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./postgres_data:/var/lib/postgresql/data/
|
||||
environment:
|
||||
- POSTGRES_USER=${SQL_USER}
|
||||
- POSTGRES_PASSWORD=${SQL_PASSWORD}
|
||||
- POSTGRES_DB=${SQL_DATABASE}
|
||||
|
||||
redis:
|
||||
image: docker.io/redis:6
|
||||
container_name: ${REDIS_NAME}
|
||||
restart: unless-stopped
|
||||
command: redis-server --save 60 1 --loglevel warning
|
||||
volumes:
|
||||
- ./redis_data:/data
|
||||
healthcheck:
|
||||
test: [ "CMD", "redis-cli", "ping" ]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
celeryworker:
|
||||
<<: *django
|
||||
image: ${CELERYWORKER_NAME}
|
||||
container_name: ${CELERYWORKER_NAME}
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
ports: [ ]
|
||||
command: /start-celeryworker
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "NONE" ]
|
||||
|
||||
celerybeat:
|
||||
<<: *django
|
||||
image: ${CELERYBEAT_NAME}
|
||||
container_name: ${CELERYBEAT_NAME}
|
||||
depends_on:
|
||||
- redis
|
||||
- db
|
||||
ports: [ ]
|
||||
command: /start-celerybeat
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "NONE" ]
|
||||
|
||||
flower:
|
||||
<<: *django
|
||||
image: ${FLOWER_NAME}
|
||||
container_name: ${FLOWER_NAME}
|
||||
ports:
|
||||
- '5556:5555'
|
||||
command: /start-flower
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: curl --fail http://localhost:5555/healthcheck || exit 1
|
||||
interval: 60s
|
||||
timeout: 30s
|
||||
retries: 3
|
||||
start_period: 360s
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: compose_default
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
temp:
|
||||
8
docker/dev/celery/beat/start
Normal file
8
docker/dev/celery/beat/start
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
rm -f './celerybeat.pid'
|
||||
exec watchfiles --filter python celery.__main__.main --args '-A WYGIWYH.celery_app beat -l INFO'
|
||||
9
docker/dev/celery/flower/start
Normal file
9
docker/dev/celery/flower/start
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
exec watchfiles --filter python celery.__main__.main \
|
||||
--args \
|
||||
"-A WYGIWYH.celery_app -b \"${CELERY_BROKER_URL}\" flower
|
||||
--basic_auth=\"${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}\""
|
||||
7
docker/dev/celery/worker/start
Normal file
7
docker/dev/celery/worker/start
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o nounset
|
||||
|
||||
|
||||
exec watchfiles --filter python celery.__main__.main --args '-A WYGIWYH.celery_app worker -l INFO'
|
||||
63
docker/dev/django/Dockerfile
Normal file
63
docker/dev/django/Dockerfile
Normal file
@@ -0,0 +1,63 @@
|
||||
# pull official base image
|
||||
FROM python:3.11.8-slim-buster AS python
|
||||
LABEL authors="Herculino de Miranda Trotta"
|
||||
|
||||
FROM docker.io/python AS python-build-stage
|
||||
|
||||
RUN apt update && apt install --no-install-recommends -y \
|
||||
# dependencies for building Python packages \
|
||||
build-essential \
|
||||
# psycopg2 dependencies \
|
||||
gettext \
|
||||
libpq-dev
|
||||
|
||||
# Requirements are installed here to ensure they will be cached.
|
||||
COPY ../requirements.txt .
|
||||
|
||||
# Create Python Dependency and Sub-Dependency Wheels.
|
||||
RUN pip wheel --wheel-dir /usr/src/app/wheels \
|
||||
-r requirements.txt
|
||||
|
||||
#FROM node:alpine AS webpack_build
|
||||
#
|
||||
#WORKDIR /usr/src/frontend
|
||||
#
|
||||
#COPY ../frontend .
|
||||
#COPY ../app/templates /usr/src/app/templates
|
||||
#RUN npm install
|
||||
#RUN npm run build
|
||||
|
||||
FROM docker.io/python AS python-run-stage
|
||||
#COPY --from=webpack_build /usr/src/frontend/build /usr/src/frontend/build
|
||||
|
||||
# set work directory
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE 1
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
COPY --from=python-build-stage /usr/src/app/wheels /wheels/
|
||||
RUN apt update && apt install --no-install-recommends -y gettext
|
||||
RUN pip install --upgrade pip
|
||||
RUN pip install --no-cache-dir --no-index --find-links=/wheels/ /wheels/* \
|
||||
&& rm -rf /wheels/
|
||||
|
||||
COPY ./docker/dev/django/start /start
|
||||
RUN sed -i 's/\r$//g' /start
|
||||
RUN chmod +x /start
|
||||
|
||||
COPY ./docker/dev/celery/worker/start /start-celeryworker
|
||||
RUN sed -i 's/\r$//g' /start-celeryworker
|
||||
RUN chmod +x /start-celeryworker
|
||||
|
||||
COPY ./docker/dev/celery/beat/start /start-celerybeat
|
||||
RUN sed -i 's/\r$//g' /start-celerybeat
|
||||
RUN chmod +x /start-celerybeat
|
||||
|
||||
COPY ./docker/dev/celery/flower/start /start-flower
|
||||
RUN sed -i 's/\r$//g' /start-flower
|
||||
RUN chmod +x /start-flower
|
||||
|
||||
# copy project
|
||||
COPY ./app .
|
||||
10
docker/dev/django/start
Normal file
10
docker/dev/django/start
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
python manage.py migrate
|
||||
python manage.py loaddata ./fixtures/*.json
|
||||
#python manage.py compilemessages
|
||||
exec python manage.py runserver 0.0.0.0:8000
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user