feat: financial year planning — annual budgets, income tracking, household sharing
- Financial year page (/financial-year): year selector, 3 KPI cards (income, fixed costs, actual expenses), income and budget-items tabs with inline CRUD - Revenue accounts as income source: salary-months toggle (12/13) per account - Household support: create household, invite members by email (existing and new users via PendingHouseholdInvite), accept invitations, set roles - Combined household income view across all active members - FinancialYear, YearlyIncome, YearlyBudgetItem, Household, HouseholdMembership models with migrations; household invite email template - Management command to migrate existing accounts/budgets to financial years - FinancialYearService in Angular with full API integration - Dashboard updated: income/fixed-costs read from financial year data, year dropdown synced with available financial years - Sidebar: financial year nav item added - i18n: all keys in DE/EN/FR/IT
This commit is contained in:
@@ -104,6 +104,7 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
||||
EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True'
|
||||
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch')
|
||||
FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:4200')
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
|
||||
@@ -14,6 +14,11 @@ from finance.views import (
|
||||
SessionListView, SessionRevokeView, SessionRevokeAllView,
|
||||
DataExportView, NotificationPrefsView,
|
||||
VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView,
|
||||
FinancialYearListCreateView, FinancialYearDetailView, FinancialYearCopyView,
|
||||
YearlyIncomeListCreateView, YearlyIncomeDetailView,
|
||||
YearlyBudgetItemListCreateView, YearlyBudgetItemDetailView,
|
||||
HouseholdListCreateView, HouseholdInviteView, HouseholdAcceptView, HouseholdLeaveView,
|
||||
HouseholdSetRoleView, HouseholdRevenueAccountsView,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -52,4 +57,17 @@ urlpatterns = [
|
||||
path('api/notifications/', NotificationsView.as_view()),
|
||||
path('api/calendar/ical-url/', ICalUrlView.as_view()),
|
||||
path('api/calendar/ical/<int:user_id>/<str:token>/', ICalFeedView.as_view()),
|
||||
path('api/financial-years/', FinancialYearListCreateView.as_view()),
|
||||
path('api/financial-years/<int:year>/', FinancialYearDetailView.as_view()),
|
||||
path('api/financial-years/<int:year>/copy-from/<int:source_year>/', FinancialYearCopyView.as_view()),
|
||||
path('api/financial-years/<int:year>/incomes/', YearlyIncomeListCreateView.as_view()),
|
||||
path('api/financial-years/<int:year>/incomes/<int:pk>/', YearlyIncomeDetailView.as_view()),
|
||||
path('api/financial-years/<int:year>/budget-items/', YearlyBudgetItemListCreateView.as_view()),
|
||||
path('api/financial-years/<int:year>/budget-items/<int:pk>/', YearlyBudgetItemDetailView.as_view()),
|
||||
path('api/households/', HouseholdListCreateView.as_view()),
|
||||
path('api/households/<int:pk>/invite/', HouseholdInviteView.as_view()),
|
||||
path('api/households/<int:pk>/accept/', HouseholdAcceptView.as_view()),
|
||||
path('api/households/<int:pk>/leave/', HouseholdLeaveView.as_view()),
|
||||
path('api/households/<int:pk>/members/<int:membership_id>/set-role/', HouseholdSetRoleView.as_view()),
|
||||
path('api/households/<int:pk>/revenue-accounts/', HouseholdRevenueAccountsView.as_view()),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from finance.models import Account, Budget, FinancialYear, YearlyIncome, YearlyBudgetItem
|
||||
|
||||
User = get_user_model()
|
||||
TARGET_YEAR = 2026
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Migrate existing revenue accounts and budgets into FinancialYear 2026. Idempotent — safe to run multiple times.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Preview what would be created without writing to the database.',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options['dry_run']
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('DRY RUN — no changes will be saved.\n'))
|
||||
|
||||
total_incomes = 0
|
||||
total_budgets = 0
|
||||
|
||||
for user in User.objects.all():
|
||||
revenue_accounts = Account.objects.filter(user=user, account_type='revenue', active=True)
|
||||
budgets = Budget.objects.filter(account__user=user, active=True)
|
||||
|
||||
if not revenue_accounts.exists() and not budgets.exists():
|
||||
continue
|
||||
|
||||
self.stdout.write(f'\nUser: {user.email}')
|
||||
|
||||
if dry_run:
|
||||
fy = FinancialYear.objects.filter(user=user, year=TARGET_YEAR).first()
|
||||
if fy:
|
||||
self.stdout.write(f' FinancialYear {TARGET_YEAR} already exists (id={fy.pk})')
|
||||
else:
|
||||
self.stdout.write(f' Would create FinancialYear {TARGET_YEAR}')
|
||||
else:
|
||||
fy, fy_created = FinancialYear.objects.get_or_create(user=user, year=TARGET_YEAR)
|
||||
if fy_created:
|
||||
self.stdout.write(f' Created FinancialYear {TARGET_YEAR} (id={fy.pk})')
|
||||
else:
|
||||
self.stdout.write(f' FinancialYear {TARGET_YEAR} exists (id={fy.pk})')
|
||||
|
||||
for account in revenue_accounts:
|
||||
label = f'YearlyIncome "{account.name}" CHF {account.balance}'
|
||||
if dry_run:
|
||||
exists = fy and YearlyIncome.objects.filter(financial_year=fy, name=account.name).exists()
|
||||
self.stdout.write(f' {"SKIP (exists)" if exists else "Would create"}: {label}')
|
||||
else:
|
||||
_, created = YearlyIncome.objects.get_or_create(
|
||||
financial_year=fy,
|
||||
name=account.name,
|
||||
defaults={'amount': account.balance, 'member': user, 'active': True},
|
||||
)
|
||||
self.stdout.write(f' {"Created" if created else "Skipped (exists)"}: {label}')
|
||||
if created:
|
||||
total_incomes += 1
|
||||
|
||||
for budget in budgets:
|
||||
label = f'YearlyBudgetItem "{budget.name}" CHF {budget.amount}'
|
||||
if dry_run:
|
||||
exists = fy and YearlyBudgetItem.objects.filter(financial_year=fy, name=budget.name).exists()
|
||||
self.stdout.write(f' {"SKIP (exists)" if exists else "Would create"}: {label}')
|
||||
else:
|
||||
_, created = YearlyBudgetItem.objects.get_or_create(
|
||||
financial_year=fy,
|
||||
name=budget.name,
|
||||
defaults={'amount': budget.amount, 'active': budget.active},
|
||||
)
|
||||
self.stdout.write(f' {"Created" if created else "Skipped (exists)"}: {label}')
|
||||
if created:
|
||||
total_budgets += 1
|
||||
|
||||
if not dry_run:
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\nDone. Created {total_incomes} income(s) and {total_budgets} budget item(s).'
|
||||
))
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING('\nDry run complete. Re-run without --dry-run to apply.'))
|
||||
@@ -0,0 +1,89 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-18 20:16
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0018_profile_savings_rate_goal'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Household',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_households', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FinancialYear',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('year', models.PositiveSmallIntegerField()),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('notes', models.TextField(blank=True, default='')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='financial_years', to=settings.AUTH_USER_MODEL)),
|
||||
('household', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='financial_years', to='finance.household')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HouseholdMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('left', 'Left')], default='pending', max_length=10)),
|
||||
('effective_from_year', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('effective_until_year', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='finance.household')),
|
||||
('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_invitations', to=settings.AUTH_USER_MODEL)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='household_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='YearlyBudgetItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('notes', models.TextField(blank=True, default='')),
|
||||
('financial_year', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budget_items', to='finance.financialyear')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='YearlyIncome',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=12)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('notes', models.TextField(blank=True, default='')),
|
||||
('financial_year', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incomes', to='finance.financialyear')),
|
||||
('member', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='yearly_incomes', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='financialyear',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('household__isnull', True), ('user__isnull', False)), models.Q(('household__isnull', False), ('user__isnull', True)), _connector='OR'), name='financial_year_owner_exclusive'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='financialyear',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'year'), name='unique_personal_financial_year'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='financialyear',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('household__isnull', False)), fields=('household', 'year'), name='unique_household_financial_year'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='householdmembership',
|
||||
unique_together={('household', 'user')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-19 07:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0019_financial_year'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='householdmembership',
|
||||
name='role',
|
||||
field=models.CharField(choices=[('member', 'Member'), ('admin', 'Admin')], default='member', max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-21 18:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0020_household_membership_role'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='account',
|
||||
name='salary_months',
|
||||
field=models.IntegerField(choices=[(12, 12), (13, 13)], default=12),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-21 19:49
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0021_add_salary_months_to_account'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PendingHouseholdInvite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('invited_email', models.EmailField(max_length=254)),
|
||||
('effective_from_year', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_invites', to='finance.household')),
|
||||
('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_pending_invitations', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('household', 'invited_email')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 6.0.4 on 2026-05-21 20:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finance', '0022_add_pending_household_invite'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='email_verified',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='email_verify_token',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='email_verify_token_expires',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='password_reset_token_expires',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='password_reset_token_hash',
|
||||
field=models.CharField(blank=True, default='', max_length=64),
|
||||
),
|
||||
]
|
||||
+143
-1
@@ -18,6 +18,7 @@ class Account(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset')
|
||||
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00)
|
||||
salary_months = models.IntegerField(default=12, choices=[(12, 12), (13, 13)])
|
||||
active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -223,4 +224,145 @@ class BackupCode(models.Model):
|
||||
indexes = [models.Index(fields=['user', 'used'])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} – backup {'used' if self.used else 'active'}"
|
||||
return f"{self.user} – backup {'used' if self.used else 'active'}"
|
||||
|
||||
|
||||
# ── FinancialYear ─────────────────────────────────────────────────────────────
|
||||
|
||||
class Household(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='created_households',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class HouseholdMembership(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pending'),
|
||||
('active', 'Active'),
|
||||
('left', 'Left'),
|
||||
]
|
||||
ROLE_CHOICES = [
|
||||
('member', 'Member'),
|
||||
('admin', 'Admin'),
|
||||
]
|
||||
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='memberships')
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='household_memberships',
|
||||
)
|
||||
invited_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='sent_invitations',
|
||||
)
|
||||
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
|
||||
role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='member')
|
||||
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
effective_until_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['household', 'user']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} in {self.household} ({self.status})"
|
||||
|
||||
|
||||
class PendingHouseholdInvite(models.Model):
|
||||
household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='pending_invites')
|
||||
invited_email = models.EmailField()
|
||||
invited_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='sent_pending_invitations',
|
||||
)
|
||||
effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['household', 'invited_email']
|
||||
|
||||
def __str__(self):
|
||||
return f"Pending invite for {self.invited_email} to {self.household}"
|
||||
|
||||
|
||||
class FinancialYear(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='financial_years',
|
||||
null=True, blank=True,
|
||||
)
|
||||
household = models.ForeignKey(
|
||||
Household,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='financial_years',
|
||||
null=True, blank=True,
|
||||
)
|
||||
year = models.PositiveSmallIntegerField()
|
||||
is_active = models.BooleanField(default=True)
|
||||
notes = models.TextField(blank=True, default='')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=(
|
||||
models.Q(user__isnull=False, household__isnull=True) |
|
||||
models.Q(user__isnull=True, household__isnull=False)
|
||||
),
|
||||
name='financial_year_owner_exclusive',
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=['user', 'year'],
|
||||
condition=models.Q(user__isnull=False),
|
||||
name='unique_personal_financial_year',
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=['household', 'year'],
|
||||
condition=models.Q(household__isnull=False),
|
||||
name='unique_household_financial_year',
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
owner = self.user or self.household
|
||||
return f"{owner} — {self.year}"
|
||||
|
||||
|
||||
class YearlyIncome(models.Model):
|
||||
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='incomes')
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='yearly_incomes',
|
||||
)
|
||||
name = models.CharField(max_length=100)
|
||||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
active = models.BooleanField(default=True)
|
||||
notes = models.TextField(blank=True, default='')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
|
||||
|
||||
|
||||
class YearlyBudgetItem(models.Model):
|
||||
financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='budget_items')
|
||||
name = models.CharField(max_length=100)
|
||||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
active = models.BooleanField(default=True)
|
||||
notes = models.TextField(blank=True, default='')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}: CHF {self.amount} ({self.financial_year.year})"
|
||||
@@ -1,6 +1,10 @@
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from .models import Account, Transaction, Budget, Expense, Profile, Deadline
|
||||
from .models import (
|
||||
Account, Transaction, Budget, Expense, Profile, Deadline,
|
||||
Household, HouseholdMembership, PendingHouseholdInvite,
|
||||
FinancialYear, YearlyIncome, YearlyBudgetItem,
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -44,6 +48,10 @@ class ExpenseSerializer(serializers.ModelSerializer):
|
||||
|
||||
class ProfileSerializer(serializers.ModelSerializer):
|
||||
totp_enabled = serializers.BooleanField(read_only=True)
|
||||
email = serializers.SerializerMethodField()
|
||||
|
||||
def get_email(self, obj):
|
||||
return obj.email or (obj.user.email if obj.user else '')
|
||||
|
||||
class Meta:
|
||||
model = Profile
|
||||
@@ -56,6 +64,76 @@ class DeadlineSerializer(serializers.ModelSerializer):
|
||||
exclude = ['user']
|
||||
|
||||
|
||||
class HouseholdMembershipSerializer(serializers.ModelSerializer):
|
||||
user_email = serializers.EmailField(source='user.email', read_only=True)
|
||||
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = HouseholdMembership
|
||||
fields = ['id', 'user', 'user_email', 'invited_by_email', 'status', 'role',
|
||||
'effective_from_year', 'effective_until_year', 'created_at']
|
||||
read_only_fields = ['id', 'user', 'user_email', 'invited_by_email', 'created_at']
|
||||
|
||||
|
||||
class PendingHouseholdInviteSerializer(serializers.ModelSerializer):
|
||||
invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PendingHouseholdInvite
|
||||
fields = ['id', 'invited_email', 'invited_by_email', 'effective_from_year', 'created_at']
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class HouseholdSerializer(serializers.ModelSerializer):
|
||||
memberships = HouseholdMembershipSerializer(many=True, read_only=True)
|
||||
pending_invites = PendingHouseholdInviteSerializer(many=True, read_only=True)
|
||||
created_by_email = serializers.EmailField(source='created_by.email', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Household
|
||||
fields = ['id', 'name', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
|
||||
read_only_fields = ['id', 'created_by_email', 'memberships', 'pending_invites', 'created_at']
|
||||
|
||||
|
||||
class YearlyIncomeSerializer(serializers.ModelSerializer):
|
||||
member_email = serializers.EmailField(source='member.email', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = YearlyIncome
|
||||
fields = ['id', 'member', 'member_email', 'name', 'amount', 'active', 'notes']
|
||||
read_only_fields = ['id', 'member_email']
|
||||
|
||||
|
||||
class YearlyBudgetItemSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = YearlyBudgetItem
|
||||
fields = ['id', 'name', 'amount', 'active', 'notes']
|
||||
read_only_fields = ['id']
|
||||
|
||||
|
||||
class FinancialYearSerializer(serializers.ModelSerializer):
|
||||
incomes = YearlyIncomeSerializer(many=True, read_only=True)
|
||||
budget_items = YearlyBudgetItemSerializer(many=True, read_only=True)
|
||||
total_income = serializers.SerializerMethodField()
|
||||
total_fixed_costs = serializers.SerializerMethodField()
|
||||
owner_type = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = FinancialYear
|
||||
fields = ['id', 'year', 'is_active', 'notes', 'owner_type', 'household_id',
|
||||
'total_income', 'total_fixed_costs', 'incomes', 'budget_items', 'created_at']
|
||||
read_only_fields = ['id', 'created_at', 'owner_type', 'household_id', 'total_income', 'total_fixed_costs']
|
||||
|
||||
def get_total_income(self, obj):
|
||||
return sum(i.amount for i in obj.incomes.filter(active=True))
|
||||
|
||||
def get_total_fixed_costs(self, obj):
|
||||
return sum(b.amount for b in obj.budget_items.filter(active=True))
|
||||
|
||||
def get_owner_type(self, obj):
|
||||
return 'household' if obj.household_id else 'personal'
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField(min_length=8, write_only=True)
|
||||
@@ -67,8 +145,21 @@ class RegisterSerializer(serializers.Serializer):
|
||||
|
||||
def create(self, validated_data):
|
||||
email = validated_data['email']
|
||||
return User.objects.create_user(
|
||||
user = User.objects.create_user(
|
||||
username=email,
|
||||
email=email,
|
||||
password=validated_data['password'],
|
||||
)
|
||||
from .models import PendingHouseholdInvite, HouseholdMembership
|
||||
for invite in PendingHouseholdInvite.objects.filter(invited_email__iexact=email):
|
||||
HouseholdMembership.objects.get_or_create(
|
||||
household=invite.household,
|
||||
user=user,
|
||||
defaults={
|
||||
'invited_by': invite.invited_by,
|
||||
'status': 'pending',
|
||||
'effective_from_year': invite.effective_from_year,
|
||||
},
|
||||
)
|
||||
invite.delete()
|
||||
return user
|
||||
|
||||
+460
-1
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import datetime
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
@@ -15,16 +16,23 @@ from django.contrib.auth import get_user_model, authenticate
|
||||
from django.http import HttpResponse
|
||||
from icalendar import Calendar as iCalendar, Event as iCalEvent
|
||||
|
||||
from django.db import models
|
||||
from rest_framework import viewsets, views, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
from rest_framework_simplejwt.exceptions import TokenError
|
||||
from .models import Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession
|
||||
from django.db import transaction as db_transaction
|
||||
from .models import (
|
||||
Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession,
|
||||
Household, HouseholdMembership, FinancialYear, YearlyIncome, YearlyBudgetItem,
|
||||
)
|
||||
from .serializers import (
|
||||
AccountSerializer, TransactionSerializer, BudgetSerializer,
|
||||
ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer,
|
||||
HouseholdSerializer, HouseholdMembershipSerializer,
|
||||
FinancialYearSerializer, YearlyIncomeSerializer, YearlyBudgetItemSerializer,
|
||||
)
|
||||
|
||||
|
||||
@@ -997,3 +1005,454 @@ class PasswordResetConfirmView(views.APIView):
|
||||
for session in UserSession.objects.filter(user=user):
|
||||
_blacklist_session(session)
|
||||
return Response({'detail': 'Password updated.'})
|
||||
|
||||
|
||||
# ── FinancialYear Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _get_user_financial_year(user, year):
|
||||
"""Return the FinancialYear for a given year accessible to this user."""
|
||||
# Personal year
|
||||
fy = FinancialYear.objects.filter(user=user, year=year).first()
|
||||
if fy:
|
||||
return fy
|
||||
# Household year where user is an active member for this year
|
||||
memberships = HouseholdMembership.objects.filter(
|
||||
user=user,
|
||||
status='active',
|
||||
effective_from_year__lte=year,
|
||||
).filter(
|
||||
models.Q(effective_until_year__isnull=True) | models.Q(effective_until_year__gt=year)
|
||||
)
|
||||
household_ids = memberships.values_list('household_id', flat=True)
|
||||
return FinancialYear.objects.filter(household_id__in=household_ids, year=year).first()
|
||||
|
||||
|
||||
def _all_user_financial_years(user):
|
||||
"""Return all FinancialYears accessible to this user."""
|
||||
personal = FinancialYear.objects.filter(user=user)
|
||||
memberships = HouseholdMembership.objects.filter(user=user, status='active')
|
||||
household_ids = memberships.values_list('household_id', flat=True)
|
||||
household = FinancialYear.objects.filter(household_id__in=household_ids)
|
||||
return (personal | household).distinct().order_by('-year')
|
||||
|
||||
|
||||
def _max_year_for_user(user):
|
||||
"""Return the highest year the user currently has access to."""
|
||||
years = _all_user_financial_years(user).values_list('year', flat=True)
|
||||
return max(years) if years else None
|
||||
|
||||
|
||||
# ── FinancialYear Views ───────────────────────────────────────────────────────
|
||||
|
||||
class FinancialYearListCreateView(views.APIView):
|
||||
def get(self, request):
|
||||
qs = _all_user_financial_years(request.user)
|
||||
return Response(FinancialYearSerializer(qs, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
year = request.data.get('year')
|
||||
if not year:
|
||||
return Response({'year': 'This field is required.'}, status=400)
|
||||
try:
|
||||
year = int(year)
|
||||
except (TypeError, ValueError):
|
||||
return Response({'year': 'Must be an integer.'}, status=400)
|
||||
|
||||
current_year = datetime.date.today().year
|
||||
if year > current_year + 1:
|
||||
return Response(
|
||||
{'year': f'You can only create years up to {current_year + 1}.'},
|
||||
status=400,
|
||||
)
|
||||
|
||||
max_year = _max_year_for_user(request.user)
|
||||
if max_year is not None and year != max_year + 1:
|
||||
return Response(
|
||||
{'year': f'You can only create the next year ({max_year + 1}).'},
|
||||
status=400,
|
||||
)
|
||||
|
||||
household_id = request.data.get('household_id')
|
||||
if household_id:
|
||||
household = Household.objects.filter(
|
||||
id=household_id,
|
||||
memberships__user=request.user,
|
||||
memberships__status='active',
|
||||
).first()
|
||||
if not household:
|
||||
return Response({'detail': 'Household not found or not a member.'}, status=404)
|
||||
if FinancialYear.objects.filter(household=household, year=year).exists():
|
||||
return Response({'year': 'This year already exists for this household.'}, status=400)
|
||||
FinancialYear.objects.filter(household=household, is_active=True).update(is_active=False)
|
||||
fy = FinancialYear.objects.create(household=household, year=year, is_active=True)
|
||||
else:
|
||||
if FinancialYear.objects.filter(user=request.user, year=year).exists():
|
||||
return Response({'year': 'This year already exists.'}, status=400)
|
||||
FinancialYear.objects.filter(user=request.user, is_active=True).update(is_active=False)
|
||||
fy = FinancialYear.objects.create(user=request.user, year=year, is_active=True)
|
||||
|
||||
return Response(FinancialYearSerializer(fy).data, status=201)
|
||||
|
||||
|
||||
class FinancialYearDetailView(views.APIView):
|
||||
def _get_or_404(self, request, year):
|
||||
fy = _get_user_financial_year(request.user, year)
|
||||
if not fy:
|
||||
return None
|
||||
return fy
|
||||
|
||||
def get(self, request, year):
|
||||
fy = self._get_or_404(request, year)
|
||||
if not fy:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
return Response(FinancialYearSerializer(fy).data)
|
||||
|
||||
def patch(self, request, year):
|
||||
fy = self._get_or_404(request, year)
|
||||
if not fy:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
serializer = FinancialYearSerializer(fy, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
def delete(self, request, year):
|
||||
fy = self._get_or_404(request, year)
|
||||
if not fy:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
if not fy.is_active:
|
||||
return Response({'detail': 'Archived years cannot be deleted.'}, status=400)
|
||||
fy.delete()
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class FinancialYearCopyView(views.APIView):
|
||||
def post(self, request, year, source_year):
|
||||
source = _get_user_financial_year(request.user, source_year)
|
||||
if not source:
|
||||
return Response({'detail': f'Source year {source_year} not found.'}, status=404)
|
||||
|
||||
target = _get_user_financial_year(request.user, year)
|
||||
if not target:
|
||||
return Response({'detail': f'Target year {year} not found.'}, status=404)
|
||||
|
||||
if not target.is_active:
|
||||
return Response({'detail': 'Target year is archived.'}, status=400)
|
||||
|
||||
with db_transaction.atomic():
|
||||
incomes_copied = 0
|
||||
for income in source.incomes.all():
|
||||
YearlyIncome.objects.create(
|
||||
financial_year=target,
|
||||
member=income.member,
|
||||
name=income.name,
|
||||
amount=income.amount,
|
||||
active=income.active,
|
||||
notes=income.notes,
|
||||
)
|
||||
incomes_copied += 1
|
||||
|
||||
items_copied = 0
|
||||
for item in source.budget_items.all():
|
||||
YearlyBudgetItem.objects.create(
|
||||
financial_year=target,
|
||||
name=item.name,
|
||||
amount=item.amount,
|
||||
active=item.active,
|
||||
notes=item.notes,
|
||||
)
|
||||
items_copied += 1
|
||||
|
||||
return Response({
|
||||
'year': year,
|
||||
'source_year': source_year,
|
||||
'incomes_copied': incomes_copied,
|
||||
'budget_items_copied': items_copied,
|
||||
})
|
||||
|
||||
|
||||
# ── YearlyIncome Views ────────────────────────────────────────────────────────
|
||||
|
||||
class YearlyIncomeListCreateView(views.APIView):
|
||||
def _get_year_or_404(self, request, year):
|
||||
fy = _get_user_financial_year(request.user, year)
|
||||
return fy
|
||||
|
||||
def get(self, request, year):
|
||||
fy = self._get_year_or_404(request, year)
|
||||
if not fy:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
return Response(YearlyIncomeSerializer(fy.incomes.all(), many=True).data)
|
||||
|
||||
def post(self, request, year):
|
||||
fy = self._get_year_or_404(request, year)
|
||||
if not fy:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
if not fy.is_active:
|
||||
return Response({'detail': 'Archived years are read-only.'}, status=403)
|
||||
serializer = YearlyIncomeSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(financial_year=fy, member=request.user)
|
||||
return Response(serializer.data, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
|
||||
class YearlyIncomeDetailView(views.APIView):
|
||||
def _get_income_or_404(self, request, year, pk):
|
||||
fy = _get_user_financial_year(request.user, year)
|
||||
if not fy:
|
||||
return None, None
|
||||
income = fy.incomes.filter(pk=pk).first()
|
||||
return fy, income
|
||||
|
||||
def patch(self, request, year, pk):
|
||||
fy, income = self._get_income_or_404(request, year, pk)
|
||||
if not income:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
serializer = YearlyIncomeSerializer(income, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
def delete(self, request, year, pk):
|
||||
fy, income = self._get_income_or_404(request, year, pk)
|
||||
if not income:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
income.delete()
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
# ── YearlyBudgetItem Views ────────────────────────────────────────────────────
|
||||
|
||||
class YearlyBudgetItemListCreateView(views.APIView):
|
||||
def _get_year_or_404(self, request, year):
|
||||
return _get_user_financial_year(request.user, year)
|
||||
|
||||
def get(self, request, year):
|
||||
fy = self._get_year_or_404(request, year)
|
||||
if not fy:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
return Response(YearlyBudgetItemSerializer(fy.budget_items.all(), many=True).data)
|
||||
|
||||
def post(self, request, year):
|
||||
fy = self._get_year_or_404(request, year)
|
||||
if not fy:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
serializer = YearlyBudgetItemSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(financial_year=fy)
|
||||
return Response(serializer.data, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
|
||||
class YearlyBudgetItemDetailView(views.APIView):
|
||||
def _get_item_or_404(self, request, year, pk):
|
||||
fy = _get_user_financial_year(request.user, year)
|
||||
if not fy:
|
||||
return None, None
|
||||
item = fy.budget_items.filter(pk=pk).first()
|
||||
return fy, item
|
||||
|
||||
def patch(self, request, year, pk):
|
||||
fy, item = self._get_item_or_404(request, year, pk)
|
||||
if not item:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
serializer = YearlyBudgetItemSerializer(item, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
def delete(self, request, year, pk):
|
||||
fy, item = self._get_item_or_404(request, year, pk)
|
||||
if not item:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
item.delete()
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
# ── Household Views ───────────────────────────────────────────────────────────
|
||||
|
||||
class HouseholdListCreateView(views.APIView):
|
||||
def get(self, request):
|
||||
memberships = HouseholdMembership.objects.filter(user=request.user, status__in=['active', 'pending'])
|
||||
household_ids = memberships.values_list('household_id', flat=True)
|
||||
created = Household.objects.filter(created_by=request.user)
|
||||
qs = (Household.objects.filter(id__in=household_ids) | created).distinct()
|
||||
return Response(HouseholdSerializer(qs, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
serializer = HouseholdSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
household = serializer.save(created_by=request.user)
|
||||
# Creator is automatically an active member
|
||||
next_year = (datetime.date.today().year + 1)
|
||||
HouseholdMembership.objects.create(
|
||||
household=household,
|
||||
user=request.user,
|
||||
invited_by=request.user,
|
||||
status='active',
|
||||
role='admin',
|
||||
effective_from_year=next_year,
|
||||
)
|
||||
return Response(HouseholdSerializer(household).data, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
|
||||
class HouseholdInviteView(views.APIView):
|
||||
def post(self, request, pk):
|
||||
household = Household.objects.filter(pk=pk).first()
|
||||
if not household:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
# Only founder or active admins can invite
|
||||
is_founder = household.created_by == request.user
|
||||
is_admin = HouseholdMembership.objects.filter(
|
||||
household=household, user=request.user, status='active', role='admin'
|
||||
).exists()
|
||||
if not (is_founder or is_admin):
|
||||
return Response({'detail': 'Only admins can invite members.'}, status=403)
|
||||
|
||||
email = request.data.get('email', '').strip().lower()
|
||||
User = get_user_model()
|
||||
invitee = User.objects.filter(email__iexact=email).first()
|
||||
|
||||
from django.conf import settings
|
||||
from .email import send_email
|
||||
from .models import PendingHouseholdInvite
|
||||
next_year = datetime.date.today().year + 1
|
||||
inviter_name = request.user.get_full_name() or request.user.email
|
||||
|
||||
if not invitee:
|
||||
if PendingHouseholdInvite.objects.filter(household=household, invited_email__iexact=email).exists():
|
||||
return Response({'detail': 'Invitation already sent to this email.'}, status=400)
|
||||
PendingHouseholdInvite.objects.create(
|
||||
household=household,
|
||||
invited_by=request.user,
|
||||
invited_email=email,
|
||||
effective_from_year=next_year,
|
||||
)
|
||||
register_url = f"{settings.FRONTEND_URL}/register"
|
||||
send_email(
|
||||
template_name='household_invite',
|
||||
subject=f'Einladung zum Haushalt «{household.name}»',
|
||||
context={
|
||||
'invitee_name': email,
|
||||
'inviter_name': inviter_name,
|
||||
'household_name': household.name,
|
||||
'accept_url': register_url,
|
||||
'cta_label': 'Konto erstellen & beitreten',
|
||||
},
|
||||
to=email,
|
||||
)
|
||||
return Response({'detail': f'Registration invitation sent to {email}.'})
|
||||
|
||||
if invitee == request.user:
|
||||
return Response({'detail': 'You cannot invite yourself.'}, status=400)
|
||||
if HouseholdMembership.objects.filter(household=household, user=invitee, status__in=['pending', 'active']).exists():
|
||||
return Response({'detail': 'User is already a member or has a pending invitation.'}, status=400)
|
||||
|
||||
HouseholdMembership.objects.create(
|
||||
household=household,
|
||||
user=invitee,
|
||||
invited_by=request.user,
|
||||
status='pending',
|
||||
effective_from_year=next_year,
|
||||
)
|
||||
invitee_name = invitee.get_full_name() or invitee.email
|
||||
send_email(
|
||||
template_name='household_invite',
|
||||
subject=f'Einladung zum Haushalt «{household.name}»',
|
||||
context={
|
||||
'invitee_name': invitee_name,
|
||||
'inviter_name': inviter_name,
|
||||
'household_name': household.name,
|
||||
'accept_url': f"{settings.FRONTEND_URL}/financial-year",
|
||||
'cta_label': 'Einladung annehmen',
|
||||
},
|
||||
to=invitee.email,
|
||||
)
|
||||
return Response({'detail': f'Invitation sent to {email} for year {next_year}.'})
|
||||
|
||||
|
||||
class HouseholdAcceptView(views.APIView):
|
||||
def post(self, request, pk):
|
||||
membership = HouseholdMembership.objects.filter(
|
||||
household_id=pk, user=request.user, status='pending'
|
||||
).first()
|
||||
if not membership:
|
||||
return Response({'detail': 'No pending invitation found.'}, status=404)
|
||||
membership.status = 'active'
|
||||
membership.save(update_fields=['status'])
|
||||
return Response({'detail': 'Invitation accepted.'})
|
||||
|
||||
|
||||
class HouseholdLeaveView(views.APIView):
|
||||
def post(self, request, pk):
|
||||
membership = HouseholdMembership.objects.filter(
|
||||
household_id=pk, user=request.user, status='active'
|
||||
).first()
|
||||
if not membership:
|
||||
return Response({'detail': 'You are not an active member of this household.'}, status=404)
|
||||
|
||||
next_year = datetime.date.today().year + 1
|
||||
membership.status = 'left'
|
||||
membership.effective_until_year = next_year
|
||||
membership.save(update_fields=['status', 'effective_until_year'])
|
||||
return Response({'detail': f'You will leave this household at the end of {next_year - 1}.'})
|
||||
|
||||
|
||||
class HouseholdSetRoleView(views.APIView):
|
||||
def post(self, request, pk, membership_id):
|
||||
# Only the founder can assign roles
|
||||
household = Household.objects.filter(pk=pk, created_by=request.user).first()
|
||||
if not household:
|
||||
return Response({'detail': 'Not found or not owner.'}, status=404)
|
||||
|
||||
membership = HouseholdMembership.objects.filter(
|
||||
pk=membership_id, household=household, status='active'
|
||||
).first()
|
||||
if not membership:
|
||||
return Response({'detail': 'Active membership not found.'}, status=404)
|
||||
|
||||
if membership.user == request.user:
|
||||
return Response({'detail': 'Cannot change your own role.'}, status=400)
|
||||
|
||||
role = request.data.get('role')
|
||||
if role not in ['member', 'admin']:
|
||||
return Response({'detail': 'Role must be "member" or "admin".'}, status=400)
|
||||
|
||||
membership.role = role
|
||||
membership.save(update_fields=['role'])
|
||||
return Response(HouseholdMembershipSerializer(membership).data)
|
||||
|
||||
|
||||
class HouseholdRevenueAccountsView(views.APIView):
|
||||
def get(self, request, pk):
|
||||
membership = HouseholdMembership.objects.filter(
|
||||
household_id=pk, user=request.user, status='active'
|
||||
).first()
|
||||
if not membership:
|
||||
return Response({'detail': 'Not a member of this household.'}, status=403)
|
||||
|
||||
member_users = HouseholdMembership.objects.filter(
|
||||
household_id=pk, status='active'
|
||||
).values_list('user_id', flat=True)
|
||||
|
||||
accounts = Account.objects.filter(
|
||||
user_id__in=member_users, account_type='revenue', active=True
|
||||
).select_related('user')
|
||||
|
||||
data = [
|
||||
{
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'balance': str(a.balance),
|
||||
'salary_months': a.salary_months,
|
||||
'owner_email': a.user.email,
|
||||
'is_mine': a.user_id == request.user.id,
|
||||
}
|
||||
for a in accounts
|
||||
]
|
||||
return Response(data)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "emails/base.html" %}
|
||||
|
||||
{% block subject %}Armarium – Einladung zum Haushalt{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo {{ invitee_name }},</p>
|
||||
|
||||
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
|
||||
<strong>{{ inviter_name }}</strong> hat dich eingeladen, dem Haushalt
|
||||
<strong>{{ household_name }}</strong> auf Armarium beizutreten.
|
||||
</p>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 28px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ accept_url }}"
|
||||
style="display:inline-block;background-color:#7c3aed;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;padding:14px 32px;border-radius:8px;letter-spacing:0.1px;">
|
||||
{{ cta_label }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
|
||||
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
|
||||
</p>
|
||||
<p style="margin:0 0 24px;font-size:13px;color:#7c3aed;line-height:1.6;word-break:break-all;">
|
||||
{{ accept_url }}
|
||||
</p>
|
||||
|
||||
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
|
||||
– Das Armarium-Team
|
||||
</p>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,10 @@
|
||||
Hallo {{ invitee_name }},
|
||||
|
||||
{{ inviter_name }} hat dich eingeladen, dem Haushalt "{{ household_name }}" auf Armarium beizutreten.
|
||||
|
||||
{{ cta_label }}:
|
||||
{{ accept_url }}
|
||||
|
||||
Falls du diese Einladung nicht erwartet hast, kannst du sie ignorieren.
|
||||
|
||||
– Das Armarium-Team
|
||||
Reference in New Issue
Block a user