diff --git a/compta/__pycache__/admin.cpython-310.pyc b/compta/__pycache__/admin.cpython-310.pyc index 7a98e87..6f05201 100644 Binary files a/compta/__pycache__/admin.cpython-310.pyc and b/compta/__pycache__/admin.cpython-310.pyc differ diff --git a/compta/__pycache__/models.cpython-310.pyc b/compta/__pycache__/models.cpython-310.pyc index 42213d9..92e79de 100644 Binary files a/compta/__pycache__/models.cpython-310.pyc and b/compta/__pycache__/models.cpython-310.pyc differ diff --git a/compta/__pycache__/views.cpython-310.pyc b/compta/__pycache__/views.cpython-310.pyc index 329267b..1ed4767 100644 Binary files a/compta/__pycache__/views.cpython-310.pyc and b/compta/__pycache__/views.cpython-310.pyc differ diff --git a/compta/__pycache__/views_saisie.cpython-310.pyc b/compta/__pycache__/views_saisie.cpython-310.pyc index 0c915b5..01ad69a 100644 Binary files a/compta/__pycache__/views_saisie.cpython-310.pyc and b/compta/__pycache__/views_saisie.cpython-310.pyc differ diff --git a/compta/admin.py b/compta/admin.py index 442734a..5beb84a 100644 --- a/compta/admin.py +++ b/compta/admin.py @@ -2,11 +2,34 @@ from django.contrib import admin from django.utils import timezone from .models import ( - Bareme, Entreprise, Client, Facture, LigneFacture, Depense, Paiement, - Categorie, + Bareme, Entreprise, Client, Facture, LigneFacture, Depense, Paiement, Categorie, ) +class ScopedAdmin(admin.ModelAdmin): + """Cloisonne les données par entreprise pour les non-superadmins.""" + scope_path = "entreprise" + + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(**{f"{self.scope_path}__in": Entreprise.accessibles(request.user)}) + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if not request.user.is_superuser: + acc = Entreprise.accessibles(request.user) + if db_field.name == "entreprise": + kwargs["queryset"] = acc + elif db_field.name == "client": + kwargs["queryset"] = Client.objects.filter(entreprise__in=acc) + elif db_field.name == "paiement": + kwargs["queryset"] = Paiement.objects.filter(entreprise__in=acc) + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + +# ----- Référentiels mutualisés (visibles par tous) ----- + @admin.register(Categorie) class CategorieAdmin(admin.ModelAdmin): list_display = ("nom", "usage", "exclure_calculs", "actif", "ordre") @@ -22,16 +45,33 @@ class BaremeAdmin(admin.ModelAdmin): list_filter = ("annee", "activite") +# ----- Entreprise (accès) ----- + @admin.register(Entreprise) class EntrepriseAdmin(admin.ModelAdmin): list_display = ("nom", "activite", "franchise_tva", "versement_liberatoire", "num_tva_intracom", "vue_tva", "email") list_filter = ("activite", "franchise_tva", "versement_liberatoire") search_fields = ("nom", "siret", "num_tva_intracom") + filter_horizontal = ("utilisateurs", "groupes") + def get_queryset(self, request): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return Entreprise.accessibles(request.user) + + def get_readonly_fields(self, request, obj=None): + # Seul un superadmin gère les droits d'accès. + if not request.user.is_superuser: + return ("utilisateurs", "groupes") + return () + + +# ----- Données cloisonnées par entreprise ----- @admin.register(Client) -class ClientAdmin(admin.ModelAdmin): +class ClientAdmin(ScopedAdmin): list_display = ("nom", "entreprise", "email", "siret") list_filter = ("entreprise",) search_fields = ("nom", "email", "siret") @@ -48,10 +88,9 @@ def marquer_encaissee(modeladmin, request, queryset): @admin.register(Facture) -class FactureAdmin(admin.ModelAdmin): +class FactureAdmin(ScopedAdmin): list_display = ("numero", "entreprise", "client", "date_emission", - "date_encaissement", "statut", "montant_ht", - "montant_tva", "montant_ttc") + "date_encaissement", "statut", "montant_ht", "montant_tva", "montant_ttc") list_filter = ("entreprise", "statut", "activite", "date_emission") search_fields = ("numero", "client__nom") date_hierarchy = "date_emission" @@ -72,7 +111,7 @@ class DepenseInline(admin.TabularInline): @admin.register(Paiement) -class PaiementAdmin(admin.ModelAdmin): +class PaiementAdmin(ScopedAdmin): list_display = ("date", "entreprise", "libelle", "sens", "montant_total", "reparti", "reste", "equilibre") list_filter = ("entreprise", "sens", "date") @@ -94,10 +133,9 @@ class PaiementAdmin(admin.ModelAdmin): @admin.register(Depense) -class DepenseAdmin(admin.ModelAdmin): +class DepenseAdmin(ScopedAdmin): list_display = ("date", "entreprise", "libelle", "categorie", "activite", - "regime_tva", "montant_ttc", "montant_tva", "tva_autoliq", - "paiement") + "regime_tva", "montant_ttc", "montant_tva", "tva_autoliq", "paiement") list_filter = ("entreprise", "categorie", "regime_tva", "activite", "date") search_fields = ("libelle",) date_hierarchy = "date" diff --git a/compta/migrations/0007_alter_paiement_options_entreprise_groupes_and_more.py b/compta/migrations/0007_alter_paiement_options_entreprise_groupes_and_more.py new file mode 100644 index 0000000..bf6ec06 --- /dev/null +++ b/compta/migrations/0007_alter_paiement_options_entreprise_groupes_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2.15 on 2026-06-30 06:30 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('compta', '0006_paiement_categorie_alter_depense_paiement_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='paiement', + options={'ordering': ['-date'], 'verbose_name': 'Opération bancaire', 'verbose_name_plural': 'Opérations bancaires'}, + ), + migrations.AddField( + model_name='entreprise', + name='groupes', + field=models.ManyToManyField(blank=True, help_text='Tous les membres de ces groupes ont accès à cette entreprise.', related_name='entreprises', to='auth.group', verbose_name='Groupes autorisés'), + ), + migrations.AddField( + model_name='entreprise', + name='utilisateurs', + field=models.ManyToManyField(blank=True, help_text='Utilisateurs ayant accès directement à cette entreprise.', related_name='entreprises', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateurs autorisés'), + ), + migrations.AlterField( + model_name='depense', + name='paiement', + field=models.ForeignKey(blank=True, help_text='Opération bancaire dont cette dépense fait partie.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='depenses', to='compta.paiement', verbose_name='Opération bancaire'), + ), + migrations.AlterField( + model_name='entreprise', + name='franchise_tva', + field=models.BooleanField(default=False, help_text='Décochez si vous facturez la TVA.', verbose_name='En franchise de TVA'), + ), + migrations.AlterField( + model_name='paiement', + name='categorie', + field=models.ForeignKey(blank=True, help_text='Surtout pour les mouvements neutres.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='paiements', to='compta.categorie'), + ), + ] diff --git a/compta/models.py b/compta/models.py index 40535d7..93ec728 100644 --- a/compta/models.py +++ b/compta/models.py @@ -3,8 +3,9 @@ Modèles de données — JB Compta. """ from decimal import Decimal +from django.conf import settings from django.db import models -from django.db.models import Sum +from django.db.models import Sum, Q class Activite(models.TextChoices): @@ -27,7 +28,7 @@ class RegimeTva(models.TextChoices): class Categorie(models.Model): - """Catégorie globale. exclure_calculs=True => mouvement neutre (hors fiscal).""" + """Catégorie globale (mutualisée). exclure_calculs=True => mouvement neutre.""" USAGE = [("depense", "Dépense"), ("vente", "Vente"), ("neutre", "Mouvement neutre")] nom = models.CharField(max_length=100, unique=True) usage = models.CharField(max_length=10, choices=USAGE, default="depense") @@ -48,6 +49,7 @@ class Categorie(models.Model): class Bareme(models.Model): + """Barème mutualisé (partagé entre toutes les entreprises).""" activite = models.CharField(max_length=20, choices=Activite.choices) annee = models.PositiveIntegerField() taux_urssaf = models.DecimalField("Taux cotisations URSSAF (%)", max_digits=5, decimal_places=2) @@ -73,18 +75,24 @@ class Entreprise(models.Model): siret = models.CharField(max_length=20, blank=True) activite = models.CharField( "Activité principale", max_length=20, choices=Activite.choices, - default=Activite.SERVICE_BNC, - help_text="Activité par défaut des nouvelles factures.") + default=Activite.SERVICE_BNC, help_text="Activité par défaut des nouvelles factures.") franchise_tva = models.BooleanField( - "En franchise de TVA", default=False, - help_text="Décochez si vous facturez et déclarez la TVA.") + "En franchise de TVA", default=False, help_text="Décochez si vous facturez la TVA.") taux_tva_defaut = models.DecimalField( "Taux de TVA par défaut (%)", max_digits=4, decimal_places=2, default=Decimal("20.00")) versement_liberatoire = models.BooleanField("Option versement libératoire", default=False) num_tva_intracom = models.CharField("N° TVA intracommunautaire", max_length=20, blank=True) vue_tva = models.CharField( - "Mode d'affichage de la déclaration TVA", max_length=10, - choices=VUE_TVA, default="simplifie") + "Mode d'affichage de la déclaration TVA", max_length=10, choices=VUE_TVA, default="simplifie") + # --- Droits d'accès (multi-utilisateur) --- + utilisateurs = models.ManyToManyField( + settings.AUTH_USER_MODEL, blank=True, related_name="entreprises", + verbose_name="Utilisateurs autorisés", + help_text="Utilisateurs ayant accès directement à cette entreprise.") + groupes = models.ManyToManyField( + "auth.Group", blank=True, related_name="entreprises", + verbose_name="Groupes autorisés", + help_text="Tous les membres de ces groupes ont accès à cette entreprise.") email = models.EmailField(blank=True) adresse = models.TextField(blank=True) cree_le = models.DateTimeField(auto_now_add=True) @@ -100,6 +108,16 @@ class Entreprise(models.Model): def bareme(self, annee): return Bareme.objects.filter(activite=self.activite, annee=annee).first() + @classmethod + def accessibles(cls, user): + """Entreprises visibles par un utilisateur (toutes si superadmin).""" + if not user or not user.is_authenticated: + return cls.objects.none() + if user.is_superuser: + return cls.objects.all() + return cls.objects.filter( + Q(utilisateurs=user) | Q(groupes__in=user.groups.all())).distinct() + class Client(models.Model): entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE, related_name="clients") @@ -125,8 +143,7 @@ class Facture(models.Model): activite = models.CharField(max_length=20, choices=Activite.choices) date_emission = models.DateField() date_encaissement = models.DateField( - null=True, blank=True, - help_text="Le CA micro est compté à l'encaissement.") + null=True, blank=True, help_text="Le CA micro est compté à l'encaissement.") statut = models.CharField(max_length=12, choices=StatutFacture.choices, default=StatutFacture.BROUILLON) montant_ht = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0")) montant_tva = models.DecimalField(max_digits=12, decimal_places=2, default=Decimal("0")) @@ -184,15 +201,14 @@ class Paiement(models.Model): libelle = models.CharField("Libellé bancaire", max_length=255) montant_total = models.DecimalField(max_digits=12, decimal_places=2) sens = models.CharField(max_length=6, choices=SENS, default="debit") - categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, - null=True, blank=True, related_name="paiements", - help_text="Surtout pour les mouvements neutres " - "(remboursement TVA, virement…).") + categorie = models.ForeignKey(Categorie, on_delete=models.SET_NULL, null=True, blank=True, + related_name="paiements", + help_text="Surtout pour les mouvements neutres.") cree_le = models.DateTimeField(auto_now_add=True) class Meta: - verbose_name = "Paiement" - verbose_name_plural = "Paiements" + verbose_name = "Opération bancaire" + verbose_name_plural = "Opérations bancaires" ordering = ["-date"] def __str__(self): @@ -215,7 +231,7 @@ class Paiement(models.Model): class Depense(models.Model): entreprise = models.ForeignKey(Entreprise, on_delete=models.CASCADE, related_name="depenses") paiement = models.ForeignKey(Paiement, on_delete=models.SET_NULL, null=True, blank=True, - related_name="depenses", + related_name="depenses", verbose_name="Opération bancaire", help_text="Opération bancaire dont cette dépense fait partie.") date = models.DateField() activite = models.CharField( diff --git a/compta/views.py b/compta/views.py index 47b03d2..2a24cac 100644 --- a/compta/views.py +++ b/compta/views.py @@ -12,20 +12,22 @@ MOIS = services.MOIS_FR def _entreprise(request): + """Entreprise courante, restreinte à celles accessibles par l'utilisateur.""" + accessibles = Entreprise.accessibles(request.user) eid = request.GET.get("entreprise") if eid: - return get_object_or_404(Entreprise, pk=eid) - return Entreprise.objects.first() + return get_object_or_404(accessibles, pk=eid) + return accessibles.first() def _annee(request): return int(request.GET.get("annee", date.today().year)) -def _base_ctx(entreprise, annee, vue): +def _base_ctx(request, entreprise, annee, vue): return { "entreprise": entreprise, - "entreprises": Entreprise.objects.all(), + "entreprises": Entreprise.accessibles(request.user), "annee": annee, "annees": services.annees_disponibles(entreprise) or [annee], "vue": vue, @@ -40,7 +42,7 @@ def dashboard(request): return render(request, "compta/dashboard.html", {"entreprise": None}) annee = _annee(request) ctx = services.synthese(entreprise, annee) - ctx.update(_base_ctx(entreprise, annee, "global")) + ctx.update(_base_ctx(request, entreprise, annee, "global")) ctx.update({ "mois_labels": ["Jan", "Fév", "Mar", "Avr", "Mai", "Juin", "Juil", "Août", "Sep", "Oct", "Nov", "Déc"], @@ -55,7 +57,7 @@ def vue_mensuelle(request): if entreprise is None: return render(request, "compta/mensuel.html", {"entreprise": None}) annee = _annee(request) - ctx = _base_ctx(entreprise, annee, "mensuel") + ctx = _base_ctx(request, entreprise, annee, "mensuel") ctx["rows"] = services.tableau_mensuel(entreprise, annee) return render(request, "compta/mensuel.html", ctx) @@ -67,7 +69,7 @@ def declaration_urssaf(request): return render(request, "compta/decl_urssaf.html", {"entreprise": None}) annee = _annee(request) mois = int(request.GET.get("mois", date.today().month)) - ctx = _base_ctx(entreprise, annee, "urssaf") + ctx = _base_ctx(request, entreprise, annee, "urssaf") ctx["mois_sel"] = mois ctx["decl"] = services.declaration_urssaf(entreprise, annee, mois) return render(request, "compta/decl_urssaf.html", ctx) @@ -81,7 +83,7 @@ def declaration_tva(request): annee = _annee(request) mois = int(request.GET.get("mois", date.today().month)) mode = request.GET.get("mode") or entreprise.vue_tva - ctx = _base_ctx(entreprise, annee, "tva") + ctx = _base_ctx(request, entreprise, annee, "tva") ctx["mois_sel"] = mois ctx["mode"] = mode ctx["decl"] = services.declaration_tva(entreprise, annee, mois) diff --git a/compta/views_saisie.py b/compta/views_saisie.py index 1b73ca7..9756d14 100644 --- a/compta/views_saisie.py +++ b/compta/views_saisie.py @@ -1,8 +1,9 @@ """ Saisie unifiée : une opération bancaire signée. - Montant négatif -> dépense(s) (éclatement possible). -- Montant positif -> facture (vente) avec création de client à la volée, +- Montant positif -> facture (vente) + création de client à la volée, OU mouvement neutre (ex. remboursement de TVA). +Cloisonnée : un utilisateur ne voit que ses entreprises. """ from decimal import Decimal, InvalidOperation from datetime import date @@ -37,7 +38,7 @@ def _numero_unique(entreprise, annee): def _context(entreprises, post=None): return { "entreprises": entreprises, - "clients": Client.objects.select_related("entreprise"), + "clients": Client.objects.filter(entreprise__in=entreprises).select_related("entreprise"), "activites": Activite.choices, "regimes": RegimeTva.choices, "categories_depense": Categorie.objects.filter(actif=True).exclude(usage="vente"), @@ -49,7 +50,7 @@ def _context(entreprises, post=None): @login_required def saisie_paiement(request): - entreprises = Entreprise.objects.all() + entreprises = Entreprise.accessibles(request.user) if request.method != "POST": return render(request, "compta/saisie_paiement.html", _context(entreprises)) @@ -61,34 +62,28 @@ def saisie_paiement(request): erreurs = [] if not entreprise: - erreurs.append("Sélectionne une entreprise.") + erreurs.append("Sélectionne une entreprise autorisée.") if not libelle: - erreurs.append("Renseigne le libellé du paiement.") + erreurs.append("Renseigne le libellé de l'opération.") if montant_total == 0: erreurs.append("Le montant ne peut pas être nul.") - if erreurs: for e in erreurs: messages.error(request, e) - return render(request, "compta/saisie_paiement.html", - _context(entreprises, request.POST)) + return render(request, "compta/saisie_paiement.html", _context(entreprises, request.POST)) cats = {str(c.pk): c for c in Categorie.objects.all()} - # --------------------------------------------------------------- - # CRÉDIT (montant positif) -> facture (vente) ou mouvement neutre - # --------------------------------------------------------------- + # --- CRÉDIT (positif) -> facture (vente) ou mouvement neutre --- if montant_total > 0: cat = cats.get(request.POST.get("v_categorie")) if cat and cat.exclure_calculs: - # Mouvement neutre (ex. remboursement de TVA) : pas de CA. Paiement.objects.create( entreprise=entreprise, date=d, libelle=libelle, montant_total=montant_total, sens="credit", categorie=cat) messages.success(request, f"Mouvement neutre « {libelle} » enregistré.") return redirect("saisie_paiement") - # Vente -> facture encaissée client = None nouveau = (request.POST.get("v_client_nouveau") or "").strip() if nouveau: @@ -98,29 +93,23 @@ def saisie_paiement(request): entreprise=entreprise, pk=request.POST.get("v_client")).first() if not client: messages.error(request, "Choisis un client existant ou saisis-en un nouveau.") - return render(request, "compta/saisie_paiement.html", - _context(entreprises, request.POST)) + return render(request, "compta/saisie_paiement.html", _context(entreprises, request.POST)) activite = request.POST.get("v_activite") or entreprise.activite taux = _dec(request.POST.get("v_taux"), "0") designation = (request.POST.get("v_designation") or libelle).strip() ttc = montant_total ht = (ttc / (Decimal("1") + taux / Decimal("100"))).quantize(Decimal("0.01")) - f = Facture.objects.create( - entreprise=entreprise, client=client, - numero=_numero_unique(entreprise, annee), activite=activite, - date_emission=d, date_encaissement=d, statut="encaissee") - LigneFacture.objects.create( - facture=f, designation=designation, quantite=Decimal("1"), - prix_unitaire_ht=ht, taux_tva=taux) + entreprise=entreprise, client=client, numero=_numero_unique(entreprise, annee), + activite=activite, date_emission=d, date_encaissement=d, statut="encaissee") + LigneFacture.objects.create(facture=f, designation=designation, quantite=Decimal("1"), + prix_unitaire_ht=ht, taux_tva=taux) f.recompute_totaux() messages.success(request, f"Vente « {libelle} » enregistrée (facture {f.numero}).") return redirect("saisie_paiement") - # --------------------------------------------------------------- - # DÉBIT (montant négatif) -> dépense(s), éclatement possible - # --------------------------------------------------------------- + # --- DÉBIT (négatif) -> dépense(s), éclatement possible --- total_abs = -montant_total libelles = request.POST.getlist("l_libelle") categories = request.POST.getlist("l_categorie") @@ -149,24 +138,19 @@ def saisie_paiement(request): somme = sum((l["montant"] for l in lignes), Decimal("0")) if not lignes: messages.error(request, "Ajoute au moins une ligne de dépense.") - return render(request, "compta/saisie_paiement.html", - _context(entreprises, request.POST)) + return render(request, "compta/saisie_paiement.html", _context(entreprises, request.POST)) if abs(somme - total_abs) >= Decimal("0.01"): messages.error(request, f"La somme des lignes ({somme} €) ne correspond pas au montant " f"({total_abs} €). Écart : {somme - total_abs} €.") - return render(request, "compta/saisie_paiement.html", - _context(entreprises, request.POST)) + return render(request, "compta/saisie_paiement.html", _context(entreprises, request.POST)) paiement = Paiement.objects.create( - entreprise=entreprise, date=d, libelle=libelle, - montant_total=total_abs, sens="debit") + entreprise=entreprise, date=d, libelle=libelle, montant_total=total_abs, sens="debit") for l in lignes: Depense.objects.create( - entreprise=entreprise, paiement=paiement, date=d, - libelle=l["libelle"], categorie=l["categorie"], - activite=l["activite"] or "", regime_tva=l["regime"], + entreprise=entreprise, paiement=paiement, date=d, libelle=l["libelle"], + categorie=l["categorie"], activite=l["activite"] or "", regime_tva=l["regime"], taux_tva=l["taux"], montant_ttc=l["montant"], montant_tva=l["tva"]) - messages.success(request, - f"Paiement « {libelle} » enregistré ({len(lignes)} dépense(s)).") + messages.success(request, f"Opération « {libelle} » enregistrée ({len(lignes)} dépense(s)).") return redirect("saisie_paiement")