Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
1aa0d58
init
crosspolar Oct 5, 2025
a35819e
edit manager
crosspolar Oct 5, 2025
735ef3e
create migration
crosspolar Oct 5, 2025
e71615a
fix also ShareOwnerQuerySet outside of Shareowner class
crosspolar Oct 5, 2025
8ee857f
tests
crosspolar Oct 5, 2025
af3d112
buttons
crosspolar Oct 5, 2025
43e670d
translations
crosspolar Oct 5, 2025
a89a542
remove callback
crosspolar Oct 10, 2025
ea4534a
remove transaction atomic
crosspolar Oct 10, 2025
9cd2b39
use form_valid, according to docs https://docs.djangoproject.com/en/5…
crosspolar Oct 10, 2025
c3f21fe
rename to soft_delete
crosspolar Oct 10, 2025
6668313
fix: fix failing tests. Reading again documentation helped, which sta…
crosspolar Oct 10, 2025
0a5215d
refactor: comply with self.assert...
crosspolar Oct 10, 2025
d606f36
use outline-danger
crosspolar Oct 10, 2025
94eadc3
move part of forbidding removing own user to form_valid
crosspolar Oct 10, 2025
0ca1fb0
test: forbidding removing own user
crosspolar Oct 10, 2025
5837288
access only to perms.group.manage
crosspolar Oct 10, 2025
1a179c8
translation
crosspolar Oct 12, 2025
add0fe3
avoid auto-escaping
crosspolar Oct 13, 2025
a44ecee
Merge branch 'master' into 328-add-the-option-to-mark-users-as-delete…
crosspolar Oct 13, 2025
f9cdf1d
reformat
crosspolar Oct 18, 2025
6a58c51
Permission_group_manage
crosspolar Oct 18, 2025
9a125c8
add simple test
crosspolar Oct 18, 2025
bc948ce
translation
crosspolar Oct 18, 2025
4e09694
added more information about the consequences of deleting.
crosspolar Nov 16, 2025
7240766
use tapir_button_link
crosspolar Nov 16, 2025
30049e9
undo changes by mistake
crosspolar Nov 16, 2025
e8244b2
add logs
crosspolar Nov 16, 2025
1676bdc
test_shareOwnerGetsDeleted_deletedAt_hasDate
crosspolar Nov 20, 2025
fa6cf2b
fix: test_shareowner_delete_view.py
crosspolar Nov 21, 2025
52b1ffc
use SoftDeleteMixin to get ShareOwner use Soft-Delete
crosspolar Nov 21, 2025
ddd4547
translation
crosspolar Nov 21, 2025
bd860cd
delete old migrations
crosspolar Nov 21, 2025
a6349a1
tests users cannot delete itself
crosspolar Nov 21, 2025
28dea6a
Merge branch 'master' into 328-add-the-option-to-mark-users-as-delete…
crosspolar Nov 21, 2025
79a63a0
translation
crosspolar Nov 21, 2025
909f445
fix: return HTTPReponse
crosspolar Nov 21, 2025
1a5c3bc
use @transaction.atomic
crosspolar Nov 21, 2025
b761927
transl
crosspolar Nov 21, 2025
33e101e
Merge branch 'master' into 328-add-the-option-to-mark-users-as-delete…
crosspolar Dec 14, 2025
2b0416d
Update tapir/coop/views/shareowner.py
crosspolar Dec 14, 2025
a5b12cc
Merge remote-tracking branch 'origin/328-add-the-option-to-mark-users…
crosspolar Dec 14, 2025
58af918
DeleteView automatically calls delete() so we don't need to call it here
crosspolar Dec 14, 2025
ba5826f
move SoftDeleteMixin and Manager to coop/models.py
crosspolar Dec 14, 2025
e3ec40d
forward using to save
crosspolar Dec 14, 2025
f2039b1
translation
crosspolar Dec 14, 2025
5598bc3
use soft_delete()
crosspolar Jan 30, 2026
74b1edc
DeleteView was actually deleting ShareOwner, so form_valid returns Ht…
crosspolar Jan 30, 2026
dc2f4c5
Merge branch 'master' into 328-add-the-option-to-mark-users-as-delete…
crosspolar Jan 30, 2026
44e96df
transl
crosspolar Jan 30, 2026
0deafeb
fix: ShareOwnerQuerySet is stand-alone here
crosspolar Jan 31, 2026
4258dd0
Merge branch 'master' into 328-add-the-option-to-mark-users-as-delete…
crosspolar Feb 7, 2026
b4868f9
don't show shareowner-card if soft-deleted
crosspolar Feb 7, 2026
1b035bf
transl
crosspolar Feb 7, 2026
737523c
Update tapir/coop/tests/test_shareowner_delete_view.py
crosspolar Feb 25, 2026
d84fb36
Update tapir/coop/tests/test_shareowner_softdelete.py
crosspolar Feb 25, 2026
e998126
Update tapir/coop/tests/test_shareowner_softdelete.py
crosspolar Feb 25, 2026
99bcb6f
Update tapir/coop/tests/test_shareowner_softdelete.py
crosspolar Feb 25, 2026
f4bb83c
Update tapir/coop/tests/test_shareowner_delete_view.py
crosspolar Feb 25, 2026
19ddea8
Update tapir/coop/tests/test_shareowner_delete_view.py
crosspolar Feb 25, 2026
d2d3491
Update tapir/coop/tests/test_shareowner_softdelete.py
crosspolar Feb 25, 2026
caee031
Update tapir/coop/tests/test_shareowner_softdelete.py
crosspolar Feb 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Generated by Django 5.1.12 on 2025-11-21 14:55

