• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

akvo / akvo-mis / #773

30 Jul 2026 07:05AM UTC coverage: 88.786% (+0.3%) from 88.523%
#773

Pull #280

coveralls-python

web-flow
[#271] Enforce tenant ownership on every write (#282)

* [#271] Add the tenant stamping mixin and scoped related field

Two write-side mechanisms mirroring the read-side for_user. The
stamping mixin sets a new row's tenant from the authenticated user
(never the payload) in create(). The scoped related field narrows a
foreign-key input's candidates to for_user(user), so a pk pointing at
another tenant's object fails validation as does_not_exist. Both
resolve the acting user from either the "user" or the DRF "request"
context key.

A test covers the case that matters most for trust: a payload carrying
an explicit tenant id is ignored, and the row is stamped to the caller.

* [#271] Stamp direct-FK creates with the creator's tenant

Form, organisation, user, entity, attribute and single-administration
creates now set tenant from the authenticated user. Rows that landed
tenant-less before were invisible to their own creator the moment read
filtering arrived — a form saved in the builder and vanished. A test
covers exactly that: create, then list, and find it.

The plan expected two variants, the mixin where create() is default and
explicit assignment where it is overridden. Only the mixin is needed:
all three custom create() methods call super().create(), so the mixin
sits behind them in the MRO and stamps on the way through. One
mechanism, applied uniformly.

Two unscoped level lookups had to be fixed to get here. validate_parent
and _assign_level both resolved the child tier with
Levels.objects.get(level=n), which is unique per tenant but not
globally: with a second tenant present they matched two rows and raised
MultipleObjectsReturned, so creating any child administration was a 500
rather than a leak. Both now resolve within the parent's tenant. Read
filtering missed them because they live in a serializer validator, not
a view queryset.

* [#271] Reject write payloads referencing another tenant's objects

Foreign-key inputs to tenant-owned objects — a... (continued)
Pull Request #280: Epic/multi tenant saas

5733 of 6629 branches covered (86.48%)

Branch coverage included in aggregate %.

10877 of 12079 relevant lines covered (90.05%)

0.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

78.87
backend/utils/draft_model.py
1
from django.db import models
1✔
2
from .soft_deletes_model import SoftDeletesQuerySet, SoftDeletesManager
1✔
3
from .tenant_scoped_model import TenantScopedQuerySetMixin
1✔
4

5

6
class DraftQuerySet(TenantScopedQuerySetMixin, models.QuerySet):
1✔
7
    def only_draft(self):
1✔
8
        return self.filter(is_draft=True)
×
9

10
    def without_draft(self):
1✔
11
        return self.filter(is_draft=False)
×
12

13
    def published(self):
1✔
14
        return self.filter(is_draft=False)
×
15

16

17
class DraftSoftDeletesQuerySet(SoftDeletesQuerySet):
1✔
18
    def only_draft(self):
1✔
19
        return self.filter(is_draft=True)
1✔
20

21
    def without_draft(self):
1✔
22
        return self.filter(is_draft=False)
×
23

24
    def published(self):
1✔
25
        return self.filter(is_draft=False)
×
26

27

28
class DraftManager(models.Manager):
1✔
29
    def __init__(self, *args, **kwargs):
1✔
30
        self.only_draft = kwargs.pop("only_draft", False)
1✔
31
        super().__init__(*args, **kwargs)
1✔
32

33
    def get_queryset(self):
1✔
34
        if self.only_draft:
×
35
            return DraftQuerySet(self.model).only_draft()
×
36
        return DraftQuerySet(self.model).without_draft()
×
37

38
    def published(self):
1✔
39
        return self.get_queryset().published()
×
40

41
    def draft(self):
1✔
42
        return DraftQuerySet(self.model).only_draft()
×
43

44
    def for_user(self, user):
1✔
45
        return self.get_queryset().for_user(user)
×
46

47

48
class DraftSoftDeletesManager(SoftDeletesManager):
1✔
49
    def __init__(self, *args, **kwargs):
1✔
50
        self.only_draft = kwargs.pop("only_draft", False)
1✔
51
        super().__init__(*args, **kwargs)
1✔
52

53
    def get_queryset(self):
1✔
54
        if self.only_draft:
1✔
55
            # For draft data, we only care about non-deleted drafts
56
            # since drafts are always hard deleted
57
            queryset = DraftSoftDeletesQuerySet(self.model)
1✔
58
            return queryset.without_deleted().only_draft()
1✔
59
        # For non-draft queries, use the parent logic
60
        return super().get_queryset()
1✔
61

62
    def draft(self):
1✔
63
        queryset = DraftSoftDeletesQuerySet(self.model)
×
64
        return queryset.without_deleted().only_draft()
×
65

66

67
class Draft(models.Model):
1✔
68
    is_draft = models.BooleanField(default=False)
1✔
69

70
    class Meta:
1✔
71
        abstract = True
1✔
72

73
    objects = DraftManager()
1✔
74
    objects_draft = DraftManager(only_draft=True)
1✔
75

76
    def publish(self) -> None:
1✔
77
        self.is_draft = False
1✔
78
        self.save(update_fields=["is_draft"])
1✔
79

80
    def mark_as_draft(self) -> None:
1✔
81
        self.is_draft = True
1✔
82
        self.save(update_fields=["is_draft"])
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc