initial commit

This commit is contained in:
Herculino Trotta
2024-09-26 11:00:40 -03:00
parent 830e821a17
commit 50b0c6ce01
138 changed files with 13566 additions and 46 deletions

43
.env.example Normal file
View 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
View File

@@ -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

1
README.md Normal file
View File

@@ -0,0 +1 @@
# radar_da_inovacao_web

3
app/WYGIWYH/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)

16
app/WYGIWYH/asgi.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

0
app/apps/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,6 @@
from django.contrib import admin
from apps.accounts.models import Account
admin.site.register(Account)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"

View 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',
},
),
]

View File

View 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

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

6
app/apps/common/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.common"

View File

View 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

View File

View File

View 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])

View 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
View 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
View File

@@ -0,0 +1,5 @@
from django.shortcuts import render
def toasts(request):
return render(request, "common/toasts.html")

View File

View File

View 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)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CurrenciesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.currencies"

View 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',
},
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View 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")

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

View File

View 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)

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TransactionsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.transactions"

View 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

View 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
)

View 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)),
],
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
]

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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',
),
]

View 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)

View 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)

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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",
),
]

View 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},
)

View 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},
# )

View 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

View File

69
app/apps/users/admin.py Normal file
View 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
View 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
View 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."),
}

View 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)

View 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()),
],
),
]

View File

19
app/apps/users/models.py Normal file
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
{}

22
app/manage.py Normal file
View 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()

View 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

Binary file not shown.

Binary file not shown.

View 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 %}

View 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' %}

View 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>

View File

@@ -0,0 +1,7 @@
{% load webpack_loader %}
{% javascript_pack 'bootstrap' %}
{% javascript_pack 'sweetalert2' %}
{% javascript_pack 'htmx' %}
{% javascript_pack 'jquery' %}
{#{% javascript_pack 'select' %}#}

View File

@@ -0,0 +1,4 @@
{% load webpack_loader %}
{% stylesheet_pack 'style' %}
{#{% stylesheet_pack 'select' %}#}

View File

@@ -0,0 +1,3 @@
<div id="toasts" hx-get="{% url 'toasts' %}"
hx-trigger="load, toast from:window">
</div>

View 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>

View 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 %}

View 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 %}

View File

@@ -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>

View File

@@ -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>

View 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 %}

View 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>

View 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 %}

View File

@@ -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
View 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
View 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:

View 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'

View 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}\""

View 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'

View 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
View 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