Skip to content

Commit dc29df7

Browse files
committed
Delete old (already deleted) consultations after 30 days. (SEA-1459).
1 parent b4a9e72 commit dc29df7

File tree

3 files changed

+168
-0
lines changed

3 files changed

+168
-0
lines changed

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ fanstatic.libraries =
8787
privatim:css = privatim.static:css_library
8888

8989
console_scripts =
90+
data_retention = privatim.cli.apply_data_retention_policy:hard_delete
9091
add_user = privatim.cli.user:add_user
9192
# delete_user = privatim.cli.user:delete_user
9293
add_content = privatim.cli.add_content:main
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from datetime import timedelta
2+
3+
import click
4+
from pyramid.paster import bootstrap
5+
from pyramid.paster import get_appsettings
6+
from sedate import utcnow
7+
from sqlalchemy import delete
8+
9+
from privatim.models import Consultation
10+
from privatim.models.soft_delete import SoftDeleteMixin
11+
from privatim.orm import get_engine, Base
12+
13+
14+
from typing import TYPE_CHECKING
15+
if TYPE_CHECKING:
16+
from sqlalchemy.orm.session import Session
17+
18+
19+
KEEP_DELETED_FILES_TIMESPAN = 30
20+
21+
22+
def delete_old_records(
23+
session: 'Session',
24+
model: type[Base],
25+
days_threshold: int = 30
26+
) -> list[str]:
27+
if not issubclass(model, SoftDeleteMixin):
28+
raise ValueError(f'{model.__name__} does not support soft delete')
29+
30+
cutoff_date = utcnow() - timedelta(days=days_threshold)
31+
32+
# Combine the select and delete operations
33+
stmt = (
34+
delete(model)
35+
.where(
36+
model.updated <= cutoff_date, # type:ignore[attr-defined]
37+
model.deleted.is_(True)
38+
)
39+
.returning(model.id) # type:ignore[attr-defined]
40+
)
41+
result = session.execute(stmt)
42+
deleted_ids = result.scalars().all()
43+
session.flush()
44+
return deleted_ids
45+
46+
47+
@click.command()
48+
@click.argument('config_uri')
49+
def hard_delete(
50+
config_uri: str,
51+
) -> None:
52+
env = bootstrap(config_uri)
53+
settings = get_appsettings(config_uri)
54+
engine = get_engine(settings)
55+
Base.metadata.create_all(engine)
56+
57+
with env['request'].tm:
58+
session = env['request'].dbsession
59+
deleted_consultation_ids = delete_old_records(session, Consultation)
60+
for id in deleted_consultation_ids:
61+
print(f"Deleted Consultation with ID: {id}")
62+
63+
64+
if __name__ == '__main__':
65+
hard_delete()
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import pytest
2+
from datetime import timedelta
3+
from sedate import utcnow
4+
from sqlalchemy import select, Column, Integer
5+
6+
from privatim.cli.apply_data_retention_policy import delete_old_records
7+
from privatim.models import Consultation, SearchableFile
8+
from privatim.orm import Base
9+
10+
11+
def create_consultation(
12+
session, user, title, days_old, is_deleted=False, with_file=False
13+
):
14+
"""Helper function to create a consultation"""
15+
16+
consultation = Consultation(
17+
title=title,
18+
creator=user,
19+
description='Test description',
20+
status='Created',
21+
)
22+
23+
if with_file:
24+
file = SearchableFile(
25+
filename='test.txt',
26+
content=b'Test content',
27+
)
28+
session.add(file)
29+
consultation.files.append(file)
30+
31+
consultation.updated = utcnow() - timedelta(days=days_old)
32+
consultation.deleted = is_deleted
33+
session.add(consultation)
34+
session.flush()
35+
return consultation
36+
37+
38+
def test_delete_old_records_consultations(session, user):
39+
# Create various consultations
40+
create_consultation(session, user, 'Recent Active', 10)
41+
create_consultation(session, user, 'Old Active', 40)
42+
old_deleted = create_consultation(
43+
session, user, 'Old Deleted', 40, is_deleted=True
44+
)
45+
old_deleted_with_file = create_consultation(
46+
session,
47+
user,
48+
'Old Deleted with File',
49+
40,
50+
is_deleted=True,
51+
with_file=True,
52+
)
53+
create_consultation(session, user, 'Recent Deleted', 10, is_deleted=True)
54+
55+
session.flush()
56+
57+
# Run the delete_old_records function
58+
deleted_ids = delete_old_records(session, Consultation)
59+
60+
# Check that the correct consultations were deleted
61+
assert len(deleted_ids) == 2
62+
assert old_deleted.id in deleted_ids
63+
assert old_deleted_with_file.id in deleted_ids
64+
65+
with session.no_soft_delete_filter():
66+
remaining_consultations = session.scalars(select(Consultation)).all()
67+
remaining_ids = [c.id for c in remaining_consultations]
68+
69+
assert len(remaining_consultations) == 3
70+
assert old_deleted.id not in remaining_ids
71+
assert old_deleted_with_file.id not in remaining_ids
72+
73+
# Check that associated files were also deleted
74+
remaining_files = session.scalars(select(SearchableFile)).all()
75+
assert len(remaining_files) == 0
76+
77+
78+
def test_delete_old_records_no_deletions(session, user):
79+
# Create only recent or active consultations
80+
create_consultation(session, user, 'Recent Active', 10)
81+
create_consultation(session, user, 'Recent Deleted', 10, is_deleted=True)
82+
83+
session.flush()
84+
85+
# Run the delete_old_records function
86+
deleted_ids = delete_old_records(session, Consultation)
87+
88+
# Check that no consultations were deleted
89+
assert len(deleted_ids) == 0
90+
91+
with session.no_soft_delete_filter():
92+
remaining_consultations = session.scalars(select(Consultation)).all()
93+
assert len(remaining_consultations) == 2
94+
95+
96+
def test_delete_old_records_invalid_model(session):
97+
class InvalidModel(Base):
98+
__tablename__ = 'invalid_model'
99+
id = Column(Integer, primary_key=True)
100+
101+
with pytest.raises(ValueError, match="does not support soft delete"):
102+
delete_old_records(session, InvalidModel)

0 commit comments

Comments
 (0)