Initial commit

This commit is contained in:
Felix Baumann
2026-06-27 17:07:34 +00:00
commit fb47c53d52
256 changed files with 21270 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.pyo
.env
db.sqlite3
media/
staticfiles/
venv/
.venv/
+2
View File
@@ -0,0 +1,2 @@
django: python manage.py runserver
tailwind: python manage.py tailwind start
View File
+34
View File
@@ -0,0 +1,34 @@
from django.contrib import admin
from .models import Role, UserProfile
@admin.register(Role)
class RoleAdmin(admin.ModelAdmin):
list_display = [
"name",
]
search_fields = [
"name",
]
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = [
"user",
"role",
]
list_filter = [
"role",
]
search_fields = [
"user__username",
"user__first_name",
"user__last_name",
"user__email",
]
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
name = 'accounts'
+50
View File
@@ -0,0 +1,50 @@
from functools import wraps
from django.shortcuts import redirect
from django.contrib import messages
def permission_required(permission):
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
# Superuser haben alles
if request.user.is_superuser:
return view_func(
request,
*args,
**kwargs
)
profile = getattr(
request.user,
"profile",
None
)
if profile and profile.has_permission(permission):
return view_func(
request,
*args,
**kwargs
)
messages.error(
request,
"Keine Berechtigung für diese Aktion!"
)
return redirect("core:dashboard")
return wrapper
return decorator
+61
View File
@@ -0,0 +1,61 @@
from django import forms
from django.contrib.auth.models import User
from .models import Role
from core.forms.base import BaseForm
class UserAddForm(BaseForm):
password = forms.CharField(
label="Passwort",
widget=forms.PasswordInput
)
role = forms.ModelChoiceField(
queryset=Role.objects.order_by("name"),
required=False,
label="Rolle"
)
class Meta:
model = User
fields = [
"username",
"first_name",
"last_name",
"email",
]
labels = {
"username": "Benutzername",
"first_name": "Vorname",
"last_name": "Nachname",
"email": "E-Mail",
}
help_texts = {
"username": "",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.apply_style()
class RoleForm(BaseForm):
class Meta:
model = Role
fields = [
"name",
]
labels = {
"name": "Rollenname",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.apply_style()
+97
View File
@@ -0,0 +1,97 @@
# Generated by Django 6.0.6 on 2026-06-23 22:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Role',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='Rolle')),
('projects_view', models.BooleanField(default=False, help_text='Benutzer darf Projekte einsehen.', verbose_name='Projekte anzeigen')),
('projects_archived_view', models.BooleanField(default=False, help_text='Benutzer darf archivierte Projekte einsehen.', verbose_name='Archivierte Projekte anzeigen')),
('projects_detail_view', models.BooleanField(default=False, help_text='Benutzer darf Details des Projekts einsehen.', verbose_name='Projektdetails anzeigen')),
('projects_add', models.BooleanField(default=False, help_text='Benutzer darf neue Projekte anlegen.', verbose_name='Projekt erstellen')),
('projects_contact_add', models.BooleanField(default=False, help_text='Benutzer darf Kontakte mit dem Projekt verknüpfen.', verbose_name='Projektkontakt verknüpfen')),
('projects_contact_delete', models.BooleanField(default=False, help_text='Benutzer darf Kontakte aus dem Projekt entfernen.', verbose_name='Projektkontakt entfernen')),
('projects_location_add', models.BooleanField(default=False, help_text='Benutzer darf Standorte mit dem Projekt verknüpfen.', verbose_name='Projektstandort verknüpfen')),
('projects_location_delete', models.BooleanField(default=False, help_text='Benutzer darf Standorte aus dem Projekt entfernen.', verbose_name='Projektstandort entfernen')),
('projects_edit', models.BooleanField(default=False, help_text='Benutzer darf Projekte ändern.', verbose_name='Projekte bearbeiten')),
('projects_user_add', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter im Projekt hinzufügen.', verbose_name='Projektmitarbeiter erstellen')),
('projects_user_delete', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter aus dem Projekt entfernen.', verbose_name='Projektmitarbeiter entfernen')),
('projects_finance_view', models.BooleanField(default=False, help_text='Benutzer darf Projektfinanzen einsehen.', verbose_name='Projektfinanzen anzeigen')),
('projects_finance_income_add', models.BooleanField(default=False, help_text='Benutzer darf Einnahmen hinzufügen.', verbose_name='Projekteinnahmen erfassen')),
('projects_finance_expense_add', models.BooleanField(default=False, help_text='Benutzer darf Ausgaben hinzufügen.', verbose_name='Projektausgaben erfassen')),
('projects_time_tracking_view', models.BooleanField(default=False, help_text='Benutzer darf Projektzeiten sehen.', verbose_name='Projektzeiten anzeigen')),
('projects_time_tracking_edit', models.BooleanField(default=False, help_text='Benutzer darf Projektzeiten ändern.', verbose_name='Projektzeiten bearbeiten')),
('projects_time_tracking_delete', models.BooleanField(default=False, help_text='Benutzer darf Projektzeiten löschen.', verbose_name='Projektzeiten löschen')),
('projects_documents_view', models.BooleanField(default=False, help_text='Benutzer darf Dokumente sehen.', verbose_name='Projektdokumente anzeigen')),
('projects_documents_add', models.BooleanField(default=False, help_text='Benutzer darf Dokumente hochladen.', verbose_name='Projektdokumente hinzufügen')),
('projects_documents_delete', models.BooleanField(default=False, help_text='Benutzer darf Dokumente löschen.', verbose_name='Projektdokumente löschen')),
('projects_inventory_inventory_view', models.BooleanField(default=False, help_text='Benutzer darf Projektinventar sehen.', verbose_name='Projektinventar anzeigen')),
('projects_inventory_inventory_add', models.BooleanField(default=False, help_text='Benutzer darf Inventar hinzufügen.', verbose_name='Projektinventar hinzufügen')),
('projects_inventory_inventory_edit', models.BooleanField(default=False, help_text='Benutzer darf Inventar ändern.', verbose_name='Projektinventar bearbeiten')),
('projects_inventory_inventory_delete', models.BooleanField(default=False, help_text='Benutzer darf Inventar löschen.', verbose_name='Projektinventar löschen')),
('projects_inventory_shopping_add', models.BooleanField(default=False, help_text='Benutzer darf Einkaufspositionen erstellen.', verbose_name='Einkauf hinzufügen')),
('projects_inventory_shopping_edit', models.BooleanField(default=False, help_text='Benutzer darf Einkaufspositionen ändern.', verbose_name='Einkauf bearbeiten')),
('projects_inventory_shopping_delete', models.BooleanField(default=False, help_text='Benutzer darf Einkaufspositionen löschen.', verbose_name='Einkauf löschen')),
('inventory_view', models.BooleanField(default=False, help_text='Benutzer darf Inventar einsehen.', verbose_name='Inventar anzeigen')),
('inventory_add', models.BooleanField(default=False, help_text='Benutzer darf Inventareinträge erstellen.', verbose_name='Inventar hinzufügen')),
('inventory_edit', models.BooleanField(default=False, help_text='Benutzer darf Inventareinträge ändern.', verbose_name='Inventar bearbeiten')),
('inventory_delete', models.BooleanField(default=False, help_text='Benutzer darf Inventareinträge löschen.', verbose_name='Inventar löschen')),
('invoices_view', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen verwalten.', verbose_name='Rechnungsbereich anzeigen')),
('invoices_customers_view', models.BooleanField(default=False, help_text='Benutzer darf Kunden sehen.', verbose_name='Kunden anzeigen')),
('invoices_customers_detail_view', models.BooleanField(default=False, help_text='Benutzer darf Details des Kunden sehen.', verbose_name='Kundendetails anzeigen')),
('invoices_customers_add', models.BooleanField(default=False, help_text='Benutzer darf Kunden anlegen.', verbose_name='Kunden erstellen')),
('invoices_customers_edit', models.BooleanField(default=False, help_text='Benutzer darf Kunden ändern.', verbose_name='Kunden bearbeiten')),
('invoices_customers_archive', models.BooleanField(default=False, help_text='Benutzer darf Kunden archivieren.', verbose_name='Kunden archivieren')),
('invoices_customers_activate', models.BooleanField(default=False, help_text='Benutzer darf Kunden wieder aktivieren.', verbose_name='Kunden reaktivieren')),
('invoices_items_view', models.BooleanField(default=False, help_text='Benutzer darf Artikel sehen.', verbose_name='Artikel anzeigen')),
('invoices_items_add', models.BooleanField(default=False, help_text='Benutzer darf Artikel erstellen.', verbose_name='Artikel erstellen')),
('invoices_items_edit', models.BooleanField(default=False, help_text='Benutzer darf Artikel ändern.', verbose_name='Artikel bearbeiten')),
('invoices_items_delete', models.BooleanField(default=False, help_text='Benutzer darf Artikel löschen.', verbose_name='Artikel löschen')),
('invoices_invoices_view', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen sehen.', verbose_name='Rechnungen anzeigen')),
('invoices_invoices_project_add', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen aus Projekten erstellen.', verbose_name='Projekt-Rechnung erstellen')),
('invoices_invoices_customer_add', models.BooleanField(default=False, help_text='Benutzer darf Rechnungen für Kunden erstellen.', verbose_name='Kunden-Rechnung erstellen')),
('invoices_invoices_invoices_view', models.BooleanField(default=False, help_text='Benutzer darf Rechnungsdetails sehen.', verbose_name='Rechnungsdetails anzeigen')),
('invoices_invoices_invoices_item_add', models.BooleanField(default=False, help_text='Benutzer darf Positionen hinzufügen.', verbose_name='Rechnungsposition hinzufügen')),
('invoices_invoices_invoices_item_delete', models.BooleanField(default=False, help_text='Benutzer darf Positionen löschen.', verbose_name='Rechnungsposition löschen')),
('invoices_invoices_invoices_preview', models.BooleanField(default=False, help_text='Benutzer darf Vorschauen öffnen.', verbose_name='Rechnungsvorschau anzeigen')),
('users_view', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter sehen.', verbose_name='Benutzer anzeigen')),
('users_add', models.BooleanField(default=False, help_text='Benutzer darf neue Mitarbeiter anlegen.', verbose_name='Benutzer erstellen')),
('users_edit', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter bearbeiten.', verbose_name='Benutzer bearbeiten')),
('users_archive', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter deaktivieren.', verbose_name='Benutzer archivieren')),
('users_activate', models.BooleanField(default=False, help_text='Benutzer darf Mitarbeiter aktivieren.', verbose_name='Benutzer aktivieren')),
('users_groups_add', models.BooleanField(default=False, help_text='Benutzer darf neue Rollen erstellen.', verbose_name='Rollen erstellen')),
('users_groups_edit', models.BooleanField(default=False, help_text='Benutzer darf Rollen und Berechtigungen ändern.', verbose_name='Rollen bearbeiten')),
('users_groups_delete', models.BooleanField(default=False, help_text='Benutzer darf Rollen entfernen.', verbose_name='Rollen löschen')),
('settings_view', models.BooleanField(default=False, help_text='Benutzer darf Einstellungskategorien sehen.', verbose_name='Einstellungsübersicht anzeigen')),
('settings_detail_view', models.BooleanField(default=False, help_text='Benutzer darf Systemeinstellungen sehen.', verbose_name='Einstellungen anzeigen')),
('settings_add', models.BooleanField(default=False, help_text='Benutzer darf neue Einstellungen hinzufügen.', verbose_name='Einstellungen erstellen')),
('settings_delete', models.BooleanField(default=False, help_text='Benutzer darf Einstellungen entfernen.', verbose_name='Einstellungen löschen')),
],
options={
'verbose_name': 'Rolle',
'verbose_name_plural': 'Rollen',
},
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.role')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-06-23 23:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='role',
name='invoices_invoices_invoices_download',
field=models.BooleanField(default=False, help_text='Benutzer darf Rechnungen herunterladen.', verbose_name='Rechnung herunterladen'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-06-24 08:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_role_invoices_invoices_invoices_download'),
]
operations = [
migrations.AddField(
model_name='role',
name='invoices_invoices_invoices_cancel',
field=models.BooleanField(default=False, help_text='Benutzer darf Rechnungen stornieren.', verbose_name='Rechnung stornieren'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-06-24 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_role_invoices_invoices_invoices_cancel'),
]
operations = [
migrations.AddField(
model_name='role',
name='invoices_invoices_invoices_cancel_download',
field=models.BooleanField(default=False, help_text='Benutzer darf Stornorechnungen herunterladen.', verbose_name='Stornorechnung Download'),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 6.0.6 on 2026-06-24 13:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_role_invoices_invoices_invoices_cancel_download'),
]
operations = [
migrations.RemoveField(
model_name='role',
name='invoices_invoices_invoices_cancel_download',
),
migrations.AddField(
model_name='role',
name='invoices_invoices_invoices_paid',
field=models.BooleanField(default=False, help_text='Benutzer darf Rechnungsstatus ändern.', verbose_name='Rechnung auf bezahlt setzen'),
),
]
@@ -0,0 +1,53 @@
# Generated by Django 6.0.6 on 2026-06-25 16:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_remove_role_invoices_invoices_invoices_cancel_download_and_more'),
]
operations = [
migrations.AddField(
model_name='role',
name='invoices_offers_add',
field=models.BooleanField(default=False, help_text='Benutzer darf Angebote für Kunden erstellen.', verbose_name='Angebot erstellen'),
),
migrations.AddField(
model_name='role',
name='invoices_offers_offers_download',
field=models.BooleanField(default=False, help_text='Benutzer darf Angebote herunterladen.', verbose_name='Angebot herunterladen'),
),
migrations.AddField(
model_name='role',
name='invoices_offers_offers_item_add',
field=models.BooleanField(default=False, help_text='Benutzer darf Positionen hinzufügen.', verbose_name='Angebotsposition hinzufügen'),
),
migrations.AddField(
model_name='role',
name='invoices_offers_offers_item_delete',
field=models.BooleanField(default=False, help_text='Benutzer darf Positionen löschen.', verbose_name='Angebotsposition löschen'),
),
migrations.AddField(
model_name='role',
name='invoices_offers_offers_preview',
field=models.BooleanField(default=False, help_text='Benutzer darf Vorschauen öffnen.', verbose_name='Angebotsvorschau anzeigen'),
),
migrations.AddField(
model_name='role',
name='invoices_offers_offers_status',
field=models.BooleanField(default=False, help_text='Benutzer darf Angebotsstatus ändern.', verbose_name='Angebotsstatus bearbeiten'),
),
migrations.AddField(
model_name='role',
name='invoices_offers_offers_view',
field=models.BooleanField(default=False, help_text='Benutzer darf Angebotsdetails sehen.', verbose_name='Angebotsdetails anzeigen'),
),
migrations.AddField(
model_name='role',
name='invoices_offers_view',
field=models.BooleanField(default=False, help_text='Benutzer darf Angebote sehen.', verbose_name='Angebote anzeigen'),
),
]
View File
+193
View File
@@ -0,0 +1,193 @@
from django.db import models
from django.contrib.auth.models import User
class Role(models.Model):
name = models.CharField(
max_length=100,
unique=True,
verbose_name="Rolle"
)
projects_view = models.BooleanField(default=False, verbose_name="Projekte anzeigen", help_text="Benutzer darf Projekte einsehen.")
projects_archived_view = models.BooleanField(default=False, verbose_name="Archivierte Projekte anzeigen", help_text="Benutzer darf archivierte Projekte einsehen.")
projects_detail_view = models.BooleanField(default=False, verbose_name="Projektdetails anzeigen", help_text="Benutzer darf Details des Projekts einsehen.")
projects_add = models.BooleanField(default=False, verbose_name="Projekt erstellen", help_text="Benutzer darf neue Projekte anlegen.")
projects_contact_add = models.BooleanField(default=False, verbose_name="Projektkontakt verknüpfen", help_text="Benutzer darf Kontakte mit dem Projekt verknüpfen.")
projects_contact_delete = models.BooleanField(default=False, verbose_name="Projektkontakt entfernen", help_text="Benutzer darf Kontakte aus dem Projekt entfernen.")
projects_location_add = models.BooleanField(default=False, verbose_name="Projektstandort verknüpfen", help_text="Benutzer darf Standorte mit dem Projekt verknüpfen.")
projects_location_delete = models.BooleanField(default=False, verbose_name="Projektstandort entfernen", help_text="Benutzer darf Standorte aus dem Projekt entfernen.")
projects_edit = models.BooleanField(default=False, verbose_name="Projekte bearbeiten", help_text="Benutzer darf Projekte ändern.")
projects_user_add = models.BooleanField(default=False, verbose_name="Projektmitarbeiter erstellen", help_text="Benutzer darf Mitarbeiter im Projekt hinzufügen.")
projects_user_delete = models.BooleanField(default=False, verbose_name="Projektmitarbeiter entfernen", help_text="Benutzer darf Mitarbeiter aus dem Projekt entfernen.")
projects_finance_view = models.BooleanField(default=False, verbose_name="Projektfinanzen anzeigen", help_text="Benutzer darf Projektfinanzen einsehen.")
projects_finance_income_add = models.BooleanField(default=False, verbose_name="Projekteinnahmen erfassen", help_text="Benutzer darf Einnahmen hinzufügen.")
projects_finance_expense_add = models.BooleanField(default=False, verbose_name="Projektausgaben erfassen", help_text="Benutzer darf Ausgaben hinzufügen.")
projects_time_tracking_view = models.BooleanField(default=False, verbose_name="Projektzeiten anzeigen", help_text="Benutzer darf Projektzeiten sehen.")
projects_time_tracking_edit = models.BooleanField(default=False, verbose_name="Projektzeiten bearbeiten", help_text="Benutzer darf Projektzeiten ändern.")
projects_time_tracking_delete = models.BooleanField(default=False, verbose_name="Projektzeiten löschen", help_text="Benutzer darf Projektzeiten löschen.")
projects_documents_view = models.BooleanField(default=False, verbose_name="Projektdokumente anzeigen", help_text="Benutzer darf Dokumente sehen.")
projects_documents_add = models.BooleanField(default=False, verbose_name="Projektdokumente hinzufügen", help_text="Benutzer darf Dokumente hochladen.")
projects_documents_delete = models.BooleanField(default=False, verbose_name="Projektdokumente löschen", help_text="Benutzer darf Dokumente löschen.")
projects_inventory_inventory_view = models.BooleanField(default=False, verbose_name="Projektinventar anzeigen", help_text="Benutzer darf Projektinventar sehen.")
projects_inventory_inventory_add = models.BooleanField(default=False, verbose_name="Projektinventar hinzufügen", help_text="Benutzer darf Inventar hinzufügen.")
projects_inventory_inventory_edit = models.BooleanField(default=False, verbose_name="Projektinventar bearbeiten", help_text="Benutzer darf Inventar ändern.")
projects_inventory_inventory_delete = models.BooleanField(default=False, verbose_name="Projektinventar löschen", help_text="Benutzer darf Inventar löschen.")
projects_inventory_shopping_add = models.BooleanField(default=False, verbose_name="Einkauf hinzufügen", help_text="Benutzer darf Einkaufspositionen erstellen.")
projects_inventory_shopping_edit = models.BooleanField(default=False, verbose_name="Einkauf bearbeiten", help_text="Benutzer darf Einkaufspositionen ändern.")
projects_inventory_shopping_delete = models.BooleanField(default=False, verbose_name="Einkauf löschen", help_text="Benutzer darf Einkaufspositionen löschen.")
#projects_inventory_shopping_buy = models.BooleanField(default=False, verbose_name="Einkäufe durchführen", help_text="Benutzer darf Einkäufe als erledigt markieren.")
# =====================
# Inventar
# =====================
inventory_view = models.BooleanField(default=False, verbose_name="Inventar anzeigen", help_text="Benutzer darf Inventar einsehen.")
inventory_add = models.BooleanField(default=False, verbose_name="Inventar hinzufügen", help_text="Benutzer darf Inventareinträge erstellen.")
inventory_edit = models.BooleanField(default=False, verbose_name="Inventar bearbeiten", help_text="Benutzer darf Inventareinträge ändern.")
inventory_delete = models.BooleanField(default=False, verbose_name="Inventar löschen", help_text="Benutzer darf Inventareinträge löschen.")
# =====================
# Rechnungen
# =====================
invoices_view = models.BooleanField(default=False, verbose_name="Rechnungsbereich anzeigen", help_text="Benutzer darf Rechnungen verwalten.")
invoices_customers_view = models.BooleanField(default=False, verbose_name="Kunden anzeigen", help_text="Benutzer darf Kunden sehen.")
invoices_customers_detail_view = models.BooleanField(default=False, verbose_name="Kundendetails anzeigen", help_text="Benutzer darf Details des Kunden sehen.")
invoices_customers_add = models.BooleanField(default=False, verbose_name="Kunden erstellen", help_text="Benutzer darf Kunden anlegen.")
invoices_customers_edit = models.BooleanField(default=False, verbose_name="Kunden bearbeiten", help_text="Benutzer darf Kunden ändern.")
invoices_customers_archive = models.BooleanField(default=False, verbose_name="Kunden archivieren", help_text="Benutzer darf Kunden archivieren.")
invoices_customers_activate = models.BooleanField(default=False, verbose_name="Kunden reaktivieren", help_text="Benutzer darf Kunden wieder aktivieren.")
invoices_items_view = models.BooleanField(default=False, verbose_name="Artikel anzeigen", help_text="Benutzer darf Artikel sehen.")
invoices_items_add = models.BooleanField(default=False, verbose_name="Artikel erstellen", help_text="Benutzer darf Artikel erstellen.")
invoices_items_edit = models.BooleanField(default=False, verbose_name="Artikel bearbeiten", help_text="Benutzer darf Artikel ändern.")
invoices_items_delete = models.BooleanField(default=False, verbose_name="Artikel löschen", help_text="Benutzer darf Artikel löschen.")
invoices_invoices_view = models.BooleanField(default=False, verbose_name="Rechnungen anzeigen", help_text="Benutzer darf Rechnungen sehen.")
invoices_invoices_project_add = models.BooleanField(default=False, verbose_name="Projekt-Rechnung erstellen", help_text="Benutzer darf Rechnungen aus Projekten erstellen.")
invoices_invoices_customer_add = models.BooleanField(default=False, verbose_name="Kunden-Rechnung erstellen", help_text="Benutzer darf Rechnungen für Kunden erstellen.")
invoices_invoices_invoices_view = models.BooleanField(default=False, verbose_name="Rechnungsdetails anzeigen", help_text="Benutzer darf Rechnungsdetails sehen.")
invoices_invoices_invoices_item_add = models.BooleanField(default=False, verbose_name="Rechnungsposition hinzufügen", help_text="Benutzer darf Positionen hinzufügen.")
invoices_invoices_invoices_item_delete = models.BooleanField(default=False, verbose_name="Rechnungsposition löschen", help_text="Benutzer darf Positionen löschen.")
invoices_invoices_invoices_preview = models.BooleanField(default=False, verbose_name="Rechnungsvorschau anzeigen", help_text="Benutzer darf Vorschauen öffnen.")
invoices_invoices_invoices_download = models.BooleanField(default=False, verbose_name="Rechnung herunterladen", help_text="Benutzer darf Rechnungen herunterladen.")
invoices_invoices_invoices_cancel = models.BooleanField(default=False, verbose_name="Rechnung stornieren", help_text="Benutzer darf Rechnungen stornieren.")
invoices_invoices_invoices_paid = models.BooleanField(default=False, verbose_name="Rechnung auf bezahlt setzen", help_text="Benutzer darf Rechnungsstatus ändern.")
invoices_offers_view = models.BooleanField(default=False, verbose_name="Angebote anzeigen", help_text="Benutzer darf Angebote sehen.")
invoices_offers_add = models.BooleanField(default=False, verbose_name="Angebot erstellen", help_text="Benutzer darf Angebote für Kunden erstellen.")
invoices_offers_offers_view = models.BooleanField(default=False, verbose_name="Angebotsdetails anzeigen", help_text="Benutzer darf Angebotsdetails sehen.")
invoices_offers_offers_item_add = models.BooleanField(default=False, verbose_name="Angebotsposition hinzufügen", help_text="Benutzer darf Positionen hinzufügen.")
invoices_offers_offers_item_delete = models.BooleanField(default=False, verbose_name="Angebotsposition löschen", help_text="Benutzer darf Positionen löschen.")
invoices_offers_offers_preview = models.BooleanField(default=False, verbose_name="Angebotsvorschau anzeigen", help_text="Benutzer darf Vorschauen öffnen.")
invoices_offers_offers_download = models.BooleanField(default=False, verbose_name="Angebot herunterladen", help_text="Benutzer darf Angebote herunterladen.")
invoices_offers_offers_status = models.BooleanField(default=False, verbose_name="Angebotsstatus bearbeiten", help_text="Benutzer darf Angebotsstatus ändern.")
# =====================
# Benutzerverwaltung
# =====================
users_view = models.BooleanField(default=False, verbose_name="Benutzer anzeigen", help_text="Benutzer darf Mitarbeiter sehen.")
users_add = models.BooleanField(default=False, verbose_name="Benutzer erstellen", help_text="Benutzer darf neue Mitarbeiter anlegen.")
users_edit = models.BooleanField(default=False, verbose_name="Benutzer bearbeiten", help_text="Benutzer darf Mitarbeiter bearbeiten.")
users_archive = models.BooleanField(default=False, verbose_name="Benutzer archivieren", help_text="Benutzer darf Mitarbeiter deaktivieren.")
users_activate = models.BooleanField(default=False, verbose_name="Benutzer aktivieren", help_text="Benutzer darf Mitarbeiter aktivieren.")
users_groups_add = models.BooleanField(default=False, verbose_name="Rollen erstellen", help_text="Benutzer darf neue Rollen erstellen.")
users_groups_edit = models.BooleanField(default=False, verbose_name="Rollen bearbeiten", help_text="Benutzer darf Rollen und Berechtigungen ändern.")
users_groups_delete = models.BooleanField(default=False, verbose_name="Rollen löschen", help_text="Benutzer darf Rollen entfernen.")
# =====================
# Einstellungen
# =====================
settings_view = models.BooleanField(default=False, verbose_name="Einstellungsübersicht anzeigen", help_text="Benutzer darf Einstellungskategorien sehen.")
settings_detail_view = models.BooleanField(default=False, verbose_name="Einstellungen anzeigen", help_text="Benutzer darf Systemeinstellungen sehen.")
settings_add = models.BooleanField(default=False, verbose_name="Einstellungen erstellen", help_text="Benutzer darf neue Einstellungen hinzufügen.")
#settings_edit = models.BooleanField(default=False, verbose_name="Einstellungen bearbeiten", help_text="Benutzer darf Einstellungen ändern.")
settings_delete = models.BooleanField(default=False, verbose_name="Einstellungen löschen", help_text="Benutzer darf Einstellungen entfernen.")
# =====================
# Zeiterfassung
# =====================
#time_tracking_own_view = models.BooleanField(default=False, verbose_name="Eigene Zeiten anzeigen", help_text="Benutzer darf eigene Arbeitszeiten sehen.")
#time_tracking_own_add = models.BooleanField(default=False, verbose_name="Eigene Zeiten erfassen", help_text="Benutzer darf eigene Arbeitszeiten hinzufügen.")
#time_tracking_own_edit = models.BooleanField(default=False, verbose_name="Eigene Zeiten bearbeiten", help_text="Benutzer darf eigene Arbeitszeiten ändern.")
#time_tracking_own_delete = models.BooleanField(default=False, verbose_name="Eigene Zeiten löschen", help_text="Benutzer darf eigene Arbeitszeiten löschen.")
#time_tracking_all_view = models.BooleanField(default=False, verbose_name="Alle Zeiten anzeigen", help_text="Benutzer darf Arbeitszeiten aller Benutzer sehen.")
#time_tracking_all_add = models.BooleanField(default=False, verbose_name="Alle Zeiten erfassen", help_text="Benutzer darf Arbeitszeiten für andere Benutzer hinzufügen.")
#time_tracking_all_edit = models.BooleanField(default=False, verbose_name="Alle Zeiten bearbeiten", help_text="Benutzer darf Arbeitszeiten anderer Benutzer ändern.")
#time_tracking_all_delete = models.BooleanField(default=False, verbose_name="Alle Zeiten löschen", help_text="Benutzer darf Arbeitszeiten anderer Benutzer löschen.")
# =====================
# Finanzen
# =====================
#finance_view = models.BooleanField(default=False, verbose_name="Finanzen anzeigen", help_text="Benutzer darf Finanzdaten sehen.")
#finance_export = models.BooleanField(default=False, verbose_name="Finanzen exportieren", help_text="Benutzer darf Finanzdaten exportieren.")
# =====================
# Investments
# =====================
#invests_view = models.BooleanField(default=False, verbose_name="Investitionen anzeigen", help_text="Benutzer darf Investitionen sehen.")
#invests_add = models.BooleanField(default=False, verbose_name="Investitionen erstellen", help_text="Benutzer darf neue Investitionen anlegen.")
#invests_edit = models.BooleanField(default=False, verbose_name="Investitionen bearbeiten", help_text="Benutzer darf Investitionen ändern.")
#invests_delete = models.BooleanField(default=False, verbose_name="Investitionen löschen", help_text="Benutzer darf Investitionen entfernen.")
class Meta:
verbose_name = "Rolle"
verbose_name_plural = "Rollen"
def __str__(self):
return self.name
class UserProfile(models.Model):
user = models.OneToOneField(
User,
on_delete=models.CASCADE,
related_name="profile"
)
role = models.ForeignKey(
Role,
on_delete=models.SET_NULL,
null=True,
blank=True
)
def has_permission(self, permission):
if self.user.is_superuser:
return True
if not self.role:
return False
return getattr(
self.role,
permission.replace(".", "_"),
False
)
def __str__(self):
return self.user.username
+102
View File
@@ -0,0 +1,102 @@
import django_tables2 as tables
from django.contrib.auth.models import User
from .models import Role
class RoleTable(tables.Table):
toggle_prefix = "role"
detail_template = "accounts/tables/user/role_edit_form.html"
name = tables.Column(
verbose_name="Rolle"
)
class Meta:
model = Role
template_name = "django_tables2/table_toggle.html"
fields = (
"name",
)
attrs = {
"class": "w-full text-sm min-w-[700px]"
}
class ArchivedUserTable(tables.Table):
toggle_prefix = "user"
detail_template = "accounts/tables/user/archive_edit_form.html"
username = tables.Column(
verbose_name="Benutzername"
)
first_name = tables.Column(
verbose_name="Vorname"
)
last_name = tables.Column(
verbose_name="Nachname"
)
email = tables.Column(
verbose_name="E-Mail"
)
role = tables.TemplateColumn(
template_name="accounts/tables/user/role.html",
verbose_name="Rolle",
orderable=False
)
superuser = tables.TemplateColumn(
template_name="accounts/tables/user/superuser.html",
verbose_name="Superuser",
orderable=False
)
class Meta:
model = User
template_name = "django_tables2/table_toggle.html"
fields = (
"username",
"first_name",
"last_name",
"email",
"role",
"superuser",
)
attrs = {
"class": "w-full text-sm min-w-[700px]"
}
class UserTable(tables.Table):
toggle_prefix = "user"
detail_template = "accounts/tables/user/edit_form.html"
username = tables.Column(
verbose_name="Benutzername"
)
first_name = tables.Column(
verbose_name="Vorname"
)
last_name = tables.Column(
verbose_name="Nachname"
)
email = tables.Column(
verbose_name="E-Mail"
)
role = tables.TemplateColumn(
template_name="accounts/tables/user/role.html",
verbose_name="Rolle",
orderable=False
)
superuser = tables.TemplateColumn(
template_name="accounts/tables/user/superuser.html",
verbose_name="Superuser",
orderable=False
)
class Meta:
model = User
template_name = "django_tables2/table_toggle.html"
fields = (
"username",
"first_name",
"last_name",
"email",
"role",
"superuser",
)
attrs = {
"class": "w-full text-sm min-w-[700px]"
}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}
Mitarbeiter
{% endblock %}
{% block header_title %}
Mitarbeiter hinzufügen
{% endblock %}
{% block header_description %}
Neuen Mitarbeiter anlegen
{% endblock %}
{% block content %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<form method="post" enctype="multipart/form-data" class="space-y-4">
{% csrf_token %}
<div class="grid grid-cols-1 gap-4">
{{ form.as_p }}
</div>
<button
type="submit"
class="btn mt-4 btn-save">
Speichern
</button>
</form>
</div>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}
Login
{% endblock %}
{% block header_title %}
<div class="flex justify-center">
{% if company_settings.logo %}
<img src="{{ company_settings.logo.url }}" alt="" class="h-32">
{% elif company_settings.companyname %}
{{ company_settings.companyname }}
{% else %}
Projektverwaltung
{% endif %}
</div>
{% endblock %}
{% block header_description %}
{% endblock %}
{% block content %}
<div class="flex justify-center items-center min-h-[80vh]">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors w-full max-w-md">
<h1 class="text-3xl font-bold text-center mb-6">
Anmelden
</h1>
<form method="post" class="space-y-4">
{% csrf_token %}
{% for field in form %}
<div class="space-y-1">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300">
{{ field.label }}
</label>
<input
type="{{ field.field.widget.input_type }}"
name="{{ field.html_name }}"
id="{{ field.id_for_label }}"
{% if field.value %}value="{{ field.value }}"{% endif %}
class="w-full px-4 py-3 rounded-lg border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
placeholder="{{ field.label }}"
>
{% if field.errors %}
<p class="text-sm text-red-500">{{ field.errors|join:", " }}</p>
{% endif %}
</div>
{% endfor %}
<button
type="submit"
class="btn w-full btn-action"
>
Anmelden
</button>
</form>
</div>
</div>
{% endblock %}
+100
View File
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% block title %}
Mitarbeiter
{% endblock %}
{% block header_title %}
Mitarbeiter
{% endblock %}
{% block header_description %}
Mitarbeiter, Rollen und Berechtigungen verwalten
{% endblock %}
{% block content %}
{% load django_tables2 %}
<div class="mb-8">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Mitarbeiter oder Rollen hinzufügen</p>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<a href="{% url 'accounts:user_add' %}" class="btn btn-add">+ Mitarbeiter</a>
<a href="{% url 'accounts:role_add' %}" class="btn btn-add">+ Rolle</a>
</div>
</div>
</div>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<div class="mb-6">
<h2 class="text-xl font-bold">
Benutzer
</h2>
<p class="text-slate-400 text-sm mt-1">
Aktive Benutzer mit Rollen und Berechtigungen
</p>
</div>
<div class="overflow-x-auto rounded-xl">
{% render_table table %}
</div>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<div class="mb-6">
<h2 class="text-xl font-bold">
Archivierte Benutzer
</h2>
<p class="text-slate-400 text-sm mt-1">
Deaktivierte Benutzer
</p>
</div>
<div class="overflow-x-auto rounded-xl">
{% render_table archived_table %}
</div>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mt-8">
<div class="mb-6">
<h2 class="text-xl font-bold">
Rollen
</h2>
<p class="text-slate-400 text-sm mt-1">
Rollen und Berechtigungen verwalten
</p>
</div>
<div class="overflow-x-auto rounded-xl">
{% render_table role_table %}
</div>
</div>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}
Mitarbeiter
{% endblock %}
{% block header_title %}
Rolle hinzufügen
{% endblock %}
{% block header_description %}
Neue Rolle anlegen
{% endblock %}
{% block content %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-8">
<form method="post" enctype="multipart/form-data" class="space-y-4">
{% csrf_token %}
<div class="grid grid-cols-1 gap-4">
{{ form.as_p }}
</div>
<button
type="submit"
class="btn mt-4 btn-save">
Speichern
</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,121 @@
<form method="post" action="{% url 'accounts:user_edit' item.id %}">
{% csrf_token %}
<div>
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-4">
<div>
<label class="block text-sm mb-1">
Benutzername
</label>
<input
type="text"
name="username"
value="{{ item.username }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Vorname
</label>
<input
type="text"
name="first_name"
value="{{ item.first_name }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Nachname
</label>
<input
type="text"
name="last_name"
value="{{ item.last_name }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
E-Mail
</label>
<input
type="email"
name="email"
value="{{ item.email }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Rolle
</label>
<select
name="role"
class="border rounded px-3 py-2 w-full">
<option value="">
---
</option>
{% for role in table.roles %}
<option
value="{{ role.id }}"
{% if item.profile.role_id == role.id %}selected{% endif %}>
{{ role.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm mb-1">
Letzter Login
</label>
<input
type="text"
disabled
value="{{ item.last_login|date:'d.m.Y H:i' }}"
class="border rounded px-3 py-2 w-full bg-slate-100">
</div>
</div>
<div class="mt-5 flex gap-2">
<button
type="submit"
class="btn btn-save">
Speichern
</button>
<button
type="submit"
formaction="{% url 'accounts:user_reactivate' item.id %}"
class="btn btn-action">
Aktivieren
</button>
</div>
</div>
</form>
@@ -0,0 +1,121 @@
<form method="post" action="{% url 'accounts:user_edit' item.id %}">
{% csrf_token %}
<div>
<div class="grid md:grid-cols-2 xl:grid-cols-3 gap-4">
<div>
<label class="block text-sm mb-1">
Benutzername
</label>
<input
type="text"
name="username"
value="{{ item.username }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Vorname
</label>
<input
type="text"
name="first_name"
value="{{ item.first_name }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Nachname
</label>
<input
type="text"
name="last_name"
value="{{ item.last_name }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
E-Mail
</label>
<input
type="email"
name="email"
value="{{ item.email }}"
class="border rounded px-3 py-2 w-full">
</div>
<div>
<label class="block text-sm mb-1">
Rolle
</label>
<select
name="role"
class="border rounded px-3 py-2 w-full">
<option value="">
---
</option>
{% for role in table.roles %}
<option
value="{{ role.id }}"
{% if item.profile.role_id == role.id %}selected{% endif %}>
{{ role.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm mb-1">
Letzter Login
</label>
<input
type="text"
disabled
value="{{ item.last_login|date:'d.m.Y H:i' }}"
class="border rounded px-3 py-2 w-full bg-slate-100">
</div>
</div>
<div class="mt-5 flex gap-2">
<button
type="submit"
class="btn btn-save">
Speichern
</button>
<button
type="submit"
formaction="{% url 'accounts:user_archive' item.id %}"
class="btn btn-action">
Archivieren
</button>
</div>
</div>
</form>
@@ -0,0 +1 @@
{{ record.profile.role.name|default:"-" }}
@@ -0,0 +1,63 @@
<form method="post" action="{% url 'accounts:role_edit' item.id %}">
{% csrf_token %}
<div>
<h3 class="font-bold mb-4">
Berechtigungen
</h3>
<div class="grid md:grid-cols-2 xl-grid-cols-3 gap-4">
{% for permission in item.permissions %}
<label class="
bg-slate-100 dark:bg-slate-700
rounded-xl
p-3
flex
items-center
gap-3
">
<input
type="checkbox"
name="{{ permission.name }}"
{% if permission.value %}
checked
{% endif %}
class="w-4 h-4">
<span>
{{ permission.verbose_name }}
</span>
</label>
{% endfor %}
</div>
<div class="mt-5 flex gap-2">
<button
type="submit"
class="btn btn-save">
Speichern
</button>
<button
type="submit"
formaction="{% url 'accounts:role_delete' item.id %}"
class="btn btn-delete">
Löschen
</button>
</div>
</div>
</form>
@@ -0,0 +1,5 @@
{% if record.is_superuser %}
Ja
{% else %}
Nein
{% endif %}
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+19
View File
@@ -0,0 +1,19 @@
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [
path("login/", views.CustomLoginView.as_view(), name="login"),
path("logout/", views.CustomLogoutView.as_view(), name="logout"),
path("", views.overview, name="overview"),
path("add/",views.user_add,name="user_add"),
path("<int:user_id>/edit/",views.user_edit,name="user_edit"),
path("<int:user_id>/archive/",views.user_archive,name="user_archive"),
path("<int:user_id>/reactivate/",views.user_reactivate,name="user_reactivate"),
path("role/add/",views.role_add,name="role_add"),
path("role/<int:role_id>/edit/",views.role_edit,name="role_edit"),
path("role/<int:role_id>/delete/",views.role_delete,name="role_delete"),
]
+398
View File
@@ -0,0 +1,398 @@
from django.contrib.auth.views import LoginView, LogoutView
from django.contrib.auth.decorators import login_required
from accounts.decorators import permission_required
from django.contrib.auth.models import User
from django.shortcuts import render, redirect, get_object_or_404
from .forms import UserAddForm, RoleForm
from .models import Role, UserProfile
from .tables import UserTable, ArchivedUserTable, RoleTable
from django_tables2 import RequestConfig
class CustomLoginView(LoginView):
template_name = "accounts/login.html"
redirect_authenticated_user = True
class CustomLogoutView(LogoutView):
pass
@login_required
@permission_required("users_view")
def overview(request):
users = (
User.objects
.filter(is_active=True)
.select_related(
"profile",
"profile__role"
)
.order_by("username")
)
archived_users = (
User.objects
.filter(is_active=False)
.select_related(
"profile",
"profile__role"
)
.order_by("username")
)
roles = list(
Role.objects.order_by("name")
)
# ==================================================
# ROLE PERMISSIONS FÜR CHECKBOXEN
# ==================================================
permission_fields = []
for field in Role._meta.fields:
if field.get_internal_type() == "BooleanField":
permission_fields.append({
"name": field.name,
"verbose_name": field.verbose_name or field.name
})
for role in roles:
role.permissions = []
for permission in permission_fields:
role.permissions.append({
"name": permission["name"],
"verbose_name": permission["verbose_name"],
"value": getattr(
role,
permission["name"]
)
})
# ==================================================
# USER ROLLEN-PERMISSIONS
# ==================================================
for user in list(users) + list(archived_users):
role = getattr(
getattr(user, "profile", None),
"role",
None
)
user.role_permissions = []
if role:
for permission in permission_fields:
if getattr(
role,
permission["name"]
):
user.role_permissions.append(
permission["verbose_name"]
)
# ==================================================
# TABLES
# ==================================================
table = UserTable(users)
table.roles = roles
RequestConfig(
request,
paginate=False
).configure(table)
archived_table = ArchivedUserTable(archived_users)
archived_table.roles = roles
RequestConfig(
request,
paginate=False
).configure(archived_table)
role_table = RoleTable(roles)
role_table.roles = roles
RequestConfig(
request,
paginate=False
).configure(role_table)
return render(
request,
"accounts/overview.html",
{
"table": table,
"archived_table": archived_table,
"role_table": role_table,
}
)
@login_required
@permission_required("users_edit")
def user_edit(request, user_id):
user = get_object_or_404(
User.objects.select_related(
"profile"
),
id=user_id
)
if user.is_superuser:
return redirect("accounts:overview")
if request.method != "POST":
return redirect("accounts:overview")
# -------------------------
# USER
# -------------------------
user.username = request.POST.get(
"username",
user.username
)
user.first_name = request.POST.get(
"first_name",
user.first_name
)
user.last_name = request.POST.get(
"last_name",
user.last_name
)
user.email = request.POST.get(
"email",
user.email
)
user.save()
# -------------------------
# PROFILE / ROLE
# -------------------------
profile, _ = UserProfile.objects.get_or_create(
user=user
)
role_id = request.POST.get("role")
if role_id:
profile.role_id = role_id
else:
profile.role = None
profile.save()
return redirect(
"accounts:overview"
)
@login_required
@permission_required("users_archive")
def user_archive(request, user_id):
user = get_object_or_404(
User,
id=user_id
)
if user.is_superuser:
return redirect("accounts:overview")
if request.method == "POST":
user.is_active = False
user.save()
return redirect("accounts:overview")
@login_required
@permission_required("users_activate")
def user_reactivate(request, user_id):
user = get_object_or_404(
User,
id=user_id
)
if user.is_superuser:
return redirect("accounts:overview")
if request.method == "POST":
user.is_active = True
user.save()
return redirect("accounts:overview")
@login_required
@permission_required("users_add")
def user_add(request):
form = UserAddForm(
request.POST or None
)
if form.is_valid():
user = form.save(commit=False)
password = form.cleaned_data["password"]
user.set_password(password)
user.save()
UserProfile.objects.create(
user=user,
role=form.cleaned_data["role"]
)
return redirect(
"accounts:overview"
)
return render(
request,
"accounts/add.html",
{
"form": form
}
)
def get_role_permissions():
fields = []
for field in Role._meta.fields:
if field.get_internal_type() == "BooleanField":
fields.append({
"name": field.name,
"label": field.verbose_name or field.name
})
return fields
@login_required
@permission_required("users_groups_edit")
def role_edit(request, role_id):
role = get_object_or_404(
Role,
id=role_id
)
if request.method == "POST":
for field in Role._meta.fields:
if (
field.get_internal_type()
== "BooleanField"
):
setattr(
role,
field.name,
field.name in request.POST
)
role.name = request.POST.get(
"name",
role.name
)
role.save()
return redirect(
"accounts:overview"
)
@login_required
@permission_required("users_groups_delete")
def role_delete(request, role_id):
role = get_object_or_404(
Role,
id=role_id
)
if request.method == "POST":
role.delete()
return redirect(
"accounts:overview"
)
@login_required
@permission_required("users_groups_add")
def role_add(request):
if request.method == "POST":
form = RoleForm(
request.POST
)
if form.is_valid():
form.save()
return redirect(
"accounts:overview"
)
else:
form = RoleForm()
return render(
request,
"accounts/role_add.html",
{
"form": form
}
)
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
import re
from pathlib import Path
ROOT = Path("/opt/projects")
EXCLUDE_DIRS = {
"venv",
"__pycache__",
".git",
"node_modules",
"staticfiles",
"media",
}
CLASS_RE = re.compile(r'class="([^"]*)"')
for path in ROOT.rglob("*"):
if not path.is_file():
continue
if any(part in EXCLUDE_DIRS for part in path.parts):
continue
if path.suffix not in {".html", ".htm"}:
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
changed = False
def replace(match):
classes = match.group(1).split()
has_btn = "btn" in classes
has_btn_variant = any(
c.startswith("btn-")
for c in classes
)
if has_btn_variant and not has_btn:
classes.insert(0, "btn")
return f'class="{" ".join(classes)}"'
return match.group(0)
new_text = CLASS_RE.sub(replace, text)
if new_text != text:
path.write_text(
new_text,
encoding="utf-8"
)
print(f"✔ geändert: {path}")
View File
+16
View File
@@ -0,0 +1,16 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_asgi_application()
+132
View File
@@ -0,0 +1,132 @@
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-a*^y8tl19j6+i9029ye&n1nhyaze(sd&!drt1++kfq_lok)5o@'
DEBUG = True
ALLOWED_HOSTS = ["91.99.235.6", "localhost"]
# Application definition
INSTALLED_APPS = [
'accounts',
'core',
'projects',
'finance',
'inventory',
'documents',
'invoices',
'masterdata.apps.MasterdataConfig',
'time_tracking',
'tailwind',
'theme',
"django_tables2",
"django_filters",
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
"core.context_processors.company_settings",
"core.context_processors.project_switcher",
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/'
# TAILWIND
TAILWIND_APP_NAME = 'theme'
# ACCOUNTS
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "core:dashboard"
LOGOUT_REDIRECT_URL = "accounts:login"
#AUTH_USER_MODEL = "accounts.User"
# MEDIEN
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR / "media"
if DEBUG:
INSTALLED_APPS += ["django_browser_reload"]
MIDDLEWARE += [
"django_browser_reload.middleware.BrowserReloadMiddleware",
]
+22
View File
@@ -0,0 +1,22 @@
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("", include("core.urls")),
path("admin/", admin.site.urls),
path("accounts/", include("accounts.urls")),
path("projects/", include("projects.urls")),
path("projects/", include("finance.urls")),
path("projects/", include("documents.urls")),
path("projects/", include("time_tracking.urls")),
path("projects/", include("inventory.urls")),
path("inventory/", include("inventory.urls_global")),
path("invoices/", include("invoices.urls")),
path("settings/", include("masterdata.urls")),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,document_root=settings.MEDIA_ROOT)
urlpatterns += [path("__reload__/", include("django_browser_reload.urls")),]
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()
View File
+12
View File
@@ -0,0 +1,12 @@
from django.contrib import admin
from .models import CompanySetting
@admin.register(CompanySetting)
class CompanySettingAdmin(admin.ModelAdmin):
list_display = (
"name",
"city",
"updated_at",
)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'
+21
View File
@@ -0,0 +1,21 @@
from projects.models import ProjectMembership
from .models import CompanySetting
def project_switcher(request):
if not request.user.is_authenticated:
return {"user_projects": []}
memberships = ProjectMembership.objects.select_related("project").filter(
user=request.user,
project__active=True
)
return {
"user_projects": [m.project for m in memberships]
}
def company_settings(request):
return {
"company_settings": CompanySetting.objects.first()
}
+16
View File
@@ -0,0 +1,16 @@
from django import forms
class BaseForm(forms.ModelForm):
default_class = (
"w-full px-4 py-3 rounded-lg border border-slate-300 dark:border-slate-600 "
"bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-white "
"placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 "
"focus:border-transparent transition"
)
def apply_style(self):
for field_name, field in self.fields.items():
existing = field.widget.attrs.get("class", "")
field.widget.attrs["class"] = f"{self.default_class} {existing}".strip()
+46
View File
@@ -0,0 +1,46 @@
# Generated by Django 6.0.6 on 2026-06-23 22:12
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='CompanySetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('companyname', models.CharField(max_length=255, verbose_name='Firmenname')),
('name', models.CharField(max_length=255, verbose_name='Geschäftsführer')),
('logo', models.ImageField(blank=True, null=True, upload_to='company/', verbose_name='Logo')),
('slogan', models.CharField(blank=True, max_length=255, verbose_name='Slogan')),
('color', models.CharField(default='#0f172a', max_length=20, verbose_name='Farbe')),
('street', models.CharField(max_length=255, verbose_name='Straße')),
('zip', models.CharField(max_length=20, verbose_name='PLZ')),
('city', models.CharField(max_length=255, verbose_name='Ort')),
('country', models.CharField(default='Deutschland', max_length=100, verbose_name='Land')),
('phone', models.CharField(blank=True, max_length=100, verbose_name='Telefon')),
('email', models.EmailField(blank=True, max_length=100, verbose_name='E-Mail')),
('website', models.URLField(blank=True, max_length=100, verbose_name='Webseite')),
('tax_number', models.CharField(blank=True, max_length=100, verbose_name='Steuernummer')),
('vat_number', models.CharField(blank=True, max_length=100, verbose_name='USt-IdNr.')),
('is_small_business', models.BooleanField(default=False, verbose_name='Kleinunternehmer (§19 UStG)')),
('small_business_text', models.TextField(blank=True, verbose_name='Kleinunternehmer Hinweis')),
('invoice_header_text', models.TextField(blank=True, verbose_name='Rechnungszusatztext')),
('bank_name', models.CharField(blank=True, max_length=255, verbose_name='Bank')),
('iban', models.CharField(blank=True, max_length=50, verbose_name='IBAN')),
('bic', models.CharField(blank=True, max_length=50, verbose_name='BIC')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Firmeneinstellung',
'verbose_name_plural': 'Firmeneinstellungen',
},
),
]
@@ -0,0 +1,32 @@
# Generated by Django 6.0.6 on 2026-06-24 09:51
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='companysetting',
name='invoice_number',
field=models.CharField(blank=True, max_length=50, verbose_name='BIC'),
),
migrations.CreateModel(
name='DocumentNumberSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invoice_prefix', models.CharField(default='RE', max_length=20)),
('invoice_next_number', models.PositiveIntegerField(default=1)),
('cancel_prefix', models.CharField(default='ST', max_length=20)),
('cancel_next_number', models.PositiveIntegerField(default=1)),
('offer_prefix', models.CharField(default='ANG', max_length=20)),
('offer_next_number', models.PositiveIntegerField(default=1)),
('company', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='number_settings', to='core.companysetting')),
],
),
]
@@ -0,0 +1,16 @@
# Generated by Django 6.0.6 on 2026-06-24 09:53
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0002_companysetting_invoice_number_documentnumbersettings'),
]
operations = [
migrations.DeleteModel(
name='DocumentNumberSettings',
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-06-25 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_delete_documentnumbersettings'),
]
operations = [
migrations.AddField(
model_name='companysetting',
name='offer_header_text',
field=models.TextField(blank=True, verbose_name='Angebotszusatztext'),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 6.0.6 on 2026-06-26 06:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0004_companysetting_offer_header_text'),
]
operations = [
migrations.RemoveField(
model_name='companysetting',
name='invoice_number',
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.6 on 2026-06-27 08:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_remove_companysetting_invoice_number'),
]
operations = [
migrations.AddField(
model_name='companysetting',
name='reminder_header_text',
field=models.TextField(blank=True, verbose_name='Mahnungszusatztext'),
),
]
View File
+41
View File
@@ -0,0 +1,41 @@
from django.db import models
class CompanySetting(models.Model):
companyname = models.CharField(max_length=255,verbose_name="Firmenname")
name = models.CharField(max_length=255,verbose_name="Geschäftsführer")
logo = models.ImageField(upload_to="company/",blank=True,null=True,verbose_name="Logo")
slogan = models.CharField(max_length=255,blank=True,verbose_name="Slogan")
color = models.CharField(max_length=20,default="#0f172a",verbose_name="Farbe")
street = models.CharField(max_length=255,verbose_name="Straße")
zip = models.CharField(max_length=20,verbose_name="PLZ")
city = models.CharField(max_length=255,verbose_name="Ort")
country = models.CharField(max_length=100,default="Deutschland",verbose_name="Land")
phone = models.CharField(max_length=100,blank=True,verbose_name="Telefon" )
email = models.EmailField(max_length=100,blank=True,verbose_name="E-Mail")
website = models.URLField(max_length=100,blank=True,verbose_name="Webseite")
tax_number = models.CharField(max_length=100,blank=True,verbose_name="Steuernummer")
vat_number = models.CharField(max_length=100,blank=True,verbose_name="USt-IdNr.")
is_small_business = models.BooleanField(default=False,verbose_name="Kleinunternehmer (§19 UStG)")
small_business_text = models.TextField(blank=True,verbose_name="Kleinunternehmer Hinweis")
invoice_header_text = models.TextField(blank=True,verbose_name="Rechnungszusatztext")
offer_header_text = models.TextField(blank=True,verbose_name="Angebotszusatztext")
reminder_header_text = models.TextField(blank=True,verbose_name="Mahnungszusatztext")
bank_name = models.CharField(max_length=255,blank=True,verbose_name="Bank")
iban = models.CharField(max_length=50,blank=True,verbose_name="IBAN")
bic = models.CharField(max_length=50,blank=True,verbose_name="BIC")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Firmeneinstellung"
verbose_name_plural = "Firmeneinstellungen"
def __str__(self):
return self.name
+10
View File
@@ -0,0 +1,10 @@
from masterdata.registry import register_setting
from .models import CompanySetting
register_setting(
group="System",
name="Firmeneinstellungen",
model=CompanySetting,
setting_type="single",
)
+342
View File
@@ -0,0 +1,342 @@
{% load static tailwind_tags %}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>
{% block title %}
Projektverwaltung
{% endblock %}
</title>
<script>
if (localStorage.getItem("theme") === "dark") {
document.documentElement.classList.add("dark");
}
</script>
{% tailwind_css %}
<style>
:root {
--company-color: {{ company_settings.color }};
}
</style>
</head>
<body class="min-h-screen bg-slate-100 dark:bg-slate-900 text-slate-900 dark:text-white transition-colors">
<div class="flex min-h-screen">
{% if user.is_authenticated %}
<div id="overlay"
class="fixed inset-0 bg-black/40 hidden z-30 lg:hidden"
onclick="toggleSidebar()"></div>
<!-- Sidebar -->
<aside id="sidebar" class="fixed top-0 z-40 w-72 h-screen bg-slate-800 text-white transform -translate-x-full lg:translate-x-0 transition-transform duration-300 flex flex-col border-r border-white/20">
<div class="p-6 border-b border-white/20 " style="background-color: {{ company_settings.color }};">
<h2 class="text-xl font-bold">
{{ company_settings.companyname|default:"Projektverwaltung" }}
</h2>
</div>
<nav class="flex-1 p-4 space-y-2">
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<a href="{% url 'core:dashboard' %}">
Dashboard
</a>
</div>
<div class="space-y-2">
<!-- Projekt wechseln -->
<div class="rounded-lg overflow-hidden">
<button
onclick="toggleProjects()"
class="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<span>{{ current_project.name|default:"Projekt wählen" }}</span>
<svg id="projectArrow"
class="w-4 h-4 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="projectMenu" class="hidden border-t border-slate-700">
{% for p in user_projects %}
<a href="{% url 'projects:overview' p.id %}"
class="block px-8 py-3 hover:bg-slate-700">
{{ p.name }}
</a>
{% endfor %}
</div>
</div>
<!-- Rechnungswesen -->
<div class="rounded-lg overflow-hidden">
<button
type="button"
onclick="toggleInvoicesMenu()"
class="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-700 transition">
<span>
Rechnungswesen
</span>
<svg id="invoiceArrow"
class="w-4 h-4 transition-transform duration-200"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="invoiceMenu" class="hidden border-t border-slate-700">
<a href="{% url 'invoices:overview' %}"
class="block px-8 py-3 hover:bg-slate-700 transition">
Übersicht
</a>
<a href="{% url 'invoices:invoices_offers_overview' %}"
class="block px-8 py-3 hover:bg-slate-700 transition">
Angebote
</a>
<a href="{% url 'invoices:invoices_invoices_overview' %}"
class="block px-8 py-3 hover:bg-slate-700 transition">
Rechnungen
</a>
<a href="{% url 'invoices:invoices_reminders_overview' %}"
class="block px-8 py-3 hover:bg-slate-700 transition">
Mahnungen
</a>
<a href="{% url 'invoices:customers_overview' %}"
class="block px-8 py-3 hover:bg-slate-700 transition">
Kunden
</a>
<a href="{% url 'invoices:items_overview' %}"
class="block px-8 py-3 hover:bg-slate-700 transition">
Posten
</a>
</div>
</div>
</div>
<!--
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<a href="{% url 'core:dashboard' %}">
Finanzübersicht
</a>
</div>
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<a href="{% url 'core:dashboard' %}">
Arbeitszeiten
</a>
</div>
-->
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<a href="{% url 'inventory_global:overview' %}">
Inventar
</a>
</div>
<!--
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<a href="{% url 'core:dashboard' %}">
Investitionen
</a>
</div>
-->
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<a href="{% url 'accounts:overview' %}">
Mitarbeiter
</a>
</div>
<div class="w-full rounded-lg flex items-center justify-between px-4 py-3 hover:bg-slate-700">
<a href="{% url 'masterdata:overview' %}">
Einstellungen
</a>
</div>
</nav>
<div class="p-4 border-t border-white/20">
{% if user.is_superuser %}
<div class="w-full rounded-xl flex items-center justify-between px-4 py-3 hover:bg-slate-700 mb-4 border-white/20 border">
<a href="{% url 'admin:index' %}">Admin</a>
</div>
{% endif %}
<div class="flex gap-2 w-full">
<button id="theme-toggle" class="rounded-xl px-4 py-3 hover:bg-slate-700 border-white/20 border">🌙</button>
<form method="post" action="{% url 'accounts:logout' %}" class="w-full">
{% csrf_token %}
<button class="w-full rounded-xl px-4 py-3 border-white/20 border" style="background-color: {{ company_settings.color }};">Abmelden</button>
</form>
</div>
</div>
</aside>
{% endif %}
<!-- Hauptbereich -->
{% if user.is_authenticated %}
<div class="flex-1 flex flex-col lg:ml-72">
{% else %}
<div class="flex-1 flex flex-col">
{% endif %}
<header class="lg:hidden bg-white shadow h-16 flex items-center px-4 dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<button onclick="toggleSidebar()" class="text-3xl">☰</button>
<span class="ml-4 font-bold">{{ company_settings.companyname|default:"Projektverwaltung" }}</span>
</header>
<main class="flex-1 p-6 overflow-x-auto w-screen lg:w-auto">
<div class="p-6 rounded-xl shadow mb-8 bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<h1 class="text-3xl font-bold">
{% block header_title %}
{% endblock %}
</h1>
<p class="text-slate-400 mt-1">
{% block header_description %}
{% endblock %}
</p>
</div>
{% block content %}
{% endblock %}
</main>
</div>
</div>
{% if messages %}
<div class="fixed top-5 right-5 z-50 space-y-3">
{% for message in messages %}
<div
class="rounded-xl shadow-lg px-5 py-4 flex items-center gap-4
{% if message.tags == 'success' %}
bg-green-100 border border-green-300 text-green-800
{% elif message.tags == 'error' %}
bg-red-100 border border-red-300 text-red-800
{% elif message.tags == 'warning' %}
bg-yellow-100 border border-yellow-300 text-yellow-800
{% else %}
bg-blue-100 border border-blue-300 text-blue-800
{% endif %}
"
>
<div>
{{ message }}
</div>
</div>
{% endfor %}
</div>
<script>
setTimeout(() => {
document
.querySelectorAll(".fixed.top-5.right-5 > div")
.forEach(el => {
el.style.opacity = "0";
setTimeout(() => {
el.remove();
}, 500);
});
}, 3000);
</script>
{% endif %}
<script>
function toggleProjects() {
const menu = document.getElementById("projectMenu");
const arrow = document.getElementById("projectArrow");
menu.classList.toggle("hidden");
arrow.classList.toggle("rotate-180");
}
function toggleInvoicesMenu() {
const menu = document.getElementById("invoiceMenu");
const arrow = document.getElementById("invoiceArrow");
menu.classList.toggle("hidden");
arrow.classList.toggle("rotate-180");
}
function toggleSidebar() {
const sidebar = document.getElementById("sidebar");
const overlay = document.getElementById("overlay");
sidebar.classList.toggle("-translate-x-full");
overlay.classList.toggle("hidden");
}
</script>
<script>
document.addEventListener("DOMContentLoaded", () => {
const btn = document.getElementById("theme-toggle");
const html = document.documentElement;
// Gespeichertes Theme anwenden
const saved = localStorage.getItem("theme");
if (saved === "dark") {
html.classList.add("dark");
} else {
html.classList.remove("dark");
}
// Toggle
btn.addEventListener("click", () => {
html.classList.toggle("dark");
localStorage.setItem("theme", html.classList.contains("dark") ? "dark" : "light");
btn.textContent = html.classList.contains("dark") ? "☀️" : "🌙";
});
// Icon initial setzen
btn.textContent = html.classList.contains("dark") ? "☀️" : "🌙";
});
</script>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block header_title %}
Dashboard
{% endblock %}
{% block header_description %}
Willkommen {{ user.username }}
{% endblock %}
{% block content %}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4 mb-8">
<a href="{% url 'invoices:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Rechnungswesen</h2>
</div>
</a>
<!--
<a href="" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Finanzübersicht</h2>
</div>
</a>
<a href="" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Arbeitszeiten</h2>
</div>
</a> -->
<a href="{% url 'inventory_global:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Inventar</h2>
</div>
</a>
<!--
<a href="" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Investitionen</h2>
</div>
</a>
-->
<a href="{% url 'accounts:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Mitarbeiter</h2>
</div>
</a>
<a href="{% url 'masterdata:overview' %}" class="border border-white/20 p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex items-center gap-3 justify-center">
<h2 class="text-xl font-bold">Einstellungen</h2>
</div>
</a>
</div>
<div class="mb-8">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<a href="{% url 'projects:add' %}" class="btn btn-add">+ Projekt</a>
</div>
</div>
</div>
</div>
{% if memberships %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{% for m in memberships %}
<a href="{% url 'projects:overview' m.project.id %}"
class="p-6 rounded-xl shadow hover:shadow-lg transition block text-white"
style="background: linear-gradient(135deg, {{ m.project.color }}, {{ m.project.color }}ff);">
<h2 class="text-xl font-bold">
{{ m.project.name }}
</h2>
<p class="text-sm mt-2">
Rolle: {{ m.get_role_display }}
</p>
</a>
{% endfor %}
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 border border-orange-500 rounded-xl p-6 text-orange-500">
Du bist noch keinem aktiven Projekt zugewiesen.
</div>
{% endif %}
{% if archived_count %}
<a href="{% url 'projects:archived' %}" class="text-sm text-slate-400 hover:text-slate-500">
{{ archived_count }} archivierte
{% if archived_count == 1 %}
Projekt anzeigen
{% else %}
Projekte anzeigen
{% endif %}
</a>
{% endif %}
{% endblock %}
+47
View File
@@ -0,0 +1,47 @@
{% load django_tables2 %}
<table {% render_attrs table.attrs %}>
<thead class="bg-slate-100 dark:bg-slate-700 text-left">
<tr>
{% for column in table.columns %}
<th class="p-3">
{{ column.header }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in table.paginated_rows %}
<tr class="border-t">
{% for column, cell in row.items %}
<td class="p-3">
{{ cell }}
</td>
{% endfor %}
</tr>
{% empty %}
<tr class="border-t">
<td colspan="{{ table.columns|length }}"
class="p-6 text-center text-slate-400">
Keine Daten vorhanden
</td>
</tr>
{% endfor %}
</tbody>
</table>
@@ -0,0 +1,95 @@
{% load django_tables2 %}
<div class="overflow-x-auto rounded-xl">
<table {% render_attrs table.attrs %}>
<thead class="bg-slate-100 dark:bg-slate-700 text-left">
<tr>
<th class="p-3"></th>
{% for column in table.columns %}
<th class="p-3">
{{ column.header }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in table.paginated_rows %}
<tr class="border-t">
<td class="p-3">
<button
type="button"
onclick="toggleItem('{{ table.toggle_prefix }}-{{ row.record.id }}')"
class="font-bold">
▼
</button>
</td>
{% for column, cell in row.items %}
<td class="p-3">
{{ cell }}
</td>
{% endfor %}
</tr>
{% if table.detail_template %}
<tr id="{{ table.toggle_prefix }}-{{ row.record.id }}"
class="hidden border-t">
<td colspan="{{ table.columns|length|add:'1' }}"
class="p-6">
{% include table.detail_template with item=row.record categories=table.categories locations=table.locations users=table.users %}
</td>
</tr>
{% endif %}
{% empty %}
<tr>
<td colspan="{{ table.columns|length|add:1 }}"
class="p-6 text-center text-slate-400">
Keine Daten vorhanden
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
function toggleItem(id) {
const element = document.getElementById(id);
if (!element) {
console.log("Nicht gefunden:", id);
return;
}
element.classList.toggle("hidden");
}
</script>
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from .views import dashboard
app_name = "core"
urlpatterns = [
path("", dashboard, name="dashboard"),
]
+9
View File
@@ -0,0 +1,9 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from projects.models import ProjectMembership
@login_required
def dashboard(request):
memberships = ProjectMembership.objects.select_related("project").filter(user=request.user,project__active=True)
archived_count = ProjectMembership.objects.filter(user=request.user,project__active=False).count()
return render(request,"core/dashboard.html",{"memberships": memberships,"archived_count": archived_count,})
View File
+46
View File
@@ -0,0 +1,46 @@
from django.contrib import admin
# Register your models here.
from django.contrib import admin
from .models import DocumentCategory, Document
@admin.register(DocumentCategory)
class DocumentCategoryAdmin(admin.ModelAdmin):
list_display = ("name",)
search_fields = ("name",)
@admin.register(Document)
class DocumentAdmin(admin.ModelAdmin):
list_display = (
"title",
"project",
"category",
"uploaded_by",
"created_at",
)
list_filter = (
"project",
"category",
"created_at",
)
search_fields = (
"title",
)
readonly_fields = (
"created_at",
)
autocomplete_fields = (
"project",
"category",
"uploaded_by",
)
ordering = (
"-created_at",
)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class DocumentsConfig(AppConfig):
name = 'documents'
+11
View File
@@ -0,0 +1,11 @@
from .models import Document
from core.forms.base import BaseForm
class DocumentForm(BaseForm):
class Meta:
model = Document
fields = ["title", "category", "file"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.apply_style()
+36
View File
@@ -0,0 +1,36 @@
# Generated by Django 6.0.6 on 2026-06-23 22:12
import documents.models
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Document',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='Name')),
('file', models.FileField(upload_to=documents.models.document_upload_path, verbose_name='Datei')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='DocumentCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120, unique=True)),
],
options={
'verbose_name': 'Dokumentenkategorie',
'verbose_name_plural': 'Dokumentenkategorien',
'ordering': ['name'],
},
),
]
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 6.0.6 on 2026-06-23 22:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('documents', '0001_initial'),
('projects', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='document',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='projects.project'),
),
migrations.AddField(
model_name='document',
name='uploaded_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='document',
name='category',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='documents.documentcategory', verbose_name='Kategorie'),
),
]
View File
+32
View File
@@ -0,0 +1,32 @@
from django.db import models
from projects.models import Project
import os
def document_upload_path(instance, filename):
return f"projects/{instance.project.id}/documents/{filename}"
class DocumentCategory(models.Model):
name = models.CharField(max_length=120, unique=True)
class Meta:
verbose_name = "Dokumentenkategorie"
verbose_name_plural = "Dokumentenkategorien"
ordering = ["name"]
def __str__(self):
return self.name
class Document(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="documents")
category = models.ForeignKey(DocumentCategory, on_delete=models.CASCADE, verbose_name="Kategorie")
title = models.CharField(max_length=255, verbose_name="Name")
file = models.FileField(upload_to=document_upload_path, verbose_name="Datei")
uploaded_by = models.ForeignKey("auth.User", on_delete=models.SET_NULL, null=True)
created_at = models.DateTimeField(auto_now_add=True)
@property
def extension(self):
return os.path.splitext(self.file.name)[1].lower()
def __str__(self):
return self.title
+12
View File
@@ -0,0 +1,12 @@
from masterdata.registry import register_setting
from .models import (
DocumentCategory,
)
register_setting(
group="Dokumente",
name="Kategorien",
model=DocumentCategory,
)
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}
{{ project.name }} - Dokumente
{% endblock %}
{% block header_title %}
Dokumente hochladen
{% endblock %}
{% block header_description %}
Dokumente und Belege hochladen
{% endblock %}
{% block content %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="grid grid-cols-1 gap-4">
{{ form.as_p }}
</div>
<button
type="submit"
class="btn mt-4 btn-save">
Speichern
</button>
</form>
</div>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}
{{ project.name }} - Dokumente
{% endblock %}
{% block header_title %}
Dokumente
{% endblock %}
{% block header_description %}
Dateien & Bilder
{% endblock %}
{% block content %}
<div class="mb-8">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Dokumente hochladen</p>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<a href="{% url 'documents:add' project.id %}" class="btn btn-add">+ Dokument</a>
</div>
</div>
</div>
</div>
<!-- Kategorien -->
{% for cat, docs in grouped.items %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors mb-2" >
<div class="flex items-center justify-between mb-2">
<h2 class="text-xl font-bold">
{{ cat.name }}
</h2>
<span class="text-sm text-slate-400">
{{ docs|length }} Dokumente
</span>
</div>
{% if docs %}
<div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-4 gap-6">
{% for d in docs %}
<div class="bg-slate-100 dark:bg-slate-700 rounded-xl shadow overflow-hidden transition">
<!-- Vorschau klickbar -->
<a href="{{ d.file.url }}" target="_blank">
{% if d.extension == ".jpg" or d.extension == ".jpeg" or d.extension == ".png" or d.extension == ".webp" %}
<div class="h-64 bg-slate-100 flex items-center justify-center overflow-hidden">
<img src="{{ d.file.url }}" class="w-full h-full object-cover">
</div>
{% else %}
<div class="h-64 flex flex-col items-center justify-center bg-slate-100">
<div class="text-5xl mb-2">📄</div>
<div class="text-slate-400 text-sm">Keine Vorschau verfügbar</div>
</div>
{% endif %}
</a>
<!-- Infos -->
<div class="p-4">
<h3 class="font-semibold truncate">{{ d.title }}</h3>
<div class="mt-2 text-sm text-slate-400">Hochgeladen von {{ d.uploaded_by }}</div>
<div class="text-sm text-slate-400">{{ d.created_at|date:"d.m.Y H:i" }}</div>
<div class="mt-4 flex">
<form method="post" action="{% url 'documents:delete' project.id d.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-delete">Löschen</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-white dark:bg-slate-800 border border-orange-500 rounded-xl p-6 text-orange-500">
Keine Dokumente vorhanden.
</div>
{% endif %}
</div>
{% empty %}
<div class="bg-white dark:bg-slate-800 border border-orange-500 rounded-xl p-6 text-orange-500">
Noch keine Kategorien vorhanden.
</div>
{% endfor %}
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+10
View File
@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "documents"
urlpatterns = [
path("<int:project_id>/documents/", views.document_overview, name="overview"),
path("<int:project_id>/documents/delete/<int:doc_id>/", views.document_delete, name="delete"),
path("<int:project_id>/documents/add/", views.document_add, name="add"),
]
+88
View File
@@ -0,0 +1,88 @@
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib.auth.decorators import login_required
from accounts.decorators import permission_required
from projects.models import ProjectMembership
from .models import Document, DocumentCategory
from .forms import DocumentForm
@login_required
@permission_required("projects_documents_view")
def document_overview(request, project_id):
membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id)
project = membership.project
documents = Document.objects.filter(project=project).order_by("-created_at")
categories = DocumentCategory.objects.all()
form = DocumentForm()
if request.method == "POST":
form = DocumentForm(request.POST, request.FILES)
if form.is_valid():
doc = form.save(commit=False)
doc.project = project
doc.uploaded_by = request.user
doc.save()
return redirect("documents:overview", project.id)
# gruppiert nach Kategorie
grouped = {}
for cat in categories:
docs = list(documents.filter(category=cat))
if docs:
grouped[cat] = docs
uncategorized = documents.filter(category__isnull=True)
return render(request, "documents/overview.html", {
"project": project,
"form": form,
"grouped": grouped,
"uncategorized": uncategorized,
})
@login_required
@permission_required("projects_documents_delete")
def document_delete(request, project_id, doc_id):
membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id)
doc = get_object_or_404(Document, id=doc_id, project=membership.project)
doc.file.delete()
doc.delete()
return redirect("documents:overview", project_id)
@login_required
@permission_required("projects_documents_add")
def document_add(request, project_id):
membership = get_object_or_404(ProjectMembership, user=request.user, project_id=project_id)
project = membership.project
documents = Document.objects.filter(project=project).order_by("-created_at")
categories = DocumentCategory.objects.all()
form = DocumentForm()
if request.method == "POST":
form = DocumentForm(request.POST, request.FILES)
if form.is_valid():
doc = form.save(commit=False)
doc.project = project
doc.uploaded_by = request.user
doc.save()
return redirect("documents:overview", project.id)
else:
form = DocumentForm()
return render(
request,
"documents/add.html",
{
"project": project,
"form": form,
}
)
View File
+23
View File
@@ -0,0 +1,23 @@
from django.contrib import admin
from .models import Category, Supplier, PaymentMethod, Expense, Income
admin.site.register(Category)
admin.site.register(Supplier)
admin.site.register(PaymentMethod)
@admin.register(Expense)
class ExpenseAdmin(admin.ModelAdmin):
list_display = ("amount", "project", "category", "date", "created_by")
list_filter = ("project", "category", "date")
search_fields = (
"name",
"project__name",
)
@admin.register(Income)
class IncomeAdmin(admin.ModelAdmin):
list_display = ("amount", "project", "category", "date", "created_by")
list_filter = ("project", "category", "date")
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class FinanceConfig(AppConfig):
name = 'finance'
+38
View File
@@ -0,0 +1,38 @@
from django import forms
from .models import Income, Expense
from core.forms.base import BaseForm
from django.utils import timezone
class IncomeForm(BaseForm):
class Meta:
model = Income
fields = ["name", "amount", "date", "category", "payment_method", "note"]
widgets = {
"date": forms.DateTimeInput(attrs={"type": "datetime-local"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.instance.pk:
self.fields["date"].initial = timezone.now()
self.apply_style()
class ExpenseForm(BaseForm):
class Meta:
model = Expense
fields = ["name", "amount", "date", "category", "supplier", "payment_method", "receipt", "note"]
widgets = {
"date": forms.DateTimeInput(attrs={"type": "datetime-local"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.instance.pk:
self.fields["date"].initial = timezone.now()
self.apply_style()
+77
View File
@@ -0,0 +1,77 @@
# Generated by Django 6.0.6 on 2026-06-23 22:12
import django.db.models.deletion
import finance.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
],
options={
'verbose_name': 'Finanzkategorie',
'verbose_name_plural': 'Finanzkategorien',
},
),
migrations.CreateModel(
name='Income',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, null=True, verbose_name='Name')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Betrag (€)')),
('date', models.DateTimeField(verbose_name='Datum')),
('note', models.TextField(blank=True, verbose_name='Notiz')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
],
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=50)),
],
options={
'verbose_name': 'Zahlungsmethode',
'verbose_name_plural': 'Zahlungsmethoden',
},
),
migrations.CreateModel(
name='Supplier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=150)),
],
options={
'verbose_name': 'Lieferant',
'verbose_name_plural': 'Lieferanten',
},
),
migrations.CreateModel(
name='Expense',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Betrag (€)')),
('date', models.DateTimeField(verbose_name='Datum')),
('receipt', models.FileField(blank=True, null=True, upload_to=finance.models.receipt_upload_path, verbose_name='Beleg')),
('note', models.TextField(blank=True, verbose_name='Notiz')),
('invoiced', models.BooleanField(default=False, verbose_name='Abgerechnet')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')),
],
),
]
+54
View File
@@ -0,0 +1,54 @@
# Generated by Django 6.0.6 on 2026-06-23 22:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('finance', '0001_initial'),
('projects', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='expense',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='projects.project', verbose_name='Projekt'),
),
migrations.AddField(
model_name='income',
name='category',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie'),
),
migrations.AddField(
model_name='income',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von'),
),
migrations.AddField(
model_name='income',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incomes', to='projects.project', verbose_name='Projekt'),
),
migrations.AddField(
model_name='income',
name='payment_method',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.paymentmethod', verbose_name='Zahlungsmethode'),
),
migrations.AddField(
model_name='expense',
name='payment_method',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.paymentmethod', verbose_name='Zahlungsmethode'),
),
migrations.AddField(
model_name='expense',
name='supplier',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.supplier', verbose_name='Lieferant'),
),
]
View File
+65
View File
@@ -0,0 +1,65 @@
from django.db import models
from django.contrib.auth import get_user_model
from projects.models import Project
import os
User = get_user_model()
def receipt_upload_path(instance, filename):
return f"projects/{instance.project.id}/receipts/{filename}"
class Category(models.Model):
name = models.CharField(max_length=100)
class Meta:
verbose_name = "Finanzkategorie"
verbose_name_plural = "Finanzkategorien"
def __str__(self):
return self.name
class Supplier(models.Model):
name = models.CharField(max_length=150)
class Meta:
verbose_name = "Lieferant"
verbose_name_plural = "Lieferanten"
def __str__(self):
return self.name
class PaymentMethod(models.Model):
name = models.CharField(max_length=50)
class Meta:
verbose_name = "Zahlungsmethode"
verbose_name_plural = "Zahlungsmethoden"
def __str__(self):
return self.name
class Expense(models.Model):
name = models.CharField(max_length=255, verbose_name="Name")
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="expenses", verbose_name="Projekt")
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, verbose_name="Kategorie")
supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Lieferant")
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von")
payment_method = models.ForeignKey(PaymentMethod, on_delete=models.SET_NULL, null=True, verbose_name="Zahlungsmethode")
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Betrag (€)")
date = models.DateTimeField(verbose_name="Datum")
receipt = models.FileField(upload_to=receipt_upload_path, null=True, blank=True, verbose_name="Beleg")
note = models.TextField(blank=True, verbose_name="Notiz")
invoiced = models.BooleanField(default=False,verbose_name="Abgerechnet")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
def __str__(self):
return f"{self.name} - {self.amount} €"
class Income(models.Model):
name = models.CharField(max_length=255, verbose_name="Name", null=True)
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="incomes", verbose_name="Projekt")
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, verbose_name="Kategorie")
amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Betrag (€)")
date = models.DateTimeField(verbose_name="Datum")
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Erstellt von")
payment_method = models.ForeignKey(PaymentMethod, on_delete=models.SET_NULL, null=True, verbose_name="Zahlungsmethode")
note = models.TextField(blank=True, verbose_name="Notiz")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Erstellt am")
def __str__(self):
return f"+{self.amount} € - {self.project.name}"
+26
View File
@@ -0,0 +1,26 @@
from masterdata.registry import register_setting
from .models import (
Category,
Supplier,
PaymentMethod,
)
register_setting(
group="Finanzen",
name="Kategorien",
model=Category,
)
register_setting(
group="Finanzen",
name="Lieferanten",
model=Supplier,
)
register_setting(
group="Finanzen",
name="Zahlungsarten",
model=PaymentMethod,
)
+163
View File
@@ -0,0 +1,163 @@
import django_tables2 as tables
from django.utils.html import format_html
class PeriodAnalyticsTable(tables.Table):
period = tables.Column(verbose_name="Zeitraum")
income = tables.Column(verbose_name="Einnahmen")
expense = tables.Column(verbose_name="Ausgaben")
profit = tables.Column(verbose_name="Gewinn")
actions = tables.TemplateColumn(
template_name="finance/tables/period_actions.html",
verbose_name="Aktionen",
orderable=False,
)
class Meta:
attrs = {
"class": "w-full text-sm min-w-[700px]"
}
template_name = "django_tables2/table.html"
fields = (
"period",
"income",
"expense",
"profit",
"actions",
)
def __init__(self, *args, period_type="day", **kwargs):
self.period_type = period_type
super().__init__(*args, **kwargs)
def render_period(self, value):
if self.period_type == "year":
return value.strftime("%Y")
if self.period_type == "month":
return value.strftime("%m / %Y") # z.B. 06.2026
if self.period_type == "week":
week = value.isocalendar().week
return f"KW {week:02d} / {value.year}"
return value.strftime("%d.%m.%Y")
def render_income(self, value):
return f"{value:.2f} €"
def render_expense(self, value):
return f"{value:.2f} €"
def render_profit(self, value):
return f"{value:.2f} €"
class FinanceTransactionTable(tables.Table):
type = tables.Column(
verbose_name="Typ"
)
name = tables.Column(
verbose_name="Name"
)
amount = tables.Column(
verbose_name="Betrag"
)
date = tables.DateColumn(
verbose_name="Datum",
format="d.m.Y"
)
category = tables.Column(
verbose_name="Kategorie"
)
created_by = tables.Column(
verbose_name="Erstellt von"
)
payment_method = tables.Column(
verbose_name="Zahlungsmethode"
)
supplier = tables.Column(
verbose_name="Lieferant"
)
receipt = tables.TemplateColumn(
template_name="finance/tables/receipt.html",
verbose_name="Beleg",
orderable=False,
)
note = tables.Column(
verbose_name="Notiz"
)
class Meta:
attrs = {
"class": "w-full text-sm min-w-[700px]"
}
template_name = "django_tables2/table.html"
fields = (
"type",
"name",
"amount",
"date",
"category",
"created_by",
"payment_method",
"supplier",
"receipt",
"note",
)
def render_type(self, value):
color = "text-green-600" if value == "Einnahme" else "text-red-600"
return format_html(
'<span class="font-bold {}">{}</span>',
color,
value,
)
def render_amount(self, value):
if value is None:
return "/"
return f"{value:.2f} €"
def render_name(self, value):
return value or "/"
def render_category(self, value):
return value or "/"
def render_created_by(self, value):
return value or "/"
def render_payment_method(self, value):
return value or "/"
def render_supplier(self, value):
return value or "/"
def render_note(self, value):
return value or "/"
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}
{{ project.name }} - Finanzen
{% endblock %}
{% block header_title %}
Finanzen tracken
{% endblock %}
{% block header_description %}
Einnahme oder Ausgabe hinzufügen
{% endblock %}
{% block content %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<form method="post" enctype="multipart/form-data" class="space-y-4">
{% csrf_token %}
{{ form.as_p }}
<button class="btn btn-save">
Speichern
</button>
</form>
</div>
{% endblock %}
+206
View File
@@ -0,0 +1,206 @@
{% extends "base.html" %}
{% block title %}
{{ project.name }} - Finanzen
{% endblock %}
{% block header_title %}
Finanzen
{% endblock %}
{% block header_description %}
Einnahmen & Ausgaben
{% endblock %}
{% block content %}
{% load django_tables2 %}
<div class="mb-8">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Einnahmen und Ausgaben tracken</p>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<a href="{% url 'finance:add_income' project.id %}" class="btn btn-action-start">+ Einnahme</a>
<a href="{% url 'finance:add_expense' project.id %}" class="btn btn-action-stop">+ Ausgabe</a>
</div>
</div>
</div>
</div>
<div class="space-y-2 mb-8">
<!-- ===================== -->
<!-- FILTER CARD -->
<!-- ===================== -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Filter</h2>
<p class="text-slate-400 text-sm mt-1">Zeitraum für aktuelle Auswertung</p>
</div>
<form method="get" class="flex flex-wrap gap-4 items-end">
<div>
<label class="text-sm text-slate-400">Start</label>
<input type="date" name="start"
value="{{ request.GET.start|default_if_none:'' }}"
class="border rounded px-3 py-2">
</div>
<div>
<label class="text-sm text-slate-400">Ende</label>
<input type="date" name="end"
value="{{ request.GET.end|default_if_none:'' }}"
class="border rounded px-3 py-2">
</div>
<button class="btn btn-save">
Anwenden
</button>
</form>
</div>
<!-- ===================== -->
<!-- KPI FLOATING CARDS -->
<!-- ===================== -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<p class="text-slate-400 text-sm mt-1">Einnahmen</p>
<p class="text-3xl font-bold text-green-600 mt-1">
{{ total_income }} €
</p>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<p class="text-slate-400 text-sm mt-1">Ausgaben</p>
<p class="text-3xl font-bold text-red-600 mt-1">
{{ total_expense }} €
</p>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<p class="text-slate-400 text-sm mt-1">Gewinn</p>
<p class="text-3xl font-bold text-blue-600 mt-1">
{{ total_profit }} €
</p>
</div>
</div>
<!-- ===================== -->
<!-- LIST CARDS -->
<!-- ===================== -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<!-- INCOME -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="p-4">
<h3 class="font-semibold text-green-600">Einnahmen</h3>
</div>
<div class="divide-y">
{% for i in incomes %}
<div class="p-4 flex justify-between">
<div>
<p class="font-medium text-green-600">+{{ i.amount }} € {{ i.name }}</p>
<p class="text-slate-400 text-sm mt-1">{{ i.date|date:"d.m.Y" }} · {{ i.category }}</p>
</div>
</div>
{% empty %}
<p class="p-4 text-slate-400">Keine Einnahmen</p>
{% endfor %}
</div>
</div>
<!-- EXPENSE -->
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="p-4">
<h3 class="font-semibold text-red-600">Ausgaben</h3>
</div>
<div class="divide-y">
{% for e in expenses %}
<div class="p-4 flex justify-between">
<div>
<p class="font-medium text-red-600">-{{ e.amount }} € {{ e.name }}</p>
<p class="text-slate-400 text-sm mt-1">{{ e.date|date:"d.m.Y" }} · {{ e.category }}</p>
</div>
</div>
{% empty %}
<p class="p-4 text-slate-400">Keine Ausgaben</p>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">
Zeitraum-Analyse
</h2>
<p class="text-slate-400 text-sm mt-1">
Aggregierte Auswertung nach Zeitraum
</p>
</div>
<div class="flex gap-2 mb-6">
<a href="?period=year"
class="px-3 py-1 rounded border {% if period == 'year' %}bg-slate-900 text-white{% endif %}">
Jahr
</a>
<a href="?period=month"
class="px-3 py-1 rounded border {% if period == 'month' %}bg-slate-900 text-white{% endif %}">
Monat
</a>
<a href="?period=week"
class="px-3 py-1 rounded border {% if period == 'week' %}bg-slate-900 text-white{% endif %}">
Woche
</a>
<a href="?period=day"
class="px-3 py-1 rounded border {% if period == 'day' %}bg-slate-900 text-white{% endif %}">
Tag
</a>
</div>
<div class="overflow-x-auto rounded-xl">
{% render_table table %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,8 @@
<div class="flex gap-2">
<a href="{% url 'finance:view' project.id %}?start={{ record.period_start|date:'Y-m-d' }}&end={{ record.period_end|date:'Y-m-d' }}"
class="btn btn-edit">
Details
</a>
</div>
@@ -0,0 +1,9 @@
{% if record.receipt %}
<a href="{{ record.receipt.url }}"
target="_blank"
class="btn btn-action">
Download
</a>
{% else %}
—
{% endif %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}{{ project.name }} - Finanzen{% endblock %}
{% block header_title %}
Finanzen Details
{% endblock %}
{% block header_description %}
Zeitraum: {{ start_date|date:"d.m.Y" }} → {{ end_date|date:"d.m.Y" }}
{% endblock %}
{% block content %}
{% load django_tables2 %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="overflow-x-auto rounded-xl">{% render_table table %}</div>
</div>
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from .views import finance_overview, finance_view, add_income, add_expense
app_name = "finance"
urlpatterns = [
path("<int:project_id>/finance/", finance_overview, name="overview"),
path("<int:project_id>/finance/view/", finance_view, name="view"),
path("<int:project_id>/finance/add-income/", add_income, name="add_income"),
path("<int:project_id>/finance/add-expense/", add_expense, name="add_expense"),
]
+331
View File
@@ -0,0 +1,331 @@
from django.contrib.auth.decorators import login_required
from accounts.decorators import permission_required
from django.shortcuts import render, get_object_or_404, redirect
from django.db.models.functions import TruncMonth, TruncYear, TruncWeek, TruncDay
from django.db.models import Sum
from datetime import date, datetime, timedelta
from projects.models import ProjectMembership
from .models import Income, Expense
import csv
from django.http import HttpResponse
from calendar import monthrange
from .forms import IncomeForm, ExpenseForm
from .tables import PeriodAnalyticsTable, FinanceTransactionTable
# =========================================================
# OVERVIEW
# =========================================================
@login_required
@permission_required("projects_finance_view")
def finance_overview(request, project_id):
membership = get_object_or_404(
ProjectMembership,
user=request.user,
project_id=project_id
)
project = membership.project
# -------------------------
# DATE FILTER (for KPI ONLY)
# -------------------------
start = request.GET.get("start")
end = request.GET.get("end")
if not start or not end:
start_date = end_date = date.today()
else:
start_date = datetime.strptime(start, "%Y-%m-%d").date()
end_date = datetime.strptime(end, "%Y-%m-%d").date()
# -------------------------
# KPI DATA (FILTERED)
# -------------------------
filtered_incomes = Income.objects.filter(
project=project,
date__date__range=[start_date, end_date]
)
filtered_expenses = Expense.objects.filter(
project=project,
date__date__range=[start_date, end_date]
)
total_income = sum(i.amount for i in filtered_incomes)
total_expense = sum(e.amount for e in filtered_expenses)
total_profit = total_income - total_expense
incomes = Income.objects.filter(
project=project,
date__date__range=[start_date, end_date]
).order_by("-date")
expenses = Expense.objects.filter(
project=project,
date__date__range=[start_date, end_date]
).order_by("-date")
# -------------------------
# PERIOD VIEW
# -------------------------
period = request.GET.get("period", "year")
if period == "year":
trunc = TruncYear("date")
elif period == "week":
trunc = TruncWeek("date")
elif period == "day":
trunc = TruncDay("date")
else:
trunc = TruncMonth("date")
income_stats = (
Income.objects.filter(project=project)
.annotate(period_key=trunc)
.values("period_key")
.annotate(total=Sum("amount"))
.order_by("-period_key")
)
expense_stats = (
Expense.objects.filter(project=project)
.annotate(period_key=trunc)
.values("period_key")
.annotate(total=Sum("amount"))
.order_by("-period_key")
)
expense_map = {
str(e["period_key"]): e["total"]
for e in expense_stats
}
period_data = []
for i in income_stats:
period_start = i["period_key"]
income_total = i["total"] or 0
expense_total = expense_map.get(str(period_start), 0)
if period == "year":
period_end = period_start.replace(month=12, day=31)
elif period == "week":
period_end = period_start + timedelta(days=6)
elif period == "day":
period_end = period_start
else:
last_day = monthrange(period_start.year, period_start.month)[1]
period_end = period_start.replace(day=last_day)
period_data.append({
"period": period_start,
"period_start": period_start,
"period_end": period_end,
"income": income_total,
"expense": expense_total,
"profit": income_total - expense_total,
})
# -------------------------
# RETURN
# -------------------------
table = PeriodAnalyticsTable(
period_data,
period_type=period,
request=request,
)
return render(request, "finance/overview.html", {
"project": project,
# KPI (filtered)
"total_income": total_income,
"total_expense": total_expense,
"total_profit": total_profit,
# LISTS (all data)
"incomes": incomes,
"expenses": expenses,
# PERIOD
"period_data": period_data,
"period": period,
# UI
"start_date": start_date,
"end_date": end_date,
"table": table,
})
@login_required
@permission_required("projects_finance_view")
def finance_view(request, project_id):
membership = get_object_or_404(
ProjectMembership,
user=request.user,
project_id=project_id
)
project = membership.project
start = request.GET.get("start")
end = request.GET.get("end")
incomes = Income.objects.filter(project=project)
expenses = Expense.objects.filter(project=project)
if start:
try:
start_date = datetime.strptime(start, "%Y-%m-%d").date()
incomes = incomes.filter(date__date__gte=start_date)
expenses = expenses.filter(date__date__gte=start_date)
except ValueError:
pass
if end:
try:
end_date = datetime.strptime(end, "%Y-%m-%d").date()
incomes = incomes.filter(date__date__lte=end_date)
expenses = expenses.filter(date__date__lte=end_date)
except ValueError:
pass
# -------------------------
# MERGED LIST (LIKE EXPORT ORDER)
# -------------------------
transactions = []
for i in incomes:
transactions.append({
"type": "Einnahme",
"date": i.date,
"amount": i.amount,
"category": i.category,
"name": i.name,
"created_by": i.created_by,
"note": i.note,
})
for e in expenses:
transactions.append({
"type": "Ausgabe",
"date": e.date,
"amount": e.amount,
"category": e.category,
"name": e.name,
"created_by": e.created_by,
"note": e.note,
"supplier": e.supplier,
"payment_method": e.payment_method,
"receipt": e.receipt,
})
transactions.sort(
key=lambda x: x["date"],
reverse=True,
)
table = FinanceTransactionTable(
transactions,
request=request,
)
return render(
request,
"finance/view.html",
{
"project": project,
"table": table,
"start_date": start_date,
"end_date": end_date,
},
)
# =========================
# INCOME CREATE
# =========================
@login_required
@permission_required("projects_finance_income_add")
def add_income(request, project_id):
membership = get_object_or_404(
ProjectMembership,
user=request.user,
project_id=project_id
)
project = membership.project
if request.method == "POST":
form = IncomeForm(request.POST)
if form.is_valid():
income = form.save(commit=False)
income.project = project
income.created_by = request.user
income.save()
return redirect("finance:overview", project_id=project.id)
else:
form = IncomeForm()
return render(request, "finance/form.html", {
"form": form,
"project": project,
"type": "income",
})
# =========================
# EXPENSE CREATE
# =========================
@login_required
@permission_required("projects_finance_expense_add")
def add_expense(request, project_id):
membership = get_object_or_404(
ProjectMembership,
user=request.user,
project_id=project_id
)
project = membership.project
if request.method == "POST":
form = ExpenseForm(request.POST, request.FILES)
if form.is_valid():
expense = form.save(commit=False)
expense.project = project
expense.created_by = request.user
expense.save()
return redirect("finance:overview", project_id=project.id)
else:
form = ExpenseForm()
return render(request, "finance/form.html", {
"form": form,
"project": project,
"type": "expense",
})
View File
+83
View File
@@ -0,0 +1,83 @@
from django.contrib import admin
from .models import (
InventoryCategory,
InventoryLocation,
InventoryItem,
ProjectInventoryItem,
ShoppingItem,
)
@admin.register(InventoryCategory)
class InventoryCategoryAdmin(admin.ModelAdmin):
list_display = ("name",)
search_fields = ("name",)
@admin.register(InventoryLocation)
class InventoryLocationAdmin(admin.ModelAdmin):
list_display = ("name",)
search_fields = ("name",)
@admin.register(InventoryItem)
class InventoryItemAdmin(admin.ModelAdmin):
list_display = (
"name",
"quantity",
"category",
"location",
"responsible",
"article_number",
)
list_filter = (
"category",
"location",
)
search_fields = (
"name",
"article_number",
"description",
)
@admin.register(ProjectInventoryItem)
class ProjectInventoryItemAdmin(admin.ModelAdmin):
list_display = (
"project",
"item",
"quantity",
"status",
"created_at",
)
list_filter = (
"status",
"project",
)
search_fields = (
"project__name",
"item__name",
)
@admin.register(ShoppingItem)
class ShoppingItemAdmin(admin.ModelAdmin):
list_display = (
"name",
"project",
"quantity",
"price",
"category",
"supplier",
"created_at",
)
list_filter = (
"project",
"category",
"supplier",
)
search_fields = (
"name",
"description",
"project__name",
)
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class InventoryConfig(AppConfig):
name = 'inventory'
+43
View File
@@ -0,0 +1,43 @@
from django import forms
from .models import InventoryItem, ShoppingItem
from core.forms.base import BaseForm
class InventoryItemForm(BaseForm):
class Meta:
model = InventoryItem
fields = [
"name",
"description",
"quantity",
"location",
"article_number",
"responsible",
"category",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.apply_style()
class ShoppingItemForm(BaseForm):
class Meta:
model = ShoppingItem
fields = [
"name",
"description",
"quantity",
"supplier",
"price",
"category",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.apply_style()
+90
View File
@@ -0,0 +1,90 @@
# Generated by Django 6.0.6 on 2026-06-23 22:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='InventoryCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='Name')),
],
options={
'verbose_name': 'Inventarkategorie',
'verbose_name_plural': 'Inventarkategorien',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='InventoryLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True, verbose_name='Name')),
],
options={
'verbose_name': 'Inventarstandort',
'verbose_name_plural': 'Inventarstandorte',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ShoppingItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Anzahl')),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Preis')),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'verbose_name': 'Einkaufslistenartikel',
'verbose_name_plural': 'Einkaufslistenartikel',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='InventoryItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
('quantity', models.PositiveIntegerField(default=0, verbose_name='Anzahl')),
('article_number', models.CharField(blank=True, max_length=255, verbose_name='Artikelnummer')),
('created_at', models.DateTimeField(auto_now_add=True)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.inventorycategory', verbose_name='Kategorie')),
('responsible', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Verantwortlicher')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='inventory.inventorylocation', verbose_name='Standort')),
],
options={
'verbose_name': 'Inventarartikel',
'verbose_name_plural': 'Inventarartikel',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='ProjectInventoryItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Anzahl')),
('status', models.CharField(choices=[('open', 'Offen'), ('packed', 'Eingepackt'), ('on_site', 'Vor Ort')], default='open', max_length=20, verbose_name='Status')),
('created_at', models.DateTimeField(auto_now_add=True)),
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='inventory.inventoryitem', verbose_name='Artikel')),
],
options={
'verbose_name': 'Projektartikel',
'verbose_name_plural': 'Projektartikel',
},
),
]
+42
View File
@@ -0,0 +1,42 @@
# Generated by Django 6.0.6 on 2026-06-23 22:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('finance', '0002_initial'),
('inventory', '0001_initial'),
('projects', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='projectinventoryitem',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='projects.project', verbose_name='Projekt'),
),
migrations.AddField(
model_name='shoppingitem',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.category', verbose_name='Kategorie'),
),
migrations.AddField(
model_name='shoppingitem',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='shopping_items', to='projects.project', verbose_name='Projekt'),
),
migrations.AddField(
model_name='shoppingitem',
name='supplier',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='finance.supplier', verbose_name='Lieferant'),
),
migrations.AlterUniqueTogether(
name='projectinventoryitem',
unique_together={('project', 'item')},
),
]
View File
+87
View File
@@ -0,0 +1,87 @@
from django.db import models
from django.contrib.auth.models import User
from projects.models import Project
from finance.models import Category, Supplier
from django.db.models import Sum
class InventoryCategory(models.Model):
name = models.CharField(max_length=255, unique=True, verbose_name="Name")
class Meta:
verbose_name = "Inventarkategorie"
verbose_name_plural = "Inventarkategorien"
ordering = ["name"]
def __str__(self):
return self.name
class InventoryLocation(models.Model):
name = models.CharField(max_length=255, unique=True, verbose_name="Name")
class Meta:
verbose_name = "Inventarstandort"
verbose_name_plural = "Inventarstandorte"
ordering = ["name"]
def __str__(self):
return self.name
class InventoryItem(models.Model):
name = models.CharField(max_length=255, verbose_name="Name")
description = models.TextField(blank=True, verbose_name="Beschreibung")
quantity = models.PositiveIntegerField(default=0, verbose_name="Anzahl")
location = models.ForeignKey(InventoryLocation, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Standort")
article_number = models.CharField(max_length=255, blank=True, verbose_name="Artikelnummer")
responsible = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Verantwortlicher")
category = models.ForeignKey(InventoryCategory, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Inventarartikel"
verbose_name_plural = "Inventarartikel"
ordering = ["name"]
def __str__(self):
return self.name
class ProjectInventoryItem(models.Model):
class Status(models.TextChoices):
OPEN = "open", "Offen"
PACKED = "packed", "Eingepackt"
ON_SITE = "on_site", "Vor Ort"
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="inventory_items", verbose_name="Projekt")
item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE, verbose_name="Artikel")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl")
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN, verbose_name="Status")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Projektartikel"
verbose_name_plural = "Projektartikel"
unique_together = ("project", "item")
def __str__(self):
return f"{self.project} - {self.item}"
class ShoppingItem(models.Model):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="shopping_items", verbose_name="Projekt")
name = models.CharField(max_length=255, verbose_name="Name")
description = models.TextField(blank=True, verbose_name="Beschreibung")
quantity = models.PositiveIntegerField(default=1, verbose_name="Anzahl")
supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Lieferant")
price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, verbose_name="Preis")
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Kategorie")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Einkaufslistenartikel"
verbose_name_plural = "Einkaufslistenartikel"
ordering = ["name"]
def __str__(self):
return self.name
+19
View File
@@ -0,0 +1,19 @@
from masterdata.registry import register_setting
from .models import (
InventoryCategory,
InventoryLocation,
)
register_setting(
group="Inventar",
name="Kategorien",
model=InventoryCategory,
)
register_setting(
group="Inventar",
name="Standorte",
model=InventoryLocation,
)
+197
View File
@@ -0,0 +1,197 @@
import django_tables2 as tables
from .models import ProjectInventoryItem, ShoppingItem, InventoryItem
import django_tables2 as tables
class InventoryGlobalTable(tables.Table):
toggle_prefix = "inv"
detail_template = "inventory/tables/inventory_global/edit_form.html"
name = tables.Column(
verbose_name="Name"
)
category = tables.Column(
verbose_name="Kategorie"
)
available = tables.TemplateColumn(
template_name="inventory/tables/inventory_global/available.html",
verbose_name="Anzahl",
orderable=False
)
location = tables.Column(
verbose_name="Standort"
)
article_number = tables.Column(
verbose_name="Artikelnummer"
)
class Meta:
model = InventoryItem
template_name = "django_tables2/table_toggle.html"
fields = (
"expand",
"name",
"category",
"available",
"location",
"article_number",
)
attrs = {
"class": "w-full text-sm min-w-[700px]"
}
class InventoryAddTable(tables.Table):
name = tables.Column(
accessor="name",
verbose_name="Name"
)
category = tables.Column(
accessor="category",
verbose_name="Kategorie"
)
available = tables.Column(
accessor="available_quantity",
verbose_name="Verfügbar"
)
article_number = tables.Column(
accessor="article_number",
verbose_name="Artikelnummer"
)
quantity = tables.TemplateColumn(
template_name="inventory/tables/inventory_add/quantity.html",
verbose_name="Anzahl hinzufügen",
orderable=False
)
class Meta:
model = InventoryItem
fields = (
"name",
"category",
"available",
"article_number",
"quantity",
)
attrs = {
"class": "w-full text-sm min-w-[700px]"
}
class ShoppingItemTable(tables.Table):
name = tables.TemplateColumn(
template_name="inventory/tables/shopping/name.html",
verbose_name="Name"
)
category = tables.TemplateColumn(
template_name="inventory/tables/shopping/category.html",
verbose_name="Kategorie"
)
quantity = tables.TemplateColumn(
template_name="inventory/tables/shopping/quantity.html",
verbose_name="Anzahl"
)
supplier = tables.TemplateColumn(
template_name="inventory/tables/shopping/supplier.html",
verbose_name="Lieferant"
)
price = tables.TemplateColumn(
template_name="inventory/tables/shopping/price.html",
verbose_name="Preis"
)
description = tables.TemplateColumn(
template_name="inventory/tables/shopping/description.html",
verbose_name="Beschreibung"
)
actions = tables.TemplateColumn(
template_name="inventory/tables/shopping/actions.html",
verbose_name="Aktionen",
orderable=False
)
class Meta:
model = ShoppingItem
fields = (
"name",
"category",
"quantity",
"supplier",
"price",
"description",
"actions",
)
attrs = {
"class": "w-full text-sm"
}
class ProjectInventoryItemTable(tables.Table):
name = tables.Column(
accessor="item.name",
verbose_name="Name"
)
category = tables.Column(
accessor="item.category",
verbose_name="Kategorie"
)
quantity = tables.TemplateColumn(
template_name="inventory/tables/quantity.html",
verbose_name="Anzahl"
)
location = tables.Column(
accessor="item.location",
verbose_name="Standort"
)
article_number = tables.Column(
accessor="item.article_number",
verbose_name="Artikelnummer"
)
responsible = tables.Column(
accessor="item.responsible",
verbose_name="Verantwortlicher"
)
status = tables.TemplateColumn(
template_name="inventory/tables/status.html",
verbose_name="Status"
)
actions = tables.TemplateColumn(
template_name="inventory/tables/actions.html",
verbose_name="Aktionen",
orderable=False
)
class Meta:
model = ProjectInventoryItem
fields = (
"name",
"category",
"quantity",
"location",
"article_number",
"responsible",
"status",
"actions",
)
attrs = {
"class": "w-full text-sm"
}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}
{{ project.name }} - Inventar
{% endblock %}
{% block header_title %}
Packartikel hinzufügen
{% endblock %}
{% block header_description %}
Artikel zur Packliste hinzufügen
{% endblock %}
{% block content %}
{% load django_tables2 %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<form method="post">
{% csrf_token %}
<div class="overflow-x-auto rounded-xl">
{% render_table table %}
</div>
<button
type="submit"
class="btn mt-4 btn-save">
Speichern
</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}
Inventar
{% endblock %}
{% block header_title %}
Inventarartikel hinzufügen
{% endblock %}
{% block header_description %}
Neuen Artikel zum globalen Inventar hinzufügen
{% endblock %}
{% block content %}
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<form method="post" class="space-y-4">
{% csrf_token %}
<div class="grid grid-cols-1 gap-4">
{{ form.as_p }}
</div>
<button
type="submit"
class="btn mt-4 btn-save">
Speichern
</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}
Inventar
{% endblock %}
{% block header_title %}
Inventar
{% endblock %}
{% block header_description %}
Globale Inventarverwaltung
{% endblock %}
{% block content %}
{% load django_tables2 %}
<div class="mb-8">
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h2 class="text-xl font-bold">Aktionen</h2>
<p class="text-slate-400 text-sm mt-1">Inventarartikel hinzufügen</p>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<a href="{% url 'inventory_global:add' %}" class="btn btn-add">+ Artikel</a>
</div>
</div>
</div>
</div>
<div class="p-6 rounded-xl shadow bg-white dark:bg-slate-800 text-slate-900 dark:text-white transition-colors">
<div class="mb-6">
<h2 class="text-xl font-bold">Inventarliste</h2>
<p class="text-slate-400 text-sm mt-1">Gelagerte und verfügbare Artikel</p>
</div>
<div class="overflow-x-auto rounded-xl">
{% render_table table %}
</div>
</div>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More