feat: Armarium v1.1.0 — dashboard, auth, 2FA, SMTP, settings, deploy
Dashboard: - ApexCharts bar chart (income vs fixed costs vs expenses) and donut chart - KPI cards: income, fixed costs, savings rate with configurable goal - Greeting with time-of-day and locale-aware date/time display Authentication & security: - Email-based login (no username), case-insensitive lookup - JWT access/refresh tokens with rotation and blacklist - TOTP 2FA with QR code, backup codes (copy + PDF export) - 2FA recovery via email code - Cloudflare Turnstile CAPTCHA on login and register Email flows: - Email verification on registration (24h token) - Password reset flow (15min token, anti-enumeration) - Brevo SMTP integration with HTML + plaintext email templates - Notification emails: 2FA recovery, password changed, email changed Settings page: - 2FA management (enable/disable, QR, backup codes) - Active sessions list with per-device revoke - Data export: ZIP with 6 PDFs via fpdf2 - Notification preferences (3 toggles) - Danger zone: account deletion with mandatory export + confirmation phrase UI & layout: - Sidebar with collapsible/flyout mode, Angular signal-based dropdowns - Dark mode (class-based), language switcher (DE/FR/IT/EN) - Mobile-responsive layout with touch-friendly targets - Roboto font via @fontsource (GDPR-compliant, no Google CDN) - Pure Tailwind CSS v3 Infrastructure: - Forgejo Actions CI/CD pipeline (auto-deploy on push to main) - Gunicorn + Nginx + PostgreSQL production setup - Rate limiting, HSTS, secure cookies, CSRF protection
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
class Account(models.Model):
|
||||
# Typen basierend auf der Firefly III Logik
|
||||
ACCOUNT_TYPES = [
|
||||
('asset', 'Asset Account (Bank/Cash)'),
|
||||
('expense', 'Expense Account (Laden/Empfänger)'),
|
||||
('revenue', 'Revenue Account (Einnahmequelle)'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='accounts',
|
||||
null=True,
|
||||
)
|
||||
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)
|
||||
active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_account_type_display()})"
|
||||
|
||||
class Transaction(models.Model):
|
||||
description = models.CharField(max_length=255)
|
||||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
date = models.DateField()
|
||||
|
||||
# Die Verknüpfung zu den Konten (Double-Entry Prinzip)
|
||||
source_account = models.ForeignKey(
|
||||
Account,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='withdrawals'
|
||||
)
|
||||
destination_account = models.ForeignKey(
|
||||
Account,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='deposits'
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.date}: {self.description} ({self.amount}€)"
|
||||
|
||||
|
||||
class Budget(models.Model):
|
||||
MAIN_CATEGORY_CHOICES = [
|
||||
('fixed_expenses', 'Fixe Ausgaben'),
|
||||
('mobile_internet', 'Mobile & Internet'),
|
||||
('subscriptions', 'Abonnements'),
|
||||
('leisure', 'Freizeit'),
|
||||
('tax_reserves', 'Steuerrücklagen'),
|
||||
('insurance', 'Versicherungen'),
|
||||
('loans', 'Abzahlungen & Kredite'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
main_category = models.CharField(max_length=50, choices=MAIN_CATEGORY_CHOICES, default='fixed_expenses')
|
||||
account = models.ForeignKey(
|
||||
Account,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='budgets'
|
||||
)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.amount} CHF)"
|
||||
|
||||
|
||||
class Expense(models.Model):
|
||||
CATEGORY_CHOICES = [
|
||||
('groceries', 'Groceries'),
|
||||
('dining', 'Dining & Restaurants'),
|
||||
('transport', 'Transport'),
|
||||
('health', 'Health & Medical'),
|
||||
('clothing', 'Clothing'),
|
||||
('electronics', 'Electronics'),
|
||||
('household', 'Household'),
|
||||
('entertainment', 'Entertainment'),
|
||||
('travel', 'Travel'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
amount = models.DecimalField(max_digits=12, decimal_places=2)
|
||||
date = models.DateField()
|
||||
category = models.CharField(max_length=50, choices=CATEGORY_CHOICES, default='other')
|
||||
account = models.ForeignKey(
|
||||
Account,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='expenses'
|
||||
)
|
||||
notes = models.TextField(blank=True, default='')
|
||||
due_date = models.DateField(blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.date}: {self.name} ({self.amount} CHF)"
|
||||
|
||||
|
||||
CANTON_CHOICES = [
|
||||
('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'),
|
||||
('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'),
|
||||
('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'),
|
||||
('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'),
|
||||
('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'),
|
||||
('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'),
|
||||
('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'),
|
||||
('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'),
|
||||
('ZG', 'Zug'), ('ZH', 'Zürich'),
|
||||
]
|
||||
|
||||
|
||||
class Deadline(models.Model):
|
||||
TYPE_CHOICES = [
|
||||
('tax', 'Tax'),
|
||||
('insurance', 'Insurance'),
|
||||
('invoice', 'Invoice'),
|
||||
('personal', 'Personal'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='deadlines',
|
||||
)
|
||||
title = models.CharField(max_length=200)
|
||||
date = models.DateField()
|
||||
type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='other')
|
||||
notes = models.TextField(blank=True, default='')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.date}: {self.title}"
|
||||
|
||||
|
||||
class Profile(models.Model):
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='profile',
|
||||
null=True,
|
||||
)
|
||||
first_name = models.CharField(max_length=100, blank=True, default='')
|
||||
last_name = models.CharField(max_length=100, blank=True, default='')
|
||||
email = models.EmailField(blank=True, default='')
|
||||
avatar_color = models.CharField(max_length=7, default='#1A56DB')
|
||||
avatar_image = models.ImageField(upload_to='avatars/', blank=True, null=True)
|
||||
canton = models.CharField(max_length=2, choices=CANTON_CHOICES, default='ZH')
|
||||
language = models.CharField(max_length=2, choices=[('de','Deutsch'),('fr','Français'),('it','Italiano'),('en','English')], default='de')
|
||||
totp_secret = models.CharField(max_length=64, blank=True, default='')
|
||||
totp_enabled = models.BooleanField(default=False)
|
||||
totp_last_used_code = models.CharField(max_length=6, blank=True, default='')
|
||||
recovery_email = models.EmailField(blank=True, default='')
|
||||
recovery_code_hash = models.CharField(max_length=64, blank=True, default='')
|
||||
recovery_code_expires = models.DateTimeField(null=True, blank=True)
|
||||
notif_deadlines = models.BooleanField(default=True)
|
||||
notif_budget_alerts = models.BooleanField(default=True)
|
||||
notif_monthly_summary = models.BooleanField(default=False)
|
||||
savings_rate_goal = models.PositiveSmallIntegerField(default=20)
|
||||
email_verified = models.BooleanField(default=False)
|
||||
email_verify_token = models.CharField(max_length=64, blank=True, default='')
|
||||
email_verify_token_expires = models.DateTimeField(null=True, blank=True)
|
||||
password_reset_token_hash = models.CharField(max_length=64, blank=True, default='')
|
||||
password_reset_token_expires = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.first_name} {self.last_name}".strip() or 'Profile'
|
||||
|
||||
|
||||
class UserSession(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='user_sessions',
|
||||
)
|
||||
session_key = models.CharField(max_length=64, unique=True)
|
||||
refresh_jti = models.CharField(max_length=255, blank=True, default='')
|
||||
device_name = models.CharField(max_length=200, blank=True, default='')
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_active_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-last_active_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} – {self.device_name}"
|
||||
|
||||
|
||||
class ReadEvent(models.Model):
|
||||
EVENT_TYPES = [
|
||||
('deadline', 'Deadline'),
|
||||
('expense', 'Expense'),
|
||||
]
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='read_events',
|
||||
)
|
||||
event_type = models.CharField(max_length=20, choices=EVENT_TYPES)
|
||||
event_id = models.PositiveIntegerField()
|
||||
|
||||
class Meta:
|
||||
unique_together = ['user', 'event_type', 'event_id']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} – {self.event_type} {self.event_id}"
|
||||
|
||||
|
||||
class BackupCode(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='backup_codes',
|
||||
)
|
||||
code_hash = models.CharField(max_length=64)
|
||||
used = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [models.Index(fields=['user', 'used'])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} – backup {'used' if self.used else 'active'}"
|
||||
Reference in New Issue
Block a user