mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-07-07 05:15:22 +02:00
initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.accounts.models import Account
|
||||
|
||||
|
||||
admin.site.register(Account)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
@@ -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,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
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommonConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.common"
|
||||
@@ -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,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])
|
||||
@@ -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")
|
||||
@@ -0,0 +1,11 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"toasts/",
|
||||
views.toasts,
|
||||
name="toasts",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
|
||||
def toasts(request):
|
||||
return render(request, "common/toasts.html")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CurrenciesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.currencies"
|
||||
@@ -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,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")
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TransactionsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.transactions"
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
+54
@@ -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'),
|
||||
),
|
||||
]
|
||||
+34
@@ -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'),
|
||||
),
|
||||
]
|
||||
+34
@@ -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,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,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)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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},
|
||||
# )
|
||||
@@ -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,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
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.users"
|
||||
@@ -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."),
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,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})"
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user