import django.contrib.postgres.fields.hstore
import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
(
"coop",
"0055_membershippauseupdatedlogentry_coop_member_old_val_6e6554_gin_and_more",
),
("log", "0008_logentry_log_logentr_user_id_c4ca60_idx_and_more"),
]

operations = [
migrations.CreateModel(
name="DeleteShareOwnerLogEntry",
fields=[
(
"logentry_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="log.logentry",
),
),
("values", django.contrib.postgres.fields.hstore.HStoreField()),
],
options={
"abstract": False,
},
bases=("log.logentry",),
),
migrations.AddField(
model_name="shareowner",
name="deleted_at",
field=models.DateTimeField(blank=True, default=None, null=True),
),
]
174 changes: 93 additions & 81 deletions tapir/coop/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from tapir.coop.services.membership_pause_service import MembershipPauseService
from tapir.coop.services.number_of_shares_service import NumberOfSharesService
from tapir.core.config import help_text_displayed_name
from tapir.core.models import SoftDeleteMixin, NonDeleted
from tapir.log.models import UpdateModelLogEntry, ModelLogEntry, LogEntry
from tapir.utils.expection_utils import TapirException
from tapir.utils.models import (
Expand All @@ -31,7 +32,89 @@
from tapir.utils.user_utils import UserUtils


class ShareOwner(models.Model):
class ShareOwnerQuerySet(models.QuerySet):
def with_name(self, search_string: str):
searches = [s for s in search_string.split(" ") if s != ""]

combined_filters = Q(last_name__icontains="")
for search in searches:
word_filter = (
Q(last_name__unaccent__icontains=search)
| Q(first_name__unaccent__icontains=search)
| Q(usage_name__unaccent__icontains=search)
| Q(user__first_name__unaccent__icontains=search)
| Q(user__usage_name__unaccent__icontains=search)
| Q(user__last_name__unaccent__icontains=search)
| Q(company_name__unaccent__icontains=search)
)
combined_filters = combined_filters & word_filter

return self.filter(combined_filters)

def with_status(
self, status: str, at_datetime: datetime.datetime | datetime.date = None
):
if at_datetime is None:
at_datetime = timezone.now()

at_datetime = ensure_datetime(at_datetime)

share_owners_with_nb_of_shares = NumberOfSharesService.annotate_share_owner_queryset_with_nb_of_active_shares(
ShareOwner.objects.all(), at_datetime.date()
)
members_without_shares = share_owners_with_nb_of_shares.filter(
**{NumberOfSharesService.ANNOTATION_NUMBER_OF_ACTIVE_SHARES: 0}
)
members_without_shares_ids = list(
members_without_shares.values_list("id", flat=True)
)

if status == MemberStatus.SOLD:
return self.filter(id__in=members_without_shares_ids).distinct()

members_with_valid_shares = self.exclude(id__in=members_without_shares_ids)

members_with_investing_annotation = InvestingStatusService.annotate_share_owner_queryset_with_investing_status_at_datetime(
ShareOwner.objects.all(), at_datetime
)
investing_members = members_with_investing_annotation.filter(
**{InvestingStatusService.ANNOTATION_WAS_INVESTING: True}
)
investing_members_ids = list(investing_members.values_list("id", flat=True))

if status == MemberStatus.INVESTING:
return members_with_valid_shares.filter(
id__in=investing_members_ids
).distinct()

member_with_shares_and_not_investing: Self = members_with_valid_shares.exclude(
id__in=investing_members_ids
)

members_with_paused_annotation = (
MembershipPauseService.annotate_share_owner_queryset_with_has_active_pause(
ShareOwner.objects.all(), at_datetime.date()
)
)
paused_members = members_with_paused_annotation.filter(
**{MembershipPauseService.ANNOTATION_HAS_ACTIVE_PAUSE: True}
)
paused_members_ids = list(paused_members.values_list("id", flat=True))

if status == MemberStatus.PAUSED:
return member_with_shares_and_not_investing.filter(
id__in=paused_members_ids
).distinct()

if status == MemberStatus.ACTIVE:
return member_with_shares_and_not_investing.exclude(
id__in=paused_members_ids
).distinct()

raise TapirException(f"Invalid status : {status}")


class ShareOwner(SoftDeleteMixin, models.Model):
"""ShareOwner represents a share_owner of a ShareOwnership.

Usually, this is just a proxy for the associated user. However, it may also be used to
Expand Down Expand Up @@ -96,86 +179,8 @@ class Meta:
)
create_account_reminder_email_sent = models.BooleanField(default=False)

class ShareOwnerQuerySet(models.QuerySet):
def with_name(self, search_string: str):
searches = [s for s in search_string.split(" ") if s != ""]

combined_filters = Q(last_name__icontains="")
for search in searches:
word_filter = (
Q(last_name__unaccent__icontains=search)
| Q(first_name__unaccent__icontains=search)
| Q(usage_name__unaccent__icontains=search)
| Q(user__first_name__unaccent__icontains=search)
| Q(user__usage_name__unaccent__icontains=search)
| Q(user__last_name__unaccent__icontains=search)
| Q(company_name__unaccent__icontains=search)
)
combined_filters = combined_filters & word_filter

return self.filter(combined_filters)

def with_status(
self, status: str, at_datetime: datetime.datetime | datetime.date = None
):
if at_datetime is None:
at_datetime = timezone.now()

at_datetime = ensure_datetime(at_datetime)

share_owners_with_nb_of_shares = NumberOfSharesService.annotate_share_owner_queryset_with_nb_of_active_shares(
ShareOwner.objects.all(), at_datetime.date()
)
members_without_shares = share_owners_with_nb_of_shares.filter(
**{NumberOfSharesService.ANNOTATION_NUMBER_OF_ACTIVE_SHARES: 0}
)
members_without_shares_ids = list(
members_without_shares.values_list("id", flat=True)
)

if status == MemberStatus.SOLD:
return self.filter(id__in=members_without_shares_ids).distinct()

members_with_valid_shares = self.exclude(id__in=members_without_shares_ids)

members_with_investing_annotation = InvestingStatusService.annotate_share_owner_queryset_with_investing_status_at_datetime(
ShareOwner.objects.all(), at_datetime
)
investing_members = members_with_investing_annotation.filter(
**{InvestingStatusService.ANNOTATION_WAS_INVESTING: True}
)
investing_members_ids = list(investing_members.values_list("id", flat=True))

if status == MemberStatus.INVESTING:
return members_with_valid_shares.filter(
id__in=investing_members_ids
).distinct()

member_with_shares_and_not_investing: Self = (
members_with_valid_shares.exclude(id__in=investing_members_ids)
)

members_with_paused_annotation = MembershipPauseService.annotate_share_owner_queryset_with_has_active_pause(
ShareOwner.objects.all(), at_datetime.date()
)
paused_members = members_with_paused_annotation.filter(
**{MembershipPauseService.ANNOTATION_HAS_ACTIVE_PAUSE: True}
)
paused_members_ids = list(paused_members.values_list("id", flat=True))

if status == MemberStatus.PAUSED:
return member_with_shares_and_not_investing.filter(
id__in=paused_members_ids
).distinct()

if status == MemberStatus.ACTIVE:
return member_with_shares_and_not_investing.exclude(
id__in=paused_members_ids
).distinct()

raise TapirException(f"Invalid status : {status}")

objects = ShareOwnerQuerySet.as_manager()
# overwrite from Mixin:
objects = NonDeleted.from_queryset(ShareOwnerQuerySet)()

def blank_info_fields(self):
"""Used after a ShareOwner is linked to a user, which is used as the source for user info instead."""
Expand Down Expand Up @@ -866,3 +871,10 @@ def populate(
return super().populate_base(
actor=actor, share_owner=model.share_owner, model=model
)


class DeleteShareOwnerLogEntry(ModelLogEntry):
template_name = "coop/log/delete_share_owner_log_entry.html"

def populate(self, share_owner: ShareOwner, actor, model):
return self.populate_base(share_owner=share_owner, actor=actor, model=model)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% load i18n %}
{% blocktranslate %}
Member has been deleted.
{% endblocktranslate %}
32 changes: 32 additions & 0 deletions tapir/coop/templates/coop/shareowner_confirm_delete.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{% extends "core/base.html" %}
{% load utils %}
{% load django_bootstrap5 %}
{% load i18n %}
{% load core %}
{% block title %}
{% translate "Confirm Delete" %}: {% get_display_name_for_viewer object request.user %}
{% endblock title %}
{% block content %}
<div class="container">
<div class="card mb-2" id="share_owner_confirm_delete_card">
<h5 class="card-header">
<span>{% translate "Confirm Delete" %}: {% get_display_name_for_viewer object request.user %}</span>
</h5>
<div class="card-body">
<div class="alert alert-warning">{% translate "Are you sure you want to delete this member?" %}</div>
<p>
{% translate "Please note that deleting this member will permanently remove their account and all associated data such as owned shares and payments. The deleted user will no longer be part of the cooperative. However, all shift records and logs will remain intact." %}
</p>
<form method="post">
{% csrf_token %}
<button type="submit" class="{% tapir_button_custom 'danger' %}">
<span class="material-icons">delete</span>
{% translate 'Delete' %}
</button>
<a href="{% url 'coop:shareowner_list' %}"
class="{% tapir_button_link %}">{% translate 'Cancel' %}</a>
</form>
</div>
</div>
</div>
{% endblock content %}
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,38 @@
{% load core %}
<div class="card mb-2" id="user_coop_info_card">
<h5 class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
{% if share_owner %}
{% if share_owner and not share_owner.is_soft_deleted %}
<span>
{% blocktranslate with coop_share_owner_id=share_owner.id %}
Member #{{ coop_share_owner_id }}
{% endblocktranslate %}
</span>
{% if perms.accounts.manage %}
{% if perms.accounts.manage or perms.group.manage %}
<span class="d-flex justify-content-end flex-fill flex-wrap gap-2">
<a class="{% tapir_button_link %}"
href="{% url 'coop:shareowner_membership_confirmation' share_owner.pk %}">
<span class="material-icons">file_present</span>{% translate 'Membership confirmation' %}
</a>
<a class="{% tapir_button_link_to_action %}"
href="{% url 'coop:shareowner_update' share_owner.pk %}"
id="share_owner_edit_button">
<span class="material-icons">edit</span>{% translate 'Edit' %}
</a>
{% if perms.accounts.manage %}
<a class="{% tapir_button_link %}"
href="{% url 'coop:shareowner_membership_confirmation' share_owner.pk %}">
<span class="material-icons">file_present</span>{% translate 'Membership confirmation' %}
</a>
<a class="{% tapir_button_link_to_action %}"
href="{% url 'coop:shareowner_update' share_owner.pk %}"
id="share_owner_edit_button">
<span class="material-icons">edit</span>{% translate 'Edit' %}
</a>
{% endif %}
{% if perms.group.manage %}
<a class="{% tapir_button_custom 'outline-danger' %}"
href="{% url 'coop:shareowner_delete' share_owner.pk %}"
id="share_owner_delete_button">
<span class="material-icons">delete</span>{% translate 'Delete' %}
</a>
{% endif %}
</span>
{% endif %}
{% endif %}
</h5>
<div class="card-body">
{% if share_owner %}
{% if share_owner and not share_owner.is_soft_deleted %}
<div class="row m-1">
<div class="col-12 col-sm-5 fw-bold text-sm-end">{% translate "Status" %}:</div>
<div class="col-12 col-sm-7" id="share_owner_status">
Expand Down
Loading